Initial import of HEROLL source and design documents
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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: 750, height: 1334 }, deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
const failedRequests = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") {
|
||||
const location = message.location();
|
||||
errors.push(`console: ${message.text()} @ ${location.url || "unknown"}:${location.lineNumber ?? 0}`);
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
|
||||
page.on("requestfailed", (request) => failedRequests.push(`${request.url()} ${request.failure()?.errorText}`));
|
||||
page.on("response", (response) => { if (response.status() >= 400) errors.push(`http ${response.status()}: ${response.url()}`); });
|
||||
|
||||
try {
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.locator("#seed-input").fill("BROWSER-SMOKE-001");
|
||||
await page.locator("#new-run-button").click();
|
||||
await page.locator("#game-screen.active").waitFor();
|
||||
assert.equal(await page.locator(".board-tile").count(), 40);
|
||||
assert.equal(await page.locator(".board-tile.logical").count(), 40);
|
||||
assert.equal(await page.locator(".board-tile.presentation").count(), 0);
|
||||
assert.equal(await page.locator(".board-tile.miniboss").count(), 1);
|
||||
assert.equal(await page.locator(".board-tile.infected_boss").count(), 1);
|
||||
assert.equal(await page.locator("#floor-value").textContent(), "0/2");
|
||||
assert.equal(await page.locator("#auto-indicator").textContent(), "AUTO");
|
||||
assert.equal(await page.locator("button.boost-button, button[data-placement]").count(), 0, "界面不得出现广告增强按钮");
|
||||
const layout = await page.evaluate(() => Object.fromEntries(["#top-hud", ".resource-chip", "#auto-indicator", "#floor-meter", "#board-ring", "#boss-figure", "#bottom-hud", "#gear-slots", "#roll-button", "#potion-button"].map((selector) => {
|
||||
const rect = document.querySelector(selector).getBoundingClientRect();
|
||||
return [selector, { x: rect.x, y: rect.y, width: rect.width, height: rect.height }];
|
||||
})));
|
||||
assert.ok(layout[".resource-chip"].x < 30 && layout[".resource-chip"].y < 100);
|
||||
assert.ok(layout["#auto-indicator"].x > 500 && layout["#floor-meter"].x > 680);
|
||||
assert.ok(Math.abs(layout["#boss-figure"].x + layout["#boss-figure"].width / 2 - 375) < 4);
|
||||
assert.ok(layout["#bottom-hud"].y > 940 && layout["#bottom-hud"].y < 1010 && layout["#gear-slots"].y > 1020);
|
||||
assert.ok(layout["#roll-button"].y > 1200 && layout["#potion-button"].x > 630);
|
||||
console.log("STEP board layout passed");
|
||||
await page.screenshot({ path: "tests/artifacts/heroll-mvp-board.png", fullPage: true });
|
||||
console.log("STEP board screenshot captured");
|
||||
|
||||
await page.locator("#roll-button").click();
|
||||
await page.waitForTimeout(180);
|
||||
assert.equal(await page.locator(".die-face").count(), 2);
|
||||
const rolledSum = Number(await page.locator("#dice-result").getAttribute("data-sum"));
|
||||
assert.equal(Number(await page.locator(".dice-sum").textContent()), rolledSum);
|
||||
console.log("STEP dice rendered");
|
||||
await page.screenshot({ path: "tests/artifacts/heroll-mvp-dice.png", fullPage: true });
|
||||
await page.locator("#game-screen.animating-move").waitFor({ state: "visible", timeout: 2500 });
|
||||
assert.equal(await page.locator("#modal-layer .modal").count(), 0, "移动期间不允许事件或战斗结算硬切入");
|
||||
await page.locator("#game-screen.animating-move").waitFor({ state: "hidden", timeout: 6000 });
|
||||
const landedIndex = Number(await page.locator(".board-tile.current").getAttribute("data-visual-index"));
|
||||
assert.equal(landedIndex, rolledSum, `从START ROLL ${rolledSum}必须顺时针移动${rolledSum}个实际地块`);
|
||||
assert.match(await page.locator("#points-value").textContent(), /^\d+$/);
|
||||
assert.equal(await page.locator("#floor-value").textContent(), "1/2");
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
await page.locator("#debug-toggle").click();
|
||||
await page.locator("#debug-points").fill("999");
|
||||
await page.locator("button[data-debug=apply]").click();
|
||||
await page.locator("button[data-debug=merchant]").click();
|
||||
await page.locator(".shop-modal").waitFor();
|
||||
assert.equal(await page.locator(".shop-offer").count(), 6);
|
||||
const fixedPotionLabels = await page.locator(".shop-offer").evaluateAll((nodes) => nodes.slice(0, 3).map((node) => node.querySelector("strong")?.textContent ?? ""));
|
||||
assert.deepEqual(fixedPotionLabels, ["补充 1 瓶丹药(不超过上限)", "丹药持有上限 +1", "丹药基础恢复 +30 HP"]);
|
||||
assert.match(await page.locator(".shop-offer").first().textContent(), /已满/);
|
||||
await page.locator("#debug-panel").evaluate((element) => { element.hidden = true; });
|
||||
await page.locator("#tutorial-layer").evaluate((element) => { element.innerHTML = ""; });
|
||||
await page.locator("#toast-layer").evaluate((element) => { element.innerHTML = ""; });
|
||||
await page.screenshot({ path: "tests/artifacts/heroll-mvp-shop.png", fullPage: true });
|
||||
await page.locator("#debug-panel").evaluate((element) => { element.hidden = false; });
|
||||
await page.locator("#debug-panel").evaluate((element) => { element.hidden = true; });
|
||||
await page.locator("button[data-action=shop-reroll]").click();
|
||||
assert.equal(await page.locator(".shop-offer").count(), 6);
|
||||
await page.locator("button[data-action=event-close]").click();
|
||||
await page.locator("#debug-panel").evaluate((element) => { element.hidden = false; });
|
||||
|
||||
await page.locator("button[data-debug=dig]").click();
|
||||
await page.getByRole("heading", { name: "挖掘" }).waitFor();
|
||||
assert.match(await page.locator(".modal p").first().textContent(), /5/);
|
||||
await page.locator(".dig-cell").first().click();
|
||||
if (await page.getByRole("heading", { name: "装备掉落" }).count()) await page.locator("button[data-action=gear-salvage]").click();
|
||||
await page.locator("button[data-action=event-close]").click();
|
||||
|
||||
await page.locator("button[data-debug=chest]").click();
|
||||
await page.locator("button[data-action=event-claim]").click();
|
||||
await page.getByRole("heading", { name: "装备掉落" }).waitFor();
|
||||
assert.equal(await page.locator(".gear-card").count(), 1);
|
||||
await page.locator("button[data-action=gear-equip]").click();
|
||||
|
||||
await page.locator("button[data-debug=blessing]").click();
|
||||
await page.locator("button[data-action=event-claim]").click();
|
||||
await page.getByRole("heading", { name: "祝福揭示" }).waitFor();
|
||||
await page.locator("button[data-action=blessing-accept]").click();
|
||||
|
||||
await page.locator("button[data-debug=campfire]").click();
|
||||
await page.getByRole("heading", { name: "营火" }).waitFor();
|
||||
await page.locator("button[data-action=event-claim]").click();
|
||||
|
||||
await page.locator("button[data-debug=smithy]").click();
|
||||
await page.getByRole("heading", { name: "铁匠铺" }).waitFor();
|
||||
const smithTarget = page.locator("button[data-action=smith]:not([disabled])").first();
|
||||
if (await smithTarget.count()) await smithTarget.click();
|
||||
else await page.locator("button[data-action=event-close]").click();
|
||||
|
||||
await page.locator("button[data-debug=legend]").click();
|
||||
await page.locator("button[data-action=event-claim]").click();
|
||||
await page.getByRole("heading", { name: "装备掉落" }).waitFor();
|
||||
await page.locator("button[data-action=gear-salvage]").click();
|
||||
|
||||
for (const [debug, heading] of [["lotto", "彩票"], ["blackjack", "21点"], ["machine", "骰子机"]]) {
|
||||
await page.locator(`button[data-debug=${debug}]`).click();
|
||||
await page.getByRole("heading", { name: heading }).waitFor();
|
||||
await page.locator("button[data-action=event-claim]").click();
|
||||
}
|
||||
|
||||
await page.locator("button[data-debug=claw]").click();
|
||||
await page.getByRole("heading", { name: "抓娃娃" }).waitFor();
|
||||
await page.locator("button[data-action=event-claim]").click();
|
||||
if (await page.getByRole("heading", { name: "装备掉落" }).count()) await page.locator("button[data-action=gear-salvage]").click();
|
||||
if (await page.getByRole("heading", { name: "抓娃娃" }).count()) await page.locator("button[data-action=event-close]").click();
|
||||
|
||||
await page.locator("button[data-debug=carriage]").click();
|
||||
await page.getByRole("heading", { name: "黄金马车" }).waitFor();
|
||||
await page.locator("button[data-action=carriage-points]").click();
|
||||
|
||||
await page.locator("button[data-debug=bubble]").click();
|
||||
await page.getByRole("heading", { name: "泡泡龙" }).waitFor();
|
||||
for (let shot = 0; shot < 3; shot += 1) await page.locator("button[data-action=event-claim]").click();
|
||||
await page.locator("button[data-action=event-close]").click();
|
||||
|
||||
await page.locator("button[data-debug=battle]").click();
|
||||
await page.locator("#battle-view.active").waitFor();
|
||||
await page.locator("#battle-announcer.show").waitFor();
|
||||
await page.locator("#debug-panel").evaluate((element) => { element.hidden = true; });
|
||||
await page.locator("#tutorial-layer").evaluate((element) => { element.innerHTML = ""; });
|
||||
await page.locator("#toast-layer").evaluate((element) => { element.innerHTML = ""; });
|
||||
await page.screenshot({ path: "tests/artifacts/heroll-mvp-battle.png", fullPage: true });
|
||||
await page.locator("#debug-panel").evaluate((element) => { element.hidden = false; });
|
||||
await page.locator("button[data-debug=death]").click();
|
||||
await page.locator("#battle-announcer.show").waitFor();
|
||||
assert.equal(await page.locator(".result-modal.defeat-modal").count(), 0, "失败结算必须等待受击与 DEFEAT 演出完成");
|
||||
await page.locator(".result-modal.defeat-modal").waitFor({ timeout: 4000 });
|
||||
assert.equal(await page.locator("button.boost-button, button[data-placement], button[data-action=revive]").count(), 0, "失败界面不得出现广告复活按钮");
|
||||
await page.locator("button[data-action=retry]").click();
|
||||
await page.locator("#game-screen.active").waitFor();
|
||||
await page.locator("#debug-panel").evaluate((element) => { element.hidden = false; });
|
||||
|
||||
await page.locator("button[data-debug=boss]").click();
|
||||
await page.locator("#battle-announcer.show").waitFor();
|
||||
await page.locator("button[data-debug=win]").click();
|
||||
await page.locator("#battle-announcer.show").waitFor();
|
||||
assert.match(await page.locator("#battle-announcer").textContent(), /VICTORY/);
|
||||
assert.equal(await page.locator(".result-modal").count(), 0, "最终结算不得跳过 VICTORY 演出直接出现");
|
||||
await page.locator(".result-modal.victory-modal").waitFor({ timeout: 4000 });
|
||||
assert.match(await page.locator(".result-modal h2").textContent(), /STAGE CLEAR/);
|
||||
assert.equal(await page.locator(".result-rewards .count-up").count(), 3);
|
||||
await page.waitForTimeout(1050);
|
||||
assert.equal(await page.locator(".result-rewards .count-up").first().textContent(), await page.locator("#points-value").textContent());
|
||||
await page.screenshot({ path: "tests/artifacts/heroll-mvp-result.png", fullPage: true });
|
||||
|
||||
const saved = await page.evaluate(() => localStorage.getItem("heroll.validation.run.v1"));
|
||||
assert.ok(saved && saved.length > 100);
|
||||
await page.locator("#debug-panel").evaluate((element) => { element.hidden = true; });
|
||||
await page.screenshot({ path: "tests/artifacts/heroll-mvp-smoke.png", fullPage: true });
|
||||
|
||||
assert.deepEqual(errors, [], `浏览器错误:\n${errors.join("\n")}`);
|
||||
assert.deepEqual(failedRequests, [], `资源失败:\n${failedRequests.join("\n")}`);
|
||||
console.log("PASS browser smoke: fixed board/clockwise move → shop → events → gear → blessing → battle → no-ad defeat → victory/result → save");
|
||||
console.log("SCREENSHOTS tests/artifacts/heroll-mvp-{board,dice,shop,battle,result,smoke}.png");
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { GameEngine, QUALITY, QUALITY_WEIGHTS } from "../dist/core.js";
|
||||
import { parseCsv } from "../dist/data.js";
|
||||
import { SeededRng } from "../dist/rng.js";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SPECS = path.resolve(HERE, "../../design/gameplay-validation/specs");
|
||||
const number = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
|
||||
const bool = (value) => String(value).toLowerCase() === "true";
|
||||
const csv = async (name) => parseCsv(await readFile(path.join(SPECS, name), "utf8"));
|
||||
|
||||
async function loadTables() {
|
||||
const [floors, gear, statuses, blessings, potions, shop, events] = await Promise.all([
|
||||
csv("floor-balance.csv"), csv("gear-catalog.csv"), csv("gear-status.csv"), csv("blessings.csv"),
|
||||
csv("potions.csv"), csv("shop-offers.csv"), csv("events.csv"),
|
||||
]);
|
||||
return {
|
||||
floors: floors.map((r) => ({ floor: number(r.floor), attackPower: number(r.attack_power), health: number(r.health), defense: number(r.defense), attackSpeed: number(r.attack_speed, 1), evasion: number(r.evasion), aim: number(r.aim) })),
|
||||
gear: gear.map((r) => ({ catalogId: number(r.catalog_id), stringId: r.string_id, slot: r.slot, iconName: r.icon_name, indexInSlot: number(r.index_in_slot) })),
|
||||
gearStatuses: statuses.map((r) => ({ statusId: number(r.status_id), stringId: r.string_id, baseMin: number(r.base_min), baseMax: number(r.base_max), plusValue: number(r.plus_value), isPercent: bool(r.is_percent) })),
|
||||
blessings: blessings.map((r) => ({ blessingId: r.blessing_id, stringId: r.string_id, enabled: bool(r.enabled), weight: number(r.prob_weight, 1), linearValue: number(r.linear_value), values: r.values ? r.values.split("|").map(Number) : [], stackable: bool(r.stackable), effectContract: r.effect_contract })),
|
||||
potions: potions.map((r) => ({ recordId: r.record_id, recordType: r.record_type, level: number(r.level), value: number(r.value), valueA: number(r.value_a), valueB: number(r.value_b) })),
|
||||
shop: shop.map((r) => ({ offerId: number(r.offer_id), stringId: r.string_id, itemType: r.item_type, itemId: r.item_id, fixedKey: r.fixed_key, fixedSort: number(r.fixed_sort), fixedSlot: number(r.fixed_slot, -1), price: number(r.price), buyCount: number(r.buy_count, 1) })),
|
||||
events: events.map((r) => ({ eventId: r.event_id, tileId: r.tile_id, stringId: r.string_id, naturalCore: bool(r.natural_core), boostPlacement: r.boost_placement, baseRule: r.base_rule })),
|
||||
};
|
||||
}
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push([name, fn]);
|
||||
const data = await loadTables();
|
||||
|
||||
test("GDD 配置规模精确", () => {
|
||||
assert.equal(data.floors.length, 10);
|
||||
assert.equal(data.gear.length, 40);
|
||||
assert.equal(data.gearStatuses.length, 13);
|
||||
assert.equal(data.blessings.filter((item) => item.enabled).length, 32);
|
||||
assert.equal(data.shop.length, 97);
|
||||
assert.equal(data.events.length, 13);
|
||||
});
|
||||
|
||||
test("百万次公平双骰的 36 个有序结果通过卡方检查", () => {
|
||||
const rng = new SeededRng("HEROLL-FAIRNESS-1M");
|
||||
const bins = Array(36).fill(0);
|
||||
for (let i = 0; i < 1_000_000; i += 1) {
|
||||
const a = rng.int(1, 6);
|
||||
const b = rng.int(1, 6);
|
||||
bins[(a - 1) * 6 + b - 1] += 1;
|
||||
}
|
||||
const expected = 1_000_000 / 36;
|
||||
const chi2 = bins.reduce((sum, observed) => sum + ((observed - expected) ** 2) / expected, 0);
|
||||
assert.ok(chi2 < 66.62, `chi²=${chi2.toFixed(2)} 未通过 df=35, p=0.001 门槛`);
|
||||
assert.ok(bins.every((count) => Math.abs(count - expected) / expected < 0.02));
|
||||
});
|
||||
|
||||
test("品质权重遵循 210:60:28:4:1", () => {
|
||||
const rng = new SeededRng("HEROLL-QUALITY");
|
||||
const bins = Object.fromEntries(QUALITY.map((name) => [name, 0]));
|
||||
for (let i = 0; i < 200_000; i += 1) bins[rng.weighted(QUALITY, (item) => QUALITY_WEIGHTS[QUALITY.indexOf(item)])] += 1;
|
||||
const totalWeight = QUALITY_WEIGHTS.reduce((a, b) => a + b, 0);
|
||||
QUALITY.forEach((name, index) => {
|
||||
const actual = bins[name] / 200_000;
|
||||
const expected = QUALITY_WEIGHTS[index] / totalWeight;
|
||||
assert.ok(Math.abs(actual - expected) < 0.004, `${name} ${actual} != ${expected}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("同 seed 生成同棋盘、同骰子与同 RNG 索引", () => {
|
||||
const a = new GameEngine(data, "DETERMINISTIC-SEED");
|
||||
const b = new GameEngine(data, "DETERMINISTIC-SEED");
|
||||
assert.deepEqual(a.state.tiles, b.state.tiles);
|
||||
assert.deepEqual(a.state.coreEventSchedule, b.state.coreEventSchedule);
|
||||
assert.deepEqual(a.rollDice(), b.rollDice());
|
||||
assert.deepEqual(a.state.rng, b.state.rng);
|
||||
});
|
||||
|
||||
test("40格棋盘越过起点进入下一层并恢复30%", () => {
|
||||
const game = new GameEngine(data, "FLOOR-CROSSING");
|
||||
game.state.currentHp = 50;
|
||||
game.state.tileIndex = 39;
|
||||
game.state.reviveUsedThisFloor = true;
|
||||
game.rollDice();
|
||||
assert.ok(game.state.floor >= 2);
|
||||
assert.ok(game.state.currentHp >= 95);
|
||||
assert.equal(game.state.reviveUsedThisFloor, false);
|
||||
assert.equal(game.state.tiles.filter((tile) => tile.kind === "EVENT").length >= 2, true);
|
||||
});
|
||||
|
||||
test("ROLL点数严格等于顺时针移动的实际格数", () => {
|
||||
const game = new GameEngine(data, "ONE-POINT-ONE-TILE");
|
||||
assert.equal(game.state.tiles.length, 40);
|
||||
const roll = game.rollDice();
|
||||
assert.equal(game.state.tileIndex, roll.sum, `ROLL ${roll.sum} 应从0移动到${roll.sum}`);
|
||||
assert.ok(roll.sum >= 2 && roll.sum <= 12);
|
||||
});
|
||||
|
||||
test("1至4骷髅地块严格生成对应数量怪物并在胜利后变荒地", () => {
|
||||
for (const count of [1, 2, 3, 4]) {
|
||||
const seed = `SKULL-TILE-${count}`;
|
||||
const probe = new GameEngine(data, seed);
|
||||
const target = probe.rollDice().sum;
|
||||
const game = new GameEngine(data, seed);
|
||||
game.state.tiles[target] = { index: target, kind: "BATTLE", consumed: false, enemyCount: count, pollutionLevel: 0 };
|
||||
const roll = game.rollDice();
|
||||
assert.equal(game.state.tileIndex, roll.sum);
|
||||
assert.equal(game.state.battle.enemies.length, count);
|
||||
game.debugWinBattle();
|
||||
assert.equal(game.state.tiles[target].kind, "EMPTY");
|
||||
}
|
||||
});
|
||||
|
||||
test("小Boss与污染Boss地块进入战斗且胜利后变荒地", () => {
|
||||
for (const kind of ["MINIBOSS", "INFECTED_BOSS"]) {
|
||||
const seed = `SPECIAL-BOSS-${kind}`;
|
||||
const probe = new GameEngine(data, seed);
|
||||
const target = probe.rollDice().sum;
|
||||
const game = new GameEngine(data, seed);
|
||||
game.state.tiles[target] = { index: target, kind, consumed: false, enemyCount: 3, infectionTurnsRemaining: kind === "INFECTED_BOSS" ? 3 : undefined };
|
||||
game.rollDice();
|
||||
assert.equal(game.state.battle.isMiniBoss, true);
|
||||
assert.equal(game.state.battle.enemies.length, 3);
|
||||
game.debugWinBattle();
|
||||
assert.equal(game.state.tiles[target].kind, "EMPTY");
|
||||
}
|
||||
});
|
||||
|
||||
test("未消灭的污染Boss在过层时连续3次令普通怪物地块污染加1", () => {
|
||||
const game = new GameEngine(data, "INFECTED-BOSS-THREE-FLOORS");
|
||||
game.state.tiles = Array.from({ length: 40 }, (_, index) => ({ index, kind: index === 0 ? "START" : "EMPTY", consumed: false }));
|
||||
game.state.tiles[20] = { index: 20, kind: "BATTLE", consumed: false, enemyCount: 1, pollutionLevel: 0 };
|
||||
game.state.tiles[25] = { index: 25, kind: "INFECTED_BOSS", consumed: false, enemyCount: 1, infectionTurnsRemaining: 3 };
|
||||
for (let level = 1; level <= 3; level += 1) {
|
||||
game.state.tileIndex = 39;
|
||||
game.state.phase = "READY";
|
||||
game.state.event = undefined;
|
||||
game.state.battle = undefined;
|
||||
game.rollDice();
|
||||
assert.equal(game.state.tiles[20].enemyCount, level + 1);
|
||||
assert.equal(game.state.tiles[20].pollutionLevel, level);
|
||||
assert.equal(game.state.tiles[25].infectionTurnsRemaining, 3 - level);
|
||||
}
|
||||
});
|
||||
|
||||
test("事件结算后变为荒地且未被覆盖的荒地跨层保留", () => {
|
||||
const seed = "EVENT-TO-WASTELAND";
|
||||
const probe = new GameEngine(data, seed);
|
||||
const target = probe.rollDice().sum;
|
||||
const game = new GameEngine(data, seed);
|
||||
game.state.tiles[target] = { index: target, kind: "EVENT", eventId: "EV-138", consumed: false };
|
||||
game.rollDice();
|
||||
assert.equal(game.state.phase, "EVENT");
|
||||
game.resolveSimpleEvent();
|
||||
assert.equal(game.state.tiles[target].kind, "EMPTY");
|
||||
const emptyBefore = new Set(game.state.tiles.filter((tile) => tile.kind === "EMPTY").map((tile) => tile.index));
|
||||
game.state.tileIndex = 39;
|
||||
game.state.phase = "READY";
|
||||
game.rollDice();
|
||||
const retained = game.state.tiles.filter((tile) => tile.kind === "EMPTY" && emptyBefore.has(tile.index));
|
||||
assert.ok(retained.length >= emptyBefore.size - 3, "除本层新增地块覆盖外,荒地必须跨层保留");
|
||||
});
|
||||
|
||||
test("Boss 按 2 次 ROLL 蓄势、4 次 ROLL 倒计时并转换 4 个非祝福地块", () => {
|
||||
let seed = "";
|
||||
for (let attempt = 0; attempt < 100 && !seed; attempt += 1) {
|
||||
const probe = new GameEngine(data, `BOSS-CYCLE-${attempt}`);
|
||||
let sixth;
|
||||
for (let roll = 0; roll < 6; roll += 1) {
|
||||
probe.state.phase = "READY";
|
||||
probe.state.battle = undefined;
|
||||
probe.state.event = undefined;
|
||||
probe.state.shop = undefined;
|
||||
sixth = probe.rollDice();
|
||||
}
|
||||
if (sixth.sum < 10) seed = `BOSS-CYCLE-${attempt}`;
|
||||
}
|
||||
assert.ok(seed);
|
||||
const game = new GameEngine(data, seed);
|
||||
const rollAgain = () => {
|
||||
game.state.phase = "READY";
|
||||
game.state.battle = undefined;
|
||||
game.state.event = undefined;
|
||||
game.state.shop = undefined;
|
||||
game.rollDice();
|
||||
return game.drainEvents();
|
||||
};
|
||||
|
||||
const firstEvents = rollAgain();
|
||||
assert.equal(game.state.bossGauge.mode, "BUILDUP");
|
||||
assert.equal(game.state.bossGauge.playerRolls, 1);
|
||||
assert.ok(firstEvents.some((event) => event.type === "BOSS_GAUGE_CHANGED" && event.payload.playerRolls === 1));
|
||||
assert.ok(!firstEvents.some((event) => event.type === "BOSS_ACTION"));
|
||||
|
||||
const secondEvents = rollAgain();
|
||||
assert.equal(game.state.bossGauge.mode, "COUNTDOWN");
|
||||
assert.equal(game.state.bossGauge.countdownRemaining, 4);
|
||||
assert.ok(secondEvents.some((event) => event.type === "BOSS_COUNTDOWN_STARTED" && event.payload.remaining === 4));
|
||||
|
||||
for (const expected of [3, 2, 1]) {
|
||||
const events = rollAgain();
|
||||
assert.equal(game.state.bossGauge.countdownRemaining, expected);
|
||||
assert.ok(events.some((event) => event.type === "BOSS_COUNTDOWN_TICK" && event.payload.remaining === expected));
|
||||
assert.ok(!events.some((event) => event.type === "BOSS_ACTION"));
|
||||
}
|
||||
|
||||
game.state.tileIndex = 0;
|
||||
game.state.tiles = Array.from({ length: 40 }, (_, index) => ({
|
||||
index,
|
||||
kind: index === 0 ? "START" : index === 38 ? "EVENT" : "EMPTY",
|
||||
eventId: index === 38 ? "EV-144" : undefined,
|
||||
consumed: false,
|
||||
}));
|
||||
const sixthEvents = rollAgain();
|
||||
const action = sixthEvents.find((event) => event.type === "BOSS_ACTION");
|
||||
assert.ok(action);
|
||||
assert.equal(action.payload.tiles.length, 4);
|
||||
assert.equal(new Set(action.payload.tiles).size, 4);
|
||||
assert.ok(!action.payload.tiles.includes(38), "祝福地块不得被 Boss 转换");
|
||||
assert.equal(game.state.bossGauge.mode, "BUILDUP");
|
||||
assert.equal(game.state.bossGauge.playerRolls, 0);
|
||||
assert.equal(game.state.bossGauge.countdownRemaining, 0);
|
||||
assert.equal(game.state.bossGauge.bossActions, 1);
|
||||
assert.equal(game.state.tiles.filter((tile) => tile.kind === "BATTLE").length, 4);
|
||||
assert.equal(game.state.tiles[38].eventId, "EV-144");
|
||||
});
|
||||
|
||||
test("十层自然调度覆盖六类核心事件且商人仅保证在 3/6/9 层", () => {
|
||||
const game = new GameEngine(data, "CORE-EVENT-COVERAGE");
|
||||
const seen = new Set();
|
||||
for (let floor = 1; floor <= 10; floor += 1) {
|
||||
game.debugSet(floor, 0);
|
||||
for (const tile of game.state.tiles) if (tile.eventId) seen.add(tile.eventId);
|
||||
assert.equal(game.state.tiles.some((tile) => tile.kind === "MERCHANT"), [3, 6, 9].includes(floor));
|
||||
}
|
||||
for (const id of ["EV-MERCHANT", "EV-137", "EV-138", "EV-140", "EV-141", "EV-144"]) assert.ok(seen.has(id), `missing ${id}`);
|
||||
});
|
||||
|
||||
test("第十层越过 Start 必定进入 Boss", () => {
|
||||
const game = new GameEngine(data, "BOSS-TRIGGER");
|
||||
game.debugSet(10, 999);
|
||||
game.state.tileIndex = 39;
|
||||
game.rollDice();
|
||||
assert.equal(game.state.phase, "BOSS");
|
||||
assert.equal(game.state.battle.isBoss, true);
|
||||
assert.equal(game.state.battle.enemies[0].role, "BOSS");
|
||||
});
|
||||
|
||||
test("第三骰只在基础双骰后提供替换决策", () => {
|
||||
const game = new GameEngine(data, "THIRD-DIE-FLOW");
|
||||
game.state.blessings.push({ blessingId: "AUG-THIRD-DIE", stringId: "ThirdDieReplace", stack: 1, value: 1 });
|
||||
const roll = game.rollDice();
|
||||
assert.equal(game.state.phase, "THIRD_DIE");
|
||||
assert.ok(roll.c >= 1 && roll.c <= 6);
|
||||
const resolved = game.chooseThirdDie("A");
|
||||
assert.equal(resolved.chosenA, roll.c);
|
||||
assert.equal(resolved.chosenB, roll.b);
|
||||
assert.equal(game.state.pendingThirdDie, undefined);
|
||||
});
|
||||
|
||||
test("商人固定显示三个升级槽和三个随机槽", () => {
|
||||
const game = new GameEngine(data, "SHOP-SIX-SLOTS");
|
||||
game.debugForceEvent("EV-MERCHANT");
|
||||
const offers = game.getShopOffers();
|
||||
assert.equal(offers.length, 6);
|
||||
assert.equal(offers.slice(0, 3).every((offer) => offer.itemType === "FIXED"), true);
|
||||
assert.equal(offers.slice(3).every((offer) => offer.itemType !== "FIXED"), true);
|
||||
});
|
||||
|
||||
test("事件增强 transactionId 幂等且失败重入不重复发奖", () => {
|
||||
const game = new GameEngine(data, "IDEMPOTENCY");
|
||||
game.debugForceEvent("EV-140");
|
||||
assert.equal(game.state.event.state.attempts, 5);
|
||||
assert.equal(game.useEventBoost("BOOST_DIG_EXTRA"), true);
|
||||
assert.equal(game.useEventBoost("BOOST_DIG_EXTRA"), false);
|
||||
assert.equal(game.state.event.state.attempts, 10);
|
||||
assert.equal(game.state.committedTransactions.length, 1);
|
||||
});
|
||||
|
||||
test("广告复活入口已禁用,致死后直接进入本局失败", () => {
|
||||
const game = new GameEngine(data, "NO-AD-REVIVE");
|
||||
game.startBattle(false);
|
||||
game.debugForceDeath();
|
||||
assert.equal(game.state.phase, "DEFEAT");
|
||||
assert.equal(game.instantRevive(), false);
|
||||
});
|
||||
|
||||
test("玩家面板反击属性保留且受击后能触发反击事件", () => {
|
||||
const game = new GameEngine(data, "COUNTER-ATTACK");
|
||||
game.state.baseStats.counterAttack = 101;
|
||||
game.state.baseStats.attackSpeed = 0.1;
|
||||
game.startBattle(false);
|
||||
game.drainEvents();
|
||||
const events = [];
|
||||
for (let i = 0; i < 40 && !events.some((event) => event.type === "COUNTER"); i += 1) {
|
||||
game.tickBattle(250);
|
||||
events.push(...game.drainEvents());
|
||||
}
|
||||
assert.equal(game.effectiveStats().counterAttack, 101);
|
||||
assert.ok(events.some((event) => event.type === "COUNTER"));
|
||||
});
|
||||
|
||||
test("暴击恢复为 criticalRate 大于 0..100 整数掷值", () => {
|
||||
const collect = (rate) => {
|
||||
const game = new GameEngine(data, `CRITICAL-${rate}`);
|
||||
game.state.baseStats.attack = 1;
|
||||
game.state.baseStats.attackSpeed = 1000;
|
||||
game.state.baseStats.critRate = rate;
|
||||
game.startBattle(false);
|
||||
game.drainEvents();
|
||||
const damageEvents = [];
|
||||
for (let i = 0; i < 20 && damageEvents.length < 5; i += 1) {
|
||||
game.tickBattle(250);
|
||||
damageEvents.push(...game.drainEvents().filter((event) => event.type === "DAMAGE" && event.payload.actor === "player"));
|
||||
}
|
||||
return damageEvents;
|
||||
};
|
||||
const zero = collect(0);
|
||||
const full = collect(101);
|
||||
assert.ok(zero.length > 0 && zero.every((event) => event.payload.critical === false));
|
||||
assert.ok(full.length > 0 && full.every((event) => event.payload.critical === true));
|
||||
assert.ok(full.every((event) => event.payload.criticalRoll >= 0 && event.payload.criticalRoll <= 100));
|
||||
});
|
||||
|
||||
test("唯一丹药仅由三个固定商品补充、加上限和增加恢复量", () => {
|
||||
const game = new GameEngine(data, "POTION-SHOP");
|
||||
game.debugForceEvent("EV-MERCHANT");
|
||||
game.state.points = 999;
|
||||
const offers = game.getShopOffers();
|
||||
const fixed = Object.fromEntries(offers.filter((offer) => offer.itemType === "FIXED").map((offer) => [offer.fixedKey, offer]));
|
||||
assert.deepEqual(Object.keys(fixed).sort(), ["potionAdd", "potionCount", "potionHeal"]);
|
||||
assert.ok(offers.slice(3).every((offer) => offer.itemType !== "POTION"));
|
||||
assert.equal(game.buyShopOffer(fixed.potionAdd.offerId), false);
|
||||
assert.equal(game.state.potion.charges, 1);
|
||||
game.state.potion.charges = 0;
|
||||
assert.equal(game.buyShopOffer(fixed.potionAdd.offerId), true);
|
||||
assert.equal(game.state.potion.charges, 1);
|
||||
assert.equal(game.buyShopOffer(fixed.potionCount.offerId), true);
|
||||
assert.equal(game.state.potion.maxCharges, 2);
|
||||
assert.equal(game.state.potion.charges, 1);
|
||||
assert.equal(game.buyShopOffer(fixed.potionHeal.offerId), true);
|
||||
assert.equal(game.state.potion.healAmount, 80);
|
||||
game.closeEvent();
|
||||
game.startBattle(false);
|
||||
game.state.currentHp = 1;
|
||||
game.state.battle.playerHp = 1;
|
||||
assert.equal(game.usePotion(), true);
|
||||
assert.equal(game.state.currentHp, 81, "原作 HealLevel 80 是固定恢复 80 HP");
|
||||
});
|
||||
|
||||
test("旧安全点可迁移固定治疗、Boss循环和40格棋盘", () => {
|
||||
const original = new GameEngine(data, "LEGACY-SAVE").snapshot();
|
||||
original.saveVersion = 2;
|
||||
original.tiles = Array.from({ length: 10 }, (_, index) => ({ index, kind: index === 0 ? "START" : index === 3 ? "BATTLE" : "EMPTY", consumed: false }));
|
||||
original.tileIndex = 3;
|
||||
original.potion.healPercent = 80;
|
||||
delete original.potion.healAmount;
|
||||
original.bossGauge = { current: 50, maximum: 100, perPlayerAction: 50, bossActions: 2 };
|
||||
const restored = new GameEngine(data, original);
|
||||
assert.equal(restored.state.potion.healAmount, 80);
|
||||
assert.equal(restored.state.potion.healPercent, undefined);
|
||||
assert.deepEqual(restored.state.bossGauge, { mode: "BUILDUP", playerRolls: 1, buildupRollsRequired: 2, countdownRemaining: 0, countdownRolls: 4, bossActions: 2 });
|
||||
assert.equal(restored.state.saveVersion, 3);
|
||||
assert.equal(restored.state.tiles.length, 40);
|
||||
assert.equal(restored.state.tileIndex, 12);
|
||||
assert.equal(restored.state.tiles[12].kind, "BATTLE");
|
||||
});
|
||||
|
||||
test("对子机缘永久叠加属性并只把先攻带入下一场战斗", () => {
|
||||
let game;
|
||||
let fortuneEvent;
|
||||
for (let i = 0; i < 200 && !fortuneEvent; i += 1) {
|
||||
game = new GameEngine(data, `FORTUNE-${i}`);
|
||||
game.rollDice();
|
||||
fortuneEvent = game.drainEvents().find((event) => event.type === "FORTUNE_TRIGGERED");
|
||||
}
|
||||
assert.ok(game && fortuneEvent);
|
||||
assert.equal(game.state.fortune.damagePercent + game.state.fortune.maxHpPercent + game.state.fortune.defensePercent, 10);
|
||||
game.state.phase = "READY";
|
||||
game.state.battle = undefined;
|
||||
game.state.fortune.pendingOpeningStrike = true;
|
||||
game.startBattle(false);
|
||||
assert.equal(game.state.fortune.pendingOpeningStrike, false);
|
||||
assert.equal(game.state.battle.fortuneOpeningStrike, true);
|
||||
game.drainEvents();
|
||||
game.tickBattle(1);
|
||||
assert.ok(game.drainEvents().some((event) => event.type === "FORTUNE_STRIKE"));
|
||||
});
|
||||
|
||||
test("第一层自动战斗可在基准数值下完成", () => {
|
||||
const game = new GameEngine(data, "BATTLE-BASELINE");
|
||||
game.startBattle(false);
|
||||
for (let i = 0; i < 500 && ["BATTLE", "BOSS"].includes(game.state.phase); i += 1) game.tickBattle(100);
|
||||
assert.ok(["READY", "GEAR_REWARD"].includes(game.state.phase), `unexpected ${game.state.phase}`);
|
||||
});
|
||||
|
||||
test("初始配置无需广告复活即可连续击败前三组怪物", () => {
|
||||
const game = new GameEngine(data, "THREE-EARLY-GROUPS");
|
||||
for (let encounter = 0; encounter < 3; encounter += 1) {
|
||||
game.startBattle(false);
|
||||
for (let i = 0; i < 600 && ["BATTLE", "BOSS"].includes(game.state.phase); i += 1) game.tickBattle(100);
|
||||
assert.notEqual(game.state.phase, "DEFEAT", `第 ${encounter + 1} 组怪物导致失败`);
|
||||
if (game.state.phase === "GEAR_REWARD") game.salvagePendingGear();
|
||||
assert.equal(game.state.phase, "READY");
|
||||
}
|
||||
assert.ok(game.state.currentHp > 0);
|
||||
assert.ok(game.state.dda.threatScale <= 1);
|
||||
});
|
||||
|
||||
let failed = 0;
|
||||
const started = performance.now();
|
||||
for (const [name, fn] of tests) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
console.log(`\n${tests.length - failed}/${tests.length} passed in ${Math.round(performance.now() - started)}ms`);
|
||||
if (failed) process.exitCode = 1;
|
||||
@@ -0,0 +1,120 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user