diff --git a/apps/web/src/components/sheet-view.tsx b/apps/web/src/components/sheet-view.tsx index 64117ab..80515dd 100644 --- a/apps/web/src/components/sheet-view.tsx +++ b/apps/web/src/components/sheet-view.tsx @@ -9,10 +9,14 @@ import type { import { createEffect, createSignal, For, on, onCleanup, Show, type JSX } from "solid-js"; import { Chip, DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx"; -/** "rolled 17" once vitals are pinned, otherwise the possible range. */ +function statRange(stat: StatValue): string { + return `${stat.min}–${stat.max} (avg ${stat.average})`; +} + +/** `current / max` once vitals are pinned (DESIGN.md), otherwise the possible range. */ function statValue(stat: StatValue): string { - const range = `${stat.min}–${stat.max} (avg ${stat.average})`; - return stat.rolled === undefined ? range : `${stat.rolled} (range ${range})`; + if (stat.rolled === undefined) return statRange(stat); + return `${stat.current ?? stat.rolled} / ${stat.rolled}`; } function saveTarget(save: SheetSave): string { @@ -77,12 +81,18 @@ function PortraitFrame() { ); } -/** A stat line; clickable (and amber on hover) when the page wires a roll. */ +/** A stat line; clickable (and amber on hover) when the page wires a roll. + * `disabled` renders the row dead-steel and inert — e.g. a spell the + * character can't afford — via `aria-disabled` (not the native attribute), + * so it stays focusable and the `title` reason reaches keyboard and + * screen-reader users. */ function StatRow(props: { label: JSX.Element; value: JSX.Element; live?: boolean; onRoll?: () => void; + disabled?: boolean; + title?: string; }) { const value = () => ( + + } /> diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index c45ea62..202090d 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -1,14 +1,18 @@ import { + applyDamage as damagePools, characterSchema, + comaDeathFloor, deriveSheet, getOcc, + getSpell, rollHitPoints, rollPhysicalSdc, rollPpe, type Character, } from "@riftforge/rules"; import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { mutation, query, type MutationCtx } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; import { characterFields } from "./schema"; /** A stored character document: the fields plus Convex's system columns. */ @@ -40,6 +44,30 @@ function validateCharacter(input: unknown): Character { return character; } +/** Load a stored character or throw; returns the parsed choices sans system columns. */ +async function loadCharacter(ctx: MutationCtx, id: Id<"characters">): Promise { + const doc = await ctx.db.get(id); + if (doc === null) throw new Error(`Character ${id} not found.`); + const { _id, _creationTime, ...stored } = doc; + return characterSchema.parse(stored); +} + +/** + * Write a new live-vitals state. Round-trips through the rules layer first + * (like every write), so a `current` that exceeds its maximum or sinks below + * the coma/death floor can never be stored — mutations compute legal values, + * this is the backstop. + */ +async function patchCurrent( + ctx: MutationCtx, + id: Id<"characters">, + character: Character, + current: Character["current"], +): Promise { + validateCharacter({ ...character, current }); + await ctx.db.patch(id, { current }); +} + export const create = mutation({ args: characterInputFields, returns: v.id("characters"), @@ -97,10 +125,7 @@ export const rollVitals = mutation({ ppe: v.optional(v.number()), }), handler: async (ctx, { id }) => { - const doc = await ctx.db.get(id); - if (doc === null) throw new Error(`Character ${id} not found.`); - const { _id, _creationTime, ...stored } = doc; - const character = characterSchema.parse(stored); + const character = await loadCharacter(ctx, id); const occ = getOcc(character.occId); if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); const pe = character.attributes.PE; @@ -109,11 +134,116 @@ export const rollVitals = mutation({ sdc: rollPhysicalSdc(), ...(occ.ppe ? { ppe: rollPpe(occ, pe, character.level) } : {}), }; - await ctx.db.patch(id, { rolled }); + // New maximums invalidate the old live state — a reroll is a fresh start, + // so clear `current` (absent = at maximum) rather than carry stale spend. + await ctx.db.patch(id, { rolled, current: undefined }); return rolled; }, }); +/** + * Cast a known spell: decrement live P.P.E. by its printed cost. The cost is + * derived server-side from the spell id — the client never names a price. + * Rejects casts the character can't afford (the sheet greys those out, but + * the server is the authority) and casts before vitals are rolled (no + * maximum to spend from). + */ +export const castSpell = mutation({ + args: { id: v.id("characters"), spellId: v.string() }, + returns: v.object({ + spent: v.number(), + ppe: v.object({ current: v.number(), max: v.number() }), + }), + handler: async (ctx, { id, spellId }) => { + const character = await loadCharacter(ctx, id); + if (!character.spellIds.includes(spellId)) { + throw new Error(`Character does not know the spell "${spellId}".`); + } + const spell = getSpell(spellId); + if (!spell) throw new Error(`Unknown spell "${spellId}".`); + const 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; + if (spell.ppe > available) { + throw new Error( + `Insufficient P.P.E.: ${spell.name} costs ${spell.ppe}, ${available} remaining.`, + ); + } + const remaining = available - spell.ppe; + await patchCurrent(ctx, id, character, { ...character.current, ppe: remaining }); + return { spent: spell.ppe, ppe: { current: remaining, max } }; + }, +}); + +/** + * Deal damage to the live pools: S.D.C. absorbs first, the overflow comes off + * Hit Points, which stop at the -(P.E.) coma/death floor (rules-layer + * `applyDamage`, RUE pp.287/347). + */ +export const applyDamage = mutation({ + args: { id: v.id("characters"), amount: v.number() }, + returns: v.object({ sdc: v.number(), hitPoints: v.number() }), + handler: async (ctx, { id, amount }) => { + const character = await loadCharacter(ctx, id); + const rolled = character.rolled; + if (rolled?.hitPoints === undefined || rolled.sdc === undefined) { + throw new Error("Roll vitals before applying damage — no pools to deplete."); + } + const pool = { + sdc: character.current?.sdc ?? rolled.sdc, + hitPoints: character.current?.hitPoints ?? rolled.hitPoints, + }; + const next = damagePools(pool, amount, comaDeathFloor(character.attributes.PE)); + await patchCurrent(ctx, id, character, { ...character.current, ...next }); + return next; + }, +}); + +/** + * Recover points into one or more pools, clamped at the rolled maximums. + * Amounts are how much to ADD (a first-aid roll, a rest tick) — full recovery + * is `restoreVitals`. Recovery *rates* (P.P.E. per hour of rest, ley-line + * draw) are rules-layer follow-up work on #38. + */ +export const heal = mutation({ + args: { + id: v.id("characters"), + hitPoints: v.optional(v.number()), + sdc: v.optional(v.number()), + ppe: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, { id, ...amounts }) => { + const character = await loadCharacter(ctx, id); + const current = { ...character.current }; + for (const field of ["hitPoints", "sdc", "ppe"] as const) { + const amount = amounts[field]; + if (amount === undefined) continue; + if (!Number.isInteger(amount) || amount < 0) { + throw new Error(`heal.${field} must be a non-negative integer, got ${amount}.`); + } + const max = character.rolled?.[field]; + if (max === undefined) { + throw new Error(`Cannot heal ${field} — it has not been rolled.`); + } + current[field] = Math.min(max, (current[field] ?? max) + amount); + } + await patchCurrent(ctx, id, character, current); + return null; + }, +}); + +/** Reset every pool to its rolled maximum (absent `current` means "full"). */ +export const restoreVitals = mutation({ + args: { id: v.id("characters") }, + returns: v.null(), + handler: async (ctx, { id }) => { + await loadCharacter(ctx, id); // existence check + await ctx.db.patch(id, { current: undefined }); + return null; + }, +}); + /** Newest characters first; a hard page size keeps the query bounded as the table grows. */ const LIST_LIMIT = 50; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 3bd7c9b..d617663 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -46,6 +46,14 @@ export const characterFields = { ppe: v.optional(v.number()), }), ), + /** Live resource state (damage taken, P.P.E. spent); absent = at maximum. */ + current: v.optional( + v.object({ + hitPoints: v.optional(v.number()), + sdc: v.optional(v.number()), + ppe: v.optional(v.number()), + }), + ), narrative: v.optional( v.object({ epithet: v.optional(v.string()), diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 63f826e..2c29944 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -189,3 +189,136 @@ describe("characters — a saved Ley Line Walker round-trips", () => { expect(await t.query(api.characters.sheet, { id })).toBeNull(); }); }); + +describe("living vitals — current vs. max (#38)", () => { + /** A saved Vesper with vitals pinned to known values (P.E. 14 -> floor -14). */ + async function savedVesper(t: ReturnType) { + const id = await t.mutation(api.characters.create, vesper); + await t.run(async (ctx) => { + await ctx.db.patch(id, { rolled: { hitPoints: 18, sdc: 20, ppe: 84 } }); + }); + return id; + } + + test("castSpell spends the spell's printed cost, floor at the server", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + + // Energy Bolt costs 5 P.P.E. (content, RUE) — the client never names a price. + const first = await t.mutation(api.characters.castSpell, { id, spellId: "energy-bolt" }); + expect(first).toEqual({ spent: 5, ppe: { current: 79, max: 84 } }); + + // Spends accumulate; the sheet streams the live value next to the max. + await t.mutation(api.characters.castSpell, { id, spellId: "armor-of-ithan" }); + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.ppe).toMatchObject({ rolled: 84, current: 69 }); + }); + + test("castSpell rejects casts the character can't make", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + + // A spell the character hasn't learned (even though the catalog has it). + await expect(t.mutation(api.characters.castSpell, { id, spellId: "see-aura" })).rejects.toThrow( + /does not know/, + ); + + // Not enough P.P.E. left — and a failed cast must not spend anything. + await t.run(async (ctx) => { + await ctx.db.patch(id, { current: { ppe: 3 } }); + }); + await expect( + t.mutation(api.characters.castSpell, { id, spellId: "energy-bolt" }), + ).rejects.toThrow(/Insufficient P\.P\.E\./); + expect((await t.query(api.characters.get, { id }))?.current).toEqual({ ppe: 3 }); + }); + + test("castSpell before vitals are rolled is refused", async () => { + const t = convexTest(schema, modules); + const id = await t.mutation(api.characters.create, vesper); + await expect( + t.mutation(api.characters.castSpell, { id, spellId: "energy-bolt" }), + ).rejects.toThrow(/Roll vitals/); + }); + + test("applyDamage depletes S.D.C. before H.P., down to the coma floor", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + + expect(await t.mutation(api.characters.applyDamage, { id, amount: 7 })).toEqual({ + sdc: 13, + hitPoints: 18, + }); + expect(await t.mutation(api.characters.applyDamage, { id, amount: 20 })).toEqual({ + sdc: 0, + hitPoints: 11, + }); + // Overkill clamps at -(P.E.), the coma/death floor. + expect(await t.mutation(api.characters.applyDamage, { id, amount: 999 })).toEqual({ + sdc: 0, + hitPoints: -14, + }); + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.vitals.hitPoints).toMatchObject({ rolled: 18, current: -14 }); + expect(sheet?.vitals.sdc).toMatchObject({ rolled: 20, current: 0 }); + + await expect(t.mutation(api.characters.applyDamage, { id, amount: -5 })).rejects.toThrow( + /non-negative/, + ); + }); + + test("heal recovers points, clamped at the rolled maximums", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.applyDamage, { id, amount: 25 }); // sdc 0, hp 13 + + await t.mutation(api.characters.heal, { id, sdc: 6, hitPoints: 2 }); + let stored = await t.query(api.characters.get, { id }); + expect(stored?.current).toMatchObject({ sdc: 6, hitPoints: 15 }); + + // Over-healing stops at the maximum, and untouched pools stay untouched. + await t.mutation(api.characters.heal, { id, sdc: 999 }); + stored = await t.query(api.characters.get, { id }); + expect(stored?.current).toMatchObject({ sdc: 20, hitPoints: 15 }); + + await expect(t.mutation(api.characters.heal, { id, ppe: -1 })).rejects.toThrow(/non-negative/); + }); + + test("restoreVitals resets every pool to full", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.applyDamage, { id, amount: 25 }); + await t.mutation(api.characters.castSpell, { id, spellId: "energy-bolt" }); + + await t.mutation(api.characters.restoreVitals, { id }); + const stored = await t.query(api.characters.get, { id }); + expect(stored?.current).toBeUndefined(); + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.ppe).toMatchObject({ rolled: 84, current: 84 }); + }); + + test("rollVitals clears the live state along with the old maximums", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await t.mutation(api.characters.castSpell, { id, spellId: "energy-bolt" }); + await t.mutation(api.characters.rollVitals, { id }); + const stored = await t.query(api.characters.get, { id }); + expect(stored?.current).toBeUndefined(); + }); + + test("illegal `current` states are rejected at every write", async () => { + const t = convexTest(schema, modules); + // Above the maximum on create. + await expect( + t.mutation(api.characters.create, { + ...vesper, + rolled: { ppe: 84 }, + current: { ppe: 90 }, + }), + ).rejects.toThrow(/exceeds the rolled maximum/); + // Without a rolled maximum on create. + await expect( + t.mutation(api.characters.create, { ...vesper, current: { ppe: 10 } }), + ).rejects.toThrow(/requires rolled/); + }); +}); diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts index f46969a..8fc309e 100644 --- a/packages/rules/src/engine/character.ts +++ b/packages/rules/src/engine/character.ts @@ -21,6 +21,9 @@ import { getSpell, occSpellStrength } from "./spells.ts"; /** A dice-derived stat: its range, plus the concrete roll if one was recorded. */ export interface StatValue extends StatRange { rolled?: number; + /** What's left of the rolled maximum (damage taken, P.P.E. spent). Present + * exactly when `rolled` is; equals `rolled` until something depletes it. */ + current?: number; } /** A saving throw: the d20 target (fixed or a range) and the character's total bonus. */ @@ -73,8 +76,8 @@ function occSaveBonus(occ: Occ, target: string, level: number): number { return total; } -function withRolled(range: StatRange, rolled?: number): StatValue { - return rolled === undefined ? { ...range } : { ...range, rolled }; +function withRolled(range: StatRange, rolled?: number, current?: number): StatValue { + return rolled === undefined ? { ...range } : { ...range, rolled, current: current ?? rolled }; } /** @@ -106,6 +109,31 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { const isCaster = occ.spellKnowledge !== undefined || occ.ppe !== undefined; + // Live `current` values are only meaningful against a rolled maximum, and a + // write that exceeds it (or sinks H.P. below the coma/death floor) is a bug + // upstream — reject rather than clamp, so illegal states never reach storage + // (the backend validates every write through this function). + const floor = comaDeathFloor(attrs.PE); + const currentMinimums = [ + ["hitPoints", floor], + ["sdc", 0], + ["ppe", 0], + ] as const; + for (const [field, min] of currentMinimums) { + const cur = character.current?.[field]; + if (cur === undefined) continue; + const max = character.rolled?.[field]; + if (max === undefined) { + throw new Error(`current.${field} requires rolled.${field} — no maximum to measure against.`); + } + if (cur > max) { + throw new Error(`current.${field} (${cur}) exceeds the rolled maximum (${max}).`); + } + if (cur < min) { + throw new Error(`current.${field} (${cur}) is below the legal minimum (${min}).`); + } + } + const saves: Record = { magic: { targetRange: savingThrowTarget("magic")?.targetRange, @@ -175,11 +203,17 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { damageBonus: combat.damageBonus, }, vitals: { - hitPoints: withRolled(hitPointsRange(attrs.PE, level), character.rolled?.hitPoints), - sdc: withRolled(physicalSdcRange(), character.rolled?.sdc), - comaDeathFloor: comaDeathFloor(attrs.PE), + hitPoints: withRolled( + hitPointsRange(attrs.PE, level), + character.rolled?.hitPoints, + character.current?.hitPoints, + ), + sdc: withRolled(physicalSdcRange(), character.rolled?.sdc, character.current?.sdc), + comaDeathFloor: floor, }, - ppe: occ.ppe ? withRolled(ppeRange(occ, attrs.PE, level), character.rolled?.ppe) : undefined, + ppe: occ.ppe + ? withRolled(ppeRange(occ, attrs.PE, level), character.rolled?.ppe, character.current?.ppe) + : undefined, spellStrength: isCaster ? occSpellStrength(occ, level) : undefined, saves, skills, diff --git a/packages/rules/src/engine/combat.ts b/packages/rules/src/engine/combat.ts index 07307fa..dee4618 100644 --- a/packages/rules/src/engine/combat.ts +++ b/packages/rules/src/engine/combat.ts @@ -73,6 +73,28 @@ export function comaDeathFloor(pe: number): number { return -pe; } +/** A character's live damage pools: what's left of S.D.C. and Hit Points. */ +export interface VitalsPool { + sdc: number; + hitPoints: number; +} + +/** + * Deal damage to the pools: S.D.C. absorbs first — "all the S.D.C. of a living + * thing must be reduced to zero before the Hit Points can be affected by + * normal attacks" (RUE p.347) — then the overflow comes off Hit Points, which + * stop at the coma/death floor (0 down to the floor is the coma band; below + * it, dead — RUE p.287, floor = `comaDeathFloor(pe)`). + */ +export function applyDamage(pool: VitalsPool, damage: number, floor: number): VitalsPool { + if (!Number.isInteger(damage) || damage < 0) { + throw new Error(`Damage must be a non-negative integer, got ${damage}.`); + } + const sdcAfter = Math.max(0, pool.sdc - damage); + const overflow = Math.max(0, damage - pool.sdc); + return { sdc: sdcAfter, hitPoints: Math.max(floor, pool.hitPoints - overflow) }; +} + /** Total attacks per melee for a Hand-to-Hand type at a given level. */ export function attacksPerMelee(hthId: string, level: number): number { const t = requireHandToHand(hthId); diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts index 890f257..02f24bd 100644 --- a/packages/rules/src/schema/character.ts +++ b/packages/rules/src/schema/character.ts @@ -104,6 +104,19 @@ export const characterSchema = z.object({ ppe: z.number().int().nonnegative().optional(), }) .optional(), + /** Live resource state — what's LEFT of each rolled maximum (damage taken, + * P.P.E. spent). Absent fields mean "at maximum". A field is only legal when + * its maximum is rolled, never above it, and H.P. never below the -(P.E.) + * coma/death floor — enforced in `deriveSheet` (the floor needs P.E.), so + * illegal states are rejected at every write. H.P. may be negative: 0 down + * to the floor is the coma band (RUE p.287). */ + current: z + .object({ + hitPoints: z.number().int().optional(), + sdc: z.number().int().nonnegative().optional(), + ppe: z.number().int().nonnegative().optional(), + }) + .optional(), /** Optional player-authored identity; passed through to the sheet untouched. */ narrative: narrativeSchema.optional(), }); diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts index 25ee2cf..6f2a39f 100644 --- a/packages/rules/tests/character.test.ts +++ b/packages/rules/tests/character.test.ts @@ -73,9 +73,45 @@ describe("deriveSheet — a level-1 Ley Line Walker", () => { }); describe("deriveSheet — edge cases", () => { - test("a recorded H.P. roll shows as `rolled`", () => { + test("a recorded H.P. roll shows as `rolled`, with `current` defaulting to it", () => { const sheet = deriveSheet({ ...leyLineWalker, rolled: { hitPoints: 18 } }); expect(sheet.vitals.hitPoints.rolled).toBe(18); + expect(sheet.vitals.hitPoints.current).toBe(18); + // Unrolled stats carry neither. + expect(sheet.vitals.sdc.rolled).toBeUndefined(); + expect(sheet.vitals.sdc.current).toBeUndefined(); + }); + + test("live `current` values ride the sheet next to their maximums", () => { + const sheet = deriveSheet({ + ...leyLineWalker, + rolled: { hitPoints: 18, sdc: 20, ppe: 84 }, + current: { hitPoints: -3, sdc: 0, ppe: 79 }, + }); + expect(sheet.vitals.hitPoints).toMatchObject({ rolled: 18, current: -3 }); // coma band + expect(sheet.vitals.sdc).toMatchObject({ rolled: 20, current: 0 }); + expect(sheet.ppe).toMatchObject({ rolled: 84, current: 79 }); + }); + + test("illegal `current` states are rejected, not clamped", () => { + const rolled = { hitPoints: 18, sdc: 20, ppe: 84 }; + // No maximum to measure against. + expect(() => deriveSheet({ ...leyLineWalker, current: { ppe: 10 } })).toThrow( + /current\.ppe requires rolled\.ppe/, + ); + // Above the rolled maximum. + expect(() => deriveSheet({ ...leyLineWalker, rolled, current: { ppe: 85 } })).toThrow( + /exceeds the rolled maximum/, + ); + // H.P. below the -(P.E.) coma/death floor (P.E. 14 -> -14). + expect(() => deriveSheet({ ...leyLineWalker, rolled, current: { hitPoints: -15 } })).toThrow( + /below the legal minimum/, + ); + // At the floor exactly is still legal (coma, not gone). + expect( + deriveSheet({ ...leyLineWalker, rolled, current: { hitPoints: -14 } }).vitals.hitPoints + .current, + ).toBe(-14); }); test("an unknown O.C.C. throws", () => { diff --git a/packages/rules/tests/combat.test.ts b/packages/rules/tests/combat.test.ts index 01d13db..6f521f8 100644 --- a/packages/rules/tests/combat.test.ts +++ b/packages/rules/tests/combat.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; import { + applyDamage, attacksPerMelee, combatProfile, comaDeathFloor, @@ -34,6 +35,41 @@ describe("Hit Points & S.D.C. (RUE p.287)", () => { }); }); +describe("applyDamage — S.D.C. before H.P. (RUE p.347), coma floor (p.287)", () => { + const floor = comaDeathFloor(14); + + test("S.D.C. absorbs damage while it lasts", () => { + expect(applyDamage({ sdc: 20, hitPoints: 18 }, 7, floor)).toEqual({ + sdc: 13, + hitPoints: 18, + }); + }); + + test("overflow past S.D.C. comes off Hit Points", () => { + expect(applyDamage({ sdc: 5, hitPoints: 18 }, 12, floor)).toEqual({ + sdc: 0, + hitPoints: 11, + }); + }); + + test("H.P. can go negative (coma band) but stops at the floor", () => { + expect(applyDamage({ sdc: 0, hitPoints: 4 }, 10, floor)).toEqual({ + sdc: 0, + hitPoints: -6, + }); + expect(applyDamage({ sdc: 0, hitPoints: 4 }, 999, floor)).toEqual({ + sdc: 0, + hitPoints: -14, + }); + }); + + test("zero damage is a no-op; negative or fractional damage throws", () => { + expect(applyDamage({ sdc: 5, hitPoints: 18 }, 0, floor)).toEqual({ sdc: 5, hitPoints: 18 }); + expect(() => applyDamage({ sdc: 5, hitPoints: 18 }, -3, floor)).toThrow(/non-negative/); + expect(() => applyDamage({ sdc: 5, hitPoints: 18 }, 2.5, floor)).toThrow(/non-negative/); + }); +}); + describe("Hand to Hand progression (RUE pp.347-349)", () => { test("Basic attacks per melee: 4, +1 at 4/9/15", () => { expect(attacksPerMelee("basic", 1)).toBe(4);