diff --git a/packages/rules/src/content/spells/spells.json b/packages/rules/src/content/spells/spells.json new file mode 100644 index 0000000..7bc75fb --- /dev/null +++ b/packages/rules/src/content/spells/spells.json @@ -0,0 +1,336 @@ +{ + "book": "Rifts Ultimate Edition", + "spellStrengthBase": 12, + "ritualSaveTarget": 16, + "spells": [ + { + "id": "blinding-flash", + "name": "Blinding Flash", + "level": 1, + "ppe": 1, + "range": "10 ft (3 m) radius, up to 60 ft (18.3 m) away", + "duration": "Instant", + "savingThrow": "standard", + "savingThrowNote": "-1 to save if 3 P.P.E. are pumped in; does not affect robotic/bionic/cybernetic eyes", + "description": "Blinds everyone in a 10 ft radius for 1D4 melees (-10 strike/parry/dodge).", + "page": 198 + }, + { + "id": "cloud-of-smoke", + "name": "Cloud of Smoke", + "level": 1, + "ppe": 2, + "range": "90 ft (27.4 m)", + "duration": "4 melees (1 minute) per level", + "savingThrow": "none", + "description": "Dense black smoke up to 30x30x30 ft; victims inside are -5 strike/parry/dodge/disarm/entangle.", + "page": 198 + }, + { + "id": "death-trance", + "name": "Death Trance", + "level": 1, + "ppe": 1, + "range": "Self only", + "duration": "10 melees (2.5 minutes) per level", + "savingThrow": "none", + "description": "The caster appears dead (no breathing/pulse). Helpless while in the trance; cancelable at will.", + "page": 198 + }, + { + "id": "globe-of-daylight", + "name": "Globe of Daylight", + "level": 1, + "ppe": 2, + "range": "Near self or up to 30 ft (9.1 m) away", + "duration": "12 melees (3 minutes) per level", + "savingThrow": "none", + "description": "A globe of true daylight lighting a 12 ft area per level; wards off vampires/undead.", + "page": 198 + }, + { + "id": "lantern-light", + "name": "Lantern Light", + "level": 1, + "ppe": 1, + "range": "10 ft (3 m); can light up a room", + "duration": "30 minutes per level", + "savingThrow": "none", + "description": "A dimmable floating light (50-300 watt equivalent). Not sunlight; no effect on vampires.", + "page": 198 + }, + { + "id": "see-aura", + "name": "See Aura", + "level": 1, + "ppe": 6, + "range": "100 ft (30.5 m)", + "duration": "One melee", + "savingThrow": "none", + "description": "Reveals level of experience, presence of magic/psionics, base P.P.E., possession, and health.", + "page": 199 + }, + { + "id": "see-the-invisible", + "name": "See the Invisible", + "level": 1, + "ppe": 4, + "range": "200 ft (61 m)", + "duration": "One minute (4 melees) per level", + "savingThrow": "none", + "description": "See astral beings, entities, elementals, ghosts, and naturally/magically invisible things.", + "page": 199 + }, + { + "id": "sense-evil", + "name": "Sense Evil", + "level": 1, + "ppe": 2, + "range": "90 ft (27.4 m) area", + "duration": "Two minutes (8 melees) per level", + "savingThrow": "none", + "description": "Sense the presence and approximate number/location of (especially supernatural) evil.", + "page": 199 + }, + { + "id": "sense-magic", + "name": "Sense Magic", + "level": 1, + "ppe": 4, + "range": "120 ft (36 m) area", + "duration": "Two minutes (8 melees) per level", + "savingThrow": "none", + "description": "Sense magic being used/enchantments nearby, like a Geiger counter. Not psionics.", + "page": 199 + }, + { + "id": "thunderclap", + "name": "Thunderclap", + "level": 1, + "ppe": 4, + "range": "30 ft (9.1 m); heard up to 1 mile", + "duration": "Instant", + "savingThrow": "horrorFactor", + "description": "A booming thunderclap. Caster gets +5 initiative, +1 strike/parry/dodge; creates Horror Factor 8.", + "page": 199 + }, + + { + "id": "befuddle", + "name": "Befuddle", + "level": 2, + "ppe": 6, + "range": "100 ft (30.5 m)", + "duration": "Two minutes (8 melees) per level", + "savingThrow": "standard", + "description": "Confuses one victim: -2 strike/parry/dodge, attacks halved, all skills -20%.", + "page": 199 + }, + { + "id": "chameleon", + "name": "Chameleon", + "level": 2, + "ppe": 6, + "range": "Self or others by touch", + "duration": "18 melees (4.5 minutes) per level", + "savingThrow": "none", + "description": "Blend into surroundings: 90% undetectable if still, less while moving; ineffective if moving fast.", + "page": 199 + }, + { + "id": "heavy-breathing", + "name": "Heavy Breathing", + "level": 2, + "ppe": 5, + "range": "60 ft (18.3 m)", + "duration": "75 seconds (5 melees) per level", + "savingThrow": "standard", + "savingThrowNote": "those who save are not affected/fearful", + "description": "Conjures frightful breathing sounds; those who fail may flee, others are -2 strike / -1 parry/dodge.", + "page": 201 + }, + { + "id": "levitation", + "name": "Levitation", + "level": 2, + "ppe": 5, + "range": "Up to 60 ft (18.3 m) away", + "duration": "Three minutes (12 melees) per level", + "savingThrow": "standard", + "description": "Raise self/others/an object straight up and hover; up to 200 lbs +20 lbs per level.", + "page": 201 + }, + { + "id": "turn-dead", + "name": "Turn Dead", + "level": 2, + "ppe": 6, + "range": "Up to 60 ft (18.3 m) away", + "duration": "Instant", + "savingThrow": "standard", + "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", + "level": 3, + "ppe": 10, + "range": "Self or other by touch", + "duration": "One minute (4 melees) per level", + "savingThrow": "none", + "description": "Invisible mystic armor with M.D.C. 10 per level. Magic fire/lightning/cold do half damage to it.", + "page": 202 + }, + { + "id": "breathe-without-air", + "name": "Breathe Without Air", + "level": 3, + "ppe": 5, + "range": "Self or others by touch", + "duration": "12 melees (3 minutes) per level", + "savingThrow": "none", + "description": "Function normally without air (underwater, vacuum). Protects vs natural/man-made gases, not magic toxins.", + "page": 202 + }, + { + "id": "energy-bolt", + "name": "Energy Bolt", + "level": 3, + "ppe": 5, + "range": "150 ft (45.7 m)", + "duration": "Instant", + "savingThrow": "dodge", + "savingThrowNote": "dodge of 18 or higher", + "damage": "4D6 S.D.C. (6D6 on a ley line, 8D6 at a nexus)", + "description": "A mentally-directed energy bolt; one target per casting.", + "page": 202 + }, + { + "id": "fingers-of-the-wind", + "name": "Fingers of the Wind", + "level": 3, + "ppe": 5, + "range": "90 ft (27.4 m)", + "duration": "Three melees per level", + "savingThrow": "none", + "description": "Conjure and manipulate wind to touch/tap/press objects under 10 lbs.", + "page": 202 + }, + { + "id": "float-in-air", + "name": "Float in Air", + "level": 3, + "ppe": 5, + "range": "Self or others within 30 ft (9.1 m)", + "duration": "10 melees per level", + "savingThrow": "none", + "description": "Hover 1-2 ft above ground; slow a fall or float on water. -1 to all combat, half speed.", + "page": 202 + }, + { + "id": "fuel-flame", + "name": "Fuel Flame", + "level": 3, + "ppe": 5, + "range": "120 ft (36.6 m)", + "duration": "Instant", + "savingThrow": "none", + "description": "Doubles the size of an existing fire, up to a 100 ft area.", + "page": 202 + }, + { + "id": "ignite-fire", + "name": "Ignite Fire", + "level": 3, + "ppe": 6, + "range": "40 ft (12.2 m)", + "duration": "Instant (fire lasts until put out)", + "savingThrow": "none", + "damage": "2D6 S.D.C. per melee (clothes/hair, after first 2 melees)", + "description": "Spontaneous combustion of flammable material (max 3 ft area). Cannot ignite contained volatiles.", + "page": 202 + }, + { + "id": "impervious-to-fire", + "name": "Impervious to Fire", + "level": 3, + "ppe": 5, + "range": "Self or others up to 60 ft (18.3 m) away", + "duration": "Five minutes (20 melees) per level", + "savingThrow": "none", + "description": "Normal, magical, and Mega-Damage fire do no damage to the protected person or their gear.", + "page": 202 + }, + { + "id": "impervious-to-poison", + "name": "Impervious to Poison", + "level": 3, + "ppe": 5, + "range": "Self or others by touch", + "duration": "Five minutes (20 melees) per level", + "savingThrow": "none", + "description": "Temporarily impervious to poisons, venom, toxins, pollution, and poison gas.", + "page": 202 + }, + + { + "id": "blind", + "name": "Blind", + "level": 4, + "ppe": 6, + "range": "Touch or 10 ft (3 m) away", + "duration": "One minute (4 melees) per level", + "savingThrow": "standard", + "description": "Blinds one victim: -5 strike, -10 parry/dodge, 50% chance to stumble per 10 ft. Not vs enclosed armor/robots.", + "page": 204 + }, + { + "id": "carpet-of-adhesion", + "name": "Carpet of Adhesion", + "level": 4, + "ppe": 10, + "range": "30 ft (9.1 m) +10 ft per level", + "duration": "10 melees (2.5 minutes) per level", + "savingThrow": "special", + "description": "A sticky carpet (up to 10x20 ft) anyone touching adheres to. Works even on cyborgs/robots/Supernatural P.S.", + "page": 204 + }, + { + "id": "charismatic-aura", + "name": "Charismatic Aura", + "level": 4, + "ppe": 10, + "range": "60 ft (18.3 m) radius", + "duration": "Six melees per level", + "savingThrow": "standard", + "description": "Boosts P.B. by 8 and charms all in radius. Can invoke friendship/trust, power/fear (Horror Factor 13), or deception.", + "page": 204 + }, + { + "id": "cure-minor-disorders", + "name": "Cure Minor Disorders", + "level": 4, + "ppe": 10, + "range": "Touch or 10 ft (3 m)", + "duration": "Instant", + "savingThrow": "standard", + "savingThrowNote": "only if unwanted", + "description": "Instantly relieves minor ailments (headache, nausea, low fever, muscle stiffness) and negates minor-disorder curses.", + "page": 204 + }, + { + "id": "electric-arc", + "name": "Electric Arc", + "level": 4, + "ppe": 8, + "range": "30 ft (9 m) per level", + "duration": "One melee round", + "savingThrow": "dodge", + "damage": "2D6 M.D.", + "description": "A crackling bolt of blue energy; +2 to strike. Each blast counts as one melee attack.", + "page": 204 + } + ] +} diff --git a/packages/rules/src/engine/spells.ts b/packages/rules/src/engine/spells.ts new file mode 100644 index 0000000..a0ab8bf --- /dev/null +++ b/packages/rules/src/engine/spells.ts @@ -0,0 +1,109 @@ +import type { Occ } from "../schema/occ.ts"; +import { spellBookSchema, type Spell } from "../schema/spells.ts"; +import spellsRaw from "../content/spells/spells.json" with { type: "json" }; + +/** The spell book (RUE Magic Spells), validated at load. */ +export const spellBook = spellBookSchema.parse(spellsRaw); + +function normalizeName(name: string): string { + return name.trim().toLowerCase(); +} + +// id + name indexes, failing fast on collisions (same approach as the skill catalog). +const spellById = new Map(); +const spellByName = new Map(); +for (const s of spellBook.spells) { + if (spellById.has(s.id)) { + throw new Error(`Duplicate spell id "${s.id}" in the spell book.`); + } + spellById.set(s.id, s); + const key = normalizeName(s.name); + if (spellByName.has(key)) { + throw new Error(`Duplicate spell name "${s.name}" in the spell book.`); + } + spellByName.set(key, s); +} + +export function getSpell(id: string): Spell | undefined { + return spellById.get(id); +} + +export function getSpellByName(name: string): Spell | undefined { + return spellByName.get(normalizeName(name)); +} + +/** All spells of a given level. */ +export function spellsByLevel(level: number): Spell[] { + return spellBook.spells.filter((s) => s.level === level); +} + +/** Whether a caster with `availablePpe` can afford to cast `spell`. */ +export function canCast(spell: Spell, availablePpe: number): boolean { + return availablePpe >= spell.ppe; +} + +/** + * A caster's Spell Strength (RUE p.187): base 12, plus one for each experience + * level (<= the caster's level) at which their O.C.C. grants a Spell Strength + * increase. This is the d20 number a victim must roll to save against the spell. + */ +export function spellStrength(casterLevel: number, incrementLevels: readonly number[]): number { + return spellBook.spellStrengthBase + incrementLevels.filter((l) => l <= casterLevel).length; +} + +/** + * Spell Strength from a single spell-strength bonus (or none). Handles both + * shapes the schema allows: + * - **level-gated** (`atLevels` present): apply the increment once per increment + * level the caster has reached. + * - **flat** (no `atLevels`): apply the value once, at every level. + */ +export function spellStrengthFromBonus( + bonus: { value?: number; atLevels?: readonly number[] } | undefined, + casterLevel: number, +): number { + const base = spellBook.spellStrengthBase; + if (!bonus) return base; + const increment = typeof bonus.value === "number" ? bonus.value : 1; + if (bonus.atLevels && bonus.atLevels.length > 0) { + return base + bonus.atLevels.filter((l) => l <= casterLevel).length * increment; + } + return base + increment; +} + +/** Spell Strength for a specific O.C.C. at a given level, read from its bonuses. */ +export function occSpellStrength(occ: Occ, casterLevel: number): number { + return spellStrengthFromBonus( + occ.bonuses?.find((b) => b.type === "spellStrength"), + casterLevel, + ); +} + +/** The d20 target a victim must roll to save against a caster's spell magic. */ +export function saveTargetVsSpell(casterSpellStrength: number): number { + return casterSpellStrength; +} + +/** The d20 target to save against ritual magic (fixed; spell-strength bonuses don't apply). */ +export const ritualSaveTarget = spellBook.ritualSaveTarget; + +export interface InitialSpellChoice { + level: number; + choose: number; + options: Spell[]; +} + +/** + * The spells available for an O.C.C.'s initial spell selection, grouped by + * eligible level (e.g. the Ley Line Walker picks `fromEachLevel` from each of + * levels 1-4). Returns the choose-count and options for each eligible level. + */ +export function initialSpellChoices(occ: Occ): InitialSpellChoice[] { + const init = occ.spellKnowledge?.initial; + if (!init) return []; + return init.spellLevels.map((level) => ({ + level, + choose: init.fromEachLevel, + options: spellsByLevel(level), + })); +} diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 411d009..1345b10 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -2,8 +2,10 @@ export * from "./schema/attributes.ts"; export * from "./schema/occ.ts"; export * from "./schema/combat.ts"; export * from "./schema/skills.ts"; +export * from "./schema/spells.ts"; export * from "./engine/attributes.ts"; export * from "./engine/dice.ts"; export * from "./engine/occ.ts"; export * from "./engine/combat.ts"; export * from "./engine/skills.ts"; +export * from "./engine/spells.ts"; diff --git a/packages/rules/src/schema/spells.ts b/packages/rules/src/schema/spells.ts new file mode 100644 index 0000000..d6f6b98 --- /dev/null +++ b/packages/rules/src/schema/spells.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +/** How a target resists a spell, per the spell's "Saving Throw" line. */ +export const savingThrowKindSchema = z.enum([ + "none", + "standard", + "dodge", + "horrorFactor", + "special", +]); +export type SavingThrowKind = z.infer; + +/** A single magic spell (invocation). */ +export const spellSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + /** Spell level (1-15). */ + level: z.number().int().min(1).max(15), + /** P.P.E. cost to cast. */ + ppe: z.number().int().nonnegative(), + /** Range as printed (e.g. "150 feet", "Self", "Touch"). */ + range: z.string().min(1), + /** Duration as printed (e.g. "Instant", "12 melees per level"). */ + duration: z.string().min(1), + savingThrow: savingThrowKindSchema, + /** Clarifier for the save (e.g. "dodge of 18 or higher"). */ + savingThrowNote: z.string().optional(), + /** Damage expression as printed, if the spell deals damage. */ + damage: z.string().optional(), + description: z.string().optional(), + page: z.number().int().positive(), +}); +export type Spell = z.infer; + +export const spellBookSchema = z.object({ + book: z.string().min(1), + /** Base Spell Strength: the d20 number a victim must roll to save vs magic (RUE p.187). */ + spellStrengthBase: z.number().int().positive(), + /** Save target vs ritual magic (RUE p.187). */ + ritualSaveTarget: z.number().int().positive(), + spells: z.array(spellSchema), +}); +export type SpellBook = z.infer; diff --git a/packages/rules/tests/spells.test.ts b/packages/rules/tests/spells.test.ts new file mode 100644 index 0000000..89dc2db --- /dev/null +++ b/packages/rules/tests/spells.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + canCast, + getSpell, + getSpellByName, + initialSpellChoices, + leyLineWalker, + occSpellStrength, + ritualSaveTarget, + saveTargetVsSpell, + spellBook, + spellStrength, + spellStrengthFromBonus, + spellsByLevel, +} from "../src/index.ts"; + +describe("spell book (RUE Magic Spells, levels 1-4)", () => { + test("validates and carries the RUE spell-strength constants (p.187)", () => { + expect(spellBook.spellStrengthBase).toBe(12); + expect(ritualSaveTarget).toBe(16); + }); + + test("lookup by id and by name", () => { + expect(getSpell("armor-of-ithan")).toMatchObject({ level: 3, ppe: 10 }); + expect(getSpellByName("Energy Bolt")?.id).toBe("energy-bolt"); + expect(getSpellByName(" globe of daylight ")?.id).toBe("globe-of-daylight"); + 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("castability depends on available P.P.E.", () => { + const ithan = getSpell("armor-of-ithan")!; + expect(canCast(ithan, 137)).toBe(true); // an average level-1 LLW has ~137 P.P.E. + expect(canCast(ithan, 9)).toBe(false); // needs 10 + }); +}); + +describe("spell strength (RUE p.187)", () => { + test("base 12, +1 per increment level reached", () => { + const levels = [3, 7, 10, 13]; // Ley Line Walker's spell-strength levels + expect(spellStrength(1, levels)).toBe(12); + expect(spellStrength(3, levels)).toBe(13); + expect(spellStrength(7, levels)).toBe(14); + expect(spellStrength(10, levels)).toBe(15); + expect(spellStrength(13, levels)).toBe(16); + }); + + test("derived from the O.C.C.'s own bonuses", () => { + expect(occSpellStrength(leyLineWalker, 1)).toBe(12); + expect(occSpellStrength(leyLineWalker, 7)).toBe(14); + expect(occSpellStrength(leyLineWalker, 13)).toBe(16); + }); + + test("a flat spell-strength bonus (no atLevels) applies once at every level", () => { + expect(spellStrengthFromBonus({ value: 2 }, 1)).toBe(14); + expect(spellStrengthFromBonus({ value: 2 }, 10)).toBe(14); + expect(spellStrengthFromBonus({ value: 2, atLevels: [] }, 5)).toBe(14); + }); + + test("a level-gated bonus increments per level reached; no bonus = base", () => { + const gated = { value: 1, atLevels: [3, 7, 10, 13] }; + expect(spellStrengthFromBonus(gated, 1)).toBe(12); + expect(spellStrengthFromBonus(gated, 7)).toBe(14); + expect(spellStrengthFromBonus(undefined, 5)).toBe(12); + }); + + test("save target vs a spell is the caster's spell strength", () => { + expect(saveTargetVsSpell(occSpellStrength(leyLineWalker, 3))).toBe(13); + }); +}); + +describe("Ley Line Walker initial spell selection (RUE p.116)", () => { + test("3 spells from each of levels 1-4, with real options available", () => { + const choices = initialSpellChoices(leyLineWalker); + expect(choices.map((c) => c.level)).toEqual([1, 2, 3, 4]); + for (const c of choices) { + expect(c.choose).toBe(3); + expect(c.options.length).toBeGreaterThanOrEqual(c.choose); + } + }); +});