From b0bf4cc2bb9c2afa4849b0ca9fa829174e0c0610 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 7 Jul 2026 14:41:52 +0800 Subject: [PATCH 1/4] feat: item schema, page-stamped equipment catalog, inventory + armor layer Rules layer: schema/items.ts (weapon/armor/gear discriminated union; M.D.C. armor rejects A.R., S.D.C. armor requires A.R. + pool), 34-item catalog transcribed from RUE pp.113/116/267-270/326-329, engine catalog with per-suit M.D.C. rolls (LLW concealed 2D6+32, p.113) and the p.287 absorb rule (armor takes the whole hit, floors at 0, never spills onto the body). Character: items[] with per-instance worn/rolledMdc state; current.armor is the worn suit's remaining pool (living-vitals pattern). deriveSheet resolves items, enforces armor-only worn/rolledMdc, one worn suit, roll-in-printed- range, and pool-below-max; surfaces equipment + worn armor on the sheet. Backend: addItem (rolls dice-capacity suits at acquisition, one transaction), removeItem (clears the pool with the worn suit), equipArmor (exclusive worn, fresh pool on change), applyDamage toArmor flag (strike-vs-A.R. adjudication stays with the GM until the combat resolver, like elapsed time in #41). PAGE_MAP.md gains the equipment chapter index. --- docs/rules/PAGE_MAP.md | 17 + packages/backend/convex/characters.ts | 128 ++++++- packages/backend/convex/schema.ts | 13 + packages/backend/tests/characters.test.ts | 119 +++++++ packages/rules/src/content/items/items.json | 362 ++++++++++++++++++++ packages/rules/src/engine/character.ts | 79 +++++ packages/rules/src/engine/items.ts | 66 ++++ packages/rules/src/index.ts | 2 + packages/rules/src/schema/character.ts | 25 ++ packages/rules/src/schema/items.ts | 108 ++++++ packages/rules/tests/character.test.ts | 84 +++++ packages/rules/tests/items.test.ts | 181 ++++++++++ 12 files changed, 1178 insertions(+), 6 deletions(-) create mode 100644 packages/rules/src/content/items/items.json create mode 100644 packages/rules/src/engine/items.ts create mode 100644 packages/rules/src/schema/items.ts create mode 100644 packages/rules/tests/items.test.ts diff --git a/docs/rules/PAGE_MAP.md b/docs/rules/PAGE_MAP.md index 603387f..999b4e4 100644 --- a/docs/rules/PAGE_MAP.md +++ b/docs/rules/PAGE_MAP.md @@ -53,6 +53,23 @@ vision extraction (`scratchpad/extract_pages.py` renders a page to PNG for readi | Ranged Combat (Modern WP, Dodging, Missiles) | 360–365 | 362–367 | | Psychic Combat / Horror Factor / Perception Rolls | 366–367 | 368–369 | +## Equipment (printed 256+) + +| Section | Printed pp. | PDF idx | +| ---------------------------------------------------------------- | ----------- | ------- | +| Missile Stats & Prices | 256 | 258 | +| Coalition Combat Weapons (CS ammo, Vibro-Blades, explosives) | 257–260 | 259–262 | +| Dead Boy Body Armor / Dog Pack Light Riot Armor | 261 | 263 | +| Common Gear (basic, communications, medical, optics, sensors) | 261–265 | 263–267 | +| Common Vehicles | 266 | 268 | +| Mega-Damage Capacity Body Armor (Gladiator, Crusader, Juicer) | 267 | 269 | +| — More suits (Urban Warrior, Plastic-Man, Huntsman, Bushman) | 268 | 270 | +| Weapons (E-Clips, Wilk's 320; 447/NG pistols 269; rifles 270) | 268–270 | 270–272 | +| Power Armor / Robot Vehicles | 271–273 | 273–275 | +| S.D.C. Armor & A.R. rules (under Step 2: Damage Ratings) | 287 | 289 | +| Ancient W.P. weapon damage (axe 326, knife 327) | 326–327 | 328–329 | +| Modern W.P. weapon damage (handguns 328, SMG/shotgun/rifle 329) | 328–329 | 330–331 | + ## Magic (printed 185+) — needed for Ley Line Walker | Section | Printed pp. | PDF idx | diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index 5f721c6..d8b3705 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -1,12 +1,17 @@ import { applyDamage as damagePools, + armorMaxPool, + armorNeedsRoll, characterSchema, comaDeathFloor, + damageArmor, deriveSheet, + getItem, getOcc, getSpell, leyLineDraw as leyLineDrawAmount, restRecovery, + rollArmorMdc, rollHitPoints, rollPhysicalSdc, rollPpe, @@ -303,16 +308,127 @@ export const castSpell = mutation({ }, }); +/** `current` without the armor pool (dropped when nothing else remains). */ +function withoutArmorPool(current: Character["current"]): Character["current"] { + if (current === undefined) return undefined; + const { armor: _armor, ...rest } = current; + return Object.keys(rest).length > 0 ? rest : undefined; +} + +/** + * Add an item to the inventory. Dice-capacity armor (the Ley Line Walker's + * concealed suit prints "2D6+32 M.D.C. main body", RUE p.113) rolls its + * per-suit maximum HERE — a mutation, not an action, for the same reason as + * `rollVitals`: mutations get seeded randomness, and rolling + persisting in + * one transaction means a suit's roll can never be observed and then lost. + */ +export const addItem = mutation({ + args: { id: v.id("characters"), itemId: v.string() }, + returns: v.object({ index: v.number(), rolledMdc: v.optional(v.number()) }), + handler: async (ctx, { id, itemId }) => { + const character = await loadCharacter(ctx, id); + const item = getItem(itemId); + if (!item) throw new Error(`Unknown item "${itemId}".`); + const entry = + item.kind === "armor" && armorNeedsRoll(item) + ? { itemId, rolledMdc: rollArmorMdc(item, Math.random) } + : { itemId }; + const items = [...character.items, entry]; + validateCharacter({ ...character, items }); + await ctx.db.patch(id, { items }); + return { + index: items.length - 1, + ...("rolledMdc" in entry ? { rolledMdc: entry.rolledMdc } : {}), + }; + }, +}); + /** - * Deal damage to the live pools: S.D.C. absorbs first, the overflow comes off - * Hit Points, which stop at the -(P.E.) coma/death floor (rules-layer - * `applyDamage`, RUE pp.287/347). + * Drop the item at `index`. Removing the worn armor also clears its live + * pool — `current.armor` measures the WORN suit, so it can't outlive it. + */ +export const removeItem = mutation({ + args: { id: v.id("characters"), index: v.number() }, + returns: v.null(), + handler: async (ctx, { id, index }) => { + const character = await loadCharacter(ctx, id); + const entry = Number.isInteger(index) ? character.items[index] : undefined; + if (entry === undefined) throw new Error(`No item at index ${index}.`); + const items = character.items.filter((_, i) => i !== index); + const current = entry.worn === true ? withoutArmorPool(character.current) : character.current; + validateCharacter({ ...character, items, current }); + await ctx.db.patch(id, { items, current }); + return null; + }, +}); + +/** + * Wear the armor at `index` (exclusively — at most one worn suit), or pass + * `null` to unequip. Changing what's worn resets the live pool: a freshly + * equipped suit starts at its maximum (per-suit damage memory across swaps + * is future scope — the pool follows the fresh-pools pattern of vitals). + */ +export const equipArmor = mutation({ + args: { id: v.id("characters"), index: v.union(v.number(), v.null()) }, + returns: v.null(), + handler: async (ctx, { id, index }) => { + const character = await loadCharacter(ctx, id); + if (index !== null) { + const entry = Number.isInteger(index) ? character.items[index] : undefined; + if (entry === undefined) throw new Error(`No item at index ${index}.`); + const item = getItem(entry.itemId); + if (item?.kind !== "armor") { + throw new Error(`Only armor can be worn — "${entry.itemId}" is not armor.`); + } + } + const items = character.items.map((e, i) => { + const { worn: _worn, ...rest } = e; + return i === index ? { ...rest, worn: true } : rest; + }); + const current = withoutArmorPool(character.current); + validateCharacter({ ...character, items, current }); + await ctx.db.patch(id, { items, current }); + return null; + }, +}); + +/** + * Deal damage to the live pools. Body hits (the default): S.D.C. absorbs + * first, the overflow comes off Hit Points, which stop at the -(P.E.) + * coma/death floor (rules-layer `applyDamage`, RUE pp.287/347). + * + * `toArmor` lands the hit on the WORN armor instead: the suit absorbs the + * whole attack ("subtract the damage from the armor's S.D.C.", RUE p.287) and + * nothing spills onto the body — a depleted suit stops protecting *future* + * hits. WHICH hits strike armor (the strike-vs-A.R. threshold roll) is + * combat-resolver scope; until then the flag keeps it GM-adjudicated, the + * same philosophy as elapsed time in rest/treatment. */ export const applyDamage = mutation({ - args: { id: v.id("characters"), amount: v.number() }, - returns: v.object({ sdc: v.number(), hitPoints: v.number() }), - handler: async (ctx, { id, amount }) => { + args: { id: v.id("characters"), amount: v.number(), toArmor: v.optional(v.boolean()) }, + returns: v.object({ + sdc: v.optional(v.number()), + hitPoints: v.optional(v.number()), + armor: v.optional(v.number()), + }), + handler: async (ctx, { id, amount, toArmor }) => { const character = await loadCharacter(ctx, id); + if (toArmor === true) { + const worn = character.items.find((e) => e.worn === true); + if (worn === undefined) throw new Error("No armor is worn — nothing to strike."); + const item = getItem(worn.itemId); + if (item?.kind !== "armor") { + throw new Error(`Worn item "${worn.itemId}" is not armor.`); // unreachable for stored docs + } + const max = armorMaxPool(item, worn.rolledMdc); + if (max === undefined) { + throw new Error("The worn armor's M.D.C. has not been rolled — no pool to deplete."); + } + // `damageArmor` rejects amounts that aren't whole, non-negative counts. + const next = damageArmor(character.current?.armor ?? max, amount); + await patchCurrent(ctx, id, character, { ...character.current, armor: next }); + return { armor: next }; + } const rolled = character.rolled; if (rolled?.hitPoints === undefined || rolled.sdc === undefined) { throw new Error("Roll vitals before applying damage — no pools to deplete."); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 1290065..fcd5356 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -39,6 +39,18 @@ export const characterFields = { }), ), spellIds: v.array(v.string()), + /** Owned items (one entry per physical instance, with per-instance state). + * Optional in storage: characters stored before the inventory existed have + * none; the rules layer defaults it to []. */ + items: v.optional( + v.array( + v.object({ + itemId: v.string(), + worn: v.optional(v.boolean()), + rolledMdc: v.optional(v.number()), + }), + ), + ), rolled: v.optional( v.object({ hitPoints: v.optional(v.number()), @@ -53,6 +65,7 @@ export const characterFields = { hitPoints: v.optional(v.number()), sdc: v.optional(v.number()), ppe: v.optional(v.number()), + armor: v.optional(v.number()), treatmentDays: v.optional(v.number()), }), ), diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 1c87a12..0b58b97 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -468,6 +468,125 @@ describe("living vitals — current vs. max (#38)", () => { expect(result).toEqual({ spent: 5, ppe: { current: 79, max: 84 } }); }); + test("addItem stocks the inventory; dice-capacity armor rolls its suit (#43)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + + expect(await t.mutation(api.characters.addItem, { id, itemId: "canteen" })).toEqual({ + index: 0, + }); + expect( + await t.mutation(api.characters.addItem, { id, itemId: "wilks-320-laser-pistol" }), + ).toEqual({ index: 1 }); + + // The LLW concealed suit prints "2D6+32 M.D.C. main body" (p.113) — the + // per-suit roll happens at acquisition and lands in the same transaction. + const suit = await t.mutation(api.characters.addItem, { id, itemId: "llw-concealed-light" }); + expect(suit.index).toBe(2); + expect(suit.rolledMdc).toBeGreaterThanOrEqual(34); + expect(suit.rolledMdc).toBeLessThanOrEqual(44); + expect((await t.query(api.characters.get, { id }))?.items?.[2]?.rolledMdc).toBe(suit.rolledMdc); + + // A fixed suit (Gladiator: 70, p.267) has nothing to roll. + expect(await t.mutation(api.characters.addItem, { id, itemId: "gladiator" })).toEqual({ + index: 3, + }); + + await expect(t.mutation(api.characters.addItem, { id, itemId: "bfg-9000" })).rejects.toThrow( + /Unknown item/, + ); + }); + + test("equipArmor wears one suit exclusively; the sheet surfaces its pool (#43)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.addItem, { id, itemId: "canteen" }); // 0 + await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 1 + const suit = await t.mutation(api.characters.addItem, { id, itemId: "llw-concealed-light" }); // 2 + + await expect(t.mutation(api.characters.equipArmor, { id, index: 0 })).rejects.toThrow( + /Only armor can be worn/, + ); + await expect(t.mutation(api.characters.equipArmor, { id, index: 9 })).rejects.toThrow( + /No item at index/, + ); + + await t.mutation(api.characters.equipArmor, { id, index: 1 }); + let sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.armor).toMatchObject({ max: 70, current: 70 }); // Gladiator, p.267 + + // Swapping suits moves the worn flag and the pool follows the new suit. + await t.mutation(api.characters.equipArmor, { id, index: 2 }); + sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.armor?.max).toBe(suit.rolledMdc); + expect((await t.query(api.characters.get, { id }))?.items?.[1]?.worn).toBeUndefined(); + + // Unequip: no worn armor, no armor layer on the sheet. + await t.mutation(api.characters.equipArmor, { id, index: null }); + sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.armor).toBeUndefined(); + }); + + test("toArmor damage lands on the worn suit and never spills onto the body (#43)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); + await t.mutation(api.characters.equipArmor, { id, index: 0 }); + + // "Subtract the damage from the armor's S.D.C." (RUE p.287). + expect(await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true })).toEqual( + { armor: 40 }, + ); + // The depleting hit is fully absorbed — the body pools stay untouched + // (only FUTURE attacks reach the body once the suit reads 0). + expect(await t.mutation(api.characters.applyDamage, { id, amount: 99, toArmor: true })).toEqual( + { armor: 0 }, + ); + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.armor).toMatchObject({ max: 70, current: 0 }); + expect(sheet?.vitals.hitPoints.current).toBe(18); + expect(sheet?.vitals.sdc.current).toBe(20); + + await expect( + t.mutation(api.characters.applyDamage, { id, amount: -5, toArmor: true }), + ).rejects.toThrow(/non-negative/); + }); + + test("toArmor without a worn (or rolled) suit is refused (#43)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await expect( + t.mutation(api.characters.applyDamage, { id, amount: 5, toArmor: true }), + ).rejects.toThrow(/No armor is worn/); + + // A worn dice-capacity suit that somehow lost its roll has no pool yet. + await t.run(async (ctx) => { + await ctx.db.patch(id, { items: [{ itemId: "llw-concealed-light", worn: true }] }); + }); + await expect( + t.mutation(api.characters.applyDamage, { id, amount: 5, toArmor: true }), + ).rejects.toThrow(/has not been rolled/); + }); + + test("removeItem drops the instance; removing the worn suit clears its pool (#43)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 0 + await t.mutation(api.characters.addItem, { id, itemId: "canteen" }); // 1 + await t.mutation(api.characters.equipArmor, { id, index: 0 }); + await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true }); + + await expect(t.mutation(api.characters.removeItem, { id, index: 5 })).rejects.toThrow( + /No item at index/, + ); + + await t.mutation(api.characters.removeItem, { id, index: 0 }); + const stored = await t.query(api.characters.get, { id }); + expect(stored?.items).toEqual([{ itemId: "canteen" }]); + // current.armor measured the removed suit — it can't outlive it. + expect(stored?.current?.armor).toBeUndefined(); + }); + test("illegal `current` states are rejected at every write", async () => { const t = convexTest(schema, modules); // Above the maximum on create. diff --git a/packages/rules/src/content/items/items.json b/packages/rules/src/content/items/items.json new file mode 100644 index 0000000..ad2e265 --- /dev/null +++ b/packages/rules/src/content/items/items.json @@ -0,0 +1,362 @@ +{ + "book": "Rifts Ultimate Edition", + "items": [ + { + "kind": "armor", + "id": "llw-concealed-light", + "name": "Ley Line Walker Concealed Armor (Light)", + "mdc": { "mainBody": "2D6+32" }, + "notes": "Light to medium body armor worn under the robes; chest, shoulders, thighs and back of the head are always protected. Made from natural M.D.C. materials interlaced with M.D. ceramic plates, padding and miracle fibers. Very common.", + "page": 113 + }, + { + "kind": "armor", + "id": "llw-concealed-medium", + "name": "Ley Line Walker Concealed Armor (Medium)", + "mdc": { + "mainBody": "3D6+50", + "byLocation": "Arms typically have 11-18 M.D.C., legs have 22-28 M.D.C." + }, + "movementPenalty": "-5% to Prowl, Climb, Swimming and other physical skills", + "notes": "Medium concealed armor worn under the robes. Very common.", + "page": 113 + }, + { + "kind": "armor", + "id": "urban-warrior", + "name": "Urban Warrior Padded Environmental Body Armor", + "mdc": { + "mainBody": "50", + "byLocation": "Helmet: 35, Arms: 16 each, Legs: 30 each" + }, + "environmental": true, + "movementPenalty": "excellent mobility, -5% movement penalty", + "weight": "11 pounds (5 kg)", + "cost": "35,000 credits", + "notes": "A lightweight full body suit with special flexible padding and strategic placement of light metal plates.", + "page": 268 + }, + { + "kind": "armor", + "id": "plastic-man", + "name": "Plastic-Man Full Environmental Body Armor", + "mdc": { + "mainBody": "35", + "byLocation": "Helmet: 30, Arms: 15 each, Legs: 22 each" + }, + "environmental": true, + "movementPenalty": "fair mobility, -10% movement penalty", + "weight": "13 pounds (5.8 kg)", + "cost": "18,000 credits", + "notes": "An inexpensive armor made of lightweight M.D.C. polycarbonate plate.", + "page": 268 + }, + { + "kind": "armor", + "id": "huntsman", + "name": "Huntsman Plate & Padded Armor", + "mdc": { + "mainBody": "45", + "byLocation": "Helmet: 35, Arms: 15 each, Legs: 25 each" + }, + "environmental": false, + "movementPenalty": "fair mobility, -10% movement penalty", + "weight": "16 pounds (7.2 kg)", + "cost": "24,000 credits", + "notes": "Non-environmental. A lightweight body with a heavy plate and padded vest and padded armor with leg and arm plates.", + "page": 268 + }, + { + "kind": "armor", + "id": "bushman", + "name": "Bushman Full Composite Environmental Body Armor", + "mdc": { + "mainBody": "60", + "byLocation": "Helmet: 50, Arms: 30 each, Legs: 55 each" + }, + "environmental": true, + "movementPenalty": "fair mobility, -10% movement penalty", + "weight": "17 pounds (7.6 kg)", + "cost": "32,000 credits", + "notes": "A lightweight padding, Kevlar, and plate composite armor. Also available as the Bushman Trooper: a bit bulkier, main body 90 M.D.C., -15% movement penalty, 46,000 credits.", + "page": 268 + }, + { + "kind": "armor", + "id": "gladiator", + "name": "Gladiator Full Environmental Body Armor", + "mdc": { + "mainBody": "70", + "byLocation": "Helmet: 45, Arms: 25 each, Legs: 45 each" + }, + "environmental": true, + "movementPenalty": "fair mobility, -10% movement penalty", + "weight": "21 pounds (9.5 kg)", + "cost": "38,000 credits", + "notes": "One of the most popular medium-weight full environmental suits on the market; super lightweight fiber armor resembling chain mail with light plates.", + "page": 267 + }, + { + "kind": "armor", + "id": "crusader", + "name": "Crusader Full Environmental Body Armor", + "mdc": { + "mainBody": "95", + "byLocation": "Helmet: 50, Arms: 30 each, Legs: 50 each" + }, + "environmental": true, + "movementPenalty": "fair mobility, -15% movement penalty", + "weight": "24 pounds (10.8 kg)", + "cost": "55,000 credits", + "notes": "Full suit of body armor resembling the knights of the ancient Crusades, with chain mail-like M.D.C. materials and knee length skirt.", + "page": 267 + }, + { + "kind": "weapon", + "id": "survival-knife", + "name": "Survival Knife", + "category": "knife", + "damage": { + "formula": "1D6", + "type": "sdc", + "note": "Typical knife damage; very small knives do 1D4 (W.P. Knife)." + }, + "page": 327 + }, + { + "kind": "weapon", + "id": "hand-axe", + "name": "Hand Axe", + "category": "axe", + "damage": { + "formula": "1D6", + "type": "sdc", + "note": "Small axes and hatchets do 1D6; battle axes do 2D6 or 2D8 depending on size and style (W.P. Axe)." + }, + "page": 326 + }, + { + "kind": "weapon", + "id": "automatic-pistol", + "name": ".45 Automatic Pistol", + "category": "handgun", + "damage": { + "formula": "4D6", + "type": "sdc", + "note": "Heavy/large caliber handguns: 4D6 (.45 automatic) to 6D6 S.D.C. (Magnum revolvers). Double damage for a standard short burst (three rounds fired); only pistols (not revolvers) can fire in bursts." + }, + "range": "140 feet (42.7 m) average", + "payload": "Automatic Pistol: 8-16 rounds", + "page": 328 + }, + { + "kind": "weapon", + "id": "submachine-gun", + "name": "Submachine-Gun", + "category": "submachineGun", + "damage": { + "formula": "4D6", + "type": "sdc", + "note": "4D6 S.D.C. per single round or 1D4*10 S.D.C. per three round burst. Can only fire in bursts." + }, + "range": "500-600 feet (152 to 183 m; an Uzi is the latter range)", + "payload": "Fires pistol rounds", + "page": 329 + }, + { + "kind": "weapon", + "id": "wilks-320-laser-pistol", + "name": "Wilk's 320 Laser Pistol", + "category": "energyPistol", + "damage": { "formula": "1D6", "type": "md" }, + "range": "1000 feet (305 m)", + "payload": "20 shots", + "strikeBonusNote": "+2 bonus to strike on an Aimed shot because of the light weight and superior balance", + "weight": "2 lbs (0.9 kg)", + "cost": "11,000 credits", + "notes": "An excellent laser pistol known for its durability, range, accuracy and light weight; sleek, black plastic and ceramic.", + "page": 268 + }, + { + "kind": "weapon", + "id": "wilks-447-laser-rifle", + "name": "Wilk's 447 Laser Rifle", + "category": "energyRifle", + "damage": { "formula": "3D6", "type": "md" }, + "range": "2000 feet (610 m)", + "payload": "20 shots per standard clip, can not use a Long E-Clip", + "strikeBonusNote": "+1 to strike on an Aimed shot", + "weight": "5 lbs (2.25 kg)", + "cost": "18,000 credits", + "notes": "A rifle version of the handgun; sleek, black plastic and ceramic weapon with all the usual features of a Wilk's product.", + "page": 269 + }, + { + "kind": "weapon", + "id": "ng-57-ion-blaster", + "name": "NG-57 Northern Gun Heavy-Duty Ion Blaster", + "category": "energyPistol", + "damage": { + "formula": "3D6", + "type": "md", + "note": "Two settings, 2D4 or 3D6 M.D." + }, + "range": "500 feet (152 m)", + "payload": "10 shots", + "weight": "5 lbs (2.25 kg)", + "cost": "8,000 credits", + "notes": "A sturdy pistol that packs a wallop, but is comparatively heavy. A variety of scopes can be attached.", + "page": 269 + }, + { + "kind": "weapon", + "id": "ng-33-laser-pistol", + "name": "NG-33 Northern Gun Laser Pistol", + "category": "energyPistol", + "damage": { "formula": "1D6", "type": "md" }, + "range": "800 feet (244 m)", + "payload": "20 shots", + "weight": "4 lbs (1.8 kg)", + "cost": "6,500 credits", + "notes": "Looks like a sleeker ion blaster with a pointed nose.", + "page": 269 + }, + { + "kind": "weapon", + "id": "ng-l5-laser-rifle", + "name": "NG-L5 Northern Gun Laser Rifle", + "category": "energyRifle", + "damage": { "formula": "3D6", "type": "md" }, + "range": "1600 feet (488 m)", + "payload": "10 shots standard clip or 20 shots Long E-Clip", + "weight": "14 lbs (6.3 kg)", + "cost": "16,000 credits", + "notes": "A durable, heavy-duty laser rifle that suffers from the usual problem of weight, but can endure a massive amount of abuse and keep on working.", + "page": 270 + }, + { + "kind": "weapon", + "id": "ng-p7-particle-beam-rifle", + "name": "NG-P7 Northern Gun Particle Beam Rifle", + "category": "energyRifle", + "damage": { "formula": "1D4*10", "type": "md" }, + "range": "1200 feet (365 m)", + "payload": "8 shots", + "weight": "21 lbs (9.45 kg)", + "cost": "22,000 credits", + "notes": "Another heavy-duty weapon that is a bit heavy and awkward, but sturdy and dependable in combat.", + "page": 270 + }, + { + "kind": "weapon", + "id": "l-20-pulse-rifle", + "name": "L-20 Pulse Rifle", + "category": "energyRifle", + "damage": { + "formula": "2D6", + "type": "md", + "note": "2D6 M.D. single shot, or 6D6 multiple pulse burst (three simultaneous shots)." + }, + "range": "1600 feet (488 m)", + "payload": "40 shots short E-Clip or 50 shots Long E-Clip", + "weight": "7 lbs (3 kg)", + "cost": "25,000 credits", + "notes": "A common frontier weapon manufactured by the Black Market and several kingdoms across the land; dependable, lightweight, with multiple laser bursts.", + "page": 270 + }, + { + "kind": "gear", + "id": "robe-or-cape", + "name": "Robe or Cape", + "notes": "Part of the traditional garb of the Ley Line Walker.", + "page": 116 + }, + { + "kind": "gear", + "id": "set-of-clothing", + "name": "Set of Clothing", + "page": 116 + }, + { + "kind": "gear", + "id": "traveling-clothes", + "name": "Set of Traveling Clothes", + "page": 116 + }, + { + "kind": "gear", + "id": "knapsack", + "name": "Knapsack", + "page": 116 + }, + { + "kind": "gear", + "id": "backpack", + "name": "Backpack", + "page": 116 + }, + { + "kind": "gear", + "id": "small-sacks", + "name": "Small Sacks", + "notes": "1D4 small sacks.", + "page": 116 + }, + { + "kind": "gear", + "id": "large-sack", + "name": "Large Sack", + "page": 116 + }, + { + "kind": "gear", + "id": "wooden-stakes-and-mallet", + "name": "Wooden Stakes & Mallet", + "notes": "Six wooden stakes and mallet (for vampires and other practical applications).", + "page": 116 + }, + { + "kind": "gear", + "id": "canteen", + "name": "Canteen", + "page": 116 + }, + { + "kind": "gear", + "id": "binoculars", + "name": "Binoculars", + "page": 116 + }, + { + "kind": "gear", + "id": "tinted-goggles", + "name": "Tinted Goggles or Sunglasses", + "page": 116 + }, + { + "kind": "gear", + "id": "air-filter-and-gas-mask", + "name": "Air Filter & Gas Mask", + "page": 116 + }, + { + "kind": "gear", + "id": "flashlight", + "name": "Flashlight", + "page": 116 + }, + { + "kind": "gear", + "id": "cord-and-grappling-hook", + "name": "Lightweight Cord & Grappling Hook", + "notes": "100 feet (30.5 m) of lightweight cord and grappling hook.", + "page": 116 + }, + { + "kind": "gear", + "id": "pens-and-sketch-pad", + "name": "Pen or Pencils & Note or Sketch Pad", + "page": 116 + } + ] +} diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts index 0f5803c..3661d3f 100644 --- a/packages/rules/src/engine/character.ts +++ b/packages/rules/src/engine/character.ts @@ -1,10 +1,13 @@ import type { Alignment } from "../schema/alignments.ts"; import type { Character, CharacterInput, Narrative } from "../schema/character.ts"; import { characterSchema } from "../schema/character.ts"; +import type { Armor, Item } from "../schema/items.ts"; import type { Occ } from "../schema/occ.ts"; import type { Spell } from "../schema/spells.ts"; import { getAlignment } from "./alignments.ts"; import { deriveAttributeBonuses } from "./attributes.ts"; +import { diceMax, diceMin } from "./dice.ts"; +import { armorMaxPool, armorNeedsRoll, getItem } from "./items.ts"; import { combatProfile, comaDeathFloor, @@ -35,6 +38,23 @@ export interface SheetSave { percent?: boolean; } +/** An owned item resolved against the catalog, with its per-instance state. */ +export interface SheetEquipmentEntry { + item: Item; + worn?: boolean; + rolledMdc?: number; +} + +/** The worn armor's live layer — its own ablative pool, never mixed into the + * body vitals (there is no AC in Palladium; RUE p.287). */ +export interface SheetArmor { + item: Armor; + /** Maximum main-body pool; absent until a dice-capacity suit is rolled. */ + max?: number; + /** Remaining pool (equals max until damaged); present exactly when `max` is. */ + current?: number; +} + export interface CharacterSheet { name: string; occ: { id: string; name: string; category: string }; @@ -65,6 +85,9 @@ export interface CharacterSheet { saves: Record; skills: ResolvedSkill[]; spells: { known: Spell[]; count: number }; + equipment: SheetEquipmentEntry[]; + /** Present when an armor is worn. */ + armor?: SheetArmor; } /** Total O.C.C. save bonus for a given save target at a level (respects level gating). */ @@ -193,6 +216,60 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { return spell; }); + // Resolve owned items, enforcing the kind rules the schema can't see: + // `worn` and `rolledMdc` are armor-only, a per-suit roll must match its + // printed dice, and at most one armor is worn at a time. + let wornArmor: SheetArmor | undefined; + const equipment = character.items.map((entry): SheetEquipmentEntry => { + const item = getItem(entry.itemId); + if (!item) throw new Error(`Unknown item "${entry.itemId}".`); + if (entry.rolledMdc !== undefined) { + if (item.kind !== "armor" || !armorNeedsRoll(item)) { + throw new Error( + `rolledMdc on "${item.id}" — only armor with dice-capacity M.D.C. is rolled per suit.`, + ); + } + const formula = item.mdc!.mainBody; + if (entry.rolledMdc < diceMin(formula) || entry.rolledMdc > diceMax(formula)) { + throw new Error( + `rolledMdc (${entry.rolledMdc}) is outside the printed ${formula} range for "${item.id}".`, + ); + } + } + if (entry.worn === true) { + if (item.kind !== "armor") { + throw new Error(`Only armor can be worn — "${item.id}" is ${item.kind}.`); + } + if (wornArmor !== undefined) { + throw new Error("At most one armor can be worn at a time."); + } + wornArmor = { item, max: armorMaxPool(item, entry.rolledMdc) }; + } + return { + item, + ...(entry.worn === true ? { worn: true } : {}), + ...(entry.rolledMdc !== undefined ? { rolledMdc: entry.rolledMdc } : {}), + }; + }); + + // The armor pool follows the vitals rule: `current` is only meaningful + // against a known maximum, and a write above it is rejected, not clamped. + const currentArmor = character.current?.armor; + if (currentArmor !== undefined) { + if (wornArmor === undefined) { + throw new Error("current.armor requires a worn armor — no pool to measure against."); + } + if (wornArmor.max === undefined) { + throw new Error("current.armor requires the suit's rolled M.D.C. — no maximum to measure against."); + } + if (currentArmor > wornArmor.max) { + throw new Error(`current.armor (${currentArmor}) exceeds the suit's maximum (${wornArmor.max}).`); + } + } + if (wornArmor !== undefined && wornArmor.max !== undefined) { + wornArmor.current = currentArmor ?? wornArmor.max; + } + return { name: character.name, occ: { id: occ.id, name: occ.name, category: occ.category }, @@ -225,5 +302,7 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { saves, skills, spells: { known: knownSpells, count: knownSpells.length }, + equipment, + armor: wornArmor, }; } diff --git a/packages/rules/src/engine/items.ts b/packages/rules/src/engine/items.ts new file mode 100644 index 0000000..4e5c5cd --- /dev/null +++ b/packages/rules/src/engine/items.ts @@ -0,0 +1,66 @@ +import { itemCatalogSchema, type Armor, type Item } from "../schema/items.ts"; +import itemsRaw from "../content/items/items.json" with { type: "json" }; +import { diceMax, diceMin, rollDice, type Rng } from "./dice.ts"; + +/** The item catalog (RUE equipment chapter + W.P. weapon damage), validated at load. */ +export const itemCatalog = itemCatalogSchema.parse(itemsRaw); + +// id index, failing fast on collisions (same approach as the spell/skill catalogs). +const itemById = new Map(); +for (const item of itemCatalog.items) { + if (itemById.has(item.id)) { + throw new Error(`Duplicate item id "${item.id}" in the item catalog.`); + } + itemById.set(item.id, item); +} + +export function getItem(id: string): Item | undefined { + return itemById.get(id); +} + +/** All items of a given kind, in catalog order. */ +export function itemsByKind(kind: K): Extract[] { + return itemCatalog.items.filter((i): i is Extract => i.kind === kind); +} + +/** + * Whether this armor's main-body capacity is printed as a per-suit roll + * (LLW concealed: "2D6+32 M.D.C. main body", p.113) rather than a constant. + */ +export function armorNeedsRoll(armor: Armor): boolean { + if (armor.mdc === undefined) return false; + return diceMin(armor.mdc.mainBody) !== diceMax(armor.mdc.mainBody); +} + +/** Roll a concrete main-body M.D.C. for a dice-capacity suit (throws otherwise). */ +export function rollArmorMdc(armor: Armor, rng: Rng = Math.random): number { + if (!armorNeedsRoll(armor)) { + throw new Error(`${armor.name} has a fixed capacity — nothing to roll.`); + } + // armorNeedsRoll only passes when mdc is present. + return rollDice(armor.mdc!.mainBody, rng); +} + +/** + * The armor's maximum main-body pool: the S.D.C. pool for S.D.C. armor, the + * printed constant for fixed M.D.C. suits, or the recorded per-suit roll for + * dice-capacity suits — `undefined` until that roll exists. + */ +export function armorMaxPool(armor: Armor, rolledMdc?: number): number | undefined { + if (armor.mdc === undefined) return armor.sdc; + if (!armorNeedsRoll(armor)) return diceMin(armor.mdc.mainBody); + return rolledMdc; +} + +/** + * Damage absorbed by a worn armor pool. The armor takes the WHOLE hit — "the + * armor absorbs the attack — subtract the damage from the armor's S.D.C." + * (RUE p.287); nothing spills onto the body. A depleted suit (0) no longer + * affords protection: *future* attacks hit the character's body. + */ +export function damageArmor(pool: number, damage: number): number { + if (!Number.isInteger(damage) || damage < 0) { + throw new Error(`Damage must be a non-negative integer, got ${damage}.`); + } + return Math.max(0, pool - damage); +} diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 65ed90a..ecddb90 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -5,6 +5,7 @@ export * from "./schema/combat.ts"; export * from "./schema/recovery.ts"; export * from "./schema/skills.ts"; export * from "./schema/spells.ts"; +export * from "./schema/items.ts"; export * from "./schema/character.ts"; export * from "./engine/alignments.ts"; export * from "./engine/attributes.ts"; @@ -14,6 +15,7 @@ export * from "./engine/combat.ts"; export * from "./engine/recovery.ts"; export * from "./engine/skills.ts"; export * from "./engine/spells.ts"; +export * from "./engine/items.ts"; export * from "./engine/character.ts"; export * from "./engine/rolls.ts"; export * from "./engine/builder.ts"; diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts index 07cff33..6721cd5 100644 --- a/packages/rules/src/schema/character.ts +++ b/packages/rules/src/schema/character.ts @@ -13,6 +13,22 @@ export const characterSkillSchema = z.object({ }); export type CharacterSkill = z.infer; +/** + * An item the character owns — one array entry per physical instance, with + * per-instance state. Only `itemId` is validated here; item-kind rules (worn + * requires armor, rolledMdc requires a dice-capacity suit, at most one worn + * armor) live in `deriveSheet`, where the catalog is available. + */ +export const characterItemSchema = z.object({ + itemId: z.string().min(1), + /** This armor is currently worn (at most one worn armor across the inventory). */ + worn: z.boolean().optional(), + /** Per-suit rolled main-body M.D.C. maximum, for armor whose printed + * capacity is dice (LLW concealed armor: "2D6+32 M.D.C.", RUE p.113). */ + rolledMdc: z.number().int().positive().optional(), +}); +export type CharacterItem = z.infer; + /** The eight rolled attributes (I.Q., M.E., M.A., P.S., P.P., P.E., P.B., Spd). */ export const characterAttributesSchema = z.object({ IQ: z.number().int().positive(), @@ -97,6 +113,9 @@ export const characterSchema = z.object({ message: "A spell cannot be known twice (duplicate spellId).", }) .default([]), + /** Owned items (one entry per physical instance). Item-kind rules are + * checked in `deriveSheet`, which can see the catalog. */ + items: z.array(characterItemSchema).default([]), rolled: z .object({ hitPoints: z.number().int().positive().optional(), @@ -115,6 +134,12 @@ export const characterSchema = z.object({ hitPoints: z.number().int().optional(), sdc: z.number().int().nonnegative().optional(), ppe: z.number().int().nonnegative().optional(), + /** Remaining main-body pool of the WORN armor — armor is its own + * ablative layer (RUE p.287), so it lives beside the body pools. Only + * legal while an armor with a known maximum is worn, never above that + * maximum (enforced in `deriveSheet`). Cleared when armor changes: + * a freshly equipped suit starts at its maximum. */ + armor: z.number().int().nonnegative().optional(), /** Days of battle-injury treatment already applied this course (drives * the professional 2-then-4 ramp, RUE p.354). Lives in `current` on * purpose: a full restore or vitals reroll clears it — fresh pools, diff --git a/packages/rules/src/schema/items.ts b/packages/rules/src/schema/items.ts new file mode 100644 index 0000000..4d4525e --- /dev/null +++ b/packages/rules/src/schema/items.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { diceFormulaSchema } from "./dice.ts"; + +/** Weapon category — the hook future W.P. proficiency wiring keys on. */ +export const weaponCategorySchema = z.enum([ + "knife", + "axe", + "handgun", + "submachineGun", + "energyPistol", + "energyRifle", +]); +export type WeaponCategory = z.infer; + +/** + * Structured weapon damage: the dice the engine must roll, and which damage + * system they belong to — S.D.C. or Mega-Damage (one M.D. point is about one + * hundred S.D.C., RUE p.288; the conversion is combat-resolver scope, not + * stored here). Multi-setting or variable printed damage keeps the full + * sentence in `note` beside the headline dice, like `ppeNote` on spells. + */ +export const weaponDamageSchema = z.object({ + formula: diceFormulaSchema, + type: z.enum(["sdc", "md"]), + note: z.string().optional(), +}); +export type WeaponDamage = z.infer; + +/** Fields every item kind shares. `page` is the printed page number. */ +const itemBase = { + id: z.string().min(1), + name: z.string().min(1), + /** Weight as printed (e.g. "2 lbs (0.9 kg)"). */ + weight: z.string().optional(), + /** Black Market cost as printed (e.g. "11,000 credits"). */ + cost: z.string().optional(), + notes: z.string().optional(), + page: z.number().int().positive(), +}; + +export const weaponSchema = z.object({ + ...itemBase, + kind: z.literal("weapon"), + category: weaponCategorySchema, + damage: weaponDamageSchema, + /** Effective range as printed (e.g. "1000 feet (305 m)"). */ + range: z.string().optional(), + /** Payload as printed (e.g. "20 shots"). */ + payload: z.string().optional(), + /** Printed strike bonus rule (e.g. "+2 to strike on an Aimed shot"). */ + strikeBonusNote: z.string().optional(), +}); +export type Weapon = z.infer; + +/** + * Armor is its own ablative layer — there is no AC in Palladium. S.D.C. armor + * pairs an Armor Rating threshold with an S.D.C. pool (RUE p.287: a strike + * roll above the A.R. penetrates; ties favor the defender). M.D.C. armor is a + * Mega-Damage shell with NO A.R. — the schema rejects the contradiction. Its + * main-body capacity is a dice formula because some suits print a roll per + * suit (Ley Line Walker concealed armor: "2D6+32 M.D.C. main body", p.113) + * while factory suits print a constant ("70"). M.D.C. combat *mechanics* are + * combat-resolver scope; here the shell is just an ablative pool. + */ +export const armorSchema = z + .object({ + ...itemBase, + kind: z.literal("armor"), + /** Armor Rating of S.D.C. armor: strike rolls above it penetrate (RUE p.287). */ + ar: z.number().int().min(1).max(20).optional(), + /** Ablative S.D.C. pool of S.D.C. armor. */ + sdc: z.number().int().positive().optional(), + mdc: z + .object({ + /** Main-body capacity: a constant ("70") or a per-suit roll ("2D6+32"). */ + mainBody: diceFormulaSchema, + /** Other hit locations as printed (helmet/arms/legs — resolver scope). */ + byLocation: z.string().optional(), + }) + .optional(), + /** Full environmental battle armor (RUE p.267). */ + environmental: z.boolean().optional(), + /** Movement/physical-skill penalty as printed (e.g. "-10% movement penalty"). */ + movementPenalty: z.string().optional(), + }) + .refine((a) => a.mdc !== undefined || (a.ar !== undefined && a.sdc !== undefined), { + message: "Armor must be M.D.C. (mdc) or S.D.C. (both ar and sdc).", + }) + .refine((a) => a.mdc === undefined || (a.ar === undefined && a.sdc === undefined), { + message: "M.D.C. armor is a Mega-Damage shell with no A.R./S.D.C. — it cannot declare both.", + }); +export type Armor = z.infer; + +/** Flavor-tier gear: name and printed notes, no mechanics. */ +export const gearSchema = z.object({ + ...itemBase, + kind: z.literal("gear"), +}); +export type Gear = z.infer; + +export const itemSchema = z.discriminatedUnion("kind", [weaponSchema, armorSchema, gearSchema]); +export type Item = z.infer; + +export const itemCatalogSchema = z.object({ + book: z.string().min(1), + items: z.array(itemSchema), +}); +export type ItemCatalog = z.infer; diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts index 6f2a39f..1615843 100644 --- a/packages/rules/tests/character.test.ts +++ b/packages/rules/tests/character.test.ts @@ -172,6 +172,90 @@ describe("deriveSheet — edge cases", () => { ); }); + test("equipment resolves against the catalog; unknown item ids throw", () => { + const sheet = deriveSheet({ + ...leyLineWalker, + items: [{ itemId: "wilks-320-laser-pistol" }, { itemId: "canteen" }], + }); + expect(sheet.equipment.map((e) => e.item.id)).toEqual(["wilks-320-laser-pistol", "canteen"]); + expect(sheet.armor).toBeUndefined(); + expect(() => deriveSheet({ ...leyLineWalker, items: [{ itemId: "bfg-9000" }] })).toThrow( + /Unknown item "bfg-9000"/, + ); + }); + + test("a worn fixed suit surfaces its pool at maximum; current.armor depletes it", () => { + const items = [{ itemId: "gladiator", worn: true }]; // 70 M.D.C. main body, p.267 + expect(deriveSheet({ ...leyLineWalker, items }).armor).toMatchObject({ + max: 70, + current: 70, + }); + const damaged = deriveSheet({ ...leyLineWalker, items, current: { armor: 12 } }); + expect(damaged.armor).toMatchObject({ max: 70, current: 12 }); + }); + + test("a dice-capacity suit (LLW concealed, 2D6+32, p.113) needs its per-suit roll", () => { + const worn = { itemId: "llw-concealed-light", worn: true }; + // Unrolled: the suit is worn but has no pool yet. + expect(deriveSheet({ ...leyLineWalker, items: [worn] }).armor).toMatchObject({ + max: undefined, + }); + // Rolled: the roll is the maximum. + expect( + deriveSheet({ ...leyLineWalker, items: [{ ...worn, rolledMdc: 40 }] }).armor, + ).toMatchObject({ max: 40, current: 40 }); + // A roll outside the printed 2D6+32 range (34-44) is a bug upstream. + expect(() => + deriveSheet({ ...leyLineWalker, items: [{ ...worn, rolledMdc: 45 }] }), + ).toThrow(/outside the printed 2D6\+32 range/); + // rolledMdc on a fixed suit or a non-armor item is meaningless. + expect(() => + deriveSheet({ ...leyLineWalker, items: [{ itemId: "gladiator", rolledMdc: 40 }] }), + ).toThrow(/only armor with dice-capacity/); + expect(() => + deriveSheet({ ...leyLineWalker, items: [{ itemId: "canteen", rolledMdc: 40 }] }), + ).toThrow(/only armor with dice-capacity/); + }); + + test("worn is armor-only and exclusive; illegal current.armor states are rejected", () => { + expect(() => + deriveSheet({ ...leyLineWalker, items: [{ itemId: "canteen", worn: true }] }), + ).toThrow(/Only armor can be worn/); + expect(() => + deriveSheet({ + ...leyLineWalker, + items: [ + { itemId: "gladiator", worn: true }, + { itemId: "crusader", worn: true }, + ], + }), + ).toThrow(/At most one armor/); + // No worn armor -> no pool to measure against. + expect(() => + deriveSheet({ + ...leyLineWalker, + items: [{ itemId: "gladiator" }], + current: { armor: 10 }, + }), + ).toThrow(/requires a worn armor/); + // Worn but unrolled dice suit -> no maximum yet. + expect(() => + deriveSheet({ + ...leyLineWalker, + items: [{ itemId: "llw-concealed-light", worn: true }], + current: { armor: 10 }, + }), + ).toThrow(/requires the suit's rolled M\.D\.C\./); + // Above the suit's maximum. + expect(() => + deriveSheet({ + ...leyLineWalker, + items: [{ itemId: "gladiator", worn: true }], + current: { armor: 71 }, + }), + ).toThrow(/exceeds the suit's maximum/); + }); + test("an O.C.C. flat-value grant overrides the computed percentage (Native Tongue 98%)", () => { const sheet = deriveSheet({ ...leyLineWalker, diff --git a/packages/rules/tests/items.test.ts b/packages/rules/tests/items.test.ts new file mode 100644 index 0000000..a7fe832 --- /dev/null +++ b/packages/rules/tests/items.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + armorMaxPool, + armorNeedsRoll, + armorSchema, + damageArmor, + getItem, + itemCatalog, + itemsByKind, + rollArmorMdc, + type Armor, +} from "../src/index.ts"; + +describe("item catalog (RUE equipment)", () => { + test("pins the per-kind counts (silent drift is caught forever after)", () => { + expect(itemsByKind("armor")).toHaveLength(8); + expect(itemsByKind("weapon")).toHaveLength(11); + expect(itemsByKind("gear")).toHaveLength(15); + expect(itemCatalog.items).toHaveLength(34); + }); + + test("Ley Line Walker concealed armor rolls its M.D.C. per suit (p.113)", () => { + const light = getItem("llw-concealed-light"); + expect(light).toMatchObject({ + kind: "armor", + mdc: { mainBody: "2D6+32" }, + page: 113, + }); + const medium = getItem("llw-concealed-medium"); + expect(medium).toMatchObject({ + kind: "armor", + mdc: { mainBody: "3D6+50" }, + page: 113, + }); + }); + + test("M.D.C. body armor suits carry the printed main-body values (pp.267-268)", () => { + // RUE p.267 + expect(getItem("gladiator")).toMatchObject({ mdc: { mainBody: "70" }, page: 267 }); + expect(getItem("crusader")).toMatchObject({ mdc: { mainBody: "95" }, page: 267 }); + // RUE p.268 + expect(getItem("urban-warrior")).toMatchObject({ mdc: { mainBody: "50" }, page: 268 }); + expect(getItem("plastic-man")).toMatchObject({ mdc: { mainBody: "35" }, page: 268 }); + expect(getItem("huntsman")).toMatchObject({ + mdc: { mainBody: "45" }, + environmental: false, + page: 268, + }); + expect(getItem("bushman")).toMatchObject({ mdc: { mainBody: "60" }, page: 268 }); + }); + + test("energy weapons carry the printed damage dice (pp.268-270)", () => { + expect(getItem("wilks-320-laser-pistol")).toMatchObject({ + damage: { formula: "1D6", type: "md" }, + range: "1000 feet (305 m)", + page: 268, + }); + expect(getItem("wilks-447-laser-rifle")).toMatchObject({ + damage: { formula: "3D6", type: "md" }, + range: "2000 feet (610 m)", + page: 269, + }); + expect(getItem("ng-57-ion-blaster")).toMatchObject({ + damage: { formula: "3D6", type: "md" }, + page: 269, + }); + expect(getItem("ng-33-laser-pistol")).toMatchObject({ + damage: { formula: "1D6", type: "md" }, + page: 269, + }); + expect(getItem("ng-l5-laser-rifle")).toMatchObject({ + damage: { formula: "3D6", type: "md" }, + page: 270, + }); + expect(getItem("ng-p7-particle-beam-rifle")).toMatchObject({ + damage: { formula: "1D4*10", type: "md" }, + page: 270, + }); + expect(getItem("l-20-pulse-rifle")).toMatchObject({ + damage: { formula: "2D6", type: "md" }, + page: 270, + }); + }); + + test("conventional weapons carry the printed W.P. damage (pp.326-329)", () => { + // W.P. Axe: small axes and hatchets do 1D6 (p.326). + expect(getItem("hand-axe")).toMatchObject({ + damage: { formula: "1D6", type: "sdc" }, + page: 326, + }); + // W.P. Knife: typical 1D6 (p.327). + expect(getItem("survival-knife")).toMatchObject({ + damage: { formula: "1D6", type: "sdc" }, + page: 327, + }); + // W.P. Handguns: heavy/large caliber 4D6 (.45 automatic) (p.328). + expect(getItem("automatic-pistol")).toMatchObject({ + damage: { formula: "4D6", type: "sdc" }, + page: 328, + }); + // W.P. Submachine-Gun: 4D6 per single round (p.329). + expect(getItem("submachine-gun")).toMatchObject({ + damage: { formula: "4D6", type: "sdc" }, + page: 329, + }); + }); + + test("the LLW standard-equipment gear is present, page-stamped to the list (p.116)", () => { + for (const id of ["robe-or-cape", "canteen", "wooden-stakes-and-mallet", "flashlight"]) { + expect(getItem(id)).toMatchObject({ kind: "gear", page: 116 }); + } + }); +}); + +describe("armor schema refinements", () => { + const base = { kind: "armor" as const, id: "x", name: "X", page: 1 }; + + test("rejects armor that is neither M.D.C. nor a full S.D.C. (A.R. + pool) shape", () => { + expect(() => armorSchema.parse(base)).toThrow(); + expect(() => armorSchema.parse({ ...base, ar: 12 })).toThrow(); // A.R. without a pool + expect(() => armorSchema.parse({ ...base, sdc: 50 })).toThrow(); // pool without an A.R. + }); + + test("rejects an M.D.C. shell that also declares A.R./S.D.C. (no A.R. on M.D.C. armor)", () => { + expect(() => + armorSchema.parse({ ...base, mdc: { mainBody: "70" }, ar: 12, sdc: 50 }), + ).toThrow(); + }); + + test("accepts the two legal shapes", () => { + expect(armorSchema.parse({ ...base, ar: 14, sdc: 38 }).ar).toBe(14); + expect(armorSchema.parse({ ...base, mdc: { mainBody: "2D6+32" } }).mdc?.mainBody).toBe( + "2D6+32", + ); + }); +}); + +describe("armor pools", () => { + const sdcArmor = armorSchema.parse({ + kind: "armor", + id: "test-sdc", + name: "Test S.D.C. Armor", + ar: 14, + sdc: 38, + page: 287, + }); + + test("fixed suits need no roll; dice suits do", () => { + expect(armorNeedsRoll(getItem("gladiator") as Armor)).toBe(false); + expect(armorNeedsRoll(getItem("llw-concealed-light") as Armor)).toBe(true); + expect(armorNeedsRoll(sdcArmor)).toBe(false); + }); + + test("armorMaxPool: S.D.C. pool, printed constant, or the per-suit roll", () => { + expect(armorMaxPool(sdcArmor)).toBe(38); + expect(armorMaxPool(getItem("gladiator") as Armor)).toBe(70); // p.267 + const llw = getItem("llw-concealed-light") as Armor; + expect(armorMaxPool(llw)).toBeUndefined(); // 2D6+32: no pool until rolled + expect(armorMaxPool(llw, 40)).toBe(40); + }); + + test("rollArmorMdc stays within the printed 2D6+32 bounds (p.113)", () => { + const llw = getItem("llw-concealed-light") as Armor; + for (let i = 0; i < 50; i++) { + const rolled = rollArmorMdc(llw); + expect(rolled).toBeGreaterThanOrEqual(34); + expect(rolled).toBeLessThanOrEqual(44); + } + expect(() => rollArmorMdc(getItem("gladiator") as Armor)).toThrow(/fixed capacity/); + }); + + test("damageArmor absorbs the whole hit, floors at zero, never spills (p.287)", () => { + expect(damageArmor(38, 10)).toBe(28); + // "The armor absorbs the attack" — even the depleting hit is fully + // absorbed; only FUTURE attacks reach the body. + expect(damageArmor(5, 12)).toBe(0); + expect(damageArmor(0, 12)).toBe(0); + expect(() => damageArmor(10, -1)).toThrow(); + expect(() => damageArmor(10, 2.5)).toThrow(); + }); +}); From aee58250c8f4182b2d03176ce0dc3f21f9bfefe0 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 7 Jul 2026 14:51:06 +0800 Subject: [PATCH 2/4] feat: equipment manifest panel, worn-armor bar, weapon damage telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dossier grows the inventory surface: an EQUIPMENT — MANIFEST panel (rows with kind tags; weapons roll their printed dice into the telemetry log client-side, like skills and saves; armor rows wear/doff and discard), a catalog picker to acquire items, and a rust-toned worn-armor bar beside the vitals (amber family — armor is NOT magic) showing the live pool over the per-suit maximum with the printed dice range on hover. The Damage control gains an Armor toggle that routes the hit to the worn suit via the applyDamage toArmor flag; telemetry distinguishes the layers. All per-character control state (armor toggle included) resets on route-id change, per the navigation-state rules. --- apps/web/src/components/sheet-view.tsx | 211 ++++++++++++++++++++++++- apps/web/src/pages/character-sheet.tsx | 94 ++++++++++- docs/rules/PAGE_MAP.md | 26 +-- packages/rules/src/engine/character.ts | 8 +- packages/rules/tests/character.test.ts | 6 +- 5 files changed, 317 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/sheet-view.tsx b/apps/web/src/components/sheet-view.tsx index 80515dd..bce3f2c 100644 --- a/apps/web/src/components/sheet-view.tsx +++ b/apps/web/src/components/sheet-view.tsx @@ -1,13 +1,21 @@ -import type { - Appearance, - CharacterSheet, - ResolvedSkill, - SheetSave, - Spell, - StatValue, +import { + armorMaxPool, + diceAverage, + diceMax, + diceMin, + itemsByKind, + type Appearance, + type CharacterSheet, + type ResolvedSkill, + type SheetArmor, + type SheetEquipmentEntry, + type SheetSave, + type Spell, + type StatValue, + type Weapon, } from "@riftforge/rules"; import { createEffect, createSignal, For, on, onCleanup, Show, type JSX } from "solid-js"; -import { Chip, DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx"; +import { Button, Chip, DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx"; function statRange(stat: StatValue): string { return `${stat.min}–${stat.max} (avg ${stat.average})`; @@ -47,6 +55,12 @@ export interface SheetActions { rollSkill: (skill: ResolvedSkill) => void; castSpell: (spell: Spell) => void; rollCombat: (kind: "strike" | "parry" | "dodge", bonus: number) => void; + /** Weapon damage rolls are client-side telemetry, like skills and saves. */ + rollWeapon: (weapon: Weapon) => void; + /** Inventory writes persist via mutations (`index` into `sheet.equipment`). */ + acquireItem: (itemId: string) => void; + discardItem: (index: number) => void; + equipArmor: (index: number | null) => void; } /** Schematic silhouette — the portrait frame ships before upload does. */ @@ -130,9 +144,28 @@ function StatRow(props: { const barFill = { blood: "bg-gradient-to-r from-[#7a2018] to-blood shadow-[0_0_10px_rgb(226_59_46/0.4)]", amber: "bg-gradient-to-r from-[#7a5a17] to-amber shadow-[0_0_10px_rgb(255_174_61/0.35)]", + // Worn metal: the armor layer speaks rust, amber-family — it is NOT magic. + rust: "bg-gradient-to-r from-[#5c421c] to-rust shadow-[0_0_10px_rgb(168_121_50/0.35)]", ley: "bg-gradient-to-r from-[#14606e] to-ley ppe-pulse", } as const; +/** + * The worn armor's pool as a `StatValue`: the printed dice (or constant) give + * the range, the per-suit roll (or constant) is the maximum, and the live + * remainder rides `current` — so the armor bar behaves exactly like a vital. + */ +function armorStat(armor: SheetArmor): StatValue { + const formula = armor.item.mdc?.mainBody; + const fixed = armor.item.sdc ?? 0; + return { + min: formula ? diceMin(formula) : fixed, + max: formula ? diceMax(formula) : fixed, + average: formula ? diceAverage(formula) : fixed, + rolled: armor.max, + current: armor.current, + }; +} + /** * A vital as a resource bar: fill = what's LEFT of the rolled maximum * (`current / rolled`), full right after a roll, empty until one lands. The @@ -188,6 +221,128 @@ function VitalBar(props: { label: string; stat: StatValue; tone: keyof typeof ba ); } +const KIND_LABEL = { weapon: "WPN", armor: "ARM", gear: "GEAR" } as const; + +/** The manifest value column: a weapon's dice, an armor's pool, a gear dash. */ +function equipmentValue(entry: SheetEquipmentEntry, armor: SheetArmor | undefined): string { + const item = entry.item; + if (item.kind === "weapon") { + return `${item.damage.formula} ${item.damage.type === "md" ? "M.D." : "S.D.C."}`; + } + if (item.kind === "armor") { + const unit = item.mdc ? "M.D.C." : "S.D.C."; + const max = armorMaxPool(item, entry.rolledMdc); + if (max === undefined) return `UNRATED ${unit}`; + // The worn suit shows its live remainder; stowed suits just their rating. + if (entry.worn === true && armor?.current !== undefined) { + return `${armor.current} / ${max} ${unit}`; + } + return `${max} ${unit}`; + } + return "—"; +} + +/** One manifest row: name + kind tag, the value column (weapons roll on + * click), and the equip/discard controls when the page wires actions. */ +function EquipmentRow(props: { + entry: SheetEquipmentEntry; + index: number; + armor: SheetArmor | undefined; + actions?: SheetActions; +}) { + const item = () => props.entry.item; + const value = () => equipmentValue(props.entry, props.armor); + return ( +
+ + {item().name} + + {KIND_LABEL[item().kind]} + + + + Worn + + + + + {value()}} + > + + + + + + + + + +
+ ); +} + +/** Catalog picker + acquire button, grouped by kind (armory requisition). */ +function AcquireControl(props: { onAcquire: (itemId: string) => void }) { + const [selected, setSelected] = createSignal(""); + return ( +
+ + +
+ ); +} + /** * Every `deriveSheet` section in Ley Terminal chrome (DESIGN.md). Shared by * the live sheet page and the builder's review preview so they never drift. @@ -272,6 +427,15 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions
+ + {(armor) => ( + + )} + {(ppe) => } @@ -422,6 +586,37 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions
+ +
+ EQUIPMENT — MANIFEST ({s().equipment.length}) + + WEAPONS: CLICK DICE TO ROLL + +
+
+ + // NOTHING ON MANIFEST +

+ } + > + {(entry, index) => ( + + )} +
+
+ + {(actions) => actions().acquireItem(itemId)} />} + +
+ {(backstory) => ( diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index bc440ca..2764998 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -1,12 +1,15 @@ import { api } from "@riftforge/backend/api"; import type { Id } from "@riftforge/backend/dataModel"; import { + getItem, rollD20, + rollDice, rollSave, rollSkillCheck, type CharacterSheet, type Narrative, type Spell, + type Weapon, } from "@riftforge/rules"; import { useParams } from "@solidjs/router"; import { @@ -145,9 +148,15 @@ export function CharacterSheetPage() { const restMutation = createMutation(convex, api.characters.rest); const leyLineDrawMutation = createMutation(convex, api.characters.leyLineDraw); const treatMutation = createMutation(convex, api.characters.treat); + const addItemMutation = createMutation(convex, api.characters.addItem); + const removeItemMutation = createMutation(convex, api.characters.removeItem); + const equipArmorMutation = createMutation(convex, api.characters.equipArmor); const telemetry = createTelemetry(["// ley-link established", "// awaiting command…"]); const [rollError, setRollError] = createSignal(); const [damageInput, setDamageInput] = createSignal(""); + // Where the hit lands: the body pools, or the worn armor's own layer. + // WHICH hits strike armor (strike-vs-A.R.) is GM adjudication for now. + const [toArmor, setToArmor] = createSignal(false); const [restHours, setRestHours] = createSignal(""); const [atNexus, setAtNexus] = createSignal(false); const [professional, setProfessional] = createSignal(false); @@ -173,6 +182,7 @@ export function CharacterSheetPage() { telemetry.reset(); setRollError(undefined); setDamageInput(""); + setToArmor(false); setRestHours(""); setAtNexus(false); setProfessional(false); @@ -326,17 +336,76 @@ export function CharacterSheetPage() { return; } const damagedFor = id(); + const strikesArmor = toArmor(); try { - const next = await applyDamageMutation({ id: damagedFor, amount }); + const next = await applyDamageMutation({ + id: damagedFor, + amount, + ...(strikesArmor ? { toArmor: true } : {}), + }); if (id() !== damagedFor) return; setDamageInput(""); - telemetry.log(`> DAMAGE :: ${amount} — S.D.C. ${next.sdc} · H.P. ${next.hitPoints}`, "bad"); + telemetry.log( + "armor" in next + ? `> DAMAGE (ARMOR) :: ${amount} — ARMOR ${next.armor}` + : `> DAMAGE :: ${amount} — S.D.C. ${next.sdc} · H.P. ${next.hitPoints}`, + "bad", + ); } catch (error) { if (id() !== damagedFor) return; telemetry.log(`> DAMAGE :: REFUSED (${reason(error)})`, "bad"); } }; + // Inventory writes persist; like every persisting action, a result that + // arrives after the dossier switched characters is dropped. + const acquire = async (itemId: string) => { + const forId = id(); + const name = (getItem(itemId)?.name ?? itemId).toUpperCase(); + try { + const result = await addItemMutation({ id: forId, itemId }); + if (id() !== forId) return; + // Dice-capacity suits (LLW concealed) are rated at acquisition. + telemetry.log( + `> ACQUIRE :: ${name}${result.rolledMdc !== undefined ? ` — SUIT RATED ${result.rolledMdc} M.D.C.` : ""}`, + "good", + ); + } catch (error) { + if (id() !== forId) return; + telemetry.log(`> ACQUIRE :: ${name} — REFUSED (${reason(error)})`, "bad"); + } + }; + + const discard = async (index: number) => { + const forId = id(); + // Name the item before the write moves the manifest under the index. + const name = (sheet()?.equipment[index]?.item.name ?? `ITEM #${index}`).toUpperCase(); + try { + await removeItemMutation({ id: forId, index }); + if (id() !== forId) return; + telemetry.log(`> DISCARD :: ${name}`); + } catch (error) { + if (id() !== forId) return; + telemetry.log(`> DISCARD :: ${name} — REFUSED (${reason(error)})`, "bad"); + } + }; + + const equip = async (index: number | null) => { + const forId = id(); + const name = + index === null + ? undefined + : (sheet()?.equipment[index]?.item.name ?? `ITEM #${index}`).toUpperCase(); + try { + await equipArmorMutation({ id: forId, index }); + if (id() !== forId) return; + telemetry.log(index === null ? "> DOFF :: ARMOR OFFLINE" : `> EQUIP :: ${name} — WORN`); + } catch (error) { + if (id() !== forId) return; + telemetry.log(`> EQUIP :: ${name ?? "—"} — REFUSED (${reason(error)})`, "bad"); + } + }; + const restore = async () => { const restoredFor = id(); try { @@ -371,6 +440,24 @@ export function CharacterSheetPage() { rollCombat: (kind, bonus) => { telemetry.log(`> ${kind.toUpperCase()} :: ${d20Line(rollD20(bonus))}`); }, + // Weapon damage is table telemetry, not a persisted write — the rolled + // points go through the Damage control on whoever they actually hit. + rollWeapon: (weapon: Weapon) => { + const total = rollDice(weapon.damage.formula); + const unit = weapon.damage.type === "md" ? "M.D." : "S.D.C."; + telemetry.log( + `> WEAPON :: ${weapon.name.toUpperCase()} — ${weapon.damage.formula} = ${total} ${unit}`, + ); + }, + acquireItem: (itemId) => { + void acquire(itemId); + }, + discardItem: (index) => { + void discard(index); + }, + equipArmor: (index) => { + void equip(index); + }, }; const roll = async () => { @@ -434,6 +521,9 @@ export function CharacterSheetPage() { + setToArmor((v) => !v)}> + Armor + diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 2764998..0e0c551 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -8,6 +8,7 @@ import { rollSkillCheck, type CharacterSheet, type Narrative, + type SheetEquipmentEntry, type Spell, type Weapon, } from "@riftforge/rules"; @@ -376,12 +377,19 @@ export function CharacterSheetPage() { } }; - const discard = async (index: number) => { + // The instance state the click targeted — the server refuses the write if + // the manifest shifted under an in-flight request (index races). + const expectOf = (entry: SheetEquipmentEntry) => ({ + itemId: entry.item.id, + ...(entry.worn === true ? { worn: true } : {}), + ...(entry.rolledMdc !== undefined ? { rolledMdc: entry.rolledMdc } : {}), + }); + + const discard = async (index: number, entry: SheetEquipmentEntry) => { const forId = id(); - // Name the item before the write moves the manifest under the index. - const name = (sheet()?.equipment[index]?.item.name ?? `ITEM #${index}`).toUpperCase(); + const name = entry.item.name.toUpperCase(); try { - await removeItemMutation({ id: forId, index }); + await removeItemMutation({ id: forId, index, expect: expectOf(entry) }); if (id() !== forId) return; telemetry.log(`> DISCARD :: ${name}`); } catch (error) { @@ -390,14 +398,15 @@ export function CharacterSheetPage() { } }; - const equip = async (index: number | null) => { + const equip = async (index: number | null, entry?: SheetEquipmentEntry) => { const forId = id(); - const name = - index === null - ? undefined - : (sheet()?.equipment[index]?.item.name ?? `ITEM #${index}`).toUpperCase(); + const name = entry?.item.name.toUpperCase(); try { - await equipArmorMutation({ id: forId, index }); + await equipArmorMutation({ + id: forId, + index, + ...(index !== null && entry !== undefined ? { expect: expectOf(entry) } : {}), + }); if (id() !== forId) return; telemetry.log(index === null ? "> DOFF :: ARMOR OFFLINE" : `> EQUIP :: ${name} — WORN`); } catch (error) { @@ -452,11 +461,11 @@ export function CharacterSheetPage() { acquireItem: (itemId) => { void acquire(itemId); }, - discardItem: (index) => { - void discard(index); + discardItem: (index, entry) => { + void discard(index, entry); }, - equipArmor: (index) => { - void equip(index); + equipArmor: (index, entry) => { + void equip(index, entry); }, }; diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index d8b3705..1a7b01d 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -315,6 +315,37 @@ function withoutArmorPool(current: Character["current"]): Character["current"] { return Object.keys(rest).length > 0 ? rest : undefined; } +/** Client's snapshot of the item instance it targeted (index + this state). */ +const expectedItemValidator = v.object({ + itemId: v.string(), + worn: v.optional(v.boolean()), + rolledMdc: v.optional(v.number()), +}); +type ExpectedItem = { itemId: string; worn?: boolean; rolledMdc?: number }; + +/** + * Resolve the item instance an index-based inventory mutation targets. The + * index alone can go stale — the manifest may have changed while the click + * was in flight — so the client also names the instance state it saw, and a + * mismatch is refused instead of landing on whatever now sits at that slot. + */ +function requireItemAt( + character: Character, + index: number, + expect: ExpectedItem, +): Character["items"][number] { + const entry = Number.isInteger(index) ? character.items[index] : undefined; + if (entry === undefined) throw new Error(`No item at index ${index}.`); + if ( + entry.itemId !== expect.itemId || + (entry.worn === true) !== (expect.worn === true) || + entry.rolledMdc !== expect.rolledMdc + ) { + throw new Error("The manifest changed while the request was in flight — try again."); + } + return entry; +} + /** * Add an item to the inventory. Dice-capacity armor (the Ley Line Walker's * concealed suit prints "2D6+32 M.D.C. main body", RUE p.113) rolls its @@ -344,16 +375,16 @@ export const addItem = mutation({ }); /** - * Drop the item at `index`. Removing the worn armor also clears its live - * pool — `current.armor` measures the WORN suit, so it can't outlive it. + * Drop the item at `index` (verified against the client's `expect` snapshot). + * Removing the worn armor also clears its live pool — `current.armor` + * measures the WORN suit, so it can't outlive it. */ export const removeItem = mutation({ - args: { id: v.id("characters"), index: v.number() }, + args: { id: v.id("characters"), index: v.number(), expect: expectedItemValidator }, returns: v.null(), - handler: async (ctx, { id, index }) => { + handler: async (ctx, { id, index, expect }) => { const character = await loadCharacter(ctx, id); - const entry = Number.isInteger(index) ? character.items[index] : undefined; - if (entry === undefined) throw new Error(`No item at index ${index}.`); + const entry = requireItemAt(character, index, expect); const items = character.items.filter((_, i) => i !== index); const current = entry.worn === true ? withoutArmorPool(character.current) : character.current; validateCharacter({ ...character, items, current }); @@ -363,24 +394,33 @@ export const removeItem = mutation({ }); /** - * Wear the armor at `index` (exclusively — at most one worn suit), or pass - * `null` to unequip. Changing what's worn resets the live pool: a freshly - * equipped suit starts at its maximum (per-suit damage memory across swaps - * is future scope — the pool follows the fresh-pools pattern of vitals). + * Wear the armor at `index` (exclusively — at most one worn suit; verified + * against the client's `expect` snapshot), or pass `null` to unequip. + * Changing what's worn resets the live pool: a freshly equipped suit starts + * at its maximum (per-suit damage memory across swaps is future scope — the + * pool follows the fresh-pools pattern of vitals). Asking for the state the + * character is already in is a no-op, so a repeated click can never + * "repair" the worn suit by resetting its pool. */ export const equipArmor = mutation({ - args: { id: v.id("characters"), index: v.union(v.number(), v.null()) }, + args: { + id: v.id("characters"), + index: v.union(v.number(), v.null()), + expect: v.optional(expectedItemValidator), + }, returns: v.null(), - handler: async (ctx, { id, index }) => { + handler: async (ctx, { id, index, expect }) => { const character = await loadCharacter(ctx, id); if (index !== null) { - const entry = Number.isInteger(index) ? character.items[index] : undefined; - if (entry === undefined) throw new Error(`No item at index ${index}.`); + if (expect === undefined) throw new Error("Equipping needs the expected item snapshot."); + const entry = requireItemAt(character, index, expect); const item = getItem(entry.itemId); if (item?.kind !== "armor") { throw new Error(`Only armor can be worn — "${entry.itemId}" is not armor.`); } } + const wornIndex = character.items.findIndex((e) => e.worn === true); + if (index === (wornIndex === -1 ? null : wornIndex)) return null; // already there const items = character.items.map((e, i) => { const { worn: _worn, ...rest } = e; return i === index ? { ...rest, worn: true } : rest; @@ -424,8 +464,15 @@ export const applyDamage = mutation({ if (max === undefined) { throw new Error("The worn armor's M.D.C. has not been rolled — no pool to deplete."); } + const pool = character.current?.armor ?? max; + // A depleted suit "no longer affords protection. Any future attacks + // will hit the character's body." (RUE p.287) — refuse rather than + // silently soak the hit at zero; the GM routes it to the body. + if (pool <= 0) { + throw new Error("The worn armor is depleted — the hit strikes the body (RUE p.287)."); + } // `damageArmor` rejects amounts that aren't whole, non-negative counts. - const next = damageArmor(character.current?.armor ?? max, amount); + const next = damageArmor(pool, amount); await patchCurrent(ctx, id, character, { ...character.current, armor: next }); return { armor: next }; } diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 0b58b97..2bdabf9 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -504,19 +504,27 @@ describe("living vitals — current vs. max (#38)", () => { await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 1 const suit = await t.mutation(api.characters.addItem, { id, itemId: "llw-concealed-light" }); // 2 - await expect(t.mutation(api.characters.equipArmor, { id, index: 0 })).rejects.toThrow( - /Only armor can be worn/, - ); - await expect(t.mutation(api.characters.equipArmor, { id, index: 9 })).rejects.toThrow( - /No item at index/, - ); + await expect( + t.mutation(api.characters.equipArmor, { id, index: 0, expect: { itemId: "canteen" } }), + ).rejects.toThrow(/Only armor can be worn/); + await expect( + t.mutation(api.characters.equipArmor, { id, index: 9, expect: { itemId: "gladiator" } }), + ).rejects.toThrow(/No item at index/); - await t.mutation(api.characters.equipArmor, { id, index: 1 }); + await t.mutation(api.characters.equipArmor, { + id, + index: 1, + expect: { itemId: "gladiator" }, + }); let sheet = await t.query(api.characters.sheet, { id }); expect(sheet?.armor).toMatchObject({ max: 70, current: 70 }); // Gladiator, p.267 // Swapping suits moves the worn flag and the pool follows the new suit. - await t.mutation(api.characters.equipArmor, { id, index: 2 }); + await t.mutation(api.characters.equipArmor, { + id, + index: 2, + expect: { itemId: "llw-concealed-light", rolledMdc: suit.rolledMdc }, + }); sheet = await t.query(api.characters.sheet, { id }); expect(sheet?.armor?.max).toBe(suit.rolledMdc); expect((await t.query(api.characters.get, { id }))?.items?.[1]?.worn).toBeUndefined(); @@ -531,12 +539,19 @@ describe("living vitals — current vs. max (#38)", () => { const t = convexTest(schema, modules); const id = await savedVesper(t); await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); - await t.mutation(api.characters.equipArmor, { id, index: 0 }); + await t.mutation(api.characters.equipArmor, { + id, + index: 0, + expect: { itemId: "gladiator" }, + }); // "Subtract the damage from the armor's S.D.C." (RUE p.287). expect(await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true })).toEqual( { armor: 40 }, ); + await expect( + t.mutation(api.characters.applyDamage, { id, amount: -5, toArmor: true }), + ).rejects.toThrow(/non-negative/); // The depleting hit is fully absorbed — the body pools stay untouched // (only FUTURE attacks reach the body once the suit reads 0). expect(await t.mutation(api.characters.applyDamage, { id, amount: 99, toArmor: true })).toEqual( @@ -547,9 +562,63 @@ describe("living vitals — current vs. max (#38)", () => { expect(sheet?.vitals.hitPoints.current).toBe(18); expect(sheet?.vitals.sdc.current).toBe(20); + // A depleted suit "no longer affords protection" (p.287) — further armor + // hits are refused, not silently soaked at zero. await expect( - t.mutation(api.characters.applyDamage, { id, amount: -5, toArmor: true }), - ).rejects.toThrow(/non-negative/); + t.mutation(api.characters.applyDamage, { id, amount: 5, toArmor: true }), + ).rejects.toThrow(/depleted/); + }); + + test("re-equipping the worn suit is a no-op — a click can't repair the pool (#43)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); + await t.mutation(api.characters.equipArmor, { + id, + index: 0, + expect: { itemId: "gladiator" }, + }); + await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true }); // 40/70 + + await t.mutation(api.characters.equipArmor, { + id, + index: 0, + expect: { itemId: "gladiator", worn: true }, + }); + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.armor).toMatchObject({ max: 70, current: 40 }); // NOT refilled + }); + + test("index-based inventory writes verify the instance snapshot (#43)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.addItem, { id, itemId: "canteen" }); // 0 + await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 1 + + // The manifest shifted under the click (index 0 is not the pistol) — refuse. + await expect( + t.mutation(api.characters.removeItem, { + id, + index: 0, + expect: { itemId: "wilks-320-laser-pistol" }, + }), + ).rejects.toThrow(/manifest changed/); + await expect( + t.mutation(api.characters.equipArmor, { + id, + index: 1, + expect: { itemId: "gladiator", worn: true }, // stale: not worn yet + }), + ).rejects.toThrow(/manifest changed/); + // Equipping without a snapshot is refused outright. + await expect(t.mutation(api.characters.equipArmor, { id, index: 1 })).rejects.toThrow( + /expected item snapshot/, + ); + // Nothing was written by the refused calls. + expect((await t.query(api.characters.get, { id }))?.items).toEqual([ + { itemId: "canteen" }, + { itemId: "gladiator" }, + ]); }); test("toArmor without a worn (or rolled) suit is refused (#43)", async () => { @@ -573,14 +642,22 @@ describe("living vitals — current vs. max (#38)", () => { const id = await savedVesper(t); await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 0 await t.mutation(api.characters.addItem, { id, itemId: "canteen" }); // 1 - await t.mutation(api.characters.equipArmor, { id, index: 0 }); + await t.mutation(api.characters.equipArmor, { + id, + index: 0, + expect: { itemId: "gladiator" }, + }); await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true }); - await expect(t.mutation(api.characters.removeItem, { id, index: 5 })).rejects.toThrow( - /No item at index/, - ); + await expect( + t.mutation(api.characters.removeItem, { id, index: 5, expect: { itemId: "canteen" } }), + ).rejects.toThrow(/No item at index/); - await t.mutation(api.characters.removeItem, { id, index: 0 }); + await t.mutation(api.characters.removeItem, { + id, + index: 0, + expect: { itemId: "gladiator", worn: true }, + }); const stored = await t.query(api.characters.get, { id }); expect(stored?.items).toEqual([{ itemId: "canteen" }]); // current.armor measured the removed suit — it can't outlive it. diff --git a/packages/rules/src/schema/items.ts b/packages/rules/src/schema/items.ts index 4d4525e..8ffc2d9 100644 --- a/packages/rules/src/schema/items.ts +++ b/packages/rules/src/schema/items.ts @@ -87,7 +87,7 @@ export const armorSchema = z message: "Armor must be M.D.C. (mdc) or S.D.C. (both ar and sdc).", }) .refine((a) => a.mdc === undefined || (a.ar === undefined && a.sdc === undefined), { - message: "M.D.C. armor is a Mega-Damage shell with no A.R./S.D.C. — it cannot declare both.", + message: "M.D.C. armor is a Mega-Damage shell — it cannot declare an A.R. or an S.D.C. pool.", }); export type Armor = z.infer; From 11920c7508b9354af415d3886834ece12c9543a6 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 7 Jul 2026 15:26:06 +0800 Subject: [PATCH 4/4] review: doff verifies the worn-suit snapshot too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unequip (index: null) now requires and verifies the client''s snapshot of the suit it saw worn — a doff racing a swap refuses instead of blindly unequipping whatever became worn since. Doff with nothing worn stays a no-op (the desired state is already true, and there is nothing to verify against). Completes the instance-verification contract across all three index-based inventory writes. --- apps/web/src/pages/character-sheet.tsx | 4 +++- packages/backend/convex/characters.ts | 8 +++++++- packages/backend/tests/characters.test.ts | 19 +++++++++++++++++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 0e0c551..16edc3a 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -402,10 +402,12 @@ export function CharacterSheetPage() { const forId = id(); const name = entry?.item.name.toUpperCase(); try { + // Wear and doff both name the instance the click saw: doffing verifies + // the WORN suit, so a racing swap can't be unequipped blind. await equipArmorMutation({ id: forId, index, - ...(index !== null && entry !== undefined ? { expect: expectOf(entry) } : {}), + ...(entry !== undefined ? { expect: expectOf(entry) } : {}), }); if (id() !== forId) return; telemetry.log(index === null ? "> DOFF :: ARMOR OFFLINE" : `> EQUIP :: ${name} — WORN`); diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index 1a7b01d..229d590 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -411,6 +411,7 @@ export const equipArmor = mutation({ returns: v.null(), handler: async (ctx, { id, index, expect }) => { const character = await loadCharacter(ctx, id); + const wornIndex = character.items.findIndex((e) => e.worn === true); if (index !== null) { if (expect === undefined) throw new Error("Equipping needs the expected item snapshot."); const entry = requireItemAt(character, index, expect); @@ -418,8 +419,13 @@ export const equipArmor = mutation({ if (item?.kind !== "armor") { throw new Error(`Only armor can be worn — "${entry.itemId}" is not armor.`); } + } else if (wornIndex !== -1) { + // Doff verifies too: unequip the suit the CLIENT saw worn, not whatever + // a racing write made worn since. (Nothing worn = already unequipped — + // the desired state, a no-op below.) + if (expect === undefined) throw new Error("Unequipping needs the expected item snapshot."); + requireItemAt(character, wornIndex, expect); } - const wornIndex = character.items.findIndex((e) => e.worn === true); if (index === (wornIndex === -1 ? null : wornIndex)) return null; // already there const items = character.items.map((e, i) => { const { worn: _worn, ...rest } = e; diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 2bdabf9..dfe2fe8 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -529,10 +529,25 @@ describe("living vitals — current vs. max (#38)", () => { expect(sheet?.armor?.max).toBe(suit.rolledMdc); expect((await t.query(api.characters.get, { id }))?.items?.[1]?.worn).toBeUndefined(); - // Unequip: no worn armor, no armor layer on the sheet. - await t.mutation(api.characters.equipArmor, { id, index: null }); + // Doff verifies the worn suit's snapshot too — a stale one is refused... + await expect( + t.mutation(api.characters.equipArmor, { + id, + index: null, + expect: { itemId: "gladiator", worn: true }, + }), + ).rejects.toThrow(/manifest changed/); + // ...and the matching snapshot unequips: no worn armor, no armor layer. + await t.mutation(api.characters.equipArmor, { + id, + index: null, + expect: { itemId: "llw-concealed-light", worn: true, rolledMdc: suit.rolledMdc }, + }); sheet = await t.query(api.characters.sheet, { id }); expect(sheet?.armor).toBeUndefined(); + + // Nothing worn: doff is already satisfied — a no-op, snapshot or not. + await t.mutation(api.characters.equipArmor, { id, index: null }); }); test("toArmor damage lands on the worn suit and never spills onto the body (#43)", async () => {