diff --git a/scripts/author-templates.cjs b/scripts/author-templates.cjs index ad4ed655..d5b4d8d6 100644 --- a/scripts/author-templates.cjs +++ b/scripts/author-templates.cjs @@ -1151,6 +1151,19 @@ const BEAT_DEF = { } }; +// ---- module-owned DEFS (the LOADER region) ------------------------------------------- +// Each slug here is read from the sibling modules checkout as +// modules//.def.json (moduleDef above), emitted by that module's own build so the +// def and the module cannot drift. `--only ` authors one of them; the def's +// `installModules` installs the zips first so the card shows the game. +// football — 24-B (PR #218) +// dungeon-realms — 21-C C6-b: the TWO-MODULE case (`modules: dungeon + dungeon-realms`); +// its world is scene-root module content, so the def names +// `thumb.sceneGroups: ['dungeon-module']` to get it onto the card +// untangle — 21-C C7: the thin template (pose + level + room + HUD + graph); its +// board is scene-root content too (`thumb.sceneGroups: ['untangle-module']`) +const MODULE_DEFS = ['football', 'dungeon-realms', 'untangle']; + const DEFS = [ { kind: 'template', @@ -1297,7 +1310,7 @@ const DEFS = [ MIRROR_DEF, BEAT_DEF, // the def is the module's (see moduleDef); a checkout without it cannot author it - ...['football'].map((id) => moduleDef(id) ?? { slug: id, missingModuleDef: true }) + ...MODULE_DEFS.map((id) => moduleDef(id) ?? { slug: id, missingModuleDef: true }) ]; (async () => { @@ -1677,6 +1690,17 @@ const DEFS = [ scene.add(sun); const clone = new T.ObjectLoader().parse(group.toJSON()); scene.add(clone); + // 21-C C6-b: a module's WORLD lives at the scene root (golden rule 5), so a + // card rendered from objectsGroup alone shows a dungeon template as a lone + // arch. `thumb.sceneGroups` names scene-root groups to include — cloned into + // the offscreen scene, never moved; absent, the picture is what it always was. + /** @type {any} */ let liveScene; + s.globalScene.subscribe((v) => (liveScene = v))(); + for (const name of d.thumb?.sceneGroups ?? []) { + const live = liveScene?.getObjectByName(name); + if (live) clone.add(live.clone(true)); + else console.log(' WARN thumb.sceneGroups: no scene-root group named ' + name); + } const box = new T.Box3().setFromObject(clone); const size = Math.max(box.getSize(new T.Vector3()).length(), 1); const center = box.getCenter(new T.Vector3()); diff --git a/tests/e2e/game-dungeon-realms.test.cjs b/tests/e2e/game-dungeon-realms.test.cjs new file mode 100644 index 00000000..9bf08554 --- /dev/null +++ b/tests/e2e/game-dungeon-realms.test.cjs @@ -0,0 +1,354 @@ +// 21-C C6-b ACCEPTANCE — the Dungeon Realms game template: the TWO-MODULE case (the +// `dungeon` Kit generates + renders the world from the graph's Dungeon node; the +// `dungeon-realms` module plays it) on the REAL artefacts and nothing authored in-test: +// the scene — games/dungeon-realms/scene.tpscene from the scenes FEED (SCENES_BASE, +// tag v2), or DUNGEON_REALMS_TPSCENE=, or a sibling scenes checkout +// the zips — dungeon.zip + dungeon-realms.zip: DUNGEON_KIT_ZIP / DUNGEON_REALMS_ZIP, +// the MODULES_REPO checkout, a packed sibling modules checkout, or the CDN +// installed on TWO peers plus a LATE JOINER. Skip-never-fail: when the scene or a zip +// cannot be reached the suite prints why and exits green. +// +// What it proves: +// 1 the world arrives whole: the 6 arch objects, night env, the play block (click, +// grounded, no sim), the HUD document, both modules in the file's requirement list — +// and the Kit regenerated SEED 1337 FROM THE NODE after applySession's `/clear all` +// (counterfactual: a menu-seeded dungeon loaded before the file is replaced, so the +// "every game template silently starts elsewhere" failure is asserted, not believed) +// 2 B receives it over the handshake: same objects, same Kit checksum, same graph +// 3 play: the module menu, P1/P2 through it, Start -> the `drevent start` stamp in the +// TRIGGER LOG -> Set Game State flips the shell to playing on BOTH -> the HUD screen; +// the HUD Text elements read the Realms Value nodes (gems, need, level), the lists +// read Realms HUD Rows (players, objective), grounded resolves true from the contract +// 4 a gem on A -> B's HUD text; the replicated gem EVENT counts once per pickup on both +// (Counter -> HUD Text) — the fireNodeTrigger contract +// 5 P pauses / Resume; unseal -> portal click -> floor 2 on both (LEVEL 2 in the HUD); +// the top floor's gems -> victory -> `drevent victory` -> the shell is `over` +// 6 the late joiner: C joins mid-game, reads the objects, the Kit seed + floor, the +// collected gems and the game shell +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_BASE = (process.env.DUNGEON_REALMS_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2').replace(/\/$/, ''); +const MODULES_BASE = (process.env.DUNGEON_REALMS_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); +const ROOT = path.resolve(__dirname, '../../..'); +const SEED = 1337; + +/** bytes from a URL, or null with the reason logged (never throws) */ +async function fetchBytes(url) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(20000) }); + if (!res.ok) { + console.log(' (source) ' + url + ' -> HTTP ' + res.status); + return null; + } + return Buffer.from(await res.arrayBuffer()); + } catch (error) { + console.log(' (source) ' + url + ' -> ' + error.message); + return null; + } +} + +/** the scene: env override -> the feed -> a sibling scenes checkout */ +async function sceneBytes() { + if (process.env.DUNGEON_REALMS_TPSCENE) { + const p = process.env.DUNGEON_REALMS_TPSCENE; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const feed = await fetchBytes(SCENES_BASE + '/games/dungeon-realms/scene.tpscene'); + if (feed) return { bytes: feed, from: SCENES_BASE }; + for (const dir of ['theprototype.app-scenes', 'scenes']) { + const p = path.join(ROOT, dir, 'games/dungeon-realms/scene.tpscene'); + if (fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + } + return null; +} + +/** a module zip: env override -> MODULES_REPO -> a packed sibling modules checkout -> the CDN + * @param {string} id @param {string} envKey */ +async function zipBytes(id, envKey) { + if (process.env[envKey]) { + const p = process.env[envKey]; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const local = []; + if (process.env.MODULES_REPO) local.push(path.join(process.env.MODULES_REPO, id + '.zip')); + local.push(h.moduleZipPath(id)); + for (const dir of fs.readdirSync(ROOT)) if (/^(theprototype\.app-)?modules/.test(dir)) local.push(path.join(ROOT, dir, id + '.zip')); + for (const p of local) if (p && fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + const cdn = await fetchBytes(MODULES_BASE + '/' + id + '.zip'); + return cdn ? { bytes: cdn, from: MODULES_BASE } : null; +} + +/** install zip bytes through the REAL manager (the helper only knows one folder) */ +async function installZip(peer, id, bytes, label) { + await peer.page.evaluate(() => window.__stores.modulesOpen.set(true)); + await peer.page.waitForTimeout(400); + await peer.page.getByRole('tab', { name: /^User/ }).click(); + await peer.page.waitForTimeout(200); + await peer.page.locator('#install-module-zip').setInputFiles({ name: id + '.zip', mimeType: 'application/zip', buffer: bytes }); + await h.eventually( + () => peer.page.evaluate(() => window.__stores.moduleSDK.loadedModules.map((m) => m.id)), + (ids) => ids.includes(id), + label + ': ' + id + ' installed from the real zip', + 20000 + ); + await peer.page.evaluate(() => window.__stores.modulesOpen.set(false)); + await peer.page.waitForTimeout(300); +} + +// ---- page probes ------------------------------------------------------------------------- + +/** the Kit world + the Realms game on a page */ +const snap = (page) => + page.evaluate(() => { + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + const kitGroup = scene?.getObjectByName('dungeon-module'); + const overlay = scene?.getObjectByName('dungeon-realms'); + const game = window.__dungeonRealms?.game ?? null; + const floors = kitGroup?.getObjectByName('dk-floors'); + const portal = overlay?.getObjectByName('dr-portal-up'); + const play = kitGroup?.userData?.play; + return { + kit: !!kitGroup?.userData?.kit, + seed: kitGroup?.userData?.seed ?? null, + floorIndex: kitGroup?.userData?.floorIndex ?? null, + levelCount: kitGroup?.userData?.levelCount ?? null, + checksum: kitGroup?.userData?.checksum ?? null, + campaignChecksum: kitGroup?.userData?.campaignChecksum ?? null, + floorInstances: floors ? floors.count : 0, + grounded: play?.grounded ?? null, + markers: Array.isArray(play?.markers) ? play.markers.length : null, + overlay: !!overlay, + started: !!game?.state.started, + won: !!game?.state.wonAt, + p1: game?.state.slots.p1?.peerId ?? null, + p2: game?.state.slots.p2?.peerId ?? null, + collected: game ? Object.values(game.state.collected).reduce((sum, set) => sum + set.size, 0) : 0, + sealedUp: portal ? portal.userData.portal.sealed : null, + need: game?.gemTotals().need ?? 0, + total: game?.gemTotals().total ?? 0, + gameFloor: game?.state.floorIndex ?? null, + menu: !!document.getElementById('dr-menu'), + resolvedGrounded: window.__stores.playSettings.resolvePlaySettings(scene).grounded + }; + }); + +/** name -> uuid for every object in the replicated group */ +const namesOf = (page) => + page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return Object.fromEntries((g?.children ?? []).map((c) => [c.name, c.uuid])); + }); + +/** the trigger log's stamp for a node, or null */ +const stampOf = (page, id) => + page.evaluate((id) => { + let t; + window.__stores.flowTriggers.subscribe((v) => (t = v))(); + const s = t?.[id]?.lastT; + return typeof s === 'number' ? s : null; + }, id); + +const gameStateOf = (page) => + page.evaluate(() => { + let g; + window.__stores.gameState.gameState.subscribe((v) => (g = v))(); + return g?.state ?? null; + }); +const screenOf = (page) => page.evaluate(() => window.__stores.hudDocs.visibleScreen('scene')?.id ?? null); +const hudText = async (page) => (await page.locator('#hud-layer').textContent().catch(() => '')) ?? ''; +const hudRuntime = (page) => + page.evaluate(() => { + let r; + window.__stores.hudDocs.hudRuntime.subscribe((v) => (r = v))(); + return r ?? {}; + }); +const pressP = (page) => + page.evaluate(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyP', bubbles: true })); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyP', bubbles: true })); + }); +const hudButton = (page, name) => page.getByRole('button', { name, exact: true }); +/** click a module MENU button by its action id (the module DOM menu) */ +const clickMenu = (page, id) => + page.evaluate((id) => { + const button = document.querySelector('#dr-menu .dr-btn[data-id="' + id + '"]'); + if (!button) return false; + button.click(); + return true; + }, id); +/** every graph node of a type, `{id, data}` */ +const nodesOf = (page, type) => + page.evaluate((type) => { + let graphs; + window.__stores.flowGraphs.subscribe((g) => (graphs = g))(); + const out = []; + for (const graph of Object.values(graphs ?? {})) for (const n of graph.nodes ?? []) if (n.type === type) out.push({ id: n.id, data: n.data }); + return out; + }, type); +/** collect `count` gems on the current floor through the module's debug hook */ +const collect = (page, count) => + page.evaluate((count) => { + const g = window.__dungeonRealms.game; + const floor = g.state.floorIndex; + let done = 0; + for (let i = 0; i < g.gemTotals().total && done < count; i++) { + if (g.state.collected[floor]?.has(i)) continue; + g.collectGem(floor, i); + done++; + } + return done; + }, count); + +h.run(async () => { + const scene = await sceneBytes(); + if (!scene) { + console.log('SKIP: games/dungeon-realms/scene.tpscene unreachable (feed ' + SCENES_BASE + ', no DUNGEON_REALMS_TPSCENE, no sibling scenes checkout)'); + return; + } + const kitZip = await zipBytes('dungeon', 'DUNGEON_KIT_ZIP'); + const realmsZip = await zipBytes('dungeon-realms', 'DUNGEON_REALMS_ZIP'); + if (!kitZip || !realmsZip) { + console.log('SKIP: dungeon.zip / dungeon-realms.zip unreachable (no env override, no MODULES_REPO, no packed sibling checkout, CDN ' + MODULES_BASE + ')'); + return; + } + console.log(' scene from ' + scene.from + ' (' + scene.bytes.length + ' bytes)'); + console.log(' kit from ' + kitZip.from + ' (' + kitZip.bytes.length + ' bytes), realms from ' + realmsZip.from + ' (' + realmsZip.bytes.length + ' bytes)'); + + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const B = await h.setupPage(browser, 'B'); + for (const peer of [A, B]) { + await installZip(peer, 'dungeon', kitZip.bytes, peer === A ? 'A' : 'B'); + await installZip(peer, 'dungeon-realms', realmsZip.bytes, peer === A ? 'A' : 'B'); + } + + // ---- 1. the world ------------------------------------------------------------------------- + // the counterfactual premise: a dungeon with ANOTHER seed exists before the file loads + await A.page.evaluate(() => window.__dungeonKit.kit.generate(777, {})); + await h.eventually(() => snap(A.page), (s) => s.seed === 777 && s.floorInstances > 0, '1.0 (premise) a menu-seeded dungeon (777) stands before the file loads'); + const payload = await A.page.evaluate(async (arr) => { + const s = window.__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + const modules = (payload.modules ?? []).map((m) => m.id); + await s.sessions.applySession(payload, { backup: false }); + return { modules }; + }, Array.from(scene.bytes)); + await A.page.waitForTimeout(2500); + h.check(payload.modules.includes('dungeon') && payload.modules.includes('dungeon-realms'), '1.1 the file\'s requirement list names BOTH modules (' + payload.modules.join(',') + ')'); + const names = await namesOf(A.page); + h.check(Object.keys(names).length === 6 && !!names['Entrance plinth'] && !!names['Arch lintel'], '1.2 the file restored the 6 arch objects (' + Object.keys(names).length + ')'); + const env = await A.page.evaluate(() => { + let e; + window.__stores.environment.environment.subscribe((v) => (e = v))(); + return e?.preset ?? null; + }); + h.check(env === 'night', '1.3 the night environment preset (' + env + ')'); + const phys = await A.page.evaluate(() => window.__stores.scenePhysics.scenePhysicsDebug()); + h.check(phys.play?.interaction === 'click' && phys.play?.grounded === true && phys.play?.simOnPlay === false, '1.4 play block: click, grounded, no sim (' + JSON.stringify(phys.play) + ')'); + await h.eventually(() => snap(A.page), (s) => s.seed === SEED && s.floorInstances > 0 && s.overlay, '1.5 the Kit regenerated SEED ' + SEED + ' FROM THE NODE after /clear all (777 is gone) and Realms overlaid it', 15000); + const a1 = await snap(A.page); + h.check(a1.levelCount === 5 && a1.floorIndex === 1 && a1.total >= 4, '1.6 five floors, floor 1, ' + a1.total + ' gems (need ' + a1.need + ')'); + const dungeonNodes = await nodesOf(A.page, 'dkdungeon'); + h.check(dungeonNodes.length === 1 && dungeonNodes[0].data.apply === true && dungeonNodes[0].data.seed === SEED, '1.7 ONE Dungeon node owns the recipe (apply on, seed ' + SEED + ')'); + h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.8 the game shell starts in menu with the menu screen'); + h.check(/DUNGEON REALMS/.test(await hudText(A.page)), '1.9 the HUD menu screen renders its title'); + h.check(a1.resolvedGrounded === true && a1.grounded === true, '1.10 grounded resolves TRUE from the Kit contract (playSettings)'); + + // ---- 2. B receives it over the handshake --------------------------------------------------- + await h.connect(A, B); + // by UUID, not name: a mesh name with a space arrives underscored on the peer + // ("Entrance_plinth" — the light keeps its space), and the graph binds by uuid anyway + await h.eventually( + () => namesOf(B.page).then((n) => ({ uuids: Object.values(n), count: Object.keys(n).length })), + (v) => v.count === 6 && v.uuids.includes(names['Entrance plinth']), + '2.1 B received the 6 arch objects with the same plinth uuid', + 30000 + ); + await h.eventually(() => snap(B.page), (s) => s.seed === SEED && s.checksum === a1.checksum && s.campaignChecksum === a1.campaignChecksum && s.overlay, '2.2 B: the graph replicated and its Dungeon node built the SAME world (checksums match)', 20000); + await h.eventually(() => nodesOf(B.page, 'drevent').then((n) => n.length), (n) => n === 4, '2.3 B: the four Realms Event nodes are in the graph'); + await h.eventually(() => screenOf(B.page), (v) => v === 'menu', '2.4 B: the HUD document arrived (menu screen)', 10000); + + // ---- 3. play, the module menu, Start -> trigger log -> game shell -> HUD ---------------------- + await A.page.locator('#play-button').click(); + await B.page.locator('#play-button').click(); + await h.eventually(() => snap(A.page), (s) => s.menu, '3.1 A: the module start menu appears in play mode'); + await h.eventually(() => snap(B.page), (s) => s.menu, '3.2 B: the module start menu appears'); + h.check(await clickMenu(A.page, 'join-p1'), '3.3 A joins as Player 1 (module menu)'); + h.check(await clickMenu(B.page, 'join-p2'), '3.4 B joins as Player 2'); + await h.eventually(() => snap(B.page), (s) => s.p1 === A.id && s.p2 === B.id, '3.5 B sees both slots'); + const startNode = (await nodesOf(A.page, 'drevent')).find((n) => n.data.event === 'start'); + const startBefore = await stampOf(B.page, startNode.id); + await clickMenu(A.page, 'start'); + await h.eventually(() => snap(B.page), (s) => s.started, '3.6 B: the game started'); + await h.eventually(() => stampOf(B.page, startNode.id), (t) => t !== null && t !== startBefore, '3.7 B: the "On start" stamp landed in the TRIGGER LOG'); + await h.eventually(() => gameStateOf(B.page), (v) => v === 'playing', '3.8 ...and its Set Game State flipped the shell to playing on B'); + await h.eventually(() => gameStateOf(A.page), (v) => v === 'playing', '3.9 ...and on A'); + await h.eventually(() => screenOf(A.page), (v) => v === 'hud', '3.10 A sees the playing HUD screen', 6000); + await h.eventually(() => hudRuntime(A.page), (r) => r['dr-gems']?.text === '0' && r['dr-need']?.text === '/ ' + a1.need + ' needed', '3.11 HUD Text reads the Realms Value nodes: gems 0, need ' + a1.need); + await h.eventually(() => hudRuntime(A.page), (r) => r['dr-level']?.text === 'LEVEL 1' && r['dr-levels']?.text === '/ 5', '3.12 HUD Text: LEVEL 1 / 5'); + await h.eventually(() => hudRuntime(A.page), (r) => (r['dr-players']?.rows ?? []).length === 2 && (r['dr-objective']?.rows ?? []).length === 1, '3.13 HUD lists read Realms HUD Rows: 2 player pills, 1 objective line'); + await h.eventually(() => hudText(A.page), (t) => /LEVEL 1/.test(t) && /needed/.test(t) && /more gem/.test(t), '3.14 the HUD layer renders the level, the gem line and the objective'); + + // ---- 4. a gem: HUD text on B, the replicated EVENT counts once per pickup ------------------- + const takenBefore = (await hudRuntime(B.page))['dr-taken']?.text ?? ''; + h.check(await collect(A.page, 1) === 1, '4.1 A collects one gem'); + await h.eventually(() => hudRuntime(B.page), (r) => r['dr-gems']?.text === '1', '4.2 B\'s HUD Text reads 1 gem'); + await h.eventually(() => hudRuntime(A.page), (r) => r['dr-taken']?.text === '1 gems taken', '4.3 A: the gem EVENT counted once (Counter -> HUD Text)'); + await h.eventually(() => hudRuntime(B.page), (r) => r['dr-taken']?.text === '1 gems taken', '4.4 B: the replicated event counted ONCE, not once per peer (was "' + takenBefore + '")'); + await h.eventually(() => snap(B.page), (s) => s.markers === a1.total - 1 + 1, '4.5 B: one minimap marker fewer (gems + the portal)'); + + // ---- 5. pause, unseal, travel, victory ------------------------------------------------------- + await pressP(A.page); + await h.eventually(() => screenOf(A.page), (v) => v === 'pause', '5.1 P opens the pause screen', 6000); + await hudButton(A.page, 'Resume').click(); + await h.eventually(() => screenOf(A.page), (v) => v === 'hud', '5.2 Resume closes it', 6000); + await collect(A.page, a1.need); + await h.eventually(() => snap(A.page), (s) => s.sealedUp === false, '5.3 A: the UP portal unseals at the threshold'); + await h.eventually(() => snap(B.page), (s) => s.sealedUp === false, '5.4 B: unseal replicated'); + const travelled = await A.page.evaluate(() => { + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + const ring = scene.getObjectByName('dungeon-realms').getObjectByName('dr-portal-up').children[0]; + return window.__stores.moduleSDK.moduleClickHandlers.some((handler) => handler(ring)); + }); + h.check(travelled, '5.5 A clicks the portal (module click handler consumed it)'); + await h.eventually(() => snap(A.page), (s) => s.floorIndex === 2 && s.gameFloor === 2, '5.6 A: floor 2 (Kit + game agree)'); + const a2 = await snap(A.page); + await h.eventually(() => snap(B.page), (s) => s.floorIndex === 2 && s.checksum === a2.checksum, '5.7 B: floor 2, same checksum'); + await h.eventually(() => hudRuntime(B.page), (r) => r['dr-level']?.text === 'LEVEL 2', '5.8 B\'s HUD Text reads LEVEL 2'); + // climb to the top: collect the threshold and step up, floor by floor + for (let floor = 2; floor < 5; floor++) { + await collect(A.page, (await snap(A.page)).need); + await h.eventually(() => snap(A.page), (s) => s.sealedUp === false, '5.9 floor ' + floor + ' unseals'); + await A.page.evaluate((next) => window.__dungeonRealms.game.travel(next), floor + 1); + await h.eventually(() => snap(A.page), (s) => s.floorIndex === floor + 1 && s.gameFloor === floor + 1, '5.10 A reaches floor ' + (floor + 1)); + } + const winNode = (await nodesOf(A.page, 'drevent')).find((n) => n.data.event === 'victory'); + const winBefore = await stampOf(B.page, winNode.id); + await collect(A.page, (await snap(A.page)).need); + await h.eventually(() => snap(A.page), (s) => s.won, '5.11 A: victory on the top floor'); + await h.eventually(() => snap(B.page), (s) => s.won, '5.12 B: victory replicated'); + await h.eventually(() => stampOf(B.page, winNode.id), (t) => t !== null && t !== winBefore, '5.13 B: the "On victory" stamp landed in the TRIGGER LOG'); + await h.eventually(() => gameStateOf(B.page), (v) => v === 'over', '5.14 ...and the shell is OVER on B'); + await h.eventually(() => screenOf(A.page), (v) => v === 'over', '5.15 A sees the victory screen', 6000); + h.check(/HOARD IS YOURS/.test(await hudText(A.page)), '5.16 the victory screen renders'); + + // ---- 6. the late joiner ------------------------------------------------------------------------ + await A.page.keyboard.press('Escape'); + await A.page.waitForTimeout(500); + const C = await h.setupPage(browser, 'C'); + await installZip(C, 'dungeon', kitZip.bytes, 'C'); + await installZip(C, 'dungeon-realms', realmsZip.bytes, 'C'); + await h.connect(C, A); + const aEnd = await snap(A.page); + await h.eventually(() => namesOf(C.page).then((n) => Object.keys(n).length), (n) => n === 6, '6.1 C received the arch', 30000); + await h.eventually(() => snap(C.page), (s) => s.seed === SEED && s.floorIndex === 5 && s.checksum === aEnd.checksum && s.overlay, '6.2 C: the Kit rebuilt seed ' + SEED + ' on floor 5 (same checksum) with the overlay', 30000); + await h.eventually(() => snap(C.page), (s) => s.collected === aEnd.collected && s.started && s.won, '6.3 C: the game state caught up (gems, started, won)', 20000); + await h.eventually(() => gameStateOf(C.page), (v) => v === 'over', '6.4 C: the game shell reads over'); + + await h.finish(browser); +}); diff --git a/tests/e2e/game-untangle.test.cjs b/tests/e2e/game-untangle.test.cjs new file mode 100644 index 00000000..6ea1a78d --- /dev/null +++ b/tests/e2e/game-untangle.test.cjs @@ -0,0 +1,283 @@ +// 21-C C7 ACCEPTANCE — the Untangle game template: the thin, honest template (pose + +// level + room + HUD + graph; the puzzle stays imperative) on the REAL artefacts: +// the scene — games/untangle/scene.tpscene from the scenes FEED (tag v2), or +// UNTANGLE_TPSCENE=, or a sibling scenes checkout +// the zip — untangle.zip: UNTANGLE_ZIP=, MODULES_REPO, a packed sibling modules +// checkout, or the modules CDN +// on TWO peers plus a LATE JOINER. Skip-never-fail when either cannot be reached. +// +// What it proves: +// 1 the world arrives whole: the 6 room objects, the sunset env, the play block, the +// HUD document, `untangle` in the requirement list — and THE ORDERING: applySession +// runs `/clear all` FIRST (the module resets to level 1), then the graph's Untangle +// Board node applies its level on the next tick. A board on level 5 BEFORE the load +// ends on level 2 (the node's), not 1 (the clear) and not 5 — the plan's "every game +// template silently starts at level 1" failure, asserted rather than believed +// 2 the pose is node data: boardY from the node, and editing the node on A moves the +// board on B (the replicated graph), then the value is put back +// 3 B receives it over the handshake: same objects, same level, same scramble +// 4 play: the HUD menu, Start -> playing + the HUD screen; HUD Text reads the Untangle +// Value nodes (LEVEL 2, the crossings count); a move on A changes B's crossings text +// 5 a solve: detected on both from the same positions; the `utevent solved` stamp in +// the TRIGGER LOG on B; the Counter -> HUD Text counts ONCE on both (fireNodeTrigger +// replicates from the peer where it happened); autoAdvance -> level 3 on both +// 6 no sprite on desktop (the VR-only path); P pauses / Resume +// 7 the late joiner: C reads the room, the level, A's positions and the game shell +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const SCENES_BASE = (process.env.UNTANGLE_SCENES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/scenes@v2').replace(/\/$/, ''); +const MODULES_BASE = (process.env.UNTANGLE_MODULES_BASE || 'https://cdn.jsdelivr.net/gh/theprototype-app/modules@main').replace(/\/$/, ''); +const ROOT = path.resolve(__dirname, '../../..'); +const TEMPLATE_LEVEL = 2; + +async function fetchBytes(url) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(20000) }); + if (!res.ok) { + console.log(' (source) ' + url + ' -> HTTP ' + res.status); + return null; + } + return Buffer.from(await res.arrayBuffer()); + } catch (error) { + console.log(' (source) ' + url + ' -> ' + error.message); + return null; + } +} +async function sceneBytes() { + if (process.env.UNTANGLE_TPSCENE) { + const p = process.env.UNTANGLE_TPSCENE; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const feed = await fetchBytes(SCENES_BASE + '/games/untangle/scene.tpscene'); + if (feed) return { bytes: feed, from: SCENES_BASE }; + for (const dir of ['theprototype.app-scenes', 'scenes']) { + const p = path.join(ROOT, dir, 'games/untangle/scene.tpscene'); + if (fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + } + return null; +} +async function zipBytes() { + if (process.env.UNTANGLE_ZIP) { + const p = process.env.UNTANGLE_ZIP; + return fs.existsSync(p) ? { bytes: fs.readFileSync(p), from: p } : null; + } + const local = []; + if (process.env.MODULES_REPO) local.push(path.join(process.env.MODULES_REPO, 'untangle.zip')); + local.push(h.moduleZipPath('untangle')); + for (const dir of fs.readdirSync(ROOT)) if (/^(theprototype\.app-)?modules/.test(dir)) local.push(path.join(ROOT, dir, 'untangle.zip')); + for (const p of local) if (p && fs.existsSync(p)) return { bytes: fs.readFileSync(p), from: p }; + const cdn = await fetchBytes(MODULES_BASE + '/untangle.zip'); + return cdn ? { bytes: cdn, from: MODULES_BASE } : null; +} +async function installZip(peer, bytes, label) { + await peer.page.evaluate(() => window.__stores.modulesOpen.set(true)); + await peer.page.waitForTimeout(400); + await peer.page.getByRole('tab', { name: /^User/ }).click(); + await peer.page.waitForTimeout(200); + await peer.page.locator('#install-module-zip').setInputFiles({ name: 'untangle.zip', mimeType: 'application/zip', buffer: bytes }); + await h.eventually( + () => peer.page.evaluate(() => window.__stores.moduleSDK.loadedModules.map((m) => m.id)), + (ids) => ids.includes('untangle'), + label + ': the untangle module installed from the real zip', + 20000 + ); + await peer.page.evaluate(() => window.__stores.modulesOpen.set(false)); + await peer.page.waitForTimeout(300); +} + +// ---- probes -------------------------------------------------------------------------------- +const snap = (page) => + page.evaluate(() => { + let scene; + window.__stores.globalScene.subscribe((v) => (scene = v))(); + const group = scene?.getObjectByName('untangle-module'); + const s = window.__untangle?.state() ?? null; + return s + ? { + level: s.level, + crossings: s.crossings, + won: s.won, + built: s.built, + nodeOwned: s.nodeOwned, + sceneClears: s.sceneClears, + sprite: s.sprite, + board: s.board, + positions: s.positions.map((p) => [Math.round(p[0] * 1000) / 1000, Math.round(p[1] * 1000) / 1000]), + groupY: group ? Math.round(group.position.y * 100) / 100 : null, + dots: group ? group.children.filter((c) => c.name.startsWith('untangle-dot-')).length : 0, + spriteInScene: !!group?.getObjectByName('untangle-hud') + } + : null; + }); +const namesOf = (page) => + page.evaluate(() => { + let g; + window.__stores.objectsGroup.subscribe((v) => (g = v))(); + return Object.fromEntries((g?.children ?? []).map((c) => [c.name, c.uuid])); + }); +const stampOf = (page, id) => + page.evaluate((id) => { + let t; + window.__stores.flowTriggers.subscribe((v) => (t = v))(); + const s = t?.[id]?.lastT; + return typeof s === 'number' ? s : null; + }, id); +const gameStateOf = (page) => + page.evaluate(() => { + let g; + window.__stores.gameState.gameState.subscribe((v) => (g = v))(); + return g?.state ?? null; + }); +const screenOf = (page) => page.evaluate(() => window.__stores.hudDocs.visibleScreen('scene')?.id ?? null); +const hudText = async (page) => (await page.locator('#hud-layer').textContent().catch(() => '')) ?? ''; +const hudRuntime = (page) => + page.evaluate(() => { + let r; + window.__stores.hudDocs.hudRuntime.subscribe((v) => (r = v))(); + return r ?? {}; + }); +const pressP = (page) => + page.evaluate(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyP', bubbles: true })); + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyP', bubbles: true })); + }); +const hudButton = (page, name) => page.getByRole('button', { name, exact: true }); +const nodesOf = (page, type) => + page.evaluate((type) => { + let graphs; + window.__stores.flowGraphs.subscribe((g) => (graphs = g))(); + const out = []; + for (const [graphId, graph] of Object.entries(graphs ?? {})) for (const n of graph.nodes ?? []) if (n.type === type) out.push({ id: n.id, graphId, data: n.data }); + return out; + }, type); +const setNodeData = (page, id, graphId, patch) => page.evaluate(({ id, graphId, patch }) => window.__stores.nodesHandler.setNodeData(id, patch, graphId), { id, graphId, patch }); + +h.run(async () => { + const scene = await sceneBytes(); + if (!scene) { + console.log('SKIP: games/untangle/scene.tpscene unreachable (feed ' + SCENES_BASE + ', no UNTANGLE_TPSCENE, no sibling scenes checkout)'); + return; + } + const zip = await zipBytes(); + if (!zip) { + console.log('SKIP: untangle.zip unreachable (no UNTANGLE_ZIP, no MODULES_REPO, no packed sibling checkout, CDN ' + MODULES_BASE + ')'); + return; + } + console.log(' scene from ' + scene.from + ' (' + scene.bytes.length + ' bytes)'); + console.log(' module from ' + zip.from + ' (' + zip.bytes.length + ' bytes)'); + + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } } }); + const B = await h.setupPage(browser, 'B'); + await installZip(A, zip.bytes, 'A'); + await installZip(B, zip.bytes, 'B'); + + // ---- 1. the world + THE ORDERING ---------------------------------------------------------- + await h.eventually(() => snap(A.page), (s) => !!s && s.built && s.level === 1, '1.0 (premise) the fallback level-1 board stands before the load (no node yet)'); + await A.page.evaluate(() => window.__untangle.setLevel(5)); + await h.eventually(() => snap(A.page), (s) => s.level === 5, '1.0b (premise) A is on level 5 before the file loads'); + const clearsBefore = (await snap(A.page)).sceneClears; + const payload = await A.page.evaluate(async (arr) => { + const s = window.__stores; + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + const modules = (payload.modules ?? []).map((m) => m.id); + await s.sessions.applySession(payload, { backup: false }); + return { modules }; + }, Array.from(scene.bytes)); + await A.page.waitForTimeout(2000); + h.check(payload.modules.includes('untangle'), '1.1 the file\'s requirement list names untangle (' + payload.modules.join(',') + ')'); + const names = await namesOf(A.page); + h.check(Object.keys(names).length === 6 && !!names['Pedestal'] && !!names['Board frame'], '1.2 the file restored the 6 room objects (' + Object.keys(names).length + ')'); + const env = await A.page.evaluate(() => { + let e; + window.__stores.environment.environment.subscribe((v) => (e = v))(); + return e?.preset ?? null; + }); + h.check(env === 'sunset', '1.3 the sunset environment preset (' + env + ')'); + const phys = await A.page.evaluate(() => window.__stores.scenePhysics.scenePhysicsDebug()); + h.check(phys.play?.interaction === 'click' && phys.play?.simOnPlay === false, '1.4 play block: click, no sim'); + await h.eventually(() => snap(A.page), (s) => s.sceneClears === clearsBefore + 1, '1.5 applySession ran /clear all FIRST (the module saw one scene clear)'); + await h.eventually(() => snap(A.page), (s) => s.level === TEMPLATE_LEVEL && s.nodeOwned && s.built, '1.6 ...then the Untangle Board node applied LEVEL ' + TEMPLATE_LEVEL + ' — not 1 (the clear) and not 5 (before the load)', 10000); + let a1 = await snap(A.page); + h.check(a1.dots === 5 + TEMPLATE_LEVEL, '1.7 level ' + TEMPLATE_LEVEL + ' has ' + (5 + TEMPLATE_LEVEL) + ' dots (' + a1.dots + ')'); + h.check((await gameStateOf(A.page)) === 'menu' && (await screenOf(A.page)) === 'menu', '1.8 the game shell starts in menu with the menu screen'); + h.check(/UNTANGLE/.test(await hudText(A.page)) && /Start/.test(await hudText(A.page)), '1.9 the menu renders its title and Start'); + + // 1c — the SAME file loaded AGAIN (Templates modal, twice): the node's data did not change, so + // only a node re-armed by the scene clear applies its level again. Without that the second load + // has NO board at all (no fallback either — a node is alive). Local play first, no peers yet. + await A.page.evaluate(() => window.__untangle.setLevel(4)); + await h.eventually(() => snap(A.page), (s) => s.level === 4, '1.11 (premise) A plays on to level 4'); + await A.page.evaluate(async (arr) => { + const s = window.__stores; + await s.sessions.applySession(await s.sessions.readSessionZip(new Uint8Array(arr).buffer), { backup: false }); + }, Array.from(scene.bytes)); + await h.eventually(() => snap(A.page), (s) => s.sceneClears === clearsBefore + 2 && s.level === TEMPLATE_LEVEL && s.built && s.nodeOwned, '1.12 the same file loaded again: cleared once more, and the node re-applied LEVEL ' + TEMPLATE_LEVEL + ' (a board exists)', 10000); + + // ---- 2. the pose is node data -------------------------------------------------------------- + a1 = await snap(A.page); + h.check(a1.groupY === 1.6 && a1.board.radius === 1.1, '2.1 the board stands at the node\'s boardY 1.6, radius 1.1'); + const boardNode = (await nodesOf(A.page, 'utboard'))[0]; + h.check(!!boardNode && boardNode.data.level === TEMPLATE_LEVEL && boardNode.data.apply === true, '2.2 ONE Untangle Board node owns the board (level ' + TEMPLATE_LEVEL + ', apply on)'); + + // ---- 3. B over the handshake ----------------------------------------------------------------- + await h.connect(A, B); + await h.eventually(() => namesOf(B.page).then((n) => n['Pedestal']), (u) => u === names['Pedestal'], '3.1 B received the room with the same pedestal uuid', 30000); + await h.eventually(() => snap(B.page), (s) => !!s && s.level === TEMPLATE_LEVEL && s.nodeOwned && JSON.stringify(s.positions) === JSON.stringify(a1.positions), '3.2 B: the graph replicated, its node built level ' + TEMPLATE_LEVEL + ' with the IDENTICAL scramble', 20000); + await setNodeData(A.page, boardNode.id, boardNode.graphId, { boardY: 2.1 }); + await h.eventually(() => snap(B.page), (s) => s.groupY === 2.1, '3.3 editing boardY on A\'s node moves B\'s board to 2.1 (the replicated graph)'); + await setNodeData(A.page, boardNode.id, boardNode.graphId, { boardY: 1.6 }); + await h.eventually(() => snap(B.page), (s) => s.groupY === 1.6, '3.4 ...and back to 1.6'); + await h.eventually(() => screenOf(B.page), (v) => v === 'menu', '3.5 B: the HUD document arrived (menu screen)', 10000); + + // ---- 4. play + the HUD readouts -------------------------------------------------------------- + await A.page.locator('#play-button').click(); + await B.page.locator('#play-button').click(); + await A.page.waitForTimeout(500); + await hudButton(A.page, 'Start').click(); + await h.eventually(() => gameStateOf(A.page), (v) => v === 'playing', '4.1 Start flips the shell to playing'); + await h.eventually(() => screenOf(A.page), (v) => v === 'hud', '4.2 A sees the HUD screen', 6000); + await h.eventually(() => hudRuntime(A.page), (r) => r['ut-level']?.text === 'LEVEL ' + TEMPLATE_LEVEL, '4.3 HUD Text reads the Untangle Value node: LEVEL ' + TEMPLATE_LEVEL); + await h.eventually(() => hudRuntime(A.page), (r) => r['ut-crossings']?.text === a1.crossings + ' crossings', '4.4 HUD Text reads the crossings count (' + a1.crossings + ')'); + h.check(await A.page.evaluate(() => window.__untangle.move(0, [0.9, 0.9])), '4.5 A drops dot 0 in a corner'); + const a2 = await snap(A.page); + await h.eventually(() => snap(B.page), (s) => s.positions[0][0] === 0.9 && s.crossings === a2.crossings, '4.6 B receives the move and derives the same crossings (' + a2.crossings + ')'); + await h.eventually(() => hudRuntime(B.page), (r) => r['ut-crossings']?.text === a2.crossings + ' crossings', '4.7 B\'s HUD Text follows'); + + // ---- 5. a solve: trigger log, the Counter once, autoAdvance in lockstep ----------------------- + const solvedNode = (await nodesOf(A.page, 'utevent')).find((n) => n.data.event === 'solved'); + const solvedBefore = await stampOf(B.page, solvedNode.id); + h.check(await A.page.evaluate(() => window.__untangle.solve()), '5.1 A drops every dot on the solution circle'); + await h.eventually(() => snap(B.page), (s) => s.crossings === 0 || s.level === TEMPLATE_LEVEL + 1, '5.2 B: zero crossings from the same positions'); + await h.eventually(() => stampOf(B.page, solvedNode.id), (t) => t !== null && t !== solvedBefore, '5.3 B: the "On solved" stamp landed in the TRIGGER LOG'); + await h.eventually(() => hudRuntime(A.page), (r) => r['ut-counter']?.text === '1 untangled', '5.4 A: Counter -> HUD Text counts the solve once'); + await h.eventually(() => hudRuntime(B.page), (r) => r['ut-counter']?.text === '1 untangled', '5.5 B: counted ONCE, not once per peer'); + await h.eventually(() => snap(A.page), (s) => s.level === TEMPLATE_LEVEL + 1, '5.6 A: autoAdvance to level ' + (TEMPLATE_LEVEL + 1), 6000); + await h.eventually(() => snap(B.page), (s) => s.level === TEMPLATE_LEVEL + 1, '5.7 B: advanced in lockstep', 6000); + await h.eventually(() => hudRuntime(B.page), (r) => r['ut-level']?.text === 'LEVEL ' + (TEMPLATE_LEVEL + 1), '5.8 B\'s HUD Text reads LEVEL ' + (TEMPLATE_LEVEL + 1)); + h.check((await snap(A.page)).nodeOwned === true, '5.9 the node still owns the board after the advance (its level is the STARTING level)'); + + // ---- 6. no sprite on desktop; pause ------------------------------------------------------------- + const a3 = await snap(A.page); + h.check(a3.sprite === false && a3.spriteInScene === false, '6.1 no canvas sprite HUD on desktop (the VR-only path)'); + await pressP(A.page); + await h.eventually(() => screenOf(A.page), (v) => v === 'pause', '6.2 P opens the pause screen', 6000); + await hudButton(A.page, 'Resume').click(); + await h.eventually(() => screenOf(A.page), (v) => v === 'hud', '6.3 Resume closes it', 6000); + + // ---- 7. the late joiner ------------------------------------------------------------------------------ + h.check(await A.page.evaluate(() => window.__untangle.move(1, [-0.3, 0.2])), '7.0 A moves a dot before the joiner arrives'); + await A.page.keyboard.press('Escape'); + await A.page.waitForTimeout(500); + const C = await h.setupPage(browser, 'C'); + await installZip(C, zip.bytes, 'C'); + await h.connect(C, A); + const aEnd = await snap(A.page); + await h.eventually(() => namesOf(C.page).then((n) => Object.keys(n).length), (n) => n === 6, '7.1 C received the room', 30000); + await h.eventually(() => snap(C.page), (s) => !!s && s.level === aEnd.level && JSON.stringify(s.positions) === JSON.stringify(aEnd.positions), '7.2 C: level ' + aEnd.level + ' with A\'s exact positions', 30000); + await h.eventually(() => gameStateOf(C.page), (v) => v === 'playing', '7.3 C: the game shell reads playing'); + + await h.finish(browser); +});