M1: backend (Express+ws+better-sqlite3) + integration tests
- 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.
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user