Rework backend #1

Merged
robert merged 86 commits from rework-backend into main 2026-07-01 19:29:34 -04:00
9 changed files with 5205 additions and 19 deletions
Showing only changes of commit e06adaa081 - Show all commits
+6 -2
View File
@@ -1,4 +1,3 @@
# .gitignore
node_modules node_modules
build build
dist dist
@@ -6,4 +5,9 @@ dist
.env.local .env.local
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
*.log
data/*.sqlite
data/*.sqlite-*
server/data/*.sqlite
server/data/*.sqlite-*
+4288 -16
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -2,6 +2,10 @@
"name": "ttrpg-initiative-tracker", "name": "ttrpg-initiative-tracker",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"workspaces": [
"server",
"shared"
],
"dependencies": { "dependencies": {
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
@@ -20,7 +24,10 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"server:dev": "npm run dev --workspace server",
"server:test": "npm test --workspace server",
"shared:test": "npm test --workspace shared"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@ttrpg/server",
"version": "0.1.0",
"private": true,
"description": "Self-hosted backend: Express + ws + better-sqlite3. Server-authoritative turn logic.",
"main": "index.js",
"scripts": {
"dev": "node --watch index.js",
"start": "node index.js",
"test": "jest"
},
"dependencies": {
"@ttrpg/shared": "*",
"better-sqlite3": "^11.3.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"nanoid": "^5.0.7",
"ws": "^8.18.0"
},
"devDependencies": {
"jest": "^29.7.0",
"supertest": "^7.0.0"
}
}
+2
View File
@@ -0,0 +1,2 @@
// @ttrpg/shared — barrel export.
module.exports = require('./turn.js');
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/*.test.js'],
collectCoverageFrom: ['turn.js'],
};
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@ttrpg/shared",
"version": "0.1.0",
"private": true,
"description": "Pure logic shared by client + server + tests. No I/O.",
"main": "index.js",
"scripts": {
"test": "jest"
},
"devDependencies": {
"jest": "^29.7.0"
}
}
+340
View File
@@ -0,0 +1,340 @@
// Characterization tests for shared/turn.js.
// Lock CURRENT behavior (bugs included). M3 will extend, M4 will fix.
// These tests assert what the code does NOW, not what it SHOULD do.
const shared = require('@ttrpg/shared');
const {
sortParticipantsByInitiative,
computeTurnOrderAfterRemoval,
computeTurnOrderAfterAddition,
startEncounter,
nextTurn,
togglePause,
addParticipant,
removeParticipant,
toggleParticipantActive,
applyHpChange,
deathSave,
toggleCondition,
reorderParticipants,
endEncounter,
makeParticipant,
} = shared;
// Helper: minimal encounter with given participants.
function enc(participants = [], extra = {}) {
return {
name: 'Test Encounter',
participants,
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
...extra,
};
}
function p(id, initiative, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative, maxHp: 20, currentHp: 20,
...extra,
});
}
describe('sortParticipantsByInitiative', () => {
test('higher initiative first', () => {
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const sorted = sortParticipantsByInitiative(ps, ps);
expect(sorted.map(x => x.id)).toEqual(['b', 'c', 'a']);
});
test('ties broken by original order', () => {
const ps = [p('a', 10), p('b', 10), p('c', 10)];
const sorted = sortParticipantsByInitiative(ps, ps);
expect(sorted.map(x => x.id)).toEqual(['a', 'b', 'c']);
});
});
describe('startEncounter', () => {
test('throws if no participants', () => {
expect(() => startEncounter(enc([]))).toThrow('participants');
});
test('throws if no active participants', () => {
const e = enc([p('a', 10, { isActive: false })]);
expect(() => startEncounter(e)).toThrow('active');
});
test('sets round 1, turn order sorted, current = highest init', () => {
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps);
const { patch } = startEncounter(e);
expect(patch.isStarted).toBe(true);
expect(patch.round).toBe(1);
expect(patch.turnOrderIds).toEqual(['b', 'c', 'a']);
expect(patch.currentTurnParticipantId).toBe('b');
});
test('inactive excluded from turn order', () => {
const ps = [p('a', 5), p('b', 15, { isActive: false }), p('c', 10)];
const { patch } = startEncounter(enc(ps));
expect(patch.turnOrderIds).toEqual(['c', 'a']);
expect(patch.currentTurnParticipantId).toBe('c');
});
});
describe('nextTurn', () => {
test('throws if not started', () => {
expect(() => nextTurn(enc([p('a', 10)], { isStarted: false }))).toThrow();
});
test('throws if paused', () => {
expect(() => nextTurn(enc([p('a', 10)], { isStarted: true, isPaused: true, currentTurnParticipantId: 'a', turnOrderIds: ['a'] }))).toThrow();
});
test('advances to next in order, no round bump', () => {
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'b',
turnOrderIds: ['b', 'c', 'a'],
});
const { patch } = nextTurn(e);
expect(patch.currentTurnParticipantId).toBe('c');
expect(patch.round).toBe(1);
});
test('wraps round when last in order', () => {
const ps = [p('a', 5), p('b', 15), p('c', 10)];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'a',
turnOrderIds: ['b', 'c', 'a'],
});
const { patch } = nextTurn(e);
expect(patch.currentTurnParticipantId).toBe('b');
expect(patch.round).toBe(2);
});
test('ends encounter if no active participants', () => {
const ps = [p('a', 10, { isActive: false })];
const e = enc(ps, {
isStarted: true,
round: 1,
currentTurnParticipantId: 'a',
turnOrderIds: ['a'],
});
const { patch } = nextTurn(e);
expect(patch.isStarted).toBe(false);
expect(patch.currentTurnParticipantId).toBe(null);
});
});
describe('togglePause', () => {
test('pauses started encounter', () => {
const e = enc([p('a', 10)], { isStarted: true, isPaused: false });
const { patch } = togglePause(e);
expect(patch.isPaused).toBe(true);
});
test('resume recomputes turn order from active', () => {
const ps = [p('a', 5), p('b', 15)];
const e = enc(ps, { isStarted: true, isPaused: true, turnOrderIds: ['a', 'b'] });
const { patch } = togglePause(e);
expect(patch.isPaused).toBe(false);
expect(patch.turnOrderIds).toEqual(['b', 'a']);
});
});
describe('removeParticipant', () => {
test('removes from participants array', () => {
const ps = [p('a', 10), p('b', 5)];
const { patch } = removeParticipant(enc(ps), 'a');
expect(patch.participants.map(x => x.id)).toEqual(['b']);
});
test('not started: no turn order mutation', () => {
const ps = [p('a', 10), p('b', 5)];
const { patch } = removeParticipant(enc(ps), 'a');
expect(patch.turnOrderIds).toBeUndefined();
});
test('started: removes from turnOrderIds', () => {
const ps = [p('a', 10), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
const { patch } = removeParticipant(e, 'a');
expect(patch.turnOrderIds).toEqual(['b']);
});
test('started: removing current picks next active', () => {
const ps = [p('a', 10), p('b', 5), p('c', 3)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b', 'c'], currentTurnParticipantId: 'a' });
const { patch } = removeParticipant(e, 'a');
expect(patch.currentTurnParticipantId).toBe('b');
});
});
describe('toggleParticipantActive', () => {
test('deactivates participant', () => {
const ps = [p('a', 10, { isActive: true })];
const { patch } = toggleParticipantActive(enc(ps), 'a');
expect(patch.participants[0].isActive).toBe(false);
});
test('started: deactivating current advances turn', () => {
const ps = [p('a', 10), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const { patch } = toggleParticipantActive(e, 'a');
expect(patch.currentTurnParticipantId).toBe('b');
});
test('started: reactivating appends to turn order', () => {
const ps = [p('a', 10, { isActive: false }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['b'], currentTurnParticipantId: 'b' });
const { patch } = toggleParticipantActive(e, 'a');
expect(patch.turnOrderIds).toEqual(['b', 'a']);
});
});
describe('applyHpChange', () => {
test('damage reduces hp, clamps 0', () => {
const ps = [p('a', 10, { currentHp: 15, maxHp: 20 })];
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 5);
expect(patch.participants[0].currentHp).toBe(10);
});
test('damage to 0 deactivates + removes from turn order', () => {
const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)];
const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const { patch } = applyHpChange(e, 'a', 'damage', 5);
expect(patch.participants[0].currentHp).toBe(0);
expect(patch.participants[0].isActive).toBe(false);
expect(patch.currentTurnParticipantId).toBe('b');
});
test('heal above 0 revives + reactivates + resets death saves', () => {
const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })];
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 5);
expect(patch.participants[0].currentHp).toBe(5);
expect(patch.participants[0].isActive).toBe(true);
expect(patch.participants[0].deathSaves).toBe(0);
});
test('heal clamps to maxHp', () => {
const ps = [p('a', 10, { currentHp: 18, maxHp: 20 })];
const { patch } = applyHpChange(enc(ps), 'a', 'heal', 10);
expect(patch.participants[0].currentHp).toBe(20);
});
test('zero amount = no-op', () => {
const ps = [p('a', 10, { currentHp: 10 })];
const { patch } = applyHpChange(enc(ps), 'a', 'damage', 0);
expect(patch).toBe(null);
});
});
describe('deathSave', () => {
test('increments saves', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 0 })];
const { patch } = deathSave(enc(ps), 'a', 1);
expect(patch.participants[0].deathSaves).toBe(1);
});
test('clicking same save decrements (toggle)', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
const { patch } = deathSave(enc(ps), 'a', 2);
expect(patch.participants[0].deathSaves).toBe(1);
});
test('third save sets isDying', () => {
const ps = [p('a', 10, { currentHp: 0, deathSaves: 2 })];
const result = deathSave(enc(ps), 'a', 3);
expect(result.patch.participants[0].deathSaves).toBe(3);
expect(result.patch.participants[0].isDying).toBe(true);
expect(result.isDying).toBe(true);
});
});
describe('toggleCondition', () => {
test('adds condition', () => {
const ps = [p('a', 10, { conditions: [] })];
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
expect(patch.participants[0].conditions).toEqual(['poisoned']);
});
test('removes condition', () => {
const ps = [p('a', 10, { conditions: ['poisoned', 'blinded'] })];
const { patch } = toggleCondition(enc(ps), 'a', 'poisoned');
expect(patch.participants[0].conditions).toEqual(['blinded']);
});
});
describe('reorderParticipants', () => {
test('swaps within same initiative', () => {
const ps = [p('a', 10), p('b', 10), p('c', 10)];
const { patch } = reorderParticipants(enc(ps), 'a', 'c');
expect(patch.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
});
test('throws if different initiative', () => {
const ps = [p('a', 10), p('b', 5)];
expect(() => reorderParticipants(enc(ps), 'a', 'b')).toThrow('same initiative');
});
});
describe('endEncounter', () => {
test('resets all combat state', () => {
const e = enc([p('a', 10)], {
isStarted: true, round: 5, currentTurnParticipantId: 'a', turnOrderIds: ['a'],
});
const { patch } = endEncounter(e);
expect(patch.isStarted).toBe(false);
expect(patch.round).toBe(0);
expect(patch.currentTurnParticipantId).toBe(null);
expect(patch.turnOrderIds).toEqual([]);
});
});
describe('computeTurnOrderAfterRemoval', () => {
test('not started = empty', () => {
const out = computeTurnOrderAfterRemoval(enc([]), 'a', []);
expect(out).toEqual({});
});
test('removing non-current only filters turnOrderIds', () => {
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'b' });
const out = computeTurnOrderAfterRemoval(e, 'a', []);
expect(out).toEqual({ turnOrderIds: ['b'] });
});
});
describe('computeTurnOrderAfterAddition', () => {
test('not started = empty', () => {
const out = computeTurnOrderAfterAddition(enc([]), 'a');
expect(out).toEqual({});
});
test('appends if not present', () => {
const e = enc([], { isStarted: true, turnOrderIds: ['b'] });
const out = computeTurnOrderAfterAddition(e, 'a');
expect(out).toEqual({ turnOrderIds: ['b', 'a'] });
});
test('no-op if already present', () => {
const e = enc([], { isStarted: true, turnOrderIds: ['a', 'b'] });
const out = computeTurnOrderAfterAddition(e, 'a');
expect(out).toEqual({});
});
});
describe('addParticipant', () => {
test('appends participant', () => {
const np = p('z', 7);
const { patch } = addParticipant(enc([p('a', 10)]), np);
expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']);
});
});
+519
View File
@@ -0,0 +1,519 @@
// @ttrpg/shared — turn.js
// Pure turn-order logic. No I/O, no React, no Firebase.
// Ported VERBATIM from src/App.js (M1). Bugs preserved intentionally.
// Characterization tests lock current behavior. Fixes come in M4.
//
// Functions return NEW state (immutable). They never mutate input encounter.
'use strict';
// ----------------------------------------------------------------------------
// Constants (mirror src/App.js)
// ----------------------------------------------------------------------------
const DEFAULT_MAX_HP = 10;
const DEFAULT_INIT_MOD = 0;
const MONSTER_DEFAULT_INIT_MOD = 2;
// ----------------------------------------------------------------------------
// Utility functions (verbatim from src/App.js)
// ----------------------------------------------------------------------------
const generateId = () =>
(typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const rollD20 = () => Math.floor(Math.random() * 20) + 1;
const formatInitMod = (mod) => {
if (mod === undefined || mod === null) return 'N/A';
return mod >= 0 ? `+${mod}` : `${mod}`;
};
// Verbatim from src/App.js. originalOrder preserves insertion order for ties.
const sortParticipantsByInitiative = (participants, originalOrder) => {
return [...participants].sort((a, b) => {
if (a.initiative === b.initiative) {
const indexA = originalOrder.findIndex(p => p.id === a.id);
const indexB = originalOrder.findIndex(p => p.id === b.id);
return indexA - indexB;
}
return b.initiative - a.initiative;
});
};
// Verbatim from src/App.js. Returns turnOrderIds/currentTurnParticipantId updates
// when a participant leaves active combat.
const computeTurnOrderAfterRemoval = (encounter, removedId, updatedParticipants) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
const newIds = currentIds.filter(id => id !== removedId);
const updates = { turnOrderIds: newIds };
if (encounter.currentTurnParticipantId === removedId) {
const removedPos = currentIds.indexOf(removedId);
const candidates = [...currentIds.slice(removedPos + 1), ...currentIds.slice(0, removedPos)];
const nextId = candidates.find(id => updatedParticipants.find(p => p.id === id && p.isActive)) ?? null;
updates.currentTurnParticipantId = nextId;
}
return updates;
};
// Verbatim from src/App.js. Returns turnOrderIds update when a participant
// re-enters active combat mid-encounter.
const computeTurnOrderAfterAddition = (encounter, addedId) => {
if (!encounter.isStarted) return {};
const currentIds = encounter.turnOrderIds || [];
if (currentIds.includes(addedId)) return {};
return { turnOrderIds: [...currentIds, addedId] };
};
// ----------------------------------------------------------------------------
// Participant factory (mirrors ParticipantManager.handleAddParticipant shape)
// ----------------------------------------------------------------------------
function makeParticipant(opts) {
return {
id: opts.id || generateId(),
name: opts.name,
type: opts.type, // 'character' | 'monster'
originalCharacterId: opts.originalCharacterId || null,
initiative: opts.initiative,
maxHp: opts.maxHp,
currentHp: opts.currentHp,
isNpc: opts.isNpc || false,
conditions: opts.conditions || [],
isActive: opts.isActive !== undefined ? opts.isActive : true,
deathSaves: opts.deathSaves || 0,
isDying: opts.isDying || false,
};
}
// Build a character participant from a campaign character (rolls initiative).
function buildCharacterParticipant(character) {
const initiativeRoll = rollD20();
const modifier = character.defaultInitMod || 0;
const finalInitiative = initiativeRoll + modifier;
const maxHp = character.defaultMaxHp || DEFAULT_MAX_HP;
return {
participant: makeParticipant({
name: character.name,
type: 'character',
originalCharacterId: character.id,
initiative: finalInitiative,
maxHp,
currentHp: maxHp,
isNpc: false,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
};
}
// Build a monster participant (rolls initiative).
function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) {
const initiativeRoll = rollD20();
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
const finalInitiative = initiativeRoll + modifier;
const hp = maxHp || DEFAULT_MAX_HP;
return {
participant: makeParticipant({
name,
type: 'monster',
initiative: finalInitiative,
maxHp: hp,
currentHp: hp,
isNpc: isNpc || false,
}),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
};
}
// ----------------------------------------------------------------------------
// Action handlers — pure: (encounter, action) => { encounter, patch, log }
// Return patch = partial fields to merge into stored encounter.
// Caller persists patch + broadcasts.
// ----------------------------------------------------------------------------
// START_ENCOUNTER — verbatim from InitiativeControls.handleStartEncounter
function startEncounter(encounter) {
if (!encounter.participants || encounter.participants.length === 0) {
throw new Error('Add participants first.');
}
const activeParticipants = encounter.participants.filter(p => p.isActive);
if (activeParticipants.length === 0) {
throw new Error('No active participants.');
}
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
return {
patch: {
isStarted: true,
isPaused: false,
round: 1,
currentTurnParticipantId: sortedParticipants[0].id,
turnOrderIds: sortedParticipants.map(p => p.id),
},
log: {
message: `Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`,
undo: {
isStarted: encounter.isStarted ?? false,
isPaused: encounter.isPaused ?? false,
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
},
};
}
// NEXT_TURN — verbatim from InitiativeControls.handleNextTurn
// NOTE: this is the suspected skip-bug source. Preserved for M3 characterization.
function nextTurn(encounter) {
if (!encounter.isStarted || encounter.isPaused) {
throw new Error('Encounter not running.');
}
if (!encounter.currentTurnParticipantId || !encounter.turnOrderIds || encounter.turnOrderIds.length === 0) {
throw new Error('No active turn.');
}
const activePsInOrder = encounter.turnOrderIds
.map(id => encounter.participants.find(p => p.id === id && p.isActive))
.filter(Boolean);
if (activePsInOrder.length === 0) {
// End encounter — no active participants left.
return {
patch: {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: encounter.round,
},
log: { message: `Combat auto-ended: no active participants`, undo: null },
};
}
let currentIndex = activePsInOrder.findIndex(p => p.id === encounter.currentTurnParticipantId);
let nextRound = encounter.round;
// Current participant was removed; find next after their old position in turnOrderIds.
if (currentIndex === -1) {
const rawPos = (encounter.turnOrderIds || []).indexOf(encounter.currentTurnParticipantId);
const candidateIds = [
...(encounter.turnOrderIds || []).slice(rawPos + 1),
...(encounter.turnOrderIds || []).slice(0, rawPos),
];
const nextP = candidateIds.map(id => activePsInOrder.find(p => p.id === id)).find(Boolean);
currentIndex = nextP ? activePsInOrder.findIndex(p => p.id === nextP.id) - 1 : -1;
}
let nextIndex = (currentIndex + 1) % activePsInOrder.length;
let newTurnOrderIds = encounter.turnOrderIds;
if (nextIndex === 0 && currentIndex !== -1) {
nextRound += 1;
// Rebuild turn order by initiative at start of new round so participants
// activated mid-round (appended to end) slot into proper initiative position next round.
const activePs = encounter.participants.filter(p => p.isActive);
const sorted = sortParticipantsByInitiative(activePs, encounter.participants);
newTurnOrderIds = sorted.map(p => p.id);
}
const nextParticipant = (nextIndex === 0 && currentIndex !== -1)
? encounter.participants.find(p => p.id === newTurnOrderIds[0])
: activePsInOrder[nextIndex];
if (!nextParticipant) {
throw new Error('Could not determine next participant.');
}
return {
patch: {
currentTurnParticipantId: nextParticipant.id,
round: nextRound,
turnOrderIds: newTurnOrderIds,
},
log: {
message: `${nextParticipant.name}'s turn (Round ${nextRound})`,
undo: {
currentTurnParticipantId: encounter.currentTurnParticipantId,
round: encounter.round,
turnOrderIds: [...encounter.turnOrderIds],
},
},
};
}
// PAUSE / RESUME — verbatim from InitiativeControls.handleTogglePause
function togglePause(encounter) {
if (!encounter || !encounter.isStarted) {
throw new Error('Encounter not started.');
}
const newPausedState = !encounter.isPaused;
let newTurnOrderIds = encounter.turnOrderIds;
if (!newPausedState && encounter.isPaused) {
// Resuming — recompute turn order from active participants.
const activeParticipants = encounter.participants.filter(p => p.isActive);
const sortedParticipants = sortParticipantsByInitiative(activeParticipants, encounter.participants);
newTurnOrderIds = sortedParticipants.map(p => p.id);
}
return {
patch: { isPaused: newPausedState, turnOrderIds: newTurnOrderIds },
log: {
message: `Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`,
undo: {
isPaused: encounter.isPaused ?? false,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
},
};
}
// ADD_PARTICIPANT — appends participant. (Initiative rolled by caller via build*.)
function addParticipant(encounter, participant) {
const updatedParticipants = [...(encounter.participants || []), participant];
return {
patch: { participants: updatedParticipants },
log: {
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
undo: { participants: [...(encounter.participants || [])] },
},
};
}
// ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters").
function addParticipants(encounter, newParticipants) {
const updatedParticipants = [...(encounter.participants || []), ...newParticipants];
return { patch: { participants: updatedParticipants }, log: null };
}
// UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc).
function updateParticipant(encounter, participantId, updatedData) {
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, ...updatedData } : p
);
return { patch: { participants: updatedParticipants }, log: null };
}
// REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant
function removeParticipant(encounter, participantId) {
const updatedParticipants = (encounter.participants || []).filter(p => p.id !== participantId);
const turnUpdates = computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
const participant = (encounter.participants || []).find(p => p.id === participantId);
return {
patch: { participants: updatedParticipants, ...turnUpdates },
log: {
message: `${participant ? participant.name : 'Participant'} removed from encounter`,
undo: {
participants: [...(encounter.participants || [])],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
},
};
}
// TOGGLE_ACTIVE — verbatim from ParticipantManager.toggleParticipantActive
function toggleParticipantActive(encounter, participantId) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const newIsActive = !participant.isActive;
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, isActive: newIsActive } : p
);
const turnUpdates = newIsActive
? computeTurnOrderAfterAddition(encounter, participantId)
: computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants);
return {
patch: { participants: updatedParticipants, ...turnUpdates },
log: {
message: `${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`,
undo: {
participants: [...(encounter.participants || [])],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
},
};
}
// APPLY_HP_CHANGE — verbatim from ParticipantManager.applyHpChange
// changeType: 'damage' | 'heal'
function applyHpChange(encounter, participantId, changeType, amount) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
if (isNaN(amount) || amount === 0) {
return { patch: null, log: null }; // no-op
}
let newHp = participant.currentHp;
if (changeType === 'damage') newHp = Math.max(0, participant.currentHp - amount);
else if (changeType === 'heal') newHp = Math.min(participant.maxHp, participant.currentHp + amount);
const wasDead = participant.currentHp === 0;
const isDead = newHp === 0;
const wasResurrected = wasDead && newHp > 0;
const updatedParticipants = (encounter.participants || []).map(p => {
if (p.id !== participantId) return p;
const updates = { ...p, currentHp: newHp };
if (isDead && !wasDead) {
updates.isActive = false;
updates.deathSaves = p.deathSaves || 0;
updates.isDying = false;
}
if (wasResurrected) {
updates.isActive = true;
updates.deathSaves = 0;
updates.isDying = false;
}
return updates;
});
const turnUpdates = (isDead && !wasDead)
? computeTurnOrderAfterRemoval(encounter, participantId, updatedParticipants)
: wasResurrected
? computeTurnOrderAfterAddition(encounter, participantId)
: {};
const hpLine = `${participant.currentHp}${newHp} HP`;
const deathSuffix = (isDead && !wasDead)
? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated')
: '';
const resurSuffix = wasResurrected ? ' — Revived' : '';
const message = changeType === 'damage'
? `${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`
: `${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`;
return {
patch: { participants: updatedParticipants, ...turnUpdates },
log: {
message,
undo: {
participants: [...(encounter.participants || [])],
...((isDead && !wasDead) || wasResurrected ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
},
};
}
// DEATH_SAVE — verbatim from ParticipantManager.handleDeathSaveChange
// saveNumber: 1 | 2 | 3. Returns isDying flag if 3rd save hit (client triggers removal animation).
function deathSave(encounter, participantId, saveNumber) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const currentSaves = participant.deathSaves || 0;
const newSaves = currentSaves === saveNumber ? saveNumber - 1 : saveNumber;
if (newSaves === 3) {
// Mark dying — caller waits for animation, then calls removeParticipant.
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves, isDying: true } : p
);
return {
patch: { participants: updatedParticipants },
log: null,
isDying: true,
};
}
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, deathSaves: newSaves } : p
);
return { patch: { participants: updatedParticipants }, log: null, isDying: false };
}
// TOGGLE_CONDITION — verbatim from ParticipantManager.toggleCondition
function toggleCondition(encounter, participantId, conditionId) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const wasActive = (participant.conditions || []).includes(conditionId);
const updatedParticipants = (encounter.participants || []).map(p => {
if (p.id !== participantId) return p;
const current = p.conditions || [];
const next = wasActive ? current.filter(c => c !== conditionId) : [...current, conditionId];
return { ...p, conditions: next };
});
return {
patch: { participants: updatedParticipants },
log: {
message: `${participant.name} ${wasActive ? 'lost' : 'gained'} ${conditionId}`,
undo: { participants: [...(encounter.participants || [])] },
},
};
}
// REORDER_PARTICIPANTS — drag-drop within same-initiative tie.
// Verbatim from ParticipantManager.handleDrop.
function reorderParticipants(encounter, draggedId, targetId) {
const participants = [...(encounter.participants || [])];
const draggedIndex = participants.findIndex(p => p.id === draggedId);
const targetIndex = participants.findIndex(p => p.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) {
throw new Error('Dragged or target item not found.');
}
const draggedItem = participants[draggedIndex];
const targetItem = participants[targetIndex];
if (draggedItem.initiative !== targetItem.initiative) {
throw new Error('Drag-drop only allowed for participants with same initiative.');
}
const [removedItem] = participants.splice(draggedIndex, 1);
participants.splice(targetIndex, 0, removedItem);
return { patch: { participants }, log: null };
}
// END_ENCOUNTER — verbatim from InitiativeControls.confirmEndEncounter
function endEncounter(encounter) {
return {
patch: {
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
round: 0,
turnOrderIds: [],
},
log: {
message: `Combat ended: "${encounter.name}"`,
undo: {
isStarted: encounter.isStarted ?? false,
isPaused: encounter.isPaused ?? false,
round: encounter.round ?? 0,
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
},
};
}
module.exports = {
DEFAULT_MAX_HP,
DEFAULT_INIT_MOD,
MONSTER_DEFAULT_INIT_MOD,
generateId,
rollD20,
formatInitMod,
sortParticipantsByInitiative,
computeTurnOrderAfterRemoval,
computeTurnOrderAfterAddition,
makeParticipant,
buildCharacterParticipant,
buildMonsterParticipant,
startEncounter,
nextTurn,
togglePause,
addParticipant,
addParticipants,
updateParticipant,
removeParticipant,
toggleParticipantActive,
applyHpChange,
deathSave,
toggleCondition,
reorderParticipants,
endEncounter,
};