diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..c2327f2 --- /dev/null +++ b/server/db.js @@ -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, +}; diff --git a/server/handlers.js b/server/handlers.js new file mode 100644 index 0000000..e40d138 --- /dev/null +++ b/server/handlers.js @@ -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 }; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..8f2d09b --- /dev/null +++ b/server/index.js @@ -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. 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 }; diff --git a/server/jest.config.js b/server/jest.config.js new file mode 100644 index 0000000..61f4584 --- /dev/null +++ b/server/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['**/*.test.js'], + testTimeout: 10000, +}; diff --git a/server/package.json b/server/package.json index bcff624..51ffdcf 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "scripts": { "dev": "node --watch index.js", "start": "node index.js", - "test": "jest" + "test": "jest --forceExit" }, "dependencies": { "@ttrpg/shared": "*", diff --git a/server/server.test.js b/server/server.test.js new file mode 100644 index 0000000..3ff315a --- /dev/null +++ b/server/server.test.js @@ -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); + }); +});