// 七日全流程自动化验收:任务/线索/悲伤行为/黄昏/梦境/结局 + 控制台零报错 // 运行:node tools/e2e_flow.mjs import { chromium } from "playwright"; import { createServer } from "vite"; import { mkdirSync } from "fs"; const SHOT_DIR = process.env.SHOT_DIR || "/tmp/mtc-flow"; mkdirSync(SHOT_DIR, { recursive: true }); const server = await createServer({ server: { port: 5198 } }); await server.listen(); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); const errors = []; page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); page.on("pageerror", (e) => errors.push("PAGEERROR: " + e.message)); const sleep = (ms) => page.waitForTimeout(ms); // 无头环境下 press() 的 down/up 同帧到达会吞掉 JustDown,改为跨帧按键 const pressE = async () => { await page.keyboard.down("e"); await sleep(90); await page.keyboard.up("e"); }; const shot = (n) => page.screenshot({ path: `${SHOT_DIR}/${n}.png` }); const D = (expr) => page.evaluate(`(() => { const d = window.__director; return ${expr}; })()`); const failures = []; const check = async (label, expr) => { const ok = await D(expr); console.log(`${ok ? "PASS" : "FAIL"} ${label}`); if (!ok) { failures.push(label); const diag = await page.evaluate(() => { const d = window.__director; const sc = window.__game.scene.getScenes(true).find((s) => s.player); return `day=${d.day} scene=${d.currentScene} wipes=${d.wipes} clues=[${[...d.clues]}] cutscene=${d.cutscene} dialog=${window.__game.scene.getScene("ui").dialogOpen} player=${sc ? Math.round(sc.player.x) + "," + Math.round(sc.player.y) : "-"} trans=${sc ? sc.transitioning : "-"}`; }); console.log(" diag:", diag); } }; // 把玩家瞬移到当前场景某像素点 const tp = (x, y) => page.evaluate(([x, y]) => { const g = window.__game; const sc = g.scene.getScenes(true).find((s) => s.player); sc.player.setPosition(x, y); sc.player.body.reset(x, y); }, [x, y]); const curScene = () => page.evaluate(() => window.__director.currentScene); // 连按 E 直到对话结束 const talk = async (times = 14) => { for (let i = 0; i < times; i++) { await pressE(); await sleep(160); const open = await page.evaluate(() => window.__game.scene.getScene("ui").dialogOpen); if (!open) break; } }; const interactAt = async (x, y, presses = 14) => { await tp(x, y); await sleep(350); await pressE(); await sleep(300); await talk(presses); await sleep(200); }; // 走进门(瞬移到门内并等场景切换) const enterDoor = async (x, y, expectScene) => { await tp(x, y); for (let i = 0; i < 30; i++) { await sleep(200); if ((await curScene()) === expectScene) break; } const ok = (await curScene()) === expectScene; console.log(`${ok ? "PASS" : "FAIL"} 进入 ${expectScene}`); if (!ok) { failures.push("进入 " + expectScene); const diag = await page.evaluate(() => { const d = window.__director; const sc = window.__game.scene.getScenes(true).find((s) => s.player); return `scene=${d.currentScene} cutscene=${d.cutscene} dialog=${window.__game.scene.getScene("ui").dialogOpen} player=${sc ? Math.round(sc.player.x) + "," + Math.round(sc.player.y) : "-"} trans=${sc ? sc.transitioning : "-"}`; }); console.log(" diag:", diag); } await sleep(400); }; const sleepToNextDay = async () => { await enterDoor(72, 68, "house"); // 回小屋(从广场小屋门) await interactAt(30, 70, 6); // 床 await sleep(14500); // 梦境过场 await sleep(1200); }; await page.goto("http://localhost:5198/"); await sleep(2500); await page.fill("#name-input", "验收员"); await page.press("#name-input", "Enter"); await sleep(1800); // ===== Day 1 ===== await check("D1 开局幸福 30", "d.day===1 && d.happiness===30 && d.awareness===0"); await interactAt(30, 40, 4); // 日历(D1 无线索) await check("D1 日历尚无 C1", "!d.clues.has('C1')"); await enterDoor(88, 122, "plaza"); // 出小屋 await interactAt(188, 126); // 公告板 await check("D1 公告已读", "d.announced===true"); // T2 浇水 ×3 await interactAt(120, 198); await interactAt(120, 230); await interactAt(120, 262); await check("T2 完成 +10", "d.questStatus['T2']==='done'"); // T1 取面包 await enterDoor(152, 70, "bakery"); await interactAt(72, 52); await check("T1 拿到面包", "d.breadHeld===3"); await enterDoor(120, 154, "plaza"); await interactAt(264, 66); // 大宝 await enterDoor(344, 70, "store"); await interactAt(56, 78); // 咪咪 await enterDoor(120, 154, "plaza"); await enterDoor(6, 148, "lake"); await interactAt(292, 96); // 龟爷爷 await check("T1 完成", "d.questStatus['T1']==='done'"); await check("T3 完成(问好×3)", "d.questStatus['T3']==='done'"); await check("D1 黄昏已触发", "d.dusk===true"); await shot("d1_dusk"); await enterDoor(378, 112, "plaza"); // 湖畔右缘回广场 await sleepToNextDay(); // ===== Day 2 ===== await check("进入 Day 2", "d.day===2"); await interactAt(30, 40, 6); // 日历 C1 await check("C1 获得(觉知+8)", "d.clues.has('C1') && d.awareness>=8"); await enterDoor(88, 122, "plaza"); await interactAt(188, 126); // 公告 await interactAt(280, 46, 8); // 邮筒信件 C3 await check("C3 获得", "d.clues.has('C3')"); await interactAt(264, 66); // 大宝取信 await check("T4 拿到信", "d.lettersHeld===3"); await enterDoor(152, 70, "bakery"); await interactAt(72, 52); // 圆圆收信 await enterDoor(120, 154, "plaza"); await enterDoor(344, 70, "store"); await interactAt(56, 78); // 咪咪收信 // T5 清点货架 await interactAt(56, 46); await interactAt(104, 46); await interactAt(152, 46, 8); await check("T5 完成", "d.questStatus['T5']==='done'"); await interactAt(198, 92, 8); // C5 货架 await check("C5 获得", "d.clues.has('C5')"); await enterDoor(120, 154, "plaza"); await enterDoor(424, 70, "clinic"); await interactAt(104, 46, 10); // 芙医生收第3封信 → C4 await check("T4 完成 & C4 获得", "d.questStatus['T4']==='done' && d.clues.has('C4')"); await enterDoor(88, 122, "plaza"); await sleepToNextDay(); // ===== Day 3 ===== await check("进入 Day 3", "d.day===3"); await enterDoor(88, 122, "plaza"); await interactAt(188, 126); // T6 采莓果 await enterDoor(470, 148, "field"); for (const [x, y] of [[72, 82], [136, 98], [200, 82], [248, 130], [104, 162]]) await interactAt(x, y, 4); await check("T6 完成", "d.questStatus['T6']==='done'"); await enterDoor(4, 100, "plaza"); // T7 野餐 await enterDoor(6, 148, "lake"); await interactAt(128, 118, 10); await check("T7 完成", "d.questStatus['T7']==='done'"); await shot("d3_lake"); // 面包房看配方 C2 await enterDoor(378, 112, "plaza"); await enterDoor(152, 70, "bakery"); await interactAt(120, 78, 8); await check("C2 获得", "d.clues.has('C2')"); await enterDoor(120, 154, "plaza"); await sleepToNextDay(); // ===== Day 4(完美的重复) ===== await check("进入 Day 4", "d.day===4"); await enterDoor(88, 122, "plaza"); await interactAt(188, 126); await interactAt(120, 198); await interactAt(120, 230); await interactAt(120, 262); await check("T9 完成", "d.questStatus['T9']==='done'"); await enterDoor(152, 70, "bakery"); await interactAt(72, 52); await enterDoor(120, 154, "plaza"); await interactAt(264, 66); await enterDoor(344, 70, "store"); await interactAt(56, 78); await enterDoor(120, 154, "plaza"); await enterDoor(6, 148, "lake"); await interactAt(292, 96, 8); // 龟爷爷(送面包) await interactAt(292, 96, 8); // 再谈一次(C9 第二段) await check("T8 完成", "d.questStatus['T8']==='done'"); await check("T10 完成", "d.questStatus['T10']==='done'"); await check("C9 链已记 D2/D4", "d.guiChain.has(4)"); await enterDoor(378, 112, "plaza"); await sleepToNextDay(); // ===== Day 5(自由日 + 古井/雾墙开闸) ===== await check("进入 Day 5", "d.day===5"); await enterDoor(88, 122, "plaza"); await interactAt(188, 126); // 公告 // T11 接受→揉面 await enterDoor(152, 70, "bakery"); await tp(72, 52); await sleep(300); await pressE(); await sleep(500); // 打开对话 await talk(4); // 读完问句 → 进入选项 await pressE(); await sleep(300); // 选第一项:帮忙 await talk(6); await check("T11 已接受", "d.t11Accepted===true"); await interactAt(168, 102, 8); // 揉面台 await check("T11 完成", "d.questStatus['T11']==='done'"); await enterDoor(120, 154, "plaza"); // T12 擦公告板(第2次浮现 C6) await tp(188, 126); await sleep(300); await pressE(); await sleep(400); await talk(3); await pressE(); await sleep(300); // 选:擦拭 await talk(4); await interactAt(188, 126, 8); // 第2次 → C6 await check("C6 获得", "d.clues.has('C6')"); await interactAt(188, 126, 8); // 第3次 → 完成 await check("T12 完成", "d.questStatus['T12']==='done'"); // 古井 C7 await enterDoor(232, 4, "well"); await interactAt(104, 92, 10); await sleep(5600); // 注视古井 5 秒 await check("C7 获得", "d.clues.has('C7')"); await check("注视古井 +3 觉知", "d.stared.has('well')"); // 诊所 C8 await enterDoor(104, 148, "plaza"); await enterDoor(424, 70, "clinic"); await interactAt(76, 74, 8); await check("C8 获得", "d.clues.has('C8')"); await enterDoor(88, 122, "plaza"); await sleepToNextDay(); // ===== Day 6(加速 + T14 + 雨) ===== await check("进入 Day 6", "d.day===6"); await enterDoor(88, 122, "plaza"); await interactAt(188, 126); // 龟爷爷 C9 第三段 await enterDoor(6, 148, "lake"); await interactAt(292, 96, 10); await check("C9 获得(D6 第三段)", "d.clues.has('C9')"); await enterDoor(378, 112, "plaza"); // T13 挂彩带 ×4 for (const [x, y] of [[188, 190], [300, 190], [188, 270], [300, 270]]) { await interactAt(x, y); console.log(" ribbons:", await D("[...d.ribbons].join('|')")); } await check("T13 完成", "d.questStatus['T13']==='done'"); // T14 保持微笑 60 秒(把计时快进以免真等 60s) await interactAt(240, 210, 4); // 启动 await sleep(1500); await page.evaluate(() => { const sc = window.__game.scene.getScenes(true).find((s) => s.player); sc.t14Start -= 61000; // 快进 }); await sleep(1500); await talk(6); await check("T14 完成", "d.questStatus['T14']==='done'"); await check("D6 黄昏(脚本雨)", "d.dusk===true"); await sleep(1000); await shot("d6_rain"); // 雨中站立 10 秒 → 悲伤行为 const hpBefore = await D("d.happiness"); await page.evaluate(() => { const sc = window.__game.scene.getScenes(true).find((s) => s.player); sc.rainStillAt -= 11000; }); await sleep(800); const hpAfter = await D("d.happiness"); console.log(`${hpAfter <= hpBefore ? "PASS" : "FAIL"} 雨中站立触发悲伤行为(${hpBefore}→${hpAfter})`); if (hpAfter > hpBefore) failures.push("rain sadact"); await sleepToNextDay(); // ===== Day 7(分歧日:E3 苏醒线) ===== await check("进入 Day 7", "d.day===7"); await check("线索 ≥6", "d.clues.size>=6"); await D("d.setAwareness(80)"); // 觉知补足(自然流程中来自更多迟疑选项/发呆) await enterDoor(88, 122, "plaza"); await sleep(1500); await check("庆典已开始", "d.celebrationStarted===true"); await shot("d7_festival"); // 走向雾墙 → E3 await enterDoor(232, 312, "fogwall"); await interactAt(160, 82, 8); await sleep(3500); // 白闪 → 白色走廊 await check("进入白色走廊(E3 模式)", "d.e3Mode===true && d.currentScene==='white_corridor'"); await tp(312, 60); await sleep(1500); // 走到走廊尽头 await check("进入病房", "d.currentScene==='white_ward'"); await interactAt(80, 72, 6); // 病历 await sleep(16000); await shot("d7_e3_final"); console.log("CONSOLE ERRORS:", errors.length); errors.slice(0, 20).forEach((e) => console.log(" -", e)); console.log("FAILURES:", failures.length); await browser.close(); await server.close(); process.exit(errors.length || failures.length ? 1 : 0);