const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const out = path.resolve('playtest-artifacts/bite-sized-editor'); fs.mkdirSync(out, { recursive: true }); const observations = []; async function state(page) { return page.evaluate(async () => { const { useGameStore } = await window.playtestImport('/src/game/store.ts'); const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts'); const { useDungeonRunStore } = await window.playtestImport('/src/game/dungeonRunStore.ts'); const game = useGameStore.getState(), layouts = useBiteSizedLayoutsStore.getState(); return { yaw: game.cameraYaw, position: game.playerPosition, grounded: game.playerGrounded, assetStatus: game.assetStatus, mode: layouts.editorMode, point: layouts.editingPointId, focused: layouts.panelFocused, dirty: layouts.dirty, run: useDungeonRunStore.getState().progress, pointerLock: document.pointerLockElement !== null }; }); } async function ready(page) { await page.locator('.game-shell').waitFor({ timeout: 120000 }); await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 }); await page.waitForFunction(async () => { const { useGameStore } = await window.playtestImport('/src/game/store.ts'); return useGameStore.getState().assetStatus === 'ready'; }, null, { timeout: 120000 }); await page.waitForTimeout(1200); console.log(`Ready: ${(await state(page)).mode || 'run'}`); } async function verifyEditorMovement(page, panel) { await panel.getByRole('button', { name: 'Walk in scene', exact: true }).click(); for (const key of ['w', 's', 'a', 'd']) { const before = await state(page); await page.keyboard.down(key); await page.waitForTimeout(600); await page.keyboard.up(key); await page.waitForTimeout(150); const after = await state(page); assert.ok(Math.hypot(after.position[0] - before.position[0], after.position[2] - before.position[2]) > .15, `${key.toUpperCase()} must move the player in edit mode`); } await panel.getByLabel('Spawn X', { exact: true }).focus(); const before = await state(page); assert.equal(before.focused, true); await page.keyboard.down('w'); await page.waitForTimeout(350); await page.keyboard.up('w'); const paused = await state(page); assert.ok(Math.hypot(paused.position[0] - before.position[0], paused.position[2] - before.position[2]) < .1, 'Panel focus must pause movement'); await page.mouse.click(550, 380); assert.equal((await state(page)).focused, false, 'Clicking the world must return keyboard control'); await page.keyboard.down('w'); await page.waitForTimeout(500); await page.keyboard.up('w'); const walking = await state(page); assert.ok(Math.hypot(walking.position[0] - paused.position[0], walking.position[2] - paused.position[2]) > .15, 'W must work again after clicking the world'); await page.keyboard.press('Digit1'); assert.equal(await page.evaluate(async () => { const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts'); return useCombatStore.getState().activeCast; }), null, 'Editing must still block combat actions'); await page.mouse.move(550, 380); await page.mouse.down({ button: 'right' }); const yaw = (await state(page)).yaw; await page.mouse.move(650, 395, { steps: 8 }); await page.waitForTimeout(300); assert.notEqual((await state(page)).yaw, yaw); assert.equal((await state(page)).pointerLock, false); await page.mouse.up({ button: 'right' }); await page.waitForTimeout(100); const released = (await state(page)).yaw; await page.mouse.move(700, 420, { steps: 4 }); await page.waitForTimeout(300); assert.equal((await state(page)).yaw, released, 'Camera motion must stop after right-button release'); observations.push({ name: 'editor WASD, panel focus return, combat gating, and right drag without pointer lock', state: await state(page) }); await page.screenshot({ path: path.join(out, 'editor-movement.png') }); } (async () => { const { createGameServer } = await import('../server/server.mjs'); const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-editor-playtest-')); const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir }); await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${runtime.server.address().port}`; const auth = await (await fetch(`${origin}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'codex_dev_gm', password: 'local-playtest-password' }) })).json(); const browser = await chromium.launch({ channel: 'msedge', headless: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); await page.addInitScript(() => { // Vite app imports may carry an HMR timestamp even in a fresh browser. // Reuse that exact URL so inspection never creates a second Zustand store. window.playtestImport = (modulePath) => { const url = performance.getEntriesByType('resource').find((entry) => new URL(entry.name).pathname === modulePath)?.name; return import(url || modulePath); }; }); const errors = []; page.on('pageerror', (error) => errors.push(error.message)); await page.route('**/api/**', async (route) => { const request = route.request(); const response = await route.fetch({ url: `${origin}${new URL(request.url()).pathname}${new URL(request.url()).search}` }); await route.fulfill({ response }); }); try { await page.goto(process.env.BITE_SIZED_EDITOR_URL || 'http://127.0.0.1:5173', { waitUntil: 'domcontentloaded', timeout: 120000 }); await page.getByLabel('Username', { exact: true }).fill('codex_dev_gm'); await page.getByLabel('Password', { exact: true }).fill('local-playtest-password'); await page.getByRole('button', { name: /Enter world/ }).click(); await page.getByRole('button', { name: /Create character/ }).click(); await page.getByRole('button', { name: /Customize & name/ }).click(); await page.getByLabel('Character name').fill('SpotTester'); await page.getByRole('button', { name: /Create character/ }).click(); await page.getByRole('button', { name: /Continue|Enter world/ }).first().click({ force: true }); await page.getByRole('button', { name: 'Continue', exact: true }).click({ force: true }); await page.locator('.main-menu-screen').waitFor(); await page.getByRole('button', { name: /GM Menu/ }).click(); await page.getByRole('button', { name: /Bite-Sized Editor/ }).click(); const panel = page.locator('.bite-sized-editor'); await panel.getByLabel('Dungeon', { exact: true }).selectOption('bite-sized-stormwind-stockade'); await panel.getByRole('button', { name: 'Go to spot', exact: true }).click(); await ready(page); assert.equal((await state(page)).mode, 'edit'); assert.equal((await state(page)).grounded, true); await page.screenshot({ path: path.join(out, 'wow-editor.png') }); await verifyEditorMovement(page, panel); if (process.env.BITE_SIZED_EDITOR_INPUT_ONLY === '1') { assert.deepEqual(errors, []); fs.writeFileSync(path.join(out, 'movement-observations.json'), `${JSON.stringify({ observations, errors }, null, 2)}\n`); console.log(`Passed editor movement and camera checks. Screenshots: ${out}`); return; } await panel.getByRole('button', { name: 'New spot', exact: true }).click(); await ready(page); await panel.getByLabel('Spot name', { exact: true }).fill('Playtest new spot'); await panel.getByLabel('Other dungeons', { exact: true }).check(); await panel.getByLabel('Search mobs', { exact: true }).fill('Forsaken'); await panel.getByRole('button', { name: 'Add mob here', exact: true }).click(); await panel.getByRole('button', { name: 'Duplicate mob point', exact: true }).waitFor(); await panel.getByRole('button', { name: 'Duplicate mob point', exact: true }).click(); await panel.getByRole('button', { name: 'Remove mob point', exact: true }).click(); await panel.getByRole('button', { name: 'Random boss spawn', exact: true }).click(); await panel.getByRole('button', { name: 'Set here', exact: true }).click(); await page.screenshot({ path: path.join(out, 'cross-dungeon-preview.png') }); await panel.getByRole('button', { name: 'Save & publish', exact: true }).click(); await page.waitForFunction(async () => { const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts'); return !useBiteSizedLayoutsStore.getState().saving && !useBiteSizedLayoutsStore.getState().dirty; }, null, { timeout: 120000 }); observations.push({ name: 'publish new spot with imported mob', state: await state(page) }); await panel.getByRole('button', { name: 'Test run', exact: true }).click(); await ready(page); assert.equal((await state(page)).run.timed, false); assert.equal((await state(page)).mode, 'test'); assert.equal(await page.evaluate(async () => { const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts'); return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead); }), false, 'The after-mobs boss must be absent before the trash dies'); await page.evaluate(async () => { const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts'); const combat = useCombatStore.getState(); Object.values(combat.mobs).filter((m) => !m.boss).forEach((m) => combat.damageMob(m.id, 1e9)); }); await page.waitForTimeout(1200); await page.screenshot({ path: path.join(out, 'boss-after-mobs.png') }); assert.equal(await page.evaluate(async () => { const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts'); return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead); }), true, 'The boss must activate after mobs die'); await panel.getByRole('button', { name: 'Return to editing', exact: true }).click(); await ready(page); await panel.getByLabel('Boss arrival', { exact: true }).selectOption('immediate'); await panel.getByRole('button', { name: 'Test run', exact: true }).click(); await ready(page); await page.screenshot({ path: path.join(out, 'boss-present.png') }); assert.equal(await page.evaluate(async () => { const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts'); return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead); }), true, 'The immediate boss must be present'); await page.evaluate(async () => { const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts'); const combat = useCombatStore.getState(); Object.values(combat.mobs).forEach((m) => combat.damageMob(m.id, 1e9)); }); await panel.getByText(/Test complete/).waitFor({ timeout: 15000 }); observations.push({ name: 'both boss modes unranked test', state: await state(page) }); await panel.getByRole('button', { name: 'Return to editing', exact: true }).click(); await ready(page); await panel.getByLabel('Spot name', { exact: true }).focus(); const before = await state(page); await page.keyboard.down('w'); await page.waitForTimeout(350); await page.keyboard.up('w'); assert.ok(Math.hypot(...(await state(page)).position.map((v, i) => v - before.position[i])) < .1, 'Panel focus must gate movement'); await panel.getByRole('button', { name: 'Collapse editor' }).click(); await page.mouse.move(550, 380); await page.mouse.down({ button: 'right' }); const yaw = (await state(page)).yaw; await page.mouse.move(650, 395, { steps: 8 }); await page.waitForTimeout(300); assert.notEqual((await state(page)).yaw, yaw); assert.equal((await state(page)).pointerLock, false); await page.mouse.up({ button: 'right' }); await page.waitForTimeout(100); const released = (await state(page)).yaw; await page.mouse.move(700, 420, { steps: 4 }); await page.waitForTimeout(300); assert.equal((await state(page)).yaw, released); observations.push({ name: 'panel input gating and right drag, no pointer lock', state: await state(page) }); await panel.getByRole('button', { name: 'Spot browser', exact: true }).click(); await panel.getByLabel('Dungeon', { exact: true }).selectOption('bite-sized-forsaken-abbey-ancalon'); await panel.getByRole('button', { name: 'Go to spot', exact: true }).click(); await ready(page); await page.screenshot({ path: path.join(out, 'rom-editor.png') }); observations.push({ name: 'RoM editing', state: await state(page) }); await panel.getByRole('button', { name: 'Spot browser', exact: true }).click(); await panel.getByRole('button', { name: 'Main menu', exact: true }).click(); for (const source of ['stormwind-stockade', 'forsaken-abbey']) { await page.evaluate(async (source) => { const { selectBiteSizedRun } = await window.playtestImport('/shared/bite-sized-layouts.mjs'); const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts'); const { prepareBiteSizedLayoutContent } = await window.playtestImport('/src/app/biteSizedLayoutContent.ts'); const { useShellStore } = await window.playtestImport('/src/app/shellStore.ts'); const { installDungeonShellRuntime } = await window.playtestImport('/src/app/dungeonShellRuntime.ts'); const id = 'randomized-bite-sized-stormwind-stockade-available', seed = `playtest-${source}`; const parent = useBiteSizedLayoutsStore.getState().config.spots.find((s) => s.sourceDungeonId === source).dungeonId; const selection = selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed, { eligibleDungeonIds: [parent] }); await prepareBiteSizedLayoutContent(id, seed, selection); installDungeonShellRuntime(); useShellStore.getState().enterDungeon(id, 'healer', { freshRun: true, runId: seed, biteSizedSelection: selection }); }, source); await ready(page); await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 30000 }); await page.waitForTimeout(300); await page.screenshot({ path: path.join(out, `randomized-${source}.png`) }); observations.push({ name: `randomized ${source}`, state: await state(page) }); await page.evaluate(async () => { const { useShellStore } = await window.playtestImport('/src/app/shellStore.ts'); useShellStore.getState().returnToMainMenu(); }); await page.locator('.main-menu-screen').waitFor(); } assert.deepEqual(errors, []); fs.writeFileSync(path.join(out, 'observations.json'), `${JSON.stringify({ observations, errors }, null, 2)}\n`); console.log(`Passed ${observations.length} live checks. Screenshots: ${out}`); } catch (error) { await page.screenshot({ path: path.join(out, 'failure.png') }); console.error((await page.locator('body').innerText()).slice(-2400)); console.error(errors.slice(-8)); throw error; } finally { await browser.close(); await new Promise((resolve) => runtime.server.close(resolve)); fs.rmSync(dataDir, { recursive: true, force: true }); } })().catch((error) => { console.error(error); process.exitCode = 1; });