Files

768 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 验收测试:PRD §14 共 20 条。用法:先 npm run build && npm run preview,再 node tools/acceptance.mjs
// 需要本机 Chromepuppeteer-core 驱动,无额外下载)。
import puppeteer from 'puppeteer-core';
import fs from 'node:fs';
import { spawn } from 'node:child_process';
import net from 'node:net';
const BASE = 'http://127.0.0.1:4815/';
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const SHOTS = 'tools/shots';
fs.mkdirSync(SHOTS, { recursive: true });
// 自起 preview 服务器(退出时关闭)
const preview = spawn('npx', ['vite', 'preview', '--host', '127.0.0.1', '--port', '4815', '--strictPort'], { stdio: 'ignore' });
process.on('exit', () => { try { preview.kill(); } catch {} });
async function waitPort(port, timeout = 15000) {
const t0 = Date.now();
for (;;) {
const ok = await new Promise((res) => { const s = net.connect(port, '127.0.0.1'); s.once('connect', () => { s.end(); res(true); }); s.once('error', () => res(false)); });
if (ok) return;
if (Date.now() - t0 > timeout) throw new Error('preview server 未就绪');
await new Promise((r) => setTimeout(r, 300));
}
}
await waitPort(4815);
let pass = 0, fail = 0;
const failures = [];
function check(name, cond, detail = '') {
if (cond) { pass++; console.log(` PASS ${name}`); }
else { fail++; failures.push(name + (detail ? ` — ${detail}` : '')); console.log(` FAIL ${name} ${detail}`); }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const read = (p) => fs.readFileSync(p, 'utf8');
// ================= 静态源码/资源检查 =================
console.log('== 静态检查 ==');
const ann = read('src/data/announcements.ts');
const PRD_ANN = [
'早上好呀!今天的幸福任务贴在板上啦。祝你开心!',
'早上好呀!新的一天,新的幸福。祝你开心!',
'早上好!今天要把幸福装得满满的哦。要开心!',
'早上好呀!今天的幸福任务贴在板上啦。祝你开心!',
'早上好。今天是自由日,你可以自由地选择幸福。要开心哦。',
'明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。',
'庆典开始了。来吧。',
];
for (let i = 0; i < 7; i++) check(`公告 D${i + 1} 逐字`, ann.includes(PRD_ANN[i]), PRD_ANN[i]);
check('公告 D4===D1', PRD_ANN[3] === PRD_ANN[0] && (ann.split(PRD_ANN[0]).length - 1) >= 2);
const tasks = read('src/data/tasks.ts');
const REWARDS = { T1: 15, T2: 10, T3: 10, T4: 15, T5: 10, T6: 10, T7: 15, T8: 15, T9: 10, T10: 10, T11: 10, T12: 10, T13: 10, T14: 15 };
for (const [id, r] of Object.entries(REWARDS)) check(`任务 ${id} 奖励 +${r}`, new RegExp(`id: '${id}'[\\s\\S]*?reward: ${r}[,}]`).test(tasks));
const t1 = /T1:\s*\{[^}]*title: '([^']+)'/.exec(tasks)?.[1];
const t8 = /T8:\s*\{[^}]*title: '([^']+)'/.exec(tasks)?.[1];
const t2 = /T2:\s*\{[^}]*title: '([^']+)'/.exec(tasks)?.[1];
const t9 = /T9:\s*\{[^}]*title: '([^']+)'/.exec(tasks)?.[1];
const t3 = /T3:\s*\{[^}]*title: '([^']+)'/.exec(tasks)?.[1];
const t10 = /T10:\s*\{[^}]*title: '([^']+)'/.exec(tasks)?.[1];
check('T8/T9/T10 与 T1/T2/T3 逐字相同', t1 === t8 && t2 === t9 && t3 === t10 && !!t1);
check('T15 标题逐字', tasks.includes('参加第 413 届丰收庆典'));
const corrupt = read('src/core/TextCorruptor.ts');
const PAIRS = [['幸福', '辛福'], ['开心', '开欣'], ['美好', '美妤'], ['大家', '大伽'], ['微笑', '微效'], ['明天', '名天'], ['甜', '恬'], ['阳光', '央光'], ['朋友', '棚友'], ['永远', '泳远']];
for (const [a, b] of PAIRS) check(`错字表 ${a}${b}`, corrupt.includes(`['${a}', '${b}']`));
check('腐蚀优先命中咪咪', /mimi/.test(corrupt) && /咪咪/.test(corrupt));
const dlg = read('src/data/dialogues.ts') + read('src/data/clues.ts');
for (const s of ['湖水一直很平静', '面包一直是那个味道', '孩子,今天是第几个今天?', '按疗程配比。甜度:最大。']) check(`线索台词逐字「${s}」`, dlg.includes(s));
const endings = read('src/data/endings.ts');
for (const s of ['蜜糖谷情绪疗养中心', 'PT-07', '系统检测到疗程无效。', '疗程重新开始。', '从此,每一天都很幸福。', '每一天。']) check(`结局文本逐字「${s}」`, endings.includes(s));
// 地图 JSON 结构
const MAP_KEYS = ['house', 'plaza', 'bakery', 'shop', 'clinic', 'lake', 'field', 'well', 'fog', 'white'];
const maps = {};
const mapJson = {};
for (const key of MAP_KEYS) {
const d = JSON.parse(read(`public/assets/maps/${key}.json`));
mapJson[key] = d;
const names = d.layers.map((l) => l.name);
check(`地图 ${key} 三层齐全`, names.includes('ground') && names.includes('obstacles') && names.includes('objects'));
const ob = d.layers.find((l) => l.name === 'obstacles');
check(`地图 ${key} obstacles collides=true`, (ob.properties || []).some((p) => p.name === 'collides' && p.value === true));
check(`地图 ${key} tileset 内嵌`, d.tilesets[0].image === '../tileset.png' && d.tilesets[0].tilewidth === 16);
const wpx = d.width * 16, hpx = d.height * 16;
const ox = Math.max(0, Math.floor((320 - wpx) / 2));
const oy = Math.max(0, Math.floor((180 - hpx) / 2));
const og = d.layers.find((l) => l.name === 'objects');
maps[key] = og.objects.map((o) => ({
name: o.name, x: o.x + ox, y: o.y + oy,
props: Object.fromEntries((o.properties || []).map((p) => [p.name, p.value])),
}));
}
// 门双向成对
for (const key of MAP_KEYS) {
for (const d of maps[key].filter((o) => o.name === 'door')) {
const tgt = d.props.target, sp = d.props.spawn;
const okSpawn = maps[tgt]?.some((o) => o.name === 'spawn' && o.props.id === sp);
const okBack = maps[tgt]?.some((o) => o.name === 'door' && o.props.target === key);
check(`门 ${key}${tgt}(${sp}) 双向`, !!okSpawn && !!okBack);
}
}
// NPC 站位(§4
const npcAt = (m, id) => maps[m].some((o) => o.name === 'npc' && o.props.id === id);
for (const [m, id] of [['plaza', 'afu'], ['plaza', 'dabao'], ['plaza', 'bird'], ['bakery', 'yuanyuan'], ['shop', 'mimi'], ['clinic', 'fuyisheng'], ['lake', 'guiyeye']]) {
check(`NPC 站位 ${id}@${m}`, npcAt(m, id));
}
let extras = 0;
for (const k of MAP_KEYS) extras += maps[k].filter((o) => o.name === 'npc' && ['squirrel', 'hedgehog', 'fox', 'pig', 'chick'].includes(o.props.id)).length;
check('群演 ×5 分布各场景', extras === 5, `实际 ${extras}`);
// ================= 浏览器部分 =================
const SCENE = { house: 'HouseScene', plaza: 'PlazaScene', bakery: 'BakeryScene', shop: 'ShopScene', clinic: 'ClinicScene', lake: 'LakeScene', field: 'FieldScene', well: 'WellScene', fog: 'FogScene', white: 'WhiteScene' };
const browser = await puppeteer.launch({
executablePath: CHROME, headless: true,
args: ['--autoplay-policy=no-user-gesture-required', '--mute-audio', '--disable-dev-shm-usage'],
});
const page = await browser.newPage();
await page.setViewport({ width: 960, height: 560 });
const consoleErrors = [];
page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', (e) => consoleErrors.push('PAGEERROR: ' + e.message));
const extReqs = [];
page.on('request', (r) => { const u = r.url(); if (!u.startsWith(BASE) && !u.startsWith('data:') && !u.startsWith('blob:') && !u.startsWith('devtools:')) extReqs.push(u); });
const ev = (fn, ...a) => page.evaluate(fn, ...a);
const isActive = (k) => ev((k) => window.__game.scene.isActive(k), k);
async function waitActive(k, timeout = 9000) { await page.waitForFunction((kk) => window.__game.scene.isActive(kk), { timeout }, k); }
async function startScene(k, spawn = 'default') {
await ev((k, s) => {
const sm = window.__game.scene;
// game.scene.start 不会停掉其它场景:先停掉所有活动地图场景(保留常驻 UIScene)
for (const sc of sm.getScenes(true)) {
const key = sc.scene.key;
if (key !== 'UIScene' && key !== k) sm.stop(key);
}
sm.start(k, { spawn: s });
}, k, spawn);
await waitActive(k); await sleep(500);
}
async function dget(expr) { return ev(new Function(`return window.__director.${expr}`)); }
async function dset(expr) { return ev(new Function(`window.__director.${expr}`)); }
const darr = (expr) => ev(new Function(`return [...window.__director.${expr}]`));
async function setPlayer(sceneKey, x, y) {
await ev((k, x, y) => { const s = window.__game.scene.getScene(k); s.player.setPosition(x, y); s.player.setVelocity(0, 0); }, sceneKey, x, y);
await sleep(120);
}
const playerPos = (sceneKey) => ev((k) => { const s = window.__game.scene.getScene(k); return { x: s.player.x, y: s.player.y }; }, sceneKey);
const dlgOpen = () => ev(() => { const u = window.__game.scene.getScene('UIScene'); return !!(u && u.payload); });
const dlgText = () => ev(() => { const u = window.__game.scene.getScene('UIScene'); return u && u.dlgBody ? u.dlgBody.text : ''; });
// Phaser JustDown 需要按键“按住”跨过至少一个游戏帧;瞬发 press 的 down+up 落在同一帧内会被吞掉
async function tap(code, holdMs = 80) {
await page.keyboard.down(code);
await sleep(holdMs);
await page.keyboard.up(code);
await sleep(30);
}
async function drain(choiceKey = 'Digit1', maxIter = 60) {
const texts = [];
for (let i = 0; i < maxIter; i++) {
if (!(await dlgOpen())) break;
texts.push(await dlgText());
const choosing = await ev(() => { const u = window.__game.scene.getScene('UIScene'); return u.showingChoices; });
await tap(choosing ? choiceKey : 'KeyE');
await sleep(160);
}
for (let i = 0; i < 20 && (await dlgOpen()); i++) { await tap('KeyE'); await sleep(140); }
return texts;
}
async function interactAt(mapKey, kind, id, choiceKey = 'Digit1') {
const sceneKey = SCENE[mapKey];
if (!(await isActive(sceneKey))) await startScene(sceneKey);
await drain(); // 清掉残留对话(黄昏/满格等)
const obj = maps[mapKey].find((o) => o.name === kind && o.props.id === id);
if (!obj) throw new Error(`no ${kind}:${id} in ${mapKey}`);
await setPlayer(sceneKey, obj.x + 40, obj.y + 40); // 先离开感应圈(触发目标重武装)
await setPlayer(sceneKey, obj.x, obj.y + 8);
await tap('KeyE');
await sleep(400);
return drain(choiceKey);
}
// plaza 代码补区(相对公告板)
function plazaZone(id) {
const nb = maps.plaza.find((o) => o.name === 'interact' && o.props.id === 'noticeboard');
if (id === 'noticeboard') return { x: nb.x, y: nb.y };
const rel = {
mailbox_c3: [-48, 24], ribbon_1: [-48, -32], ribbon_2: [64, -32], ribbon_3: [-48, 64], ribbon_4: [64, 64],
smile_spot: [0, 32], celebration_site: [0, -72],
}[id];
return { x: nb.x + rel[0], y: nb.y + rel[1] };
}
async function interactPlazaZone(id, choiceKey = 'Digit1') {
if (!(await isActive('PlazaScene'))) await startScene('PlazaScene');
await drain();
const z = plazaZone(id);
await setPlayer('PlazaScene', z.x + 40, z.y + 40); // 先离开感应圈(触发目标重武装)
await setPlayer('PlazaScene', z.x, z.y + 8);
await tap('KeyE');
await sleep(400);
return drain(choiceKey);
}
async function shot(name, clip = null) {
const box = await (await page.$('canvas')).boundingBox();
const c = clip ? { x: box.x + clip.x, y: box.y + clip.y, width: clip.w, height: clip.h } : { x: box.x, y: box.y, width: box.width, height: box.height };
await page.screenshot({ path: `${SHOTS}/${name}.png`, clip: c });
}
async function sleepNight() {
await interactAt('house', 'interact', 'bed');
await waitActive('DreamScene', 12000).catch(() => {});
for (let i = 0; i < 30 && (await isActive('DreamScene')); i++) { await drain(); await sleep(500); }
await waitActive('HouseScene', 12000);
await sleep(500);
}
async function boardAnnounce(day) {
const texts = await interactAt('plaza', 'interact', 'noticeboard');
const found = texts.some((t) => t.includes(PRD_ANN[day - 1].slice(0, 8)));
return { texts, found };
}
// ---------- #1 标题 → 名字 → Day1 小屋 ----------
console.log('== #1 标题与开局 ==');
try {
await page.goto(BASE, { waitUntil: 'networkidle2', timeout: 30000 });
await page.waitForFunction('window.__game && window.__director', { timeout: 15000 });
await waitActive('TitleScene', 20000);
await sleep(400);
await shot('01_title');
check('标题画面进入', true);
const hasInput = await ev(() => !!document.querySelector('#game input[type="text"]'));
check('名字输入框存在', hasInput);
await ev(() => { document.querySelector('#game input[type="text"]').closest('div').querySelector('button').click(); });
await waitActive('HouseScene', 15000); await sleep(600);
check('留空默认「小满」', (await dget('playerName')) === '小满');
check('Day 1 小屋开始', (await dget('day')) === 1 && (await isActive('HouseScene')));
await shot('02_house_d1');
// ---------- #2 移动/动画/碰撞/相机 ----------
console.log('== #2 移动与碰撞 ==');
{
const p0 = await playerPos('HouseScene');
await page.keyboard.down('KeyD'); await sleep(400); await page.keyboard.up('KeyD');
const p1 = await playerPos('HouseScene');
check('D 键右移', p1.x > p0.x + 10, `${p0.x}${p1.x}`);
await page.keyboard.down('KeyD'); await sleep(120);
const anim = await ev(() => window.__game.scene.getScene('HouseScene').player.anims.currentAnim?.key);
await page.keyboard.up('KeyD');
check('四向动画 walk-right', anim === 'walk-right', String(anim));
await sleep(200);
const idleFrame = await ev(() => window.__game.scene.getScene('HouseScene').player.frame.name);
check('待机帧(右=12', Number(idleFrame) === 12, String(idleFrame));
// 碰撞:向西推墙
await setPlayer('HouseScene', 80, 90);
await page.keyboard.down('KeyA'); await sleep(700); await page.keyboard.up('KeyA');
const px = (await playerPos('HouseScene')).x;
check('西墙碰撞拦截', px > 55, `x=${px}`);
// 相机钳制(plaza 480×320 > 320×180
await startScene('PlazaScene');
await setPlayer('PlazaScene', 8, 16);
await sleep(400);
const c1 = await ev(() => { const c = window.__game.scene.getScene('PlazaScene').cameras.main; return { x: c.scrollX, y: c.scrollY }; });
await setPlayer('PlazaScene', 472, 312);
await sleep(500);
const c2 = await ev(() => { const c = window.__game.scene.getScene('PlazaScene').cameras.main; return { x: c.scrollX, y: c.scrollY }; });
check('相机左上钳制', c1.x <= 0.5 && c1.y <= 0.5, JSON.stringify(c1));
check('相机右下钳制', c2.x + 320 <= 480.5 && c2.y + 180 <= 320.5, JSON.stringify(c2));
}
// ---------- #3 十场景可达 ----------
console.log('== #3 十场景 ==');
for (const [m, s] of Object.entries(SCENE)) {
await startScene(s);
check(`场景可达 ${s}`, await isActive(s));
}
await shot('03_plaza');
// ---------- 七日完整流程(覆盖 #5/#6/#11/#12/#13/#14/#16 ----------
console.log('== 七日流程 ==');
await startScene('HouseScene');
// --- Day 1 ---
console.log('-- Day 1 --');
{
const b = await boardAnnounce(1);
check('D1 公告逐字显示', b.found, b.texts[0]);
// #12 前半:D5 前古井/雾墙劝离
const w = await interactAt('well', 'interact', 'ancient_well');
check('#12 古井 D5 前劝离', !(await darr('clues')).includes('C7') && w.length > 0);
await startScene('PlazaScene');
const fogDoor = maps.plaza.find((o) => o.name === 'door' && o.props.target === 'fog');
await setPlayer('PlazaScene', fogDoor.x, fogDoor.y + 20);
await drain();
await page.keyboard.down('KeyW'); await sleep(700); await page.keyboard.up('KeyW');
await sleep(400); await drain();
check('#12 雾墙门 D5 前劝离(未进 FogScene', !(await isActive('FogScene')));
// T1 取面包 + 送三人(同时完成 T3)
await interactAt('bakery', 'interact', 'counter_bread');
check('T1 已取面包', await dget("taskProgress['breadTaken']"));
await interactAt('plaza', 'npc', 'dabao');
await interactAt('shop', 'npc', 'mimi');
await interactAt('lake', 'npc', 'guiyeye');
check('T1 完成 +15', (await darr('tasksDone')).includes('T1'));
check('T3 问好完成 +10', (await darr('tasksDone')).includes('T3'));
// T2 浇花 ×3(第三次浇花的对话收尾后同一批 drain 内就会弹出黄昏钟声)
await interactAt('plaza', 'interact', 'flowerbed_1');
await interactAt('plaza', 'interact', 'flowerbed_2');
const fb3 = await interactAt('plaza', 'interact', 'flowerbed_3');
check('T2 完成 +10', (await darr('tasksDone')).includes('T2'));
// 黄昏钟声(任务完成触发,可能已并入上一步 drain 的文本里)
await sleep(1200);
const duskTexts = [...fb3, ...(await drain())];
check('#5 黄昏钟声出现', duskTexts.some((t) => t.includes('钟声')), duskTexts.join('/').slice(0, 60));
// 对话选项:温柔 +2
const h0 = await dget('happiness');
await interactAt('plaza', 'npc', 'afu', 'Digit1');
check('温柔选项 +2', (await dget('happiness')) === h0 + 2, `${h0}${await dget('happiness')}`);
await drain();
await sleepNight();
check('D1→D2 睡眠过日', (await dget('day')) === 2);
}
// --- Day 2 ---
console.log('-- Day 2 --');
{
check('晨起于小屋', await isActive('HouseScene'));
const b = await boardAnnounce(2);
check('D2 公告逐字显示', b.found);
// C1 日历(D2 起)
await interactAt('house', 'interact', 'calendar');
check('C1 日历 D2 可发现 +8', (await darr('clues')).includes('C1'));
// T4:取信→送 3 人(第 3 封 C4)
await interactAt('plaza', 'npc', 'dabao');
check('T4 已取信 ×3', (await dget("taskProgress['letters']")) === 3);
const dY = await interactAt('bakery', 'npc', 'yuanyuan');
const dM = await interactAt('shop', 'npc', 'mimi');
const dF = await interactAt('clinic', 'npc', 'fuyisheng');
await sleep(700); // 第三封后 400ms 才弹 C4 对话,等它出现后关掉
const c4t = await drain();
const t4diag = `day=${await dget('day')} active=${await dget("isTaskActive('T4')")} letters=${await dget("taskProgress['letters']")} Y=${dY.join('|').slice(0, 40)} M=${dM.join('|').slice(0, 40)} F=${[...dF, ...c4t].join('|').slice(0, 60)}`;
check('T4 完成 +15', (await darr('tasksDone')).includes('T4'), t4diag);
check('C4 乱码署名 D2 当日发现', (await darr('clues')).includes('C4'));
// T5 + C5
await interactAt('shop', 'interact', 'shelf_count');
check('T5 完成 +10', (await darr('tasksDone')).includes('T5'));
check('C5 货架发现', (await darr('clues')).includes('C5'));
// C3 邮筒(D2 起)
await interactPlazaZone('mailbox_c3');
check('C3 邮筒信件发现', (await darr('clues')).includes('C3'));
// C9 第一段(D2
const c9d2 = await interactAt('lake', 'npc', 'guiyeye');
check('C9 第一段 D2', (await darr('clues')).includes('C9'));
check('C9 D2 台词「湖水一直很平静」', c9d2.some((t) => t.includes('湖水一直很平静')), c9d2.join('/').slice(0, 60));
await sleepNight();
check('D2→D3', (await dget('day')) === 3);
}
// --- Day 3 ---
console.log('-- Day 3 --');
{
const b = await boardAnnounce(3);
check('D3 公告逐字显示', b.found);
await interactAt('field', 'interact', 'berry_1');
await interactAt('field', 'interact', 'berry_2');
await interactAt('field', 'interact', 'berry_3');
await interactAt('field', 'interact', 'berry_4');
await interactAt('field', 'interact', 'berry_5');
check('T6 完成 +10', (await darr('tasksDone')).includes('T6'));
await interactAt('lake', 'interact', 'picnic_mat');
check('T7 完成 +15', (await darr('tasksDone')).includes('T7'));
await interactAt('bakery', 'interact', 'recipe_paper');
check('C2 配方纸 D3 起', (await darr('clues')).includes('C2'));
await sleepNight();
check('D3→D4(梦境闪切已过)', (await dget('day')) === 4);
}
// --- Day 4 ---
console.log('-- Day 4 --');
{
const b = await boardAnnounce(4);
check('D4 公告与 D1 逐字相同', b.found);
await interactAt('bakery', 'interact', 'counter_bread');
await interactAt('plaza', 'npc', 'dabao');
await interactAt('shop', 'npc', 'mimi');
await interactAt('lake', 'npc', 'guiyeye'); // C9 第二段(D4+ T10 计数
check('T8 完成', (await darr('tasksDone')).includes('T8'));
check('T10 完成', (await darr('tasksDone')).includes('T10'));
await interactAt('plaza', 'interact', 'flowerbed_1');
await interactAt('plaza', 'interact', 'flowerbed_2');
await interactAt('plaza', 'interact', 'flowerbed_3');
check('T9 完成', (await darr('tasksDone')).includes('T9'));
// C9 第二段(D4):T8 完成后再与龟爷爷对话
const c9d4 = await interactAt('lake', 'npc', 'guiyeye');
check('C9 D4 台词「面包一直是那个味道」', c9d4.some((t) => t.includes('面包一直是那个味道')), c9d4.join('/').slice(0, 60));
await sleepNight();
check('D4→D5', (await dget('day')) === 5);
}
// --- Day 5 ---
console.log('-- Day 5 --');
{
const b = await boardAnnounce(5);
check('D5 公告逐字显示', b.found);
// T11 拒绝(悲伤行为:拒绝任务)
const aw0 = await dget('awareness');
await interactAt('bakery', 'npc', 'yuanyuan', 'Digit2');
check('T11 可拒绝且被记录', (await darr('refusedTasks')).includes('T11'));
check('#7 拒绝任务计悲伤(觉知+3', (await dget('awareness')) >= aw0 + 3, `${aw0}${await dget('awareness')}`);
// T12 接受(C6
await interactPlazaZone('noticeboard', 'Digit1');
check('T12 完成 + C6 旧字迹', (await darr('tasksDone')).includes('T12') && (await darr('clues')).includes('C6'));
// #12 后半:D5 起古井可交互(C7,聆听 5 秒)
{
const sceneKey = 'WellScene';
if (!(await isActive(sceneKey))) await startScene(sceneKey);
await drain();
const obj = maps.well.find((o) => o.name === 'interact' && o.props.id === 'ancient_well');
await setPlayer(sceneKey, obj.x, obj.y + 8);
await tap('KeyE');
await sleep(600); await drain();
await page.waitForFunction(() => window.__director.clues.has('C7'), { timeout: 15000 });
await drain();
check('#12 古井 D5 起可交互', true);
check('C7 井底回声 +8', (await darr('clues')).includes('C7'));
}
// C8 诊所
await interactAt('clinic', 'interact', 'doctor_desk');
check('C8 诊所表格 D5 起', (await darr('clues')).includes('C8'));
await sleepNight();
check('D5→D6', (await dget('day')) === 6);
}
// --- Day 6#13 T14、#14 雨)---
console.log('-- Day 6 --');
{
const b = await boardAnnounce(6);
check('D6 公告逐字显示', b.found);
await interactPlazaZone('ribbon_1');
await interactPlazaZone('ribbon_2');
await interactPlazaZone('ribbon_3');
await interactPlazaZone('ribbon_4');
check('T13 完成 +10', (await darr('tasksDone')).includes('T13'));
// C9 第三段(D6
const c9d6 = await interactAt('lake', 'npc', 'guiyeye');
check('C9 三段递进完成', (await darr('clues')).includes('C9'));
check('C9 D6 台词「孩子,今天是第几个今天?」', c9d6.some((t) => t.includes('孩子,今天是第几个今天?')), c9d6.join('/').slice(0, 60));
// T14:保持微笑 60 秒
{
await startScene('PlazaScene');
await drain();
const z = plazaZone('smile_spot');
await setPlayer('PlazaScene', z.x, z.y + 8);
await tap('KeyE');
await sleep(600); await drain();
check('T14 开始(t14Active', await dget('t14Active'));
await sleep(10000);
const zoom1 = await ev(() => window.__game.scene.getScene('PlazaScene').cameras.main.zoom);
check('#13 镜头缓慢拉近', zoom1 > 1.02, `zoom=${zoom1}`);
const staring = await ev(() => window.__game.scene.getScene('PlazaScene').actors.some((a) => a.stareAtPlayer));
check('#13 全村注视', staring);
await sleep(35000); // t≈45s:渐隐已开始
const g1 = await ev(() => window.__audio.master.gain.value);
await sleep(6000); // t≈51s
const g2 = await ev(() => window.__audio.master.gain.value);
check('#13 第 4060 秒声音渐隐至零', g2 < g1 && g2 < 0.3, `gain ${g1}${g2}`);
await shot('13_t14_smile');
await page.waitForFunction(() => window.__director.tasksDone.has('T14'), { timeout: 40000 });
await drain();
check('T14 完成 +15', true);
check('#13 T14 后脚本雨开始', await dget('rainActive'));
}
// #14 站雨中 ≥10 秒计悲伤(先把幸福调到 50,避开满格溢出池机制)
{
await dset('setHappiness(50)');
const h0 = await dget('happiness'), aw0 = await dget('awareness');
await drain();
await setPlayer('PlazaScene', 240, 260);
await sleep(11000);
check('#14 雨中站 10 秒计悲伤', (await dget('happiness')) <= h0 - 5 && (await dget('awareness')) >= aw0 + 3, `h ${h0}${await dget('happiness')} aw ${aw0}${await dget('awareness')}`);
// NPC 台词对雨零反应:与阿福对话正常弹出
const t = await interactAt('plaza', 'npc', 'afu');
check('#14 雨中 NPC 台词零反应(正常对话)', t.length > 0);
await shot('14_rain');
}
await sleepNight();
check('D6→D7', (await dget('day')) === 7);
}
// --- Day 7E3 真结局路线)---
console.log('-- Day 7 --');
{
const b = await boardAnnounce(7);
check('D7 公告逐字显示', b.found);
const aw = await dget('awareness'), nc = (await darr('clues')).length;
check('E3 条件就绪(觉知≥70 线索≥6)', aw >= 70 && nc >= 6, `aw=${aw} clues=${nc}`);
// 走向雾墙
await startScene('FogScene');
await drain();
const fw = maps.fog.find((o) => o.name === 'interact' && o.props.id === 'fog_wall');
await setPlayer('FogScene', fw.x, fw.y + 8);
await tap('KeyE');
await sleep(600); await drain();
await waitActive('WhiteScene', 15000);
check('E3:雾墙裂开成白色走廊', true);
await shot('15_white_corridor');
// 走进病房
await setPlayer('WhiteScene', 72, 144);
await waitActive('EndingScene', 15000);
await sleep(2500);
await shot('15_e3_record');
const e3texts = await ev(() => window.__game.scene.getScene('EndingScene').children.list.filter((o) => o.type === 'Text').map((o) => o.text).join('\n'));
check('#15 E3 病历:蜜糖谷情绪疗养中心', e3texts.includes('蜜糖谷情绪疗养中心'));
check('#15 E3 病历:姓名=小满', e3texts.includes('小满'));
check('#15 E3 病历:PT-07', e3texts.includes('PT-07'));
check('#15 E3 病历:真实游玩时长', /\d{1,2}:\d{2}/.test(e3texts), e3texts.slice(0, 120));
await sleep(5500); // 等到最后一帧
const hasReal = await ev(() => window.__game.scene.getScene('EndingScene').children.list.some((o) => o.texture && o.texture.key === 'portrait_player_real'));
check('#15 E3 最后一帧:真实的脸', hasReal);
await shot('15_e3_realface');
}
console.log('== 阶段 A(七日流程)完成 ==');
// ================= 阶段 B:重开页面做 Debug/矩阵/悲伤/庆典/结局 =================
console.log('== 阶段 B ==');
await page.goto(BASE, { waitUntil: 'networkidle2' });
await page.waitForFunction('window.__game && window.__director', { timeout: 15000 });
await waitActive('TitleScene', 20000);
await ev(() => { document.querySelector('#game input[type="text"]').closest('div').querySelector('button').click(); });
await waitActive('HouseScene', 15000); await sleep(400);
// ---------- #13(Debug 面板,PRD §13) ----------
console.log('== Debug 面板 ==');
{
const hidden = await ev(() => { const els = [...document.querySelectorAll('#game div')]; const p = els.find((e) => e.textContent.includes('DEBUG')); return p ? getComputedStyle(p).display === 'none' : null; });
check('Debug 默认隐藏无痕迹', hidden === true);
await tap('Backquote'); await sleep(200);
const shown = await ev(() => { const els = [...document.querySelectorAll('#game div')]; const p = els.find((e) => e.textContent.includes('DEBUG')); return p ? getComputedStyle(p).display !== 'none' : false; });
check('反引号打开 Debug 面板', shown);
// 设幸福 66(数值行靠「设」按钮生效)
await ev(() => { const els = [...document.querySelectorAll('#game div')]; const p = els.find((e) => e.textContent.includes('DEBUG')); const input = p.querySelector('input'); input.value = '66'; input.closest('div').querySelector('button').click(); });
await sleep(200);
check('Debug 设幸福 66', (await dget('happiness')) === 66);
// 传送 bakery
await ev(() => { const els = [...document.querySelectorAll('#game div')]; const p = els.find((e) => e.textContent.includes('DEBUG')); const sel = p.querySelector('select'); sel.value = 'BakeryScene'; [...p.querySelectorAll('button')].find((b) => b.textContent === '传送').click(); });
await waitActive('BakeryScene', 8000);
check('Debug 传送到面包房', true);
// 置齐线索
await ev(() => { const els = [...document.querySelectorAll('#game div')]; const p = els.find((e) => e.textContent.includes('DEBUG')); [...p.querySelectorAll('button')].find((b) => b.textContent.includes('线索')).click(); });
check('Debug 置齐全部线索', ((await darr('clues')).length) === 9);
await tap('Backquote'); await sleep(150);
}
// ---------- #9 侵蚀矩阵 ----------
console.log('== #9 侵蚀矩阵 ==');
{
await startScene('PlazaScene');
const cases = [
[10, 0, 0.9, 1.0], [40, 1, 1.0, 1.0], [70, 2, 1.15, 0.97], [90, 3, 1.3, 0.92], [100, 4, 1.4, 0.84],
];
for (const [h, st, sat, rate] of cases) {
await dset(`setHappiness(${h})`); await sleep(400);
const s = await dget('stage');
const p = await dget('params');
check(`幸福${h}→S${st}`, s === st, `stage=${s}`);
check(`S${st} 饱和度${sat}/速率${rate}`, Math.abs(p.saturation - sat) < 1e-6 && Math.abs(p.playbackRate - rate) < 1e-6, JSON.stringify(p));
await shot(`09_stage_S${st}_h${h}`);
}
// 低通与鸟鸣
const p3 = await dget('params'); // S4
check('S4 低通 4kHz + 鸟鸣静音', p3.lowpass === 4000 && p3.birdMuted === true);
await dset('setHappiness(90)'); await sleep(300);
const p2 = await dget('params');
check('S3 低通 8kHz', p2.lowpass === 8000);
// NPC 表情档:h=80 时 咪咪(-10→S3 flat) 与 阿福(0→S2 smile) 不同;圆圆(+15→S2) 与阈值一致
await dset('setHappiness(80)'); await sleep(500);
await startScene('ShopScene');
const mimiFrame = await ev(() => { const a = window.__game.scene.getScene('ShopScene').actors.find((x) => x.def.id === 'mimi'); return a ? a.sprite.frame.name : -1; });
await startScene('PlazaScene');
const afuFrame = await ev(() => { const a = window.__game.scene.getScene('PlazaScene').actors.find((x) => x.def.id === 'afu'); return a ? a.sprite.frame.name : -1; });
check('h=80 咪咪比阿福先崩(flat≠smile', String(mimiFrame) !== String(afuFrame), `mimi=${mimiFrame} afu=${afuFrame}`);
// S4 花瓣过亮 + 黑籽(视觉)+ 双子鸟张嘴动画仍在
await dset('setHappiness(100)'); await sleep(400);
const birdAnim = await ev(() => { const a = window.__game.scene.getScene('PlazaScene').actors.find((x) => x.def.id === 'bird'); return a ? (a.sprite.anims.isPlaying || a.sprite.anims.currentAnim !== null) : false; });
check('S4 双子鸟动画仍在(鸟鸣静音)', birdAnim);
await shot('09_hud_s4', { x: 0, y: 0, w: 48 * 3, h: 48 * 3 });
await dset('setHappiness(10)'); await sleep(300);
await shot('09_hud_s0', { x: 0, y: 0, w: 48 * 3, h: 48 * 3 });
await dset('setHappiness(70)'); await sleep(300);
await shot('09_hud_s2', { x: 0, y: 0, w: 48 * 3, h: 48 * 3 });
}
// ---------- #4 向日葵表盘 ----------
console.log('== #4 向日葵表盘 ==');
{
const pc = (h) => ev((hh) => window.__petalCount(hh), h);
check('#4 h=10 → 02 瓣', (await pc(10)) >= 0 && (await pc(10)) <= 2, `petals=${await pc(10)}`);
check('#4 h=40 → 36 瓣', (await pc(40)) >= 3 && (await pc(40)) <= 6, `petals=${await pc(40)}`);
check('#4 h=70 → 79 瓣', (await pc(70)) >= 7 && (await pc(70)) <= 9, `petals=${await pc(70)}`);
check('#4 h=90 → 1012 瓣', (await pc(90)) >= 10 && (await pc(90)) <= 12, `petals=${await pc(90)}`);
check('#4 h=100 → 12 瓣', (await pc(100)) === 12);
await dset('setHappiness(100)'); await sleep(300);
check('#4 满格花心黑籽', await ev(() => window.__game.scene.getScene('UIScene').seedsShown));
await dset('setHappiness(50)'); await sleep(300);
check('#4 未满格无黑籽', !(await ev(() => window.__game.scene.getScene('UIScene').seedsShown)));
// 表盘区域(左上 120×120)不得出现任何 Text(无数字)
const textsNearDial = await ev(() => window.__game.scene.getScene('UIScene').children.list
.filter((o) => o.type === 'Text' && o.visible && o.x < 120 && o.y < 120).length);
check('#4 表盘无数字显示', textsNearDial === 0, `文本对象 ${textsNearDial} 个`);
}
// ---------- #16 时长设计(每日 5 分钟兜底,7 天 ≤35 分钟) ----------
console.log('== #16 时长 ==');
{
const srcOk = (() => { const m = /DAY_LENGTH_MS = (\d+) \* 60 \* 1000/.exec(read('src/core/CorruptionDirector.ts')); return m ? Number(m[1]) : 0; })();
check('#16 每日兜底 5 分钟(7 天 ≤35 分钟)', srcOk === 5, `DAY_LENGTH_MS=${srcOk}min`);
await dset('dayElapsedMs = 5 * 60 * 1000 + 1');
check('#16 计时到点可睡觉(黄昏兜底)', await dget('canSleep'));
await dset('dayElapsedMs = 0');
}
// ---------- #10 错字替换(运行时) ----------
console.log('== #10 运行时文本腐蚀 ==');
{
await dset('setHappiness(100)'); await sleep(300);
await startScene('ShopScene');
let found = '';
const corruptedChars = PAIRS.map((p) => p[1]);
for (let i = 0; i < 8 && !found; i++) {
const texts = await interactAt('shop', 'npc', 'mimi');
for (const t of texts) {
if (corruptedChars.some((c) => t.includes(c)) || /[!?。~]{3}$/.test(t) || /(.)\1{2}$/.test(t)) { found = t; break; }
}
}
check('#10 S4 咪咪台词可见腐蚀/标点×3', found !== '', found || '8 次采样未见');
}
// ---------- #7 悲伤行为:发呆 / 纠缠 / 否定 ----------
console.log('== #7 悲伤行为 ==');
{
await dset('setHappiness(60)'); await dset('setAwareness(0)');
await startScene('PlazaScene');
await drain();
// 发呆 10 秒(远离任务点)
await setPlayer('PlazaScene', 60, 280);
const glanceP = ev(() => new Promise((r) => window.__director.once('npcGlance', () => r(true))));
await sleep(11000);
check('#7 发呆:幸福 5', (await dget('happiness')) === 55, String(await dget('happiness')));
check('#7 发呆:觉知 +3', (await dget('awareness')) >= 3, String(await dget('awareness')));
check('#7 最近 NPC 转头', await Promise.race([glanceP, sleep(1500).then(() => 'timeout')]) === true);
// 否定选项
const aw1 = await dget('awareness'), h1 = await dget('happiness');
await interactAt('plaza', 'npc', 'afu', 'Digit3');
check('#7 否定选项:觉知+2 且计悲伤', (await dget('awareness')) >= aw1 + 5 && (await dget('happiness')) <= h1 - 5, `aw ${aw1}${await dget('awareness')}`);
// 纠缠:同一天对同一 NPC 对话 ≥5 次
const aw2 = await dget('awareness');
for (let i = 0; i < 5; i++) await interactAt('plaza', 'npc', 'dabao', 'Digit1');
check('#7 纠缠 ≥5 次计悲伤(觉知+3', (await dget('awareness')) >= aw2 + 3, `aw ${aw2}${await dget('awareness')}`);
}
// ---------- #8 欢乐庆典 ----------
console.log('== #8 欢乐庆典 ==');
{
await dset('setHappiness(20)');
await startScene('PlazaScene');
await drain();
await ev(() => { for (let i = 0; i < 260; i++) window.__director.tick(500); }); // 模拟 130 秒
await sleep(400);
check('#8 幸福<30 持续 2 分钟触发庆典', await dget('celebrationActive'));
await drain();
// 逃离:走门离开 → 计数
const esc0 = await dget('escapedCelebrations');
const door = maps.plaza.find((o) => o.name === 'door' && o.props.target === 'house');
await setPlayer('PlazaScene', door.x, door.y + 18);
await page.keyboard.down('KeyW'); await sleep(800); await page.keyboard.up('KeyW');
await sleep(700); await drain();
check('#8 从场景边缘走离逃脱且计数', (await dget('escapedCelebrations')) > esc0, `esc ${esc0}${await dget('escapedCelebrations')}`);
// 再次触发并不逃离 → 拉回 50
await dset('setHappiness(20)');
await startScene('PlazaScene'); await drain();
await ev(() => { for (let i = 0; i < 260; i++) window.__director.tick(500); });
await sleep(300); await drain();
await ev(() => window.__director.completeCelebration());
await drain();
check('#8 未逃离则拉回 50', (await dget('happiness')) === 50, String(await dget('happiness')));
}
// ---------- #15 强制结局 E1/E2/E4Debug 面板按钮) ----------
console.log('== #15 强制结局 ==');
async function forceEndingViaPanel(e) {
await tap('Backquote'); await sleep(150);
await ev((e) => { const els = [...document.querySelectorAll('#game div')]; const p = els.find((x) => x.textContent.includes('DEBUG')); [...p.querySelectorAll('button')].find((b) => b.textContent === `强制 ${e}`).click(); }, e);
await waitActive('EndingScene', 8000);
await tap('Backquote'); await sleep(100);
}
{
await forceEndingViaPanel('E1');
await sleep(7200);
const t1 = await ev(() => window.__game.scene.getScene('EndingScene').children.list.filter((o) => o.type === 'Text').map((o) => o.text).join('\n'));
await shot('15_e1');
check('E1 字幕逐字', t1.includes('从此,每一天都很幸福。') && t1.includes('每一天。'), t1.slice(0, 80));
const smile = await ev(() => window.__game.scene.getScene('EndingScene').children.list.some((o) => o.texture && o.texture.key === 'portrait_player_smile'));
check('E1 主角头像仍是微笑', smile);
await forceEndingViaPanel('E2');
await sleep(5200);
const t2 = await ev(() => window.__game.scene.getScene('EndingScene').children.list.filter((o) => o.type === 'Text').map((o) => o.text).join('\n'));
await shot('15_e2');
check('E2 字幕逐字', t2.includes('疗程重新开始。'), t2.slice(0, 80));
await forceEndingViaPanel('E4');
await sleep(6500);
const t4 = await ev(() => window.__game.scene.getScene('EndingScene').children.list.filter((o) => o.type === 'Text').map((o) => o.text).join('\n'));
await shot('15_e4');
check('E4 字幕逐字', t4.includes('系统检测到疗程无效。'), t4.slice(0, 80));
// E3 也可由 Debug 强制触发
await forceEndingViaPanel('E3');
await sleep(2500);
const t3 = await ev(() => window.__game.scene.getScene('EndingScene').children.list.filter((o) => o.type === 'Text').map((o) => o.text).join('\n'));
check('Debug 强制 E3 病历含机构/编号', t3.includes('蜜糖谷情绪疗养中心') && t3.includes('PT-07'), t3.slice(0, 80));
// 回到标题按钮(等待其出现)
await page.waitForFunction(() => window.__game.scene.getScene('EndingScene').children.list.some((o) => o.type === 'Text' && o.text.includes('回到标题')), { timeout: 25000 });
await ev(() => { const b = window.__game.scene.getScene('EndingScene').children.list.find((o) => o.type === 'Text' && o.text.includes('回到标题')); b.emit('pointerdown'); });
await waitActive('TitleScene', 8000);
check('结局后「回到标题」可用', true);
}
// ---------- #17 音频 ----------
console.log('== #17 音频 ==');
{
const a = await ev(() => {
const e = window.__audio;
return e && e.ctx ? { state: e.ctx.state, rate: e.rate } : null;
});
check('#17 AudioContext 运行中(程序合成 BGM', !!a && a.state === 'running', JSON.stringify(a));
await dset('setHappiness(100)'); await sleep(1200);
const s4 = await ev(() => ({ rate: window.__audio.rate, lp: window.__audio.lp.frequency.value, bird: window.__audio.birdBus.gain.value }));
check('#17 S4:速率 0.84 / 低通→4kHz / 鸟鸣静音', Math.abs(s4.rate - 0.84) < 1e-6 && s4.lp < 8000 && s4.bird < 0.1, JSON.stringify(s4));
await dset('setHappiness(30)'); await sleep(1200);
const s1 = await ev(() => ({ rate: window.__audio.rate, lp: window.__audio.lp.frequency.value, bird: window.__audio.birdBus.gain.value }));
check('#17 S1:原速原调、鸟鸣恢复', Math.abs(s1.rate - 1) < 1e-6 && s1.lp > 15000 && s1.bird > 0.3, JSON.stringify(s1));
}
// ---------- #18 缩放与零报错 ----------
console.log('== #18 缩放 ==');
{
await page.setViewport({ width: 1000, height: 700 }); await sleep(300);
const s1 = await ev(() => ({ w: document.querySelector('canvas').style.width, h: document.querySelector('canvas').style.height }));
check('1000×700 → 整数倍 3×(960×540', s1.w === '960px' && s1.h === '540px', JSON.stringify(s1));
await page.setViewport({ width: 500, height: 500 }); await sleep(300);
const s2 = await ev(() => ({ w: document.querySelector('canvas').style.width, h: document.querySelector('canvas').style.height }));
check('500×500 → 整数倍 1×(320×180', s2.w === '320px' && s2.h === '180px', JSON.stringify(s2));
await page.setViewport({ width: 960, height: 560 }); await sleep(300);
}
} catch (e) {
console.log('!! 脚本异常中断:', e.message);
console.log('已收集控制台错误:', consoleErrors.slice(0, 10).join(' | ') || '(无)');
fail++; failures.push('脚本异常中断: ' + e.message);
}
// ---------- 汇总 ----------
console.log('== 汇总 ==');
check('无外部网络请求', extReqs.length === 0, extReqs.slice(0, 3).join(','));
check('全程控制台零报错', consoleErrors.length === 0, consoleErrors.slice(0, 5).join(' | '));
console.log(`\n通过 ${pass} / ${pass + fail}`);
if (failures.length) { console.log('失败项:'); failures.forEach((f) => console.log(' - ' + f)); }
await browser.close();
process.exit(fail ? 1 : 0);