Rework backend #1

Merged
robert merged 86 commits from rework-backend into main 2026-07-01 19:29:34 -04:00
6 changed files with 821 additions and 1 deletions
Showing only changes of commit 0e76fb2fc7 - Show all commits
+126
View File
@@ -0,0 +1,126 @@
// server/db.js — SQLite persistence layer.
// Owns the DB file. Only writer. Synchronous via better-sqlite3.
//
// Schema mirrors the Firestore doc tree used by src/App.js:
// artifacts/{APP_ID}/public/data/
// campaigns/{id} -> name, bg, ownerId, createdAt, players[]
// campaigns/{id}/encounters/{eid} -> name, participants[], round, currentTurnParticipantId, isStarted, isPaused, turnOrderIds[]
// activeDisplay/status -> activeCampaignId, activeEncounterId, hidePlayerHp
// logs/{id} -> timestamp, message, encounterName, undo, undone
//
// Collections (campaigns, encounters, logs) -> rows with JSON blobs for fields.
// Single-row "status" docs (activeDisplay) -> their own tables.
'use strict';
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS campaigns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
playerDisplayBackgroundUrl TEXT NOT NULL DEFAULT '',
ownerId TEXT,
createdAt TEXT NOT NULL,
players TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS encounters (
id TEXT NOT NULL,
campaignId TEXT NOT NULL,
name TEXT NOT NULL,
participants TEXT NOT NULL DEFAULT '[]',
round INTEGER NOT NULL DEFAULT 0,
currentTurnParticipantId TEXT,
isStarted INTEGER NOT NULL DEFAULT 0,
isPaused INTEGER NOT NULL DEFAULT 0,
turnOrderIds TEXT NOT NULL DEFAULT '[]',
createdAt TEXT NOT NULL,
PRIMARY KEY (campaignId, id),
FOREIGN KEY (campaignId) REFERENCES campaigns(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS active_display (
id INTEGER PRIMARY KEY CHECK (id = 1),
activeCampaignId TEXT,
activeEncounterId TEXT,
hidePlayerHp INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS logs (
id TEXT PRIMARY KEY,
timestamp INTEGER NOT NULL,
message TEXT NOT NULL,
encounterName TEXT,
undo TEXT,
undone INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_encounters_campaign ON encounters(campaignId);
`;
function openDb(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(SCHEMA);
// Ensure the single active_display row exists.
db.prepare('INSERT OR IGNORE INTO active_display (id, hidePlayerHp) VALUES (1, 1)').run();
return db;
}
// --- JSON helpers ---
const parseArr = (s) => (s ? JSON.parse(s) : []);
const parseObj = (s, fallback = null) => (s ? JSON.parse(s) : fallback);
// --- Campaign shape (matches Firestore doc) ---
function rowToCampaign(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
playerDisplayBackgroundUrl: row.playerDisplayBackgroundUrl,
ownerId: row.ownerId,
createdAt: row.createdAt,
players: parseArr(row.players),
};
}
// --- Encounter shape (matches Firestore doc) ---
function rowToEncounter(row) {
if (!row) return null;
return {
id: row.id,
campaignId: row.campaignId,
name: row.name,
participants: parseArr(row.participants),
round: row.round,
currentTurnParticipantId: row.currentTurnParticipantId,
isStarted: !!row.isStarted,
isPaused: !!row.isPaused,
turnOrderIds: parseArr(row.turnOrderIds),
createdAt: row.createdAt,
};
}
// --- Active display shape ---
function rowToActiveDisplay(row) {
if (!row) return null;
return {
activeCampaignId: row.activeCampaignId,
activeEncounterId: row.activeEncounterId,
hidePlayerHp: !!row.hidePlayerHp,
};
}
module.exports = {
openDb,
rowToCampaign,
rowToEncounter,
rowToActiveDisplay,
};
+378
View File
@@ -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 };
+198
View File
@@ -0,0 +1,198 @@
// server/index.js — Express (HTTP) + ws (WebSocket) bootstrap.
// HTTP: REST-ish endpoints for actions. WS: real-time state push (replaces onSnapshot).
//
// Env:
// PORT (default 4001)
// DB_PATH (default ./data/tracker.sqlite)
// CORS_ORIGIN (default '*'; in-house only)
'use strict';
const express = require('express');
const cors = require('cors');
const http = require('http');
const { WebSocketServer } = require('ws');
const { openDb } = require('./db');
const { createStore } = require('./handlers');
function createServer({ dbPath, port, corsOrigin } = {}) {
const db = openDb(dbPath || './data/tracker.sqlite');
const app = express();
app.use(cors({ origin: corsOrigin || '*' }));
app.use(express.json({ limit: '1mb' }));
// WS subscribers. Map: key -> Set<ws>. key = 'campaigns' | 'campaign:id' | etc.
const subscribers = new Map();
function subscribe(key, ws) {
if (!subscribers.has(key)) subscribers.set(key, new Set());
subscribers.get(key).add(ws);
}
function unsubscribe(key, ws) {
const set = subscribers.get(key);
if (set) { set.delete(ws); if (set.size === 0) subscribers.delete(key); }
}
function broadcast(change) {
const set = subscribers.get(change.type) || new Set();
// 'encounter'/'campaign' changes also notify bare type subscribers.
[...set].forEach(ws => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'change', change })); });
}
const store = createStore(db, broadcast);
// --- REST read endpoints ---
app.get('/health', (req, res) => res.json({ ok: true }));
app.get('/api/campaigns', (req, res) => res.json(store.listCampaigns()));
app.get('/api/campaigns/:campaignId', (req, res) => {
const c = store.getCampaign(req.params.campaignId);
if (!c) return res.status(404).json({ error: 'Not found' });
res.json(c);
});
app.get('/api/campaigns/:campaignId/encounters', (req, res) => res.json(store.listEncounters(req.params.campaignId)));
app.get('/api/campaigns/:campaignId/encounters/:encounterId', (req, res) => {
const e = store.getEncounter(req.params.campaignId, req.params.encounterId);
if (!e) return res.status(404).json({ error: 'Not found' });
res.json(e);
});
app.get('/api/activeDisplay', (req, res) => res.json(store.getActiveDisplay()));
app.get('/api/logs', (req, res) => res.json(store.listLogs(parseInt(req.query.limit, 10) || 500)));
// --- campaign mutations ---
app.post('/api/campaigns', (req, res) => {
try { res.json(store.createCampaign(req.body)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.delete('/api/campaigns/:campaignId', (req, res) => {
try { store.deleteCampaign(req.params.campaignId); res.json({ ok: true }); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/characters', (req, res) => {
try { res.json(store.addCampaignCharacter(req.params.campaignId, req.body)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.put('/api/campaigns/:campaignId/characters/:characterId', (req, res) => {
try { store.updateCampaignCharacter(req.params.campaignId, req.params.characterId, req.body); res.json({ ok: true }); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.delete('/api/campaigns/:campaignId/characters/:characterId', (req, res) => {
try { store.deleteCampaignCharacter(req.params.campaignId, req.params.characterId); res.json({ ok: true }); }
catch (err) { res.status(400).json({ error: err.message }); }
});
// --- encounter mutations ---
app.post('/api/campaigns/:campaignId/encounters', (req, res) => {
try { res.json(store.createEncounter(req.params.campaignId, req.body.name)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.delete('/api/campaigns/:campaignId/encounters/:encounterId', (req, res) => {
try { store.deleteEncounter(req.params.campaignId, req.params.encounterId); res.json({ ok: true }); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/display', (req, res) => {
try { store.togglePlayerDisplay(req.params.campaignId, req.params.encounterId); res.json({ ok: true }); }
catch (err) { res.status(400).json({ error: err.message }); }
});
// --- participant mutations ---
app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants', (req, res) => {
try { res.json(store.addParticipant(req.params.campaignId, req.params.encounterId, req.body)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/addAll', (req, res) => {
try { res.json(store.addAllCampaignCharacters(req.params.campaignId, req.params.encounterId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.put('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId', (req, res) => {
try { res.json(store.updateParticipant(req.params.campaignId, req.params.encounterId, req.params.participantId, req.body)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.delete('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId', (req, res) => {
try { res.json(store.removeParticipant(req.params.campaignId, req.params.encounterId, req.params.participantId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId/active', (req, res) => {
try { res.json(store.toggleParticipantActive(req.params.campaignId, req.params.encounterId, req.params.participantId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId/hp', (req, res) => {
try { res.json(store.applyHpChange(req.params.campaignId, req.params.encounterId, req.params.participantId, req.body.changeType, req.body.amount)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId/deathSave', (req, res) => {
try { res.json(store.deathSave(req.params.campaignId, req.params.encounterId, req.params.participantId, req.body.saveNumber)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants/:participantId/condition', (req, res) => {
try { res.json(store.toggleCondition(req.params.campaignId, req.params.encounterId, req.params.participantId, req.body.conditionId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/reorder', (req, res) => {
try { res.json(store.reorderParticipants(req.params.campaignId, req.params.encounterId, req.body.draggedId, req.body.targetId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
// --- combat controls ---
app.post('/api/campaigns/:campaignId/encounters/:encounterId/start', (req, res) => {
try { res.json(store.startEncounter(req.params.campaignId, req.params.encounterId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/nextTurn', (req, res) => {
try { res.json(store.nextTurn(req.params.campaignId, req.params.encounterId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/pause', (req, res) => {
try { res.json(store.togglePause(req.params.campaignId, req.params.encounterId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.post('/api/campaigns/:campaignId/encounters/:encounterId/end', (req, res) => {
try { res.json(store.endEncounter(req.params.campaignId, req.params.encounterId)); }
catch (err) { res.status(400).json({ error: err.message }); }
});
// --- display + logs ---
app.post('/api/activeDisplay/hidePlayerHp', (req, res) => {
try { res.json({ hidePlayerHp: store.toggleHidePlayerHp() }); }
catch (err) { res.status(400).json({ error: err.message }); }
});
app.delete('/api/logs', (req, res) => {
try { store.clearLogs(); res.json({ ok: true }); }
catch (err) { res.status(400).json({ error: err.message }); }
});
// --- WebSocket: real-time push ---
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; }
if (msg.type === 'subscribe' && msg.key) {
subscribe(msg.key, ws);
ws.send(JSON.stringify({ type: 'subscribed', key: msg.key }));
} else if (msg.type === 'unsubscribe' && msg.key) {
unsubscribe(msg.key, ws);
}
});
ws.on('close', () => {
for (const [key, set] of subscribers) { set.delete(ws); if (set.size === 0) subscribers.delete(key); }
});
ws.on('error', () => {});
});
return {
app, server, wss, store, db,
close(done) { wss.close(); server.close(() => { db.close(); if (done) done(); }); },
};
}
// Boot standalone if run directly.
if (require.main === module) {
const port = parseInt(process.env.PORT, 10) || 4001;
const dbPath = process.env.DB_PATH || './data/tracker.sqlite';
const { server } = createServer({ dbPath, port });
server.listen(port, () => {
console.log(`ttrpg backend listening on :${port} (db: ${dbPath})`);
});
}
module.exports = { createServer };
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/*.test.js'],
testTimeout: 10000,
};
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"dev": "node --watch index.js",
"start": "node index.js",
"test": "jest"
"test": "jest --forceExit"
},
"dependencies": {
"@ttrpg/shared": "*",
+113
View File
@@ -0,0 +1,113 @@
// Integration smoke for server. Spin on random port, hit REST, check WS broadcast.
'use strict';
const http = require('http');
const { createServer } = require('./index');
let BASE;
let handle;
beforeEach(async () => {
handle = createServer({ dbPath: ':memory:' });
await new Promise(r => handle.server.listen(0, r));
const addr = handle.server.address();
BASE = `http://127.0.0.1:${addr.port}`;
});
afterEach(async () => {
await new Promise(r => handle.close(r));
});
async function req(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
return { status: res.status, json };
}
describe('server REST', () => {
test('health', async () => {
const { status, json } = await req('GET', '/health');
expect(status).toBe(200);
expect(json.ok).toBe(true);
});
test('campaign create + list', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'Test', backgroundUrl: '' });
expect(c.name).toBe('Test');
const { json: list } = await req('GET', '/api/campaigns');
expect(list).toHaveLength(1);
expect(list[0].id).toBe(c.id);
});
test('encounter create + add monster + start', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
expect(e.participants).toEqual([]);
const { json: addRes } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, {
type: 'monster', name: 'Goblin', maxHp: 7, initMod: 2, isNpc: false,
});
expect(addRes.encounter.participants).toHaveLength(1);
expect(addRes.encounter.participants[0].name).toBe('Goblin');
expect(addRes.roll.total).toBeGreaterThan(2);
const { json: started } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
expect(started.isStarted).toBe(true);
expect(started.round).toBe(1);
expect(started.currentTurnParticipantId).toBe(addRes.encounter.participants[0].id);
});
test('next turn advances', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
const ids = [];
for (let i = 0; i < 3; i++) {
const { json: r } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, {
type: 'monster', name: `M${i}`, maxHp: 10, initMod: i,
});
ids.push(r.encounter.participants[r.encounter.participants.length - 1].id);
}
await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
const { json: t1 } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/nextTurn`);
expect(t1.currentTurnParticipantId).not.toBe(t1.turnOrderIds[0]);
});
test('damage to 0 deactivates', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
const { json: r } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, {
type: 'monster', name: 'Orc', maxHp: 5, initMod: 0,
});
const pid = r.encounter.participants[0].id;
await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
const { json: after } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants/${pid}/hp`, {
changeType: 'damage', amount: 5,
});
expect(after.participants[0].currentHp).toBe(0);
expect(after.participants[0].isActive).toBe(false);
});
test('error: start with no participants', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
const { status, json } = await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
expect(status).toBe(400);
expect(json.error).toMatch(/participants/i);
});
test('logs recorded on actions', async () => {
const { json: c } = await req('POST', '/api/campaigns', { name: 'C' });
const { json: e } = await req('POST', `/api/campaigns/${c.id}/encounters`, { name: 'E' });
await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/participants`, {
type: 'monster', name: 'Goblin', maxHp: 7, initMod: 2,
});
await req('POST', `/api/campaigns/${c.id}/encounters/${e.id}/start`);
const { json: logs } = await req('GET', '/api/logs');
expect(logs.length).toBeGreaterThanOrEqual(2);
expect(logs.some(l => /Combat started/.test(l.message))).toBe(true);
});
});