From 184f967ceff9dd7f15771892dfe8ca7637ae8e3d Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Thu, 2 Jul 2026 01:54:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(rules):=20character-sheet=20assembly?= =?UTF-8?q?=20=E2=80=94=20deriveSheet()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure function that turns a character's choices into a complete computed sheet, tying every subsystem together. Lives in the engine (no Convex/UI dep) so it runs in tests, the backend, or the client. - schema/character.ts — Character: the player's choices (occId, level, 8 attributes, hthType, skill picks + bonuses, spell picks, optional rolled H.P./ S.D.C./P.P.E.). Derived stats are computed, never stored. - engine/character.ts — deriveSheet(character): validates, then returns identity, attribute bonuses, combat profile (attacks/strike/parry/dodge/damage), vitals (H.P./S.D.C. ranges + coma/death floor), P.P.E. range, spell strength, a saves map (attribute + O.C.C. bonuses, with targets), resolved skill %s, and known spells. Tested end-to-end against a level-1 Ley Line Walker. 70 tests pass; `vp check` clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018ur5Eu6dC17feVQH5smrFw --- packages/rules/src/engine/character.ts | 164 +++++++++++++++++++++++++ packages/rules/src/index.ts | 2 + packages/rules/src/schema/character.ts | 47 +++++++ packages/rules/tests/character.test.ts | 84 +++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 packages/rules/src/engine/character.ts create mode 100644 packages/rules/src/schema/character.ts create mode 100644 packages/rules/tests/character.test.ts diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts new file mode 100644 index 0000000..9a7db05 --- /dev/null +++ b/packages/rules/src/engine/character.ts @@ -0,0 +1,164 @@ +import type { Character } from "../schema/character.ts"; +import { characterSchema } from "../schema/character.ts"; +import type { Occ } from "../schema/occ.ts"; +import type { Spell } from "../schema/spells.ts"; +import { deriveAttributeBonuses } from "./attributes.ts"; +import { + combatProfile, + comaDeathFloor, + hitPointsRange, + physicalSdcRange, + psionicsSaveTarget, + savingThrowTarget, + type StatRange, +} from "./combat.ts"; +import { basePpeRange, getOcc } from "./occ.ts"; +import { iqSkillBonus, resolveSkill, type ResolvedSkill } from "./skills.ts"; +import { getSpell, occSpellStrength } from "./spells.ts"; + +/** A dice-derived stat: its range, plus the concrete roll if one was recorded. */ +export interface StatValue extends StatRange { + rolled?: number; +} + +/** A saving throw: the d20 target (fixed or a range) and the character's total bonus. */ +export interface SheetSave { + target?: number; + targetRange?: { min: number; max: number }; + bonus: number; + /** Set for percentile saves (e.g. coma/death), whose "bonus" is a percentage. */ + percent?: boolean; +} + +export interface CharacterSheet { + name: string; + occ: { id: string; name: string; category: string }; + level: number; + attributes: Character["attributes"]; + attributeBonuses: Record; + combat: { + attacksPerMelee: number; + strike: number; + parry: number; + dodge: number; + damageBonus: number; + }; + vitals: { hitPoints: StatValue; sdc: StatValue; comaDeathFloor: number }; + /** Present for spell-casting O.C.C.s. */ + ppe?: StatValue; + spellStrength?: number; + saves: Record; + skills: ResolvedSkill[]; + spells: { known: Spell[]; count: number }; +} + +/** Total O.C.C. save bonus for a given save target at a level (respects level gating). */ +function occSaveBonus(occ: Occ, target: string, level: number): number { + let total = 0; + for (const b of occ.bonuses ?? []) { + if (b.type !== "save" || b.target !== target || typeof b.value !== "number") { + continue; + } + total += + b.atLevels && b.atLevels.length > 0 + ? b.atLevels.filter((l) => l <= level).length * b.value + : b.value; + } + return total; +} + +function withRolled(range: StatRange, rolled?: number): StatValue { + return rolled === undefined ? { ...range } : { ...range, rolled }; +} + +/** + * Assemble a character's full derived sheet from their choices — the heart of + * the "smart" sheet. Pure and deterministic (dice *rolls* are inputs via + * `character.rolled`, not generated here), so it runs anywhere: tests, the + * Convex backend, or the client. + */ +export function deriveSheet(input: Character): CharacterSheet { + const character = characterSchema.parse(input); + const occ = getOcc(character.occId); + if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); + + const attrs = character.attributes; + const { level } = character; + const iqBonus = iqSkillBonus(attrs.IQ); + const attributeBonuses = deriveAttributeBonuses(attrs); + const combat = combatProfile({ + attributes: attrs, + hthType: character.hthType, + level, + }); + + const isCaster = occ.spellKnowledge !== undefined || occ.ppe !== undefined; + + const saves: Record = { + magic: { + targetRange: savingThrowTarget("magic")?.targetRange, + bonus: combat.saveBonuses.magic + occSaveBonus(occ, "magic", level), + }, + psionics: { + target: psionicsSaveTarget("ordinary"), + bonus: combat.saveBonuses.psionic, + }, + insanity: { + target: savingThrowTarget("insanity")?.target, + bonus: combat.saveBonuses.insanity, + }, + lethalPoison: { + target: savingThrowTarget("lethalPoison")?.target, + bonus: combat.saveBonuses.poison, + }, + curses: { + target: savingThrowTarget("curses")?.target, + bonus: occSaveBonus(occ, "curses", level), + }, + horrorFactor: { bonus: occSaveBonus(occ, "horrorFactor", level) }, + possession: { + bonus: occSaveBonus(occ, "possessionAndMindControl", level), + }, + comaDeath: { bonus: combat.saveBonuses.comaDeathPct, percent: true }, + }; + + const skills = character.skills + .map((s) => + resolveSkill(s.skillId, { + level, + occBonus: s.occBonus, + categoryBonus: s.categoryBonus, + iqBonus, + }), + ) + .filter((r): r is ResolvedSkill => r !== undefined); + + const knownSpells = character.spellIds + .map((id) => getSpell(id)) + .filter((s): s is Spell => s !== undefined); + + return { + name: character.name, + occ: { id: occ.id, name: occ.name, category: occ.category }, + level, + attributes: attrs, + attributeBonuses, + combat: { + attacksPerMelee: combat.attacksPerMelee, + strike: combat.strike, + parry: combat.parry, + dodge: combat.dodge, + damageBonus: combat.damageBonus, + }, + vitals: { + hitPoints: withRolled(hitPointsRange(attrs.PE, level), character.rolled?.hitPoints), + sdc: withRolled(physicalSdcRange(), character.rolled?.sdc), + comaDeathFloor: comaDeathFloor(attrs.PE), + }, + ppe: occ.ppe ? withRolled(basePpeRange(occ, attrs.PE), character.rolled?.ppe) : undefined, + spellStrength: isCaster ? occSpellStrength(occ, level) : undefined, + saves, + skills, + spells: { known: knownSpells, count: knownSpells.length }, + }; +} diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 1345b10..f746f8d 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -3,9 +3,11 @@ export * from "./schema/occ.ts"; export * from "./schema/combat.ts"; export * from "./schema/skills.ts"; export * from "./schema/spells.ts"; +export * from "./schema/character.ts"; export * from "./engine/attributes.ts"; export * from "./engine/dice.ts"; export * from "./engine/occ.ts"; export * from "./engine/combat.ts"; export * from "./engine/skills.ts"; export * from "./engine/spells.ts"; +export * from "./engine/character.ts"; diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts new file mode 100644 index 0000000..31dd9b0 --- /dev/null +++ b/packages/rules/src/schema/character.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +/** A skill the character has taken, with the O.C.C./category bonuses that apply. */ +export const characterSkillSchema = z.object({ + skillId: z.string().min(1), + occBonus: z.number().int().optional(), + categoryBonus: z.number().int().optional(), +}); +export type CharacterSkill = 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(), + ME: z.number().int().positive(), + MA: z.number().int().positive(), + PS: z.number().int().positive(), + PP: z.number().int().positive(), + PE: z.number().int().positive(), + PB: z.number().int().positive(), + Spd: z.number().int().positive(), +}); +export type CharacterAttributes = z.infer; + +/** + * A built character — the player's *choices*. Derived stats (bonuses, attacks, + * save targets, resolved skill %s, spell strength, …) are computed by + * `deriveSheet`, never stored. Optional `rolled` values pin the dice results + * that would otherwise be shown as a range. + */ +export const characterSchema = z.object({ + name: z.string().min(1), + occId: z.string().min(1), + level: z.number().int().positive(), + attributes: characterAttributesSchema, + /** Hand-to-Hand combat type id (e.g. "basic"). */ + hthType: z.string().min(1), + skills: z.array(characterSkillSchema).default([]), + spellIds: z.array(z.string().min(1)).default([]), + rolled: z + .object({ + hitPoints: z.number().int().positive().optional(), + sdc: z.number().int().nonnegative().optional(), + ppe: z.number().int().nonnegative().optional(), + }) + .optional(), +}); +export type Character = z.infer; diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts new file mode 100644 index 0000000..8d9263e --- /dev/null +++ b/packages/rules/tests/character.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vite-plus/test"; +import { deriveSheet, type Character } from "../src/index.ts"; + +const leyLineWalker: Character = { + name: "Vesper", + occId: "ley-line-walker", + level: 1, + attributes: { IQ: 18, ME: 16, MA: 12, PS: 16, PP: 20, PE: 14, PB: 11, Spd: 12 }, + hthType: "basic", + skills: [ + { skillId: "wilderness-survival", occBonus: 10 }, + { skillId: "math-basic", occBonus: 10 }, + { skillId: "land-navigation", occBonus: 4 }, + ], + spellIds: ["globe-of-daylight", "energy-bolt", "armor-of-ithan"], +}; + +describe("deriveSheet — a level-1 Ley Line Walker", () => { + const sheet = deriveSheet(leyLineWalker); + + test("identity", () => { + expect(sheet.occ).toMatchObject({ + id: "ley-line-walker", + name: "Ley Line Walker", + category: "Practitioner of Magic", + }); + expect(sheet.level).toBe(1); + }); + + test("combat profile from P.P. 20 / P.S. 16, Basic H2H", () => { + expect(sheet.combat).toEqual({ + attacksPerMelee: 4, + strike: 3, // P.P. 20 -> +3 + parry: 3, + dodge: 3, + damageBonus: 1, // P.S. 16 -> +1 + }); + }); + + test("vitals from P.E. 14", () => { + expect(sheet.vitals.hitPoints).toEqual({ min: 15, max: 20, average: 17.5 }); + expect(sheet.vitals.sdc).toEqual({ min: 14, max: 24, average: 19 }); + expect(sheet.vitals.comaDeathFloor).toBe(-14); + }); + + test("P.P.E. = 3D6*10+20 + P.E., and spell strength 12 at level 1", () => { + expect(sheet.ppe).toEqual({ min: 64, max: 214, average: 139 }); + expect(sheet.spellStrength).toBe(12); + }); + + test("saves combine attribute + O.C.C. bonuses", () => { + expect(sheet.saves.magic).toEqual({ + targetRange: { min: 12, max: 16 }, + bonus: 0, // P.E. 14 gives no attribute bonus; magic O.C.C. bonus starts at level 3 + }); + expect(sheet.saves.psionics).toEqual({ target: 15, bonus: 1 }); // M.E. 16 -> +1 + expect(sheet.saves.horrorFactor.bonus).toBe(4); // LLW flat +4 + expect(sheet.saves.curses).toEqual({ target: 15, bonus: 3 }); + expect(sheet.saves.possession.bonus).toBe(2); + }); + + test("skills resolve with O.C.C. + I.Q.(18 -> +4) bonuses", () => { + const bySkill = Object.fromEntries(sheet.skills.map((s) => [s.id, s.value])); + expect(bySkill["wilderness-survival"]).toBe(44); // 30 + 10 + 4 + expect(bySkill["math-basic"]).toBe(59); // 45 + 10 + 4 + expect(bySkill["land-navigation"]).toBe(44); // 36 + 4 + 4 + }); + + test("known spells resolve", () => { + expect(sheet.spells.count).toBe(3); + expect(sheet.spells.known.map((s) => s.id)).toContain("armor-of-ithan"); + }); +}); + +describe("deriveSheet — edge cases", () => { + test("a recorded H.P. roll shows as `rolled`", () => { + const sheet = deriveSheet({ ...leyLineWalker, rolled: { hitPoints: 18 } }); + expect(sheet.vitals.hitPoints.rolled).toBe(18); + }); + + test("an unknown O.C.C. throws", () => { + expect(() => deriveSheet({ ...leyLineWalker, occId: "nope" })).toThrow(/Unknown O\.C\.C\./); + }); +}); From efe3af02f2930148963457fe9e71678cb1e855aa Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Thu, 2 Jul 2026 02:11:00 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(rules):=20address=20Cubic=20=E2=80=94?= =?UTF-8?q?=20level-aware=20P.P.E.,=20psychic=20class,=20unique=20skills/s?= =?UTF-8?q?pells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P.P.E.: add ppeRange(occ, pe, level) that includes per-level growth (was showing level-1 base for all levels); basePpeRange delegates to it. deriveSheet now uses the character's level. - Psionics save target: carry `psychicClass` on the character (default "ordinary") and pass it to psionicsSaveTarget, instead of hardcoding "ordinary". - Reject duplicate skillIds and spellIds via schema .refine() (can't take a skill or know a spell twice). - deriveSheet accepts CharacterInput (z.input) so defaulted fields may be omitted. 73 tests pass; `vp check` clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018ur5Eu6dC17feVQH5smrFw --- packages/rules/src/engine/character.ts | 10 ++++---- packages/rules/src/engine/occ.ts | 20 ++++++++++----- packages/rules/src/schema/character.ts | 23 +++++++++++++++-- packages/rules/tests/character.test.ts | 35 ++++++++++++++++++++++++-- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts index 9a7db05..35947b7 100644 --- a/packages/rules/src/engine/character.ts +++ b/packages/rules/src/engine/character.ts @@ -1,4 +1,4 @@ -import type { Character } from "../schema/character.ts"; +import type { Character, CharacterInput } from "../schema/character.ts"; import { characterSchema } from "../schema/character.ts"; import type { Occ } from "../schema/occ.ts"; import type { Spell } from "../schema/spells.ts"; @@ -12,7 +12,7 @@ import { savingThrowTarget, type StatRange, } from "./combat.ts"; -import { basePpeRange, getOcc } from "./occ.ts"; +import { getOcc, ppeRange } from "./occ.ts"; import { iqSkillBonus, resolveSkill, type ResolvedSkill } from "./skills.ts"; import { getSpell, occSpellStrength } from "./spells.ts"; @@ -77,7 +77,7 @@ function withRolled(range: StatRange, rolled?: number): StatValue { * `character.rolled`, not generated here), so it runs anywhere: tests, the * Convex backend, or the client. */ -export function deriveSheet(input: Character): CharacterSheet { +export function deriveSheet(input: CharacterInput): CharacterSheet { const character = characterSchema.parse(input); const occ = getOcc(character.occId); if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); @@ -100,7 +100,7 @@ export function deriveSheet(input: Character): CharacterSheet { bonus: combat.saveBonuses.magic + occSaveBonus(occ, "magic", level), }, psionics: { - target: psionicsSaveTarget("ordinary"), + target: psionicsSaveTarget(character.psychicClass), bonus: combat.saveBonuses.psionic, }, insanity: { @@ -155,7 +155,7 @@ export function deriveSheet(input: Character): CharacterSheet { sdc: withRolled(physicalSdcRange(), character.rolled?.sdc), comaDeathFloor: comaDeathFloor(attrs.PE), }, - ppe: occ.ppe ? withRolled(basePpeRange(occ, attrs.PE), character.rolled?.ppe) : undefined, + ppe: occ.ppe ? withRolled(ppeRange(occ, attrs.PE, level), character.rolled?.ppe) : undefined, spellStrength: isCaster ? occSpellStrength(occ, level) : undefined, saves, skills, diff --git a/packages/rules/src/engine/occ.ts b/packages/rules/src/engine/occ.ts index 249ec4a..2fdeb79 100644 --- a/packages/rules/src/engine/occ.ts +++ b/packages/rules/src/engine/occ.ts @@ -21,19 +21,27 @@ export interface PpeRange { } /** - * The level-1 permanent P.P.E. an O.C.C. grants, as a range, given the - * character's P.E. attribute. Returns zeros for O.C.C.s without P.P.E. + * The permanent P.P.E. an O.C.C. grants at a given level, as a range: the level-1 + * base plus the per-level gain for each level reached (matching `hitPointsRange`). + * Returns zeros for O.C.C.s without P.P.E. */ -export function basePpeRange(occ: Occ, peAttribute: number): PpeRange { +export function ppeRange(occ: Occ, peAttribute: number, level: number): PpeRange { if (!occ.ppe) return { min: 0, max: 0, average: 0 }; const add = occ.ppe.addPeAttribute ? peAttribute : 0; + const { baseFormula, perLevelFormula, perLevelStartsAt } = occ.ppe; + const perLevelGains = Math.max(0, level - perLevelStartsAt + 1); return { - min: diceMin(occ.ppe.baseFormula) + add, - max: diceMax(occ.ppe.baseFormula) + add, - average: diceAverage(occ.ppe.baseFormula) + add, + min: diceMin(baseFormula) + add + perLevelGains * diceMin(perLevelFormula), + max: diceMax(baseFormula) + add + perLevelGains * diceMax(perLevelFormula), + average: diceAverage(baseFormula) + add + perLevelGains * diceAverage(perLevelFormula), }; } +/** The level-1 permanent P.P.E. range (convenience wrapper over {@link ppeRange}). */ +export function basePpeRange(occ: Occ, peAttribute: number): PpeRange { + return ppeRange(occ, peAttribute, 1); +} + /** Roll a concrete level-1 permanent P.P.E. total for a character. */ export function rollBasePpe(occ: Occ, peAttribute: number, rng: Rng = Math.random): number { if (!occ.ppe) return 0; diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts index 31dd9b0..4e2c3a0 100644 --- a/packages/rules/src/schema/character.ts +++ b/packages/rules/src/schema/character.ts @@ -21,6 +21,10 @@ export const characterAttributesSchema = z.object({ }); export type CharacterAttributes = z.infer; +/** Psychic aptitude, which sets the save-vs-psionics target (RUE p.346/348). */ +export const psychicClassSchema = z.enum(["masterPsychic", "majorOrMinorPsychic", "ordinary"]); +export type PsychicClass = z.infer; + /** * A built character — the player's *choices*. Derived stats (bonuses, attacks, * save targets, resolved skill %s, spell strength, …) are computed by @@ -34,8 +38,20 @@ export const characterSchema = z.object({ attributes: characterAttributesSchema, /** Hand-to-Hand combat type id (e.g. "basic"). */ hthType: z.string().min(1), - skills: z.array(characterSkillSchema).default([]), - spellIds: z.array(z.string().min(1)).default([]), + /** The character's psychic aptitude (sets the save-vs-psionics target). */ + psychicClass: psychicClassSchema.default("ordinary"), + skills: z + .array(characterSkillSchema) + .refine((arr) => new Set(arr.map((s) => s.skillId)).size === arr.length, { + message: "A skill cannot be taken twice (duplicate skillId).", + }) + .default([]), + spellIds: z + .array(z.string().min(1)) + .refine((arr) => new Set(arr).size === arr.length, { + message: "A spell cannot be known twice (duplicate spellId).", + }) + .default([]), rolled: z .object({ hitPoints: z.number().int().positive().optional(), @@ -44,4 +60,7 @@ export const characterSchema = z.object({ }) .optional(), }); +/** A fully-resolved character (defaulted fields present) — e.g. after parsing/from storage. */ export type Character = z.infer; +/** Character input for `deriveSheet` — defaulted fields (psychicClass/skills/spellIds) may be omitted. */ +export type CharacterInput = z.input; diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts index 8d9263e..0442ec9 100644 --- a/packages/rules/tests/character.test.ts +++ b/packages/rules/tests/character.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vite-plus/test"; -import { deriveSheet, type Character } from "../src/index.ts"; +import { deriveSheet, type CharacterInput } from "../src/index.ts"; -const leyLineWalker: Character = { +const leyLineWalker: CharacterInput = { name: "Vesper", occId: "ley-line-walker", level: 1, @@ -81,4 +81,35 @@ describe("deriveSheet — edge cases", () => { test("an unknown O.C.C. throws", () => { expect(() => deriveSheet({ ...leyLineWalker, occId: "nope" })).toThrow(/Unknown O\.C\.C\./); }); + + test("P.P.E. grows with level (+3D6 per level from level 2)", () => { + // level 3: base {64, 214, 139} + 2 * 3D6 {3, 18, 10.5} + expect(deriveSheet({ ...leyLineWalker, level: 3 }).ppe).toEqual({ + min: 70, + max: 250, + average: 160, + }); + }); + + test("save-vs-psionics target follows the character's psychic class", () => { + expect(deriveSheet(leyLineWalker).saves.psionics.target).toBe(15); // ordinary (default) + expect( + deriveSheet({ ...leyLineWalker, psychicClass: "masterPsychic" }).saves.psionics.target, + ).toBe(10); + }); + + test("duplicate skills or spells are rejected", () => { + expect(() => + deriveSheet({ + ...leyLineWalker, + skills: [ + { skillId: "math-basic", occBonus: 10 }, + { skillId: "math-basic", occBonus: 10 }, + ], + }), + ).toThrow(); + expect(() => + deriveSheet({ ...leyLineWalker, spellIds: ["globe-of-daylight", "globe-of-daylight"] }), + ).toThrow(); + }); });