diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts new file mode 100644 index 0000000..35947b7 --- /dev/null +++ b/packages/rules/src/engine/character.ts @@ -0,0 +1,164 @@ +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"; +import { deriveAttributeBonuses } from "./attributes.ts"; +import { + combatProfile, + comaDeathFloor, + hitPointsRange, + physicalSdcRange, + psionicsSaveTarget, + savingThrowTarget, + type StatRange, +} from "./combat.ts"; +import { getOcc, ppeRange } 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: CharacterInput): 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(character.psychicClass), + 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(ppeRange(occ, attrs.PE, level), character.rolled?.ppe) : undefined, + spellStrength: isCaster ? occSpellStrength(occ, level) : undefined, + saves, + skills, + spells: { known: knownSpells, count: knownSpells.length }, + }; +} 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/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..4e2c3a0 --- /dev/null +++ b/packages/rules/src/schema/character.ts @@ -0,0 +1,66 @@ +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; + +/** 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 + * `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), + /** 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(), + sdc: z.number().int().nonnegative().optional(), + ppe: z.number().int().nonnegative().optional(), + }) + .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 new file mode 100644 index 0000000..0442ec9 --- /dev/null +++ b/packages/rules/tests/character.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "vite-plus/test"; +import { deriveSheet, type CharacterInput } from "../src/index.ts"; + +const leyLineWalker: CharacterInput = { + 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\./); + }); + + 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(); + }); +});