Rework backend #1
+5
-6
@@ -14,11 +14,10 @@ TTRPG Initiative Tracker — fork with self-hosted backend. Monorepo via npm wor
|
|||||||
package.json # workspaces root
|
package.json # workspaces root
|
||||||
src/ # React frontend (CRA, existing)
|
src/ # React frontend (CRA, existing)
|
||||||
App.js # ~2935 lines, Firebase direct (M2 abstracts this)
|
App.js # ~2935 lines, Firebase direct (M2 abstracts this)
|
||||||
server/ # Backend: Express + ws + better-sqlite3
|
server/ # Backend: generic KV doc store (firebase mirror)
|
||||||
index.js # REST + WS bootstrap
|
index.js # REST (doc/coll/batch) + WS bootstrap
|
||||||
db.js # SQLite schema, row mappers
|
db.js # SQLite docs table, KV ops, broadcast
|
||||||
handlers.js # action -> shared turn fn -> tx persist -> broadcast
|
ws-contract.test.js # adapter vs live backend (Layer 2)
|
||||||
server.test.js # integration tests
|
|
||||||
shared/ # Pure logic, no I/O (client + server + tests import)
|
shared/ # Pure logic, no I/O (client + server + tests import)
|
||||||
turn.js # turn-order state machine
|
turn.js # turn-order state machine
|
||||||
turn.characterization.test.js
|
turn.characterization.test.js
|
||||||
@@ -67,7 +66,7 @@ Three commands:
|
|||||||
```bash
|
```bash
|
||||||
npm run test:all # runs shared/ + server/ suites in sequence
|
npm run test:all # runs shared/ + server/ suites in sequence
|
||||||
npm run shared:test # turn logic only (shared/ folder)
|
npm run shared:test # turn logic only (shared/ folder)
|
||||||
npm run server:test # backend REST + combat flow (server/ folder)
|
npm run server:test # backend ws-contract (adapter vs live backend)
|
||||||
```
|
```
|
||||||
|
|
||||||
What each runs:
|
What each runs:
|
||||||
|
|||||||
+3
-5
@@ -96,11 +96,9 @@ Memory impl: in-memory Map + EventEmitter, for tests (M3).
|
|||||||
memory.js # NEW — test only
|
memory.js # NEW — test only
|
||||||
types.js # interface contract (JSDoc)
|
types.js # interface contract (JSDoc)
|
||||||
server/ # NEW
|
server/ # NEW
|
||||||
index.js # Express + ws bootstrap
|
index.js # Express + ws bootstrap, generic KV REST
|
||||||
db.js # better-sqlite3, schema, migrations
|
db.js # better-sqlite3, docs table (KV), broadcast
|
||||||
turn.js # turn-order logic (pure, server-authoritative)
|
ws-contract.test.js # adapter vs live backend (Layer 2 test)
|
||||||
handlers/ # action handlers (call turn logic, persist, broadcast)
|
|
||||||
server.test.js # API + WS integration tests
|
|
||||||
shared/ # pure logic, no I/O, importable by client + server + tests
|
shared/ # pure logic, no I/O, importable by client + server + tests
|
||||||
turn.js # turn logic (single source; server imports, tests import)
|
turn.js # turn logic (single source; server imports, tests import)
|
||||||
types.js
|
types.js
|
||||||
|
|||||||
+83
-99
@@ -1,15 +1,16 @@
|
|||||||
// server/db.js — SQLite persistence layer.
|
// server/db.js — generic KV document store on SQLite.
|
||||||
// Owns the DB file. Only writer. Synchronous via better-sqlite3.
|
// Mirrors Firestore doc-tree model: every doc lives at a string path.
|
||||||
|
// Collections are implicit = all docs whose parent path equals the collection path.
|
||||||
//
|
//
|
||||||
// Schema mirrors the Firestore doc tree used by src/App.js:
|
// Path examples (canonical, prefix already stripped by adapter):
|
||||||
// artifacts/{APP_ID}/public/data/
|
// campaigns/{id} doc
|
||||||
// campaigns/{id} -> name, bg, ownerId, createdAt, players[]
|
// campaigns/{cid}/encounters/{eid} doc
|
||||||
// campaigns/{id}/encounters/{eid} -> name, participants[], round, currentTurnParticipantId, isStarted, isPaused, turnOrderIds[]
|
// campaigns/{cid}/encounters collection (parent of encounter docs)
|
||||||
// activeDisplay/status -> activeCampaignId, activeEncounterId, hidePlayerHp
|
// activeDisplay/status doc
|
||||||
// logs/{id} -> timestamp, message, encounterName, undo, undone
|
// logs/{id} doc
|
||||||
//
|
//
|
||||||
// Collections (campaigns, encounters, logs) -> rows with JSON blobs for fields.
|
// No shape-specific tables. Data is opaque JSON. This is the firebase mirror:
|
||||||
// Single-row "status" docs (activeDisplay) -> their own tables.
|
// the adapter (src/storage/ws.js) is a thin passthrough, app logic unchanged.
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
@@ -18,48 +19,14 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
const SCHEMA = `
|
const SCHEMA = `
|
||||||
CREATE TABLE IF NOT EXISTS campaigns (
|
CREATE TABLE IF NOT EXISTS docs (
|
||||||
id TEXT PRIMARY KEY,
|
path TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
parent TEXT,
|
||||||
playerDisplayBackgroundUrl TEXT NOT NULL DEFAULT '',
|
data TEXT NOT NULL,
|
||||||
ownerId TEXT,
|
updated_at INTEGER NOT NULL
|
||||||
createdAt TEXT NOT NULL,
|
|
||||||
players TEXT NOT NULL DEFAULT '[]'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS encounters (
|
CREATE INDEX IF NOT EXISTS idx_docs_parent ON docs(parent);
|
||||||
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) {
|
function openDb(dbPath) {
|
||||||
@@ -67,60 +34,77 @@ function openDb(dbPath) {
|
|||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
db.pragma('journal_mode = WAL');
|
db.pragma('journal_mode = WAL');
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
db.exec(SCHEMA);
|
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;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- JSON helpers ---
|
// parentOf('campaigns/abc/encounters/xyz') => 'campaigns/abc/encounters'
|
||||||
const parseArr = (s) => (s ? JSON.parse(s) : []);
|
// parentOf('campaigns') => null (root-level doc, no parent collection tracked)
|
||||||
const parseObj = (s, fallback = null) => (s ? JSON.parse(s) : fallback);
|
function parentOf(p) {
|
||||||
|
const i = p.lastIndexOf('/');
|
||||||
// --- Campaign shape (matches Firestore doc) ---
|
return i === -1 ? null : p.slice(0, i);
|
||||||
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 makeStore(db, broadcast) {
|
||||||
function rowToEncounter(row) {
|
const stmtGet = db.prepare('SELECT data FROM docs WHERE path = ?');
|
||||||
if (!row) return null;
|
const stmtUpsert = db.prepare(`
|
||||||
return {
|
INSERT INTO docs (path, parent, data, updated_at) VALUES (@path, @parent, @data, @ts)
|
||||||
id: row.id,
|
ON CONFLICT(path) DO UPDATE SET data = @data, updated_at = @ts
|
||||||
campaignId: row.campaignId,
|
`);
|
||||||
name: row.name,
|
const stmtDelete = db.prepare('DELETE FROM docs WHERE path = ?');
|
||||||
participants: parseArr(row.participants),
|
const stmtColl = db.prepare('SELECT path, data FROM docs WHERE parent = ? ORDER BY path ASC');
|
||||||
round: row.round,
|
|
||||||
currentTurnParticipantId: row.currentTurnParticipantId,
|
function getDoc(p) {
|
||||||
isStarted: !!row.isStarted,
|
const row = stmtGet.get(p);
|
||||||
isPaused: !!row.isPaused,
|
return row ? JSON.parse(row.data) : null;
|
||||||
turnOrderIds: parseArr(row.turnOrderIds),
|
}
|
||||||
createdAt: row.createdAt,
|
|
||||||
};
|
function setDoc(p, data) {
|
||||||
|
const ts = Date.now();
|
||||||
|
stmtUpsert.run({ path: p, parent: parentOf(p), data: JSON.stringify(data), ts });
|
||||||
|
if (broadcast) broadcast({ path: p, parent: parentOf(p) });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// shallow merge; if doc missing, patch becomes the doc (matches firebase updateDoc create-on-miss)
|
||||||
|
function updateDoc(p, patch) {
|
||||||
|
const existing = getDoc(p) || {};
|
||||||
|
const merged = { ...existing, ...patch };
|
||||||
|
setDoc(p, merged);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDoc(p) {
|
||||||
|
stmtDelete.run(p);
|
||||||
|
if (broadcast) broadcast({ path: p, parent: parentOf(p), deleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCollection(collPath) {
|
||||||
|
return stmtColl.all(collPath).map(row => ({ id: row.path.split('/').pop(), path: row.path, ...JSON.parse(row.data) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchWrite(ops) {
|
||||||
|
const run = db.transaction((items) => {
|
||||||
|
const changed = [];
|
||||||
|
for (const op of items) {
|
||||||
|
if (op.type === 'set') {
|
||||||
|
setDoc(op.path, op.data);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path) });
|
||||||
|
} else if (op.type === 'delete') {
|
||||||
|
deleteDoc(op.path);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path), deleted: true });
|
||||||
|
} else if (op.type === 'update') {
|
||||||
|
updateDoc(op.path, op.data);
|
||||||
|
changed.push({ path: op.path, parent: parentOf(op.path) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
});
|
||||||
|
const changed = run(ops);
|
||||||
|
if (broadcast) changed.forEach(c => broadcast(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getDoc, setDoc, updateDoc, deleteDoc, getCollection, batchWrite };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Active display shape ---
|
module.exports = { openDb, parentOf, makeStore };
|
||||||
function rowToActiveDisplay(row) {
|
|
||||||
if (!row) return null;
|
|
||||||
return {
|
|
||||||
activeCampaignId: row.activeCampaignId,
|
|
||||||
activeEncounterId: row.activeEncounterId,
|
|
||||||
hidePlayerHp: !!row.hidePlayerHp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
openDb,
|
|
||||||
rowToCampaign,
|
|
||||||
rowToEncounter,
|
|
||||||
rowToActiveDisplay,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,378 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
+87
-137
@@ -1,19 +1,15 @@
|
|||||||
// server/index.js — Express (HTTP) + ws (WebSocket) bootstrap.
|
// server/index.js — generic KV document store over HTTP + WebSocket.
|
||||||
// HTTP: REST-ish endpoints for actions. WS: real-time state push (replaces onSnapshot).
|
// firebase mirror: doc-tree model. Thin REST, path-based WS push.
|
||||||
//
|
// Adapter (src/storage/ws.js) = passthrough, no shape translation.
|
||||||
// Env:
|
|
||||||
// PORT (default 4001)
|
|
||||||
// DB_PATH (default ./data/tracker.sqlite)
|
|
||||||
// CORS_ORIGIN (default '*'; in-house only)
|
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
const { openDb } = require('./db');
|
const { openDb, makeStore } = require('./db');
|
||||||
const { createStore } = require('./handlers');
|
|
||||||
|
|
||||||
function createServer({ dbPath, port, corsOrigin } = {}) {
|
function createServer({ dbPath, port, corsOrigin } = {}) {
|
||||||
const db = openDb(dbPath || './data/tracker.sqlite');
|
const db = openDb(dbPath || './data/tracker.sqlite');
|
||||||
@@ -21,144 +17,98 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
|
|||||||
app.use(cors({ origin: corsOrigin || '*' }));
|
app.use(cors({ origin: corsOrigin || '*' }));
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
// WS subscribers. Map: key -> Set<ws>. key = 'campaigns' | 'campaign:id' | etc.
|
// WS subscribers: path -> Set<ws>.
|
||||||
const subscribers = new Map();
|
// Subscribers register a path (doc or collection). On change, notify:
|
||||||
function subscribe(key, ws) {
|
// - doc subscribers at the changed path
|
||||||
if (!subscribers.has(key)) subscribers.set(key, new Set());
|
// - collection subscribers at the changed doc's parent path
|
||||||
subscribers.get(key).add(ws);
|
const docSubscribers = new Map(); // path -> Set<ws>
|
||||||
|
const collSubscribers = new Map(); // collPath -> Set<ws>
|
||||||
|
|
||||||
|
function addSub(map, key, ws) {
|
||||||
|
if (!map.has(key)) map.set(key, new Set());
|
||||||
|
map.get(key).add(ws);
|
||||||
}
|
}
|
||||||
function unsubscribe(key, ws) {
|
function removeSub(map, key, ws) {
|
||||||
const set = subscribers.get(key);
|
const set = map.get(key);
|
||||||
if (set) { set.delete(ws); if (set.size === 0) subscribers.delete(key); }
|
if (set) { set.delete(ws); if (set.size === 0) map.delete(key); }
|
||||||
}
|
}
|
||||||
|
function dropWs(ws) {
|
||||||
|
for (const [key, set] of docSubscribers) { set.delete(ws); if (set.size === 0) docSubscribers.delete(key); }
|
||||||
|
for (const [key, set] of collSubscribers) { set.delete(ws); if (set.size === 0) collSubscribers.delete(key); }
|
||||||
|
}
|
||||||
|
|
||||||
function broadcast(change) {
|
function broadcast(change) {
|
||||||
const set = subscribers.get(change.type) || new Set();
|
const payload = JSON.stringify({ type: 'change', change });
|
||||||
// 'encounter'/'campaign' changes also notify bare type subscribers.
|
// doc subscriber at exact path
|
||||||
[...set].forEach(ws => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'change', change })); });
|
const docSet = docSubscribers.get(change.path);
|
||||||
|
if (docSet) [...docSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
|
||||||
|
// collection subscribers at parent path (collection contains this doc)
|
||||||
|
if (change.parent) {
|
||||||
|
const collSet = collSubscribers.get(change.parent);
|
||||||
|
if (collSet) [...collSet].forEach(ws => ws.readyState === ws.OPEN && ws.send(payload));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = createStore(db, broadcast);
|
const store = makeStore(db, broadcast);
|
||||||
|
|
||||||
|
// --- generic REST ---
|
||||||
|
|
||||||
// --- REST read endpoints ---
|
|
||||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
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 ---
|
// GET /api/doc?path=campaigns/abc/encounters/xyz
|
||||||
app.post('/api/campaigns', (req, res) => {
|
app.get('/api/doc', (req, res) => {
|
||||||
try { res.json(store.createCampaign(req.body)); }
|
const { path: p } = req.query;
|
||||||
catch (err) { res.status(400).json({ error: err.message }); }
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
});
|
res.json({ path: p, data: store.getDoc(p) });
|
||||||
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 ---
|
// GET /api/collection?path=campaigns/abc/encounters
|
||||||
app.post('/api/campaigns/:campaignId/encounters', (req, res) => {
|
app.get('/api/collection', (req, res) => {
|
||||||
try { res.json(store.createEncounter(req.params.campaignId, req.body.name)); }
|
const { path: p } = req.query;
|
||||||
catch (err) { res.status(400).json({ error: err.message }); }
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
});
|
res.json(store.getCollection(p));
|
||||||
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 ---
|
// PUT /api/doc body: { path, data } (replace)
|
||||||
app.post('/api/campaigns/:campaignId/encounters/:encounterId/participants', (req, res) => {
|
app.put('/api/doc', (req, res) => {
|
||||||
try { res.json(store.addParticipant(req.params.campaignId, req.params.encounterId, req.body)); }
|
const { path: p, data } = req.body || {};
|
||||||
catch (err) { res.status(400).json({ error: err.message }); }
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
});
|
res.json({ path: p, data: store.setDoc(p, data) });
|
||||||
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 ---
|
// PATCH /api/doc body: { path, patch } (shallow merge, create-on-miss)
|
||||||
app.post('/api/campaigns/:campaignId/encounters/:encounterId/start', (req, res) => {
|
app.patch('/api/doc', (req, res) => {
|
||||||
try { res.json(store.startEncounter(req.params.campaignId, req.params.encounterId)); }
|
const { path: p, patch } = req.body || {};
|
||||||
catch (err) { res.status(400).json({ error: err.message }); }
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
});
|
res.json({ path: p, data: store.updateDoc(p, patch) });
|
||||||
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 ---
|
// DELETE /api/doc?path=...
|
||||||
app.post('/api/activeDisplay/hidePlayerHp', (req, res) => {
|
app.delete('/api/doc', (req, res) => {
|
||||||
try { res.json({ hidePlayerHp: store.toggleHidePlayerHp() }); }
|
const { path: p } = req.query;
|
||||||
catch (err) { res.status(400).json({ error: err.message }); }
|
if (!p) return res.status(400).json({ error: 'path required' });
|
||||||
});
|
store.deleteDoc(p);
|
||||||
app.delete('/api/logs', (req, res) => {
|
res.json({ ok: true });
|
||||||
try { store.clearLogs(); res.json({ ok: true }); }
|
|
||||||
catch (err) { res.status(400).json({ error: err.message }); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- WebSocket: real-time push ---
|
// POST /api/collection body: { path, data } (addDoc: auto-id under collection)
|
||||||
|
app.post('/api/collection', (req, res) => {
|
||||||
|
const { path: collPath, data } = req.body || {};
|
||||||
|
if (!collPath) return res.status(400).json({ error: 'path required' });
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const docPath = `${collPath}/${id}`;
|
||||||
|
res.json({ id, path: docPath, data: store.setDoc(docPath, data) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/batch body: { ops: [{type:'set'|'update'|'delete', path, data?}] }
|
||||||
|
app.post('/api/batch', (req, res) => {
|
||||||
|
const { ops } = req.body || {};
|
||||||
|
if (!Array.isArray(ops)) return res.status(400).json({ error: 'ops array required' });
|
||||||
|
store.batchWrite(ops);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- WebSocket: subscribe by path ---
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||||
|
|
||||||
@@ -166,16 +116,16 @@ function createServer({ dbPath, port, corsOrigin } = {}) {
|
|||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
let msg;
|
let msg;
|
||||||
try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; }
|
try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', error: 'bad json' })); return; }
|
||||||
if (msg.type === 'subscribe' && msg.key) {
|
if (msg.type === 'subscribe' && msg.path) {
|
||||||
subscribe(msg.key, ws);
|
if (msg.kind === 'collection') addSub(collSubscribers, msg.path, ws);
|
||||||
ws.send(JSON.stringify({ type: 'subscribed', key: msg.key }));
|
else addSub(docSubscribers, msg.path, ws);
|
||||||
} else if (msg.type === 'unsubscribe' && msg.key) {
|
ws.send(JSON.stringify({ type: 'subscribed', path: msg.path, kind: msg.kind || 'doc' }));
|
||||||
unsubscribe(msg.key, ws);
|
} else if (msg.type === 'unsubscribe' && msg.path) {
|
||||||
|
if (msg.kind === 'collection') removeSub(collSubscribers, msg.path, ws);
|
||||||
|
else removeSub(docSubscribers, msg.path, ws);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ws.on('close', () => {
|
ws.on('close', () => dropWs(ws));
|
||||||
for (const [key, set] of subscribers) { set.delete(ws); if (set.size === 0) subscribers.delete(key); }
|
|
||||||
});
|
|
||||||
ws.on('error', () => {});
|
ws.on('error', () => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Layer 2 test: exercise ws.js storage adapter against a LIVE backend.
|
||||||
|
// Complements Layer 1 (App + firebase mock) which proves App call shape but
|
||||||
|
// never touches ws.js. This catches translation bugs in the adapter.
|
||||||
|
//
|
||||||
|
// Runs the shared storage contract (same spec memory/firebase satisfy) against
|
||||||
|
// createWsStorage pointed at an ephemeral backend instance. A FRESH backend is
|
||||||
|
// spun up per test to guarantee isolation (backend has no reset endpoint yet).
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { createServer } = require('../server/index');
|
||||||
|
const { createWsStorage } = require('../src/storage/ws');
|
||||||
|
const { runStorageContract } = require('../src/storage/contract');
|
||||||
|
|
||||||
|
let nextPort = 4000 + Math.floor(Math.random() * 999);
|
||||||
|
|
||||||
|
// Factory: fresh backend (unique sqlite file) + storage pointed at it.
|
||||||
|
// Disposing the storage closes the backend so each test is fully isolated.
|
||||||
|
async function makeStorage() {
|
||||||
|
const port = nextPort++;
|
||||||
|
const dbPath = path.join(os.tmpdir(), `ws-contract-${port}-${Date.now()}.sqlite`);
|
||||||
|
const handle = createServer({ dbPath, port });
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
handle.server.on('error', reject);
|
||||||
|
handle.server.listen(port, resolve);
|
||||||
|
});
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const wsUrl = `ws://127.0.0.1:${port}/ws`;
|
||||||
|
const storage = createWsStorage({ baseUrl, wsUrl });
|
||||||
|
storage.dispose = (done) => handle.close(done);
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
runStorageContract('ws (live backend)', makeStorage);
|
||||||
@@ -191,9 +191,10 @@ function runStorageContract(name, factory) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// microtask flush so async subscribers settle.
|
// flush so async subscribers settle. WS roundtrip needs real delay (network),
|
||||||
|
// memory fires near-instant. 50ms covers localhost WS comfortably.
|
||||||
function flush() {
|
function flush() {
|
||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
return new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { runStorageContract, flush };
|
module.exports = { runStorageContract, flush };
|
||||||
|
|||||||
+60
-162
@@ -1,15 +1,14 @@
|
|||||||
// ws.js — storage adapter talking to backend over REST + WebSocket.
|
// ws.js — thin storage adapter over generic KV backend (HTTP + WebSocket).
|
||||||
|
// Passthrough: no shape translation. Backend = firebase mirror.
|
||||||
// Implements same interface as memory.js. Tested by storage contract vs running server.
|
// Implements same interface as memory.js. Tested by storage contract vs running server.
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Use native browser WebSocket when available (production). Fallback to the
|
// Native browser WebSocket if present, else ws pkg (Node/jest).
|
||||||
// `ws` npm package in Node/jest where global WebSocket is absent.
|
|
||||||
let WebSocketImpl;
|
let WebSocketImpl;
|
||||||
if (typeof WebSocket !== 'undefined') {
|
if (typeof WebSocket !== 'undefined') {
|
||||||
WebSocketImpl = WebSocket;
|
WebSocketImpl = WebSocket;
|
||||||
} else {
|
} else {
|
||||||
// require inside else so webpack ignores it in browser bundle
|
|
||||||
WebSocketImpl = require('ws').WebSocket;
|
WebSocketImpl = require('ws').WebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,9 +16,10 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, '');
|
const API = (baseUrl || 'http://127.0.0.1:4001').replace(/\/$/, '');
|
||||||
const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws');
|
const WS = wsUrl || (API.replace(/^http/, 'ws') + '/ws');
|
||||||
|
|
||||||
// App.js passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
|
// App passes firebase-prefixed paths: artifacts/{APP_ID}/public/data/campaigns/...
|
||||||
// Backend uses canonical: campaigns/... Strip the prefix so all matchers work.
|
// Backend uses canonical paths. Strip prefix.
|
||||||
function norm(p) {
|
function norm(p) {
|
||||||
|
if (!p) return p;
|
||||||
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
return p.replace(/^[\s\S]*\/public\/data\//, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,13 +27,11 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
const collSubs = new Map(); // collPath -> Set<cb>
|
const collSubs = new Map(); // collPath -> Set<cb>
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let wsReady = null;
|
let wsReady = null;
|
||||||
const pendingPaths = new Set();
|
|
||||||
|
|
||||||
function ensureWs() {
|
function ensureWs() {
|
||||||
if (wsReady) return wsReady;
|
if (wsReady) return wsReady;
|
||||||
wsReady = new Promise((resolve, reject) => {
|
wsReady = new Promise((resolve, reject) => {
|
||||||
ws = new WebSocketImpl(WS);
|
ws = new WebSocketImpl(WS);
|
||||||
// addEventListener works on both browser WebSocket and Node ws pkg.
|
|
||||||
const onOpen = () => resolve(ws);
|
const onOpen = () => resolve(ws);
|
||||||
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
|
const onError = (err) => { wsReady = null; reject(err instanceof Event ? new Error('ws error') : err); };
|
||||||
const onClose = () => { wsReady = null; };
|
const onClose = () => { wsReady = null; };
|
||||||
@@ -42,12 +40,10 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
|
let msg; try { msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()); } catch { return; }
|
||||||
handleMessage(msg);
|
handleMessage(msg);
|
||||||
};
|
};
|
||||||
// browser-style property handlers
|
|
||||||
ws.onopen = onOpen;
|
ws.onopen = onOpen;
|
||||||
ws.onerror = onError;
|
ws.onerror = onError;
|
||||||
ws.onclose = onClose;
|
ws.onclose = onClose;
|
||||||
ws.onmessage = onMessage;
|
ws.onmessage = onMessage;
|
||||||
// Node ws-style addEventListener fallback (noop in browser if absent)
|
|
||||||
if (typeof ws.addEventListener === 'function') {
|
if (typeof ws.addEventListener === 'function') {
|
||||||
ws.addEventListener('open', onOpen);
|
ws.addEventListener('open', onOpen);
|
||||||
ws.addEventListener('error', onError);
|
ws.addEventListener('error', onError);
|
||||||
@@ -58,47 +54,33 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
return wsReady;
|
return wsReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend pushes change notices (coarse: type-based). We re-fetch affected paths.
|
// Backend pushes change notices keyed by path. Re-fetch affected subscribers.
|
||||||
async function handleMessage(msg) {
|
async function handleMessage(msg) {
|
||||||
if (msg.type !== 'change' || !msg.change) return;
|
if (msg.type !== 'change' || !msg.change) return;
|
||||||
const c = msg.change;
|
const c = msg.change;
|
||||||
// Notify doc subscribers whose normalized path we cached.
|
// doc subscriber at exact changed path
|
||||||
for (const [rawPath, cbs] of docSubs) {
|
const docCbs = docSubs.get(c.path);
|
||||||
const path = norm(rawPath);
|
if (docCbs) {
|
||||||
if (pathMatchesChange(path, c)) {
|
const doc = await storage.getDoc(c.path);
|
||||||
const doc = await storage.getDoc(path);
|
docCbs.forEach(cb => cb(doc));
|
||||||
cbs.forEach(cb => cb(doc));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const [rawCollPath, cbs] of collSubs) {
|
// collection subscribers at parent path (doc belongs to this collection)
|
||||||
const collPath = norm(rawCollPath);
|
if (c.parent) {
|
||||||
if (collMatchesChange(collPath, c)) {
|
const collCbs = collSubs.get(c.parent);
|
||||||
const docs = await storage.getCollection(collPath);
|
if (collCbs) {
|
||||||
cbs.forEach(cb => cb(docs));
|
const docs = await storage.getCollection(c.parent);
|
||||||
|
collCbs.forEach(cb => cb(docs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pathMatchesChange(path, c) {
|
async function api(method, path, query, body) {
|
||||||
// Naive: campaign doc path includes campaignId; encounter doc includes encounterId.
|
let url = `${API}${path}`;
|
||||||
if (c.type === 'campaign' && c.campaignId && path === docPathForCampaign(c.campaignId)) return true;
|
if (query) {
|
||||||
if (c.type === 'encounter' && c.campaignId && c.encounterId && path === docPathForEncounter(c.campaignId, c.encounterId)) return true;
|
const qs = new URLSearchParams(query).toString();
|
||||||
if (c.type === 'activeDisplay' && path === 'activeDisplay/status') return true;
|
url += `?${qs}`;
|
||||||
return false;
|
}
|
||||||
}
|
const res = await fetch(url, {
|
||||||
function collMatchesChange(collPath, c) {
|
|
||||||
if (c.type === 'campaigns' && collPath === 'campaigns') return true;
|
|
||||||
if (c.type === 'encounters' && c.campaignId && collPath === `campaigns/${c.campaignId}/encounters`) return true;
|
|
||||||
if (c.type === 'logs' && collPath === 'logs') return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backend uses different shape (rows) than firebase docs. We adapt to doc model.
|
|
||||||
// To keep contract passing + match App.js expectations, we expose docs at canonical paths
|
|
||||||
// AND translate backend REST responses into doc-shaped data.
|
|
||||||
|
|
||||||
async function api(method, path, body) {
|
|
||||||
const res = await fetch(`${API}${path}`, {
|
|
||||||
method,
|
method,
|
||||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
@@ -112,134 +94,67 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = {
|
const storage = {
|
||||||
// --- reads ---
|
|
||||||
async getDoc(rawPath) {
|
async getDoc(rawPath) {
|
||||||
const path = norm(rawPath);
|
const p = norm(rawPath);
|
||||||
if (path === 'activeDisplay/status') {
|
const res = await api('GET', '/api/doc', { path: p });
|
||||||
const ad = await api('GET', '/api/activeDisplay');
|
return res && res.data !== undefined ? res.data : null;
|
||||||
return ad;
|
|
||||||
}
|
|
||||||
const m = path.match(/^campaigns\/([^/]+)$/);
|
|
||||||
if (m) {
|
|
||||||
const c = await api('GET', `/api/campaigns/${m[1]}`);
|
|
||||||
return c || null;
|
|
||||||
}
|
|
||||||
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
|
|
||||||
if (em) {
|
|
||||||
const e = await api('GET', `/api/campaigns/${em[1]}/encounters/${em[2]}`);
|
|
||||||
return e || null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getCollection(rawCollPath) {
|
|
||||||
const collPath = norm(rawCollPath);
|
|
||||||
if (collPath === 'campaigns') return await api('GET', '/api/campaigns');
|
|
||||||
const m = collPath.match(/^campaigns\/([^/]+)\/encounters$/);
|
|
||||||
if (m) return await api('GET', `/api/campaigns/${m[1]}/encounters`);
|
|
||||||
if (collPath === 'logs') return await api('GET', '/api/logs');
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- writes (translated to backend action endpoints) ---
|
|
||||||
async setDoc(rawPath, data) {
|
async setDoc(rawPath, data) {
|
||||||
const path = norm(rawPath);
|
const p = norm(rawPath);
|
||||||
// activeDisplay merges
|
await api('PUT', '/api/doc', null, { path: p, data });
|
||||||
if (path === 'activeDisplay/status') {
|
|
||||||
if ('activeCampaignId' in data || 'activeEncounterId' in data) {
|
|
||||||
await api('POST', `/api/campaigns/${data.activeCampaignId}/encounters/${data.activeEncounterId}/display`).catch(() => {});
|
|
||||||
}
|
|
||||||
if ('hidePlayerHp' in data) {
|
|
||||||
await api('POST', '/api/activeDisplay/hidePlayerHp').catch(() => {});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cm = path.match(/^campaigns\/([^/]+)$/);
|
|
||||||
if (cm) {
|
|
||||||
// create or replace campaign
|
|
||||||
await api('POST', '/api/campaigns', { name: data.name, backgroundUrl: data.playerDisplayBackgroundUrl, ownerId: data.ownerId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
|
|
||||||
if (em) {
|
|
||||||
await api('POST', `/api/campaigns/${em[1]}/encounters`, { name: data.name });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateDoc(rawPath, patch) {
|
async updateDoc(rawPath, patch) {
|
||||||
const path = norm(rawPath);
|
const p = norm(rawPath);
|
||||||
const cm = path.match(/^campaigns\/([^/]+)$/);
|
await api('PATCH', '/api/doc', null, { path: p, patch });
|
||||||
if (cm) {
|
|
||||||
if (Array.isArray(patch.players)) {
|
|
||||||
// players array is full replacement of character roster
|
|
||||||
// backend has dedicated char endpoints; for bulk we just set via direct if needed.
|
|
||||||
// For now: no-op bulk (App.js uses add/update/delete char endpoints individually upstream)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
|
|
||||||
if (em) {
|
|
||||||
const [campaignId, encounterId] = [em[1], em[2]];
|
|
||||||
// participants array patch = full replace. Map to per-participant ops is complex;
|
|
||||||
// backend owns participants via dedicated endpoints, so direct array replace unsupported here.
|
|
||||||
// Most App.js writes go through dedicated endpoints; this path mainly used by drag-drop reorder.
|
|
||||||
if (patch.participants && patch.dragInfo) {
|
|
||||||
await api('POST', `/api/campaigns/${campaignId}/encounters/${encounterId}/reorder`, patch.dragInfo);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteDoc(rawPath) {
|
async deleteDoc(rawPath) {
|
||||||
const path = norm(rawPath);
|
const p = norm(rawPath);
|
||||||
const cm = path.match(/^campaigns\/([^/]+)$/);
|
await api('DELETE', '/api/doc', { path: p });
|
||||||
if (cm) { await api('DELETE', `/api/campaigns/${cm[1]}`); return; }
|
|
||||||
const em = path.match(/^campaigns\/([^/]+)\/encounters\/([^/]+)$/);
|
|
||||||
if (em) { await api('DELETE', `/api/campaigns/${em[1]}/encounters/${em[2]}`); return; }
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async addDoc(rawCollPath, data) {
|
async addDoc(rawCollPath, data) {
|
||||||
const collPath = norm(rawCollPath);
|
const p = norm(rawCollPath);
|
||||||
if (collPath === 'logs') {
|
const res = await api('POST', '/api/collection', null, { path: p, data });
|
||||||
// backend auto-logs; direct insert not needed
|
return { id: res.id, path: res.path };
|
||||||
return { id: 'auto', path: 'logs/auto' };
|
},
|
||||||
}
|
|
||||||
return { id: 'unsupported', path: collPath + '/unsupported' };
|
async getCollection(rawCollPath) {
|
||||||
|
const p = norm(rawCollPath);
|
||||||
|
return await api('GET', '/api/collection', { path: p });
|
||||||
},
|
},
|
||||||
|
|
||||||
async batchWrite(ops) {
|
async batchWrite(ops) {
|
||||||
for (const op of ops) {
|
const normOps = ops.map(op => ({ ...op, path: norm(op.path) }));
|
||||||
if (op.type === 'set') await storage.setDoc(op.path, op.data);
|
await api('POST', '/api/batch', null, { ops: normOps });
|
||||||
else if (op.type === 'delete') await storage.deleteDoc(op.path);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeDoc(rawPath, cb) {
|
subscribeDoc(rawPath, cb) {
|
||||||
const path = norm(rawPath);
|
const p = norm(rawPath);
|
||||||
|
// Initial value via REST (independent of WS connect).
|
||||||
|
storage.getDoc(p).then(cb).catch(() => {});
|
||||||
|
// WS only for subsequent change notifications.
|
||||||
ensureWs().then(() => {
|
ensureWs().then(() => {
|
||||||
// subscribe to coarse change types that could affect this path
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'doc', path: p }));
|
||||||
const types = changeTypesForDocPath(path);
|
|
||||||
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
|
|
||||||
// fire current
|
|
||||||
storage.getDoc(path).then(cb).catch(() => {});
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
if (!docSubs.has(path)) docSubs.set(path, new Set());
|
if (!docSubs.has(p)) docSubs.set(p, new Set());
|
||||||
docSubs.get(path).add(cb);
|
docSubs.get(p).add(cb);
|
||||||
return () => { docSubs.get(path)?.delete(cb); };
|
return () => { docSubs.get(p)?.delete(cb); };
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeCollection(rawCollPath, cb) {
|
subscribeCollection(rawCollPath, cb) {
|
||||||
const collPath = norm(rawCollPath);
|
const p = norm(rawCollPath);
|
||||||
|
// Initial value via REST (independent of WS connect).
|
||||||
|
storage.getCollection(p).then(cb).catch(() => {});
|
||||||
|
// WS only for subsequent change notifications.
|
||||||
ensureWs().then(() => {
|
ensureWs().then(() => {
|
||||||
const types = changeTypesForCollPath(collPath);
|
ws.send(JSON.stringify({ type: 'subscribe', kind: 'collection', path: p }));
|
||||||
types.forEach(t => ws.send(JSON.stringify({ type: 'subscribe', key: t })));
|
|
||||||
storage.getCollection(collPath).then(cb).catch(() => {});
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
if (!collSubs.has(collPath)) collSubs.set(collPath, new Set());
|
if (!collSubs.has(p)) collSubs.set(p, new Set());
|
||||||
collSubs.get(collPath).add(cb);
|
collSubs.get(p).add(cb);
|
||||||
return () => { collSubs.get(collPath)?.delete(cb); };
|
return () => { collSubs.get(p)?.delete(cb); };
|
||||||
},
|
},
|
||||||
|
|
||||||
dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); },
|
dispose() { if (ws) ws.close(); docSubs.clear(); collSubs.clear(); },
|
||||||
@@ -250,21 +165,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
return storage;
|
return storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeTypesForDocPath(rawPath) {
|
|
||||||
const path = rawPath.replace(/^[\s\S]*\/public\/data\//, '');
|
|
||||||
if (path === 'activeDisplay/status') return ['activeDisplay'];
|
|
||||||
if (path.match(/^campaigns\/[^/]+\/encounters\//)) return ['encounter', 'activeDisplay'];
|
|
||||||
if (path.match(/^campaigns\//)) return ['campaign', 'campaigns'];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
function changeTypesForCollPath(rawCollPath) {
|
|
||||||
const collPath = rawCollPath.replace(/^[\s\S]*\/public\/data\//, '');
|
|
||||||
if (collPath === 'campaigns') return ['campaigns'];
|
|
||||||
if (collPath.match(/^campaigns\/[^/]+\/encounters$/)) return ['encounters'];
|
|
||||||
if (collPath === 'logs') return ['logs'];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
function docPathForCampaign(id) { return `campaigns/${id}`; }
|
|
||||||
function docPathForEncounter(campaignId, encounterId) { return `campaigns/${campaignId}/encounters/${encounterId}`; }
|
|
||||||
|
|
||||||
module.exports = { createWsStorage };
|
module.exports = { createWsStorage };
|
||||||
|
|||||||
Reference in New Issue
Block a user