NumberField draft buffer; surface massive-damage / death-at-0 rules in combat
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { cn } from '@/lib/cn';
|
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({
|
export function NumberField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -16,21 +22,43 @@ export function NumberField({
|
|||||||
className?: string;
|
className?: string;
|
||||||
'aria-label'?: string;
|
'aria-label'?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [draft, setDraft] = useState<string | null>(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 (
|
return (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
value={Number.isFinite(value) ? value : 0}
|
value={display}
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
|
onFocus={(e) => setDraft(e.target.value)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = Number(e.target.value);
|
const raw = e.target.value;
|
||||||
let n = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
setDraft(raw);
|
||||||
if (min !== undefined) n = Math.max(min, n);
|
// Emit a clamped value live (so previews update) only when the text parses;
|
||||||
if (max !== undefined) n = Math.min(max, n);
|
// an empty/"-" intermediate leaves the committed value untouched.
|
||||||
onChange(n);
|
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(
|
className={cn(
|
||||||
'w-full rounded-md border border-line bg-surface px-2 py-1.5 text-center text-sm text-ink',
|
'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',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
applyDamage,
|
applyDamage,
|
||||||
applyHealing,
|
applyHealing,
|
||||||
applyInitiatives,
|
applyInitiatives,
|
||||||
|
isMassiveDamageDeath,
|
||||||
currentCombatant,
|
currentCombatant,
|
||||||
endEncounter,
|
endEncounter,
|
||||||
logEvent,
|
logEvent,
|
||||||
@@ -240,7 +241,18 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
const note = hpLoss === amt
|
const note = hpLoss === amt
|
||||||
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
||||||
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
|
: `${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);
|
noteConcentrationCheck(c, hpLoss);
|
||||||
}}
|
}}
|
||||||
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
|
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
applyInitiatives,
|
applyInitiatives,
|
||||||
currentCombatant,
|
currentCombatant,
|
||||||
endEncounter,
|
endEncounter,
|
||||||
|
isMassiveDamageDeath,
|
||||||
moveCombatant,
|
moveCombatant,
|
||||||
nextTurn,
|
nextTurn,
|
||||||
previousTurn,
|
previousTurn,
|
||||||
@@ -216,3 +217,13 @@ describe('applyInitiatives', () => {
|
|||||||
expect(currentCombatant(e)?.id).toBe('b');
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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. */
|
/** Heal up to max. Does not touch temporary HP. */
|
||||||
export function applyHealing(c: Combatant, amount: number): Combatant {
|
export function applyHealing(c: Combatant, amount: number): Combatant {
|
||||||
if (!Number.isFinite(amount) || amount <= 0) return c;
|
if (!Number.isFinite(amount) || amount <= 0) return c;
|
||||||
|
|||||||
Reference in New Issue
Block a user