Rework backend #1
@@ -11,3 +11,4 @@ data/*.sqlite
|
|||||||
data/*.sqlite-*
|
data/*.sqlite-*
|
||||||
server/data/*.sqlite
|
server/data/*.sqlite
|
||||||
server/data/*.sqlite-*
|
server/data/*.sqlite-*
|
||||||
|
/data
|
||||||
|
|||||||
+39
-44
@@ -14,46 +14,39 @@ const {
|
|||||||
startEncounter, nextTurn, applyHpChange, toggleCondition,
|
startEncounter, nextTurn, applyHpChange, toggleCondition,
|
||||||
endEncounter,
|
endEncounter,
|
||||||
} = shared;
|
} = shared;
|
||||||
|
const { createWsStorage } = require('../src/storage/ws');
|
||||||
|
|
||||||
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001';
|
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 WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws';
|
||||||
const BASE = `artifacts/${APP_ID}/public/data`;
|
|
||||||
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
const ROUNDS = parseInt(process.argv[2], 10) || 100;
|
||||||
const DELAY = parseInt(process.argv[3], 10) || 800;
|
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));
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
async function api(method, path, query, body) {
|
// Use the ADAPTER as the contract boundary (same as App). No raw REST, no
|
||||||
let url = `${BACKEND}${path}`;
|
// hand-built paths — adapter normalizes internally. Catches path-shape drift
|
||||||
if (query) url += '?' + new URLSearchParams(query).toString();
|
// that the earlier raw-REST replay caused.
|
||||||
const res = await fetch(url, {
|
const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_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 });
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`);
|
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 campaignId = crypto.randomUUID();
|
||||||
const encounterId = 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',
|
name: 'Replay Campaign',
|
||||||
playerDisplayBackgroundUrl: '',
|
playerDisplayBackgroundUrl: '',
|
||||||
ownerId: 'replay',
|
ownerId: 'replay',
|
||||||
@@ -84,7 +77,7 @@ async function main() {
|
|||||||
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
|
...monsterSpecs.map(m => buildMonsterParticipant(m).participant),
|
||||||
];
|
];
|
||||||
|
|
||||||
await docSet(encounterPath, {
|
await storage.setDoc(getPath.encounter(campaignId, encounterId), {
|
||||||
name: 'Big Boss Replay',
|
name: 'Big Boss Replay',
|
||||||
campaignId,
|
campaignId,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -99,55 +92,57 @@ async function main() {
|
|||||||
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
|
console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`);
|
||||||
|
|
||||||
// point active display so player view shows it
|
// point active display so player view shows it
|
||||||
await docSet(activeDisplayPath, {
|
await storage.setDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: campaignId,
|
activeCampaignId: campaignId,
|
||||||
activeEncounterId: encounterId,
|
activeEncounterId: encounterId,
|
||||||
hidePlayerHp: false,
|
hidePlayerHp: false,
|
||||||
});
|
});
|
||||||
await sleep(1000); // let player view load
|
await sleep(1000);
|
||||||
|
|
||||||
|
const encounterPath = getPath.encounter(campaignId, encounterId);
|
||||||
|
const activeDisplayPath = getPath.activeDisplay();
|
||||||
|
|
||||||
// start
|
// start
|
||||||
let enc = await docGet(encounterPath);
|
let enc = await storage.getDoc(encounterPath);
|
||||||
const start = startEncounter(enc);
|
const start = startEncounter(enc);
|
||||||
await docPatch(encounterPath, start.patch);
|
await storage.updateDoc(encounterPath, start.patch);
|
||||||
enc = { ...enc, ...start.patch };
|
enc = { ...enc, ...start.patch };
|
||||||
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
|
console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`);
|
||||||
await sleep(DELAY);
|
await sleep(DELAY);
|
||||||
|
|
||||||
// main loop
|
// main loop
|
||||||
for (let r = 1; r <= ROUNDS; r++) {
|
for (let r = 1; r <= ROUNDS; r++) {
|
||||||
enc = await docGet(encounterPath);
|
enc = await storage.getDoc(encounterPath);
|
||||||
const t = nextTurn(enc);
|
const t = nextTurn(enc);
|
||||||
await docPatch(encounterPath, t.patch);
|
await storage.updateDoc(encounterPath, t.patch);
|
||||||
enc = { ...enc, ...t.patch };
|
enc = { ...enc, ...t.patch };
|
||||||
|
|
||||||
// damage front monster
|
|
||||||
if (r % 2 === 0) {
|
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);
|
const orc = enc.participants.find(p => p.name === 'OrcBoss' && p.currentHp > 0);
|
||||||
if (orc) {
|
if (orc) {
|
||||||
const h = applyHpChange(enc, orc.id, 'damage', 5);
|
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) {
|
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);
|
const cleric = enc.participants.find(p => p.name === 'Cleric' && p.currentHp > 0);
|
||||||
if (cleric) {
|
if (cleric) {
|
||||||
const h = applyHpChange(enc, cleric.id, 'heal', 3);
|
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) {
|
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);
|
const fighter = enc.participants.find(p => p.name === 'Fighter' && p.currentHp > 0);
|
||||||
if (fighter) {
|
if (fighter) {
|
||||||
const c = toggleCondition(enc, fighter.id, 'stunned');
|
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}`);
|
console.log(`round ${r}: current=${firstActiveName(enc)} | round=${enc.round}`);
|
||||||
await sleep(DELAY);
|
await sleep(DELAY);
|
||||||
|
|
||||||
@@ -155,12 +150,12 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// end
|
// end
|
||||||
enc = await docGet(encounterPath);
|
enc = await storage.getDoc(encounterPath);
|
||||||
if (enc.isStarted) {
|
if (enc.isStarted) {
|
||||||
const end = endEncounter(enc);
|
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');
|
console.log('replay done');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
describe('getCollection', () => {
|
||||||
test('returns immediate child docs only (not nested)', async () => {
|
test('returns immediate child docs only (not nested)', async () => {
|
||||||
await storage.setDoc('campaigns/a', { name: 'A' });
|
await storage.setDoc('campaigns/a', { name: 'A' });
|
||||||
|
|||||||
+27
-13
@@ -11,6 +11,13 @@ function createMemoryStorage() {
|
|||||||
const bus = new EventEmitter();
|
const bus = new EventEmitter();
|
||||||
bus.setMaxListeners(1000);
|
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 ----
|
// ---- path helpers ----
|
||||||
// collection path = path with even number of segments OR known collection.
|
// collection path = path with even number of segments OR known collection.
|
||||||
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
|
// doc path = odd segments (coll/doc, coll/doc/subcoll/subdoc).
|
||||||
@@ -44,19 +51,21 @@ function createMemoryStorage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = {
|
const storage = {
|
||||||
async getDoc(path) {
|
async getDoc(rawPath) {
|
||||||
|
const path = norm(rawPath);
|
||||||
return docs.has(path) ? deepClone(docs.get(path)) : null;
|
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));
|
docs.set(path, deepClone(data));
|
||||||
emitDoc(path, deepClone(data));
|
emitDoc(path, deepClone(data));
|
||||||
// notify parent collection
|
|
||||||
const segs = path.split('/');
|
const segs = path.split('/');
|
||||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
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 existing = docs.has(path) ? docs.get(path) : {};
|
||||||
const merged = { ...existing, ...patch };
|
const merged = { ...existing, ...patch };
|
||||||
docs.set(path, merged);
|
docs.set(path, merged);
|
||||||
@@ -65,14 +74,16 @@ function createMemoryStorage() {
|
|||||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteDoc(path) {
|
async deleteDoc(rawPath) {
|
||||||
|
const path = norm(rawPath);
|
||||||
docs.delete(path);
|
docs.delete(path);
|
||||||
emitDoc(path, null);
|
emitDoc(path, null);
|
||||||
const segs = path.split('/');
|
const segs = path.split('/');
|
||||||
if (segs.length >= 2) emitCollection(segs.slice(0, -1).join('/'));
|
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 id = genId();
|
||||||
const path = `${collectionPath}/${id}`;
|
const path = `${collectionPath}/${id}`;
|
||||||
docs.set(path, deepClone(data));
|
docs.set(path, deepClone(data));
|
||||||
@@ -81,20 +92,22 @@ function createMemoryStorage() {
|
|||||||
return { id, path };
|
return { id, path };
|
||||||
},
|
},
|
||||||
|
|
||||||
async getCollection(collPath) {
|
async getCollection(rawCollPath) {
|
||||||
|
const collPath = norm(rawCollPath);
|
||||||
return collectionDocs(collPath).map(deepClone);
|
return collectionDocs(collPath).map(deepClone);
|
||||||
},
|
},
|
||||||
|
|
||||||
async batchWrite(ops) {
|
async batchWrite(ops) {
|
||||||
for (const op of ops) {
|
for (const op of ops) {
|
||||||
if (op.type === 'set') await storage.setDoc(op.path, op.data);
|
const mop = { ...op, path: norm(op.path) };
|
||||||
else if (op.type === 'delete') await storage.deleteDoc(op.path);
|
if (mop.type === 'set') await storage.setDoc(mop.path, mop.data);
|
||||||
else if (op.type === 'update') await storage.updateDoc(op.path, op.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) {
|
subscribeDoc(rawPath, cb) {
|
||||||
// fire immediately with current value
|
const path = norm(rawPath);
|
||||||
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
|
const cur = docs.has(path) ? deepClone(docs.get(path)) : null;
|
||||||
Promise.resolve().then(() => cb(cur));
|
Promise.resolve().then(() => cb(cur));
|
||||||
const handler = (data) => cb(data);
|
const handler = (data) => cb(data);
|
||||||
@@ -102,7 +115,8 @@ function createMemoryStorage() {
|
|||||||
return () => bus.off('doc:' + path, handler);
|
return () => bus.off('doc:' + path, handler);
|
||||||
},
|
},
|
||||||
|
|
||||||
subscribeCollection(collPath, cb) {
|
subscribeCollection(rawCollPath, cb) {
|
||||||
|
const collPath = norm(rawCollPath);
|
||||||
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
|
Promise.resolve().then(() => cb(collectionDocs(collPath).map(deepClone)));
|
||||||
const handler = (docs) => cb(docs);
|
const handler = (docs) => cb(docs);
|
||||||
bus.on('coll:' + collPath, handler);
|
bus.on('coll:' + collPath, handler);
|
||||||
|
|||||||
Reference in New Issue
Block a user