From d3f7691232bee39b4ca7d4fa7c4be26515c72d14 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 6 Jul 2026 14:22:56 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20recovery=20rates=20&=20healing=20sp?= =?UTF-8?q?ells=20=E2=80=94=20rest/meditation/ley=20draw,=20treatment=20da?= =?UTF-8?q?ys,=20spell=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules: page-stamped recovery.json (RUE pp.186/354), restRecovery/ leyLineDraw/treatmentRecovery engine fns with O.C.C. overrides, structured spell healing field + rollSpellHealing. Backend: rest/treat/leyLineDraw mutations (client names time, server owns rates), castSpell targetId with cross-document heals in one transaction, shared clamped heal path. Web: RECOVERY group in the telemetry rail (rest/meditate, ley draw with nexus toggle, treatment day with professional toggle). --- apps/web/src/pages/character-sheet.tsx | 179 ++++++++++++++- packages/backend/convex/characters.ts | 207 ++++++++++++++++-- packages/backend/tests/characters.test.ts | 110 ++++++++++ packages/backend/tests/healing-cast.test.ts | 175 +++++++++++++++ .../rules/src/content/combat/recovery.json | 34 +++ packages/rules/src/engine/recovery.ts | 85 +++++++ packages/rules/src/engine/spells.ts | 21 ++ packages/rules/src/index.ts | 2 + packages/rules/src/schema/recovery.ts | 44 ++++ packages/rules/src/schema/spells.ts | 26 +++ packages/rules/tests/recovery.test.ts | 156 +++++++++++++ 11 files changed, 1017 insertions(+), 22 deletions(-) create mode 100644 packages/backend/tests/healing-cast.test.ts create mode 100644 packages/rules/src/content/combat/recovery.json create mode 100644 packages/rules/src/engine/recovery.ts create mode 100644 packages/rules/src/schema/recovery.ts create mode 100644 packages/rules/tests/recovery.test.ts diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index d78e5ba..3f40327 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,19 @@ 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); + // Which treatment day the NEXT click is — the professional ramp (2 H.P./day + // for two days, then 4) depends on it. Session-local: the GM adjudicates + // elapsed time, the page just counts clicks for this sitting. + const [treatedDays, setTreatedDays] = createSignal(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 +165,10 @@ export function CharacterSheetPage() { telemetry.reset(); setRollError(undefined); setDamageInput(""); + setRestHours(""); + setAtNexus(false); + setProfessional(false); + setTreatedDays(0); }, { defer: true }, ), @@ -142,8 +191,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 +210,73 @@ 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 () => { + const treatedFor = id(); + const pro = professional(); + const day = treatedDays() + 1; + try { + const result = await treatMutation({ + id: treatedFor, + days: 1, + professional: pro, + daysAlreadyTreated: day - 1, + }); + if (id() !== treatedFor) return; + setTreatedDays(day); + telemetry.log( + `> TREATMENT :: DAY ${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"); + } + }; + 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 +399,56 @@ export function CharacterSheetPage() { +
+ RECOVERY + +
+ setRestHours(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void rest("rest"); + }} + /> + + +
+
+ + setAtNexus((v) => !v)} + tone="ley" + > + Nexus + +
+
+
+ + setProfessional((v) => !v)} + > + Pro + +
+
} /> diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index 202090d..c4cdd9c 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,78 @@ 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; + } + return { current, gained }; +} + /** * 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 +226,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 +278,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 +291,115 @@ 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 } }; + }, +}); + +/** + * A stretch of treatment days for battle injuries (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 client + * says how deep into the course the character already is — days are + * GM-adjudicated input, like all elapsed time. + */ +export const treat = mutation({ + args: { + id: v.id("characters"), + days: v.number(), + professional: v.boolean(), + daysAlreadyTreated: v.optional(v.number()), + }, + returns: v.object({ + 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, days, professional, daysAlreadyTreated }) => { + 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."); + } + // `treatmentRecovery` rejects day counts that aren't whole and non-negative. + const amounts = treatmentRecovery(days, professional, daysAlreadyTreated ?? 0); + const { current, gained } = healPools(character, amounts); + await patchCurrent(ctx, id, character, current); + return { + 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/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 2c29944..ef6bd66 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -306,6 +306,116 @@ 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 H.P./S.D.C. at the printed daily rates (#41)", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.applyDamage, { id, amount: 25 }); // sdc 0, hp 13 + + // One day of non-professional care: 2 H.P., 4 S.D.C. + expect(await t.mutation(api.characters.treat, { id, days: 1, professional: false })).toEqual({ + gained: { hitPoints: 2, sdc: 4 }, + hitPoints: { current: 15, max: 18 }, + sdc: { current: 4, max: 20 }, + }); + + // Two more days, professional, already 2 days into the course: the ramp + // pays 4 H.P./day, S.D.C. 6/day — clamped at the rolled maximums. + expect( + await t.mutation(api.characters.treat, { + id, + days: 2, + professional: true, + daysAlreadyTreated: 2, + }), + ).toEqual({ + gained: { hitPoints: 3, sdc: 12 }, + hitPoints: { current: 18, max: 18 }, + sdc: { current: 16, max: 20 }, + }); + }); + + 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, days: 1, 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..c26633f --- /dev/null +++ b/packages/backend/tests/healing-cast.test.ts @@ -0,0 +1,175 @@ +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 + + 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 }); + }); + + 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/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/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(); + }); +}); From 5a68feaff9c5a46c8d89c3a225f0926e7491e805 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 6 Jul 2026 14:35:06 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(web):=20serialize=20Treatment=20Day=20c?= =?UTF-8?q?licks=20=E2=80=94=20in-flight=20guard=20so=20the=20ramp=20day?= =?UTF-8?q?=20number=20can't=20double-apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/character-sheet.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 3f40327..c307af6 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -153,8 +153,11 @@ export function CharacterSheetPage() { const [professional, setProfessional] = createSignal(false); // Which treatment day the NEXT click is — the professional ramp (2 H.P./day // for two days, then 4) depends on it. Session-local: the GM adjudicates - // elapsed time, the page just counts clicks for this sitting. + // elapsed time, the page just counts clicks for this sitting. `treating` + // serializes the clicks: two in-flight calls would both read the same day + // number and apply it twice. const [treatedDays, setTreatedDays] = createSignal(0); + const [treating, setTreating] = createSignal(false); // A new dossier starts with a fresh log: rolls belong to the character // they were rolled for, not whoever the page shows next. @@ -255,6 +258,8 @@ export function CharacterSheetPage() { }; const treatDay = async () => { + if (treating()) return; + setTreating(true); const treatedFor = id(); const pro = professional(); const day = treatedDays() + 1; @@ -274,6 +279,8 @@ export function CharacterSheetPage() { } catch (error) { if (id() !== treatedFor) return; telemetry.log(`> TREATMENT :: REFUSED (${reason(error)})`, "bad"); + } finally { + setTreating(false); } }; From f2ff9d9aa4cc00bae6f2231aba58b91ca3948d0e Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 6 Jul 2026 14:39:27 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(web):=20treatment=20gate=20is=20per-dos?= =?UTF-8?q?sier=20=E2=80=94=20reset=20on=20route=20change,=20stale=20settl?= =?UTF-8?q?es=20can't=20release=20the=20new=20dossier's=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/character-sheet.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index c307af6..124435a 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -172,6 +172,7 @@ export function CharacterSheetPage() { setAtNexus(false); setProfessional(false); setTreatedDays(0); + setTreating(false); }, { defer: true }, ), @@ -280,7 +281,10 @@ export function CharacterSheetPage() { if (id() !== treatedFor) return; telemetry.log(`> TREATMENT :: REFUSED (${reason(error)})`, "bad"); } finally { - setTreating(false); + // Only release the gate for the dossier that took it: the route-change + // effect already unblocked the next character, and a stale settle must + // not unlock a treat the NEW dossier has in flight. + if (id() === treatedFor) setTreating(false); } }; From da1994679b030e27b02730f060c772eeeba24883 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 6 Jul 2026 14:44:04 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(web):=20treatment=20gate=20released=20b?= =?UTF-8?q?y=20token,=20not=20dossier=20id=20=E2=80=94=20closes=20the=20le?= =?UTF-8?q?ave-and-return=20stale-settle=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/character-sheet.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 124435a..1cff59e 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -158,6 +158,10 @@ export function CharacterSheetPage() { // number and apply it twice. const [treatedDays, setTreatedDays] = createSignal(0); 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. @@ -172,6 +176,7 @@ export function CharacterSheetPage() { setAtNexus(false); setProfessional(false); setTreatedDays(0); + treatToken++; setTreating(false); }, { defer: true }, @@ -261,6 +266,7 @@ export function CharacterSheetPage() { const treatDay = async () => { if (treating()) return; setTreating(true); + const token = ++treatToken; const treatedFor = id(); const pro = professional(); const day = treatedDays() + 1; @@ -281,10 +287,9 @@ export function CharacterSheetPage() { if (id() !== treatedFor) return; telemetry.log(`> TREATMENT :: REFUSED (${reason(error)})`, "bad"); } finally { - // Only release the gate for the dossier that took it: the route-change - // effect already unblocked the next character, and a stale settle must - // not unlock a treat the NEW dossier has in flight. - if (id() === treatedFor) setTreating(false); + // 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); } }; From b08d7a26fe66044b3114337f42665aa92065c84e Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 6 Jul 2026 15:22:07 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20persist=20the=20treatment=20course?= =?UTF-8?q?=20on=20the=20character=20=E2=80=94=20server-authoritative=20da?= =?UTF-8?q?y=20counter=20+=20GM=20day=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit current.treatmentDays drives the professional ramp and survives reloads; restoreVitals/rollVitals clear it with the pools (fresh pools, fresh course). treat() reads and advances the stored counter; optional day arg is the GM override for declaring a new course. The rail's day counter is now an editable DAY input synced to the stored course. --- apps/web/src/pages/character-sheet.tsx | 47 +++++++++++---- packages/backend/convex/characters.ts | 32 ++++++---- packages/backend/convex/schema.ts | 4 +- packages/backend/tests/characters.test.ts | 72 ++++++++++++++++------- packages/rules/src/engine/character.ts | 9 ++- packages/rules/src/schema/character.ts | 6 ++ 6 files changed, 122 insertions(+), 48 deletions(-) diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 1cff59e..26cab30 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -151,12 +151,13 @@ export function CharacterSheetPage() { const [restHours, setRestHours] = createSignal(""); const [atNexus, setAtNexus] = createSignal(false); const [professional, setProfessional] = createSignal(false); - // Which treatment day the NEXT click is — the professional ramp (2 H.P./day - // for two days, then 4) depends on it. Session-local: the GM adjudicates - // elapsed time, the page just counts clicks for this sitting. `treating` - // serializes the clicks: two in-flight calls would both read the same day - // number and apply it twice. - const [treatedDays, setTreatedDays] = createSignal(0); + // 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 @@ -175,7 +176,7 @@ export function CharacterSheetPage() { setRestHours(""); setAtNexus(false); setProfessional(false); - setTreatedDays(0); + setDayInput(""); treatToken++; setTreating(false); }, @@ -265,22 +266,28 @@ export function CharacterSheetPage() { 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(); - const day = treatedDays() + 1; try { const result = await treatMutation({ id: treatedFor, - days: 1, professional: pro, - daysAlreadyTreated: day - 1, + ...(override !== undefined ? { day: override } : {}), }); if (id() !== treatedFor) return; - setTreatedDays(day); + setDayInput(""); // back to following the stored course telemetry.log( - `> TREATMENT :: DAY ${day}${pro ? " (PRO)" : ""} — H.P. +${result.gained.hitPoints} · S.D.C. +${result.gained.sdc}`, + `> TREATMENT :: DAY ${result.day}${pro ? " (PRO)" : ""} — H.P. +${result.gained.hitPoints} · S.D.C. +${result.gained.sdc}`, "good", ); } catch (error) { @@ -454,8 +461,22 @@ export function CharacterSheetPage() {
+ setDayInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void treatDay(); + }} + /> { + 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."); } - // `treatmentRecovery` rejects day counts that aren't whole and non-negative. - const amounts = treatmentRecovery(days, professional, daysAlreadyTreated ?? 0); + 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); - await patchCurrent(ctx, id, character, current); + await patchCurrent(ctx, id, character, { ...current, treatmentDays: courseDay }); 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 }, 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 ef6bd66..4a397fb 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -362,40 +362,68 @@ describe("living vitals — current vs. max (#38)", () => { ).rejects.toThrow(/non-negative integer/); }); - test("treat recovers H.P./S.D.C. at the printed daily rates (#41)", async () => { + 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: 25 }); // sdc 0, hp 13 + await t.mutation(api.characters.applyDamage, { id, amount: 30 }); // sdc 0, hp 8 - // One day of non-professional care: 2 H.P., 4 S.D.C. - expect(await t.mutation(api.characters.treat, { id, days: 1, professional: false })).toEqual({ + // 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: 15, max: 18 }, + 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); + }); - // Two more days, professional, already 2 days into the course: the ramp - // pays 4 H.P./day, S.D.C. 6/day — clamped at the rolled maximums. - expect( - await t.mutation(api.characters.treat, { - id, - days: 2, - professional: true, - daysAlreadyTreated: 2, - }), - ).toEqual({ - gained: { hitPoints: 3, sdc: 12 }, - hitPoints: { current: 18, max: 18 }, - sdc: { current: 16, max: 20 }, - }); + 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("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, days: 1, professional: false }), - ).rejects.toThrow(/Roll vitals/); + 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 () => { 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/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. */ From 9c949bed89241792975e20041ea667bb85872623 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 6 Jul 2026 15:34:04 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20fully=20mended=20ends=20the=20treat?= =?UTF-8?q?ment=20course=20=E2=80=94=20day=20counter=20self-clears=20when?= =?UTF-8?q?=20H.P.=20and=20S.D.C.=20reach=20their=20maximums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lives in the shared heal path, so every route to full ends the course: the completing treatment itself, manual heals, and healing casts. --- packages/backend/convex/characters.ts | 26 ++++++++++++++++++++- packages/backend/tests/characters.test.ts | 24 +++++++++++++++++++ packages/backend/tests/healing-cast.test.ts | 6 +++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index cb5f75b..f1bf32d 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -174,9 +174,30 @@ function healPools( 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. @@ -400,7 +421,10 @@ export const treat = mutation({ // `treatmentRecovery` re-checks the counts; rates come from the content. const amounts = treatmentRecovery(1, professional, courseDay - 1); const { current, gained } = healPools(character, amounts); - await patchCurrent(ctx, id, character, { ...current, treatmentDays: courseDay }); + // 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 }, diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 4a397fb..1c87a12 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -418,6 +418,30 @@ describe("living vitals — current vs. max (#38)", () => { 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); diff --git a/packages/backend/tests/healing-cast.test.ts b/packages/backend/tests/healing-cast.test.ts index c26633f..2b192ee 100644 --- a/packages/backend/tests/healing-cast.test.ts +++ b/packages/backend/tests/healing-cast.test.ts @@ -109,6 +109,10 @@ describe("healing casts — castSpell with a target (#41)", () => { 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, @@ -120,6 +124,8 @@ describe("healing casts — castSpell with a target (#41)", () => { 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 () => {