// 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 };