Rework backend #1
+6
-2
@@ -1,4 +1,3 @@
|
||||
# .gitignore
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
@@ -6,4 +5,9 @@ dist
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production.local
|
||||
*.log
|
||||
data/*.sqlite
|
||||
data/*.sqlite-*
|
||||
server/data/*.sqlite
|
||||
server/data/*.sqlite-*
|
||||
|
||||
Generated
+4288
-16
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -2,6 +2,10 @@
|
||||
"name": "ttrpg-initiative-tracker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
"shared"
|
||||
],
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
@@ -20,7 +24,10 @@
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"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": {
|
||||
"extends": [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// @ttrpg/shared — barrel export.
|
||||
module.exports = require('./turn.js');
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.js'],
|
||||
collectCoverageFrom: ['turn.js'],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user