hallway8 / tests /emulation.cjs
alvations's picture
Deploy Hallway 8 (multi-arc memory game)
58e1249 verified
Raw
History Blame Contribute Delete
13.7 kB
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alvations (Melon Lab)
//
// Browser emulation tests for the client rendering mechanisms that unit tests
// cannot reach: reduced-motion rendering, Act 2 touch verbs + flavour line, the
// inspect aside, and the alternate-ending achievement gate. Driven with a real
// (headless) Chromium via Playwright.
//
// Usage: node tests/emulation.cjs <baseURL> [chromiumPath]
// Exits 0 if every check passes, 1 otherwise. Prints PASS/FAIL per check.
//
// It is normally run by tests/test_emulation.py, which boots the app and skips
// gracefully when Chromium/Playwright are unavailable.
const BASE = process.argv[2] || "http://127.0.0.1:8078";
const CHROMIUM = process.argv[3] || "/opt/pw-browsers/chromium-1194/chrome-linux/chrome";
let playwright;
try { playwright = require(process.env.PW_PATH || "/opt/node22/lib/node_modules/playwright"); }
catch (e) { console.error("SKIP: playwright module not found"); process.exit(2); }
const results = [];
function check(name, ok, detail) {
results.push({ name, ok: !!ok, detail: detail || "" });
console.log(`${ok ? "PASS" : "FAIL"} ${name}${ok ? "" : " :: " + (detail || "")}`);
}
async function newPage(browser, { reduce = false, deterministic = false } = {}) {
const ctx = await browser.newContext();
const p = await ctx.newPage();
if (deterministic) await p.addInitScript(() => { Math.random = () => 0; });
// seed: skip first-run suppression for all arcs, optional reduce-motion
await p.goto(BASE);
await p.evaluate((r) => {
if (r) localStorage.setItem("h8_motion", "1");
localStorage.setItem("h8_mem", JSON.stringify({ v: 1, arcStarted: { "stairway8": true, "hallway-eight": true, "coach8": true } }));
}, reduce);
await p.goto(BASE);
await p.waitForTimeout(300);
return p;
}
async function enter(p, arc) {
await p.evaluate((a) => document.querySelector(`[data-arc="${a}"]`)?.click(), arc);
await p.waitForTimeout(600);
await p.click("#begin").catch(() => {});
await p.evaluate(() => document.querySelectorAll(".cutscene-overlay:not(.hidden),#learn:not(.hidden)").forEach(o => o.querySelector("button")?.click()));
await p.waitForSelector("#controls:not(.hidden)", { timeout: 8000 });
await p.waitForTimeout(250);
}
// advance one loop; handle the (possibly always-on) doubt prompt
async function commit(p, reduce) {
await p.evaluate(() => document.querySelector('.choice[data-choice="continue"]')?.click());
await p.waitForTimeout(120);
await p.evaluate(() => document.querySelector('#confidence:not(.hidden) [data-conf="4"]')?.click());
await p.waitForTimeout(reduce ? 1400 : 1900);
await p.waitForSelector("#controls:not(.hidden)", { timeout: 8000 }).catch(() => {});
await p.waitForTimeout(reduce ? 150 : 250);
}
(async () => {
const browser = await playwright.chromium.launch({ executablePath: CHROMIUM, args: ["--autoplay-policy=no-user-gesture-required"] });
try {
// 1) Reduced motion: prose renders in canonical order, fully visible.
{
const p = await newPage(browser, { reduce: true });
await enter(p, "stairway8");
const s = await p.evaluate(() => {
const lines = [...document.querySelectorAll("#prose .line")];
const domOrder = lines.map(l => l.dataset.prop);
let serverOrder = []; try { serverOrder = (current.room.shown || []); } catch (e) {}
const allVisible = lines.every(l => l.classList.contains("show") && parseFloat(getComputedStyle(l).opacity) > 0.9);
return { domOrder, serverOrder, allVisible, corridorHidden: document.querySelector("#corridor").classList.contains("hidden") };
});
check("reduce-motion prose in canonical order", JSON.stringify(s.domOrder) === JSON.stringify(s.serverOrder), `${JSON.stringify(s.domOrder)} vs ${JSON.stringify(s.serverOrder)}`);
check("reduce-motion prose fully visible at settle", s.allVisible && !s.corridorHidden);
await p.context().close();
}
// 2) Reduced motion: touch verbs render AND are visible whenever the server
// offers touches (deterministic Math.random=0 forces the roll true).
{
const p = await newPage(browser, { reduce: true, deterministic: true });
await enter(p, "stairway8");
let touchLoops = 0, verbVisibleLoops = 0;
for (let i = 0; i < 8; i++) {
const s = await p.evaluate(() => {
let touchLen = 0; try { touchLen = (current.room.touch || []).length; } catch (e) {}
const btns = [...document.querySelectorAll(".touch-btn")];
const vis = btns.filter(b => { const r = b.getBoundingClientRect(); return r.width > 2 && r.height > 2 && parseFloat(getComputedStyle(b).opacity) > 0.05; });
return { touchLen, vis: vis.length };
});
if (s.touchLen > 0) { touchLoops++; if (s.vis > 0) verbVisibleLoops++; }
await commit(p, true);
}
check("reduce-motion verbs render+visible on every touch loop", touchLoops > 0 && verbVisibleLoops === touchLoops, `visible ${verbVisibleLoops}/${touchLoops}`);
await p.context().close();
}
// 3) Flavour line: clicking a verb shows the line, which then auto-fades,
// and the fade is visible under reduced motion.
{
const p = await newPage(browser, { reduce: true, deterministic: true });
await enter(p, "stairway8");
// find a loop with a verb, click it
let clicked = false;
for (let i = 0; i < 6 && !clicked; i++) {
clicked = await p.evaluate(() => { const b = document.querySelector(".touch-btn"); if (b) { b.click(); return true; } return false; });
if (!clicked) await commit(p, true);
}
check("flavour line: a verb was available to click", clicked);
if (clicked) {
await p.waitForTimeout(800);
const shown = await p.evaluate(() => { const e = document.querySelector("#touch-line"); return { op: parseFloat(getComputedStyle(e).opacity), txt: (e.textContent || "").length }; });
check("flavour line appears on click", shown.op > 0.5 && shown.txt > 0, `opacity ${shown.op}`);
// transition kept alive under reduced motion (so a fade is possible)
const trans = await p.evaluate(() => getComputedStyle(document.querySelector("#touch-line")).transitionDuration);
check("flavour line keeps a fade under reduced motion", parseFloat(trans) >= 0.3, `transition-duration ${trans}`);
await p.waitForTimeout(5200); // past the 4.5s auto-fade
const faded = await p.evaluate(() => parseFloat(getComputedStyle(document.querySelector("#touch-line")).opacity));
check("flavour line auto-fades after a readable dwell", faded < 0.2, `opacity ${faded}`);
}
await p.context().close();
}
// 4) Alternate-ending achievement is gated: never unlocked by ordinary play,
// and its predicate reflects mem.ends exactly.
{
const p = await newPage(browser, { reduce: false });
await enter(p, "hallway-eight");
for (let i = 0; i < 4; i++) await commit(p, false);
const a = await p.evaluate(() => {
const ach = computeAch();
const anyEnd = Object.keys(ach).some(k => k.startsWith("end_") && ach[k]);
const endsEmpty = !mem.ends || Object.keys(mem.ends).length === 0;
return { anyEnd, endsEmpty };
});
check("end_ achievement NOT unlocked by ordinary play", !a.anyEnd && a.endsEmpty);
const b = await p.evaluate(() => { mem.ends = mem.ends || {}; mem.ends["hallway-eight"] = true; return computeAch()["end_hallway-eight"]; });
check("end_ predicate reflects mem.ends", b === true);
await p.context().close();
}
// 5) Ending credits + adaptive nudge.
{
const p = await newPage(browser, { reduce: false });
await enter(p, "hallway-eight");
const cr = await p.evaluate(() => {
openCredits();
const open = !document.querySelector("#credits").classList.contains("hidden");
const roll = document.querySelector("#credits-roll").textContent;
closeCredits();
return { open, hasTitle: roll.includes("EIGHT"), hasMaker: roll.includes("alvations"), hasStudio: roll.toUpperCase().includes("MELON LAB") };
});
check("credits open with our own roll content", cr.open && cr.hasTitle && cr.hasMaker && cr.hasStudio);
// "View credits" is gated on having won at least once.
const gate = await p.evaluate(() => {
mem.wins = {}; renderAchievements();
const hiddenNoWin = document.querySelector("#credits-row").classList.contains("hidden");
mem.wins = { "hallway-eight": 1 }; renderAchievements();
const shownWithWin = !document.querySelector("#credits-row").classList.contains("hidden");
return { hiddenNoWin, shownWithWin };
});
check("View credits appears only after a win", gate.hiddenNoWin && gate.shownWithWin);
// The adaptive nudge changes tier with fragment progress.
const nud = await p.evaluate(() => {
const arc = "hallway-eight";
const total = (ARCS_BY_ID[arc].flashbacks) || 8;
mem.flash = {}; buildNudge(arc);
const frags = document.querySelector("#nudge").textContent;
mem.flash = { [arc]: Array.from({ length: total }, (_, i) => i) }; buildNudge(arc);
const arcs = document.querySelector("#nudge").textContent;
["hallway-eight", "stairway8", "coach8"].forEach(a => { const t = (ARCS_BY_ID[a].flashbacks) || 8; mem.flash[a] = Array.from({ length: t }, (_, i) => i); });
buildNudge(arc);
const eight = document.querySelector("#nudge").textContent;
return { frags, arcs, eight };
});
check("nudge tier 1: unseen fragments here", /\d/.test(nud.frags) && nud.frags !== nud.arcs);
check("nudge tier 2: other places (distinct)", nud.arcs && nud.arcs !== nud.frags && nud.arcs !== nud.eight);
check("nudge tier 3: eight escapes (distinct)", nud.eight && nud.eight !== nud.arcs);
await p.context().close();
}
// 6) Under reduced motion the credits roll does not animate (reads static).
{
const p = await newPage(browser, { reduce: true });
await enter(p, "stairway8");
const anim = await p.evaluate(() => {
openCredits();
const name = getComputedStyle(document.querySelector("#credits-roll")).animationName;
closeCredits();
return name;
});
check("reduced-motion credits are static (no roll animation)", anim === "none");
await p.context().close();
}
// 7) Erase wipes the local memory, and only behind the confirm gate.
{
const p = await newPage(browser, { reduce: false });
await enter(p, "hallway-eight");
const r = await p.evaluate(() => {
mem.wins = { "hallway-eight": 3 }; mem.flash = { "hallway-eight": [0, 1] }; saveMem();
const before = Object.values(computeAch()).some(Boolean);
document.querySelector("#ach-erase").click();
const confirmShown = !document.querySelector("#erase-confirm").classList.contains("hidden");
document.querySelector("#erase-cancel").click();
const keptOnCancel = (mem.wins["hallway-eight"] || 0);
document.querySelector("#ach-erase").click();
document.querySelector("#erase-ok").click();
const winsAfter = Object.keys(mem.wins || {}).length;
const anyAch = Object.values(computeAch()).some(Boolean);
const storedGone = !localStorage.getItem("h8_mem") || localStorage.getItem("h8_mem").indexOf('"hallway-eight":3') === -1;
return { before, confirmShown, keptOnCancel, winsAfter, anyAch, storedGone };
});
check("erase is gated behind a confirm dialog", r.before && r.confirmShown);
check("cancel keeps the memory", r.keptOnCancel === 3);
check("erase wipes runs, achievements, and storage", r.winsAfter === 0 && !r.anyAch && r.storedGone);
await p.context().close();
}
// 8) The win screen itself renders (guards showWin / setAgainMode wiring; a
// scope bug here would silently break every win).
{
const p = await newPage(browser, { reduce: false });
await enter(p, "hallway-eight");
const r = await p.evaluate(() => {
winIsFirst = true;
showWin({ win_text: ["You are out."], attempts: 1, attempt_text: "", stats: {} });
const shown = !document.querySelector("#win").classList.contains("hidden");
const again = document.querySelector("#again").textContent;
// The linger auto-roll (same path the 20s timer fires) opens the credits,
// and closing hands back the replay button + reveals the nudge.
playCredits();
const creditsOpen = !document.querySelector("#credits").classList.contains("hidden");
closeCredits();
const afterBtn = document.querySelector("#again").textContent;
const nudgeShown = !document.querySelector("#nudge").classList.contains("hidden");
return { shown, again, creditsOpen, afterBtn, nudgeShown };
});
check("win screen renders (showWin does not throw)", r.shown);
check("first-win button reads End Credits", r.again === "End Credits");
check("linger auto-roll opens the credits", r.creditsOpen);
check("after credits: replay button + nudge return", r.afterBtn !== "End Credits" && r.nudgeShown);
await p.context().close();
}
} finally {
await browser.close();
}
const failed = results.filter(r => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
process.exit(failed.length ? 1 : 0);
})().catch((e) => { console.error("ERROR", e); process.exit(1); });