import assert from "node:assert/strict"; import { chromium } from "file:///C:/Users/toshio/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright/index.mjs"; const BASE = process.env.HEROLL_MVP_URL ?? "http://127.0.0.1:4173/mvp/"; const browser = await chromium.launch({ headless: true, executablePath: "C:/Program Files/Google/Chrome/Application/chrome.exe", }); const context = await browser.newContext({ viewport: { width: 758, height: 1352 }, deviceScaleFactor: 1 }); const page = await context.newPage(); const errors = []; const failed = []; page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); page.on("pageerror", (error) => errors.push(error.message)); page.on("requestfailed", (request) => failed.push(`${request.url()} ${request.failure()?.errorText ?? ""}`)); page.on("response", (response) => { if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`); }); const box = async (selector) => { const rect = await page.locator(selector).boundingBox(); assert.ok(rect, `缺少布局节点 ${selector}`); return rect; }; try { await page.goto(BASE, { waitUntil: "networkidle" }); await page.locator("#seed-input").fill("ORIGINAL-RESTORATION-001"); await page.locator("#new-run-button").click(); await page.locator("#game-screen.active").waitFor(); assert.equal(await page.locator(".board-tile").count(), 40, "原作细格环形路径应为40个实际地块"); assert.equal(await page.locator(".board-tile.logical").count(), 40, "每个画面格都必须是独立逻辑落点"); assert.equal(await page.locator("#gear-slots button").count(), 4, "底部固定四装备槽"); assert.equal(await page.locator("#auto-indicator").textContent(), "AUTO"); assert.equal(await page.locator("#roll-button").textContent(), "ROLL"); assert.equal(await page.locator("#board-ring").evaluate((node) => getComputedStyle(node).transform), "none", "棋盘必须固定,不得随玩家位置旋转"); const clockwise = await page.evaluate(() => { const next = document.querySelector('[data-visual-index="1"]').getBoundingClientRect(); const previous = document.querySelector('[data-visual-index="39"]').getBoundingClientRect(); return { nextX: next.x, previousX: previous.x }; }); assert.ok(clockwise.nextX < clockwise.previousX, "玩家应从底部 START 沿左侧顺时针移动"); assert.ok(await page.locator(".board-tile.logical.battle .tile-art").count() > 0, "怪物地块必须显示原作1至4骷髅图标"); assert.ok(await page.locator(".board-tile.logical.good .tile-art").count() > 0, "事件地块必须显示原作事件图标"); const battleTiles = await page.locator(".board-tile.logical.battle").evaluateAll((nodes) => nodes.map((node) => ({ label: node.getAttribute("aria-label"), src: node.querySelector("img")?.getAttribute("src") }))); assert.ok(battleTiles.every(({ label, src }) => /[1-4]怪物地块/.test(label) && /icon_NormalEnemy_[1-4]/.test(src)), "骷髅图标必须与怪物数量一一对应"); assert.match(await page.locator(".board-tile.logical.good .tile-art").first().getAttribute("src"), /Installtile_/, "事件格应使用 newtile.csv 指定的 Installtile 原作图标"); assert.equal(await page.locator(".board-tile.miniboss .tile-art").count(), 1, "必须显示原作小Boss地块"); assert.equal(await page.locator(".board-tile.infected_boss .tile-art").count(), 1, "必须显示原作污染Boss地块"); const app = await box("#app"); const resource = await box(".resource-chip"); const auto = await box("#auto-indicator"); const meter = await box("#floor-meter"); const boss = await box("#boss-figure"); const gear = await box("#gear-slots"); const roll = await box("#roll-button"); const potion = await box("#potion-button"); assert.ok(resource.x / app.width < 0.04 && resource.y / app.height < 0.08, "点数条应位于左上"); assert.ok(auto.x / app.width > 0.70 && meter.x / app.width > 0.90, "AUTO 与 Boss Gauge 应位于右上/右侧"); assert.ok(Math.abs((boss.x + boss.width / 2) / app.width - 0.5) < 0.02, "Boss 应保持棋盘中心"); assert.ok(gear.y / app.height > 0.76 && roll.y / app.height > 0.90, "装备槽与主操作键应位于底部操作区"); assert.ok(potion.x / app.width > 0.84, "药水入口应位于右下"); const sourceStyles = await page.evaluate(() => ({ hero: getComputedStyle(document.querySelector("#hero-marker")).backgroundImage, boss: getComputedStyle(document.querySelector("#boss-figure")).backgroundImage, tile: getComputedStyle(document.querySelector(".board-tile.empty")).backgroundImage, })); assert.match(sourceStyles.hero, /knight_m_idle_anim_f0/, "棋子必须使用逆向导出的原作骑士帧"); assert.match(sourceStyles.boss, /001_boss_idle_2/, "中心 Boss 必须使用原作 boss_0 帧"); assert.match(sourceStyles.tile, /tile_normal/, "棋盘格必须使用原作 Tile 纹理"); await page.locator("#roll-button").click(); await page.waitForTimeout(160); assert.ok(await page.locator("#game-screen.showing-dice").count(), "掷骰阶段必须保持独立演出态"); assert.equal(await page.locator(".die-face").count(), 2, "必须显示红/蓝两枚原作骰面"); const exactSum = Number(await page.locator("#dice-result").getAttribute("data-sum")); assert.equal(Number(await page.locator(".dice-sum").textContent()), exactSum, "骰子总点数必须明确显示"); assert.equal(await page.locator("#board-view").evaluate((node) => getComputedStyle(node).display), "block", "掷骰时仍显示棋盘"); assert.equal(await page.locator("#battle-view").evaluate((node) => getComputedStyle(node).display), "none", "骰子演出结束前不得抢先切战斗"); const diceStyles = await page.locator(".die-face").evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).getPropertyValue("--die-image"))); assert.match(diceStyles[0], /dice_red_[1-6]/); assert.match(diceStyles[1], /dice_blue_[1-6]/); await page.waitForTimeout(1050); assert.equal(await page.locator("#game-screen.showing-dice").count(), 0, "骰子演出应在落点结算前结束"); await page.locator("#game-screen.animating-move").waitFor({ state: "visible" }); assert.equal(await page.locator("#modal-layer .modal").count(), 0, "逐格移动完成前不得弹出地块结算"); const runningFrame = await page.locator("#hero-marker.running").evaluate((node) => getComputedStyle(node).backgroundImage); assert.match(runningFrame, /knight_m_run_anim_f[0-3]/, "逐格移动必须使用原作骑士跑步帧"); await page.locator("#game-screen.animating-move").waitFor({ state: "hidden", timeout: 6000 }); assert.equal(Number(await page.locator(".board-tile.current").getAttribute("data-visual-index")), exactSum, "从START时ROLL点数必须等于实际移动格数"); assert.equal(await page.locator("#board-ring").evaluate((node) => getComputedStyle(node).transform), "none", "移动结束后棋盘仍必须固定"); await page.locator("#debug-toggle").click(); await page.locator("button[data-debug=battle]").click(); await page.locator("#battle-view.active").waitFor(); await page.locator("#battle-announcer.show").waitFor(); assert.match(await page.locator("#battle-announcer").textContent(), /BATTLE/); assert.equal(await page.locator("#battle-versus").count(), 1, "战斗顶部应显示双方生命对比条"); const battleStyles = await page.evaluate(() => ({ player: getComputedStyle(document.querySelector(".player-silhouette")).backgroundImage, enemy: getComputedStyle(document.querySelector(".enemy.sword .pixel-fighter")).backgroundImage, })); assert.match(battleStyles.player, /knight_m_idle_anim_f[0-3]/, "战斗角色必须沿用原作红披风骑士帧"); assert.match(battleStyles.enemy, /m_004/, "第一层剑士型敌人必须使用 enemy_sword_0 的原作纹理"); await page.locator(".damage-number").first().waitFor({ state: "attached", timeout: 5000 }); await page.locator("button[data-debug=merchant]").click(); await page.locator(".shop-modal").waitFor(); assert.equal(await page.locator(".shop-section-title").allTextContents().then((values) => values.join("/")), "一般商品/特别商品"); assert.equal(await page.locator(".shop-offer").count(), 6, "原作商店应显示 3+3 商品"); const shopBackground = await page.locator(".shop-modal").evaluate((node) => getComputedStyle(node).backgroundImage); assert.match(shopBackground, /ingame_shop_bg/, "商店必须使用原作棚架面板"); assert.deepEqual(errors, [], `浏览器错误:\n${errors.join("\n")}`); assert.deepEqual(failed, [], `资源加载失败:\n${failed.join("\n")}`); console.log("PASS original restoration: layout → source assets → dice hold → combat sprites → shop structure"); } finally { await browser.close(); }