diff --git a/.gitignore b/.gitignore index 8950ce8..31a0bbd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ data/*.sqlite data/*.sqlite-* server/data/*.sqlite server/data/*.sqlite-* +/data diff --git a/scripts/replay-combat.js b/scripts/replay-combat.js index 629ef20..c65dc55 100644 --- a/scripts/replay-combat.js +++ b/scripts/replay-combat.js @@ -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'); } diff --git a/src/storage/contract.js b/src/storage/contract.js index 8e76bcd..ba3d0ac 100644 --- a/src/storage/contract.js +++ b/src/storage/contract.js @@ -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' }); diff --git a/src/storage/memory.js b/src/storage/memory.js index c3bd834..2d48104 100644 --- a/src/storage/memory.js +++ b/src/storage/memory.js @@ -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);