Files
nl-game/electron/e2e-starve.cjs
李琦 cd1ab90013 初始化
需要注意的是饥荒不够完善:动作、手持工具、细节交互、系统逻辑
2026-08-15 07:24:34 +08:00

384 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 饥荒专项端到端自检(开发用):登录 → 进入饥荒 → 单人开局 →
// 通过 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)