Rework backend #1
+126
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
@@ -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 };
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/*.test.js'],
|
||||||
|
testTimeout: 10000,
|
||||||
|
};
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch index.js",
|
"dev": "node --watch index.js",
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"test": "jest"
|
"test": "jest --forceExit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ttrpg/shared": "*",
|
"@ttrpg/shared": "*",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user