Files
ttrpg_manager/src/lib/mechanics/deathSaves.ts
T
NilsBriggen 2651ac03b7 V2 Phase 2: spell + resource enforcement (sheet → combat → live)
The Slice-0 vertical that proves the enforcement architecture end-to-end.

- kernel: castSpell (slot consumption + concentration set/replace, upcasting,
  warlock pact slots), spendResource/regainResource, rollDeathSave — pure
  functions returning Partial<Character> patches. 13 unit tests.
- applyRest now ends concentration on any rest (+ test).
- character sheet SpellcastingSection: per-spell Cast button, concentration
  banner with Drop, concentration markers; ResourcesSection −/+ routed through
  the kernel.
- player MyCharacterPanel: castable spell list + concentration banner, so a
  seated player casts and it broadcasts via the existing playerPatch flow.
- live sync: partialCharacterDiffSchema carries concentration; GM applies it;
  playerCharacterSchema mirrors slots/concentration/resources (PC-only) and the
  player-facing AC now respects equippedArmor. Wire round-trip test added.

Verified live: casting decrements the right slot, switching concentration logs
"Concentration on X ends", banner + Drop render. Build + 258 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:10:07 +02:00

49 lines
1.6 KiB
TypeScript

import type { Character } from '@/lib/schemas/character';
export interface DeathSaveOutcome {
patch: Partial<Character>;
/** 'pending' = still rolling; 'stable' = 3 successes; 'dead' = 3 failures; 'revived' = nat 20 */
outcome: 'pending' | 'stable' | 'dead' | 'revived';
log: string;
}
/**
* Apply one 5e death saving throw given a raw d20 face. Pure:
* - nat 20 → regain 1 HP, clear the death-save clock (back in the fight)
* - nat 1 → two failures
* - ≥10 → one success (3 → stable)
* - <10 → one failure (3 → dead)
*/
export function rollDeathSave(c: Character, d20: number): DeathSaveOutcome {
const ds = c.defenses.deathSaves;
if (d20 === 20) {
return {
patch: {
hp: { ...c.hp, current: 1 },
defenses: { ...c.defenses, deathSaves: { successes: 0, failures: 0 } },
},
outcome: 'revived',
log: `${c.name} rolls a natural 20 and regains 1 HP!`,
};
}
let successes = ds.successes;
let failures = ds.failures;
if (d20 === 1) failures = Math.min(3, failures + 2);
else if (d20 >= 10) successes = Math.min(3, successes + 1);
else failures = Math.min(3, failures + 1);
const patch: Partial<Character> = { defenses: { ...c.defenses, deathSaves: { successes, failures } } };
if (failures >= 3) return { patch, outcome: 'dead', log: `${c.name} fails a third death save and dies.` };
if (successes >= 3) return { patch, outcome: 'stable', log: `${c.name} stabilizes.` };
return {
patch,
outcome: 'pending',
log: d20 >= 10
? `${c.name} succeeds a death save (${successes}/3).`
: `${c.name} fails a death save (${failures}/3).`,
};
}