Generic (non-5e) ruleset mode: negative HP, down status, mark dead/revive

New campaign/encounter ruleset toggle: 5e (default, unchanged) vs generic.

Generic mode:
- No death saves (deathSave/stabilize throw)
- Negative HP allowed (no clamp at 0)
- <=0 HP = status 'down' (no pips, no unconscious auto-condition)
- Monster death = dead + inactive (same as 5e)
- markDead button: DM sets dead manually (both modes)
- revive: dead->conscious (generic), dead->stable (5e)

5e mode: zero behavior change.

shared/turn.js:
- applyHpChangeGeneric: negative HP, down status, no death-save logic
- markDead: status dead, monster auto-inactive
- reviveParticipant: ruleset-aware (generic=conscious, 5e=stable)
- deathSave/stabilize: throw in generic
- expandUndo: mark_dead case added

UI (src/App.js):
- CreateCampaignForm + CreateEncounterForm: ruleset radio toggle
- handleCreateCampaign/handleCreateEncounter: store ruleset field
- EncounterManager: fetch campaignDoc for default ruleset inheritance
- DM participant: down/dead labels, Mark Dead button (generic), Revive both
- death-save pips/buttons gated 5e-only
- DisplayView: down label, generic status derivation

Tests: 15 generic cases (turn.generic.test.js). 186 shared total.
This commit is contained in:
david raistrick
2026-07-07 13:36:16 -04:00
parent 7955bed4a9
commit c2bb5cca29
3 changed files with 374 additions and 14 deletions
+185
View File
@@ -0,0 +1,185 @@
// Generic (non-5e) ruleset: no death saves, negative HP allowed, down status.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const {
buildCharacterParticipant, buildMonsterParticipant,
startEncounter, applyHpChange, deathSave, markDead, reviveParticipant,
} = shared;
function char(id, hp = 100) {
const { participant } = buildCharacterParticipant({ id: `orig-${id}`, name: id, defaultMaxHp: hp, defaultInitMod: 2 });
participant.id = id;
return participant;
}
function mon(id, hp = 100) {
const { participant } = buildMonsterParticipant({ name: id, maxHp: hp, initMod: 2 });
participant.id = id;
return participant;
}
function enc(ps, ruleset = 'generic') {
return {
name: 't', participants: ps, isStarted: false, isPaused: false,
round: 0, currentTurnParticipantId: null, turnOrderIds: [],
ruleset,
};
}
const snap = (e) => JSON.parse(JSON.stringify(e));
describe('generic ruleset', () => {
test('damage past 0 = negative HP, status down (character)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 15, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(-5);
expect(p.status).toBe('down');
});
test('damage exactly to 0 = status down', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(0);
expect(p.status).toBe('down');
});
test('heal from negative = conscious', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(5);
expect(p.status).toBe('conscious');
});
test('heal caps at maxHp', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 5, ctx); // 5
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(10);
expect(p.status).toBe('conscious');
});
test('monster death = auto-inactive (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'm', 'damage', 20, ctx);
const p = newEnc.participants.find(x => x.id.startsWith('m'));
expect(p.currentHp).toBe(-10);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('monster at exactly 0 = dead + inactive (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'm', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id.startsWith('m'));
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('deathSave throws in generic mode', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
await expect(deathSave(e, 'a', 'fail', ctx)).rejects.toThrow();
});
test('revive in generic mode: dead→conscious (no stable)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
e = await markDead(e, 'a', ctx); // dead
const newEnc = await reviveParticipant(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('conscious');
expect(p.currentHp).toBe(0); // HP unchanged from down
});
test('revive generic reactivates if inactive', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
e = await markDead(e, e.participants[0].id, ctx); // dead+inactive
const newEnc = await reviveParticipant(e, e.participants[0].id, ctx);
const p = newEnc.participants[0];
expect(p.status).toBe('conscious');
expect(p.isActive).toBe(true);
});
test('markDead sets status dead (character, stays active)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
const newEnc = await markDead(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dead');
expect(p.isActive).toBe(true);
});
test('markDead on monster = dead + inactive', async () => {
const { ctx } = mockCtx();
let e = enc([mon('m', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'm', 'damage', 5, ctx); // down (5hp)
const newEnc = await markDead(e, e.participants[0].id, ctx);
const p = newEnc.participants[0];
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('markDead on conscious = dead', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await markDead(e, 'a', ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dead');
});
test('damage while down = more negative (no death saves)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 15, ctx); // -5 down
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.currentHp).toBe(-10);
expect(p.status).toBe('down');
});
test('no unconscious condition auto-added (generic)', async () => {
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
const newEnc = await applyHpChange(e, 'a', 'damage', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.conditions).not.toContain('unconscious');
});
test('down→conscious clears dead? no, dead is separate', async () => {
// sanity: down heal to conscious, dead untouched
const { ctx } = mockCtx();
let e = enc([char('a', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 10, ctx); // down
const newEnc = await applyHpChange(e, 'a', 'heal', 10, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('conscious');
});
});