Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions packages/rules/src/engine/character.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
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<string, SheetSave>;
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<string, SheetSave> = {
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 },
};
}
20 changes: 14 additions & 6 deletions packages/rules/src/engine/occ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/rules/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
66 changes: 66 additions & 0 deletions packages/rules/src/schema/character.ts
Original file line number Diff line number Diff line change
@@ -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<typeof characterSkillSchema>;

/** 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<typeof characterAttributesSchema>;

/** 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<typeof psychicClassSchema>;

/**
* 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<typeof characterSchema>;
/** Character input for `deriveSheet` — defaulted fields (psychicClass/skills/spellIds) may be omitted. */
export type CharacterInput = z.input<typeof characterSchema>;
115 changes: 115 additions & 0 deletions packages/rules/tests/character.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading