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.
199 lines
9.7 KiB
JavaScript
199 lines
9.7 KiB
JavaScript
// 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 };
|