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:
@@ -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');
|
||||
});
|
||||
});
|
||||
+115
-2
@@ -207,6 +207,7 @@ function expandUndo(entry, currentEnc) {
|
||||
case 'death_save':
|
||||
case 'stabilize':
|
||||
case 'revive':
|
||||
case 'mark_dead':
|
||||
case 'deactivate_dead_monster': {
|
||||
// Full death-state restore: currentHp, status, death-save counters,
|
||||
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
||||
@@ -574,6 +575,7 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
|
||||
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
|
||||
const ctx = maybeCtx || optionsOrCtx;
|
||||
const options = maybeCtx ? (optionsOrCtx || {}) : {};
|
||||
const ruleset = encounter.ruleset || '5e';
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
if (isNaN(amount) || amount === 0) return encounter;
|
||||
@@ -596,6 +598,12 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
let logDelta = { amount, from: participant.currentHp };
|
||||
const status = oldValues.status;
|
||||
|
||||
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
|
||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||
if (ruleset === 'generic') {
|
||||
return applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx);
|
||||
}
|
||||
|
||||
if (changeType === 'damage') {
|
||||
if (participant.currentHp === 0) {
|
||||
if (status === 'dead') {
|
||||
@@ -683,7 +691,76 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
// GENERIC ruleset HP change: no death saves, negative HP, down status at <=0.
|
||||
// Monster death = dead + inactive. No unconscious auto-condition.
|
||||
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
|
||||
const participantId = participant.id;
|
||||
let updates, message, logDelta = { amount, from: participant.currentHp };
|
||||
|
||||
if (changeType === 'damage') {
|
||||
if (oldValues.status === 'dead') {
|
||||
if (participant.type === 'monster' && participant.isActive !== false) {
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, isActive: false } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
deathSaveSuccesses: 0, deathSaveFailures: 0,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: 'deactivate_dead_monster', participantId, participantName: participant.name,
|
||||
message: `${participant.name} marked inactive (dead monster)`,
|
||||
delta: { status: 'dead', isActive: false },
|
||||
undo: { oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
return encounter;
|
||||
}
|
||||
const newHp = participant.currentHp - amount;
|
||||
const isMonster = participant.type === 'monster';
|
||||
if (newHp <= 0 && isMonster) {
|
||||
updates = { currentHp: newHp, status: 'dead', isActive: false };
|
||||
} else if (newHp <= 0) {
|
||||
updates = { currentHp: newHp, status: 'down' };
|
||||
} else {
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
}
|
||||
message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: updates.status };
|
||||
} else if (changeType === 'heal') {
|
||||
if (oldValues.status === 'dead') return encounter;
|
||||
const newHp = Math.min(participant.maxHp, participant.currentHp + amount);
|
||||
updates = { currentHp: newHp, status: 'conscious' };
|
||||
message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`;
|
||||
logDelta = { ...logDelta, to: newHp, status: 'conscious' };
|
||||
} else {
|
||||
throw new Error(`Unknown HP change type: ${changeType}`);
|
||||
}
|
||||
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...updates } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const undoAmount = changeType === 'damage' ? amount : -amount;
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: changeType, participantId, participantName: participant.name,
|
||||
message, delta: logDelta,
|
||||
undo: { amount: undoAmount, oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
|
||||
async function deathSave(encounter, participantId, outcome, ctx) {
|
||||
if ((encounter.ruleset || '5e') === 'generic') {
|
||||
throw new Error('Death saves not available in generic ruleset.');
|
||||
}
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
@@ -764,6 +841,9 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
|
||||
}
|
||||
|
||||
async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
if ((encounter.ruleset || '5e') === 'generic') {
|
||||
throw new Error('Stabilize not available in generic ruleset.');
|
||||
}
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
@@ -806,12 +886,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
}
|
||||
|
||||
async function reviveParticipant(encounter, participantId, ctx) {
|
||||
const ruleset = encounter.ruleset || '5e';
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
|
||||
if (status !== 'dead') return encounter;
|
||||
|
||||
const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
|
||||
// Generic: dead→conscious (no stable state). HP unchanged (may be negative).
|
||||
// 5e: dead→stable at 0 HP, unconscious, clears death-save counters.
|
||||
const updates = ruleset === 'generic'
|
||||
? { status: 'conscious', ...(participant.type === 'monster' ? {} : {}), ...(participant.isActive === false ? { isActive: true } : {}) }
|
||||
: { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true };
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
@@ -845,6 +930,34 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
// markDead: DM manually sets status dead. Both rulesets. Monster = auto-inactive.
|
||||
async function markDead(encounter, participantId, ctx) {
|
||||
const participant = (encounter.participants || []).find(p => p.id === participantId);
|
||||
if (!participant) throw new Error('Participant not found.');
|
||||
if (participant.status === 'dead') return encounter;
|
||||
const oldValues = {
|
||||
currentHp: participant.currentHp,
|
||||
status: participant.status || 'conscious',
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
const updates = { status: 'dead', ...(participant.type === 'monster' ? { isActive: false } : {}) };
|
||||
const updatedParticipants = (encounter.participants || []).map(p =>
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp, status: newP.status,
|
||||
isActive: newP.isActive, conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
return commit(encounter, { participants: updatedParticipants }, {
|
||||
type: 'mark_dead', participantId, participantName: participant.name,
|
||||
message: `${participant.name} marked dead`,
|
||||
delta: { status: 'dead' },
|
||||
undo: { oldValues, newValues },
|
||||
}, ctx);
|
||||
}
|
||||
|
||||
async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||
const participants = [...(encounter.participants || [])];
|
||||
const dragged = participants.find(p => p.id === draggedId);
|
||||
@@ -919,7 +1032,7 @@ module.exports = {
|
||||
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
|
||||
startEncounter, nextTurn, togglePause,
|
||||
addParticipant, addParticipants, updateParticipant, removeParticipant,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
|
||||
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
|
||||
reorderParticipants, endEncounter,
|
||||
activateDisplay, clearDisplay, toggleHidePlayerHp,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user