Honey Village: six model builds + hub frontend, Dockerized for Dokploy

This commit is contained in:
2026-07-15 10:50:12 -04:00
commit 67253bded5
523 changed files with 58879 additions and 0 deletions
+767
View File
@@ -0,0 +1,767 @@
// 验收测试: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);