From bd78c87fb3e9bcc235cac1e21a2ad6028b118e72 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 17:41:07 +0200 Subject: [PATCH] NumberField draft buffer; surface massive-damage / death-at-0 rules in combat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NumberField keeps a focus-time string draft so min-bounded fields (point-buy, HP, quantities) no longer snap to the minimum on every keystroke while retyping; the committed value is still always a clamped integer. - Combat tracker now logs the 5e death rules when a downed PC takes damage: instant death from massive damage (leftover >= max HP), otherwise a reminder to mark a death save failure (two on a crit). Surfaced, not auto-applied — the player owns their character's death-save count (consistent with the no-auto-roll design). New tested isMassiveDamageDeath() engine helper. Co-Authored-By: Claude Opus 4.8 --- src/components/ui/NumberField.tsx | 42 ++++++++++++++++++++---- src/features/combat/EncounterTracker.tsx | 14 +++++++- src/lib/combat/engine.test.ts | 11 +++++++ src/lib/combat/engine.ts | 9 +++++ 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/components/ui/NumberField.tsx b/src/components/ui/NumberField.tsx index 392b73f..7a7880f 100644 --- a/src/components/ui/NumberField.tsx +++ b/src/components/ui/NumberField.tsx @@ -1,6 +1,12 @@ +import { useState } from 'react'; import { cn } from '@/lib/cn'; -/** Integer input that never emits NaN — coerces blanks/garbage to a fallback. */ +/** + * Integer input that never emits NaN. While focused it keeps a local string draft + * so you can transiently clear or partially type a value (e.g. retype a min-bounded + * field) without it snapping to the minimum on every keystroke. The committed value + * is always a finite, clamped integer. + */ export function NumberField({ value, onChange, @@ -16,21 +22,43 @@ export function NumberField({ className?: string; 'aria-label'?: string; }) { + const [draft, setDraft] = useState(null); + const clamp = (n: number) => { + let v = Math.trunc(n); + if (min !== undefined) v = Math.max(min, v); + if (max !== undefined) v = Math.min(max, v); + return v; + }; + // While focused (draft !== null) show the raw text; otherwise the controlled value. + const display = draft !== null ? draft : Number.isFinite(value) ? String(value) : '0'; return ( setDraft(e.target.value)} onChange={(e) => { - const raw = Number(e.target.value); - let n = Number.isFinite(raw) ? Math.trunc(raw) : 0; - if (min !== undefined) n = Math.max(min, n); - if (max !== undefined) n = Math.min(max, n); - onChange(n); + const raw = e.target.value; + setDraft(raw); + // Emit a clamped value live (so previews update) only when the text parses; + // an empty/"-" intermediate leaves the committed value untouched. + if (raw.trim() !== '' && raw !== '-') { + const n = Number(raw); + if (Number.isFinite(n)) onChange(clamp(n)); + } }} + onBlur={(e) => { + const raw = e.target.value; + if (raw.trim() !== '' && raw !== '-') { + const n = Number(raw); + if (Number.isFinite(n)) onChange(clamp(n)); + } + setDraft(null); // resume showing the controlled value + }} + onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} className={cn( 'w-full rounded-md border border-line bg-surface px-2 py-1.5 text-center text-sm text-ink', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60', diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 1f1eaf8..c2401d0 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -30,6 +30,7 @@ import { applyDamage, applyHealing, applyInitiatives, + isMassiveDamageDeath, currentCombatant, endEncounter, logEvent, @@ -240,7 +241,18 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter const note = hpLoss === amt ? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage` : `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`; - mutate((e) => logEvent(updateCombatant(e, c.id, { hp: after.hp }), note)); + // Surface (don't auto-apply) the death rules for downed PCs — the player + // owns their character's death-save count, so we remind rather than mutate it. + let reminder: string | null = null; + if (c.kind !== 'monster' && hpLoss > 0) { + if (isMassiveDamageDeath(c.hp.max, after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`; + else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`; + } + mutate((e) => { + let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note); + if (reminder) next = logEvent(next, reminder); + return next; + }); noteConcentrationCheck(c, hpLoss); }} onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))} diff --git a/src/lib/combat/engine.test.ts b/src/lib/combat/engine.test.ts index fcfd7ca..0bd5c66 100644 --- a/src/lib/combat/engine.test.ts +++ b/src/lib/combat/engine.test.ts @@ -7,6 +7,7 @@ import { applyInitiatives, currentCombatant, endEncounter, + isMassiveDamageDeath, moveCombatant, nextTurn, previousTurn, @@ -216,3 +217,13 @@ describe('applyInitiatives', () => { expect(currentCombatant(e)?.id).toBe('b'); }); }); + +describe('isMassiveDamageDeath', () => { + it('kills when leftover damage past 0 is >= max HP', () => { + // 12-max PC at 4 HP takes 17 → current -13; 13 >= 12 → instant death + expect(isMassiveDamageDeath(12, -13)).toBe(true); + expect(isMassiveDamageDeath(12, -12)).toBe(true); // exactly max → dead + expect(isMassiveDamageDeath(12, -11)).toBe(false); // one short → dying, not dead + expect(isMassiveDamageDeath(12, 0)).toBe(false); // dropped to 0, no overflow + }); +}); diff --git a/src/lib/combat/engine.ts b/src/lib/combat/engine.ts index 6806ab6..9855c64 100644 --- a/src/lib/combat/engine.ts +++ b/src/lib/combat/engine.ts @@ -257,6 +257,15 @@ export function applyDamage(c: Combatant, amount: number, type?: string): Combat }; } +/** + * 5e instant death: a creature dropped to 0 HP dies outright if the leftover damage + * equals or exceeds its HP maximum (no death saves). `currentAfter` is hp.current + * after the hit (may be negative); the overflow below 0 is the leftover damage. + */ +export function isMassiveDamageDeath(hpMax: number, currentAfter: number): boolean { + return currentAfter <= 0 && -currentAfter >= hpMax; +} + /** Heal up to max. Does not touch temporary HP. */ export function applyHealing(c: Combatant, amount: number): Combatant { if (!Number.isFinite(amount) || amount <= 0) return c;