// 验收测试：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);
