diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index d78e5ba..26cab30 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -9,7 +9,16 @@ import { type Spell, } from "@riftforge/rules"; import { useParams } from "@solidjs/router"; -import { createEffect, createSignal, Match, on, Show, Switch, type Accessor } from "solid-js"; +import { + createEffect, + createSignal, + Match, + on, + Show, + Switch, + type Accessor, + type JSX, +} from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { NarrativeFields } from "../components/narrative-fields.tsx"; import { SheetView, type SheetActions } from "../components/sheet-view.tsx"; @@ -90,6 +99,32 @@ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | u ); } +/** A pressed/unpressed mode chip (nexus, professional care). Cyan tone is + * for arcane context only, per the color rules. */ +function ToggleChip(props: { + pressed: boolean; + onToggle: () => void; + tone?: "ley" | "amber"; + children: JSX.Element; +}) { + return ( + + ); +} + /** * The dossier: the live sheet plus the field-telemetry rail. Gameplay rolls * (saves, skills, strikes, casts) run CLIENT-SIDE through the isomorphic @@ -107,9 +142,27 @@ export function CharacterSheetPage() { const castSpellMutation = createMutation(convex, api.characters.castSpell); const applyDamageMutation = createMutation(convex, api.characters.applyDamage); const restoreVitals = createMutation(convex, api.characters.restoreVitals); + const restMutation = createMutation(convex, api.characters.rest); + const leyLineDrawMutation = createMutation(convex, api.characters.leyLineDraw); + const treatMutation = createMutation(convex, api.characters.treat); const telemetry = createTelemetry(["// ley-link established", "// awaiting command…"]); const [rollError, setRollError] = createSignal(); const [damageInput, setDamageInput] = createSignal(""); + const [restHours, setRestHours] = createSignal(""); + const [atNexus, setAtNexus] = createSignal(false); + const [professional, setProfessional] = createSignal(false); + // The treatment-course position lives on the DOCUMENT (`current. + // treatmentDays`) — it survives reloads and clears with a full restore or + // vitals reroll. `dayInput` is the GM override: "" follows the stored + // course (next day = stored + 1); a typed value declares which course day + // this is (e.g. a new injury course). `treating` serializes clicks: two + // in-flight calls would apply the same day twice. + const [dayInput, setDayInput] = createSignal(""); + const [treating, setTreating] = createSignal(false); + // Monotonic token naming the request that holds the `treating` gate. Newer + // requests and route changes bump it, so a stale settle can never release a + // gate it no longer owns — not even back on the same dossier. + let treatToken = 0; // A new dossier starts with a fresh log: rolls belong to the character // they were rolled for, not whoever the page shows next. @@ -120,6 +173,12 @@ export function CharacterSheetPage() { telemetry.reset(); setRollError(undefined); setDamageInput(""); + setRestHours(""); + setAtNexus(false); + setProfessional(false); + setDayInput(""); + treatToken++; + setTreating(false); }, { defer: true }, ), @@ -142,8 +201,17 @@ export function CharacterSheetPage() { try { const result = await castSpellMutation({ id: castFor, spellId: spell.id }); if (id() !== castFor) return; + // Healing spells report what actually landed (post-clamp), per pool. + const healed = result.healed + ? ` → ${[ + result.healed.hitPoints !== undefined ? `H.P. +${result.healed.hitPoints}` : undefined, + result.healed.sdc !== undefined ? `S.D.C. +${result.healed.sdc}` : undefined, + ] + .filter(Boolean) + .join(" · ")}` + : ""; telemetry.log( - `> CAST :: ${spell.name.toUpperCase()} — ${result.spent} P.P.E. [${result.ppe.current}/${result.ppe.max}]`, + `> CAST :: ${spell.name.toUpperCase()} — ${result.spent} P.P.E. [${result.ppe.current}/${result.ppe.max}]${healed}`, "magic", ); } catch (error) { @@ -152,6 +220,86 @@ export function CharacterSheetPage() { } }; + // Recovery names TIME (hours, melees, days) — the server owns the rates and + // clamps at the rolled maximums; the log reports what actually landed. + const rest = async (mode: "rest" | "meditation") => { + const verb = mode === "meditation" ? "MEDITATE" : "REST"; + // Strict parse, like damage: "3.5" and "3abc" are refused, not truncated. + const raw = restHours().trim(); + const hours = Number(raw); + if (raw === "" || !Number.isInteger(hours) || hours <= 0) { + if (raw !== "") { + telemetry.log(`> ${verb} :: REFUSED (not a whole number of hours: "${raw}")`, "bad"); + } + return; + } + const restedFor = id(); + try { + const result = await restMutation({ id: restedFor, hours, mode }); + if (id() !== restedFor) return; + setRestHours(""); + telemetry.log( + `> ${verb} :: ${hours} HR — P.P.E. +${result.gained} [${result.ppe.current}/${result.ppe.max}]`, + "magic", + ); + } catch (error) { + if (id() !== restedFor) return; + telemetry.log(`> ${verb} :: REFUSED (${reason(error)})`, "bad"); + } + }; + + const leyDraw = async () => { + const drewFor = id(); + const nexus = atNexus(); + try { + const result = await leyLineDrawMutation({ id: drewFor, melees: 1, atNexus: nexus }); + if (id() !== drewFor) return; + telemetry.log( + `> LEY DRAW${nexus ? " (NEXUS)" : ""} :: +${result.gained} P.P.E. [${result.ppe.current}/${result.ppe.max}]`, + "magic", + ); + } catch (error) { + if (id() !== drewFor) return; + telemetry.log(`> LEY DRAW :: REFUSED (${reason(error)})`, "bad"); + } + }; + + const treatDay = async () => { + if (treating()) return; + // Strict parse, like damage/hours: only a whole positive day number counts + // as a GM override; an untouched input follows the stored course. + const raw = dayInput().trim(); + const override = raw === "" ? undefined : Number(raw); + if (override !== undefined && (!Number.isInteger(override) || override <= 0)) { + telemetry.log(`> TREATMENT :: REFUSED (not a whole day number: "${raw}")`, "bad"); + return; + } + setTreating(true); + const token = ++treatToken; + const treatedFor = id(); + const pro = professional(); + try { + const result = await treatMutation({ + id: treatedFor, + professional: pro, + ...(override !== undefined ? { day: override } : {}), + }); + if (id() !== treatedFor) return; + setDayInput(""); // back to following the stored course + telemetry.log( + `> TREATMENT :: DAY ${result.day}${pro ? " (PRO)" : ""} — H.P. +${result.gained.hitPoints} · S.D.C. +${result.gained.sdc}`, + "good", + ); + } catch (error) { + if (id() !== treatedFor) return; + telemetry.log(`> TREATMENT :: REFUSED (${reason(error)})`, "bad"); + } finally { + // Only the gate's current owner may release it: a stale settle must not + // unlock a treat a newer request has in flight. + if (token === treatToken) setTreating(false); + } + }; + const damage = async () => { // Strict parse: `Number` (unlike `parseInt`) makes "3.5" and "3abc" // fail the integer check instead of silently truncating to 3. @@ -274,6 +422,70 @@ export function CharacterSheetPage() { +
+ RECOVERY + +
+ setRestHours(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void rest("rest"); + }} + /> + + +
+
+ + setAtNexus((v) => !v)} + tone="ley" + > + Nexus + +
+
+
+ setDayInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void treatDay(); + }} + /> + + setProfessional((v) => !v)} + > + Pro + +
+
} /> diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index 202090d..f1bf32d 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -5,9 +5,13 @@ import { deriveSheet, getOcc, getSpell, + leyLineDraw as leyLineDrawAmount, + restRecovery, rollHitPoints, rollPhysicalSdc, rollPpe, + rollSpellHealing, + treatmentRecovery, type Character, } from "@riftforge/rules"; import { v } from "convex/values"; @@ -141,26 +145,99 @@ export const rollVitals = mutation({ }, }); +/** + * Add server-derived amounts into the live pools, clamped at the rolled + * maximums — the ONE heal path every recovery lands through (`heal`, `rest`, + * `treat`, `leyLineDraw`, healing casts). Returns the next `current` plus + * what each pool actually gained after clamping (what telemetry reports). + */ +function healPools( + character: Character, + amounts: { hitPoints?: number; sdc?: number; ppe?: number }, +): { + current: NonNullable; + gained: { hitPoints: number; sdc: number; ppe: number }; +} { + const current = { ...character.current }; + const gained = { hitPoints: 0, sdc: 0, ppe: 0 }; + for (const field of ["hitPoints", "sdc", "ppe"] as const) { + const amount = amounts[field]; + if (amount === undefined) continue; + if (!Number.isInteger(amount) || amount < 0) { + throw new Error(`heal.${field} must be a non-negative integer, got ${amount}.`); + } + const max = character.rolled?.[field]; + if (max === undefined) { + throw new Error(`Cannot heal ${field} — it has not been rolled.`); + } + const before = current[field] ?? max; + current[field] = Math.min(max, before + amount); + gained[field] = current[field] - before; + } + // A fully mended body ends the treatment course: once BOTH battle-injury + // pools sit at their rolled maximums, the day counter clears itself — the + // next treatment starts a new course at day 1. Sitting in the shared heal + // path, this covers every route to full: treatment, manual heals, and + // healing casts. + if (current.treatmentDays !== undefined && fullyMended(current, character.rolled)) { + delete current.treatmentDays; + } + return { current, gained }; +} + +/** Whether both battle-injury pools are at their rolled maximums. */ +function fullyMended( + current: NonNullable, + rolled: Character["rolled"], +): boolean { + return ( + rolled?.hitPoints !== undefined && + rolled.sdc !== undefined && + (current.hitPoints ?? rolled.hitPoints) === rolled.hitPoints && + (current.sdc ?? rolled.sdc) === rolled.sdc + ); +} + /** * Cast a known spell: decrement live P.P.E. by its printed cost. The cost is * derived server-side from the spell id — the client never names a price. * Rejects casts the character can't afford (the sheet greys those out, but * the server is the authority) and casts before vitals are rolled (no * maximum to spend from). + * + * Healing spells also roll their structured dice server-side (mutations get + * seeded randomness) and land them on the TARGET — `targetId` defaults to the + * caster — through the clamped heal path. Spend and heal are one transaction: + * a cast that can't land (unknown target, unrolled pools) spends nothing. */ export const castSpell = mutation({ - args: { id: v.id("characters"), spellId: v.string() }, + args: { + id: v.id("characters"), + spellId: v.string(), + targetId: v.optional(v.id("characters")), + }, returns: v.object({ spent: v.number(), ppe: v.object({ current: v.number(), max: v.number() }), + /** Post-clamp points the target actually recovered, per declared pool. */ + healed: v.optional( + v.object({ hitPoints: v.optional(v.number()), sdc: v.optional(v.number()) }), + ), }), - handler: async (ctx, { id, spellId }) => { + handler: async (ctx, { id, spellId, targetId }) => { const character = await loadCharacter(ctx, id); if (!character.spellIds.includes(spellId)) { throw new Error(`Character does not know the spell "${spellId}".`); } const spell = getSpell(spellId); if (!spell) throw new Error(`Unknown spell "${spellId}".`); + const aimedAtOther = targetId !== undefined && targetId !== id; + if (spell.healing === undefined && aimedAtOther) { + throw new Error(`${spell.name} has no healing effect to aim at another character.`); + } + if (spell.healing?.target === "self" && aimedAtOther) { + throw new Error(`${spell.name} only heals the caster.`); + } const max = character.rolled?.ppe; if (max === undefined) throw new Error("Roll vitals before casting — no P.P.E. to spend."); const available = character.current?.ppe ?? max; @@ -170,8 +247,29 @@ export const castSpell = mutation({ ); } const remaining = available - spell.ppe; - await patchCurrent(ctx, id, character, { ...character.current, ppe: remaining }); - return { spent: spell.ppe, ppe: { current: remaining, max } }; + const spentCurrent = { ...character.current, ppe: remaining }; + + const amounts = spell.healing ? rollSpellHealing(spell) : undefined; + if (amounts === undefined) { + await patchCurrent(ctx, id, character, spentCurrent); + return { spent: spell.ppe, ppe: { current: remaining, max } }; + } + const report = (gained: { hitPoints: number; sdc: number }) => ({ + ...(amounts.hitPoints !== undefined ? { hitPoints: gained.hitPoints } : {}), + ...(amounts.sdc !== undefined ? { sdc: gained.sdc } : {}), + }); + if (!aimedAtOther) { + const { current, gained } = healPools({ ...character, current: spentCurrent }, amounts); + await patchCurrent(ctx, id, character, current); + return { spent: spell.ppe, ppe: { current: remaining, max }, healed: report(gained) }; + } + // Cross-document: spend on the caster, heal the target — one transaction, + // the first table-shaped interaction (VTT groundwork). + const target = await loadCharacter(ctx, targetId); + const { current: targetCurrent, gained } = healPools(target, amounts); + await patchCurrent(ctx, id, character, spentCurrent); + await patchCurrent(ctx, targetId, target, targetCurrent); + return { spent: spell.ppe, ppe: { current: remaining, max }, healed: report(gained) }; }, }); @@ -201,9 +299,8 @@ export const applyDamage = mutation({ /** * Recover points into one or more pools, clamped at the rolled maximums. - * Amounts are how much to ADD (a first-aid roll, a rest tick) — full recovery - * is `restoreVitals`. Recovery *rates* (P.P.E. per hour of rest, ley-line - * draw) are rules-layer follow-up work on #38. + * Amounts are how much to ADD (a first-aid roll, a GM adjustment) — full + * recovery is `restoreVitals`; book *rates* are `rest`/`treat`/`leyLineDraw`. */ export const heal = mutation({ args: { @@ -215,24 +312,128 @@ export const heal = mutation({ returns: v.null(), handler: async (ctx, { id, ...amounts }) => { const character = await loadCharacter(ctx, id); - const current = { ...character.current }; - for (const field of ["hitPoints", "sdc", "ppe"] as const) { - const amount = amounts[field]; - if (amount === undefined) continue; - if (!Number.isInteger(amount) || amount < 0) { - throw new Error(`heal.${field} must be a non-negative integer, got ${amount}.`); - } - const max = character.rolled?.[field]; - if (max === undefined) { - throw new Error(`Cannot heal ${field} — it has not been rolled.`); - } - current[field] = Math.min(max, (current[field] ?? max) + amount); - } + const { current } = healPools(character, amounts); await patchCurrent(ctx, id, character, current); return null; }, }); +/** + * Rest or meditate for a number of hours, recovering P.P.E. at the printed + * rate (RUE p.186), honoring the O.C.C.'s own rates (the Ley Line Walker + * rests at 7/15 instead of the default 5/10). The client names TIME — hours + * are GM-adjudicated input, never a wall clock — and the server derives the + * points and lands them through the clamped heal path, never above the + * permanent base. + */ +export const rest = mutation({ + args: { + id: v.id("characters"), + hours: v.number(), + mode: v.union(v.literal("rest"), v.literal("meditation")), + }, + returns: v.object({ + gained: v.number(), + ppe: v.object({ current: v.number(), max: v.number() }), + }), + handler: async (ctx, { id, hours, mode }) => { + const character = await loadCharacter(ctx, id); + const occ = getOcc(character.occId); + if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); + const max = character.rolled?.ppe; + if (max === undefined) throw new Error("Roll vitals before resting — no P.P.E. to recover."); + // `restRecovery` rejects hours that aren't a whole, non-negative count. + const { current, gained } = healPools(character, { + ppe: restRecovery(hours, mode, occ), + }); + await patchCurrent(ctx, id, character, current); + return { gained: gained.ppe, ppe: { current: current.ppe ?? max, max } }; + }, +}); + +/** + * Draw P.P.E. from a ley line (or nexus) the character is standing at: + * the printed supplemental rate per melee round, honoring the O.C.C. + * override — the Ley Line Walker draws double (RUE p.186). + */ +export const leyLineDraw = mutation({ + args: { + id: v.id("characters"), + melees: v.number(), + atNexus: v.boolean(), + }, + returns: v.object({ + gained: v.number(), + ppe: v.object({ current: v.number(), max: v.number() }), + }), + handler: async (ctx, { id, melees, atNexus }) => { + const character = await loadCharacter(ctx, id); + const occ = getOcc(character.occId); + if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); + if (occ.ppe === undefined) { + throw new Error(`${occ.name} is not a practitioner of magic — no ley line draw.`); + } + const max = character.rolled?.ppe; + if (max === undefined) throw new Error("Roll vitals before drawing — no P.P.E. to recover."); + // `leyLineDrawAmount` rejects melees that aren't a whole, non-negative count. + const { current, gained } = healPools(character, { + ppe: leyLineDrawAmount(melees, atNexus, occ), + }); + await patchCurrent(ctx, id, character, current); + return { gained: gained.ppe, ppe: { current: current.ppe ?? max, max } }; + }, +}); + +/** + * One day of battle-injury treatment (RUE p.354): H.P. and S.D.C. recover at + * the printed daily rates. Professional care ramps (2 H.P./day for the first + * two days of the course, then 4), so the course position is PERSISTED — + * `current.treatmentDays` counts the days already applied, and each call + * advances it. A full restore or vitals reroll clears it with the rest of + * `current` (fresh pools, fresh course). + * + * `day` is the GM override: which course day this is (1-based). Omitted, it's + * the stored counter + 1. When a new course starts within one set of pools is + * GM adjudication — the override is how they say so. + */ +export const treat = mutation({ + args: { + id: v.id("characters"), + professional: v.boolean(), + day: v.optional(v.number()), + }, + returns: v.object({ + day: v.number(), + gained: v.object({ hitPoints: v.number(), sdc: v.number() }), + hitPoints: v.object({ current: v.number(), max: v.number() }), + sdc: v.object({ current: v.number(), max: v.number() }), + }), + handler: async (ctx, { id, professional, day }) => { + const character = await loadCharacter(ctx, id); + const rolled = character.rolled; + if (rolled?.hitPoints === undefined || rolled.sdc === undefined) { + throw new Error("Roll vitals before treatment — no pools to recover."); + } + const courseDay = day ?? (character.current?.treatmentDays ?? 0) + 1; + if (!Number.isInteger(courseDay) || courseDay < 1) { + throw new Error(`Treatment day must be a positive whole number, got ${courseDay}.`); + } + // `treatmentRecovery` re-checks the counts; rates come from the content. + const amounts = treatmentRecovery(1, professional, courseDay - 1); + const { current, gained } = healPools(character, amounts); + // Record the day just applied — unless this very day completed the mend, + // in which case the course is over and the counter stays cleared. + const next = fullyMended(current, rolled) ? current : { ...current, treatmentDays: courseDay }; + await patchCurrent(ctx, id, character, next); + return { + day: courseDay, + gained: { hitPoints: gained.hitPoints, sdc: gained.sdc }, + hitPoints: { current: current.hitPoints ?? rolled.hitPoints, max: rolled.hitPoints }, + sdc: { current: current.sdc ?? rolled.sdc, max: rolled.sdc }, + }; + }, +}); + /** Reset every pool to its rolled maximum (absent `current` means "full"). */ export const restoreVitals = mutation({ args: { id: v.id("characters") }, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index d617663..1290065 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -46,12 +46,14 @@ export const characterFields = { ppe: v.optional(v.number()), }), ), - /** Live resource state (damage taken, P.P.E. spent); absent = at maximum. */ + /** Live resource state (damage taken, P.P.E. spent, treatment-course + * position); absent = at maximum / no course underway. */ current: v.optional( v.object({ hitPoints: v.optional(v.number()), sdc: v.optional(v.number()), ppe: v.optional(v.number()), + treatmentDays: v.optional(v.number()), }), ), narrative: v.optional( diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 2c29944..1c87a12 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -306,6 +306,168 @@ describe("living vitals — current vs. max (#38)", () => { expect(stored?.current).toBeUndefined(); }); + test("rest recovers P.P.E. at the O.C.C.'s printed hourly rate (#41)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.run(async (ctx) => { + await ctx.db.patch(id, { current: { ppe: 20 } }); + }); + + // Ley Line Walker rests at 7/hour (not the book default 5). + expect(await t.mutation(api.characters.rest, { id, hours: 4, mode: "rest" })).toEqual({ + gained: 28, + ppe: { current: 48, max: 84 }, + }); + // ...and meditates at 15/hour, clamped at the permanent base. + expect(await t.mutation(api.characters.rest, { id, hours: 3, mode: "meditation" })).toEqual({ + gained: 36, + ppe: { current: 84, max: 84 }, + }); + + await expect(t.mutation(api.characters.rest, { id, hours: -1, mode: "rest" })).rejects.toThrow( + /non-negative integer/, + ); + await expect(t.mutation(api.characters.rest, { id, hours: 1.5, mode: "rest" })).rejects.toThrow( + /non-negative integer/, + ); + }); + + test("rest before vitals are rolled is refused", async () => { + const t = convexTest(schema, modules); + const id = await t.mutation(api.characters.create, vesper); + await expect(t.mutation(api.characters.rest, { id, hours: 1, mode: "rest" })).rejects.toThrow( + /Roll vitals/, + ); + }); + + test("leyLineDraw pulls the doubled Walker rate per melee (#41)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.run(async (ctx) => { + await ctx.db.patch(id, { current: { ppe: 10 } }); + }); + + // One melee on the line: LLW draws 20 (double the practitioner's 10). + expect(await t.mutation(api.characters.leyLineDraw, { id, melees: 1, atNexus: false })).toEqual( + { gained: 20, ppe: { current: 30, max: 84 } }, + ); + // At a nexus the Walker pulls 40 — and the draw clamps at the base. + expect(await t.mutation(api.characters.leyLineDraw, { id, melees: 2, atNexus: true })).toEqual({ + gained: 54, + ppe: { current: 84, max: 84 }, + }); + + await expect( + t.mutation(api.characters.leyLineDraw, { id, melees: 0.5, atNexus: false }), + ).rejects.toThrow(/non-negative integer/); + }); + + test("treat recovers at the printed daily rates and persists the course day (#41)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.applyDamage, { id, amount: 30 }); // sdc 0, hp 8 + + // Day 1, non-professional: 2 H.P., 4 S.D.C. — and the course day is stored. + expect(await t.mutation(api.characters.treat, { id, professional: false })).toEqual({ + day: 1, + gained: { hitPoints: 2, sdc: 4 }, + hitPoints: { current: 10, max: 18 }, + sdc: { current: 4, max: 20 }, + }); + expect((await t.query(api.characters.get, { id }))?.current?.treatmentDays).toBe(1); + + // The stored counter drives the professional ramp: day 2 still pays 2 H.P... + const day2 = await t.mutation(api.characters.treat, { id, professional: true }); + expect(day2.day).toBe(2); + expect(day2.gained).toEqual({ hitPoints: 2, sdc: 6 }); + // ...and day 3 ramps to 4 H.P. — surviving "reloads" because it's the doc, + // not the page, that remembers. + const day3 = await t.mutation(api.characters.treat, { id, professional: true }); + expect(day3.day).toBe(3); + expect(day3.gained).toEqual({ hitPoints: 4, sdc: 6 }); + + // The sheet exposes the course position for the UI. + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.vitals.treatmentDays).toBe(3); + }); + + test("treat accepts an explicit GM day override and restores reset the course", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.applyDamage, { id, amount: 30 }); // sdc 0, hp 8 + await t.mutation(api.characters.treat, { id, professional: true }); // day 1 + await t.mutation(api.characters.treat, { id, professional: true }); // day 2 + + // GM declares a new injury course: back to the ramp's first day. + const override = await t.mutation(api.characters.treat, { id, professional: true, day: 1 }); + expect(override.day).toBe(1); + expect(override.gained.hitPoints).toBe(2); // ramp start, not the day-3 rate + expect((await t.query(api.characters.get, { id }))?.current?.treatmentDays).toBe(1); + + await expect( + t.mutation(api.characters.treat, { id, professional: true, day: 0 }), + ).rejects.toThrow(/positive whole number/); + await expect( + t.mutation(api.characters.treat, { id, professional: true, day: 2.5 }), + ).rejects.toThrow(/positive whole number/); + + // Full restore clears the course with the pools (fresh pools, fresh course). + await t.mutation(api.characters.restoreVitals, { id }); + expect((await t.query(api.characters.get, { id }))?.current).toBeUndefined(); + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.vitals.treatmentDays).toBe(0); + }); + + test("healing to full ends the treatment course automatically", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + + // A treat that completes the mend clears the counter in the same write. + await t.mutation(api.characters.applyDamage, { id, amount: 3 }); // sdc 17, hp 18 + const final = await t.mutation(api.characters.treat, { id, professional: false }); + expect(final.day).toBe(1); // the day still happened (telemetry reports it) + expect(final.sdc).toEqual({ current: 20, max: 20 }); + expect((await t.query(api.characters.get, { id }))?.current?.treatmentDays).toBeUndefined(); + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.vitals.treatmentDays).toBe(0); + + // A partial heal leaves the course running... + await t.mutation(api.characters.applyDamage, { id, amount: 30 }); // sdc 0, hp 8 + await t.mutation(api.characters.treat, { id, professional: true }); // day 1 + await t.mutation(api.characters.heal, { id, sdc: 5 }); + expect((await t.query(api.characters.get, { id }))?.current?.treatmentDays).toBe(1); + + // ...and ANY route to full ends it — here a plain heal, not a treat. + await t.mutation(api.characters.heal, { id, hitPoints: 999, sdc: 999 }); + expect((await t.query(api.characters.get, { id }))?.current?.treatmentDays).toBeUndefined(); + }); + + test("treat before vitals are rolled is refused", async () => { + const t = convexTest(schema, modules); + const id = await t.mutation(api.characters.create, vesper); + await expect(t.mutation(api.characters.treat, { id, professional: false })).rejects.toThrow( + /Roll vitals/, + ); + }); + + test("castSpell refuses to aim a non-healing spell at another character (#41)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + const other = await t.mutation(api.characters.create, { ...vesper, name: "Kestrel" }); + + await expect( + t.mutation(api.characters.castSpell, { id, spellId: "energy-bolt", targetId: other }), + ).rejects.toThrow(/no healing effect/); + + // An explicit self-target on a non-healing spell is just a normal cast. + const result = await t.mutation(api.characters.castSpell, { + id, + spellId: "energy-bolt", + targetId: id, + }); + expect(result).toEqual({ spent: 5, ppe: { current: 79, max: 84 } }); + }); + test("illegal `current` states are rejected at every write", async () => { const t = convexTest(schema, modules); // Above the maximum on create. diff --git a/packages/backend/tests/healing-cast.test.ts b/packages/backend/tests/healing-cast.test.ts new file mode 100644 index 0000000..2b192ee --- /dev/null +++ b/packages/backend/tests/healing-cast.test.ts @@ -0,0 +1,181 @@ +import { convexTest } from "convex-test"; +import { describe, expect, test, vi } from "vite-plus/test"; +import { api } from "../convex/_generated/api"; +import schema from "../convex/schema"; + +// The level 1-4 catalog has no healing spell yet (real ones — Heal Wounds L6 +// et al. — arrive with #13), so this file grafts synthetic ones onto the +// catalog to exercise castSpell's healing paths end-to-end: `getSpell` serves +// them, and `deriveSheet` ignores them so validation-on-write still passes. +// Everything else is the real rules layer. When #13 lands real healing +// spells, these tests can switch to content and drop the mock. +vi.mock("@riftforge/rules", async (importOriginal) => { + const actual = await importOriginal(); + const touchHeal = actual.spellSchema.parse({ + id: "test-heal-wounds", + name: "Test Heal Wounds", + level: 6, + ppe: 10, + range: "Touch", + duration: "Instant", + savingThrow: "none", + healing: { hitPoints: "2D6", sdc: "3D6", target: "touch" }, + page: 1, + }); + const selfHeal = actual.spellSchema.parse({ + ...touchHeal, + id: "test-self-mend", + name: "Test Self Mend", + ppe: 6, + range: "Self", + healing: { hitPoints: "1D6", target: "self" }, + }); + const grafted = new Map([ + [touchHeal.id, touchHeal], + [selfHeal.id, selfHeal], + ]); + return { + ...actual, + getSpell: (id: string) => grafted.get(id) ?? actual.getSpell(id), + deriveSheet: (input: Parameters[0]) => + actual.deriveSheet({ + ...input, + spellIds: input.spellIds?.filter((id) => !grafted.has(id)), + }), + }; +}); + +const modules = { + ...import.meta.glob("../convex/*.ts"), + ...import.meta.glob("../convex/_generated/*.js"), +}; + +const vesper = { + 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", + psychicClass: "ordinary" as const, + skills: [], + spellIds: ["energy-bolt"], +}; + +/** A saved character with pinned vitals who knows the grafted healing spells. */ +async function savedCaster(t: ReturnType, name = "Vesper") { + const id = await t.mutation(api.characters.create, { ...vesper, name }); + await t.run(async (ctx) => { + await ctx.db.patch(id, { + rolled: { hitPoints: 18, sdc: 20, ppe: 84 }, + spellIds: [...vesper.spellIds, "test-heal-wounds", "test-self-mend"], + }); + }); + return id; +} + +describe("healing casts — castSpell with a target (#41)", () => { + test("a touch heal spends the caster's P.P.E. and heals the TARGET", async () => { + const t = convexTest(schema, modules); + const caster = await savedCaster(t); + const patient = await savedCaster(t, "Kestrel"); + await t.mutation(api.characters.applyDamage, { id: patient, amount: 25 }); // sdc 0, hp 13 + + const result = await t.mutation(api.characters.castSpell, { + id: caster, + spellId: "test-heal-wounds", + targetId: patient, + }); + expect(result.spent).toBe(10); + expect(result.ppe).toEqual({ current: 74, max: 84 }); + // 2D6 H.P. / 3D6 S.D.C., rolled server-side. + expect(result.healed?.hitPoints).toBeGreaterThanOrEqual(2); + expect(result.healed?.hitPoints).toBeLessThanOrEqual(12); + expect(result.healed?.sdc).toBeGreaterThanOrEqual(3); + expect(result.healed?.sdc).toBeLessThanOrEqual(18); + + // The reported gains are exactly what landed on the patient's pools... + const stored = await t.query(api.characters.get, { id: patient }); + expect(stored?.current).toMatchObject({ + hitPoints: 13 + result.healed!.hitPoints!, + sdc: 0 + result.healed!.sdc!, + }); + // ...and the caster's own pools were only touched by the spend. + const casterStored = await t.query(api.characters.get, { id: caster }); + expect(casterStored?.current).toEqual({ ppe: 74 }); + }); + + test("healing clamps at the target's rolled maximums and reports the real gain", async () => { + const t = convexTest(schema, modules); + const caster = await savedCaster(t); + const patient = await savedCaster(t, "Kestrel"); + await t.mutation(api.characters.applyDamage, { id: patient, amount: 1 }); // sdc 19, hp 18 + // A treatment course underway: the full heal below must end it. + await t.run(async (ctx) => { + await ctx.db.patch(patient, { current: { hitPoints: 18, sdc: 19, treatmentDays: 2 } }); + }); + + const result = await t.mutation(api.characters.castSpell, { + id: caster, + spellId: "test-heal-wounds", + targetId: patient, + }); + // H.P. was already full; S.D.C. was 1 short — gains are post-clamp. + expect(result.healed?.hitPoints).toBe(0); + expect(result.healed?.sdc).toBe(1); + const stored = await t.query(api.characters.get, { id: patient }); + expect(stored?.current).toMatchObject({ hitPoints: 18, sdc: 20 }); + // Fully mended by the cast → the treatment course ended with it. + expect(stored?.current?.treatmentDays).toBeUndefined(); + }); + + test("targetId defaults to the caster: spend and heal in one write", async () => { + const t = convexTest(schema, modules); + const caster = await savedCaster(t); + await t.mutation(api.characters.applyDamage, { id: caster, amount: 22 }); // sdc 0, hp 16 + + const result = await t.mutation(api.characters.castSpell, { + id: caster, + spellId: "test-self-mend", + }); + expect(result.spent).toBe(6); + expect(result.healed?.hitPoints).toBeGreaterThanOrEqual(1); + expect(result.healed?.sdc).toBeUndefined(); // spell declares no S.D.C. + const stored = await t.query(api.characters.get, { id: caster }); + expect(stored?.current).toEqual({ + ppe: 78, + sdc: 0, + hitPoints: 16 + result.healed!.hitPoints!, + }); + }); + + test("a self-only spell cannot be aimed at another character", async () => { + const t = convexTest(schema, modules); + const caster = await savedCaster(t); + const patient = await savedCaster(t, "Kestrel"); + await expect( + t.mutation(api.characters.castSpell, { + id: caster, + spellId: "test-self-mend", + targetId: patient, + }), + ).rejects.toThrow(/only heals the caster/); + }); + + test("a cast that cannot land spends nothing (one transaction)", async () => { + const t = convexTest(schema, modules); + const caster = await savedCaster(t); + // A patient with no rolled vitals: the heal path has no maximums to clamp to. + const patient = await t.mutation(api.characters.create, { ...vesper, name: "Unrolled" }); + + await expect( + t.mutation(api.characters.castSpell, { + id: caster, + spellId: "test-heal-wounds", + targetId: patient, + }), + ).rejects.toThrow(/has not been rolled/); + // The caster's P.P.E. is untouched — spend and heal are atomic. + const stored = await t.query(api.characters.get, { id: caster }); + expect(stored?.current).toBeUndefined(); + }); +}); diff --git a/packages/rules/src/content/combat/recovery.json b/packages/rules/src/content/combat/recovery.json new file mode 100644 index 0000000..28937c2 --- /dev/null +++ b/packages/rules/src/content/combat/recovery.json @@ -0,0 +1,34 @@ +{ + "ppe": { + "source": { + "book": "Rifts Ultimate Edition", + "page": 186, + "table": "Recovery of P.P.E." + }, + "perHourRest": 5, + "perHourMeditation": 10, + "leyLineDraw": { + "perMeleeOnLine": 10, + "perMeleeAtNexus": 20, + "notes": "Most practitioners of magic can draw an additional 10 P.P.E. per melee round while standing on a ley line, 20 at a nexus point. O.C.C.s may override (the Ley Line Walker draws double: 20/40 via supplementalOnLeyLinePerMelee/supplementalAtNexusPerMelee). Special-time multipliers (dawn, equinox, solstice, eclipses) are GM territory and out of scope." + }, + "notes": "P.P.E. recovers at roughly 5 points per hour of rest or sleep; meditation restores 10 per hour and an hour of meditation counts as an hour of restorative sleep. Never above the permanent base. O.C.C.s may override the rates (recoveryPerHourRest/recoveryPerHourMeditation)." + }, + "treatment": { + "source": { + "book": "Rifts Ultimate Edition", + "page": 354, + "table": "Battle Injuries & Recovery" + }, + "nonProfessional": { + "hitPointsPerDay": 2, + "sdcPerDay": 4 + }, + "professional": { + "hitPointsPerDayFirstTwoDays": 2, + "hitPointsPerDayAfter": 4, + "sdcPerDay": 6 + }, + "notes": "Recovery from battle injuries, per day of treatment. Non-professional: 2 H.P. and 4 S.D.C. per day. Professional (hospital/medical care): 2 H.P. per day for the first two days, then 4 per day; S.D.C. at 6 per day. Optional Blood Loss and coma resuscitation rules are out of scope." + } +} diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts index 8fc309e..0f5803c 100644 --- a/packages/rules/src/engine/character.ts +++ b/packages/rules/src/engine/character.ts @@ -52,7 +52,13 @@ export interface CharacterSheet { dodge: number; damageBonus: number; }; - vitals: { hitPoints: StatValue; sdc: StatValue; comaDeathFloor: number }; + vitals: { + hitPoints: StatValue; + sdc: StatValue; + comaDeathFloor: number; + /** Days of battle-injury treatment already applied this course. */ + treatmentDays: number; + }; /** Present for spell-casting O.C.C.s. */ ppe?: StatValue; spellStrength?: number; @@ -210,6 +216,7 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { ), sdc: withRolled(physicalSdcRange(), character.rolled?.sdc, character.current?.sdc), comaDeathFloor: floor, + treatmentDays: character.current?.treatmentDays ?? 0, }, ppe: occ.ppe ? withRolled(ppeRange(occ, attrs.PE, level), character.rolled?.ppe, character.current?.ppe) diff --git a/packages/rules/src/engine/recovery.ts b/packages/rules/src/engine/recovery.ts new file mode 100644 index 0000000..57ff6e2 --- /dev/null +++ b/packages/rules/src/engine/recovery.ts @@ -0,0 +1,85 @@ +import type { Occ } from "../schema/occ.ts"; +import { recoverySchema, type RestMode } from "../schema/recovery.ts"; +import recoveryRaw from "../content/combat/recovery.json" with { type: "json" }; + +export const recovery = recoverySchema.parse(recoveryRaw); + +/** Elapsed time is GM-adjudicated *input* (hours rested, melees on the line, + * days treated) — it must be a whole, non-negative count. */ +function requireCount(name: string, value: number): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer, got ${value}.`); + } +} + +/** + * P.P.E. recovered per hour of rest or meditation (RUE p.186: ~5 resting, + * 10 meditating), honoring the O.C.C.'s own printed rates when it has them + * (e.g. the Ley Line Walker rests at 7/15). + */ +export function ppeRecoveryRate(mode: RestMode, occ?: Occ): number { + return mode === "meditation" + ? (occ?.ppe?.recoveryPerHourMeditation ?? recovery.ppe.perHourMeditation) + : (occ?.ppe?.recoveryPerHourRest ?? recovery.ppe.perHourRest); +} + +/** + * Raw P.P.E. recovered over `hours` of rest or meditation. Unclamped — the + * "never above the permanent base" rule lives where live pools are written + * (the backend's heal path), like all clamping. + */ +export function restRecovery(hours: number, mode: RestMode, occ?: Occ): number { + requireCount("hours", hours); + return hours * ppeRecoveryRate(mode, occ); +} + +/** + * Supplemental P.P.E. a practitioner of magic draws per melee round while + * standing on a ley line (or at a nexus), honoring the O.C.C. override — + * the Ley Line Walker draws double (RUE p.186). + */ +export function leyLineDrawRate(atNexus: boolean, occ?: Occ): number { + return atNexus + ? (occ?.ppe?.supplementalAtNexusPerMelee ?? recovery.ppe.leyLineDraw.perMeleeAtNexus) + : (occ?.ppe?.supplementalOnLeyLinePerMelee ?? recovery.ppe.leyLineDraw.perMeleeOnLine); +} + +/** Raw P.P.E. drawn over `melees` rounds on a ley line or at a nexus. Unclamped. */ +export function leyLineDraw(melees: number, atNexus: boolean, occ?: Occ): number { + requireCount("melees", melees); + return melees * leyLineDrawRate(atNexus, occ); +} + +export interface TreatmentRecovery { + hitPoints: number; + sdc: number; +} + +/** + * Raw H.P./S.D.C. recovered over `days` of treatment (RUE p.354). Professional + * care ramps — 2 H.P. per day for the first two days of the course, then 4 — + * so *which* days these are matters: `daysAlreadyTreated` says how far into + * the course the character already is. Non-professional care is flat + * (2 H.P. / 4 S.D.C. per day). Unclamped, like the other rates. + */ +export function treatmentRecovery( + days: number, + professional: boolean, + daysAlreadyTreated = 0, +): TreatmentRecovery { + requireCount("days", days); + requireCount("daysAlreadyTreated", daysAlreadyTreated); + const t = recovery.treatment; + if (!professional) { + return { + hitPoints: days * t.nonProfessional.hitPointsPerDay, + sdc: days * t.nonProfessional.sdcPerDay, + }; + } + // How many of these days fall inside the two-day ramp-up window. + const rampDays = Math.max(0, Math.min(2 - daysAlreadyTreated, days)); + const hitPoints = + rampDays * t.professional.hitPointsPerDayFirstTwoDays + + (days - rampDays) * t.professional.hitPointsPerDayAfter; + return { hitPoints, sdc: days * t.professional.sdcPerDay }; +} diff --git a/packages/rules/src/engine/spells.ts b/packages/rules/src/engine/spells.ts index a0ab8bf..f73686e 100644 --- a/packages/rules/src/engine/spells.ts +++ b/packages/rules/src/engine/spells.ts @@ -1,6 +1,7 @@ import type { Occ } from "../schema/occ.ts"; import { spellBookSchema, type Spell } from "../schema/spells.ts"; import spellsRaw from "../content/spells/spells.json" with { type: "json" }; +import { rollDice, type Rng } from "./dice.ts"; /** The spell book (RUE Magic Spells), validated at load. */ export const spellBook = spellBookSchema.parse(spellsRaw); @@ -42,6 +43,26 @@ export function canCast(spell: Spell, availablePpe: number): boolean { return availablePpe >= spell.ppe; } +/** Concrete points a healing cast restores (per-pool dice, rolled). */ +export interface HealingRoll { + hitPoints?: number; + sdc?: number; +} + +/** + * Roll a spell's structured healing dice; `undefined` when the spell doesn't + * heal. Raw amounts — clamping at the target's rolled maximums happens where + * live pools are written (the backend's heal path). + */ +export function rollSpellHealing(spell: Spell, rng: Rng = Math.random): HealingRoll | undefined { + const h = spell.healing; + if (!h) return undefined; + return { + ...(h.hitPoints !== undefined ? { hitPoints: rollDice(h.hitPoints, rng) } : {}), + ...(h.sdc !== undefined ? { sdc: rollDice(h.sdc, rng) } : {}), + }; +} + /** * A caster's Spell Strength (RUE p.187): base 12, plus one for each experience * level (<= the caster's level) at which their O.C.C. grants a Spell Strength diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index e5261b3..65ed90a 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -2,6 +2,7 @@ export * from "./schema/alignments.ts"; export * from "./schema/attributes.ts"; export * from "./schema/occ.ts"; export * from "./schema/combat.ts"; +export * from "./schema/recovery.ts"; export * from "./schema/skills.ts"; export * from "./schema/spells.ts"; export * from "./schema/character.ts"; @@ -10,6 +11,7 @@ export * from "./engine/attributes.ts"; export * from "./engine/dice.ts"; export * from "./engine/occ.ts"; export * from "./engine/combat.ts"; +export * from "./engine/recovery.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 index 02f24bd..07cff33 100644 --- a/packages/rules/src/schema/character.ts +++ b/packages/rules/src/schema/character.ts @@ -115,6 +115,12 @@ export const characterSchema = z.object({ hitPoints: z.number().int().optional(), sdc: z.number().int().nonnegative().optional(), ppe: z.number().int().nonnegative().optional(), + /** Days of battle-injury treatment already applied this course (drives + * the professional 2-then-4 ramp, RUE p.354). Lives in `current` on + * purpose: a full restore or vitals reroll clears it — fresh pools, + * fresh course. When a NEW course starts within one set of pools is + * GM adjudication (the treat mutation's explicit `day` override). */ + treatmentDays: z.number().int().nonnegative().optional(), }) .optional(), /** Optional player-authored identity; passed through to the sheet untouched. */ diff --git a/packages/rules/src/schema/recovery.ts b/packages/rules/src/schema/recovery.ts new file mode 100644 index 0000000..d48485b --- /dev/null +++ b/packages/rules/src/schema/recovery.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import { sourceRefSchema } from "./attributes.ts"; + +/** + * Book-default recovery rates. P.P.E. rates are *defaults*: an O.C.C. may + * override them via its `ppe` block (`recoveryPerHourRest`, + * `recoveryPerHourMeditation`, `supplementalOnLeyLinePerMelee`, + * `supplementalAtNexusPerMelee`) — e.g. the Ley Line Walker rests faster and + * draws double from ley lines. + */ +export const recoverySchema = z.object({ + ppe: z.object({ + source: sourceRefSchema, + /** P.P.E. recovered per hour of ordinary rest or sleep. */ + perHourRest: z.number().nonnegative(), + /** P.P.E. recovered per hour of meditation. */ + perHourMeditation: z.number().nonnegative(), + /** Supplemental draw for practitioners of magic standing on a ley line. */ + leyLineDraw: z.object({ + perMeleeOnLine: z.number().nonnegative(), + perMeleeAtNexus: z.number().nonnegative(), + notes: z.string().optional(), + }), + notes: z.string().optional(), + }), + treatment: z.object({ + source: sourceRefSchema, + nonProfessional: z.object({ + hitPointsPerDay: z.number().nonnegative(), + sdcPerDay: z.number().nonnegative(), + }), + professional: z.object({ + hitPointsPerDayFirstTwoDays: z.number().nonnegative(), + hitPointsPerDayAfter: z.number().nonnegative(), + sdcPerDay: z.number().nonnegative(), + }), + notes: z.string().optional(), + }), +}); +export type Recovery = z.infer; + +/** How the character spends the recovery hours: ordinary rest/sleep, or meditation. */ +export const restModeSchema = z.enum(["rest", "meditation"]); +export type RestMode = z.infer; diff --git a/packages/rules/src/schema/spells.ts b/packages/rules/src/schema/spells.ts index d6f6b98..b92c14e 100644 --- a/packages/rules/src/schema/spells.ts +++ b/packages/rules/src/schema/spells.ts @@ -1,4 +1,27 @@ import { z } from "zod"; +import { diceFormulaSchema } from "./dice.ts"; + +/** Who a healing spell can restore, per its printed "Range" line. */ +export const healingTargetKindSchema = z.enum(["self", "touch", "ranged"]); +export type HealingTargetKind = z.infer; + +/** + * Structured healing effect. The printed `damage` stays a display string, but + * healing must be *applied* by the engine (rolled server-side, landed through + * the clamped heal path), so its dice are structured and load-validated. + */ +export const spellHealingSchema = z + .object({ + /** Hit Points restored (dice formula, e.g. "2D6"). */ + hitPoints: diceFormulaSchema.optional(), + /** S.D.C. restored (dice formula). */ + sdc: diceFormulaSchema.optional(), + target: healingTargetKindSchema, + }) + .refine((h) => h.hitPoints !== undefined || h.sdc !== undefined, { + message: "A healing effect must restore hitPoints, sdc, or both.", + }); +export type SpellHealing = z.infer; /** How a target resists a spell, per the spell's "Saving Throw" line. */ export const savingThrowKindSchema = z.enum([ @@ -27,6 +50,9 @@ export const spellSchema = z.object({ savingThrowNote: z.string().optional(), /** Damage expression as printed, if the spell deals damage. */ damage: z.string().optional(), + /** Structured healing effect, if the spell restores H.P./S.D.C. + * (None in the level 1-4 catalog; arrives with the level 5-15 spells.) */ + healing: spellHealingSchema.optional(), description: z.string().optional(), page: z.number().int().positive(), }); diff --git a/packages/rules/tests/recovery.test.ts b/packages/rules/tests/recovery.test.ts new file mode 100644 index 0000000..752c840 --- /dev/null +++ b/packages/rules/tests/recovery.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + leyLineDraw, + leyLineDrawRate, + leyLineWalker, + ppeRecoveryRate, + recovery, + restRecovery, + rollSpellHealing, + spellHealingSchema, + spellSchema, + treatmentRecovery, + type Spell, +} from "../src/index.ts"; + +describe("recovery rates content (RUE pp.186/354)", () => { + test("carries the printed P.P.E. rates, page-stamped", () => { + expect(recovery.ppe.source.page).toBe(186); + expect(recovery.ppe.perHourRest).toBe(5); + expect(recovery.ppe.perHourMeditation).toBe(10); + expect(recovery.ppe.leyLineDraw).toMatchObject({ + perMeleeOnLine: 10, + perMeleeAtNexus: 20, + }); + }); + + test("carries the printed treatment rates, page-stamped", () => { + expect(recovery.treatment.source.page).toBe(354); + expect(recovery.treatment.nonProfessional).toEqual({ hitPointsPerDay: 2, sdcPerDay: 4 }); + expect(recovery.treatment.professional).toEqual({ + hitPointsPerDayFirstTwoDays: 2, + hitPointsPerDayAfter: 4, + sdcPerDay: 6, + }); + }); +}); + +describe("P.P.E. recovery — rest and meditation", () => { + test("book-default rates apply when the O.C.C. has no override", () => { + expect(ppeRecoveryRate("rest")).toBe(5); + expect(ppeRecoveryRate("meditation")).toBe(10); + expect(restRecovery(6, "rest")).toBe(30); + expect(restRecovery(3, "meditation")).toBe(30); + }); + + test("the Ley Line Walker's printed rates override the defaults (7/15)", () => { + expect(ppeRecoveryRate("rest", leyLineWalker)).toBe(7); + expect(ppeRecoveryRate("meditation", leyLineWalker)).toBe(15); + expect(restRecovery(4, "meditation", leyLineWalker)).toBe(60); + }); + + test("hours must be a whole, non-negative count (GM-adjudicated input)", () => { + expect(restRecovery(0, "rest")).toBe(0); + expect(() => restRecovery(-1, "rest")).toThrow(/non-negative integer/); + expect(() => restRecovery(1.5, "meditation")).toThrow(/non-negative integer/); + }); +}); + +describe("P.P.E. recovery — ley line draw", () => { + test("default practitioner draw: 10 per melee on a line, 20 at a nexus", () => { + expect(leyLineDrawRate(false)).toBe(10); + expect(leyLineDrawRate(true)).toBe(20); + expect(leyLineDraw(3, false)).toBe(30); + }); + + test("the Ley Line Walker draws double (20/40)", () => { + expect(leyLineDrawRate(false, leyLineWalker)).toBe(20); + expect(leyLineDrawRate(true, leyLineWalker)).toBe(40); + expect(leyLineDraw(2, true, leyLineWalker)).toBe(80); + }); + + test("melees must be a whole, non-negative count", () => { + expect(() => leyLineDraw(-2, false)).toThrow(/non-negative integer/); + expect(() => leyLineDraw(0.5, true)).toThrow(/non-negative integer/); + }); +}); + +describe("H.P./S.D.C. recovery — treatment days", () => { + test("non-professional care is flat: 2 H.P. and 4 S.D.C. per day", () => { + expect(treatmentRecovery(1, false)).toEqual({ hitPoints: 2, sdc: 4 }); + expect(treatmentRecovery(5, false)).toEqual({ hitPoints: 10, sdc: 20 }); + // The non-professional rate never ramps, no matter how deep into the course. + expect(treatmentRecovery(3, false, 10)).toEqual({ hitPoints: 6, sdc: 12 }); + }); + + test("professional care ramps: 2 H.P./day for two days, then 4; S.D.C. at 6", () => { + expect(treatmentRecovery(1, true)).toEqual({ hitPoints: 2, sdc: 6 }); + expect(treatmentRecovery(2, true)).toEqual({ hitPoints: 4, sdc: 12 }); + expect(treatmentRecovery(3, true)).toEqual({ hitPoints: 8, sdc: 18 }); // 2+2+4 + expect(treatmentRecovery(5, true)).toEqual({ hitPoints: 16, sdc: 30 }); // 2+2+4+4+4 + }); + + test("daysAlreadyTreated places the days inside the ramp", () => { + // Day 3 of an ongoing professional course heals at the ramped rate. + expect(treatmentRecovery(1, true, 2)).toEqual({ hitPoints: 4, sdc: 6 }); + // Starting mid-ramp: day 2 heals 2, day 3 heals 4. + expect(treatmentRecovery(2, true, 1)).toEqual({ hitPoints: 6, sdc: 12 }); + }); + + test("days and daysAlreadyTreated must be whole, non-negative counts", () => { + expect(treatmentRecovery(0, true)).toEqual({ hitPoints: 0, sdc: 0 }); + expect(() => treatmentRecovery(-1, false)).toThrow(/non-negative integer/); + expect(() => treatmentRecovery(2, true, -1)).toThrow(/non-negative integer/); + expect(() => treatmentRecovery(1.5, true)).toThrow(/non-negative integer/); + }); +}); + +describe("spell healing — schema and rolls", () => { + /** A synthetic healing spell (the L1-4 catalog has none; real ones land with #13). */ + const healWounds: Spell = spellSchema.parse({ + id: "test-heal-wounds", + name: "Test Heal Wounds", + level: 6, + ppe: 10, + range: "Touch", + duration: "Instant", + savingThrow: "none", + healing: { hitPoints: "1D6", sdc: "2D6", target: "touch" }, + page: 1, + }); + + test("healing must restore at least one pool, with valid dice", () => { + expect(() => spellHealingSchema.parse({ target: "self" })).toThrow(); + expect(() => spellHealingSchema.parse({ hitPoints: "banana", target: "self" })).toThrow(); + expect(spellHealingSchema.parse({ sdc: "3D6", target: "ranged" })).toMatchObject({ + sdc: "3D6", + }); + }); + + test("rollSpellHealing rolls each declared pool", () => { + const minRng = () => 0; // every die rolls 1 + expect(rollSpellHealing(healWounds, minRng)).toEqual({ hitPoints: 1, sdc: 2 }); + const maxRng = () => 0.999999; // every die rolls its max + expect(rollSpellHealing(healWounds, maxRng)).toEqual({ hitPoints: 6, sdc: 12 }); + }); + + test("rolls only the pools the spell declares", () => { + const hpOnly = spellSchema.parse({ + ...healWounds, + id: "test-hp-only", + name: "Test HP Only", + healing: { hitPoints: "2D4", target: "self" }, + }); + expect(rollSpellHealing(hpOnly, () => 0)).toEqual({ hitPoints: 2 }); + }); + + test("non-healing spells roll nothing", () => { + const bolt = spellSchema.parse({ + ...healWounds, + id: "test-bolt", + name: "Test Bolt", + healing: undefined, + }); + expect(rollSpellHealing(bolt)).toBeUndefined(); + }); +});