diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 26cab30..bc440ca 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -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; + 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 diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index f1bf32d..5f721c6 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -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(), @@ -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}".`); @@ -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; @@ -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) }; }, }); diff --git a/packages/backend/tests/healing-cast.test.ts b/packages/backend/tests/healing-cast.test.ts index 2b192ee..565e004 100644 --- a/packages/backend/tests/healing-cast.test.ts +++ b/packages/backend/tests/healing-cast.test.ts @@ -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(); - 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)), - }), - }; -}); - +// 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"), @@ -58,23 +19,27 @@ 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, 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"); @@ -82,14 +47,13 @@ describe("healing casts — castSpell with a target (#41)", () => { 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); @@ -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); + 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!, }); }); @@ -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); @@ -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/); diff --git a/packages/rules/src/content/spells/spells.json b/packages/rules/src/content/spells/spells.json index 7bc75fb..2366ae7 100644 --- a/packages/rules/src/content/spells/spells.json +++ b/packages/rules/src/content/spells/spells.json @@ -114,7 +114,6 @@ "description": "A booming thunderclap. Caster gets +5 initiative, +1 strike/parry/dodge; creates Horror Factor 8.", "page": 199 }, - { "id": "befuddle", "name": "Befuddle", @@ -137,6 +136,84 @@ "description": "Blend into surroundings: 90% undetectable if still, less while moving; ineffective if moving fast.", "page": 199 }, + { + "id": "cleanse", + "name": "Cleanse", + "level": 2, + "ppe": 6, + "range": "Self, one person up to 10 ft (3 m) away, or two people by touch", + "duration": "Instant", + "savingThrow": "none", + "description": "Magically removes dirt, grime, and stains from a living being and the clothes they wear.", + "page": 200 + }, + { + "id": "climb", + "name": "Climb", + "level": 2, + "ppe": 3, + "range": "Self, or others up to 40 ft (12.2 m) away", + "duration": "5 minutes (20 melees) per level", + "savingThrow": "none", + "description": "Climb with inhuman skill: 98% on climbable surfaces, 60% on smooth ones, rappel at 90%.", + "page": 200 + }, + { + "id": "cloak-of-darkness", + "name": "Cloak of Darkness", + "level": 2, + "ppe": 6, + "range": "Self plus a 5 ft (1.5 m) radius around the character", + "duration": "4 minutes per level", + "savingThrow": "none", + "description": "A field of darkness follows the caster; attackers beyond it are -3 to strike.", + "page": 200 + }, + { + "id": "concealment", + "name": "Concealment", + "level": 2, + "ppe": 6, + "range": "Small objects up to 40 ft (12.2 m) away", + "duration": "5 minutes (20 melees) per level", + "savingThrow": "standard", + "description": "Hides one small non-living object (under 14 inches and 14 lbs) from all who fail to save.", + "page": 200 + }, + { + "id": "detect-concealment", + "name": "Detect Concealment", + "level": 2, + "ppe": 6, + "range": "Area affect: 30 ft (9.1 m)", + "duration": "Instant", + "savingThrow": "none", + "description": "Instantly negates Concealment spells and reveals mystically concealed objects.", + "page": 200 + }, + { + "id": "extinguish-fire", + "name": "Extinguish Fire", + "level": 2, + "ppe": 4, + "range": "20 ft (6.1 m) radius, cast up to 80 ft (24.4 m) away +10 ft (3 m) per level", + "duration": "1 minute (4 melee rounds) per level", + "savingThrow": "none", + "description": "Instantly puts out fires; extinguishes up to 40 ft (12.2 m) per melee round.", + "page": 200 + }, + { + "id": "fear", + "name": "Fear", + "level": 2, + "ppe": 5, + "range": "20 ft (6.1 m) diameter, up to 100 ft (30.5 m) away", + "duration": "1 minute (4 melee rounds) per level", + "savingThrow": "horrorFactor", + "savingThrowNote": "Save vs Horror Factor 16", + "description": "Washes an area in terror: failed saves are momentarily stunned, lose initiative and one melee attack.", + "page": 200 + }, { "id": "heavy-breathing", "name": "Heavy Breathing", @@ -160,6 +237,19 @@ "description": "Raise self/others/an object straight up and hover; up to 200 lbs +20 lbs per level.", "page": 201 }, + { + "id": "manipulate-objects", + "name": "Manipulate Objects", + "level": 2, + "ppe": 2, + "ppeNote": "Two P.P.E. per five pounds (2.3 kg) of combined object weight", + "range": "50 ft (15.2 m) +10 ft (3 m) per level; line of sight", + "duration": "2 minutes (8 melee rounds) per level", + "savingThrow": "none", + "savingThrowNote": "No save for inanimate objects; living beings are immune", + "description": "Telekinetically hold and carry small objects: two per level, 10 lbs (4.5 kg) each, magic P.S. 8 +1 per level.", + "page": 201 + }, { "id": "turn-dead", "name": "Turn Dead", @@ -171,7 +261,6 @@ "description": "Turns/repels 1D6 animated dead per level for 24 hours. Not vampires/zombies/possessed corpses.", "page": 201 }, - { "id": "armor-of-ithan", "name": "Armor of Ithan", @@ -274,7 +363,102 @@ "description": "Temporarily impervious to poisons, venom, toxins, pollution, and poison gas.", "page": 202 }, - + { + "id": "invisibility-simple", + "name": "Invisibility: Simple", + "level": 3, + "ppe": 6, + "range": "Self only (includes clothes and articles on one's person)", + "duration": "3 minutes (12 melees) per level", + "savingThrow": "none", + "description": "The caster turns completely invisible (retains mass and sound); -9 to be hit, -3 once cut.", + "page": 203 + }, + { + "id": "life-source", + "name": "Life Source", + "level": 3, + "ppe": 2, + "ppeNote": "2 P.P.E. plus sacrificed life force: two S.D.C. or one Hit Point per P.P.E. point gained", + "range": "Self", + "duration": "Instant", + "savingThrow": "none", + "description": "Convert the caster's own S.D.C./Hit Points into P.P.E. for casting; painful and potentially lethal.", + "page": 203 + }, + { + "id": "light-healing", + "name": "Light Healing", + "level": 3, + "ppe": 6, + "range": "Touch", + "duration": "Instant", + "savingThrow": "none", + "healing": { + "hitPoints": "1D4", + "sdc": "1D6", + "target": "touch", + "exclusive": true, + "othersOnly": true + }, + "description": "Channels healing energy by touch: restores 1D6 S.D.C. or 1D4 Hit Points (not both). Cannot be used on oneself.", + "page": 203 + }, + { + "id": "magic-shield", + "name": "Magic Shield", + "level": 3, + "ppe": 6, + "range": "Self or other", + "duration": "2 minutes per level", + "savingThrow": "none", + "description": "A pale energy shield with 60 M.D.C.: +1 to parry melee attacks, -8 to parry blasts and projectiles.", + "page": 203 + }, + { + "id": "mystic-fulcrum", + "name": "Mystic Fulcrum", + "level": 3, + "ppe": 5, + "range": "Self or two others by touch", + "duration": "5 minutes per level", + "savingThrow": "none", + "description": "The enchanted can lift 50% more weight and carry 10% more, moving loads they'd otherwise need leverage for.", + "page": 203 + }, + { + "id": "negate-poison-toxin", + "name": "Negate Poison/Toxin", + "level": 3, + "ppe": 5, + "range": "Self or by touch", + "duration": "Instant", + "savingThrow": "none", + "description": "Turns a poisonous substance inert or negates poison in the bloodstream; prior damage is not reversed.", + "page": 203 + }, + { + "id": "paralysis-lesser", + "name": "Paralysis: Lesser", + "level": 3, + "ppe": 5, + "range": "60 ft (18.3 m)", + "duration": "1 minute (4 melees) per level", + "savingThrow": "standard", + "description": "Paralyzes one of the victim's limbs per casting; no effect inside vehicles, robots, or M.D.C. armor.", + "page": 203 + }, + { + "id": "resist-fire", + "name": "Resist Fire", + "level": 3, + "ppe": 6, + "range": "Self or one or two others, up to 60 ft (18.3 m)", + "duration": "20 melees per level", + "savingThrow": "none", + "description": "Heat has no ill effect; normal, magical, and Mega-Damage fire all do half damage.", + "page": 204 + }, { "id": "blind", "name": "Blind", @@ -331,6 +515,1310 @@ "damage": "2D6 M.D.", "description": "A crackling bolt of blue energy; +2 to strike. Each blast counts as one melee attack.", "page": 204 + }, + { + "id": "energy-field", + "name": "Energy Field", + "level": 4, + "ppe": 10, + "range": "Self or others up to 60 ft (18.3 m) away", + "duration": "1 minute (4 melees) per level, or until destroyed", + "savingThrow": "none", + "description": "A shimmering protective bubble (8 ft area) with 60 M.D.C.; doubled on a ley line, tripled at a nexus.", + "page": 205 + }, + { + "id": "fire-bolt", + "name": "Fire Bolt", + "level": 4, + "ppe": 7, + "range": "100 ft (30.5 m) plus 5 ft (1.5 m) per level", + "duration": "Instant", + "damage": "4D6 M.D. or 1D6x10 S.D.C. (caster's choice)", + "savingThrow": "dodge", + "description": "A directed bolt of magic fire, +4 to strike.", + "page": 205 + }, + { + "id": "fist-of-fury", + "name": "Fist of Fury", + "level": 4, + "ppe": 10, + "ppeNote": "Ten to cast on oneself or fifty to cast upon another", + "range": "Self or one person by touch", + "duration": "1 melee round per level", + "damage": "Varies with P.S.: punches as Supernatural P.S., 1D6 M.D. minimum", + "savingThrow": "none", + "description": "The dominant hand glows with fierce light and punches with Mega-Damage supernatural strength.", + "page": 205 + }, + { + "id": "fools-gold", + "name": "Fool's Gold", + "level": 4, + "ppe": 10, + "range": "5 ft (1.5 m)", + "duration": "20 melees per level of the spell caster", + "savingThrow": "standard", + "description": "Makes any object appear to be made of gold; those who save recognize worthless fool's gold.", + "page": 205 + }, + { + "id": "ley-line-transmission", + "name": "Ley Line Transmission", + "level": 4, + "ppe": 30, + "range": "Limited by the length of the ley line", + "duration": "Instant", + "savingThrow": "none", + "savingThrowNote": "A psionic Mind Block will block and destroy the message", + "description": "Sends a verbal/audio message along a ley line to recipients on it (one per level); one-way unless the receiver also knows the spell.", + "page": 205 + }, + { + "id": "magic-net", + "name": "Magic Net", + "level": 4, + "ppe": 7, + "range": "60 ft (18.3 m)", + "duration": "2 melees (30 seconds) per level of the spell caster", + "savingThrow": "dodge", + "savingThrowNote": "Dodge of 16 or higher", + "description": "A net of magic fibers snares 1-6 human-sized victims, who are helpless until it is cut by M.D. weapons, magic, or dispelled.", + "page": 205 + }, + { + "id": "multiple-image", + "name": "Multiple Image", + "level": 4, + "ppe": 7, + "range": "Self", + "duration": "1 minute (4 melees) per level", + "savingThrow": "standard", + "savingThrowNote": "-4 to save; viewers who save may see through the illusion", + "description": "Three identical images mimic the mage's every move: +2 initiative, +2 dodge, +1 strike; only iron dispels an image.", + "page": 206 + }, + { + "id": "repel-animals", + "name": "Repel Animals", + "level": 4, + "ppe": 7, + "range": "30 ft (9.1 m)", + "duration": "Immediate", + "savingThrow": "standard", + "savingThrowNote": "Standard save for animals", + "description": "Even hostile predators stop, turn, and leave without harming the mage; affects six animals at once.", + "page": 206 + }, + { + "id": "shadow-meld", + "name": "Shadow Meld", + "level": 4, + "ppe": 10, + "range": "Self", + "duration": "2 minutes (8 melees) per level", + "savingThrow": "none", + "description": "Step into any 5 ft shadow and become totally invisible, even to See the Invisible; intense light dispels it.", + "page": 206 + }, + { + "id": "swim-as-a-fish-lesser", + "name": "Swim as a Fish (lesser)", + "level": 4, + "ppe": 6, + "range": "Self or others up to 10 ft (3 m) away", + "duration": "5 minutes (20 melees) per level", + "savingThrow": "none", + "description": "Swim at 96% (100x P.S. in yards without tiring), survive 600 ft (183 m) depths; +1 to parry and dodge in water.", + "page": 206 + }, + { + "id": "trance", + "name": "Trance", + "level": 4, + "ppe": 10, + "range": "Touch or within 12 ft (3.6 m)", + "duration": "5 minutes per level", + "savingThrow": "standard", + "description": "Places the victim in a zombie-like hypnotic daze obeying only simple commands; cannot fight, betray friends, or reveal secrets.", + "page": 206 + }, + { + "id": "armor-bizarre", + "name": "Armor Bizarre", + "level": 5, + "ppe": 15, + "range": "Self or one other up to 30 ft (9 m) away", + "duration": "1 minute (4 melee rounds) per level of the spell caster", + "savingThrow": "horrorFactor", + "savingThrowNote": "Save vs Horror Factor only (HF 9, +1 per two levels of the caster)", + "description": "A form-fitting suit of writhing tentacles and slime: 15 M.D.C. per level; foes are -1 initiative and must save vs horror every melee.", + "page": 206 + }, + { + "id": "calling", + "name": "Calling", + "level": 5, + "ppe": 8, + "range": "2 miles (3.2 km) per level of experience", + "duration": "5 minutes per level of experience", + "savingThrow": "standard", + "description": "Telepathically call one known individual by full name; on a failed save the call repeats, compelling them to come.", + "page": 207 + }, + { + "id": "charm", + "name": "Charm", + "level": 5, + "ppe": 12, + "range": "15 ft (4.6 m)", + "duration": "4 melees (1 minute) per level of the spell caster", + "savingThrow": "standard", + "description": "The victim trusts the caster as a favorite friend, believing and helping them — though never against their own alignment.", + "page": 207 + }, + { + "id": "circle-of-flame", + "name": "Circle of Flame", + "level": 5, + "ppe": 10, + "range": "10 ft (3 m) around self", + "duration": "2 minutes (8 melee rounds) per level", + "damage": "6D6 S.D.C. to anybody passing through", + "savingThrow": "none", + "description": "A 5 ft (1.5 m) tall circle of flame around the caster; no combustible material required.", + "page": 207 + }, + { + "id": "distant-voice", + "name": "Distant Voice", + "level": 5, + "ppe": 10, + "range": "2000 ft (610 m) per level of experience; line of sight", + "duration": "5 minutes per level of experience", + "savingThrow": "none", + "description": "A two-way doorway for sound between two points; the caster must know (or see) the person spoken with.", + "page": 207 + }, + { + "id": "domination", + "name": "Domination", + "level": 5, + "ppe": 10, + "range": "Touch or within 4 ft (1.2 m)", + "duration": "15 minutes per level of experience", + "savingThrow": "standard", + "description": "Imposes the caster's will: the dazed victim fulfills commands (but never suicide, self-harm, or killing a loved one).", + "page": 207 + }, + { + "id": "energy-disruption", + "name": "Energy Disruption", + "level": 5, + "ppe": 12, + "range": "60 ft (18.3 m)", + "duration": "3 minutes (12 melees) per level of experience", + "savingThrow": "none", + "description": "Temporarily knocks out any electrical device (unharmed, works perfectly when the magic elapses); not M.D.C. armor, robots, or military vehicles.", + "page": 207 + }, + { + "id": "escape", + "name": "Escape", + "level": 5, + "ppe": 8, + "range": "Self, touch or 5 ft (1.5 m)", + "duration": "Instant", + "savingThrow": "none", + "description": "Magically escape any bonds or open any lock — rope, handcuffs, cells, trunks; one restraint or lock per invocation.", + "page": 208 + }, + { + "id": "eyes-of-thoth", + "name": "Eyes of Thoth", + "level": 5, + "ppe": 8, + "range": "Self or others by touch", + "duration": "10 minutes per level of experience", + "savingThrow": "none", + "description": "Read and understand ALL written languages, modern and ancient (spoken tongues still need Tongues or education).", + "page": 208 + }, + { + "id": "featherlight", + "name": "Featherlight", + "level": 5, + "ppe": 10, + "range": "Touch or up to 10 ft (3 m) away", + "duration": "10 minutes per level of the spell caster", + "savingThrow": "none", + "description": "Reduces one object's weight (up to 200 lbs/90 kg per level) to that of a feather; feather-light things are useless as weapons.", + "page": 208 + }, + { + "id": "fly", + "name": "Fly", + "level": 5, + "ppe": 15, + "range": "Object by touch", + "duration": "6 minutes per level of experience", + "savingThrow": "none", + "description": "Bestows flight on an inanimate object free of metal or plastic (the witch's broom): 35 mph, 1000 ft max altitude, up to 6x6 ft.", + "page": 208 + }, + { + "id": "heal-wounds", + "name": "Heal Wounds", + "level": 5, + "ppe": 10, + "range": "Touch or 3 ft (0.9 m) away", + "duration": "Instant", + "savingThrow": "standard", + "savingThrowNote": "Standard, if the person resists the magic", + "healing": { + "hitPoints": "1D6", + "sdc": "3D6", + "target": "touch" + }, + "description": "Instantly heals minor physical wounds (cuts, gashes, bullet wounds, burns): restores 3D6 S.D.C. and 1D6 Hit Points.", + "page": 208 + }, + { + "id": "house-of-glass", + "name": "House of Glass", + "level": 5, + "ppe": 12, + "range": "Up to 100 ft (30.5 m) away", + "duration": "1 minute per level of the spell caster", + "damage": "Special: attackers suffer damage identical to what they inflict on the caster", + "savingThrow": "standard", + "savingThrowNote": "Standard; gods are immune to this spell", + "description": "The enchanted victim cannot harm the spell caster without suffering the identical damage in return.", + "page": 208 + }, + { + "id": "lifeblast", + "name": "Lifeblast", + "level": 5, + "ppe": 15, + "range": "One character up to 30 ft (9 m) away per level of experience, or two by touch", + "duration": "Varies", + "damage": "Varies: 1D6 vs mummies/zombies (HF 16), 1D6x10 vs vampires, 4D6 S.D.C. vs necromancers", + "savingThrow": "none", + "savingThrowNote": "Varies; typically none — automatically hits its target", + "description": "A blast of renewing life energy: the living gain +3 initiative and +1 on all rolls for a melee; negates animated dead and drives away the undead.", + "page": 209 + }, + { + "id": "sleep", + "name": "Sleep", + "level": 5, + "ppe": 10, + "range": "Touch or one foot (0.3 m) away", + "duration": "Becomes inert within 15 minutes; effects last 10 minutes per level", + "savingThrow": "standard", + "description": "Turns normal food or drink into a sleep-inducing potion; the sleeper cannot be awakened until the magic lapses.", + "page": 209 + }, + { + "id": "superhuman-endurance", + "name": "Superhuman Endurance", + "level": 5, + "ppe": 12, + "range": "Self or one person up to 10 ft (3 m) away, or two by touch", + "duration": "Two hours", + "savingThrow": "standard", + "savingThrowNote": "Standard, provided the character resists its magic", + "description": "Tireless supernatural stamina for the duration; lift/carry 10% more and +2 to save vs disease, poison, and toxins.", + "page": 209 + }, + { + "id": "superhuman-speed", + "name": "Superhuman Speed", + "level": 5, + "ppe": 10, + "range": "Self or others by touch", + "duration": "1 minute (4 melee rounds) per level of experience", + "savingThrow": "none", + "description": "Speed attribute of 44 (30 mph/48 km), +2 to parry and +6 to dodge; all movement without fatigue.", + "page": 209 + }, + { + "id": "superhuman-strength", + "name": "Superhuman Strength", + "level": 5, + "ppe": 10, + "range": "Self or others by touch", + "duration": "2 melee rounds (30 seconds) per level of experience", + "savingThrow": "none", + "description": "Grants a Supernatural P.S. of 30, P.E. of 24, and +30 S.D.C. for the duration of the magic.", + "page": 209 + }, + { + "id": "call-lightning", + "name": "Call Lightning", + "level": 6, + "ppe": 15, + "range": "300 ft (91.5 m); line of sight", + "duration": "Instant", + "damage": "1D6 M.D. per level of the spell caster", + "savingThrow": "none", + "description": "A lightning bolt shoots down from the sky onto any target in the caster's line of vision.", + "page": 209 + }, + { + "id": "compulsion", + "name": "Compulsion", + "level": 6, + "ppe": 20, + "range": "60 ft (18.3 m) and within line of vision", + "duration": "24 hours", + "savingThrow": "standard", + "description": "Implants an irresistible desire or need; the victim is obsessed until it is attained or the magic ends.", + "page": 210 + }, + { + "id": "cure-illness", + "name": "Cure Illness", + "level": 6, + "ppe": 15, + "range": "Touch or 3 ft (0.9 m)", + "duration": "Instant cure", + "savingThrow": "none", + "savingThrowNote": "None; standard if the person resists treatment", + "description": "Cures ordinary bacterial disease and illness (fever, flu); not cancer, wounds, broken bones, or magic sickness.", + "page": 210 + }, + { + "id": "fire-ball", + "name": "Fire Ball", + "level": 6, + "ppe": 10, + "range": "90 ft (27.4 m)", + "duration": "Instant", + "damage": "1D4 M.D. per level of the spell caster", + "savingThrow": "dodge", + "savingThrowNote": "None except dodge; the victim must know the attack is coming and roll 18 or higher", + "description": "A large, magically directed fire ball that seldom misses.", + "page": 210 + }, + { + "id": "impervious-to-energy", + "name": "Impervious to Energy", + "level": 6, + "ppe": 20, + "range": "Self or others by ritual", + "duration": "2 minutes (8 melees) per level of experience", + "savingThrow": "none", + "description": "Impervious to all forms of energy — fire, heat, electricity, lasers; physical attacks do normal damage.", + "page": 210 + }, + { + "id": "magic-pigeon", + "name": "Magic Pigeon", + "level": 6, + "ppe": 20, + "range": "Immediate area", + "duration": "Two months per level of the spell caster", + "savingThrow": "none", + "description": "A mystic pigeon delivers a spoken (30 words) or written message anywhere in the world, flying 720 miles a day.", + "page": 210 + }, + { + "id": "mask-of-deceit", + "name": "Mask of Deceit", + "level": 6, + "ppe": 15, + "range": "Self", + "duration": "10 minutes per level of experience", + "savingThrow": "standard", + "savingThrowNote": "Viewers save at -4; the inattentive are fooled automatically", + "description": "An illusionary mask over the caster's facial features: age, gender, skin, hair composed by thought.", + "page": 210 + }, + { + "id": "reduce-self", + "name": "Reduce Self (6 inches)", + "level": 6, + "ppe": 20, + "range": "Self", + "duration": "10 melees per level of spell caster", + "savingThrow": "none", + "description": "Shrinks the caster, clothes and possessions to six inches tall; reduced M.D. weapons do a single point of S.D.C.", + "page": 210 + }, + { + "id": "sheltering-force", + "name": "Sheltering Force", + "level": 6, + "ppe": 20, + "range": "Around self, or up to 20 ft (6.1 m) away", + "duration": "One hour per level of experience", + "savingThrow": "none", + "description": "A semi-opaque dome tent of force for two to eight people: keeps out rain and insects, stops 1D6 M.D. per attack; outside attackers are -3 to strike.", + "page": 210 + }, + { + "id": "teleport-lesser", + "name": "Teleport: Lesser", + "level": 6, + "ppe": 15, + "range": "Five miles (8 km) per level of experience; touch", + "duration": "Requires two full melees (30 seconds)", + "savingThrow": "none", + "description": "Instantly transmits a touched non-living object (up to 50 lbs) to any known location; success 80% +2% per level.", + "page": 211 + }, + { + "id": "tongues", + "name": "Tongues", + "level": 6, + "ppe": 12, + "range": "Self or others by touch", + "duration": "3 minutes (12 melees) per level of experience", + "savingThrow": "none", + "description": "Perfectly understand and speak all spoken languages at 98% (written languages need Eyes of Thoth).", + "page": 211 + }, + { + "id": "words-of-truth", + "name": "Words of Truth", + "level": 6, + "ppe": 15, + "range": "5 ft (1.5 m)", + "duration": "1 minute (4 melees) per level of experience", + "savingThrow": "standard", + "savingThrowNote": "A saving throw for EACH question asked; a save means that question need not be answered", + "description": "Compels truthful answers; the caster can ask two brief questions per melee round.", + "page": 211 + }, + { + "id": "agony", + "name": "Agony", + "level": 7, + "ppe": 20, + "range": "5 ft (1.5 m) per level of experience", + "duration": "1 minute (4 melees)", + "damage": "Special: incapacitating pain, no physical damage", + "savingThrow": "standard", + "description": "The victim writhes in pain — no attacks, movement, skills, or speech — then fights at half for another minute.", + "page": 211 + }, + { + "id": "animate-and-control-dead", + "name": "Animate and Control Dead", + "level": 7, + "ppe": 20, + "range": "400 ft (122 m); line of vision", + "duration": "5 minutes (20 melees) per level of experience", + "savingThrow": "none", + "description": "Animate corpses or skeletons (two, plus one per level) as mindless puppets: 2 attacks each, Spd 7, 1D6 damage.", + "page": 211 + }, + { + "id": "ballistic-fire", + "name": "Ballistic Fire", + "level": 7, + "ppe": 25, + "range": "1000 ft (305 m) +10 ft (3 m) per level of experience", + "duration": "Instant", + "damage": "1D6 M.D. per fiery missile", + "savingThrow": "none", + "savingThrowNote": "Victims can attempt to dodge at -10 without benefit of any other bonuses", + "description": "One magically guided fiery missile per level, fired at any mix of targets in a single spell attack.", + "page": 211 + }, + { + "id": "constrain-being", + "name": "Constrain Being", + "level": 7, + "ppe": 20, + "range": "30 ft (9.1 m)", + "duration": "2 minutes (8 melee rounds) per level of experience", + "savingThrow": "standard", + "description": "Holds a lesser supernatural creature at bay with simple commands, the way a cross holds a vampire.", + "page": 212 + }, + { + "id": "dispel-magic-barriers", + "name": "Dispel Magic Barriers", + "level": 7, + "ppe": 20, + "range": "100 ft (30.5 m)", + "duration": "Instant", + "savingThrow": "standard", + "savingThrowNote": "The attacked spell itself saves vs magic (12) as if it were a person", + "description": "Negates magic barriers of any kind: Sorcerer's Seal, Carpet of Adhesion, wall and ward spells.", + "page": 212 + }, + { + "id": "fly-as-the-eagle", + "name": "Fly as the Eagle", + "level": 7, + "ppe": 25, + "range": "Self or two others by touch, or cast upon one up to 100 ft (30.5 m) away", + "duration": "20 minutes per level of the spell caster", + "savingThrow": "none", + "description": "True flight at up to 50 mph: +1 parry, +2 dodge, +2 damage on a diving attack; spells can be cast while flying.", + "page": 212 + }, + { + "id": "globe-of-silence", + "name": "Globe of Silence", + "level": 7, + "ppe": 20, + "range": "Up to 90 ft (27.4 m) away", + "duration": "6 melee rounds per level of the spell caster", + "savingThrow": "none", + "savingThrowNote": "No save (the space itself is altered); Negate Magic can dispel the globe", + "description": "An invisible 10 ft radius globe that stops all sound; incantation-reliant casters inside are powerless.", + "page": 212 + }, + { + "id": "heal-self", + "name": "Heal Self", + "level": 7, + "ppe": 20, + "range": "Self", + "duration": "Instant", + "savingThrow": "none", + "healing": { + "hitPoints": "1D6", + "sdc": "3D6", + "target": "self" + }, + "description": "A minute of meditative chant washes the mage with mystic energy: restores 3D6 S.D.C. and 1D6 Hit Points, healing cuts, bruises, and broken bones.", + "page": 212 + }, + { + "id": "invisibility-superior", + "name": "Invisibility (Superior)", + "level": 7, + "ppe": 20, + "range": "Self or one other by touch", + "duration": "3 minutes (12 melees) per level of experience", + "savingThrow": "none", + "description": "Invisible to ALL detection — optics, heat, motion, smell; broken the instant the character attacks.", + "page": 212 + }, + { + "id": "life-drain", + "name": "Life Drain", + "level": 7, + "ppe": 25, + "range": "30 ft (9.1 m)", + "duration": "2 melees (30 seconds) per level of experience", + "damage": "Special: S.D.C. and Hit Points reduced by half", + "savingThrow": "standard", + "description": "Debilitating weakness: the victim's S.D.C., H.P., and speed are halved, one less attack per melee, skills -10%.", + "page": 213 + }, + { + "id": "lightblade", + "name": "Lightblade", + "level": 7, + "ppe": 20, + "range": "Self; close combat/hand to hand", + "duration": "1 minute (4 melee rounds) per level of experience", + "damage": "1D4x10 +1 M.D. point per level of experience", + "savingThrow": "dodge", + "savingThrowNote": "Parry or dodge", + "description": "A weightless sword of brilliant white light: +1 to strike, double damage to vampires and demons vulnerable to light.", + "page": 213 + }, + { + "id": "metamorphosis-animal", + "name": "Metamorphosis: Animal", + "level": 7, + "ppe": 25, + "range": "Self or other by ritual", + "duration": "20 minutes per level of experience", + "savingThrow": "none", + "description": "Transform completely into an animal — cat to alligator — keeping speech, memory, S.D.C., and Hit Points.", + "page": 213 + }, + { + "id": "purification", + "name": "Purification", + "level": 7, + "ppe": 20, + "range": "Touch or 3 ft (0.9 m)", + "duration": "Instant", + "savingThrow": "none", + "description": "Purifies up to 50 lbs of food or 10 gallons of water, cleansing disease, bacteria, and poison/toxins.", + "page": 213 + }, + { + "id": "wind-rush", + "name": "Wind Rush", + "level": 7, + "ppe": 20, + "range": "120 ft (36.6 m)", + "duration": "One melee (15 seconds)", + "savingThrow": "dodge", + "savingThrowNote": "A roll of 18, 19 or 20 saves from losing balance and items", + "description": "A 60 mph wind gust knocks people down, blows objects 20-120 ft, and holds the caught helpless.", + "page": 214 + }, + { + "id": "commune-with-spirits", + "name": "Commune with Spirits", + "level": 8, + "ppe": 25, + "range": "Self, or others by ritual; 200 ft (61 m) away", + "duration": "5 minutes per level of experience", + "savingThrow": "none", + "description": "See and speak with all types of entities — poltergeists, haunting, trapped, and possessing spirits.", + "page": 214 + }, + { + "id": "exorcism", + "name": "Exorcism", + "level": 8, + "ppe": 30, + "range": "30 ft (9.1 m)", + "duration": "The spell lasts 3 minutes; results last 6 months or longer", + "savingThrow": "standard", + "savingThrowNote": "12 by spell or 16 by ritual", + "description": "Forces a possessing supernatural being out of its host; the freed victim gets +12 to save vs re-possession.", + "page": 214 + }, + { + "id": "expel-demons", + "name": "Expel Demons", + "level": 8, + "ppe": 35, + "range": "10 ft (3 m) area per level of experience", + "duration": "Immediate; 1D6 hours", + "savingThrow": "special", + "savingThrowNote": "Lesser supernatural beings save on 18+, greater demons on 12+; Demon Lords, Elementals, spirits and gods are impervious", + "description": "Repels all lesser demons and supernatural beings from the area for at least an hour.", + "page": 214 + }, + { + "id": "eyes-of-the-wolf", + "name": "Eyes of the Wolf", + "level": 8, + "ppe": 25, + "range": "Self or one other by touch", + "duration": "5 minutes (20 melees) per level of the spell caster", + "savingThrow": "none", + "description": "Wolf senses: nightvision 60 ft, See the Invisible 75%, identify plants/tracks, track 50%, recognize poison 65%.", + "page": 214 + }, + { + "id": "forcebonds", + "name": "Forcebonds", + "level": 8, + "ppe": 25, + "range": "Touch", + "duration": "30 minutes per level of experience", + "savingThrow": "special", + "savingThrowNote": "Requires Supernatural P.S. 45 or 100 M.D. to break; Negate Magic saves at +2", + "description": "Ordinary rope, chains, or cloth become magical restraints binding like M.D.C. manacles.", + "page": 214 + }, + { + "id": "greater-healing", + "name": "Greater Healing", + "level": 8, + "ppe": 30, + "range": "One character by touch (cannot be used on oneself)", + "duration": "Instant", + "savingThrow": "none", + "healing": { + "hitPoints": "6D6", + "sdc": "2D4*10", + "target": "touch", + "othersOnly": true + }, + "description": "Instantly heals external and internal injuries: restores up to 2D4x10 S.D.C. and 6D6 Hit Points; never above the target's original maximums.", + "page": 215 + }, + { + "id": "ley-line-tendril-bolts", + "name": "Ley Line Tendril Bolts", + "level": 8, + "ppe": 26, + "ppeNote": "26 P.P.E. (half — 13 — for Ley Line Walkers and Shifters); doubling the P.P.E. adds +20 M.D. to each bolt", + "range": "10 ft (3 m) per level of experience", + "duration": "One melee round; each four-tier blast counts as one melee attack", + "damage": "2D6 M.D. at level one, +1D6 M.D. per two additional levels", + "savingThrow": "standard", + "savingThrowNote": "-2 to save; a successful save means half damage", + "description": "Four bolts of mystic energy strike the four nearest enemies simultaneously. Can only be cast on a ley line.", + "page": 215 + }, + { + "id": "lightning-arc", + "name": "Lightning Arc", + "level": 8, + "ppe": 30, + "range": "100 ft (30.5 m) per level of experience", + "duration": "One melee round per level of experience", + "damage": "4D6 +2 M.D. per level of experience", + "savingThrow": "dodge", + "description": "A sustained electric arc, point and shoot: +4 to strike within 100 ft, +1 beyond; each blast counts as one attack.", + "page": 215 + }, + { + "id": "locate", + "name": "Locate", + "level": 8, + "ppe": 30, + "range": "15 miles (24 km) per level of experience", + "duration": "Instant", + "savingThrow": "none", + "description": "Sense the general location of known quarry: 01-41% +1% per level by spell, 01-89% by ritual with a personal object.", + "page": 215 + }, + { + "id": "luck-curse", + "name": "Luck Curse", + "level": 8, + "ppe": 40, + "range": "Touch or 10 ft (3 m)", + "duration": "24 hours per level of experience", + "savingThrow": "standard", + "savingThrowNote": "12 by spell, 16 by ritual", + "description": "Terrible luck: all combat bonuses reduced to zero, skills -40% in critical situations; only Remove Curse negates it.", + "page": 215 + }, + { + "id": "magical-adrenal-rush", + "name": "Magical-Adrenal Rush", + "level": 8, + "ppe": 45, + "range": "100 ft (30.5 m); line of sight, self or one by touch", + "duration": "One melee round per level of experience", + "savingThrow": "none", + "description": "A rush beyond Juicers: supernatural P.S., +2 attacks, +50% speed, impervious to pain and Horror Factor — then 1D4 minutes of exhaustion.", + "page": 215 + }, + { + "id": "metamorphosis-human", + "name": "Metamorphosis: Human", + "level": 8, + "ppe": 40, + "range": "Self, or other by ritual", + "duration": "20 minutes per level of experience", + "savingThrow": "none", + "description": "Shape change into any human appearance (the ultimate disguise); voice, memory, skills, and attributes are retained.", + "page": 216 + }, + { + "id": "negate-magic", + "name": "Negate Magic", + "level": 8, + "ppe": 30, + "range": "Touch or 60 ft (18.3 m)", + "duration": "Instant", + "savingThrow": "special", + "savingThrowNote": "Roll to save vs the magic in place: 12-15 for spell magic, 16+ for ritual magic", + "description": "Instantly cancels the effects of most magic; does not work on possession, wards, circles, summoning, or healings.", + "page": 216 + }, + { + "id": "power-weapon", + "name": "Power Weapon", + "level": 8, + "ppe": 35, + "range": "One weapon by touch", + "duration": "Two melee rounds (30 seconds) per level of the spell caster", + "savingThrow": "none", + "description": "An S.D.C. melee weapon inflicts its damage as Mega-Damage (or an M.D. melee weapon gains +25%).", + "page": 216 + }, + { + "id": "shockwave", + "name": "Shockwave", + "level": 8, + "ppe": 45, + "range": "Radius around the spell caster: 10 ft (3 m) per level of experience", + "duration": "Instant", + "damage": "1D4 M.D. per level plus knockdown", + "savingThrow": "special", + "savingThrowNote": "Roll percentile vs knockdown by weight class; the caster and those touching him are unaffected", + "description": "A circular shockwave in all directions: Mega-Damage plus likely knockdown (hurled 3D4 yards, lose initiative and two attacks).", + "page": 216 + }, + { + "id": "sickness", + "name": "Sickness", + "level": 8, + "ppe": 50, + "range": "Touch or 20 ft (6 m)", + "duration": "12 hours per level of experience", + "savingThrow": "standard", + "description": "Afflicts the victim with severe disease symptoms: one attack per melee, -4 strike/parry/dodge, skills -40%.", + "page": 217 + }, + { + "id": "spoil", + "name": "Spoil", + "level": 8, + "ppe": 30, + "range": "Touch or 3 ft (0.9 m)", + "duration": "Instant", + "savingThrow": "none", + "description": "The opposite of Purification: instantly spoils 50 lbs of food or 10 gallons of water.", + "page": 217 + }, + { + "id": "wisps-of-confusion", + "name": "Wisps of Confusion", + "level": 8, + "ppe": 40, + "range": "90 ft (27.4 m)", + "duration": "Five melees per level of the spell caster", + "savingThrow": "standard", + "description": "2D4 people become confused and disoriented: -5 strike/dodge/parry and attacks per melee halved.", + "page": 217 + }, + { + "id": "desiccate-the-supernatural", + "name": "Desiccate the Supernatural", + "level": 9, + "ppe": 50, + "range": "One up to 50 ft (15.2 m) away per level of experience, or two by touch", + "duration": "Instant", + "damage": "3D6x10 M.D. (or Hit Points, whichever is appropriate)", + "savingThrow": "standard", + "savingThrowNote": "-1 to save; a successful save vs magic means half damage", + "description": "Draws the moisture out of a supernatural being, withering it in seconds; useless against mortals and the armored.", + "page": 217 + }, + { + "id": "dragon-fire", + "name": "Dragon Fire", + "level": 9, + "ppe": 40, + "range": "100 ft (30.5 m)", + "duration": "One melee round per level of experience", + "damage": "1D4x10 M.D.", + "savingThrow": "dodge", + "savingThrowNote": "None except dodge; the victim must know the attack is coming and roll a 16 or higher", + "description": "Breathe fire like an adult Fire Dragon: two searing, magically directed blasts per melee round.", + "page": 217 + }, + { + "id": "familiar-link", + "name": "Familiar Link", + "level": 9, + "ppe": 55, + "range": "Self and animal; 600 ft (183 m)", + "duration": "Indefinite", + "savingThrow": "none", + "description": "Permanently bond a small animal familiar as a sensory extension; both gain +6 H.P., but its master shares its pain.", + "page": 217 + }, + { + "id": "mute", + "name": "Mute", + "level": 9, + "ppe": 50, + "range": "By touch or up to 30 ft (9.1 m) away", + "duration": "20 melees per level of spell caster", + "savingThrow": "standard", + "description": "Silences the victim's voice box and vocal cords, preventing any voice or sound from being uttered.", + "page": 218 + }, + { + "id": "protection-circle-simple", + "name": "Protection Circle: Simple", + "level": 9, + "ppe": 45, + "ppeNote": "45 P.P.E. to create; a mere four P.P.E. to reactivate the drawn circle", + "range": "Radius of the circle", + "duration": "24 hours; can be reactivated", + "savingThrow": "none", + "description": "A drawn circle keeps lesser supernatural creatures 5 ft away and grants occupants +2 to save vs magic and psionics.", + "page": 218 + }, + { + "id": "speed-of-the-snail", + "name": "Speed of the Snail", + "level": 9, + "ppe": 50, + "range": "60 ft (18.3 m)", + "duration": "2 melees per level of the spell caster", + "savingThrow": "standard", + "description": "Time distortion reduces up to 1D6 victims to one-third speed, attacks, dodge, and parry; affects robots and vehicles too.", + "page": 218 + }, + { + "id": "wall-of-defense", + "name": "Wall of Defense", + "level": 9, + "ppe": 55, + "range": "Can be cast up to 100 ft (30.5 m) away", + "duration": "One melee round (15 seconds) per level of experience", + "savingThrow": "none", + "description": "A paper-thin wall of magic energy stops ALL incoming attacks — rocks, bullets, missiles, energy blasts, and spells.", + "page": 218 + }, + { + "id": "water-to-wine", + "name": "Water to Wine", + "level": 9, + "ppe": 40, + "range": "12 ft (3.6 m)", + "duration": "Instant/permanent", + "savingThrow": "none", + "description": "Transforms ordinary fresh water into fair-to-average wine, ten gallons per level of experience.", + "page": 218 + }, + { + "id": "banishment", + "name": "Banishment", + "level": 10, + "ppe": 65, + "range": "100 ft (30.5 m)", + "duration": "Two weeks per level of experience", + "savingThrow": "standard", + "savingThrowNote": "16 or higher to save vs the ritual version", + "description": "Forces one lesser supernatural being per level to leave the area (600 ft radius) and stay away for weeks.", + "page": 218 + }, + { + "id": "control-and-enslave-entity", + "name": "Control & Enslave Entity", + "level": 10, + "ppe": 80, + "range": "30 ft (9.1 m)", + "duration": "48 hours per level of experience", + "savingThrow": "standard", + "description": "Controls encountered entities (two per level of experience); a failed save means the entity obeys the mage.", + "page": 218 + }, + { + "id": "deathword", + "name": "Deathword", + "level": 10, + "ppe": 70, + "range": "30 ft (9 m); clear sound", + "duration": "Instant effect", + "damage": "2D6 +1D6 per level of the spell caster; doubled if whispered in the victim's ear", + "savingThrow": "standard", + "savingThrowNote": "Save vs magic takes damage but no coma; then save vs coma to survive death; greater beings +3", + "description": "A single spoken word of death: damage ignores armor and immunities, and the victim lapses into a death-like coma for 1D4 hours.", + "page": 219 + }, + { + "id": "giant", + "name": "Giant", + "level": 10, + "ppe": 80, + "range": "Self or one other by touch", + "duration": "One melee round per level of experience", + "savingThrow": "none", + "description": "Grow ten feet taller: H.P./S.D.C. x3 become M.D.C., supernatural P.S. +50%, +1 attack — but no spell casting.", + "page": 219 + }, + { + "id": "metamorphosis-superior", + "name": "Metamorphosis: Superior", + "level": 10, + "ppe": 100, + "range": "Self, or one other by use of ritual only", + "duration": "20 minutes per level of experience", + "savingThrow": "none", + "savingThrowNote": "None; standard if an unwilling victim", + "description": "Transform into any real, living creature — animal, human, D-Bee, insect, fish — keeping one's own abilities.", + "page": 219 + }, + { + "id": "meteor", + "name": "Meteor", + "level": 10, + "ppe": 75, + "range": "200 ft (61 m) per level of experience", + "duration": "Instant", + "damage": "1D6x10 M.D. to a 40 ft (12.2 m) radius, +2 M.D. per level of the spell caster", + "savingThrow": "dodge", + "savingThrowNote": "Dodge if victims see it coming", + "description": "A flaming meteor plunges from the sky, +4 to strike; devastating against large targets and troops.", + "page": 219 + }, + { + "id": "mystic-portal", + "name": "Mystic Portal", + "level": 10, + "ppe": 60, + "range": "20 ft (6.1 m) away; 10 ft wide by 20 ft tall opening", + "duration": "Four melee rounds per level of the spell caster", + "savingThrow": "none", + "description": "A dimensional Rift used to pass through solid walls, teleport to a nearby known location, or leave a one-way passage.", + "page": 219 + }, + { + "id": "plane-skip", + "name": "Plane Skip", + "level": 10, + "ppe": 65, + "range": "Self and one other by touch", + "duration": "Instant", + "savingThrow": "none", + "description": "Skip past a dimensional portal's destination to a different dimension — usually a random, alien place.", + "page": 220 + }, + { + "id": "speed-weapon", + "name": "Speed Weapon", + "level": 10, + "ppe": 100, + "range": "Touch", + "duration": "One melee round per level of the spell caster", + "savingThrow": "none", + "description": "A melee weapon moves with amazing speed: twice as many attacks per melee with the enchanted weapon.", + "page": 220 + }, + { + "id": "summon-and-control-rodents", + "name": "Summon and Control Rodents (Ritual)", + "level": 10, + "ppe": 70, + "range": "600 ft (183 m)", + "duration": "Five hours per level of experience", + "savingThrow": "standard", + "savingThrowNote": "Standard save for animals", + "description": "A pentacle of summoning calls an army of mice or rats (30 per level) that obey the sorcerer's will.", + "page": 220 + }, + { + "id": "summon-shadow-beast", + "name": "Summon Shadow Beast", + "level": 10, + "ppe": 140, + "range": "Immediate", + "duration": "Combat: 2 minutes (8 melees) per level; labor: three hours per level", + "savingThrow": "none", + "description": "Summons a 9-12 ft shadow predator (75 M.D.C. in darkness) that merges with shadows and hunts on command.", + "page": 220 + }, + { + "id": "super-healing", + "name": "Super-Healing", + "level": 10, + "ppe": 70, + "range": "One character by touch (cannot be used on oneself)", + "duration": "Instant", + "savingThrow": "none", + "description": "Heals the wounds of Mega-Damage creatures (dragons, supernatural beings): restores 4D6 M.D. Not applicable to S.D.C./Hit Point creatures.", + "page": 221 + }, + { + "id": "anti-magic-cloud", + "name": "Anti-Magic Cloud", + "level": 11, + "ppe": 140, + "range": "100 ft (30.5 m) radius per level of the spell caster", + "duration": "20 melees per level of the spell caster", + "savingThrow": "special", + "savingThrowNote": "Only a Natural 18, 19 or 20 saves, and even then magic is at half strength", + "description": "A dark cloud over the area negates ALL magic — spells, devices, charms; only its creator is unaffected.", + "page": 221 + }, + { + "id": "create-mummy", + "name": "Create Mummy (Ritual)", + "level": 11, + "ppe": 160, + "range": "Touch", + "duration": "Exists until destroyed", + "savingThrow": "none", + "description": "A Necromantic ritual that turns a corpse into an obedient, nearly indestructible undead guardian (fire is its weakness).", + "page": 221 + }, + { + "id": "firequake", + "name": "Firequake", + "level": 11, + "ppe": 160, + "ppeNote": "Available to Earth Warlocks as an 8th level spell at half the P.P.E. (80)", + "range": "Up to 500 ft (152 m) away; 100 ft (30.5 m) radius", + "duration": "One melee round per level of experience", + "damage": "Varies; jets of flame do 5D6 M.D. on a failed dodge", + "savingThrow": "dodge", + "description": "The ground rumbles, cracks, and spews sulfur and gouts of fire; movement drops to 10% and flame jets erupt underfoot.", + "page": 222 + }, + { + "id": "re-open-gateway", + "name": "Re-Open Gateway (Rift)", + "level": 11, + "ppe": 180, + "range": "10 ft (3 m)", + "duration": "One melee round per level of experience (at most)", + "savingThrow": "none", + "description": "Re-opens an existing dimensional portal (stone pyramids, nexus gates) to the LAST world it accessed.", + "page": 222 + }, + { + "id": "remove-curse", + "name": "Remove Curse", + "level": 11, + "ppe": 140, + "range": "Touch or 10 ft (3 m)", + "duration": "Instant removal", + "savingThrow": "none", + "description": "Removes any type of curse: roll a d20 save vs magic with +5 as a spell or +10 as a ritual; success ends the curse.", + "page": 222 + }, + { + "id": "rift-teleportation", + "name": "Rift Teleportation", + "level": 11, + "ppe": 200, + "ppeNote": "200 P.P.E. (half for Shifters and Temporal Raiders/Wizards)", + "range": "Up to 100 miles (160 km) per level of the spell caster", + "duration": "Roughly 1D4+4 seconds/half a melee round", + "savingThrow": "standard", + "savingThrowNote": "+3 to save if an unwilling participant; a save means that character is left behind", + "description": "Teleports up to 20 people per level from one ley line nexus to another known nexus within range.", + "page": 222 + }, + { + "id": "amulet", + "name": "Amulet", + "level": 12, + "ppe": 290, + "ppeNote": "290-500 by amulet type: Charm 290, vs Sickness 290, vs Insanity 320, vs the Supernatural 300, See the Invisible 500, Sense Spirits 310, Turn the Undead 400", + "range": "Holder/wearer of the amulet", + "duration": "Exists as long as the medallion is not destroyed", + "savingThrow": "none", + "description": "Instills a fire-purified metal or stone medallion with a lasting protective charm (choose one effect at creation).", + "page": 223 + }, + { + "id": "calm-storms", + "name": "Calm Storms", + "level": 12, + "ppe": 200, + "range": "Immediate area around the mage, one mile (1.6 km) per level of experience", + "duration": "One hour per level of experience", + "savingThrow": "none", + "description": "Calms natural and magical tempests: downpour to light rain by spell; a tornado dispersed in an instant by ritual.", + "page": 223 + }, + { + "id": "create-zombie", + "name": "Create Zombie (Ritual)", + "level": 12, + "ppe": 250, + "range": "Touch", + "duration": "Exists until destroyed", + "savingThrow": "none", + "description": "A Necromantic graveyard ritual (full moon, blood pentagram) that raises an obedient zombie slave; only energy attacks truly harm it.", + "page": 223 + }, + { + "id": "ensorcel", + "name": "Ensorcel", + "level": 12, + "ppe": 400, + "range": "Touch", + "duration": "20 minutes per level of the spell caster (double if 800 P.P.E. are expended)", + "savingThrow": "standard", + "savingThrowNote": "-3 to save", + "description": "Enslaves the victim to the caster's magic: impervious to others' mind control and illusion, but helpless before the ensorceller.", + "page": 223 + }, + { + "id": "summon-and-control-entity", + "name": "Summon and Control Entity (Ritual)", + "level": 12, + "ppe": 250, + "range": "Not applicable", + "duration": "24 hours per level of experience", + "savingThrow": "none", + "description": "Plucks an Entity out of its native dimension to serve without question — return it before control lapses.", + "page": 224 + }, + { + "id": "protection-circle-superior", + "name": "Protection Circle: Superior", + "level": 13, + "ppe": 300, + "ppeNote": "Three hundred to create; twenty P.P.E. to reactivate", + "range": "Radius of the circle", + "duration": "24 hours; can be reactivated immediately at a cost of 20 P.P.E.", + "savingThrow": "none", + "description": "Keeps ALL supernatural creatures 20 ft from its edge; occupants get +5 vs magic/psionics, +8 vs Horror Factor, +10 P.P.E./I.S.P.", + "page": 224 + }, + { + "id": "sanctum", + "name": "Sanctum", + "level": 13, + "ppe": 390, + "range": "30x30 ft (9.1 x 9.1 m) room; can be created up to 200 miles (320 km) away", + "duration": "The lifetime of the mage or until canceled", + "savingThrow": "none", + "description": "Turns a room into a safe haven free of mystic influence: no Calling, Locate, scrying, or entry by the undead.", + "page": 224 + }, + { + "id": "close-rift", + "name": "Close Rift", + "level": 14, + "ppe": 200, + "ppeNote": "Two hundred plus 2 P.P.E. permanently drained from the caster's base (Shifters, Temporal Raiders/Wizards, Stone Masters, and gods are exempt)", + "range": "100 ft (30.5 m)", + "duration": "Instant results", + "savingThrow": "standard", + "savingThrowNote": "The gateway gets an automatic save vs magic; a Close Rift Ritual improves the odds (16)", + "description": "Closes a Rift by sheer force of will at the permanent cost of 2 P.P.E.; cannot close permanently-open Rifts.", + "page": 224 + }, + { + "id": "restoration", + "name": "Restoration", + "level": 14, + "ppe": 750, + "range": "Touch or 3 ft (0.9 m) away", + "duration": "Instant/permanent", + "savingThrow": "none", + "healing": { + "full": true, + "target": "touch" + }, + "description": "Instantly and completely heals all wounds — full S.D.C. and Hit Points, mended bones, even severed limbs restored (within 48 hours).", + "page": 224 + }, + { + "id": "resurrection", + "name": "Resurrection", + "level": 14, + "ppe": 650, + "range": "Touch or six feet (1.8 m) away", + "duration": "Instant and permanent", + "savingThrow": "none", + "description": "Restores life to the recently deceased (dead under two months): 45% success, three attempts per corpse.", + "page": 224 + }, + { + "id": "dimensional-portal", + "name": "Dimensional Portal (Rift)", + "level": 15, + "ppe": 1000, + "range": "A few feet (one meter) away", + "duration": "30 seconds (2 melee rounds) per level of the spell caster, or one minute (4 melees) per level as a ritual", + "savingThrow": "none", + "description": "Opens a two-way door to another dimension, specific or random — and some 'thing' unwanted often slips through.", + "page": 225 + }, + { + "id": "teleport-superior", + "name": "Teleport: Superior", + "level": 15, + "ppe": 600, + "range": "Self or others; distance of 300 miles (480 km) per level of experience", + "duration": "Instant", + "savingThrow": "none", + "description": "Instantly transport the mage and up to 1000 lbs per level to a known destination: 99% familiar, 58% described, 20% by name only.", + "page": 225 } ] } diff --git a/packages/rules/src/engine/spells.ts b/packages/rules/src/engine/spells.ts index f73686e..21acef4 100644 --- a/packages/rules/src/engine/spells.ts +++ b/packages/rules/src/engine/spells.ts @@ -43,20 +43,41 @@ export function canCast(spell: Spell, availablePpe: number): boolean { return availablePpe >= spell.ppe; } -/** Concrete points a healing cast restores (per-pool dice, rolled). */ +/** Concrete points a healing cast restores (per-pool dice, rolled) — or a + * complete restoration when `full` is set (no dice; both pools to maximum). */ export interface HealingRoll { hitPoints?: number; sdc?: number; + full?: boolean; } +/** The pool an exclusive healing spell restores this cast (caster's choice). */ +export type HealingPool = "hitPoints" | "sdc"; + /** * 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). + * + * Exclusive spells (Light Healing's "1D6 S.D.C. *or* 1D4 Hit Points") roll + * only the chosen `pool` and throw when none is named. Full restorations + * (Restoration) roll nothing and return `{ full: true }`. */ -export function rollSpellHealing(spell: Spell, rng: Rng = Math.random): HealingRoll | undefined { +export function rollSpellHealing( + spell: Spell, + rng: Rng = Math.random, + pool?: HealingPool, +): HealingRoll | undefined { const h = spell.healing; if (!h) return undefined; + if (h.full) return { full: true }; + if (h.exclusive) { + if (pool === undefined) { + throw new Error(`${spell.name} restores one pool per cast — choose hitPoints or sdc.`); + } + // The schema guarantees exclusive spells declare both pools. + return { [pool]: rollDice(h[pool]!, rng) }; + } return { ...(h.hitPoints !== undefined ? { hitPoints: rollDice(h.hitPoints, rng) } : {}), ...(h.sdc !== undefined ? { sdc: rollDice(h.sdc, rng) } : {}), diff --git a/packages/rules/src/schema/spells.ts b/packages/rules/src/schema/spells.ts index b92c14e..cf540f8 100644 --- a/packages/rules/src/schema/spells.ts +++ b/packages/rules/src/schema/spells.ts @@ -17,9 +17,26 @@ export const spellHealingSchema = z /** S.D.C. restored (dice formula). */ sdc: diceFormulaSchema.optional(), target: healingTargetKindSchema, + /** The spell restores ONE of the declared pools per cast, caster's + * choice — e.g. Light Healing's "1D6 S.D.C. or 1D4 Hit Points". */ + exclusive: z.boolean().optional(), + /** The spell cannot be cast on the caster (e.g. Light Healing). */ + othersOnly: z.boolean().optional(), + /** Complete restoration: both pools return to their maximums, no dice + * (Restoration, RUE p.224). Mutually exclusive with dice pools. */ + full: z.boolean().optional(), }) - .refine((h) => h.hitPoints !== undefined || h.sdc !== undefined, { - message: "A healing effect must restore hitPoints, sdc, or both.", + .refine((h) => h.full === true || h.hitPoints !== undefined || h.sdc !== undefined, { + message: "A healing effect must restore hitPoints, sdc, or be a full restoration.", + }) + .refine((h) => h.exclusive !== true || (h.hitPoints !== undefined && h.sdc !== undefined), { + message: "An exclusive healing effect needs both pools to choose between.", + }) + .refine((h) => h.full !== true || (h.hitPoints === undefined && h.sdc === undefined), { + message: "A full restoration cannot also declare dice pools.", + }) + .refine((h) => !(h.othersOnly === true && h.target === "self"), { + message: "An others-only healing effect cannot target self — it would be uncastable.", }); export type SpellHealing = z.infer; @@ -39,8 +56,13 @@ export const spellSchema = z.object({ name: z.string().min(1), /** Spell level (1-15). */ level: z.number().int().min(1).max(15), - /** P.P.E. cost to cast. */ + /** The numeric P.P.E. cost that casting/affordability checks charge. For + * variable or conditional printed costs this is the book's headline number + * — see `ppeNote` for the full printed rule (which may go lower OR higher). */ ppe: z.number().int().nonnegative(), + /** Printed cost rule when the P.P.E. cost is variable or conditional + * (e.g. "Two P.P.E. per five pounds", "half for Ley Line Walkers"). */ + ppeNote: z.string().optional(), /** Range as printed (e.g. "150 feet", "Self", "Touch"). */ range: z.string().min(1), /** Duration as printed (e.g. "Instant", "12 melees per level"). */ diff --git a/packages/rules/tests/recovery.test.ts b/packages/rules/tests/recovery.test.ts index 752c840..e7f061d 100644 --- a/packages/rules/tests/recovery.test.ts +++ b/packages/rules/tests/recovery.test.ts @@ -153,4 +153,35 @@ describe("spell healing — schema and rolls", () => { }); expect(rollSpellHealing(bolt)).toBeUndefined(); }); + + test("exclusive healing rolls only the chosen pool, and demands a choice", () => { + const either = spellSchema.parse({ + ...healWounds, + id: "test-either", + name: "Test Either", + healing: { hitPoints: "1D4", sdc: "1D6", target: "touch", exclusive: true }, + }); + expect(() => rollSpellHealing(either, () => 0)).toThrow(/choose hitPoints or sdc/); + expect(rollSpellHealing(either, () => 0, "hitPoints")).toEqual({ hitPoints: 1 }); + expect(rollSpellHealing(either, () => 0, "sdc")).toEqual({ sdc: 1 }); + }); + + test("full restorations roll no dice", () => { + const resto = spellSchema.parse({ + ...healWounds, + id: "test-resto", + name: "Test Resto", + healing: { full: true, target: "touch" }, + }); + expect(rollSpellHealing(resto)).toEqual({ full: true }); + // The schema refuses contradictory shapes. + expect(() => + spellSchema.parse({ + ...healWounds, + id: "test-bad", + name: "Test Bad", + healing: { full: true, hitPoints: "1D6", target: "touch" }, + }), + ).toThrow(); + }); }); diff --git a/packages/rules/tests/spells.test.ts b/packages/rules/tests/spells.test.ts index 89dc2db..1d74881 100644 --- a/packages/rules/tests/spells.test.ts +++ b/packages/rules/tests/spells.test.ts @@ -27,11 +27,32 @@ describe("spell book (RUE Magic Spells, levels 1-4)", () => { expect(getSpell("nope")).toBeUndefined(); }); - test("spells are grouped by level", () => { - expect(spellsByLevel(1)).toHaveLength(10); - expect(spellsByLevel(2)).toHaveLength(5); - expect(spellsByLevel(3)).toHaveLength(9); - expect(spellsByLevel(4)).toHaveLength(5); + test("spells are grouped by level (full RUE per-level counts)", () => { + const counts = [10, 13, 17, 16, 18, 12, 14, 18, 8, 12, 6, 5, 2, 3, 2]; // L1..L15 + counts.forEach((count, i) => expect(spellsByLevel(i + 1)).toHaveLength(count)); + expect(spellBook.spells).toHaveLength(156); + }); + + test("the healing spells carry structured effects (#13)", () => { + expect(getSpell("heal-wounds")?.healing).toEqual({ + hitPoints: "1D6", + sdc: "3D6", + target: "touch", + }); + expect(getSpell("heal-self")?.healing?.target).toBe("self"); + expect(getSpell("light-healing")?.healing).toMatchObject({ + exclusive: true, + othersOnly: true, + }); + expect(getSpell("greater-healing")?.healing).toMatchObject({ + hitPoints: "6D6", + sdc: "2D4*10", + othersOnly: true, + }); + expect(getSpell("restoration")?.healing).toEqual({ full: true, target: "touch" }); + // Cure Minor Disorders/Cure Illness are condition relief, not pool healing. + expect(getSpell("cure-minor-disorders")?.healing).toBeUndefined(); + expect(getSpell("cure-illness")?.healing).toBeUndefined(); }); test("castability depends on available P.P.E.", () => {