Files
ttrpg-initiative-tracker/server/handlers.js
T

379 lines
17 KiB
JavaScript
Raw Normal View History

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