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.
186 lines
6.9 KiB
JavaScript
186 lines
6.9 KiB
JavaScript
// 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');
|
|
});
|
|
});
|