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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/web/src/pages/character-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,24 @@ export function CharacterSheetPage() {
// back after the dossier switched characters is dropped.
const cast = async (spell: Spell) => {
const castFor = id();
// Exclusive either/or heals (Light Healing) need a pool choice: prefer
// the wounded pool (H.P. if down, else S.D.C.) of the character the heal
// LANDS on. Today that is always the caster (`sheet()`), and the only
// exclusive spell is also others-only, so the server refuses the cast
// before the pool matters — but when the VTT target picker arrives, this
// MUST read the TARGET's vitals instead.
let healPool: "hitPoints" | "sdc" | undefined;
if (spell.healing?.exclusive) {
const hp = sheet()?.vitals.hitPoints;
Comment thread
StreamDemon marked this conversation as resolved.
healPool =
hp?.rolled !== undefined && (hp.current ?? hp.rolled) < hp.rolled ? "hitPoints" : "sdc";
}
try {
const result = await castSpellMutation({ id: castFor, spellId: spell.id });
const result = await castSpellMutation({
id: castFor,
spellId: spell.id,
...(healPool !== undefined ? { healPool } : {}),
});
if (id() !== castFor) return;
// Healing spells report what actually landed (post-clamp), per pool.
const healed = result.healed
Expand Down
42 changes: 36 additions & 6 deletions packages/backend/convex/characters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,16 @@ function fullyMended(
* 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.
* `healPool` picks the pool for exclusive either/or spells (Light Healing);
* `othersOnly` spells refuse the caster as the target; `full` restorations
* (Restoration) top both pools up to their maximums.
*/
export const castSpell = mutation({
args: {
id: v.id("characters"),
spellId: v.string(),
targetId: v.optional(v.id("characters")),
healPool: v.optional(v.union(v.literal("hitPoints"), v.literal("sdc"))),
},
returns: v.object({
spent: v.number(),
Expand All @@ -224,7 +228,7 @@ export const castSpell = mutation({
v.object({ hitPoints: v.optional(v.number()), sdc: v.optional(v.number()) }),
),
}),
handler: async (ctx, { id, spellId, targetId }) => {
handler: async (ctx, { id, spellId, targetId, healPool }) => {
const character = await loadCharacter(ctx, id);
if (!character.spellIds.includes(spellId)) {
throw new Error(`Character does not know the spell "${spellId}".`);
Expand All @@ -238,6 +242,9 @@ export const castSpell = mutation({
if (spell.healing?.target === "self" && aimedAtOther) {
throw new Error(`${spell.name} only heals the caster.`);
}
if (spell.healing?.othersOnly && !aimedAtOther) {
throw new Error(`${spell.name} cannot be used on oneself.`);
}
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;
Expand All @@ -249,27 +256,50 @@ export const castSpell = mutation({
const remaining = available - spell.ppe;
const spentCurrent = { ...character.current, ppe: remaining };

const amounts = spell.healing ? rollSpellHealing(spell) : undefined;
if (amounts === undefined) {
// Throws for exclusive spells when no pool is chosen — before any write.
const roll = spell.healing ? rollSpellHealing(spell, Math.random, healPool) : undefined;
if (roll === undefined) {
await patchCurrent(ctx, id, character, spentCurrent);
return { spent: spell.ppe, ppe: { current: remaining, max } };
}
const report = (gained: { hitPoints: number; sdc: number }) => ({
// A full restoration becomes the exact top-up to the target's maximums
// (computed, not clamped: H.P. can sit below zero in the coma band).
const resolve = (c: Character): { hitPoints?: number; sdc?: number } => {
if (!roll.full) return roll;
const r = c.rolled;
if (r?.hitPoints === undefined || r.sdc === undefined) {
throw new Error("Cannot fully restore — vitals have not been rolled.");
}
return {
hitPoints: Math.max(0, r.hitPoints - (c.current?.hitPoints ?? r.hitPoints)),
sdc: Math.max(0, r.sdc - (c.current?.sdc ?? r.sdc)),
};
};
const report = (
amounts: { hitPoints?: number; sdc?: number },
gained: { hitPoints: number; sdc: number },
) => ({
...(amounts.hitPoints !== undefined ? { hitPoints: gained.hitPoints } : {}),
...(amounts.sdc !== undefined ? { sdc: gained.sdc } : {}),
});
if (!aimedAtOther) {
const amounts = resolve(character);
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) };
return {
spent: spell.ppe,
ppe: { current: remaining, max },
healed: report(amounts, 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 amounts = resolve(target);
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) };
return { spent: spell.ppe, ppe: { current: remaining, max }, healed: report(amounts, gained) };
},
});

Expand Down
195 changes: 118 additions & 77 deletions packages/backend/tests/healing-cast.test.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,11 @@
import { convexTest } from "convex-test";
import { describe, expect, test, vi } from "vite-plus/test";
import { describe, expect, test } 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<typeof import("@riftforge/rules")>();
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<typeof actual.deriveSheet>[0]) =>
actual.deriveSheet({
...input,
spellIds: input.spellIds?.filter((id) => !grafted.has(id)),
}),
};
});

// Real catalog content end-to-end (the #13 spell list): Heal Wounds (touch),
// Heal Self (self-only), Light Healing (exclusive either/or, others-only),
// Greater Healing (others-only), Restoration (full restoration).
const modules = {
...import.meta.glob("../convex/*.ts"),
...import.meta.glob("../convex/_generated/*.js"),
Expand All @@ -58,38 +19,41 @@ const vesper = {
hthType: "basic",
psychicClass: "ordinary" as const,
skills: [],
spellIds: ["energy-bolt"],
spellIds: [
"energy-bolt",
"heal-wounds",
"heal-self",
"light-healing",
"greater-healing",
"restoration",
],
};

/** A saved character with pinned vitals who knows the grafted healing spells. */
/** A saved caster with pinned vitals (P.P.E. 1000 affords even Restoration's 750). */
async function savedCaster(t: ReturnType<typeof convexTest>, 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"],
});
await ctx.db.patch(id, { rolled: { hitPoints: 18, sdc: 20, ppe: 1000 } });
});
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 () => {
describe("healing casts — real RUE spells through castSpell (#13)", () => {
test("Heal Wounds spends the caster's P.P.E. and heals the TARGET (3D6 S.D.C./1D6 H.P.)", 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",
spellId: "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.spent).toBe(10); // printed cost, RUE p.208
expect(result.ppe).toEqual({ current: 990, max: 1000 });
expect(result.healed?.hitPoints).toBeGreaterThanOrEqual(1);
expect(result.healed?.hitPoints).toBeLessThanOrEqual(6);
expect(result.healed?.sdc).toBeGreaterThanOrEqual(3);
expect(result.healed?.sdc).toBeLessThanOrEqual(18);

Expand All @@ -101,49 +65,45 @@ describe("healing casts — castSpell with a target (#41)", () => {
});
// ...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 });
expect(casterStored?.current).toEqual({ ppe: 990 });
});

test("healing clamps at the target's rolled maximums and reports the real gain", async () => {
test("healing clamps at the target's maximums and a full heal ends the treatment course", 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.
// One point down, mid-treatment-course: the cast must clamp AND end the course.
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",
spellId: "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);
expect(result.healed?.hitPoints).toBe(0); // was already full
expect(result.healed?.sdc).toBe(1); // post-clamp gain
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 () => {
test("Heal Self 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);
const result = await t.mutation(api.characters.castSpell, { id: caster, spellId: "heal-self" });
expect(result.spent).toBe(20); // printed cost, RUE p.212
expect(result.healed?.hitPoints).toBeGreaterThanOrEqual(1);
expect(result.healed?.sdc).toBeUndefined(); // spell declares no S.D.C.
expect(result.healed?.hitPoints).toBeLessThanOrEqual(6);
expect(result.healed?.sdc).toBeGreaterThanOrEqual(3);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
expect(result.healed?.sdc).toBeLessThanOrEqual(18);
const stored = await t.query(api.characters.get, { id: caster });
expect(stored?.current).toEqual({
ppe: 78,
sdc: 0,
ppe: 980,
sdc: 0 + result.healed!.sdc!,
hitPoints: 16 + result.healed!.hitPoints!,
});
});
Expand All @@ -155,12 +115,93 @@ describe("healing casts — castSpell with a target (#41)", () => {
await expect(
t.mutation(api.characters.castSpell, {
id: caster,
spellId: "test-self-mend",
spellId: "heal-self",
targetId: patient,
}),
).rejects.toThrow(/only heals the caster/);
});

test("others-only spells refuse the caster as target (Greater Healing, RUE p.215)", async () => {
const t = convexTest(schema, modules);
const caster = await savedCaster(t);
await expect(
t.mutation(api.characters.castSpell, { id: caster, spellId: "greater-healing" }),
).rejects.toThrow(/cannot be used on oneself/);

// Aimed at a patient it lands: 6D6 H.P. and 2D4x10 S.D.C., clamped.
const patient = await savedCaster(t, "Kestrel");
await t.mutation(api.characters.applyDamage, { id: patient, amount: 30 }); // sdc 0, hp 8
const result = await t.mutation(api.characters.castSpell, {
id: caster,
spellId: "greater-healing",
targetId: patient,
});
expect(result.spent).toBe(30);
const stored = await t.query(api.characters.get, { id: patient });
expect(stored?.current?.sdc).toBe(20); // 2D4x10 >= 20 — clamped at max
expect(stored?.current?.hitPoints).toBeGreaterThanOrEqual(14);
expect(stored?.current?.hitPoints).toBeLessThanOrEqual(18);
});

test("exclusive healing needs a chosen pool and rolls ONLY it (Light Healing, RUE p.203)", 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

// No pool chosen: refused before anything is spent.
await expect(
t.mutation(api.characters.castSpell, {
id: caster,
spellId: "light-healing",
targetId: patient,
}),
).rejects.toThrow(/choose hitPoints or sdc/);
expect((await t.query(api.characters.get, { id: caster }))?.current).toBeUndefined();

// S.D.C. chosen: 1D6 to S.D.C., nothing to H.P.
const result = await t.mutation(api.characters.castSpell, {
id: caster,
spellId: "light-healing",
targetId: patient,
healPool: "sdc",
});
expect(result.spent).toBe(6);
expect(result.healed?.hitPoints).toBeUndefined();
expect(result.healed?.sdc).toBeGreaterThanOrEqual(1);
expect(result.healed?.sdc).toBeLessThanOrEqual(6);
expect((await t.query(api.characters.get, { id: patient }))?.current?.hitPoints).toBe(13);

// And it is others-only: the caster cannot Light Heal themselves.
await expect(
t.mutation(api.characters.castSpell, {
id: caster,
spellId: "light-healing",
healPool: "sdc",
}),
).rejects.toThrow(/cannot be used on oneself/);
});

test("Restoration fully restores both pools — even out of the coma band (RUE p.224)", async () => {
const t = convexTest(schema, modules);
const caster = await savedCaster(t);
const patient = await savedCaster(t, "Kestrel");
await t.run(async (ctx) => {
await ctx.db.patch(patient, { current: { hitPoints: -10, sdc: 0, treatmentDays: 3 } });
});

const result = await t.mutation(api.characters.castSpell, {
id: caster,
spellId: "restoration",
targetId: patient,
});
expect(result.spent).toBe(750);
expect(result.healed).toEqual({ hitPoints: 28, sdc: 20 }); // -10 -> 18, 0 -> 20
const stored = await t.query(api.characters.get, { id: patient });
expect(stored?.current).toMatchObject({ hitPoints: 18, sdc: 20 });
expect(stored?.current?.treatmentDays).toBeUndefined(); // fully mended = course over
});

test("a cast that cannot land spends nothing (one transaction)", async () => {
const t = convexTest(schema, modules);
const caster = await savedCaster(t);
Expand All @@ -170,7 +211,7 @@ describe("healing casts — castSpell with a target (#41)", () => {
await expect(
t.mutation(api.characters.castSpell, {
id: caster,
spellId: "test-heal-wounds",
spellId: "heal-wounds",
targetId: patient,
}),
).rejects.toThrow(/has not been rolled/);
Expand Down
Loading
Loading