// 饥荒专项端到端自检(开发用):登录 → 进入饥荒 → 单人开局 → // 通过 window.__starve 后门验证:世界生成/三围/合成科技/查理/季节体温/巨鹿/小地图/存档 // 追加:宽屏布局无滚动条断言 + Mod 体系全链路(8 个内置 mod 勾选开局逐一验证) // 运行:npm run electron:dist; npx electron electron/e2e-starve.cjs const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') const fs = require('fs') const SERVER = process.env.NLG_SERVER || 'http://127.0.0.1:8080' const OUT_DIR = process.env.NLG_E2E_OUT || path.join(process.cwd(), 'e2e-out') const pageErrors = [] ipcMain.on('nlg:get-server-base', (e) => { e.returnValue = SERVER }) ipcMain.handle('nlg:set-server-base', () => ({ ok: true })) const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) let failures = 0 const check = (cond, msg) => { if (cond) console.log(' PASS', msg) else { failures++; console.error(' FAIL', msg) } } async function main() { fs.mkdirSync(OUT_DIR, { recursive: true }) const win = new BrowserWindow({ width: 1360, height: 860, show: true, backgroundColor: '#0f1220', webPreferences: { preload: path.join(__dirname, 'preload.cjs') }, }) win.webContents.on('console-message', (e) => { if (e?.level === 'error' && e?.message) pageErrors.push(e.message) }) const js = (code) => win.webContents.executeJavaScript(code, true) const shot = async (name) => { for (let i = 0; i < 3; i++) { try { const img = await win.webContents.capturePage() fs.writeFileSync(path.join(OUT_DIR, `${name}.png`), img.toPNG()) console.log('[e2e] screenshot:', name) return } catch { await sleep(600) } } } const waitFor = async (cond, timeoutMs, desc) => { const t0 = Date.now() while (Date.now() - t0 < timeoutMs) { try { if (await js(cond)) return true } catch {} await sleep(250) } throw new Error(`等待超时:${desc}`) } const st = () => js('window.__starve ? JSON.stringify(window.__starve.state()) : "null"').then((s) => JSON.parse(s)) const distIndex = path.join(__dirname, '../dist/index.html') if (!fs.existsSync(distIndex)) throw new Error('缺少 dist,请先 npm run electron:dist') await win.loadFile(distIndex) // 登录 await js('localStorage.clear(); true') await win.webContents.reload() await waitFor(`!!document.querySelector('input[placeholder^="用户名"]')`, 10000, '登录页') await js(`(() => { const u = document.querySelector('input[placeholder^="用户名"]') u.value = 'xiaoming'; u.dispatchEvent(new Event('input', { bubbles: true })) const p = document.querySelector('input[type="password"]') p.value = '123456'; p.dispatchEvent(new Event('input', { bubbles: true })) document.querySelector('button.submit').click() return true })()`) await waitFor(`!!localStorage.getItem('token') && location.hash === '#/'`, 10000, '登录成功') console.log('[e2e] 登录成功') // 确保饥荒可玩(必要时购买) const gameName = await js(` fetch(window.desktop.serverBase + '/api/games', { headers: { Authorization: 'Bearer ' + localStorage.getItem('token') } }) .then((r) => r.json()).then((j) => ((j.data || []).find((g) => g.code === 'starve') || {}).name || '') `) if (!gameName) throw new Error('饥荒游戏不存在') await sleep(1200) const cardBtn = `(() => { const card = [...document.querySelectorAll('.game-card')].find((c) => (c.querySelector('.name')?.textContent || '').trim() === ${JSON.stringify(gameName)}) const btn = card && card.querySelector('.actions button:last-child') return btn ? btn.textContent.trim() : '' })()` if ((await js(cardBtn)) === '去购买') { await js(`(() => { const card = [...document.querySelectorAll('.game-card')].find((c) => (c.querySelector('.name')?.textContent || '').trim() === ${JSON.stringify(gameName)}) card.querySelector('.actions button:last-child').click(); return true })()`) await waitFor(`!!document.querySelector('.buy-modal')`, 4000, '购买弹窗') await js(`document.querySelector('.buy-modal .bm-actions .btn:last-child').click(); true`) await waitFor(`${cardBtn} === '开始游戏'`, 8000, '购买完成') console.log('[e2e] 已购买饥荒') } // 进入饥荒 → 单人冒险 await js(`location.hash = '#/play/starve'; true`) await waitFor(`!!document.querySelector('.overlay .mode-card')`, 10000, '模式选择面板') await js(`document.querySelector('.overlay .mode-card').click(); true`) // 单人冒险 await sleep(400) // 有旧档则弃档重开,保证确定性 await waitFor(`!![...document.querySelectorAll('.overlay button')].find((b) => b.textContent.includes('新的冒险') || b.textContent.includes('放弃存档重新开始'))`, 6000, '单人面板') await js(`(() => { const btns = [...document.querySelectorAll('.overlay button')] const btn = btns.find((b) => b.textContent.includes('放弃存档重新开始')) || btns.find((b) => b.textContent.includes('新的冒险')) btn.click() return true })()`) await waitFor(`!!window.__starve && window.__starve.state().ents > 50`, 10000, '游戏开局 + 世界生成') console.log('[e2e] 饥荒已开局') await sleep(2500) // 1) 开局状态 let s = await st() console.log('[e2e] 开局状态:', JSON.stringify(s)) check(s.hp === 150 && s.hunger <= 150 && s.san <= 200, `三围官方值 (${s.hp}/${Math.round(s.hunger)}/${Math.round(s.san)})`) check(s.season === 'autumn' && s.day === 1, `秋季第 1 天开局`) check(!!s.biome, `出生在群系 ${s.biome}`) check(s.fps > 25, `帧率正常 (${s.fps})`) check(s.monKinds.includes('beefalo') && s.monKinds.includes('pig') && s.monKinds.includes('frog') && s.monKinds.includes('spider'), `生物齐备 (${[...new Set(s.monKinds)].join(',')})`) // 宽屏布局:整页贴合视口不出滚动条,画布铺满容器 const layout = await js(`(() => { const root = document.scrollingElement || document.documentElement const cv = document.querySelector('canvas.starve-canvas') const wrap = cv && cv.parentElement const r1 = cv && cv.getBoundingClientRect() const r2 = wrap && wrap.getBoundingClientRect() return JSON.stringify({ wide: document.body.classList.contains('wide-page'), noScroll: root.scrollHeight <= window.innerHeight + 1 && root.scrollWidth <= window.innerWidth + 1, sh: root.scrollHeight, ih: window.innerHeight, fill: !!(r1 && r2 && Math.abs(r1.height - r2.height) < 3 && Math.abs(r1.width - r2.width) < 3), ch: r1 ? Math.round(r1.height) : 0, wh: r2 ? Math.round(r2.height) : 0, }) })()`).then(JSON.parse) check(layout.wide, 'wide-page 布局生效') check(layout.noScroll, `无滚动条(页高 ${layout.sh} <= 视口 ${layout.ih})`) check(layout.fill, `画布铺满游戏区 (画布高 ${layout.ch} / 容器高 ${layout.wh})`) await shot('starve-1-autumn-day') // 2) 合成与科技 await js(`(() => { const g = window.__starve; g.give('twig',8); g.give('flint',6); g.give('grass',12); g.give('log',24); g.give('rock',24); g.give('gold',8); return true })()`) await js(`window.__starve.craft('axe'); true`) await sleep(300) s = await st() check(s.equip.hand === 'axe', `打造斧头并自动装备 (${s.equip.hand})`) const spearBefore = await js(`(() => { window.__starve.craft('spear'); return window.__starve.state().slots.join(',') })()`) check(!spearBefore.includes('spear'), '无科学机器时长矛不可合成(科技封锁)') await js(`window.__starve.craft('sciencemachine'); window.__starve.give('rope',2); true`) await sleep(300) await js(`window.__starve.craft('spear'); true`) await sleep(300) s = await st() check(s.slots.join(',').includes('spear'), `科学机器旁解锁长矛原型 (${s.slots.join(',')})`) await js(`window.__starve.craft('campfire'); true`) await sleep(300) s = await st() check(s.fires >= 1, '篝火点燃') await shot('starve-2-craft') // 3) 夜晚 + 查理 await js(`window.__starve.time(150); true`) await sleep(800) await shot('starve-3-night') // 走离篝火再测查理(先记录当前 hp) const hpBefore = (await st()).hp await js(`(() => { const g = window.__starve // 传送到远处黑暗地带(直接改不了坐标,用给火把再收走的方式不行——改用等待查理打到) return true })()`) // 玩家出生点附近有篝火,把时间拨到夜晚但玩家未必在火圈外——直接验证黑暗提示逻辑成本高, // 改为验证「夜晚黑暗掉血」:等 8 秒看是否遭袭(若在光圈内则跳过该断言) await sleep(8000) s = await st() const struck = s.hp < hpBefore - 50 console.log(`[e2e] 查理测试:hp ${hpBefore} -> ${s.hp}${struck ? '(遭袭)' : '(在光圈内,跳过)'}`) await js(`window.__starve.time(20); true`) // 拨回白天防连击致死 // 4) 冬季 + 体温 + 巨鹿(先传送远离篝火,避免火焰取暖干扰) await js(`(() => { const p = window.__starve.state().pos; window.__starve.pos(p[0] + 400, p[1]); window.__starve.season('winter'); return true })()`) await sleep(400) s = await st() check(s.season === 'winter', '跳转冬季') const tempBefore = s.temp await sleep(4000) s = await st() check(s.temp < tempBefore, `冬季体温下降 (${tempBefore}° -> ${s.temp}°)`) await shot('starve-4-winter') await js(`window.__starve.deerclops(); true`) await sleep(2500) s = await st() check(s.monKinds.includes('deerclops'), `独眼巨鹿降临 (${[...new Set(s.monKinds)].join(',')})`) await sleep(1500) await shot('starve-5-deerclops') // 5) 小地图 await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' })); true`) await sleep(800) const mapVisible = await js(`(() => { const el = document.querySelector('.ds-map'); return el && el.style.display !== 'none' })()`) check(mapVisible, '小地图 M 键开启') await shot('starve-6-minimap') await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' })); window.dispatchEvent(new KeyboardEvent('keyup', { key: 'm' })); true`) // 6) 存档往返 await js(`[...document.querySelectorAll('.hud-left .ds-btn')].find((b) => b.textContent.includes('保存'))?.click(); true`) await sleep(1200) const saved = await js(` fetch(window.desktop.serverBase + '/api/games/starve/save', { headers: { Authorization: 'Bearer ' + localStorage.getItem('token') } }) .then((r) => r.json()).then((j) => j.data || j).then((d) => !!(d && d.exists !== false && d.data)).catch(() => false) `) check(saved, '云存档已写入(v4)') // ===== 7) Mod 体系全链路 ===== console.log('[e2e] === Mod 体系 ===') // 重新进入游玩页:单人面板勾选全部 mod 后弃档重开 await js(`location.hash = '#/'; true`) await sleep(700) await js(`location.hash = '#/play/starve'; true`) await waitFor(`!!document.querySelector('.overlay .mode-card')`, 10000, '模式选择面板(二次)') await js(`document.querySelector('.overlay .mode-card').click(); true`) await waitFor(`document.querySelectorAll('.mod-pick input').length >= 8`, 6000, 'Mod 勾选列表') const modCount = await js(`(() => { const boxes = [...document.querySelectorAll('.mod-pick input')] boxes.forEach((cb) => { if (!cb.checked) cb.click() }) return boxes.filter((cb) => cb.checked).length })()`) check(modCount === 8, `勾选全部 8 个 mod (${modCount})`) const factorTxt = await js(`(document.querySelector('.mod-pick .mp-total') || {}).textContent || ''`) check(factorTxt.includes('0.64'), `面板显示难度系数 (${factorTxt.trim()})`) await shot('starve-7-mod-pick') await js(`(() => { const btns = [...document.querySelectorAll('.overlay button')] const btn = btns.find((b) => b.textContent.includes('放弃存档重新开始')) || btns.find((b) => b.textContent.includes('新的冒险')) btn.click() return true })()`) await waitFor(`!!window.__starve && window.__starve.mods().length === 8 && window.__starve.state().ents > 50`, 12000, 'Mod 局开局') s = await st() check(s.mods.length === 8, `本局激活 mod:${s.mods.join(',')}`) check(s.scoreFactor === 0.64, `得分系数 0.64 (${s.scoreFactor})`) // 和平模式:现在就排猎犬波,结尾处验证一直没有狗(无 mod 时 6 秒后必出) await js(`window.__starve.hound(); true`) // 宝藏:世界生成撒了箱子;传送过去开箱得战利品 const chestN = await js(`window.__starve.entCount('chest')`) check(chestN >= 6 && chestN <= 10, `宝箱撒点 6~10 个 (${chestN})`) const chestPos = await js(`JSON.stringify(window.__starve.find('chest'))`).then(JSON.parse) await js(`window.__starve.pos(${chestPos[0]}, ${chestPos[1] + 36}); true`) const slotsBeforeChest = (await st()).slots.join(',') await js(`window.__starve.poke('chest'); true`) await sleep(300) s = await st() check(s.slots.join(',') !== slotsBeforeChest, `开箱获得战利品 (${s.slots.join(',')})`) // 便利包:堆叠上限 99(原版 40) await js(`window.__starve.give('grass', 90); true`) await sleep(200) s = await st() check(s.slots.some((x) => x === 'grassx90'), `草堆叠 90/99 一格 (${s.slots.find((x) => x.startsWith('grass'))})`) // 农场:锄头开垦 → 播种 → 拨快生长 → 收获(撒点太密会提示"太挤",挪几步重试) await js(`window.__starve.give('hoe', 1); true`) await sleep(200) for (let k = 0; k < 8; k++) { await js(`window.__starve.useCode('hoe'); true`) await sleep(150) if (await js(`window.__starve.entCount('farmplot')`)) break await js(`(() => { const g = window.__starve; const p = g.state().pos; g.pos(p[0] + 56, p[1] + (${k} % 2 ? 64 : -64)); return true })()`) } check((await js(`window.__starve.entCount('farmplot')`)) >= 1, '锄头开垦出农田') await js(`window.__starve.give('carrotseeds', 2); window.__starve.poke('farmplot'); true`) await sleep(200) await js(`window.__starve.tickEnts(400); true`) // 拨快 400 秒 → 成熟 await sleep(200) await js(`window.__starve.poke('farmplot'); true`) await sleep(200) s = await st() check(s.slots.some((x) => x.startsWith('carrotx')), `农田收获胡萝卜 (${s.slots.find((x) => x.startsWith('carrotx'))})`) await shot('starve-8-mod-farm') // 噩梦模式:蜘蛛血量 100 → 150(就近读我们刚召的那只,别误读远处巢里的) await js(`window.__starve.spider(); true`) await sleep(300) const spiderHp = await js(`window.__starve.nearMobHp('spider')`) check(spiderHp === 150, `噩梦蜘蛛血量 150 (${spiderHp})`) // 魔法:火魔杖 AoE 灼烧(35 伤害)→ 回旋镖补刀(20 伤害) await js(`window.__starve.give('firestaff', 1); true`) await sleep(200) await js(`window.__starve.useCode('firestaff'); true`) await sleep(300) const afterStaff = await js(`window.__starve.nearMobHp('spider')`) check(afterStaff === 115, `火魔杖灼烧 150→115 (${afterStaff})`) await js(`window.__starve.give('boomerang', 1); true`) await sleep(200) await js(`window.__starve.useCode('boomerang'); true`) await sleep(1600) const afterBoom = await js(`window.__starve.nearMobHp('spider')`) check(afterBoom < afterStaff, `回旋镖命中 (${afterStaff}→${afterBoom})`) // 清场:清掉敌对生物(那只 95 血的噩梦蜘蛛会一路追杀)+ 回满三围,保证后续断言确定性 await js(`window.__starve.wipeHostiles(); window.__starve.heal(); true`) await js(`(() => { const t = window.__starve.find('tree'); if (t) window.__starve.pos(t[0] + 40, t[1] + 40); return true })()`) await sleep(300) // 切斯特:拿到眼骨后现身跟随;丢地上的东西会被它吞掉,点它吐出 await js(`window.__starve.give('eyebone', 1); true`) await waitFor(`window.__starve.state().monKinds.includes('chester')`, 6000, '切斯特现身') check(true, '眼骨召来切斯特') const store0 = await js(`JSON.stringify(window.__starve.mobStore('chester') || [])`).then(JSON.parse) check(store0.length === 0, '切斯特初始空仓') await js(`window.__starve.drop('log', 5); window.__starve.drop('flint', 3); true`) await sleep(3000) const store1 = await js(`JSON.stringify(window.__starve.mobStore('chester') || [])`).then(JSON.parse) check(store1.length >= 1, `切斯特吞了掉落物 (${store1.join(',') || '空'})`) await js(`window.__starve.pokeMob('chester'); true`) await sleep(300) const store2 = await js(`JSON.stringify(window.__starve.mobStore('chester') || [])`).then(JSON.parse) check(store2.length === 0, '点击切斯特吐出全部物品') await shot('starve-9-mod-chester') // 复活雕像:二级科技建造 → 致死伤害被拦截原地复活(先清背包防溢出落地) await js(`(() => { const g = window.__starve; g.clearInv(); g.give('rock', 40); g.give('gold', 14); g.give('log', 12); return true })()`) await js(`window.__starve.craft('sciencemachine'); true`) await sleep(200) await js(`window.__starve.craft('alchemyengine'); true`) await sleep(200) await js(`window.__starve.craft('revivestatue'); true`) await sleep(200) check((await js(`window.__starve.entCount('revivestatue')`)) === 1, '建成复活雕像') await js(`window.__starve.hurt(999); true`) await sleep(400) s = await st() check(s.hp === 75, `致死伤害被雕像拦截,半血复活 (hp=${s.hp})`) check((await js(`window.__starve.entCount('revivestatue')`)) === 0, '雕像已消耗') const overlayDead = await js(`!!document.querySelector('.death-overlay')`) check(!overlayDead, '未进入死亡结算') // 和平模式收尾断言:排波已过去 10 秒+,仍然无猎犬 s = await st() check(!s.monKinds.includes('hound') && !s.monKinds.includes('icehound'), `和平模式全程无猎犬 (${[...new Set(s.monKinds)].join(',')})`) // 便利包无迷雾:直接开地图截图(全图可见) await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' })); true`) await sleep(700) await shot('starve-10-mod-map-nofog') await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm' })); window.dispatchEvent(new KeyboardEvent('keyup', { key: 'm' })); true`) // Mod 存档往返:保存 → 云端应带 mods 字段 await js(`[...document.querySelectorAll('.hud-left .ds-btn')].find((b) => b.textContent.includes('保存'))?.click(); true`) await sleep(1200) const savedMods = await js(` fetch(window.desktop.serverBase + '/api/games/starve/save', { headers: { Authorization: 'Bearer ' + localStorage.getItem('token') } }) .then((r) => r.json()).then((j) => j.data || j) .then((d) => { try { return JSON.parse(d.data).mods || [] } catch { return [] } }) .catch(() => []) `) check(Array.isArray(savedMods) && savedMods.length === 8, `存档记录 mod 集合 (${savedMods.length} 个)`) // 汇总 const errs = pageErrors.filter((m) => !m.includes('favicon') && !m.includes('DevTools')) if (errs.length) { console.error(`[e2e] 页面报错 ${errs.length} 条:`) errs.slice(0, 10).forEach((m) => console.error(' -', m)) failures += errs.length } console.log(failures ? `[e2e] ${failures} 项失败` : '[e2e] 饥荒专项自检全部通过') app.exit(failures ? 1 : 0) } app.whenReady().then(() => main().catch((err) => { console.error('[e2e] 失败:', err.message) app.exit(1) }) ) setTimeout(() => { console.error('[e2e] 总超时') app.exit(1) }, 220000)