// 验收测试:PRD §14 共 20 条。用法:先 npm run build && npm run preview,再 node tools/acceptance.mjs // 需要本机 Chrome(puppeteer-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 第 40–60 秒声音渐隐至零', 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 7(E3 真结局路线)--- 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 → 0–2 瓣', (await pc(10)) >= 0 && (await pc(10)) <= 2, `petals=${await pc(10)}`); check('#4 h=40 → 3–6 瓣', (await pc(40)) >= 3 && (await pc(40)) <= 6, `petals=${await pc(40)}`); check('#4 h=70 → 7–9 瓣', (await pc(70)) >= 7 && (await pc(70)) <= 9, `petals=${await pc(70)}`); check('#4 h=90 → 10–12 瓣', (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/E4(Debug 面板按钮) ---------- 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);