Files
aetherbound-guild/prototype/site/verify-p8-3.js
T

311 lines
20 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const root = path.resolve(__dirname, "../..");
const baseURL = process.env.ABG_PROTOTYPE_URL || "http://127.0.0.1:8767";
const outputDir = process.env.ABG_PROTOTYPE_EVIDENCE || path.join(root, "prototype/gallery/evidence/p8-3");
const browserPath = process.env.ABG_BROWSER_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
async function screenshot(page, name) {
const destination = path.join(outputDir, name);
await page.screenshot({ path: destination, fullPage: true });
return destination;
}
async function noHorizontalOverflow(page, label) {
const metrics = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
assert.ok(metrics.scrollWidth <= metrics.clientWidth + 1, `${label}: ${JSON.stringify(metrics)}`);
}
async function stageAndActionFit(page, selector, label) {
const metrics = await page.locator(selector).evaluate((action) => {
const a = action.getBoundingClientRect();
const s = action.closest(".game-stage").getBoundingClientRect();
return { aLeft: a.left, aRight: a.right, aTop: a.top, aBottom: a.bottom, sLeft: s.left, sRight: s.right, sTop: s.top, sBottom: s.bottom, viewportWidth: innerWidth, viewportHeight: innerHeight };
});
assert.ok(metrics.sLeft >= -0.5 && metrics.sRight <= metrics.viewportWidth + 0.5 && metrics.sTop >= -0.5 && metrics.sBottom <= metrics.viewportHeight + 0.5, `${label} stage: ${JSON.stringify(metrics)}`);
assert.ok(metrics.aLeft >= metrics.sLeft && metrics.aRight <= metrics.sRight && metrics.aTop >= metrics.sTop && metrics.aBottom <= metrics.sBottom, `${label} action: ${JSON.stringify(metrics)}`);
}
async function fighterFact(page, selector) {
return page.locator(selector).evaluate((node) => {
const rect = node.getBoundingClientRect();
return {
id: node.dataset.fighterId || `enemy-${node.dataset.enemyIndex}`,
x: Number(node.dataset.x),
hp: Number(node.dataset.hp),
motion: node.dataset.motionState,
role: node.dataset.lineRole || null,
alive: node.dataset.alive === undefined ? null : node.dataset.alive === "true",
left: rect.left,
top: rect.top,
};
});
}
async function waitForBattleEvent(page, eventIndex) {
await page.waitForFunction((index) => document.querySelector(".p83-arena")?.dataset.battleEvent === String(index), eventIndex);
}
async function animationTime(page, selector) {
return page.locator(selector).evaluate((node) => node.getAnimations()[0]?.currentTime ?? null);
}
async function selectReviewCommit(page) {
assert.equal(await page.locator("[data-p83-risk]").count(), 3, "three stable risks");
assert.equal(await page.locator("[data-p83-risk].is-selected").count(), 0, "no preselected risk");
assert.ok(await page.locator("[data-p83-review]").isDisabled(), "review disabled before selection");
assert.match(await page.locator(".p82-resource").textContent(), /金币\s*12/);
assert.match(await page.locator(".p82-resource").textContent(), /撤退费\s*6/);
const prepared = await page.locator(".experience-stage-actions").textContent();
await page.locator('[data-p83-risk="RSK-P83-02"]').click();
assert.equal(await page.locator("[data-p83-risk].is-selected").count(), 1);
assert.match(await page.locator(".p83-risk-read").textContent(), /射程应对充分/);
assert.match(await page.locator(".p82-resource").textContent(), /金币\s*12/, "selection spends nothing");
assert.equal(await page.locator(".experience-stage-actions").textContent(), prepared, "selection changes no prepared Company state");
await page.locator("[data-p83-review]").click();
assert.match(await page.locator(".p83-risk-review").textContent(), /RSK-P83-02/);
assert.match(await page.locator(".p83-risk-review").textContent(), /12 金币/);
assert.match(await page.locator(".p83-risk-review").textContent(), /EQ-012/);
assert.match(await page.locator(".p83-review-terms").textContent(), /撤退.*6 金币/s);
await page.locator("[data-p83-cancel]").click();
assert.equal(await page.locator('[data-p83-risk="RSK-P83-02"].is-selected').count(), 1, "cancel preserves selected risk");
await page.locator("[data-p83-review]").click();
await page.locator("[data-p83-commit]").click();
assert.equal(await page.locator("text=RISK-P83-001").count(), 1, "one risk receipt");
assert.match(await page.locator(".p83-receipt").textContent(), /12 → 12/);
assert.match(await page.locator(".p83-receipt").textContent(), /1 \/ 1/);
}
async function openAndObserve(page, evidence) {
await page.locator("[data-p83-open-battle]").click();
assert.match(await page.locator(".p83-opening").textContent(), /战斗中不会复活/);
assert.match(await page.locator(".p83-opening").textContent(), /后备队员不会自动补位/);
assert.match(await page.locator(".p83-opening").textContent(), /已保存站位原样入场/);
assert.match(await page.locator(".p83-opening").textContent(), /近战接敌停步、后排治疗与远射/);
assert.equal(await page.locator(".p83-arena .p83-fighter.is-ally").count(), 4, "four allies share one arena");
assert.equal(await page.locator(".p83-arena .p83-fighter.is-enemy").count(), 3, "chosen risk enemies share one arena");
assert.equal(await page.locator(".p83-opening-lines").count(), 0, "opening no longer uses tall unit cards");
assert.equal(await page.locator('.p83-arena[data-formation-phase="entry"]').count(), 1, "opening starts before formation movement");
assert.deepEqual(await page.locator(".p83-fighter.is-ally").evaluateAll((nodes) => nodes.map((node) => Number(node.dataset.x))), [7, 17, 27, 37], "accepted Company enters from the left");
assert.deepEqual(await page.locator(".p83-fighter.is-enemy").evaluateAll((nodes) => nodes.map((node) => Number(node.dataset.x))), [72, 82, 92], "enemy formation enters from the right");
evidence.push(await screenshot(page, "desktop-battle-opening.png"));
await page.locator("[data-p83-start]").click();
assert.equal(await page.locator('.p83-arena[data-formation-phase="closing"]').count(), 1, "both formations enter closing phase");
assert.equal(await page.locator(".p83-fighter.is-translating").count(), 7, "every visible footprint translates during entry");
const movingFront = page.locator('[data-fighter-id="OFF-03"]');
const movingStart = await movingFront.evaluate((node) => node.getBoundingClientRect().left);
const animatedFrame = page.locator('.p83-motion-sprite img').first();
const frameBefore = await animatedFrame.evaluate((node) => ({ opacity: getComputedStyle(node).opacity, playState: getComputedStyle(node).animationPlayState }));
await page.waitForTimeout(220);
const movingAfter = await movingFront.evaluate((node) => node.getBoundingClientRect().left);
const frameAfter = await animatedFrame.evaluate((node) => ({ opacity: getComputedStyle(node).opacity, playState: getComputedStyle(node).animationPlayState }));
assert.ok(movingAfter > movingStart + 20, `allied front advances right: ${movingStart} -> ${movingAfter}`);
assert.equal(frameBefore.playState, "running");
assert.equal(frameAfter.playState, "running");
assert.notEqual(frameBefore.opacity, frameAfter.opacity, "frame animation advances while playing");
await page.locator("[data-p83-pause]").click();
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const pausedEvents = await page.locator("[data-p83-event].is-reached").count();
const pausedPositionBefore = await movingFront.evaluate((node) => node.getBoundingClientRect().left);
const pausedSprite = page.locator('.p83-motion-sprite').first();
const pausedFrameBefore = await pausedSprite.evaluate((node) => ({ backgroundImage: getComputedStyle(node).backgroundImage, playState: node.querySelector("img").getAnimations()[0]?.playState }));
await page.waitForTimeout(1100);
const pausedPositionAfter = await movingFront.evaluate((node) => node.getBoundingClientRect().left);
const pausedFrameAfter = await pausedSprite.evaluate((node) => ({ backgroundImage: getComputedStyle(node).backgroundImage, playState: node.querySelector("img").getAnimations()[0]?.playState }));
assert.equal(await page.locator("[data-p83-event].is-reached").count(), pausedEvents, "Pause freezes event progress");
assert.ok(Math.abs(pausedPositionAfter - pausedPositionBefore) < 0.1, `Pause freezes formation position: ${pausedPositionBefore} -> ${pausedPositionAfter}`);
assert.deepEqual(pausedFrameAfter, pausedFrameBefore, "Pause freezes sprite-frame motion");
assert.equal(pausedFrameAfter.playState, "paused");
assert.match(pausedFrameAfter.backgroundImage, /frame-0\.png/, "Pause renders a stable motion poster");
const duration1x = Number.parseFloat(await animatedFrame.evaluate((node) => getComputedStyle(node).animationDuration));
await page.locator('[data-p83-speed="4"]').click();
assert.equal(await page.locator('[data-p83-speed="4"].is-active').count(), 1);
const duration4x = Number.parseFloat(await page.locator('.p83-motion-sprite img').first().evaluate((node) => getComputedStyle(node).animationDuration));
assert.ok(duration4x < duration1x / 3.5, `4x shortens motion cycle: ${duration1x} -> ${duration4x}`);
await page.locator("[data-p83-pause]").click();
await waitForBattleEvent(page, 1);
await page.locator("[data-p83-pause]").click();
assert.equal(await page.locator('.p83-contact-marker[data-contact-x="52.5"]').count(), 1, "one visible contact boundary");
const contactWayrunner = await fighterFact(page, '[data-fighter-id="OFF-03"]');
const contactEnemy = await fighterFact(page, '[data-enemy-index="0"]');
const contactHearthguard = await fighterFact(page, '[data-fighter-id="MKT-R1-01"]');
const contactKindhand = await fighterFact(page, '[data-fighter-id="OFF-04"]');
const contactFarstring = await fighterFact(page, '[data-fighter-id="MKT-R1-02"]');
assert.equal(contactWayrunner.x, 46.5);
assert.equal(contactEnemy.x, 58.2);
assert.ok(contactHearthguard.x < contactWayrunner.x, "Hearthguard remains behind the foremost melee footprint");
assert.ok(contactKindhand.x < contactHearthguard.x && contactFarstring.x < contactHearthguard.x, "healer and archer preserve rear spacing");
evidence.push(await screenshot(page, "desktop-battle-contact.png"));
await page.locator("[data-p83-pause]").click();
await waitForBattleEvent(page, 2);
await page.locator("[data-p83-pause]").click();
const progressBeforeInspect = await page.locator("[data-p83-event].is-reached").count();
assert.equal(await page.locator('[data-fighter-id="OFF-03"][data-motion-state="attack"][data-contacted="true"]').count(), 1, "Wayrunner attacks only after contact");
assert.equal(await page.locator('[data-fighter-id="MKT-R1-01"][data-motion-state="attack"][data-contacted="true"]').count(), 1, "Hearthguard attacks only after contact");
assert.equal((await fighterFact(page, '[data-enemy-index="0"]')).hp, 72, "post-contact melee lowers enemy front HP");
evidence.push(await screenshot(page, "desktop-battle-melee.png"));
await page.locator('[data-p83-event="2"]').click();
assert.equal(await page.locator(".p83-cause-sheet").count(), 1);
assert.match(await page.locator(".p83-cause-sheet").textContent(), /近战只在接敌后出手/);
assert.match(await page.locator(".p83-cause-sheet").textContent(), /不会改变进度或结果/);
assert.equal(await page.locator("[data-p83-event].is-reached").count(), progressBeforeInspect, "inspect does not rewind progress");
evidence.push(await screenshot(page, "desktop-causal-inspect.png"));
await page.locator("[data-p83-close-inspect]").click();
assert.match(await page.locator(".p83-battle-rules").textContent(), /已暂停/);
await page.locator("[data-p83-pause]").click();
await waitForBattleEvent(page, 3);
await page.locator("[data-p83-pause]").click();
assert.equal(await page.locator('.p83-action-burst.is-guard').count(), 1, "Guard/intercept is visible");
assert.equal(await page.locator('[data-fighter-id="MKT-R1-01"][data-motion-state="protect"]').count(), 1, "Hearthguard uses accepted protect frames");
assert.equal((await fighterFact(page, '[data-fighter-id="MKT-R1-01"]')).hp, 72, "Hearthguard is visibly injured before healing");
evidence.push(await screenshot(page, "desktop-battle-guard.png"));
await page.locator("[data-p83-pause]").click();
await waitForBattleEvent(page, 4);
await page.locator("[data-p83-pause]").click();
assert.equal(await page.locator('[data-projectile-kind="arrow"][data-source="MKT-R1-02"][data-target="enemy-2"]').count(), 1, "Farstring launches one legal rear projectile");
assert.equal(await page.locator('[data-fighter-id="MKT-R1-02"][data-motion-state="ranged"][data-line-role="rear"]').count(), 1, "Farstring fires from the rear");
assert.equal((await fighterFact(page, '[data-enemy-index="2"]')).hp, 72, "ranged target HP decreases");
const projectileTimeBefore = await animationTime(page, '[data-projectile-kind="arrow"]');
await page.waitForTimeout(300);
const projectileTimeAfter = await animationTime(page, '[data-projectile-kind="arrow"]');
assert.equal(projectileTimeAfter, projectileTimeBefore, "Pause freezes projectile travel");
evidence.push(await screenshot(page, "desktop-battle-projectile.png"));
await page.locator("[data-p83-pause]").click();
await waitForBattleEvent(page, 5);
await page.locator("[data-p83-pause]").click();
assert.equal(await page.locator('[data-heal-target="MKT-R1-01"]').count(), 1, "heal visibly targets the injured ally");
assert.equal(await page.locator('[data-fighter-id="OFF-04"][data-motion-state="sustain"][data-line-role="rear"]').count(), 1, "Kindhand heals from the rear");
assert.equal((await fighterFact(page, '[data-fighter-id="MKT-R1-01"]')).hp, 88, "healing raises Hearthguard HP from 72 to 88");
evidence.push(await screenshot(page, "desktop-battle-sustain.png"));
await page.locator("[data-p83-pause]").click();
await waitForBattleEvent(page, 6);
await page.locator("[data-p83-pause]").click();
assert.equal(await page.locator('[data-demo-role="PF-B06"]').count(), 1, "Riftcaller language belongs to enemy rear demo");
assert.equal(await page.locator('[data-projectile-kind="spell"]').count(), 1, "spell bolt is distinct from the arrow");
assert.equal(await page.locator('[data-projectile-kind="arrow"]').count(), 0, "spell event does not reuse the arrow language");
assert.match(await page.locator(".p83-spell-demo").textContent(), /不是我方第五人/);
evidence.push(await screenshot(page, "desktop-battle-spell.png"));
await page.locator("[data-p83-pause]").click();
await waitForBattleEvent(page, 7);
await page.locator("[data-p83-pause]").click();
const fallen = await fighterFact(page, '[data-enemy-index="0"]');
const replacement = await fighterFact(page, '[data-enemy-index="1"]');
assert.equal(fallen.hp, 0);
assert.equal(fallen.alive, false, "fallen front stays down");
assert.equal(replacement.x, fallen.x, "next living front advances to the same contact footprint");
assert.equal(await page.locator(".p83-no-respawn").count(), 1);
assert.equal(await page.locator(".p83-fighter.is-ally").count(), 4, "no fifth ally or Reserve entry");
evidence.push(await screenshot(page, "desktop-battle-replacement.png"));
await page.locator("[data-p83-pause]").click();
await page.waitForSelector("[data-p83-finish]");
assert.equal(await page.locator("[data-p83-event].is-reached").count(), 9, "deterministic nine-event behavior chain");
evidence.push(await screenshot(page, "desktop-battle-resolved-timeline.png"));
await page.locator("[data-p83-finish]").click();
assert.match(await page.locator(".p83-resolved").textContent(), /P8\.4 尚未开启/);
assert.match(await page.locator(".p83-resolved").textContent(), /本体验包不包含结果判断/);
assert.equal(await page.locator(".outcome-seal").count(), 0, "no victory or defeat judgment");
evidence.push(await screenshot(page, "desktop-observation-receipt.png"));
}
async function desktopAudit(browser, evidence) {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
page.setDefaultTimeout(10000);
const errors = [];
page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); });
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`${baseURL}?package=p8-3#playtest`, { waitUntil: "networkidle" });
assert.equal(await page.locator("html").getAttribute("lang"), "zh-CN");
evidence.push(await screenshot(page, "desktop-risk-unselected.png"));
await selectReviewCommit(page);
evidence.push(await screenshot(page, "desktop-risk-receipt.png"));
await openAndObserve(page, evidence);
await page.locator("[data-p83-restart]").click();
assert.equal(await page.locator("[data-p83-risk].is-selected").count(), 0, "restart clears P8.3 state");
assert.deepEqual(errors, [], `desktop errors: ${errors.join(" | ")}`);
await page.close();
}
async function responsiveAudit(browser, evidence) {
for (const fixture of [
{ name: "phone-landscape-risk.png", viewport: { width: 844, height: 390 } },
{ name: "phone-portrait-risk.png", viewport: { width: 390, height: 844 } },
]) {
const page = await browser.newPage({ viewport: fixture.viewport, deviceScaleFactor: 1 });
page.setDefaultTimeout(10000);
await page.goto(`${baseURL}?package=p8-3#playtest`, { waitUntil: "networkidle" });
await page.locator('[data-p83-risk="RSK-P83-01"]').click();
await noHorizontalOverflow(page, fixture.name);
await stageAndActionFit(page, "[data-p83-review]", fixture.name);
assert.equal(await page.locator("[data-p83-risk]:visible").count(), 3);
evidence.push(await screenshot(page, fixture.name));
await page.close();
}
for (const fixture of [
{ name: "phone-landscape-battle.png", viewport: { width: 844, height: 390 } },
{ name: "phone-portrait-battle.png", viewport: { width: 390, height: 844 } },
]) {
const page = await browser.newPage({ viewport: fixture.viewport, deviceScaleFactor: 1 });
page.setDefaultTimeout(10000);
await page.goto(`${baseURL}?package=p8-3#playtest`, { waitUntil: "networkidle" });
await page.locator('[data-p83-risk="RSK-P83-01"]').click();
await page.locator("[data-p83-review]").click();
await page.locator("[data-p83-commit]").click();
await page.locator("[data-p83-open-battle]").click();
await page.locator("[data-p83-start]").click();
await page.locator("[data-p83-pause]").click();
await noHorizontalOverflow(page, fixture.name);
await stageAndActionFit(page, "[data-p83-pause]", fixture.name);
assert.equal(await page.locator(".p83-arena .p83-fighter.is-ally:visible").count(), 4);
assert.equal(await page.locator(".p83-arena .p83-fighter.is-enemy:visible").count(), 2);
assert.equal(await page.locator(".p83-motion-sprite:visible").count() >= 3, true);
evidence.push(await screenshot(page, fixture.name));
await page.close();
}
}
async function bundleAudit(browser) {
const bundle = path.join(root, "prototype/gallery/aetherbound-guild-prototype.html");
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const external = [];
page.on("request", (request) => { if (!request.url().startsWith("file:") && !request.url().startsWith("data:")) external.push(request.url()); });
await page.goto(`file://${bundle}?package=p8-3#playtest`, { waitUntil: "load" });
assert.equal(await page.locator("[data-p83-risk]").count(), 3);
assert.deepEqual(external, []);
await page.close();
}
async function main() {
fs.mkdirSync(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: browserPath });
const evidence = [];
try {
await desktopAudit(browser, evidence);
await responsiveAudit(browser, evidence);
await bundleAudit(browser);
} finally {
await browser.close();
}
console.log("ABG_P8_3R2_BROWSER_OK risks=3 preselected=0 commits=1 arena=continuous_line allies=4 entry=two_sided contact=single_stop melee=post_contact rear=heal,arrow spell=enemy_pf_b06 replacement=next_living no_respawn=1 reserve_entry=0 events=9 pause_freeze=position,event,frame,projectile speeds=1,2,4 inspect_readonly=1 result_mutation=0 responsive=desktop,phone_landscape,phone_portrait bundle=offline");
console.log(`evidence=${evidence.join(",")}`);
}
main().catch((error) => {
console.error(error.stack || error);
process.exitCode = 1;
});