M3: fix path-shape drift via adapter contract + identity tests
Root cause (HAR-diagnosed): replay script wrote firebase-prefixed paths via raw REST, bypassing adapter norm(). Two path roots coexisted in db: bare 'campaigns/X' (adapter writes, from App) prefixed 'artifacts/.../campaigns/X' (replay raw writes) Adapter read bare, missed prefixed. UI showed stale test1 (legit manual UI write, not wiped) but replay campaigns invisible. A. replay-combat.js: use createWsStorage adapter instead of raw fetch. Same contract boundary as App. norm() runs on all paths. Can't drift. Mirror App.js getPath locally for path construction. B. contract.js: 4 new identity tests (setDoc prefixed -> getCollection bare, setDoc prefixed -> getDoc bare, setDoc prefixed -> getDoc prefixed, setDoc bare -> getCollection prefixed). Run against every impl (memory, ws). memory.js lacked norm() -> RED first, now GREEN after adding norm. C. db moved out of /tmp to ./data/tracker.sqlite (gitignored). Never tmp. Tests: 124 green (39 shared + 23 ws-contract + 62 FE).
This commit is contained in:
+39
-44
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user