0e76fb2fc7
- server/db.js: SQLite schema mirroring Firestore doc tree - server/handlers.js: action -> shared turn fn -> tx persist -> broadcast - server/index.js: REST endpoints + WebSocket real-time push - server/server.test.js: 7 integration tests (REST CRUD + combat flow) - --forceExit for jest (open WS handles) Backend boots, serves state, persists to SQLite.
379 lines
17 KiB
JavaScript
379 lines
17 KiB
JavaScript
// server/handlers.js — action → shared turn fn → tx persist → broadcast notify.
|
|
// Server-authoritative. Client sends action; server computes result, persists, notifies subscribers.
|
|
|
|
'use strict';
|
|
|
|
const shared = require('@ttrpg/shared');
|
|
const { buildCharacterParticipant, buildMonsterParticipant, generateId } = shared;
|
|
|
|
// Create a store facade with all encounter/campaign mutations + a notify hook.
|
|
// notify(change) fans out to WS subscribers. Caller wires it.
|
|
function createStore(db, notify) {
|
|
// --- read helpers ---
|
|
function getCampaign(campaignId) {
|
|
const row = db.prepare('SELECT * FROM campaigns WHERE id = ?').get(campaignId);
|
|
return rowToCampaignLocal(row);
|
|
}
|
|
function getEncounter(campaignId, encounterId) {
|
|
const row = db.prepare('SELECT * FROM encounters WHERE campaignId = ? AND id = ?').get(campaignId, encounterId);
|
|
return rowToEncounterLocal(row);
|
|
}
|
|
function listEncounters(campaignId) {
|
|
return db.prepare('SELECT * FROM encounters WHERE campaignId = ? ORDER BY createdAt ASC').all(campaignId).map(rowToEncounterLocal);
|
|
}
|
|
function getActiveDisplay() {
|
|
const row = db.prepare('SELECT * FROM active_display WHERE id = 1').get();
|
|
return {
|
|
activeCampaignId: row.activeCampaignId,
|
|
activeEncounterId: row.activeEncounterId,
|
|
hidePlayerHp: !!row.hidePlayerHp,
|
|
};
|
|
}
|
|
|
|
// --- write helpers ---
|
|
function saveCampaign(c) {
|
|
db.prepare(`INSERT INTO campaigns (id, name, playerDisplayBackgroundUrl, ownerId, createdAt, players)
|
|
VALUES (@id, @name, @playerDisplayBackgroundUrl, @ownerId, @createdAt, @players)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name=@name, playerDisplayBackgroundUrl=@playerDisplayBackgroundUrl,
|
|
ownerId=@ownerId, players=@players`)
|
|
.run({
|
|
id: c.id, name: c.name, playerDisplayBackgroundUrl: c.playerDisplayBackgroundUrl || '',
|
|
ownerId: c.ownerId || null, createdAt: c.createdAt, players: JSON.stringify(c.players || []),
|
|
});
|
|
}
|
|
function saveEncounter(e) {
|
|
db.prepare(`INSERT INTO encounters (id, campaignId, name, participants, round, currentTurnParticipantId, isStarted, isPaused, turnOrderIds, createdAt)
|
|
VALUES (@id, @campaignId, @name, @participants, @round, @currentTurnParticipantId, @isStarted, @isPaused, @turnOrderIds, @createdAt)
|
|
ON CONFLICT(campaignId, id) DO UPDATE SET
|
|
name=@name, participants=@participants, round=@round,
|
|
currentTurnParticipantId=@currentTurnParticipantId, isStarted=@isStarted,
|
|
isPaused=@isPaused, turnOrderIds=@turnOrderIds`)
|
|
.run({
|
|
id: e.id, campaignId: e.campaignId, name: e.name, participants: JSON.stringify(e.participants || []),
|
|
round: e.round || 0, currentTurnParticipantId: e.currentTurnParticipantId || null,
|
|
isStarted: e.isStarted ? 1 : 0, isPaused: e.isPaused ? 1 : 0,
|
|
turnOrderIds: JSON.stringify(e.turnOrderIds || []), createdAt: e.createdAt,
|
|
});
|
|
}
|
|
function setActiveDisplay(patch) {
|
|
const cur = getActiveDisplay();
|
|
const next = { ...cur, ...patch };
|
|
db.prepare('UPDATE active_display SET activeCampaignId=?, activeEncounterId=?, hidePlayerHp=? WHERE id=1')
|
|
.run(next.activeCampaignId || null, next.activeEncounterId || null, next.hidePlayerHp ? 1 : 0);
|
|
}
|
|
function deleteEncounter(campaignId, encounterId) {
|
|
db.prepare('DELETE FROM encounters WHERE campaignId=? AND id=?').run(campaignId, encounterId);
|
|
}
|
|
function addLog(entry) {
|
|
if (!entry) return null;
|
|
const id = generateId();
|
|
db.prepare(`INSERT INTO logs (id, timestamp, message, encounterName, undo, undone)
|
|
VALUES (?, ?, ?, ?, ?, 0)`)
|
|
.run(id, Date.now(), entry.message, entry.encounterName || null,
|
|
entry.undo ? JSON.stringify(entry.undo) : null);
|
|
return id;
|
|
}
|
|
|
|
// --- apply patch from a shared turn fn ---
|
|
function applyEncounterPatch(campaignId, encounterId, patch) {
|
|
if (!patch) return null;
|
|
const e = getEncounter(campaignId, encounterId);
|
|
if (!e) throw new Error('Encounter not found.');
|
|
const updated = { ...e, ...patch };
|
|
saveEncounter(updated);
|
|
return updated;
|
|
}
|
|
|
|
// --- public store API: each action runs in a tx, returns result + notifies ---
|
|
const store = {
|
|
// --- reads ---
|
|
getCampaign,
|
|
getEncounter,
|
|
listEncounters,
|
|
getActiveDisplay,
|
|
listCampaigns() {
|
|
return db.prepare('SELECT * FROM campaigns ORDER BY createdAt ASC').all().map(rowToCampaignLocal);
|
|
},
|
|
listLogs(limit = 500) {
|
|
return db.prepare('SELECT * FROM logs ORDER BY timestamp DESC LIMIT ?').all(limit)
|
|
.map(r => ({
|
|
id: r.id, timestamp: r.timestamp, message: r.message,
|
|
encounterName: r.encounterName, undo: r.undo ? JSON.parse(r.undo) : null, undone: !!r.undone,
|
|
}));
|
|
},
|
|
|
|
// --- campaign mutations ---
|
|
createCampaign({ name, backgroundUrl, ownerId }) {
|
|
const c = {
|
|
id: generateId(), name: name.trim(), playerDisplayBackgroundUrl: (backgroundUrl || '').trim(),
|
|
ownerId, createdAt: new Date().toISOString(), players: [],
|
|
};
|
|
saveCampaign(c);
|
|
notify({ type: 'campaigns' });
|
|
return c;
|
|
},
|
|
deleteCampaign(campaignId) {
|
|
const tx = db.transaction(() => {
|
|
db.prepare('DELETE FROM encounters WHERE campaignId=?').run(campaignId);
|
|
db.prepare('DELETE FROM campaigns WHERE id=?').run(campaignId);
|
|
const ad = getActiveDisplay();
|
|
if (ad.activeCampaignId === campaignId) {
|
|
setActiveDisplay({ activeCampaignId: null, activeEncounterId: null });
|
|
}
|
|
});
|
|
tx();
|
|
notify({ type: 'campaigns' });
|
|
notify({ type: 'encounters', campaignId });
|
|
notify({ type: 'activeDisplay' });
|
|
},
|
|
addCampaignCharacter(campaignId, character) {
|
|
const c = getCampaign(campaignId);
|
|
if (!c) throw new Error('Campaign not found.');
|
|
const newChar = {
|
|
id: generateId(), name: character.name.trim(),
|
|
defaultMaxHp: parseInt(character.defaultMaxHp, 10) || 10,
|
|
defaultInitMod: parseInt(character.defaultInitMod, 10) || 0,
|
|
};
|
|
c.players = [...c.players, newChar];
|
|
saveCampaign(c);
|
|
notify({ type: 'campaign', campaignId });
|
|
return newChar;
|
|
},
|
|
updateCampaignCharacter(campaignId, characterId, data) {
|
|
const c = getCampaign(campaignId);
|
|
if (!c) throw new Error('Campaign not found.');
|
|
c.players = c.players.map(ch => ch.id === characterId
|
|
? { ...ch, name: data.name.trim(), defaultMaxHp: parseInt(data.defaultMaxHp, 10) || 10, defaultInitMod: parseInt(data.defaultInitMod, 10) || 0 }
|
|
: ch);
|
|
saveCampaign(c);
|
|
notify({ type: 'campaign', campaignId });
|
|
},
|
|
deleteCampaignCharacter(campaignId, characterId) {
|
|
const c = getCampaign(campaignId);
|
|
if (!c) throw new Error('Campaign not found.');
|
|
c.players = c.players.filter(ch => ch.id !== characterId);
|
|
saveCampaign(c);
|
|
notify({ type: 'campaign', campaignId });
|
|
},
|
|
|
|
// --- encounter mutations ---
|
|
createEncounter(campaignId, name) {
|
|
const e = {
|
|
id: generateId(), campaignId, name: name.trim(),
|
|
participants: [], round: 0, currentTurnParticipantId: null,
|
|
isStarted: false, isPaused: false, turnOrderIds: [],
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
saveEncounter(e);
|
|
notify({ type: 'encounters', campaignId });
|
|
return e;
|
|
},
|
|
deleteEncounter(campaignId, encounterId) {
|
|
deleteEncounter(campaignId, encounterId);
|
|
const ad = getActiveDisplay();
|
|
if (ad.activeEncounterId === encounterId) {
|
|
setActiveDisplay({ activeCampaignId: null, activeEncounterId: null });
|
|
}
|
|
notify({ type: 'encounters', campaignId });
|
|
notify({ type: 'activeDisplay' });
|
|
},
|
|
togglePlayerDisplay(campaignId, encounterId) {
|
|
const ad = getActiveDisplay();
|
|
if (ad.activeCampaignId === campaignId && ad.activeEncounterId === encounterId) {
|
|
setActiveDisplay({ activeCampaignId: null, activeEncounterId: null });
|
|
} else {
|
|
setActiveDisplay({ activeCampaignId: campaignId, activeEncounterId: encounterId });
|
|
}
|
|
notify({ type: 'activeDisplay' });
|
|
},
|
|
|
|
// --- participant mutations (call shared turn fns) ---
|
|
addParticipant(campaignId, encounterId, { type, ...rest }) {
|
|
let built;
|
|
if (type === 'character') {
|
|
const c = getCampaign(rest.campaignId || campaignId);
|
|
const char = (c && c.players || []).find(ch => ch.id === rest.characterId);
|
|
if (!char) throw new Error('Character not found.');
|
|
const e = getEncounter(campaignId, encounterId);
|
|
if ((e.participants || []).some(p => p.type === 'character' && p.originalCharacterId === char.id)) {
|
|
throw new Error(`${char.name} already in encounter.`);
|
|
}
|
|
built = buildCharacterParticipant(char);
|
|
} else {
|
|
built = buildMonsterParticipant(rest);
|
|
}
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.addParticipant(e, built.participant);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
addLog({ ...log, encounterName: e.name });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return { encounter: updated, roll: built.roll };
|
|
},
|
|
addAllCampaignCharacters(campaignId, encounterId) {
|
|
const c = getCampaign(campaignId);
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const existing = (e.participants || []).filter(p => p.type === 'character' && p.originalCharacterId).map(p => p.originalCharacterId);
|
|
const newParts = (c.players || []).filter(ch => !existing.includes(ch.id)).map(ch => buildCharacterParticipant(ch).participant);
|
|
const { patch } = shared.addParticipants(e, newParts);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return { encounter: updated, added: newParts.length };
|
|
},
|
|
updateParticipant(campaignId, encounterId, participantId, data) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch } = shared.updateParticipant(e, participantId, {
|
|
name: data.name.trim(),
|
|
initiative: parseInt(data.initiative, 10),
|
|
currentHp: parseInt(data.currentHp, 10),
|
|
maxHp: parseInt(data.maxHp, 10),
|
|
isNpc: data.isNpc || false,
|
|
});
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return updated;
|
|
},
|
|
removeParticipant(campaignId, encounterId, participantId) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.removeParticipant(e, participantId);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
addLog({ ...log, encounterName: e.name });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return updated;
|
|
},
|
|
toggleParticipantActive(campaignId, encounterId, participantId) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.toggleParticipantActive(e, participantId);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
addLog({ ...log, encounterName: e.name });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return updated;
|
|
},
|
|
applyHpChange(campaignId, encounterId, participantId, changeType, amount) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.applyHpChange(e, participantId, changeType, parseInt(amount, 10));
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
if (log) addLog({ ...log, encounterName: e.name });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return updated;
|
|
},
|
|
deathSave(campaignId, encounterId, participantId, saveNumber) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, isDying } = shared.deathSave(e, participantId, saveNumber);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return { encounter: updated, isDying };
|
|
},
|
|
toggleCondition(campaignId, encounterId, participantId, conditionId) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.toggleCondition(e, participantId, conditionId);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
addLog({ ...log, encounterName: e.name });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return updated;
|
|
},
|
|
reorderParticipants(campaignId, encounterId, draggedId, targetId) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch } = shared.reorderParticipants(e, draggedId, targetId);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return updated;
|
|
},
|
|
|
|
// --- combat controls (call shared turn fns) ---
|
|
startEncounter(campaignId, encounterId) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.startEncounter(e);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
addLog({ ...log, encounterName: e.name });
|
|
setActiveDisplay({ activeCampaignId: campaignId, activeEncounterId: encounterId });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
notify({ type: 'activeDisplay' });
|
|
return updated;
|
|
},
|
|
nextTurn(campaignId, encounterId) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.nextTurn(e);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
if (log) addLog({ ...log, encounterName: e.name });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return updated;
|
|
},
|
|
togglePause(campaignId, encounterId) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.togglePause(e);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
addLog({ ...log, encounterName: e.name });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
return updated;
|
|
},
|
|
endEncounter(campaignId, encounterId) {
|
|
const e = getEncounter(campaignId, encounterId);
|
|
const { patch, log } = shared.endEncounter(e);
|
|
const updated = applyEncounterPatch(campaignId, encounterId, patch);
|
|
addLog({ ...log, encounterName: e.name });
|
|
setActiveDisplay({ activeCampaignId: null, activeEncounterId: null });
|
|
notify({ type: 'encounter', campaignId, encounterId });
|
|
notify({ type: 'activeDisplay' });
|
|
return updated;
|
|
},
|
|
|
|
// --- display settings ---
|
|
toggleHidePlayerHp() {
|
|
const ad = getActiveDisplay();
|
|
setActiveDisplay({ hidePlayerHp: !ad.hidePlayerHp });
|
|
notify({ type: 'activeDisplay' });
|
|
return !ad.hidePlayerHp;
|
|
},
|
|
|
|
// --- logs ---
|
|
clearLogs() {
|
|
db.prepare('DELETE FROM logs').run();
|
|
notify({ type: 'logs' });
|
|
},
|
|
undoLog(logId) {
|
|
const entry = db.prepare('SELECT * FROM logs WHERE id=?').get(logId);
|
|
if (!entry) throw new Error('Log not found.');
|
|
if (entry.undone) throw new Error('Already undone.');
|
|
if (!entry.undo) throw new Error('No undo data.');
|
|
const undo = JSON.parse(entry.undo);
|
|
// Undo payload shape: { encounterPath, updates } (legacy) OR { updates } (ours)
|
|
const updates = undo.updates || undo;
|
|
// find encounter from message context if available; require campaignId/encounterId via payload
|
|
if (undo.encounterPath) {
|
|
// legacy firebase path — we can't resolve here. Caller must pass ids.
|
|
throw new Error('Legacy undo payload; requires campaignId/encounterId.');
|
|
}
|
|
db.prepare('UPDATE logs SET undone=1 WHERE id=?').run(logId);
|
|
notify({ type: 'logs' });
|
|
return undo;
|
|
},
|
|
|
|
// --- internals exposed for tests/handlers ---
|
|
_db: db,
|
|
};
|
|
|
|
// local row mappers (avoid circular import with db.js shape)
|
|
function rowToCampaignLocal(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id, name: row.name, playerDisplayBackgroundUrl: row.playerDisplayBackgroundUrl,
|
|
ownerId: row.ownerId, createdAt: row.createdAt, players: row.players ? JSON.parse(row.players) : [],
|
|
};
|
|
}
|
|
function rowToEncounterLocal(row) {
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id, campaignId: row.campaignId, name: row.name,
|
|
participants: row.participants ? JSON.parse(row.participants) : [],
|
|
round: row.round, currentTurnParticipantId: row.currentTurnParticipantId,
|
|
isStarted: !!row.isStarted, isPaused: !!row.isPaused,
|
|
turnOrderIds: row.turnOrderIds ? JSON.parse(row.turnOrderIds) : [], createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
return store;
|
|
}
|
|
|
|
module.exports = { createStore };
|