From 53837078dde8a83d9c61dc1680028cce05b0f40a Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 26 Jul 2026 23:23:36 +0800 Subject: [PATCH 1/7] feat(rules): structure O.C.C. starting loadouts --- .../src/content/occ/ley-line-walker.json | 185 +++++++++++++++--- packages/rules/src/engine/loadouts.ts | 65 ++++++ packages/rules/src/engine/occ.ts | 2 + packages/rules/src/index.ts | 2 + packages/rules/src/schema/loadouts.ts | 113 +++++++++++ packages/rules/src/schema/occ.ts | 10 +- packages/rules/tests/loadouts.test.ts | 140 +++++++++++++ packages/rules/tests/occ.test.ts | 64 +++++- 8 files changed, 551 insertions(+), 30 deletions(-) create mode 100644 packages/rules/src/engine/loadouts.ts create mode 100644 packages/rules/src/schema/loadouts.ts create mode 100644 packages/rules/tests/loadouts.test.ts diff --git a/packages/rules/src/content/occ/ley-line-walker.json b/packages/rules/src/content/occ/ley-line-walker.json index 6f70dc2..0c81127 100644 --- a/packages/rules/src/content/occ/ley-line-walker.json +++ b/packages/rules/src/content/occ/ley-line-walker.json @@ -335,29 +335,168 @@ "note": "Double when on a ley line." } ], - "standardEquipment": [ - "Robe or cape", - "set of clothing", - "set of traveling clothes", - "light M.D.C. body armor", - "knapsack", - "backpack", - "1D4 small sacks", - "one large sack", - "six wooden stakes and mallet", - "canteen", - "binoculars", - "tinted goggles or sunglasses", - "air filter and gas mask", - "flashlight", - "100 ft (30.5 m) of lightweight cord and grappling hook", - "pens/pencils and note/sketch pad" - ], - "weapons": "Survival knife, hand axe, automatic pistol or submachine-gun (player's choice), energy pistol or rifle, and several extra clips of ammunition.", - "vehicle": "Usually a Techno-Wizardry device, hover vehicle, motorcycle, jet pack, or Wing Board — inexpensive and easy to replace, since they often leave vehicles behind when phasing.", - "money": { - "credits": "1D4*1000", - "blackMarketItems": "3D4*1000" + "startingLoadout": { + "fixed": [ + { + "key": "robe-or-cape", + "itemId": "robe-or-cape", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "set-of-clothing", + "itemId": "set-of-clothing", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "traveling-clothes", + "itemId": "traveling-clothes", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "light-body-armor", + "itemId": "llw-concealed-light", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "knapsack", + "itemId": "knapsack", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "backpack", + "itemId": "backpack", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "small-sacks", + "itemId": "small-sacks", + "quantity": "1D4", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "large-sack", + "itemId": "large-sack", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "wooden-stakes-and-mallet", + "itemId": "wooden-stakes-and-mallet", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "canteen", + "itemId": "canteen", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "binoculars", + "itemId": "binoculars", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "tinted-goggles", + "itemId": "tinted-goggles", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "air-filter-and-gas-mask", + "itemId": "air-filter-and-gas-mask", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "flashlight", + "itemId": "flashlight", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "cord-and-grappling-hook", + "itemId": "cord-and-grappling-hook", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "pens-and-sketch-pad", + "itemId": "pens-and-sketch-pad", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "survival-knife", + "itemId": "survival-knife", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "hand-axe", + "itemId": "hand-axe", + "quantity": "1", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + } + ], + "choices": [ + { + "key": "conventional-sidearm", + "label": "Automatic pistol or submachine-gun", + "choose": 1, + "quantity": "1", + "pool": { + "kind": "itemIds", + "itemIds": ["automatic-pistol", "submachine-gun"] + }, + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "energy-weapon", + "label": "Energy pistol or rifle", + "choose": 1, + "quantity": "1", + "pool": { + "kind": "weaponCategories", + "categories": ["energyPistol", "energyRifle"] + }, + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + } + ], + "checklist": [ + { + "key": "ammunition", + "text": "Several extra clips of ammunition.", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "vehicle", + "text": "Vehicle is usually a Techno-Wizardry device or hover vehicle, motorcycle, or jet pack; Ley Line Walkers favor inexpensive and easy-to-replace vehicles or items they can carry, such as a Wing Board.", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "credits", + "text": "Starts with 1D4x1000 credits.", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "black-market-items", + "text": "Starts with 3D4x1000 in Black Market items.", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + }, + { + "key": "techno-wizard-items", + "text": "Ley Line Walkers like Techno-Wizard items and can use them well.", + "source": { "book": "Rifts Ultimate Edition", "page": 116 } + } + ] }, "cybernetics": "Starts with none and avoids augmentation because it interferes with magic. Bio-System prosthetics considered only if necessary." } diff --git a/packages/rules/src/engine/loadouts.ts b/packages/rules/src/engine/loadouts.ts new file mode 100644 index 0000000..646d2d5 --- /dev/null +++ b/packages/rules/src/engine/loadouts.ts @@ -0,0 +1,65 @@ +import type { Occ } from "../schema/occ.ts"; +import { MAX_STARTING_LOADOUT_INSTANCES } from "../schema/loadouts.ts"; +import { diceMax } from "./dice.ts"; +import { getItem, itemsByKind } from "./items.ts"; + +function fail(occ: Occ, key: string, message: string): never { + throw new Error(`O.C.C. "${occ.id}" starting loadout "${key}" ${message}`); +} + +/** Validate every catalog reference and feasibility invariant at content-load time. */ +export function validateStartingLoadoutReferences(occ: Occ): void { + const loadout = occ.startingLoadout; + if (!loadout) return; + + const keys = [ + ...loadout.fixed.map((entry) => entry.key), + ...loadout.choices.map((entry) => entry.key), + ...loadout.checklist.map((entry) => entry.key), + ]; + if (new Set(keys).size !== keys.length) { + throw new Error(`O.C.C. "${occ.id}" starting loadout contains a duplicate key.`); + } + + let maximumInstances = 0; + for (const grant of loadout.fixed) { + if (!getItem(grant.itemId)) { + fail(occ, grant.key, `references unknown item "${grant.itemId}".`); + } + maximumInstances += diceMax(grant.quantity); + } + + const weapons = itemsByKind("weapon"); + for (const choice of loadout.choices) { + let optionCount = 0; + if (choice.pool.kind === "itemIds") { + for (const itemId of choice.pool.itemIds) { + if (!getItem(itemId)) { + fail(occ, choice.key, `references unknown item "${itemId}".`); + } + } + optionCount = new Set(choice.pool.itemIds).size; + } else { + const categories = choice.pool.categories; + optionCount = weapons.filter((item) => categories.includes(item.category)).length; + } + + if (optionCount === 0) { + fail(occ, choice.key, "does not resolve to any catalog items."); + } + if (choice.choose > optionCount) { + fail( + occ, + choice.key, + `requires ${choice.choose} selections but resolves to only ${optionCount} distinct options.`, + ); + } + maximumInstances += choice.choose * diceMax(choice.quantity); + } + + if (maximumInstances > MAX_STARTING_LOADOUT_INSTANCES) { + throw new Error( + `O.C.C. "${occ.id}" starting loadout can create ${maximumInstances} item instances; the operational maximum is ${MAX_STARTING_LOADOUT_INSTANCES}.`, + ); + } +} diff --git a/packages/rules/src/engine/occ.ts b/packages/rules/src/engine/occ.ts index 7865b62..32b2bd4 100644 --- a/packages/rules/src/engine/occ.ts +++ b/packages/rules/src/engine/occ.ts @@ -4,6 +4,7 @@ import { diceAverage, diceMax, diceMin, rollDice, type Rng } from "./dice.ts"; import { getHandToHand } from "./combat.ts"; import { getSkill, skillCatalog, skillsForPolicy, validateSkillPolicy } from "./skills.ts"; import { getSpecies } from "./species.ts"; +import { validateStartingLoadoutReferences } from "./loadouts.ts"; /** The Ley Line Walker O.C.C. (RUE pp.113-116), validated at load. */ export const leyLineWalker: Occ = occSchema.parse(leyLineWalkerRaw); @@ -211,6 +212,7 @@ export function validateOccSkillReferences(occ: Occ): void { validateOccSpeciesReferences(leyLineWalker); validateOccSkillReferences(leyLineWalker); +validateStartingLoadoutReferences(leyLineWalker); /** All O.C.C.s currently modeled, keyed by id. */ export const occRegistry: Readonly> = { diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 505e6c9..27162ae 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -8,6 +8,7 @@ export * from "./schema/recovery.ts"; export * from "./schema/skills.ts"; export * from "./schema/spells.ts"; export * from "./schema/items.ts"; +export * from "./schema/loadouts.ts"; export * from "./schema/strike-resolution.ts"; export * from "./schema/combat-exchange.ts"; export * from "./schema/character.ts"; @@ -22,6 +23,7 @@ export * from "./engine/recovery.ts"; export * from "./engine/skills.ts"; export * from "./engine/spells.ts"; export * from "./engine/items.ts"; +export * from "./engine/loadouts.ts"; export * from "./engine/strike-resolution.ts"; export * from "./engine/combat-exchange.ts"; export * from "./engine/character.ts"; diff --git a/packages/rules/src/schema/loadouts.ts b/packages/rules/src/schema/loadouts.ts new file mode 100644 index 0000000..88a9839 --- /dev/null +++ b/packages/rules/src/schema/loadouts.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; +import { diceMax, diceMin } from "../engine/dice.ts"; +import { sourceRefSchema } from "./attributes.ts"; +import { diceFormulaSchema } from "./dice.ts"; +import { weaponCategorySchema } from "./items.ts"; + +export const MAX_STARTING_LOADOUT_INSTANCES = 100; + +const hasUniqueStrings = (values: string[]) => new Set(values).size === values.length; + +const loadoutQuantitySchema = diceFormulaSchema.superRefine((formula, context) => { + const min = diceMin(formula); + const max = diceMax(formula); + if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) { + context.addIssue({ + code: "custom", + message: "A starting-loadout quantity range must contain safe integers.", + }); + } else if (min < 1) { + context.addIssue({ + code: "custom", + message: "A starting-loadout quantity must have a minimum of at least 1.", + }); + } +}); + +const explicitItemPoolSchema = z + .object({ + kind: z.literal("itemIds"), + itemIds: z.array(z.string().min(1)).min(1).refine(hasUniqueStrings, { + message: "A loadout item pool cannot contain duplicate ids.", + }), + }) + .strict(); + +const weaponCategoryPoolSchema = z + .object({ + kind: z.literal("weaponCategories"), + categories: z.array(weaponCategorySchema).min(1).refine(hasUniqueStrings, { + message: "A loadout category pool cannot contain duplicates.", + }), + }) + .strict(); + +export const loadoutChoicePoolSchema = z.discriminatedUnion("kind", [ + explicitItemPoolSchema, + weaponCategoryPoolSchema, +]); +export type LoadoutChoicePool = z.infer; + +export const startingLoadoutGrantSchema = z + .object({ + key: z.string().min(1), + itemId: z.string().min(1), + quantity: loadoutQuantitySchema, + source: sourceRefSchema, + }) + .strict(); +export type StartingLoadoutGrant = z.infer; + +export const startingLoadoutChoiceSchema = z + .object({ + key: z.string().min(1), + label: z.string().min(1), + choose: z.number().int().positive(), + quantity: loadoutQuantitySchema, + pool: loadoutChoicePoolSchema, + source: sourceRefSchema, + }) + .strict(); +export type StartingLoadoutChoice = z.infer; + +export const startingLoadoutChecklistEntrySchema = z + .object({ + key: z.string().min(1), + text: z.string().min(1), + source: sourceRefSchema, + }) + .strict(); +export type StartingLoadoutChecklistEntry = z.infer; + +export const startingLoadoutSchema = z + .object({ + fixed: z.array(startingLoadoutGrantSchema), + choices: z.array(startingLoadoutChoiceSchema), + checklist: z.array(startingLoadoutChecklistEntrySchema), + }) + .strict() + .superRefine((loadout, context) => { + const keys = [ + ...loadout.fixed.map((entry) => entry.key), + ...loadout.choices.map((entry) => entry.key), + ...loadout.checklist.map((entry) => entry.key), + ]; + if (new Set(keys).size !== keys.length) { + context.addIssue({ + code: "custom", + message: "A starting loadout cannot contain a duplicate key.", + }); + } + }); +export type StartingLoadout = z.infer; + +const selectedItemIdsSchema = z.array(z.string().min(1)).refine(hasUniqueStrings, { + message: "A loadout choice cannot contain duplicate item ids.", +}); + +export const startingLoadoutSelectionsSchema = z + .object({ + choices: z.record(z.string().min(1), selectedItemIdsSchema), + }) + .strict(); +export type StartingLoadoutSelections = z.infer; diff --git a/packages/rules/src/schema/occ.ts b/packages/rules/src/schema/occ.ts index dde31a8..09f2225 100644 --- a/packages/rules/src/schema/occ.ts +++ b/packages/rules/src/schema/occ.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { attributeCodeSchema, sourceRefSchema } from "./attributes.ts"; import { diceFormulaSchema } from "./dice.ts"; import { skillCategorySchema, skillPoolPolicySchema } from "./skills.ts"; +import { startingLoadoutSchema } from "./loadouts.ts"; /** A minimum attribute a character must have to take this O.C.C. */ export const attributeRequirementSchema = z.object({ @@ -240,10 +241,11 @@ export const occSchema = z /** Cumulative physical S.D.C. contribution, when printed by the O.C.C. */ sdcBonus: diceFormulaSchema.optional(), bonuses: z.array(occBonusSchema).optional(), - standardEquipment: z.array(z.string()).optional(), - weapons: z.string().optional(), - vehicle: z.string().optional(), - money: moneySchema.optional(), + standardEquipment: z.never().optional(), + weapons: z.never().optional(), + vehicle: z.never().optional(), + money: z.never().optional(), + startingLoadout: startingLoadoutSchema.optional(), cybernetics: z.string().optional(), }) .catchall(z.unknown()); diff --git a/packages/rules/tests/loadouts.test.ts b/packages/rules/tests/loadouts.test.ts new file mode 100644 index 0000000..878f032 --- /dev/null +++ b/packages/rules/tests/loadouts.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + occSchema, + startingLoadoutSchema, + startingLoadoutSelectionsSchema, + validateStartingLoadoutReferences, +} from "../src/index.ts"; + +const source = { book: "Fixture", page: 1 }; + +function loadoutFixture(overrides: Record = {}) { + return { + fixed: [{ key: "canteen", itemId: "canteen", quantity: "1", source }], + choices: [ + { + key: "sidearm", + label: "Sidearm", + choose: 1, + quantity: "1", + pool: { kind: "itemIds", itemIds: ["automatic-pistol"] }, + source, + }, + ], + checklist: [{ key: "credits", text: "Record starting credits.", source }], + ...overrides, + }; +} + +function occFixture(startingLoadout: unknown) { + return occSchema.parse({ + source, + id: "loadout-fixture", + name: "Loadout fixture", + category: "Test", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "any" }, + startingLoadout, + }); +} + +describe("structured starting-loadout schemas", () => { + test("accepts only positive, safe quantity ranges", () => { + for (const quantity of ["not-dice", "0", "-1", "1D4-4", "9007199254740992"]) { + expect(() => + startingLoadoutSchema.parse({ + ...loadoutFixture(), + fixed: [{ key: "canteen", itemId: "canteen", quantity, source }], + }), + ).toThrow(); + } + }); + + test("rejects duplicate keys across every loadout section", () => { + expect(() => + startingLoadoutSchema.parse({ + ...loadoutFixture(), + checklist: [{ key: "canteen", text: "Duplicate key.", source }], + }), + ).toThrow(/duplicate.*key/i); + }); + + test("rejects malformed or ambiguous choice pools", () => { + const invalidPools = [ + { kind: "itemIds", itemIds: ["automatic-pistol", "automatic-pistol"] }, + { kind: "weaponCategories", categories: [] }, + { kind: "weaponCategories", categories: ["energyPistol", "energyPistol"] }, + { + kind: "itemIds", + itemIds: ["automatic-pistol"], + categories: ["energyPistol"], + }, + ]; + + for (const pool of invalidPools) { + expect(() => + startingLoadoutSchema.parse({ + ...loadoutFixture(), + choices: [ + { + key: "sidearm", + label: "Sidearm", + choose: 1, + quantity: "1", + pool, + source, + }, + ], + }), + ).toThrow(); + } + }); + + test("rejects duplicate submitted item ids within one choice", () => { + expect(() => + startingLoadoutSelectionsSchema.parse({ + choices: { sidearm: ["automatic-pistol", "automatic-pistol"] }, + }), + ).toThrow(/duplicate/i); + }); +}); + +describe("starting-loadout content graph validation", () => { + test("rejects unknown catalog references with the O.C.C. id and grant key", () => { + const occ = occFixture({ + ...loadoutFixture(), + fixed: [{ key: "mystery", itemId: "not-real", quantity: "1", source }], + }); + expect(() => validateStartingLoadoutReferences(occ)).toThrow( + /loadout-fixture.*mystery.*not-real/i, + ); + }); + + test("rejects an unsatisfiable exact choice", () => { + const occ = occFixture({ + ...loadoutFixture(), + choices: [ + { + key: "sidearm", + label: "Sidearm", + choose: 2, + quantity: "1", + pool: { kind: "itemIds", itemIds: ["automatic-pistol"] }, + source, + }, + ], + }); + expect(() => validateStartingLoadoutReferences(occ)).toThrow( + /loadout-fixture.*sidearm.*requires 2.*only 1/i, + ); + }); + + test("rejects a worst-case manifest above the operational 100-instance guard", () => { + const occ = occFixture({ + ...loadoutFixture(), + fixed: [{ key: "sacks", itemId: "small-sacks", quantity: "100", source }], + }); + expect(() => validateStartingLoadoutReferences(occ)).toThrow(/loadout-fixture.*101.*100/i); + }); +}); diff --git a/packages/rules/tests/occ.test.ts b/packages/rules/tests/occ.test.ts index 5fb61ca..a8a6a59 100644 --- a/packages/rules/tests/occ.test.ts +++ b/packages/rules/tests/occ.test.ts @@ -5,6 +5,7 @@ import { leyLineWalker, occSchema, rollBasePpe, + validateStartingLoadoutReferences, validateOccSkillReferences, validateOccSpeciesReferences, } from "../src/index.ts"; @@ -105,9 +106,44 @@ describe("Ley Line Walker O.C.C. (RUE pp.113-116)", () => { expect(magicSave?.atLevels).toEqual([3, 6, 9, 11, 14]); }); - test("starting money formulas", () => { - expect(leyLineWalker.money?.credits).toBe("1D4*1000"); - expect(leyLineWalker.money?.blackMarketItems).toBe("3D4*1000"); + test("uses page-stamped structured starting loadout content instead of prose", () => { + const loadout = leyLineWalker.startingLoadout; + expect(loadout?.fixed).toHaveLength(18); + expect(loadout?.fixed.filter((grant) => grant.quantity !== "1")).toEqual([ + expect.objectContaining({ key: "small-sacks", quantity: "1D4" }), + ]); + expect(loadout?.choices).toMatchObject([ + { + key: "conventional-sidearm", + choose: 1, + quantity: "1", + pool: { kind: "itemIds", itemIds: ["automatic-pistol", "submachine-gun"] }, + }, + { + key: "energy-weapon", + choose: 1, + quantity: "1", + pool: { kind: "weaponCategories", categories: ["energyPistol", "energyRifle"] }, + }, + ]); + expect(loadout?.checklist.map((entry) => entry.key)).toEqual([ + "ammunition", + "vehicle", + "credits", + "black-market-items", + "techno-wizard-items", + ]); + for (const entry of [ + ...(loadout?.fixed ?? []), + ...(loadout?.choices ?? []), + ...(loadout?.checklist ?? []), + ]) { + expect(entry.source).toEqual({ book: "Rifts Ultimate Edition", page: 116 }); + } + expect(() => validateStartingLoadoutReferences(leyLineWalker)).not.toThrow(); + for (const retired of ["standardEquipment", "weapons", "vehicle", "money"]) { + expect(retired in leyLineWalker).toBe(false); + } }); test("uses typed skill grants and policies instead of runtime prose parsing", () => { @@ -245,4 +281,26 @@ describe("Ley Line Walker O.C.C. (RUE pp.113-116)", () => { }), ).toThrow(); }); + + test("rejects retired prose loadout fields even though O.C.C. metadata is extensible", () => { + const base = { + source: { book: "Fixture", page: 1 }, + id: "legacy-loadout", + name: "Legacy loadout", + category: "Test", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "any" }, + }; + + expect(() => occSchema.parse({ ...base, standardEquipment: ["Canteen"] })).toThrow(); + expect(() => occSchema.parse({ ...base, weapons: "A weapon" })).toThrow(); + expect(() => occSchema.parse({ ...base, vehicle: "A vehicle" })).toThrow(); + expect(() => + occSchema.parse({ + ...base, + money: { credits: "1D4*1000" }, + }), + ).toThrow(); + }); }); From 261888815a4e20fdfde665f8d04db15357a71200 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 26 Jul 2026 23:27:25 +0800 Subject: [PATCH 2/7] feat(rules): plan and provision starting loadouts --- packages/rules/src/engine/loadouts.ts | 193 ++++++++++++++++++++- packages/rules/tests/character.test.ts | 13 ++ packages/rules/tests/loadouts.test.ts | 230 +++++++++++++++++++++++++ 3 files changed, 434 insertions(+), 2 deletions(-) diff --git a/packages/rules/src/engine/loadouts.ts b/packages/rules/src/engine/loadouts.ts index 646d2d5..a9b7789 100644 --- a/packages/rules/src/engine/loadouts.ts +++ b/packages/rules/src/engine/loadouts.ts @@ -1,7 +1,57 @@ +import type { SourceRef } from "../schema/attributes.ts"; +import type { CharacterItem } from "../schema/character.ts"; +import type { Item } from "../schema/items.ts"; +import type { + StartingLoadoutChecklistEntry, + StartingLoadoutSelections, +} from "../schema/loadouts.ts"; import type { Occ } from "../schema/occ.ts"; import { MAX_STARTING_LOADOUT_INSTANCES } from "../schema/loadouts.ts"; -import { diceMax } from "./dice.ts"; -import { getItem, itemsByKind } from "./items.ts"; +import { diceMax, diceMin, rollDice, type Rng } from "./dice.ts"; +import { armorNeedsRoll, getItem, itemsByKind, rollArmorMdc } from "./items.ts"; + +export interface LoadoutQuantityPlan { + formula: string; + min: number; + max: number; +} + +export interface LoadoutArmorCapacityPlan { + formula: string; + min: number; + max: number; +} + +export interface PlannedLoadoutItem { + item: Item; + quantity: LoadoutQuantityPlan; + armorCapacity?: LoadoutArmorCapacityPlan; +} + +export interface StartingLoadoutPlan { + fixed: Array; + choices: Array<{ + key: string; + label: string; + choose: number; + quantity: LoadoutQuantityPlan; + options: Array<{ item: Item; armorCapacity?: LoadoutArmorCapacityPlan }>; + source: SourceRef; + }>; + checklist: StartingLoadoutChecklistEntry[]; +} + +export interface StartingLoadoutResolution { + grants: Array< + PlannedLoadoutItem & { + key: string; + source: SourceRef; + choiceKey?: string; + } + >; + checklist: StartingLoadoutChecklistEntry[]; + errors: string[]; +} function fail(occ: Occ, key: string, message: string): never { throw new Error(`O.C.C. "${occ.id}" starting loadout "${key}" ${message}`); @@ -63,3 +113,142 @@ export function validateStartingLoadoutReferences(occ: Occ): void { ); } } + +function quantityPlan(formula: string): LoadoutQuantityPlan { + return { formula, min: diceMin(formula), max: diceMax(formula) }; +} + +function armorCapacityPlan(item: Item): LoadoutArmorCapacityPlan | undefined { + if (item.kind !== "armor" || !armorNeedsRoll(item)) return undefined; + const formula = item.mdc!.mainBody; + return { formula, min: diceMin(formula), max: diceMax(formula) }; +} + +function requiredItem(occ: Occ, key: string, itemId: string): Item { + const item = getItem(itemId); + if (!item) fail(occ, key, `references unknown item "${itemId}".`); + return item; +} + +/** Resolve a source-owned O.C.C. loadout into a UI-ready, unrolled plan. */ +export function planStartingLoadout(occ: Occ): StartingLoadoutPlan { + validateStartingLoadoutReferences(occ); + const loadout = occ.startingLoadout; + if (!loadout) return { fixed: [], choices: [], checklist: [] }; + + const weapons = itemsByKind("weapon"); + return { + fixed: loadout.fixed.map((grant) => { + const item = requiredItem(occ, grant.key, grant.itemId); + return { + key: grant.key, + item, + quantity: quantityPlan(grant.quantity), + armorCapacity: armorCapacityPlan(item), + source: grant.source, + }; + }), + choices: loadout.choices.map((choice) => { + let options: Item[]; + if (choice.pool.kind === "itemIds") { + options = choice.pool.itemIds.map((itemId) => requiredItem(occ, choice.key, itemId)); + } else { + const categories = choice.pool.categories; + options = weapons.filter((item) => categories.includes(item.category)); + } + return { + key: choice.key, + label: choice.label, + choose: choice.choose, + quantity: quantityPlan(choice.quantity), + options: options.map((item) => ({ + item, + armorCapacity: armorCapacityPlan(item), + })), + source: choice.source, + }; + }), + checklist: loadout.checklist, + }; +} + +/** Validate player-owned choice ids without rolling or silently substituting options. */ +export function resolveStartingLoadout( + occ: Occ, + selections: StartingLoadoutSelections, +): StartingLoadoutResolution { + const plan = planStartingLoadout(occ); + const errors: string[] = []; + const grants: StartingLoadoutResolution["grants"] = [...plan.fixed]; + const knownKeys = new Set(plan.choices.map((choice) => choice.key)); + + for (const choice of plan.choices) { + const selected = selections.choices[choice.key] ?? []; + if (new Set(selected).size !== selected.length) { + errors.push(`Loadout choice "${choice.key}" (${choice.label}) contains a duplicate item id.`); + continue; + } + if (selected.length !== choice.choose) { + errors.push( + `Loadout choice "${choice.key}" (${choice.label}) requires exactly ${choice.choose} selection(s); received ${selected.length}.`, + ); + continue; + } + + const options = new Map(choice.options.map((option) => [option.item.id, option])); + const illegal = selected.filter((itemId) => !options.has(itemId)); + if (illegal.length > 0) { + errors.push( + `Loadout choice "${choice.key}" (${choice.label}) item "${illegal[0]}" is not a legal option.`, + ); + continue; + } + + for (const itemId of selected) { + const option = options.get(itemId)!; + grants.push({ + key: `${choice.key}:${itemId}`, + choiceKey: choice.key, + item: option.item, + quantity: choice.quantity, + armorCapacity: option.armorCapacity, + source: choice.source, + }); + } + } + + for (const key of Object.keys(selections.choices)) { + if (!knownKeys.has(key)) { + errors.push(`Unknown starting-loadout choice key "${key}" for O.C.C. "${occ.id}".`); + } + } + + return { grants, checklist: plan.checklist, errors }; +} + +/** Roll quantities/capacities and expand an authoritative plan into ordinary inventory. */ +export function provisionStartingLoadout( + occ: Occ, + selections: StartingLoadoutSelections, + rng: Rng = Math.random, +): CharacterItem[] { + const resolution = resolveStartingLoadout(occ, selections); + if (resolution.errors.length > 0) { + throw new Error( + `Invalid starting loadout for O.C.C. "${occ.id}": ${resolution.errors.join(" ")}`, + ); + } + + const items: CharacterItem[] = []; + for (const grant of resolution.grants) { + const quantity = rollDice(grant.quantity.formula, rng); + for (let instance = 0; instance < quantity; instance++) { + if (grant.item.kind === "armor" && armorNeedsRoll(grant.item)) { + items.push({ itemId: grant.item.id, rolledMdc: rollArmorMdc(grant.item, rng) }); + } else { + items.push({ itemId: grant.item.id }); + } + } + } + return items; +} diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts index d095d6d..0a83969 100644 --- a/packages/rules/tests/character.test.ts +++ b/packages/rules/tests/character.test.ts @@ -440,6 +440,19 @@ describe("deriveSheet — edge cases", () => { ); }); + test("starting-loadout legality is creation-only for legacy and later inventory states", () => { + const legacy = deriveSheet({ ...leyLineWalker }); + const emptied = deriveSheet({ ...leyLineWalker, items: [] }); + const laterMutation = deriveSheet({ + ...leyLineWalker, + items: [{ itemId: "canteen" }], + }); + + expect(legacy.equipment).toEqual([]); + expect(emptied.equipment).toEqual([]); + expect(laterMutation.equipment.map((entry) => entry.item.id)).toEqual(["canteen"]); + }); + 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({ diff --git a/packages/rules/tests/loadouts.test.ts b/packages/rules/tests/loadouts.test.ts index 878f032..e64af1d 100644 --- a/packages/rules/tests/loadouts.test.ts +++ b/packages/rules/tests/loadouts.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vite-plus/test"; import { + leyLineWalker, occSchema, + planStartingLoadout, + provisionStartingLoadout, + resolveStartingLoadout, startingLoadoutSchema, startingLoadoutSelectionsSchema, validateStartingLoadoutReferences, @@ -138,3 +142,229 @@ describe("starting-loadout content graph validation", () => { expect(() => validateStartingLoadoutReferences(occ)).toThrow(/loadout-fixture.*101.*100/i); }); }); + +const legalSelections = { + choices: { + "conventional-sidearm": ["automatic-pistol"], + "energy-weapon": ["wilks-320-laser-pistol"], + }, +}; + +describe("starting-loadout planner", () => { + test("plans the exact Ley Line Walker grants, ranges, options, and checklist", () => { + const plan = planStartingLoadout(leyLineWalker); + const fixedRange = plan.fixed.reduce( + (range, grant) => ({ + min: range.min + grant.quantity.min, + max: range.max + grant.quantity.max, + }), + { min: 0, max: 0 }, + ); + const completeRange = plan.choices.reduce( + (range, choice) => ({ + min: range.min + choice.choose * choice.quantity.min, + max: range.max + choice.choose * choice.quantity.max, + }), + fixedRange, + ); + + expect(fixedRange).toEqual({ min: 18, max: 21 }); + expect(completeRange).toEqual({ min: 20, max: 23 }); + expect(plan.fixed.find((grant) => grant.key === "small-sacks")?.quantity).toEqual({ + formula: "1D4", + min: 1, + max: 4, + }); + expect(plan.fixed.find((grant) => grant.key === "light-body-armor")?.armorCapacity).toEqual({ + formula: "2D6+32", + min: 34, + max: 44, + }); + expect(plan.choices[0]?.options.map((option) => option.item.id)).toEqual([ + "automatic-pistol", + "submachine-gun", + ]); + expect(plan.choices[1]?.options.map((option) => option.item.id)).toEqual([ + "wilks-320-laser-pistol", + "wilks-447-laser-rifle", + "ng-57-ion-blaster", + "ng-33-laser-pistol", + "ng-l5-laser-rifle", + "ng-p7-particle-beam-rifle", + "l-20-pulse-rifle", + ]); + const weaponPoolIds = plan.choices.flatMap((choice) => + choice.options.map((option) => option.item.id), + ); + for (const nonWeapon of [ + "canteen", + "survival-knife", + "llw-concealed-light", + "ammunition", + "credits", + "vehicle", + ]) { + expect(weaponPoolIds).not.toContain(nonWeapon); + } + expect(plan.checklist).toHaveLength(5); + expect(plan.checklist.every((entry) => entry.source.page === 116)).toBe(true); + }); + + test("an O.C.C. without starting-loadout content plans an empty manifest", () => { + const occ = occFixture(undefined); + expect(planStartingLoadout(occ)).toEqual({ fixed: [], choices: [], checklist: [] }); + }); +}); + +describe("starting-loadout selection resolution", () => { + test("accepts exactly the selected legal items and preserves fixed grants", () => { + const resolution = resolveStartingLoadout(leyLineWalker, legalSelections); + expect(resolution.errors).toEqual([]); + expect(resolution.grants).toHaveLength(20); + expect( + resolution.grants + .filter((grant) => grant.choiceKey) + .map((grant) => [grant.choiceKey, grant.item.id]), + ).toEqual([ + ["conventional-sidearm", "automatic-pistol"], + ["energy-weapon", "wilks-320-laser-pistol"], + ]); + }); + + test("reports every missing and stale choice key", () => { + const missing = resolveStartingLoadout(leyLineWalker, { choices: {} }); + expect(missing.errors).toEqual([ + expect.stringMatching(/conventional-sidearm.*exactly 1/i), + expect.stringMatching(/energy-weapon.*exactly 1/i), + ]); + + const stale = resolveStartingLoadout(leyLineWalker, { + choices: { ...legalSelections.choices, obsolete: ["automatic-pistol"] }, + }); + expect(stale.errors).toEqual([expect.stringMatching(/unknown.*obsolete/i)]); + }); + + test("rejects duplicate, wrong-pool, too-few, and too-many picks without substitution", () => { + const cases = [ + { + selections: { + choices: { + ...legalSelections.choices, + "conventional-sidearm": ["automatic-pistol", "automatic-pistol"], + }, + }, + message: /conventional-sidearm.*duplicate/i, + }, + { + selections: { + choices: { + ...legalSelections.choices, + "conventional-sidearm": ["canteen"], + }, + }, + message: /conventional-sidearm.*canteen.*not.*legal/i, + }, + { + selections: { + choices: { + ...legalSelections.choices, + "energy-weapon": ["automatic-pistol"], + }, + }, + message: /energy-weapon.*automatic-pistol.*not.*legal/i, + }, + { + selections: { + choices: { + ...legalSelections.choices, + "energy-weapon": [], + }, + }, + message: /energy-weapon.*exactly 1.*received 0/i, + }, + { + selections: { + choices: { + ...legalSelections.choices, + "energy-weapon": ["wilks-320-laser-pistol", "wilks-447-laser-rifle"], + }, + }, + message: /energy-weapon.*exactly 1.*received 2/i, + }, + ]; + + for (const { selections, message } of cases) { + const resolution = resolveStartingLoadout(leyLineWalker, selections); + expect(resolution.errors).toEqual([expect.stringMatching(message)]); + expect(resolution.grants.some((grant) => grant.choiceKey === "energy-weapon")).toBe( + selections.choices["energy-weapon"]?.length === 1 && + selections.choices["energy-weapon"][0] === "wilks-320-laser-pistol", + ); + } + }); + + test("rejects non-empty selections when the O.C.C. has no starting loadout", () => { + const occ = occFixture(undefined); + expect( + resolveStartingLoadout(occ, { choices: { obsolete: ["automatic-pistol"] } }).errors, + ).toEqual([expect.stringMatching(/unknown.*obsolete/i)]); + }); +}); + +describe("starting-loadout provisioning", () => { + test("expands minimum quantities and rolls the minimum armor capacity unworn", () => { + const items = provisionStartingLoadout(leyLineWalker, legalSelections, () => 0); + expect(items).toHaveLength(20); + expect(items.filter((entry) => entry.itemId === "small-sacks")).toHaveLength(1); + expect(items.find((entry) => entry.itemId === "llw-concealed-light")).toEqual({ + itemId: "llw-concealed-light", + rolledMdc: 34, + }); + expect(items.every((entry) => entry.worn === undefined)).toBe(true); + }); + + test("expands maximum quantities and rolls the maximum armor capacity", () => { + const items = provisionStartingLoadout(leyLineWalker, legalSelections, () => 0.999); + expect(items).toHaveLength(23); + expect(items.filter((entry) => entry.itemId === "small-sacks")).toHaveLength(4); + expect(items.find((entry) => entry.itemId === "llw-concealed-light")?.rolledMdc).toBe(44); + }); + + test("rolls each dice-capacity suit independently", () => { + const occ = occFixture({ + fixed: [ + { + key: "first-suit", + itemId: "llw-concealed-light", + quantity: "1", + source, + }, + { + key: "second-suit", + itemId: "llw-concealed-light", + quantity: "1", + source, + }, + ], + choices: [], + checklist: [], + }); + const rolls = [0, 0, 0.999, 0.999]; + const items = provisionStartingLoadout(occ, { choices: {} }, () => rolls.shift() ?? 0); + expect(items).toEqual([ + { itemId: "llw-concealed-light", rolledMdc: 34 }, + { itemId: "llw-concealed-light", rolledMdc: 44 }, + ]); + }); + + test("throws instead of provisioning a partial or substituted invalid manifest", () => { + expect(() => + provisionStartingLoadout(leyLineWalker, { + choices: { + ...legalSelections.choices, + "energy-weapon": ["canteen"], + }, + }), + ).toThrow(/energy-weapon.*canteen.*not.*legal/i); + }); +}); From 489fb5c94a98796d14c96dc5737b8126857cfcea Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 26 Jul 2026 23:33:55 +0800 Subject: [PATCH 3/7] feat(backend): provision starting inventory atomically --- packages/backend/convex/characters.ts | 16 +- packages/backend/convex/schema.ts | 5 + packages/backend/tests/characters.test.ts | 204 ++++++++++++++++++-- packages/backend/tests/combat.test.ts | 54 +++--- packages/backend/tests/healing-cast.test.ts | 6 + 5 files changed, 237 insertions(+), 48 deletions(-) diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index 83a1849..56fa0c5 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -13,6 +13,7 @@ import { rollHitPoints, rollPhysicalSdc, rollPpe, + provisionStartingLoadout, rollSpellHealing, treatmentRecovery, type Character, @@ -27,7 +28,7 @@ import { requireLiving, validateCharacter, } from "./character_state"; -import { characterFields } from "./schema"; +import { characterFields, startingLoadoutSelectionsValidator } from "./schema"; /** A stored character document: the fields plus Convex's system columns. */ const characterDoc = v.object({ @@ -43,9 +44,13 @@ const characterInputFields = { spellIds: v.optional(characterFields.spellIds), }; +const { items: _clientAuthoredItems, ...createCharacterInputFields } = characterInputFields; +void _clientAuthoredItems; + const newCharacterInputFields = { - ...characterInputFields, + ...createCharacterInputFields, speciesId: v.string(), + loadoutSelections: startingLoadoutSelectionsValidator, }; function validateAuthoritativeCharacter(input: unknown) { @@ -73,7 +78,12 @@ export const create = mutation({ args: newCharacterInputFields, returns: v.id("characters"), handler: async (ctx, args) => { - return await ctx.db.insert("characters", validateAuthoritativeCharacter(args)); + const { loadoutSelections, ...characterChoices } = args; + const occ = getOcc(characterChoices.occId); + if (!occ) throw new Error(`Unknown O.C.C. "${characterChoices.occId}".`); + const items = provisionStartingLoadout(occ, loadoutSelections, Math.random); + const character = validateAuthoritativeCharacter({ ...characterChoices, items }); + return await ctx.db.insert("characters", character); }, }); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index d1395ed..bb5bbeb 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -36,6 +36,11 @@ export const skillSelectionsValidator = v.object({ hthId: v.optional(v.string()), }); +/** Create-request-only loadout choices. Resolved inventory is stored instead. */ +export const startingLoadoutSelectionsValidator = v.object({ + choices: v.record(v.string(), v.array(v.string())), +}); + /** * Convex validators mirroring `characterSchema` from @riftforge/rules. * The table stores the player's *choices* (a parsed `Character`); derived diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 3df208c..c5d1f7d 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -8,6 +8,13 @@ const modules = { ...import.meta.glob("../convex/_generated/*.js"), }; +const loadoutSelections = { + choices: { + "conventional-sidearm": ["automatic-pistol"], + "energy-weapon": ["wilks-320-laser-pistol"], + }, +}; + /** A pre-#61 flattened document retained for compatibility coverage. */ const legacyVesper = { name: "Vesper", @@ -68,8 +75,12 @@ const vesper = { hthId: "basic", }, spellIds: ["globe-of-daylight", "energy-bolt", "armor-of-ithan"], + loadoutSelections, }; +const { loadoutSelections: _requestOnlyLoadoutSelections, ...vesperCharacter } = vesper; +void _requestOnlyLoadoutSelections; + describe("characters — a saved Ley Line Walker round-trips", () => { test("create requires and stores explicit Human identity", async () => { const t = convexTest(schema, modules); @@ -80,6 +91,7 @@ describe("characters — a saved Ley Line Walker round-trips", () => { }); expect(await t.query(api.characters.get, { id })).not.toHaveProperty("skills"); expect(await t.query(api.characters.get, { id })).not.toHaveProperty("hthType"); + expect(await t.query(api.characters.get, { id })).not.toHaveProperty("loadoutSelections"); expect(await t.query(api.characters.sheet, { id })).toMatchObject({ species: { id: "human", name: "Human" }, }); @@ -88,10 +100,161 @@ describe("characters — a saved Ley Line Walker round-trips", () => { await expect(t.mutation(api.characters.create, missing as typeof vesper)).rejects.toThrow(); }); + test("create requires legal transient loadout selections and rejects client-authored items", async () => { + const t = convexTest(schema, modules); + const { loadoutSelections: _missing, ...withoutSelections } = vesper; + + await expect( + t.mutation(api.characters.create, withoutSelections as typeof vesper), + ).rejects.toThrow(); + await expect( + t.mutation(api.characters.create, { + ...vesper, + loadoutSelections: { + choices: { ...loadoutSelections.choices, obsolete: ["automatic-pistol"] }, + }, + }), + ).rejects.toThrow(/unknown.*obsolete/i); + await expect( + t.mutation(api.characters.create, { + ...vesper, + loadoutSelections: { + choices: { ...loadoutSelections.choices, "energy-weapon": ["automatic-pistol"] }, + }, + }), + ).rejects.toThrow(/energy-weapon.*automatic-pistol.*not.*legal/i); + await expect( + t.mutation(api.characters.create, { + ...vesper, + items: [{ itemId: "gladiator" }], + } as typeof vesper), + ).rejects.toThrow(); + }); + + test("create stores a concrete authoritative manifest with unworn rolled armor", async () => { + const t = convexTest(schema, modules); + const id = await t.mutation(api.characters.create, vesper); + const stored = await t.query(api.characters.get, { id }); + const items = stored?.items ?? []; + const fixedIds = [ + "robe-or-cape", + "set-of-clothing", + "traveling-clothes", + "llw-concealed-light", + "knapsack", + "backpack", + "small-sacks", + "large-sack", + "wooden-stakes-and-mallet", + "canteen", + "binoculars", + "tinted-goggles", + "air-filter-and-gas-mask", + "flashlight", + "cord-and-grappling-hook", + "pens-and-sketch-pad", + "survival-knife", + "hand-axe", + ]; + + for (const itemId of fixedIds) { + expect(items.some((entry) => entry.itemId === itemId)).toBe(true); + } + expect(items.filter((entry) => entry.itemId === "automatic-pistol")).toHaveLength(1); + expect(items.filter((entry) => entry.itemId === "wilks-320-laser-pistol")).toHaveLength(1); + expect(items.filter((entry) => entry.itemId === "small-sacks").length).toBeGreaterThanOrEqual( + 1, + ); + expect(items.filter((entry) => entry.itemId === "small-sacks").length).toBeLessThanOrEqual(4); + expect(items.length).toBeGreaterThanOrEqual(20); + expect(items.length).toBeLessThanOrEqual(23); + const armor = items.find((entry) => entry.itemId === "llw-concealed-light"); + expect(armor?.rolledMdc).toBeGreaterThanOrEqual(34); + expect(armor?.rolledMdc).toBeLessThanOrEqual(44); + expect(armor?.worn).toBeUndefined(); + expect(stored).not.toHaveProperty("loadoutSelections"); + + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.equipment.map((entry: { item: { id: string } }) => entry.item.id)).toEqual( + items.map((entry) => entry.itemId), + ); + }); + + test("selection or later character validation failure leaves no partial character", async () => { + const t = convexTest(schema, modules); + await expect( + t.mutation(api.characters.create, { + ...vesper, + spellIds: [...vesper.spellIds, "fireball-xxl"], + }), + ).rejects.toThrow(/Unknown spell/); + expect(await t.query(api.characters.list, {})).toEqual([]); + + await expect( + t.mutation(api.characters.create, { + ...vesper, + loadoutSelections: { + choices: { ...loadoutSelections.choices, "energy-weapon": ["canteen"] }, + }, + }), + ).rejects.toThrow(/energy-weapon.*canteen.*not.*legal/i); + expect(await t.query(api.characters.list, {})).toEqual([]); + }); + + test("new provisioning does not rewrite a pre-#62 character", async () => { + const t = convexTest(schema, modules); + const legacyId = await t.run((ctx) => ctx.db.insert("characters", legacyVesper)); + const before = await t.query(api.characters.get, { id: legacyId }); + + await t.mutation(api.characters.create, vesper); + + expect(await t.query(api.characters.get, { id: legacyId })).toEqual(before); + expect(before).not.toHaveProperty("items"); + }); + + test("post-create inventory mutation and equipping do not revalidate the starting manifest", async () => { + const t = convexTest(schema, modules); + const id = await t.mutation(api.characters.create, vesper); + const created = await t.query(api.characters.get, { id }); + const robeIndex = created?.items?.findIndex((entry) => entry.itemId === "robe-or-cape") ?? -1; + if (robeIndex < 0) throw new Error("provisioned robe should exist"); + await t.mutation(api.characters.removeItem, { + id, + index: robeIndex, + expect: { itemId: "robe-or-cape" }, + }); + expect(await t.query(api.characters.sheet, { id })).not.toBeNull(); + + const afterRemoval = await t.query(api.characters.get, { id }); + const armorIndex = + afterRemoval?.items?.findIndex((entry) => entry.itemId === "llw-concealed-light") ?? -1; + const armor = afterRemoval?.items?.[armorIndex]; + if (armorIndex < 0 || armor?.rolledMdc === undefined) { + throw new Error("provisioned rolled armor should exist"); + } + await t.mutation(api.characters.equipArmor, { + id, + index: armorIndex, + expect: { itemId: armor.itemId, rolledMdc: armor.rolledMdc }, + }); + expect((await t.query(api.characters.get, { id }))?.items?.[armorIndex]).toMatchObject({ + itemId: "llw-concealed-light", + rolledMdc: armor.rolledMdc, + worn: true, + }); + expect((await t.query(api.characters.sheet, { id }))?.armor).toMatchObject({ + max: armor.rolledMdc, + current: armor.rolledMdc, + }); + }); + test("new/full writes reject flattened or tampered skill metadata", async () => { const t = convexTest(schema, modules); await expect( - t.mutation(api.characters.create, legacyVesper as unknown as typeof vesper), + t.mutation(api.characters.create, { + ...legacyVesper, + loadoutSelections, + } as unknown as typeof vesper), ).rejects.toThrow(/authoritative skill selections/i); await expect( t.mutation(api.characters.create, { @@ -110,7 +273,7 @@ describe("characters — a saved Ley Line Walker round-trips", () => { await expect( t.mutation(api.characters.update, { id, - character: legacyVesper as unknown as typeof vesper, + character: legacyVesper as unknown as typeof vesperCharacter, }), ).rejects.toThrow(/authoritative skill selections/i); }); @@ -134,7 +297,7 @@ describe("characters — a saved Ley Line Walker round-trips", () => { await expect( t.mutation(api.characters.update, { id, - character: { ...vesper, speciesId: "psi-stalker" }, + character: { ...vesperCharacter, speciesId: "psi-stalker" }, }), ).rejects.toThrow("Psi-Stalker is known but not playable."); expect(await t.query(api.characters.get, { id })).toMatchObject({ speciesId: "human" }); @@ -173,7 +336,7 @@ describe("characters — a saved Ley Line Walker round-trips", () => { const t = convexTest(schema, modules); const id = await t.mutation(api.characters.create, vesper); const stored = await t.query(api.characters.get, { id }); - expect(stored).toMatchObject(vesper); + expect(stored).toMatchObject(vesperCharacter); }); test("create → sheet derives the full character sheet", async () => { @@ -200,7 +363,7 @@ describe("characters — a saved Ley Line Walker round-trips", () => { const id = await t.mutation(api.characters.create, vesper); await t.mutation(api.characters.update, { id, - character: { ...vesper, level: 3 }, + character: { ...vesperCharacter, level: 3 }, }); const sheet = await t.query(api.characters.sheet, { id }); if (sheet === null) throw new Error("sheet should exist for a saved character"); @@ -304,7 +467,7 @@ describe("characters — a saved Ley Line Walker round-trips", () => { await t.mutation(api.characters.updateNarrative, { id, narrative: undefined }); const stored = await t.query(api.characters.get, { id }); expect(stored?.narrative).toBeUndefined(); - expect(stored).toMatchObject(vesper); + expect(stored).toMatchObject(vesperCharacter); }); test("updateNarrative enforces rules-layer bounds", async () => { @@ -369,6 +532,17 @@ describe("living vitals — current vs. max (#38)", () => { return id; } + /** A pre-#62 character whose empty inventory keeps index-mutation premises explicit. */ + async function savedLegacyVesper(t: ReturnType) { + const id = await t.run((ctx) => + ctx.db.insert("characters", { + ...legacyVesper, + rolled: { hitPoints: 18, sdc: 20, ppe: 84 }, + }), + ); + return id; + } + async function markDead( t: ReturnType, id: Awaited>, @@ -508,7 +682,7 @@ describe("living vitals — current vs. max (#38)", () => { t.mutation(api.characters.update, { id, character: { - ...vesper, + ...vesperCharacter, name: "Resurrected by replacement", attributes: { ...vesper.attributes, PE: -3 }, }, @@ -611,7 +785,7 @@ describe("living vitals — current vs. max (#38)", () => { test("dead characters may edit narrative and manage inventory without losing the marker", async () => { const t = convexTest(schema, modules); - const id = await savedVesper(t); + const id = await savedLegacyVesper(t); await markDead(t, id); await t.mutation(api.characters.updateNarrative, { @@ -846,7 +1020,7 @@ describe("living vitals — current vs. max (#38)", () => { test("addItem stocks the inventory; dice-capacity armor rolls its suit (#43)", async () => { const t = convexTest(schema, modules); - const id = await savedVesper(t); + const id = await savedLegacyVesper(t); expect(await t.mutation(api.characters.addItem, { id, itemId: "canteen" })).toEqual({ index: 0, @@ -875,7 +1049,7 @@ describe("living vitals — current vs. max (#38)", () => { test("equipArmor wears one suit exclusively; the sheet surfaces its pool (#43)", async () => { const t = convexTest(schema, modules); - const id = await savedVesper(t); + const id = await savedLegacyVesper(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 @@ -928,7 +1102,7 @@ describe("living vitals — current vs. max (#38)", () => { 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); + const id = await savedLegacyVesper(t); await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); await t.mutation(api.characters.equipArmor, { id, @@ -962,7 +1136,7 @@ describe("living vitals — current vs. max (#38)", () => { 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); + const id = await savedLegacyVesper(t); await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); await t.mutation(api.characters.equipArmor, { id, @@ -982,7 +1156,7 @@ describe("living vitals — current vs. max (#38)", () => { test("index-based inventory writes verify the instance snapshot (#43)", async () => { const t = convexTest(schema, modules); - const id = await savedVesper(t); + const id = await savedLegacyVesper(t); await t.mutation(api.characters.addItem, { id, itemId: "canteen" }); // 0 await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 1 @@ -1030,7 +1204,7 @@ describe("living vitals — current vs. max (#38)", () => { test("toArmor without a worn (or rolled) suit is refused (#43)", async () => { const t = convexTest(schema, modules); - const id = await savedVesper(t); + const id = await savedLegacyVesper(t); await expect( t.mutation(api.characters.applyDamage, { id, amount: 5, toArmor: true }), ).rejects.toThrow(/No armor is worn/); @@ -1046,7 +1220,7 @@ describe("living vitals — current vs. max (#38)", () => { test("removeItem drops the instance; removing the worn suit clears its pool (#43)", async () => { const t = convexTest(schema, modules); - const id = await savedVesper(t); + const id = await savedLegacyVesper(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, { diff --git a/packages/backend/tests/combat.test.ts b/packages/backend/tests/combat.test.ts index 2a42127..5fc8967 100644 --- a/packages/backend/tests/combat.test.ts +++ b/packages/backend/tests/combat.test.ts @@ -116,18 +116,20 @@ async function createCharacter( }), ); } - return t.mutation(api.characters.create, { - ...character, - ...authoritativeOverrides, - ...(hthType === undefined - ? {} - : { - skillSelections: { - ...character.skillSelections!, - hthId: hthType, - }, - }), - }); + return t.run((ctx) => + ctx.db.insert("characters", { + ...character, + ...authoritativeOverrides, + ...(hthType === undefined + ? {} + : { + skillSelections: { + ...character.skillSelections!, + hthId: hthType, + }, + }), + }), + ); } async function exchangeCount(t: TestDb): Promise { @@ -564,48 +566,40 @@ describe("combat target selector", () => { const t = convexTest(schema, modules); for (let index = 0; index < 52; index += 1) { - await t.mutation(api.characters.create, { - ...character, + await createCharacter(t, { name: `Filler ${index}`, rolled: { hitPoints: 18, sdc: 20 }, }); } - const sdcId = await t.mutation(api.characters.create, { - ...character, + const sdcId = await createCharacter(t, { name: "S.D.C. target", rolled: ready, }); - const unreadyBodyId = await t.mutation(api.characters.create, { - ...character, + const unreadyBodyId = await createCharacter(t, { name: "Unready body", }); - const intactMdcId = await t.mutation(api.characters.create, { - ...character, + const intactMdcId = await createCharacter(t, { name: "Intact M.D.C.", rolled: ready, items: [{ itemId: "llw-concealed-light", worn: true, rolledMdc: 39 }], }); - const depletedMdcId = await t.mutation(api.characters.create, { - ...character, + const depletedMdcId = await createCharacter(t, { name: "Depleted M.D.C.", rolled: ready, items: [{ itemId: "llw-concealed-light", worn: true, rolledMdc: 39 }], current: { armor: 0 }, }); - const unreadyArmorId = await t.mutation(api.characters.create, { - ...character, + const unreadyArmorId = await createCharacter(t, { name: "Unready armor", items: [{ itemId: "llw-concealed-light", worn: true }], }); - const deadId = await t.mutation(api.characters.create, { - ...character, + const deadId = await createCharacter(t, { name: "Dead", rolled: ready, items: [{ itemId: "llw-concealed-light", worn: true }], current: { hitPoints: -14, sdc: 0, lifeState: "dead" }, }); - const attackerId = await t.mutation(api.characters.create, { - ...character, + const attackerId = await createCharacter(t, { name: "Attacker", rolled: { hitPoints: 18, sdc: 20 }, }); @@ -685,7 +679,7 @@ describe("combat target selector", () => { describe("combat exchange feeds", () => { test("start empty", async () => { const t = convexTest(schema, modules); - const characterId = await t.mutation(api.characters.create, character); + const characterId = await createCharacter(t); expect(await t.query(api.combat.incoming, { defenderId: characterId })).toEqual([]); expect(await t.query(api.combat.outgoing, { attackerId: characterId })).toEqual([]); @@ -694,7 +688,7 @@ describe("combat exchange feeds", () => { test("return at most 20 newest pending or recent exchanges without duplicates", async () => { const t = convexTest(schema, modules); - const characterId = await t.mutation(api.characters.create, character); + const characterId = await createCharacter(t); await t.run(async (ctx) => { for (let index = 0; index < 25; index += 1) { diff --git a/packages/backend/tests/healing-cast.test.ts b/packages/backend/tests/healing-cast.test.ts index 60f1729..830b880 100644 --- a/packages/backend/tests/healing-cast.test.ts +++ b/packages/backend/tests/healing-cast.test.ts @@ -60,6 +60,12 @@ const vesper = { "greater-healing", "restoration", ], + loadoutSelections: { + choices: { + "conventional-sidearm": ["automatic-pistol"], + "energy-weapon": ["wilks-320-laser-pistol"], + }, + }, }; /** A saved caster with pinned vitals (P.P.E. 1000 affords even Restoration's 750). */ From ed9b2a17e40eb799127d7684bd36303512d08383 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 26 Jul 2026 23:43:17 +0800 Subject: [PATCH 4/7] feat(web): collect authoritative loadout choices --- apps/web/src/builder/loadout-summary.tsx | 73 +++++++++++++ apps/web/src/builder/steps/loadout.tsx | 107 +++++++++++++++++++ apps/web/src/builder/steps/occ.tsx | 2 +- apps/web/src/builder/store.ts | 58 ++++++++++- apps/web/src/pages/new-character.tsx | 5 + apps/web/tests/builder.test.ts | 124 +++++++++++++++++++++++ 6 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/builder/loadout-summary.tsx create mode 100644 apps/web/src/builder/steps/loadout.tsx diff --git a/apps/web/src/builder/loadout-summary.tsx b/apps/web/src/builder/loadout-summary.tsx new file mode 100644 index 0000000..3c22d97 --- /dev/null +++ b/apps/web/src/builder/loadout-summary.tsx @@ -0,0 +1,73 @@ +import { For, Show } from "solid-js"; +import { MonoLabel, Panel, SectionTitle } from "../components/ui.tsx"; +import type { BuilderStore } from "./store.ts"; + +const ROLLED_ON_FORGE = "ROLLED ON FORGE"; + +function range(min: number, max: number): string { + return min === max ? `${min}` : `${min}-${max}`; +} + +/** Unrolled grants and checklist guidance shared by the loadout and review steps. */ +export function LoadoutSummary(props: { store: BuilderStore }) { + const grants = () => props.store.loadoutResolution()?.grants ?? []; + const checklist = () => props.store.loadoutPlan()?.checklist ?? []; + + return ( +
+ + AUTHORITATIVE STARTING MANIFEST +
+ + {(grant) => ( +
+
+

{grant.item.name}

+

+ GRANT RUE P.{grant.source.page} // ITEM RUE P.{grant.item.page} +

+
+
+

+ QTY {grant.quantity.formula} + + {" "} + // {range(grant.quantity.min, grant.quantity.max)} + +

+ + {(armor) => ( +

+ M.D.C. {armor().formula} // {range(armor().min, armor().max)} //{" "} + {ROLLED_ON_FORGE} +

+ )} +
+
+
+ )} +
+
+
+ + +
+ CHECKLIST // NOT YET MODELED + GUIDANCE ONLY // NOT INVENTORY +
+
    + + {(entry) => ( +
  • + {entry.text}{" "} + + RUE P.{entry.source.page} + +
  • + )} +
    +
+
+
+ ); +} diff --git a/apps/web/src/builder/steps/loadout.tsx b/apps/web/src/builder/steps/loadout.tsx new file mode 100644 index 0000000..7bd934f --- /dev/null +++ b/apps/web/src/builder/steps/loadout.tsx @@ -0,0 +1,107 @@ +import { For, Show } from "solid-js"; +import { Alert, MonoLabel, Panel } from "../../components/ui.tsx"; +import { LoadoutSummary } from "../loadout-summary.tsx"; +import type { BuilderStore } from "../store.ts"; + +/** Step 9 (RUE p.116): select only player-owned loadout choices. */ +export function LoadoutStep(props: { store: BuilderStore }) { + const selected = (key: string) => props.store.draft.loadoutSelections.choices[key] ?? []; + + const toggle = (key: string, itemId: string, checked: boolean) => { + const current = selected(key); + props.store.setLoadoutChoice( + key, + checked ? [...current, itemId] : current.filter((id) => id !== itemId), + ); + }; + + return ( +
+ +
+ PLAYER SELECTIONS // SERVER PROVISIONING +

+ STARTING LOADOUT +

+

+ Choose the printed options. The server reloads this O.C.C., rolls quantities and armor, + and creates the concrete manifest when you forge. +

+
+ + + {(choice) => { + const valid = () => + selected(choice.key).length === choice.choose && + selected(choice.key).every((id) => + choice.options.some((option) => option.item.id === id), + ); + return ( +
+ + {choice.label} + +

+ CHOOSE EXACTLY {choice.choose} // GRANT RUE P.{choice.source.page} +

+
+ + {(option) => ( + + )} + +
+

+ {valid() + ? "SELECTION VALID" + : `SELECTION REQUIRED — EXACTLY ${choice.choose} OPTION(S)`} +

+
+ ); + }} +
+ + + {(error) => LOADOUT INVALID — {error}} + +
+ + +
+ ); +} diff --git a/apps/web/src/builder/steps/occ.tsx b/apps/web/src/builder/steps/occ.tsx index 17002e5..44c6714 100644 --- a/apps/web/src/builder/steps/occ.tsx +++ b/apps/web/src/builder/steps/occ.tsx @@ -44,7 +44,7 @@ export function OccStep(props: { store: BuilderStore }) { name="occ" class="accent-amber" checked={props.store.draft.occId === occ.id} - onChange={() => props.store.setDraft("occId", occ.id)} + onChange={() => props.store.selectOcc(occ.id)} disabled={check(occ)?.ok !== true} /> {occ.name} diff --git a/apps/web/src/builder/store.ts b/apps/web/src/builder/store.ts index aed27db..b4eb3a6 100644 --- a/apps/web/src/builder/store.ts +++ b/apps/web/src/builder/store.ts @@ -4,6 +4,8 @@ import { getOcc, getSpecies, occSkillPlan, + planStartingLoadout, + resolveStartingLoadout, validateOccEligibility, validateInitialSpells, type AttributeCode, @@ -15,6 +17,9 @@ import { type PsionicsRoll, type PsychicClass, type SkillPick, + type StartingLoadoutPlan, + type StartingLoadoutResolution, + type StartingLoadoutSelections, } from "@riftforge/rules"; import { createMemo, type Accessor } from "solid-js"; import { createStore, type SetStoreFunction } from "solid-js/store"; @@ -37,8 +42,14 @@ export interface Draft { secondary: SkillPick[]; hthId?: string; spellIds: string[]; + loadoutSelections: StartingLoadoutSelections; } +export type NewCharacterCreateInput = Omit & { + speciesId: string; + loadoutSelections: StartingLoadoutSelections; +}; + export interface BuilderStore { draft: Draft; setDraft: SetStoreFunction; @@ -51,8 +62,15 @@ export interface BuilderStore { /** Engine validation of every skill selection (empty errors = legal). */ assembled: Accessor | undefined>; spellErrors: Accessor; + /** Unrolled catalog-backed grants and choices for the current O.C.C. */ + loadoutPlan: Accessor; + /** Shared-engine validation of the player's transient item choices. */ + loadoutResolution: Accessor; + /** Select an O.C.C. and clear loadout choices only when their owner changes. */ + selectOcc: (occId: string) => void; + setLoadoutChoice: (key: string, itemIds: string[]) => void; /** The final character, when every step is satisfied. */ - characterInput: Accessor<(CharacterInput & { speciesId: string }) | undefined>; + characterInput: Accessor; /** The derived preview for the review step, or the error preventing it. */ preview: Accessor<{ sheet?: CharacterSheet; error?: string }>; } @@ -67,6 +85,7 @@ export function createBuilderStore(): BuilderStore { related: [], secondary: [], spellIds: [], + loadoutSelections: { choices: {} }, }); const occ = createMemo(() => (draft.occId !== undefined ? getOcc(draft.occId) : undefined)); @@ -101,9 +120,32 @@ export function createBuilderStore(): BuilderStore { return chosen ? validateInitialSpells(chosen, draft.spellIds) : []; }); - const characterInput = createMemo((): (CharacterInput & { speciesId: string }) | undefined => { + const loadoutPlan = createMemo(() => { + const chosen = occ(); + return chosen ? planStartingLoadout(chosen) : undefined; + }); + + const loadoutResolution = createMemo(() => { + const chosen = occ(); + return chosen ? resolveStartingLoadout(chosen, draft.loadoutSelections) : undefined; + }); + + const selectOcc = (occId: string) => { + if (draft.occId === occId) return; + setDraft("occId", occId); + setDraft("loadoutSelections", { choices: {} }); + }; + + const setLoadoutChoice = (key: string, itemIds: string[]) => { + setDraft("loadoutSelections", "choices", key, itemIds); + }; + + const characterInput = createMemo((): NewCharacterCreateInput | undefined => { const attributes = attributeTotals(); - if (!draft.occId || !attributes || !assembled()) return undefined; + const loadout = loadoutResolution(); + if (!draft.occId || !attributes || !assembled() || !loadout || loadout.errors.length > 0) { + return undefined; + } const narrative = toNarrative(draft.narrative); return { name: draft.name.trim(), @@ -120,6 +162,7 @@ export function createBuilderStore(): BuilderStore { ...(draft.hthId !== undefined ? { hthId: draft.hthId } : {}), }, spellIds: draft.spellIds, + loadoutSelections: draft.loadoutSelections, ...(narrative !== undefined ? { narrative } : {}), }; }); @@ -128,7 +171,9 @@ export function createBuilderStore(): BuilderStore { const input = characterInput(); if (!input) return { error: "The build is incomplete." }; try { - return { sheet: deriveSheet(input) }; + const { loadoutSelections: _requestOnly, ...character } = input; + void _requestOnly; + return { sheet: deriveSheet(character) }; } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } @@ -142,6 +187,10 @@ export function createBuilderStore(): BuilderStore { attributeTotals, assembled, spellErrors, + loadoutPlan, + loadoutResolution, + selectOcc, + setLoadoutChoice, characterInput, preview, }; @@ -185,6 +234,7 @@ export function stepValidity(store: BuilderStore) { }), skills: createMemo(() => (store.assembled()?.errors.length ?? 1) === 0), spells: createMemo(() => store.spellErrors().length === 0), + loadout: createMemo(() => (store.loadoutResolution()?.errors.length ?? 1) === 0), review: createMemo(() => store.preview().sheet !== undefined), }; } diff --git a/apps/web/src/pages/new-character.tsx b/apps/web/src/pages/new-character.tsx index 122d6b3..d4871eb 100644 --- a/apps/web/src/pages/new-character.tsx +++ b/apps/web/src/pages/new-character.tsx @@ -4,6 +4,7 @@ import { createMemo, createSignal, For, Show, Switch, Match } from "solid-js"; import { AlignmentStep } from "../builder/steps/alignment.tsx"; import { AttributesStep } from "../builder/steps/attributes.tsx"; import { IdentityStep } from "../builder/steps/identity.tsx"; +import { LoadoutStep } from "../builder/steps/loadout.tsx"; import { OccSkillsStep } from "../builder/steps/occ-skills.tsx"; import { OccStep } from "../builder/steps/occ.tsx"; import { PsionicsStep } from "../builder/steps/psionics.tsx"; @@ -34,6 +35,7 @@ export function NewCharacterPage() { { id: "occSkills", title: "O.C.C. skills", valid: validity.occSkills }, { id: "skills", title: "Related & secondary", valid: validity.skills }, { id: "spells", title: "Spells", valid: validity.spells }, + { id: "loadout", title: "Loadout", valid: validity.loadout }, { id: "review", title: "Review", valid: validity.review }, ] as const; @@ -114,6 +116,9 @@ export function NewCharacterPage() { + + + diff --git a/apps/web/tests/builder.test.ts b/apps/web/tests/builder.test.ts index 3a2e9cb..6353518 100644 --- a/apps/web/tests/builder.test.ts +++ b/apps/web/tests/builder.test.ts @@ -144,6 +144,8 @@ describe("builder authoritative skill payload", () => { { skillId: "radio-basic" }, ]); store.setDraft("hthId", "basic"); + store.setLoadoutChoice("conventional-sidearm", ["automatic-pistol"]); + store.setLoadoutChoice("energy-weapon", ["wilks-320-laser-pistol"]); const input = store.characterInput(); expect(input).toMatchObject({ @@ -156,6 +158,13 @@ describe("builder authoritative skill payload", () => { }); expect(input).not.toHaveProperty("skills"); expect(input).not.toHaveProperty("hthType"); + expect(input).not.toHaveProperty("items"); + expect(input?.loadoutSelections).toEqual({ + choices: { + "conventional-sidearm": ["automatic-pistol"], + "energy-weapon": ["wilks-320-laser-pistol"], + }, + }); expect( store.assembled()?.skills.find((skill) => skill.skillId === "math-advanced"), ).toMatchObject({ categoryBonus: 10 }); @@ -182,6 +191,8 @@ describe("builder authoritative skill payload", () => { store.setDraft("attributes", legalLeyLineWalkerAttributes); store.setDraft("occId", "ley-line-walker"); store.setDraft("related", [{ skillId: "recycling", category: "Technical" }]); + store.setLoadoutChoice("conventional-sidearm", ["automatic-pistol"]); + store.setLoadoutChoice("energy-weapon", ["wilks-320-laser-pistol"]); expect(store.characterInput()?.skillSelections?.related).toEqual([ { skillId: "recycling", category: "Technical" }, @@ -190,3 +201,116 @@ describe("builder authoritative skill payload", () => { }); }); }); + +describe("builder starting-loadout ownership and request boundary", () => { + test("starts empty, exposes the authoritative plan, and gates invalid selections", () => { + createRoot((dispose) => { + const store = createBuilderStore(); + const validity = stepValidity(store); + + expect(store.draft.loadoutSelections).toEqual({ choices: {} }); + store.setDraft("attributes", legalLeyLineWalkerAttributes); + store.selectOcc("ley-line-walker"); + expect(store.loadoutPlan()?.fixed).toHaveLength(18); + expect(store.loadoutResolution()?.errors).toEqual([ + expect.stringMatching(/conventional-sidearm.*exactly 1/i), + expect.stringMatching(/energy-weapon.*exactly 1/i), + ]); + expect(validity.loadout()).toBe(false); + + store.setLoadoutChoice("conventional-sidearm", ["automatic-pistol"]); + store.setLoadoutChoice("energy-weapon", ["wilks-320-laser-pistol"]); + expect(store.loadoutResolution()?.errors).toEqual([]); + expect(validity.loadout()).toBe(true); + + store.setLoadoutChoice("energy-weapon", ["canteen"]); + expect(store.loadoutResolution()?.errors).toEqual([ + expect.stringMatching(/energy-weapon.*canteen.*not.*legal/i), + ]); + expect(validity.loadout()).toBe(false); + dispose(); + }); + }); + + test("submits selections but never client-authored inventory", () => { + createRoot((dispose) => { + const store = createBuilderStore(); + store.setDraft("name", "Vesper"); + store.setDraft("attributes", legalLeyLineWalkerAttributes); + store.selectOcc("ley-line-walker"); + store.setLoadoutChoice("conventional-sidearm", ["submachine-gun"]); + store.setLoadoutChoice("energy-weapon", ["ng-l5-laser-rifle"]); + + const input = store.characterInput(); + expect(input?.loadoutSelections).toEqual({ + choices: { + "conventional-sidearm": ["submachine-gun"], + "energy-weapon": ["ng-l5-laser-rifle"], + }, + }); + expect(input).not.toHaveProperty("items"); + dispose(); + }); + }); + + test("preserves selections for the same O.C.C. and clears only loadout choices on ownership change", () => { + createRoot((dispose) => { + const store = createBuilderStore(); + store.selectOcc("ley-line-walker"); + store.setDraft("occChoices", { + lore: [{ skillId: "lore-dbee" }], + }); + store.setLoadoutChoice("conventional-sidearm", ["automatic-pistol"]); + + store.selectOcc("ley-line-walker"); + expect(store.draft.loadoutSelections.choices["conventional-sidearm"]).toEqual([ + "automatic-pistol", + ]); + + store.selectOcc("future-occ"); + expect(store.draft.loadoutSelections).toEqual({ choices: {} }); + expect(store.draft.occChoices).toEqual({ + lore: [{ skillId: "lore-dbee" }], + }); + dispose(); + }); + }); +}); + +describe("builder loadout presentation", () => { + test("renders accessible named choices, source pages, ranges, and checklist-only guidance", () => { + const loadoutStepSource = source("../src/builder/steps/loadout.tsx"); + const loadoutSummarySource = source("../src/builder/loadout-summary.tsx"); + + expect(loadoutStepSource).toContain(" { + const newCharacterSource = source("../src/pages/new-character.tsx"); + const spells = newCharacterSource.indexOf('{ id: "spells"'); + const loadout = newCharacterSource.indexOf('{ id: "loadout"'); + const review = newCharacterSource.indexOf('{ id: "review"'); + + expect(spells).toBeGreaterThanOrEqual(0); + expect(loadout).toBeGreaterThan(spells); + expect(review).toBeGreaterThan(loadout); + expect(newCharacterSource).toContain(""); + expect(newCharacterSource).toContain( + 'id: "loadout", title: "Loadout", valid: validity.loadout', + ); + }); +}); From 1ab6bfc85c650371046f6a0dcaf88ddae685b7df Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 26 Jul 2026 23:45:07 +0800 Subject: [PATCH 5/7] feat(web): review unrolled starting loadouts --- apps/web/src/builder/steps/review.tsx | 13 +++++- apps/web/src/components/sheet-view.tsx | 61 ++++++++++++++------------ apps/web/tests/builder.test.ts | 12 +++++ apps/web/tests/character-sheet.test.ts | 7 +++ 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/apps/web/src/builder/steps/review.tsx b/apps/web/src/builder/steps/review.tsx index 8101a2b..bff1686 100644 --- a/apps/web/src/builder/steps/review.tsx +++ b/apps/web/src/builder/steps/review.tsx @@ -1,6 +1,7 @@ import { Show } from "solid-js"; import { SheetView } from "../../components/sheet-view.tsx"; import { Alert, MonoLabel } from "../../components/ui.tsx"; +import { LoadoutSummary } from "../loadout-summary.tsx"; import type { BuilderStore } from "../store.ts"; /** @@ -22,7 +23,17 @@ export function ReviewStep(props: { store: BuilderStore }) { // PRE-FORGE PREVIEW — vitals show ranges until rolled on the dossier - + +
+ SERVER-PROVISIONED CATALOG ITEMS +

+ QUANTITIES AND ARMOR CAPACITY REMAIN UNROLLED UNTIL FORGE +

+

+ CHECKLIST GUIDANCE IS NOT STORED INVENTORY +

+
+ )} diff --git a/apps/web/src/components/sheet-view.tsx b/apps/web/src/components/sheet-view.tsx index 100020f..5bda8fd 100644 --- a/apps/web/src/components/sheet-view.tsx +++ b/apps/web/src/components/sheet-view.tsx @@ -360,6 +360,7 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions; gameplayDisabledReason?: string; + showEquipment?: boolean; }) { const s = () => props.sheet; return ( @@ -635,37 +636,39 @@ export function SheetView(props: { - -
- EQUIPMENT — MANIFEST ({s().equipment.length}) + + +
+ EQUIPMENT — MANIFEST ({s().equipment.length}) + + WEAPONS: CLICK DICE TO ROLL + +
+
+ + // NOTHING ON MANIFEST +

+ } + > + {(entry, index) => ( + + )} +
+
- WEAPONS: CLICK DICE TO ROLL + {(actions) => actions().acquireItem(itemId)} />} -
-
- - // NOTHING ON MANIFEST -

- } - > - {(entry, index) => ( - - )} -
-
- - {(actions) => actions().acquireItem(itemId)} />} - -
+ + {(backstory) => ( diff --git a/apps/web/tests/builder.test.ts b/apps/web/tests/builder.test.ts index 6353518..6f9ca82 100644 --- a/apps/web/tests/builder.test.ts +++ b/apps/web/tests/builder.test.ts @@ -314,3 +314,15 @@ describe("builder loadout presentation", () => { ); }); }); + +describe("pre-forge review loadout boundary", () => { + test("reviews unrolled provisioning separately from the concrete equipment manifest", () => { + const reviewSource = source("../src/builder/steps/review.tsx"); + + expect(reviewSource).toContain("LoadoutSummary"); + expect(reviewSource).toContain("SERVER-PROVISIONED CATALOG ITEMS"); + expect(reviewSource).toContain("QUANTITIES AND ARMOR CAPACITY REMAIN UNROLLED UNTIL FORGE"); + expect(reviewSource).toContain("CHECKLIST GUIDANCE IS NOT STORED INVENTORY"); + expect(reviewSource).toContain("showEquipment={false}"); + }); +}); diff --git a/apps/web/tests/character-sheet.test.ts b/apps/web/tests/character-sheet.test.ts index b5e3f89..f333f22 100644 --- a/apps/web/tests/character-sheet.test.ts +++ b/apps/web/tests/character-sheet.test.ts @@ -101,6 +101,13 @@ describe("terminal sheet dossier", () => { expect(sheetViewSource).toContain("props.actions!.rollWeapon"); expect(sheetViewSource).not.toContain("actions={undefined}"); }); + + test("suppresses equipment only for pre-forge review while live dossiers keep the manifest", () => { + expect(sheetViewSource).toContain("showEquipment?: boolean;"); + expect(sheetViewSource).toContain(""); + expect(characterSheetSource).not.toContain("showEquipment={false}"); + expect(characterSheetSource).toContain(" { From 207f74f0248efce278b6d76d8e7ed1abda2d58e1 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 27 Jul 2026 00:24:39 +0800 Subject: [PATCH 6/7] docs: record starting loadout authority --- README.md | 10 ++++++++++ docs/rules/PAGE_MAP.md | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 733dec5..3fea71e 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,16 @@ Physical S.D.C. is additive: the p.287 `2D6+12` base and every applicable O.C.C./Physical-skill formula are exposed as a sourced breakdown, combined into the preview range, and rolled server-side from that same contribution list. +Starting loadouts follow the same authority boundary. The builder submits only +the player's page-stamped O.C.C. choice-group selections; one backend mutation +reloads the O.C.C. content, validates those choices, rolls grant quantities and +per-suit armor capacity, validates the full character, and atomically stores the +result as ordinary one-entry-per-instance inventory. The client cannot author a +starting manifest, and request-only selections are not persisted. Characters +created before structured loadouts receive no retroactive grants or migration, +while later inventory changes remain ordinary mutable character state and do not +reapply creation-time loadout legality. + ## Development Tooling is [Vite+](https://viteplus.dev) (`vp`) over a pnpm workspace. diff --git a/docs/rules/PAGE_MAP.md b/docs/rules/PAGE_MAP.md index 456a216..923da99 100644 --- a/docs/rules/PAGE_MAP.md +++ b/docs/rules/PAGE_MAP.md @@ -86,6 +86,23 @@ Implemented skill-legality coverage is anchored to the rendered printed pages: | 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 | +Implemented Ley Line Walker starting-loadout coverage is anchored to rendered +printed pages: + +- p.113: the starting concealed light suit rolls `2D6+32` M.D.C. independently + when the authoritative manifest is forged. +- p.116: all fixed grants, `1D4` small-sack quantity, named weapon choices, and + checklist-only ammunition, vehicle, credits, Black Market, and Techno-Wizard + guidance. +- pp.268–270: source-verified energy-pistol and energy-rifle catalog options. +- pp.326–329: source-verified hand axe, survival knife, automatic-pistol, + submachine-gun, and weapon-category constraints. + +The builder stores only player selections until forge. The backend resolves and +rolls concrete inventory in the character-creation transaction; pre-existing +characters are not migrated, and later inventory mutation does not revalidate +the starting manifest. + ## Magic (printed 185+) — needed for Ley Line Walker | Section | Printed pp. | PDF idx | From c41f0aa014795dd831751656f55ee475e2f00f9a Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 27 Jul 2026 01:29:52 +0800 Subject: [PATCH 7/7] fix(rules): harden loadout provisioning --- packages/rules/src/engine/loadouts.ts | 25 ++++++++++++++-- packages/rules/src/schema/loadouts.ts | 20 ++++++++++--- packages/rules/tests/loadouts.test.ts | 41 +++++++++++++++++++++++---- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/rules/src/engine/loadouts.ts b/packages/rules/src/engine/loadouts.ts index a9b7789..eb2662d 100644 --- a/packages/rules/src/engine/loadouts.ts +++ b/packages/rules/src/engine/loadouts.ts @@ -6,7 +6,11 @@ import type { StartingLoadoutSelections, } from "../schema/loadouts.ts"; import type { Occ } from "../schema/occ.ts"; -import { MAX_STARTING_LOADOUT_INSTANCES } from "../schema/loadouts.ts"; +import { + MAX_STARTING_LOADOUT_CHOICE_GROUPS, + MAX_STARTING_LOADOUT_INSTANCES, + MAX_STARTING_LOADOUT_ITEM_IDS_PER_CHOICE, +} from "../schema/loadouts.ts"; import { diceMax, diceMin, rollDice, type Rng } from "./dice.ts"; import { armorNeedsRoll, getItem, itemsByKind, rollArmorMdc } from "./items.ts"; @@ -53,6 +57,19 @@ export interface StartingLoadoutResolution { errors: string[]; } +function selectionBoundsError(selections: StartingLoadoutSelections): string | undefined { + const choices = Object.entries(selections.choices); + if (choices.length > MAX_STARTING_LOADOUT_CHOICE_GROUPS) { + return `Starting-loadout selections can contain at most ${MAX_STARTING_LOADOUT_CHOICE_GROUPS} choice groups.`; + } + for (const [key, selectedItemIds] of choices) { + if (selectedItemIds.length > MAX_STARTING_LOADOUT_ITEM_IDS_PER_CHOICE) { + return `Loadout choice "${key}" can contain at most ${MAX_STARTING_LOADOUT_ITEM_IDS_PER_CHOICE} item ids.`; + } + } + return undefined; +} + function fail(occ: Occ, key: string, message: string): never { throw new Error(`O.C.C. "${occ.id}" starting loadout "${key}" ${message}`); } @@ -178,6 +195,10 @@ export function resolveStartingLoadout( selections: StartingLoadoutSelections, ): StartingLoadoutResolution { const plan = planStartingLoadout(occ); + const boundsError = selectionBoundsError(selections); + if (boundsError) { + return { grants: [...plan.fixed], checklist: plan.checklist, errors: [boundsError] }; + } const errors: string[] = []; const grants: StartingLoadoutResolution["grants"] = [...plan.fixed]; const knownKeys = new Set(plan.choices.map((choice) => choice.key)); @@ -230,7 +251,7 @@ export function resolveStartingLoadout( export function provisionStartingLoadout( occ: Occ, selections: StartingLoadoutSelections, - rng: Rng = Math.random, + rng: Rng, ): CharacterItem[] { const resolution = resolveStartingLoadout(occ, selections); if (resolution.errors.length > 0) { diff --git a/packages/rules/src/schema/loadouts.ts b/packages/rules/src/schema/loadouts.ts index 88a9839..b816ac1 100644 --- a/packages/rules/src/schema/loadouts.ts +++ b/packages/rules/src/schema/loadouts.ts @@ -5,6 +5,8 @@ import { diceFormulaSchema } from "./dice.ts"; import { weaponCategorySchema } from "./items.ts"; export const MAX_STARTING_LOADOUT_INSTANCES = 100; +export const MAX_STARTING_LOADOUT_CHOICE_GROUPS = MAX_STARTING_LOADOUT_INSTANCES; +export const MAX_STARTING_LOADOUT_ITEM_IDS_PER_CHOICE = MAX_STARTING_LOADOUT_INSTANCES; const hasUniqueStrings = (values: string[]) => new Set(values).size === values.length; @@ -101,13 +103,23 @@ export const startingLoadoutSchema = z }); export type StartingLoadout = z.infer; -const selectedItemIdsSchema = z.array(z.string().min(1)).refine(hasUniqueStrings, { - message: "A loadout choice cannot contain duplicate item ids.", -}); +const selectedItemIdsSchema = z + .array(z.string().min(1)) + .max( + MAX_STARTING_LOADOUT_ITEM_IDS_PER_CHOICE, + `A loadout choice can contain at most ${MAX_STARTING_LOADOUT_ITEM_IDS_PER_CHOICE} item ids.`, + ) + .refine(hasUniqueStrings, { + message: "A loadout choice cannot contain duplicate item ids.", + }); export const startingLoadoutSelectionsSchema = z .object({ - choices: z.record(z.string().min(1), selectedItemIdsSchema), + choices: z + .record(z.string().min(1), selectedItemIdsSchema) + .refine((choices) => Object.keys(choices).length <= MAX_STARTING_LOADOUT_CHOICE_GROUPS, { + message: `Starting-loadout selections can contain at most ${MAX_STARTING_LOADOUT_CHOICE_GROUPS} choice groups.`, + }), }) .strict(); export type StartingLoadoutSelections = z.infer; diff --git a/packages/rules/tests/loadouts.test.ts b/packages/rules/tests/loadouts.test.ts index e64af1d..963d4c8 100644 --- a/packages/rules/tests/loadouts.test.ts +++ b/packages/rules/tests/loadouts.test.ts @@ -309,6 +309,33 @@ describe("starting-loadout selection resolution", () => { resolveStartingLoadout(occ, { choices: { obsolete: ["automatic-pistol"] } }).errors, ).toEqual([expect.stringMatching(/unknown.*obsolete/i)]); }); + + test("rejects more than 100 submitted choice groups before error amplification", () => { + const choices = Object.fromEntries( + Array.from({ length: 101 }, (_, index) => [`unknown-${index}`, ["automatic-pistol"]]), + ); + + expect(() => startingLoadoutSelectionsSchema.parse({ choices })).toThrow( + /at most 100 choice groups/i, + ); + expect(resolveStartingLoadout(leyLineWalker, { choices }).errors).toEqual([ + expect.stringMatching(/at most 100 choice groups/i), + ]); + }); + + test("rejects more than 100 item ids in one submitted choice", () => { + const choices = { + ...legalSelections.choices, + "energy-weapon": Array.from({ length: 101 }, (_, index) => `item-${index}`), + }; + + expect(() => startingLoadoutSelectionsSchema.parse({ choices })).toThrow( + /at most 100 item ids/i, + ); + expect(resolveStartingLoadout(leyLineWalker, { choices }).errors).toEqual([ + expect.stringMatching(/energy-weapon.*at most 100 item ids/i), + ]); + }); }); describe("starting-loadout provisioning", () => { @@ -359,12 +386,16 @@ describe("starting-loadout provisioning", () => { test("throws instead of provisioning a partial or substituted invalid manifest", () => { expect(() => - provisionStartingLoadout(leyLineWalker, { - choices: { - ...legalSelections.choices, - "energy-weapon": ["canteen"], + provisionStartingLoadout( + leyLineWalker, + { + choices: { + ...legalSelections.choices, + "energy-weapon": ["canteen"], + }, }, - }), + () => 0, + ), ).toThrow(/energy-weapon.*canteen.*not.*legal/i); }); });