Rework backend #1

Merged
robert merged 86 commits from rework-backend into main 2026-07-01 19:29:34 -04:00
4 changed files with 99 additions and 57 deletions
Showing only changes of commit 9fd0f3ec38 - Show all commits
+1
View File
@@ -11,3 +11,4 @@ data/*.sqlite
data/*.sqlite-*
server/data/*.sqlite
server/data/*.sqlite-*
/data
+39 -44
View File
@@ -14,46 +14,39 @@ const {
startEncounter, nextTurn, applyHpChange, toggleCondition,
endEncounter,
} = shared;
const { createWsStorage } = require('../src/storage/ws');
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const BASE = `artifacts/${APP_ID}/public/data`;
const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws';
const ROUNDS = parseInt(process.argv[2], 10) || 100;
const DELAY = parseInt(process.argv[3], 10) || 800;
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUB = `artifacts/${APP_ID}/public/data`;
// Mirror App.js getPath. Adapter takes these; norm() strips prefix.
const getPath = {
campaigns: () => `${PUB}/campaigns`,
campaign: (id) => `${PUB}/campaigns/${id}`,
encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`,
encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`,
activeDisplay: () => `${PUB}/activeDisplay/status`,
};
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function api(method, path, query, body) {
let url = `${BACKEND}${path}`;
if (query) url += '?' + new URLSearchParams(query).toString();
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const t = await res.text().catch(() => '');
throw new Error(`API ${method} ${path} ${res.status}: ${t}`);
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
const docGet = (p) => api('GET', '/api/doc', { path: p }).then(r => r && r.data);
const docSet = (p, data) => api('PUT', '/api/doc', null, { path: p, data });
const docPatch = (p, patch) => api('PATCH', '/api/doc', null, { path: p, patch });
// Use the ADAPTER as the contract boundary (same as App). No raw REST, no
// hand-built paths — adapter normalizes internally. Catches path-shape drift
// that the earlier raw-REST replay caused.
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL });
async function main() {
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
// campaign + encounter
// campaign + encounter. Adapter takes firebase-prefixed paths (same as App).
const campaignId = crypto.randomUUID();
const encounterId = crypto.randomUUID();
const campaignPath = `${BASE}/campaigns/${campaignId}`;
const encounterPath = `${BASE}/campaigns/${campaignId}/encounters/${encounterId}`;
const activeDisplayPath = `${BASE}/activeDisplay/status`;
await docSet(campaignPath, {
await storage.setDoc(getPath.campaign(campaignId), {
name: 'Replay Campaign',
playerDisplayBackgroundUrl: '',
ownerId: 'replay',
@@ -84,7 +77,7 @@ async function main() {
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
];
await docSet(encounterPath, {
await storage.setDoc(getPath.encounter(campaignId, encounterId), {
name: 'Big Boss Replay',
campaignId,
createdAt: new Date().toISOString(),
@@ -99,55 +92,57 @@ async function main() {
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
// point active display so player view shows it
await docSet(activeDisplayPath, {
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
hidePlayerHp: false,
});
await sleep(1000); // let player view load
await sleep(1000);
const encounterPath = getPath.encounter(campaignId, encounterId);
const activeDisplayPath = getPath.activeDisplay();
// start
let enc = await docGet(encounterPath);
let enc = await storage.getDoc(encounterPath);
const start = startEncounter(enc);
await docPatch(encounterPath, start.patch);
await storage.updateDoc(encounterPath, start.patch);
enc = { ...enc, ...start.patch };
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
await sleep(DELAY);
// main loop
for (let r = 1; r <= ROUNDS; r++) {
enc = await docGet(encounterPath);
enc = await storage.getDoc(encounterPath);
const t = nextTurn(enc);
await docPatch(encounterPath, t.patch);
await storage.updateDoc(encounterPath, t.patch);
enc = { ...enc, ...t.patch };
// damage front monster
if (r % 2 === 0) {
enc = await docGet(encounterPath);
enc = await storage.getDoc(encounterPath);
const orc = enc.participants.find(p => p.name === 'OrcBoss' && p.currentHp > 0);
if (orc) {
const h = applyHpChange(enc, orc.id, 'damage', 5);
if (h.patch) { await docPatch(encounterPath, h.patch); enc = { ...enc, ...h.patch }; }
if (h.patch) { await storage.updateDoc(encounterPath, h.patch); enc = { ...enc, ...h.patch }; }
}
}
if (r % 3 === 0) {
enc = await docGet(encounterPath);
enc = await storage.getDoc(encounterPath);
const cleric = enc.participants.find(p => p.name === 'Cleric' && p.currentHp > 0);
if (cleric) {
const h = applyHpChange(enc, cleric.id, 'heal', 3);
if (h.patch) { await docPatch(encounterPath, h.patch); enc = { ...enc, ...h.patch }; }
if (h.patch) { await storage.updateDoc(encounterPath, h.patch); enc = { ...enc, ...h.patch }; }
}
}
if (r % 5 === 0) {
enc = await docGet(encounterPath);
enc = await storage.getDoc(encounterPath);
const fighter = enc.participants.find(p => p.name === 'Fighter' && p.currentHp > 0);
if (fighter) {
const c = toggleCondition(enc, fighter.id, 'stunned');
if (c.patch) { await docPatch(encounterPath, c.patch); enc = { ...enc, ...c.patch }; }
if (c.patch) { await storage.updateDoc(encounterPath, c.patch); enc = { ...enc, ...c.patch }; }
}
}
enc = await docGet(encounterPath);
enc = await storage.getDoc(encounterPath);
console.log(`round ${r}: current=${firstActiveName(enc)} | round=${enc.round}`);
await sleep(DELAY);
@@ -155,12 +150,12 @@ async function main() {
}
// end
enc = await docGet(encounterPath);
enc = await storage.getDoc(encounterPath);
if (enc.isStarted) {
const end = endEncounter(enc);
if (end.patch) await docPatch(encounterPath, end.patch);
if (end.patch) await storage.updateDoc(encounterPath, end.patch);
}
await docPatch(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null });
console.log('replay done');
}
+32
View File
@@ -89,6 +89,38 @@ function runStorageContract(name, factory) {
});
});
describe('firebase-prefixed path identity', () => {
// App passes firebase-prefixed paths (artifacts/{APP_ID}/public/data/...).
// Adapter must normalize internally so write+read at prefixed path round-trips
// AND collection queries at bare canonical path find prefixed-written docs.
// Catches replay-script bug (wrote prefixed, adapter reads bare, missed).
const PREFIX = 'artifacts/test-app/public/data';
test('setDoc prefixed then getCollection bare finds it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c1`, { name: 'P1' });
const docs = await storage.getCollection('campaigns');
expect(docs.some(d => d.name === 'P1')).toBe(true);
});
test('setDoc prefixed then getDoc same prefixed path returns it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c2`, { name: 'P2' });
const doc = await storage.getDoc(`${PREFIX}/campaigns/c2`);
expect(doc).toEqual({ name: 'P2' });
});
test('setDoc prefixed then getDoc bare path returns it', async () => {
await storage.setDoc(`${PREFIX}/campaigns/c3`, { name: 'P3' });
const doc = await storage.getDoc('campaigns/c3');
expect(doc).toEqual({ name: 'P3' });
});
test('setDoc bare then getCollection prefixed finds it', async () => {
await storage.setDoc('campaigns/c4', { name: 'P4' });
const docs = await storage.getCollection(`${PREFIX}/campaigns`);
expect(docs.some(d => d.name === 'P4')).toBe(true);
});
});
describe('getCollection', () => {
test('returns immediate child docs only (not nested)', async () => {
await storage.setDoc('campaigns/a', { name: 'A' });
+27 -13
View File
@@ -11,6 +11,13 @@ function createMemoryStorage() {
const bus = new EventEmitter();
bus.setMaxListeners(1000);
// Firebase-prefixed paths (artifacts/{APP_ID}/public/data/...) normalized to
// bare canonical. Matches ws.js norm() so all impls share path identity.
function norm(p) {
if (!p) return p;
return p.replace(/^[\s\S]*\/public\/data\//, '');
}
// ---- path helpers ----
// collection path = path with even number of segments OR known collection.
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
@@ -44,19 +51,21 @@ function createMemoryStorage() {
}
const storage = {
async getDoc(path) {
async getDoc(rawPath) {
const path = norm(rawPath);
return docs.has(path) ? deepClone(docs.get(path)) : null;
},
async setDoc(path, data) {
async setDoc(rawPath, data) {
const path = norm(rawPath);
docs.set(path, deepClone(data));
emitDoc(path, deepClone(data));
// notify parent collection
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async updateDoc(path, patch) {
async updateDoc(rawPath, patch) {
const path = norm(rawPath);
const existing = docs.has(path) ? docs.get(path) : {};
const merged = { ...existing, ...patch };
docs.set(path, merged);
@@ -65,14 +74,16 @@ function createMemoryStorage() {
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async deleteDoc(path) {
async deleteDoc(rawPath) {
const path = norm(rawPath);
docs.delete(path);
emitDoc(path, null);
const segs = path.split('/');
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
},
async addDoc(collectionPath, data) {
async addDoc(rawCollectionPath, data) {
const collectionPath = norm(rawCollectionPath);
const id = genId();
const path = `${collectionPath}/${id}`;
docs.set(path, deepClone(data));
@@ -81,20 +92,22 @@ function createMemoryStorage() {
return { id, path };
},
async getCollection(collPath) {
async getCollection(rawCollPath) {
const collPath = norm(rawCollPath);
return collectionDocs(collPath).map(deepClone);
},
async batchWrite(ops) {
for (const op of ops) {
if (op.type === 'set') await storage.setDoc(op.path, op.data);
else if (op.type === 'delete') await storage.deleteDoc(op.path);
else if (op.type === 'update') await storage.updateDoc(op.path, op.data);
const mop = { ...op, path: norm(op.path) };
if (mop.type === 'set') await storage.setDoc(mop.path, mop.data);
else if (mop.type === 'delete') await storage.deleteDoc(mop.path);
else if (mop.type === 'update') await storage.updateDoc(mop.path, mop.data);
}
},
subscribeDoc(path, cb) {
// fire immediately with current value
subscribeDoc(rawPath, cb) {
const path = norm(rawPath);
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
Promise.resolve().then(() => cb(cur));
const handler = (data) => cb(data);
@@ -102,7 +115,8 @@ function createMemoryStorage() {
return () => bus.off('doc:' + path, handler);
},
subscribeCollection(collPath, cb) {
subscribeCollection(rawCollPath, cb) {
const collPath = norm(rawCollPath);
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
const handler = (docs) => cb(docs);
bus.on('coll:' + collPath, handler);