384 lines
19 KiB
JavaScript
384 lines
19 KiB
JavaScript
// 饥荒专项端到端自检(开发用):登录 → 进入饥荒 → 单人开局 →
|
||
// 通过 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)
|