Files
nl-game/test-starve.mjs
2026-09-24 10:52:55 +08:00

1298 lines
64 KiB
JavaScript
Raw 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.
// 临时冒烟测试:纯逻辑模块(不触 DOM)——世界生成 / 季节 / 烹饪锅 / 数据表一致性 / mod 注册表
import { genIsland, planEntities, isLand, biomeAt, WORLD, GW, GH, TILE, configureWorld, findLandNear } from './src/games/starve/world.js'
import { seasonOf, daySplit, phaseOf, ambientTemp, rollWeather } from './src/games/starve/climate.js'
import { cookPot, RECIPES, ITEMS, MON, MOB_DROPS, RES, SEG_SPLIT, SEASONS } from './src/games/starve/data.js'
import { activateMods, modMeta, callAllows, callHandled } from './src/games/starve/mods/registry.js'
import { MODS } from './src/games/starve/mods/index.js'
let fails = 0
function ok(cond, msg) {
if (cond) console.log(' PASS', msg)
else { fails++; console.error(' FAIL', msg) }
}
console.log('== 世界生成 ==')
for (let run = 0; run < 5; run++) {
const { tiles, road } = genIsland()
const landN = tiles.reduce((n, v) => n + (v > 0 ? 1 : 0), 0)
ok(landN > 300 && landN < 1000, `岛屿陆地占比合理 (${landN}/1200)`)
ok(isLand(tiles, WORLD.w / 2, WORLD.h / 2), '出生点在陆地上')
const biomes = new Set(Array.from(tiles).filter(Boolean))
ok(biomes.size >= 5, `群系种类 >=5 (${[...biomes].join(',')})`)
const roadN = road.reduce((a, b) => a + b, 0)
ok(roadN > 10, `道路瓦片存在 (${roadN})`)
const plan = planEntities(tiles, road)
const count = (t) => plan.placed.filter((p) => p.type === t).length
ok(count('tree') > 20, `树数量 (${count('tree')})`)
ok(count('spidernest') >= 2, `蜘蛛巢 >=2 (${count('spidernest')})`)
ok(count('beehive') >= 2, `蜂巢 >=2 (${count('beehive')})`)
ok(count('goldrock') >= 4, `金矿 >=4 (${count('goldrock')})`)
ok(count('rabbithole') >= 5, `兔子洞 >=5 (${count('rabbithole')})`)
ok(count('tentacle') >= 4, `触手 >=4 (${count('tentacle')})`)
ok(count('pighouse') >= 3, `猪屋 >=3 (${count('pighouse')})`)
ok(plan.herds.length >= 1, `牛群锚点 (${plan.herds.length})`)
ok(plan.frogs.length >= 4, `青蛙出生点 (${plan.frogs.length})`)
// 所有放置点都在陆地
const offLand = plan.placed.filter((p) => !isLand(tiles, p.x, p.y)).length
ok(offLand === 0, `没有实体落在海里 (${offLand})`)
// 最小间距抽查
let overlaps = 0
for (let i = 0; i < plan.placed.length; i++) {
for (let j = i + 1; j < plan.placed.length; j++) {
const a = plan.placed[i]
const b = plan.placed[j]
if (Math.hypot(a.x - b.x, a.y - b.y) < 24) overlaps++
}
}
ok(overlaps === 0, `实体间距无重叠 (${overlaps})`)
const spot = findLandNear(tiles, WORLD.w / 2, WORLD.h / 2, 400, 600)
ok(isLand(tiles, spot.x, spot.y), 'findLandNear 返回陆地')
}
console.log('== 季节与昼夜 ==')
ok(seasonOf(1).code === 'autumn' && seasonOf(1).dayIn === 1, '第 1 天 = 秋 1')
ok(seasonOf(20).code === 'autumn', '第 20 天仍是秋')
ok(seasonOf(21).code === 'winter' && seasonOf(21).dayIn === 1, '第 21 天入冬(官方节奏)')
ok(seasonOf(36).code === 'spring', '第 36 天入春')
ok(seasonOf(56).code === 'summer', '第 56 天入夏')
ok(seasonOf(71).code === 'autumn' && seasonOf(71).dayIn === 1, '第 71 天新一年秋')
Object.entries(SEG_SPLIT).forEach(([code, [d, k, n]]) => {
ok(d + k + n === 16, `${code} 段数合计 16`)
})
const [duskAt, nightAt] = daySplit('winter')
ok(duskAt === 50 && nightAt === 90, `冬季昼 50s / 夜起 90s (${duskAt},${nightAt})`)
ok(phaseOf('winter', 100) === 'night', '冬季 100s 是夜')
ok(phaseOf('summer', 100) === 'day', '夏季 100s 是昼')
ok(ambientTemp('winter', 'night') < -15, `冬夜气温 ${ambientTemp('winter', 'night')}`)
ok(rollWeather('winter').type === 'snow', '冬季必然飘雪')
console.log('== 烹饪锅 ==')
ok(cookPot(['meat', 'meat', 'meat', 'berry']) === 'meatystew', '3 大肉 = 炖肉汤')
ok(cookPot(['morsel', 'berry', 'berry', 'berry']) === 'meatballs', '小肉+果 = 肉丸')
ok(cookPot(['meat', 'meat', 'honey', 'berry']) === 'honeyham', '2 肉 1 蜜 = 蜜汁火腿')
ok(cookPot(['morsel', 'honey', 'berry', 'berry']) === 'honeynuggets', '半肉 1 蜜 = 蜜糖块')
ok(cookPot(['honey', 'honey', 'honey', 'berry']) === 'taffy', '3 蜜 = 太妃糖')
ok(cookPot(['monstermeat', 'monstermeat', 'berry', 'berry']) === 'monsterlasagna', '2 怪物肉 = 千层饼')
ok(cookPot(['berry', 'berry', 'berry', 'berry']) === 'wetgoop', '全果 = 湿哒哒糊糊')
console.log('== 数据表一致性 ==')
RECIPES.forEach((r) => {
Object.keys(r.cost).forEach((c) => ok(!!ITEMS[c], `配方 ${r.code} 材料 ${c} 存在`))
const isStruct = ['campfire', 'firepit', 'sciencemachine', 'alchemyengine', 'crockpot'].includes(r.code)
ok(isStruct || !!ITEMS[r.code], `配方产物 ${r.code} 在物品表或建筑`)
})
Object.entries(MOB_DROPS).forEach(([kind, drops]) => {
ok(!!MON[kind], `掉落表生物 ${kind} 存在`)
drops.forEach(([p, code]) => ok(!!ITEMS[code], `${kind} 掉落 ${code} 存在`))
})
Object.entries(RES).forEach(([type, conf]) => {
if (conf.drops) Object.keys(conf.drops).forEach((c) => ok(!!ITEMS[c], `资源 ${type} 产出 ${c} 存在`))
})
ok(SEASONS.reduce((n, s) => n + s.days, 0) === 70, '一年 70 天')
console.log('== Mod 注册表 ==')
ok(MODS.length === 19, `内置 19 个 mod (${MODS.length})`)
ok(modMeta().every((m) => m.id && m.name && m.desc), '元数据齐全')
// 空激活 = 基表原样
const rt0 = activateMods([])
ok(rt0.scoreFactor === 1, '无 mod 系数 1')
ok(Object.keys(rt0.items).length === Object.keys(ITEMS).length, '无 mod 物品表与基表一致')
ok(rt0.recipes.length === RECIPES.length, '无 mod 配方表与基表一致')
// 全量激活
const allIds = MODS.map((m) => m.id)
const rt = activateMods(allIds)
ok(rt.ids.length === 19, '全量激活 19 个')
// 系数:0.7(便利包)*0.7(和平)*1.3(噩梦)*1.1(寒潮)*1.1(旅人)*1.15(幸运)*0.9(文明火种)*1.05(农业)*1.05(牧业)*1.1(工业)*0.9(辽阔世界)*0.75(营地)*1.05(劳务市场) ≈ 0.686 → 0.69
ok(rt.scoreFactor === 0.69, `全开系数 0.69 (${rt.scoreFactor})`)
ok(activateMods(['qol']).scoreFactor === 0.7, '便利包 0.7')
ok(activateMods(['nightmare']).scoreFactor === 1.3, '噩梦 1.3')
ok(activateMods(['farm', 'chest', 'chester', 'revive', 'magic']).scoreFactor === 1, '内容 mod 不影响系数')
ok(activateMods(['qol', 'peaceful']).scoreFactor === 0.5, '0.49 夹到下限 0.5')
// 配方一致性:材料与产物都能解析
rt.recipes.forEach((r) => {
Object.keys(r.cost).forEach((c) => ok(!!rt.items[c], `mod 配方 ${r.code} 材料 ${c} 存在`))
const isStruct = ['campfire', 'firepit', 'sciencemachine', 'alchemyengine', 'crockpot'].includes(r.code) || r.build
ok(isStruct ? !!rt.res[r.code] || !!rt.entKinds[r.code] || !rt.items[r.code] : !!rt.items[r.code], `mod 配方产物 ${r.code} 可解析`)
})
// 注册内容点名
ok(['farmplot', 'chest', 'revivestatue', 'flamefx', 'boomerang'].every((t) => rt.entKinds[t] && rt.res[t]), '自定义实体齐全')
ok(rt.mobKinds.chester && rt.mon.chester?.name === '切斯特', '切斯特生物注册')
ok(rt.items.hoe && rt.items.eyebone && rt.items.firestaff && rt.items.boomerang && rt.items.carrot, 'mod 物品齐全')
ok(rt.itemCodes.length === Object.keys(rt.items).length && rt.itemCodes.every((c, i) => rt.itemIdx[c] === i), '物品索引一致(联机打包用)')
// 调参与旗标
ok(rt.tune.stackMax === 99 && rt.tune.gatherMul === 0.5, '便利包调参生效')
ok(rt.tune.houndGapMul === 0.7 && rt.tune.houndExtra === 1, '噩梦猎犬调参生效')
ok(rt.flags.noFog === true, '无迷雾旗标')
// 噩梦倍率只改运行时表,不污染基表
ok(rt.mon.spider.hp === 150 && rt.mon.spider.dmg === 30, `噩梦蜘蛛 150/30 (${rt.mon.spider.hp}/${rt.mon.spider.dmg})`)
ok(MON.spider.hp === 100 && MON.spider.dmg === 20, '基表蜘蛛不受污染')
ok(activateMods(['farm']).mon.spider.hp === 100, '不开噩梦不缩放')
// 合并顺序确定性(主客机一致)
ok(JSON.stringify(activateMods([...allIds].reverse()).itemCodes) === JSON.stringify(rt.itemCodes), '激活顺序无关,物品索引确定')
// 钩子语义
ok(callAllows(rt, 'onSpawnWave', {}, 'hound') === false, '和平模式否决猎犬波')
ok(callAllows(activateMods(['farm']), 'onSpawnWave', {}, 'hound') === true, '无和平模式放行')
// 行为级:农田三阶段生长(纯函数拨表)
{
const plot = { state: 'grow0', mx: { seed: 'carrot', t: 0 }, id: 1 }
const g = { season: 'autumn', markDirty() {}, tip() {} }
const farm = rt.entKinds.farmplot
farm.update(plot, 108, g)
ok(plot.state === 'grow1', `农田 108s 二阶段 (${plot.state})`)
farm.update(plot, 107, g)
ok(plot.state === 'grow2', `农田 215s 三阶段 (${plot.state})`)
farm.update(plot, 200, g)
ok(plot.state === 'ready', `农田 320s 成熟 (${plot.state})`)
const winterPlot = { state: 'grow0', mx: { seed: 'berry', t: 0 }, id: 2 }
farm.update(winterPlot, 500, { ...g, season: 'winter' })
ok(winterPlot.state === 'grow0' && winterPlot.mx.t === 0, '冬季生长暂停')
}
// 行为级:宝箱开箱按战利品表发放
{
const got = []
const g = {
rand: (a, b) => a, addItem: (p, c, n) => got.push([c, n]), items: rt.items,
markDirty() {}, swing() {}, addScore() {}, ptip() {}, shake() {},
}
const box = { state: 'closed', id: 3 }
rt.entKinds.chest.interact({}, box, g)
ok(box.state === 'open' && got.length >= 2, `开箱得 ${got.length} 样`)
ok(got.every(([c]) => !!rt.items[c]), '战利品都在物品表内')
got.length = 0
rt.entKinds.chest.interact({}, box, g)
ok(got.length === 0, '空箱不再出货')
}
// 行为级:复活雕像拦截死亡并消耗
{
const statue = { type: 'revivestatue', id: 9 }
const world = { entities: [statue] }
const g = {
get entities() { return world.entities },
removeEntity: (e) => { world.entities = world.entities.filter((x) => x !== e) },
ptip() {}, tip() {}, shake() {},
}
const p = { hp: 0, hunger: 3, san: 5, temp: -2, name: '测试' }
ok(callHandled(rt, 'onPlayerDeath', g, p) === true, '复活雕像拦截死亡')
ok(p.hp === 75 && world.entities.length === 0, `复活半血且雕像消耗 (hp=${p.hp})`)
ok(callHandled(rt, 'onPlayerDeath', g, p) === false, '没有雕像不再拦截')
}
// =====================================================================
// 本次交付扩展:startchest / agri / husbandry / industry 四个新 mod
// =====================================================================
const NEW_IDS = ['startchest', 'agri', 'husbandry', 'industry']
const rt4 = activateMods(NEW_IDS) // 只激活 4 个新 mod(验证不依赖其它 mod 独有物品)
// ---- 通用测试替身 ----
function makeG(opts = {}) {
const inv = {}
const received = []
if (opts.grant) opts.grant.forEach(([c, n]) => { inv[c] = (inv[c] || 0) + n })
return {
items: opts.items || rt.items,
entities: opts.entities || [],
monsters: opts.monsters || [],
day: opts.day ?? 1,
season: opts.season || 'autumn',
worldW: 2400, worldH: 2000,
inv, received,
rand: (a) => a,
addItem: (p, c, n) => { inv[c] = (inv[c] || 0) + n; received.push([c, n]) },
countItem: (p, c) => inv[c] || 0,
hasCost: (p, cost) => Object.entries(cost).every(([c, n]) => (inv[c] || 0) >= n),
payCost: (p, cost) => {
if (!Object.entries(cost).every(([c, n]) => (inv[c] || 0) >= n)) return false
Object.entries(cost).forEach(([c, n]) => { inv[c] -= n })
return true
},
markDirty() {}, swing() {}, shake() {}, addScore() {}, ptip() {}, tip() {},
invalidate() {}, consumeAt() {}, spawnMob() {}, dropLoot() {},
findLand: (x, y) => ({ x, y }),
...(opts.extra || {}),
}
}
const hookFn = (r, name, id) => (r.hooks[name] || []).find((h) => h.id === id)?.fn
// 假 40x40 canvas 2D 上下文:任意方法可调用、样式属性可读写
const makeCtx = () => new Proxy({}, {
get: (t, k) => (k in t ? t[k] : () => {}),
set: (t, k, v) => { t[k] = v; return true },
})
console.log('== 新 mod 注册与合并 ==')
ok(MODS.length === 19, `内置 19 个 mod (${MODS.length})`)
const metas = modMeta()
const metaOf = (id) => metas.find((m) => m.id === id)
;[['startchest', 0.9], ['agri', 1.05], ['husbandry', 1.05], ['industry', 1.1]].forEach(([id, f]) => {
const m = metaOf(id)
ok(!!(m && m.id === id && m.name && m.desc), `新 mod ${id} 元数据齐全`)
ok(!!(m && Math.abs(m.factor - f) < 1e-9), `新 mod ${id} 系数 ${f} (${m && m.factor})`)
})
const itemSets = {
agri: ['agrihoe', 'wheatseeds', 'wheat', 'flour', 'bread', 'pumpkin', 'pumpkinseeds', 'fertilizer'],
husbandry: ['husbshears', 'wool', 'woolhat', 'egg', 'cookedegg', 'milk', 'cheese'],
industry: ['indpickaxe', 'indaxe', 'ingot', 'gear', 'steelarmor', 'steelhelmet'],
}
Object.entries(itemSets).forEach(([id, codes]) => {
ok(codes.every((c) => !!rt.items[c]), `${id} 全部 ${codes.length} 个物品已注册`)
})
const entSets = {
startchest: ['startchest'],
agri: ['agrifarm', 'well', 'granary', 'mill'],
husbandry: ['fence', 'trough', 'coop'],
industry: ['smelter', 'steamengine', 'factory', 'miner'],
}
Object.entries(entSets).forEach(([id, types]) => {
ok(types.every((t) => !!rt.entKinds[t]), `${id} 全部 ${types.length} 个实体已注册`)
})
ok(!!rt.mobKinds.sheep && !!rt.mobKinds.chicken, 'sheep/chicken 生物已注册')
ok(rt.mon.sheep?.name === '绵羊' && rt.mon.chicken?.name === '鸡', `绵羊/鸡命名正确 (${rt.mon.sheep?.name})`)
ok((rt.mobDrops.sheep || []).length > 0, 'sheep 掉落表非空')
// 页签合并
ok(activateMods([]).tabs.length === 7, `无 mod 时基表页签 7 (${activateMods([]).tabs.length})`)
{
const rA = activateMods(['agri'])
const codes = rA.tabs.map((t) => t.code)
ok(codes.includes('agri'), '单独激活 agri 含农业页签')
ok(['tools', 'light', 'surv', 'fight', 'food', 'science', 'dress'].every((c) => codes.includes(c)), '基表 7 页签仍在')
ok(new Set(codes).size === codes.length, `无重复 code (${codes.join(',')})`)
}
{
const r3 = activateMods(['agri', 'husbandry', 'industry'])
const codes = r3.tabs.map((t) => t.code)
ok(['agri', 'husbandry', 'industry'].every((c) => codes.includes(c)), '三新页签都在')
ok(r3.tabs.length === 10, `三新页签后共 10 (${r3.tabs.length})`)
}
{
// 去重:借道临时 mod(含与基表冲突/自身重复的 code)
const dup = {
id: '__dup', name: 'x', desc: 'x', factor: 1,
tabs: [
{ code: 'tools', name: '重复基表', icon: 'axe' },
{ code: 'agri', name: '农业', icon: 'agrihoe' },
{ code: 'agri', name: '农业2', icon: 'agrihoe' },
],
}
MODS.push(dup)
const rd = activateMods(['__dup'])
MODS.pop()
ok(rd.tabs.filter((t) => t.code === 'tools').length === 1, '去重:不覆盖/不重复基表 tools')
ok(rd.tabs.filter((t) => t.code === 'agri').length === 1, '去重:同 mod 内重复 code 只留一个')
ok(rd.tabs.length === 8, `去重后页签 8 (${rd.tabs.length})`)
}
ok(rt.itemCodes.length === Object.keys(rt.items).length, 'itemCodes 数量与物品表一致')
ok(rt.itemCodes.every((c, i) => rt.itemIdx[c] === i), 'itemIdx 与 itemCodes 一一对应')
console.log('== 新 mod 数据一致性(静态) ==')
const newRecipes = rt4.recipes.filter((r) => ['agri', 'husbandry', 'industry'].includes(r.tab))
ok(newRecipes.length === 18, `新配方 18 条 (${newRecipes.length})`)
newRecipes.forEach((r) => {
Object.keys(r.cost).forEach((c) => ok(!!rt4.items[c], `新配方 ${r.code} 材料 ${c} 可解析(仅 4 新 mod)`))
ok(typeof rt4.iconPainters[r.code] === 'function', `新配方 ${r.code} 有图标画法`)
})
newRecipes.filter((r) => r.build).forEach((r) => {
ok(!!rt4.entKinds[r.code], `build 配方 ${r.code} 有同名实体`)
})
{
const ctx = makeCtx()
let painterErr = null
Object.keys(rt4.iconPainters).forEach((code) => {
try { rt4.iconPainters[code](ctx) } catch (err) { painterErr = `${code}: ${err.message}` }
})
ok(painterErr === null, `全部图标画法可执行 (${painterErr || 'ok'})`)
}
{
const byCode = Object.fromEntries(rt4.recipes.map((r) => [r.code, r]))
ok(!('ingot' in byCode.smelter.cost) && !('gear' in byCode.smelter.cost), '熔炉不依赖 ingot/gear')
ok('ingot' in byCode.steamengine.cost, '蒸汽机依赖 ingot(链条上游)')
ok('ingot' in byCode.factory.cost, '工厂依赖 ingot')
ok('gear' in byCode.miner.cost && 'ingot' in byCode.miner.cost, '矿机依赖 gear+ingot(链条末端)')
ok(Object.keys(byCode.miner.cost).every((c) => !!rt4.items[c]), '矿机材料均可解析')
}
console.log('== 新 mod 行为级验证 ==')
// --- 开局宝箱 ---
{
const g = makeG()
const box = { state: 'closed', id: 1 }
rt.entKinds.startchest.interact({}, box, g)
const always = { axe: 1, pickaxe: 1, torch: 2, grass: 10, twig: 10, log: 8, rock: 8, rope: 2, berry: 6, healingsalve: 2 }
ok(box.state === 'open', '开箱后 state=open')
ok(Object.entries(always).every(([c, n]) => g.inv[c] === n), '固定物资一次发齐')
ok(g.received.every(([c]) => !!g.items[c]), '发放物品都在物品表内')
const snap = JSON.stringify(g.inv)
g.received.length = 0
rt.entKinds.startchest.interact({}, box, g)
ok(g.received.length === 0 && JSON.stringify(g.inv) === snap, '二次交互不再发放')
}
// 边界:仅激活 startchest(无种子物品)开箱不崩、不发不存在的物品
{
const rtStart = activateMods(['startchest'])
const g = makeG({ items: rtStart.items })
let err = null
try { rtStart.entKinds.startchest.interact({}, { state: 'closed', id: 2 }, g) } catch (e) { err = e.message }
ok(err === null, `仅激活 startchest、缺种子时开箱不抛异常 (${err || 'ok'})`)
ok(!rtStart.items.wheatseeds && !(g.inv.wheatseeds > 0), '不发不存在的种子(wheatseeds)')
ok(g.inv.axe === 1 && g.inv.log === 8, '固定物资照发')
ok(g.received.every(([c]) => !!rtStart.items[c]), '只发存在的物品')
}
// 物品表为空时的防御分支
{
const g = makeG({ items: {} })
let err = null
try { rt.entKinds.startchest.interact({}, { state: 'closed', id: 3 }, g) } catch (e) { err = e.message }
ok(err === null, '物品表为空时开箱不崩')
ok(g.received.length === 0, '物品表为空时不发任何物品')
}
// --- 农业良田生长(拨表)---
{
const farm = rt.entKinds.agrifarm
const p = { state: 'grow0', mx: { seed: 'wheat', t: 0 }, x: 0, y: 0, id: 1 }
const g = makeG()
farm.update(p, 66, g); ok(p.state === 'grow1', `良田 66s→grow1 (${p.state})`)
farm.update(p, 67, g); ok(p.state === 'grow2', `良田 133s→grow2 (${p.state})`)
farm.update(p, 67, g); ok(p.state === 'ready', `良田 200s→ready (${p.state})`)
const pw = { state: 'grow0', mx: { seed: 'wheat', t: 0 }, x: 0, y: 0, id: 2 }
farm.update(pw, 120, makeG({ season: 'winter' }))
ok(pw.state === 'grow0' && pw.mx.t === 0, '冬季生长暂停')
const well = { type: 'well', x: 0, y: 0, id: 9 }
const near = { state: 'grow0', mx: { seed: 'wheat', t: 0 }, x: 0, y: 0, id: 3 }
const far = { state: 'grow0', mx: { seed: 'wheat', t: 0 }, x: 500, y: 0, id: 4 }
const gw = makeG({ entities: [well] })
farm.update(near, 10, gw); farm.update(far, 10, gw)
ok(Math.abs(near.mx.t - 16) < 1e-6, `近水井 ×1.6 (t=${near.mx.t})`)
ok(far.mx.t === 10 && near.mx.t > far.mx.t, `远水井正常生长 (t=${far.mx.t})`)
}
// --- 农业磨坊 ---
{
const g = makeG({ grant: [['wheat', 3]] })
rt.entKinds.mill.interact({}, { id: 1 }, g)
ok(g.inv.wheat === 0 && g.inv.flour === 2, `磨面 3 小麦→2 面粉 (wheat=${g.inv.wheat}, flour=${g.inv.flour})`)
const g2 = makeG({ grant: [['wheat', 2]] })
rt.entKinds.mill.interact({}, { id: 2 }, g2)
ok(g2.inv.wheat === 2 && !g2.inv.flour, '材料不足不产出不消耗')
}
// --- 农业水井 ---
{
const g = makeG()
const p = { san: 50 }
const well = { type: 'well', mx: { cd: 0 }, id: 1 }
rt.entKinds.well.interact(p, well, g)
ok(p.san === 55 && well.mx.cd === 60, `打水 san+5 冷却 60s (${p.san})`)
rt.entKinds.well.interact(p, well, g)
ok(p.san === 55, '冷却中不再加理智')
}
// --- 农业粮仓(持久化数据在 e.mx.store)---
{
const g = makeG({ grant: [['wheat', 4]] })
const gr = { type: 'granary', mx: { store: {} }, id: 1 }
rt.entKinds.granary.interact({}, gr, g)
ok(gr.mx.store.wheat === 4 && (g.inv.wheat || 0) === 0, `粮仓存入作物 (store=${JSON.stringify(gr.mx.store)})`)
rt.entKinds.granary.interact({}, gr, g)
ok(g.inv.wheat === 4 && Object.keys(gr.mx.store).length === 0, '粮仓取出作物')
}
// --- 农业肥料(onUseItem)---
{
const fn = hookFn(rt, 'onUseItem', 'agri')
const plot = { type: 'agrifarm', state: 'grow0', mx: { seed: 'wheat', t: 20 }, x: 0, y: 0, id: 1 }
const ret = fn(makeG({ entities: [plot] }), { x: 0, y: 0, dir: 1 }, { code: 'fertilizer' }, null, 0)
ok(plot.mx.t === 120 && ret === true, `肥料 +100s (t=${plot.mx.t})`)
const plot2 = { type: 'agrifarm', state: 'grow0', mx: { seed: 'wheat', t: 80 }, x: 0, y: 0, id: 2 }
fn(makeG({ entities: [plot2] }), { x: 0, y: 0, dir: 1 }, { code: 'fertilizer' }, null, 0)
ok(plot2.mx.t === 180, `肥料可跨阶段 +100s (t=${plot2.mx.t})`)
const empty = { type: 'agrifarm', state: 'empty', mx: null, x: 0, y: 0, id: 3 }
let err = null
try { fn(makeG({ entities: [empty] }), { x: 0, y: 0, dir: 1 }, { code: 'fertilizer' }, null, 0) } catch (e) { err = e.message }
ok(err === null, `对空良田施肥不崩 (${err || 'ok'})`)
}
// --- 牧业剪毛 ---
{
const g = makeG({ grant: [['husbshears', 1]], day: 3 })
const m = { kind: 'sheep', id: 1 }
rt.mobKinds.sheep.interact({}, m, g)
ok(g.inv.wool === 2 && m.state === 'sheared:3', `剪毛得羊毛2 且冷却记在 m.state (wool=${g.inv.wool}, state=${m.state})`)
g.received.length = 0
rt.mobKinds.sheep.interact({}, m, g)
ok(g.inv.wool === 2 && g.received.length === 0, '同日重复剪毛不再产出')
const g2 = makeG({ day: 3 })
rt.mobKinds.sheep.interact({}, { kind: 'sheep', id: 2 }, g2)
ok(!g2.inv.wool, '没剪刀不产出')
}
// --- 牧业鸡舍(持久化数据在 e.mx.stored)---
{
const coop = { type: 'coop', mx: { stored: 0 }, x: 0, y: 0, id: 1 }
const g = makeG({ entities: [coop], monsters: [] })
hookFn(rt, 'onNewDay', 'husbandry')(g)
ok(coop.mx.stored >= 1, `鸡舍每日囤蛋 (stored=${coop.mx.stored})`)
const n = coop.mx.stored
rt.entKinds.coop.interact({}, coop, g)
ok(g.inv.egg === n && coop.mx.stored === 0, `收取鸡蛋×${n} 且清空 (egg=${g.inv.egg})`)
}
// --- 牧业饲料槽(持久化数据在 e.mx.feed)---
{
const g = makeG({ grant: [['berry', 3]] })
const tr = { type: 'trough', mx: { feed: 0 }, id: 1 }
rt.entKinds.trough.interact({}, tr, g)
ok(tr.mx.feed === 1 && g.inv.berry === 2, `投喂加饲料 (feed=${tr.mx.feed})`)
rt.entKinds.trough.interact({}, tr, g)
rt.entKinds.trough.interact({}, tr, g)
ok(tr.mx.feed === 3 && (g.inv.berry || 0) === 0, `连续投喂累加 (feed=${tr.mx.feed})`)
rt.entKinds.trough.interact({}, tr, g)
ok(tr.mx.feed === 3, '没作物后停止投喂')
}
// --- 工业熔炉 ---
{
const g = makeG({ grant: [['rock', 3], ['log', 1]] })
rt.entKinds.smelter.interact({}, { id: 1 }, g)
ok(g.inv.ingot === 1 && g.inv.rock === 0 && g.inv.log === 0, '熔炉 rock×3+log×1→ingot×1')
const g2 = makeG({ grant: [['rock', 2], ['log', 1]] })
rt.entKinds.smelter.interact({}, { id: 2 }, g2)
ok(!g2.inv.ingot, '熔炉材料不足不出货')
}
// --- 工业工厂 ---
{
const g = makeG({ grant: [['ingot', 2]] })
rt.entKinds.factory.interact({}, { x: 0, y: 0, id: 1 }, g)
ok(!g.inv.gear && g.inv.ingot === 2, '无供能蒸汽机不出齿轮')
const eng = { type: 'steamengine', mx: { fuel: 2 }, x: 0, y: 0, id: 9 }
const g2 = makeG({ entities: [eng], grant: [['ingot', 2]] })
rt.entKinds.factory.interact({}, { x: 0, y: 0, id: 2 }, g2)
ok(g2.inv.gear === 1 && g2.inv.ingot === 0, '有蒸汽且 ingot 够→齿轮×1')
const engOff = { type: 'steamengine', mx: { fuel: 0 }, x: 0, y: 0, id: 10 }
const g3 = makeG({ entities: [engOff], grant: [['ingot', 2]] })
rt.entKinds.factory.interact({}, { x: 0, y: 0, id: 3 }, g3)
ok(!g3.inv.gear, '蒸汽机停转不出齿轮')
const farEng = { type: 'steamengine', mx: { fuel: 2 }, x: 999, y: 0, id: 11 }
const g4 = makeG({ entities: [farEng], grant: [['ingot', 2]] })
rt.entKinds.factory.interact({}, { x: 0, y: 0, id: 4 }, g4)
ok(!g4.inv.gear, '蒸汽机超出 300px 不出齿轮')
}
// --- 工业矿机(onNewDay)---
{
const sum = (arr, code) => arr.filter((c) => c[0] === code).reduce((a, c) => a + c[1], 0)
const cap1 = []
hookFn(rt, 'onNewDay', 'industry')(makeG({
entities: [{ type: 'miner', x: 0, y: 0, id: 1 }],
extra: { dropLoot: (x, y, c, n) => cap1.push([c, n]) },
}))
ok(sum(cap1, 'rock') === 2 && sum(cap1, 'flint') === 1, `无蒸汽矿机 rock×2 flint×1 (${sum(cap1, 'rock')}/${sum(cap1, 'flint')})`)
const cap2 = []
const eng = { type: 'steamengine', mx: { fuel: 5 }, x: 0, y: 0, id: 9 }
hookFn(rt, 'onNewDay', 'industry')(makeG({
entities: [{ type: 'miner', x: 0, y: 0, id: 2 }, eng],
extra: { dropLoot: (x, y, c, n) => cap2.push([c, n]) },
}))
ok(sum(cap2, 'rock') === 4 && sum(cap2, 'flint') === 2, `蒸汽矿机翻倍 rock×4 flint×2 (${sum(cap2, 'rock')}/${sum(cap2, 'flint')})`)
}
// --- 工业蒸汽机 ---
{
const g = makeG({ grant: [['log', 1]] })
const eng = { type: 'steamengine', mx: { fuel: 0 }, id: 1 }
rt.entKinds.steamengine.interact({}, eng, g)
ok(eng.mx.fuel === 3 && g.inv.log === 0, `投 log×1 燃料+3 且存进 mx (fuel=${eng.mx.fuel})`)
const gNo = makeG()
rt.entKinds.steamengine.interact({}, { type: 'steamengine', mx: { fuel: 0 }, id: 2 }, gNo)
ok(gNo.inv.log === undefined, '无燃料不消耗')
hookFn(rt, 'onNewDay', 'industry')(makeG({ entities: [eng] }))
ok(eng.mx.fuel === 2, `蒸汽机每日耗 1 (fuel=${eng.mx.fuel})`)
const eng2 = { type: 'steamengine', mx: { fuel: 1 }, id: 3 }
hookFn(rt, 'onNewDay', 'industry')(makeG({ entities: [eng2] }))
ok(eng2.mx.fuel === 0 && eng2.on === false, `燃料归零停转 (fuel=${eng2.mx.fuel}, on=${eng2.on})`)
eng.mx.fuel = 2; eng.on = false
rt.entKinds.steamengine.update(eng, 0.1, makeG())
ok(eng.on === true, 'fuel>0 时 update 置 on=true')
// 燃料必须落在 e.mx:核心只序列化 e.mx,落在 e.fuel 会存档/联机丢失
ok(eng.fuel === undefined, '燃料不再占用 e.fuel(避免存档丢失)')
}
console.log('== 跨 mod 独立性 ==')
{
const rtInd = activateMods(['industry'])
ok(['smelter', 'steamengine', 'factory', 'miner'].every((t) => !!rtInd.entKinds[t]), '单激活 industry:实体齐全')
const g = makeG({ items: rtInd.items, grant: [['rock', 3], ['log', 1]] })
let err = null
try { rtInd.entKinds.smelter.interact({}, { id: 1 }, g) } catch (e) { err = e.message }
ok(err === null && g.inv.ingot === 1, `单激活 industry 熔炉可用 (${err || 'ok'})`)
let err2 = null
try {
hookFn(rtInd, 'onNewDay', 'industry')(makeG({
items: rtInd.items,
entities: [{ type: 'miner', x: 0, y: 0, id: 1 }, { type: 'steamengine', mx: { fuel: 2 }, x: 0, y: 0, id: 2 }],
}))
} catch (e) { err2 = e.message }
ok(err2 === null, `单激活 industry onNewDay 不崩 (${err2 || 'ok'})`)
const rtAgri = activateMods(['agri'])
const ga = makeG({ items: rtAgri.items, grant: [['wheat', 3]] })
let err3 = null
try { rtAgri.entKinds.mill.interact({}, { id: 1 }, ga) } catch (e) { err3 = e.message }
ok(err3 === null && ga.inv.flour === 2, `单激活 agri 磨坊可用 (${err3 || 'ok'})`)
}
// =====================================================================
// 本轮交付(第二轮):世界缩放 + 辽阔世界 / 营地 / 劳务市场
// =====================================================================
// ---- 通用测试替身 ----
function mkPG(items = []) {
const slots = items.map(([code, n]) => ({ code, n }))
return { x: 0, y: 0, hp: 100, san: 100, hunger: 100, temp: 20, dead: false, seat: 0, slots, pack: null }
}
function countP(p, code) {
let n = 0
for (const arr of [p.slots, p.pack]) {
if (!arr) continue
for (const s of arr) if (s && s.code === code) n += s.n
}
return n
}
function payP(p, cost) {
for (const [c, need] of Object.entries(cost)) if (countP(p, c) < need) return false
for (const [c, need0] of Object.entries(cost)) {
let left = need0
for (const arr of [p.slots, p.pack]) {
if (!arr) continue
for (let i = 0; i < arr.length && left > 0; i++) {
const s = arr[i]
if (!s || s.code !== c) continue
const t = Math.min(s.n, left)
s.n -= t
left -= t
if (s.n <= 0) arr[i] = null
}
}
}
return true
}
function addP(p, code, n = 1) {
if (!p.slots) p.slots = []
const s = p.slots.find((x) => x && x.code === code)
if (s) s.n += n
else p.slots.push({ code, n })
}
function mkCG(o = {}) {
const entities = o.entities || []
const monsters = o.monsters || []
const players = o.players || []
const calls = { addEntity: [], spawnMob: [], addFire: [], damageMob: [], removeMob: [], drops: [], tips: [], ptips: [] }
const g = {
entities, monsters, players,
mon: o.mon || rt.mon,
items: o.items || rt.items,
tune: { stackMax: 20 },
day: o.day ?? 1,
season: o.season || 'autumn',
worldW: o.worldW ?? 2400,
worldH: o.worldH ?? 2000,
calls,
me: () => o.me || players[0] || null,
rand: (a) => a,
findLand: (x, y) => (o.findLand ? o.findLand(x, y) : { x, y }),
moveToward: (m, x, y) => { m.x = x; m.y = y; m.moving = true },
damageMob: (m, dmg) => { calls.damageMob.push([m, dmg]); m.hp -= dmg },
addEntity: (type, x, y, extra) => { const e = { type, x, y, id: 100 + entities.length, mx: null, ...(extra || {}) }; entities.push(e); calls.addEntity.push([type, x, y]); return e },
removeEntity: (e) => { const i = entities.indexOf(e); if (i >= 0) entities.splice(i, 1) },
spawnMob: (kind, x, y, extra) => { const m = { kind, x, y, id: 500 + monsters.length, hp: (g.mon[kind] && g.mon[kind].hp) || 100, ...(extra || {}) }; monsters.push(m); calls.spawnMob.push([kind, x, y, extra]); return m },
removeMob: (m) => { const i = monsters.indexOf(m); if (i >= 0) monsters.splice(i, 1); calls.removeMob.push(m) },
addItem: (p, code, n = 1) => addP(p, code, n),
countItem: (p, code) => countP(p, code),
hasCost: (p, cost) => Object.entries(cost).every(([c, n]) => countP(p, c) >= n),
payCost: (p, cost) => payP(p, cost),
dropLoot: (x, y, code, n) => calls.drops.push([code, n]),
markDirty: () => {}, ptip: (p, t) => calls.ptips.push(t), tip: (t) => calls.tips.push(t),
invalidate: () => {}, consumeAt: () => {}, swing: () => {}, addScore: () => {}, shake: () => {},
addFire: (x, y, opt) => { const f = { x, y, ...(opt || {}) }; calls.addFire.push(f); return f },
}
return g
}
// 假 2D 上下文:任意方法可调,measureText 返回带 width 的对象(供 labor clipText 用)
const makeCtx2 = () => new Proxy({}, {
get: (t, k) => {
if (k in t) return t[k]
if (k === 'measureText') return () => ({ width: 12 })
return () => {}
},
set: (t, k, v) => { t[k] = v; return true },
})
console.log('== A. 世界缩放(configureWorld)==')
{
ok(TILE === 80, `TILE 基准 80 (${TILE})`)
configureWorld(1.5)
ok(GW === 96 && GH === 72, `1.5 倍尺寸 96/72 (${GW}/${GH})`)
ok(WORLD.w === 7680 && WORLD.h === 5760, `1.5 倍世界像素 7680x5760 (${WORLD.w}x${WORLD.h})`)
const isle = genIsland()
ok(isle.tiles.length === GW * GH, `1.5 倍 tiles 长度 = GW*GH (${isle.tiles.length})`)
const landN = isle.tiles.reduce((n, v) => n + (v > 0 ? 1 : 0), 0)
ok(landN > 1000 && landN < GW * GH, `1.5 倍陆地占比合理 (${landN}/${GW * GH})`)
ok(isLand(isle.tiles, WORLD.w / 2, WORLD.h / 2), '1.5 倍出生点在陆地')
let planErr = null
let plan = null
try { plan = planEntities(isle.tiles, isle.road) } catch (e) { planErr = e.message }
ok(planErr === null, `1.5 倍 planEntities 不抛异常 (${planErr || 'ok'})`)
ok(plan && plan.placed.length > 0, `1.5 倍放置实体非空 (${plan && plan.placed.length})`)
const offLand = plan.placed.filter((p) => !isLand(isle.tiles, p.x, p.y)).length
ok(offLand === 0, `1.5 倍无实体落海 (${offLand})`)
configureWorld(2)
ok(GW === 128 && GH === 96, `2 倍尺寸 128/96 (${GW}/${GH})`)
configureWorld(0.1)
ok(GW === 32 && GH === 24, `0.1 夹到下限 0.5 → 32/24 (${GW}/${GH})`)
configureWorld(3)
ok(GW === 128 && GH === 96, `3 夹到上限 2 → 128/96 (${GW}/${GH})`)
let e0 = null
try { configureWorld(0) } catch (e) { e0 = e.message }
ok(e0 === null && GW === 64 && GH === 48, `configureWorld(0) 不崩且回落默认 (${GW}/${GH})`)
let e1 = null
try { configureWorld('abc') } catch (e) { e1 = e.message }
ok(e1 === null && GW === 64 && GH === 48, `configureWorld('abc') 不崩且回落默认 (${GW}/${GH})`)
let e2 = null
try { configureWorld() } catch (e) { e2 = e.message }
ok(e2 === null && GW === 64 && GH === 48, `configureWorld() 默认 1 (${GW}/${GH})`)
configureWorld(1)
ok(GW === 64 && GH === 48 && WORLD.w === 5120 && WORLD.h === 3840, `恢复默认 64/48/5120/3840 (${GW}/${GH}/${WORLD.w}/${WORLD.h})`)
}
console.log('== B. 三个新 mod 注册与数据一致性 ==')
ok(MODS.length === 19, `内置 19 个 mod (${MODS.length})`)
const META2 = modMeta()
const m2 = (id) => META2.find((x) => x.id === id)
;[['wideworld', '辽阔世界', 0.9], ['camp', '营地', 0.75], ['labor', '劳务市场', 1.05]].forEach(([id, name, f]) => {
const m = m2(id)
ok(!!m && m.name === name && !!m.desc, `新 mod ${id} 名称/描述齐全 (${m && m.name})`)
ok(!!m && Math.abs(m.factor - f) < 1e-9, `新 mod ${id} 系数 ${f} (${m && m.factor})`)
})
{
const rw = activateMods(['wideworld'])
ok(rw.flags.worldScale === 1.5, `wideworld flag worldScale=1.5 (${rw.flags.worldScale})`)
ok(Object.keys(rw.entKinds).length === 0 && rw.recipes.length === RECIPES.length, 'wideworld 不含实体/配方/钩子')
ok(Object.keys(rw.hooks).length === 0, 'wideworld 无钩子')
}
const rtAll = activateMods(MODS.map((m) => m.id))
ok(rtAll.flags.worldScale === 1.5, '全量激活 worldScale=1.5')
;['camphouse', 'campstore'].forEach((t) => ok(!!rtAll.entKinds[t], `camp 实体 ${t} 已注册`))
;['laboroffice', 'laborwarehouse'].forEach((t) => ok(!!rtAll.entKinds[t], `labor 实体 ${t} 已注册`))
ok(!!rtAll.mobKinds.guard && rtAll.mon.guard.friendly === true, 'guard 生物已注册且 friendly')
ok(!!rtAll.mobKinds.laborfarmer && !!rtAll.mobKinds.laborherder, 'laborfarmer/laborherder 已注册')
{
const byCodeAll = Object.fromEntries(rtAll.recipes.map((r) => [r.code, r]))
;['campstore', 'camphouse', 'laboroffice', 'laborwarehouse'].forEach((code) => {
const r = byCodeAll[code]
ok(!!r, `新配方 ${code} 存在`)
Object.keys(r.cost).forEach((c) => ok(!!rtAll.items[c], `新配方 ${code} 材料 ${c} 可解析`))
ok(typeof rtAll.iconPainters[code] === 'function', `新配方 ${code} 有图标画法`)
ok(r.build === true && !!rtAll.entKinds[code], `新配方 ${code} build 且有同名实体`)
})
const ctx = makeCtx2()
let err = null
;['camphouse', 'campstore', 'laboroffice', 'laborwarehouse'].forEach((code) => {
try { rtAll.iconPainters[code](ctx) } catch (e) { err = `${code}: ${e.message}` }
})
ok(err === null, `新图标画法可执行 (${err || 'ok'})`)
}
{
const tc = activateMods(['camp']).tabs.map((t) => t.code)
ok(tc.includes('camp'), '单激活 camp 含营地页签')
ok(['tools', 'light', 'surv', 'fight', 'food', 'science', 'dress'].every((c) => tc.includes(c)), '基表 7 页签仍在(camp)')
const tl = activateMods(['labor']).tabs.map((t) => t.code)
ok(tl.includes('labor'), '单激活 labor 含劳务页签')
ok(['tools', 'light', 'surv', 'fight', 'food', 'science', 'dress'].every((c) => tl.includes(c)), '基表 7 页签仍在(labor)')
}
// camp 面板布局(与 camp.js onDrawUI 保持一致,用于换算命中点;相机置 0 时屏幕=世界)
function campLayout(W, H) {
const rowH = 30, headerH = 40, footerH = 42, pad = 10
const panelW = Math.min(470, W - 24)
const panelH = headerH + 8 * rowH + footerH + pad
const px = Math.round((W - panelW) / 2)
const py = Math.round((H - panelH) / 2)
const rightEdge = px + panelW - pad - 4
const btnH = 20, depW = 66, witW = 60, gap = 6
const rows = []
for (let i = 0; i < 8; i++) {
const rowY = py + headerH + i * rowH
const by = rowY + (rowH - 3 - btnH) / 2 + 1
rows.push({ dep: { x: rightEdge - witW - gap - depW, y: by, w: depW, h: btnH }, wit: { x: rightEdge - witW, y: by, w: witW, h: btnH } })
}
const close = { x: px + panelW / 2 - 60, y: py + panelH - footerH + 8, w: 120, h: 26 }
return { px, py, panelW, panelH, rows, close }
}
const C1 = (r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 })
console.log('== C. 营地 mod 行为级验证 ==')
const campHooks = (name) => hookFn(rt, name, 'camp')
{
const g = mkCG()
campHooks('onWorldGen')(g)
const ents = g.calls.addEntity.map((c) => c[0])
ok(ents.includes('camphouse'), 'onWorldGen 放出 camphouse')
ok(g.calls.spawnMob.some((s) => s[0] === 'guard'), 'onWorldGen 生成守卫')
ok(g.calls.addFire.length === 1 && g.calls.addFire[0].eternal === true, 'onWorldGen 放出 eternal 火焰')
const f = g.calls.addFire[0]
ok(f.pit === true && f.ttl === 150 && f.max === 150, `长明篝火 pit/ttl/max 正确 (${f && f.pit}/${f && f.ttl}/${f && f.max})`)
}
{
const g = mkCG({ day: 5 })
const house = { type: 'camphouse', x: 0, y: 0, id: 1 }
rt.entKinds.camphouse.init(house)
const p = { san: 100, hp: 100 }
rt.entKinds.camphouse.interact(p, house, g)
ok(p.san === 130 && p.hp === 120 && house.mx.restDay === 5, `首次休息 san+30/hp+20 (san=${p.san},hp=${p.hp},day=${house.mx.restDay})`)
const s = p.san, h = p.hp
rt.entKinds.camphouse.interact(p, house, g)
ok(p.san === s && p.hp === h, '同日二次休息无效')
rt.entKinds.camphouse.interact(p, house, mkCG({ day: 6 }))
ok(p.san === s + 30 && house.mx.restDay === 6, '次日可再休息')
const pFull = { san: 190, hp: 145 }
const hf = { type: 'camphouse', id: 2, mx: { restDay: 0 } }
rt.entKinds.camphouse.interact(pFull, hf, mkCG({ day: 6 }))
ok(pFull.san === 200 && pFull.hp === 150, `回复封顶 200/150 (${pFull.san}/${pFull.hp})`)
const pNoMx = { san: 10, hp: 10 }
const hNoMx = { type: 'camphouse', id: 3 }
let err = null
try { rt.entKinds.camphouse.interact(pNoMx, hNoMx, mkCG()) } catch (e) { err = e.message }
ok(err === null && hNoMx.mx && hNoMx.mx.restDay === 1, `无 mx 房屋交互自动补建 (${err || 'ok'})`)
}
{
const W = 800, H = 600
const g = mkCG()
const store = { type: 'campstore', x: 0, y: 0, id: 7 }
rt.entKinds.campstore.init(store)
ok(store.mx.boxes.length === 8, `营地仓储 8 个箱子 (${store.mx.boxes.length})`)
const p = mkPG([['log', 5], ['rock', 3]])
g.players = [p]; g.me = () => p; g.entities.push(store)
rt.entKinds.campstore.interact(p, store, g)
campHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 })
const L = campLayout(W, H)
ok(campHooks('onClick')(g, C1(L.rows[0].dep).x, C1(L.rows[0].dep).y) === true, '面板内存入按钮返回 true')
ok(store.mx.boxes[0].items.log === 5 && store.mx.boxes[0].items.rock === 3, `存入箱0 log5/rock3 (${JSON.stringify(store.mx.boxes[0].items)})`)
ok(countP(p, 'log') === 0 && countP(p, 'rock') === 0, '存入后背包物资被扣')
campHooks('onClick')(g, C1(L.rows[0].wit).x, C1(L.rows[0].wit).y)
ok(countP(p, 'log') === 5 && countP(p, 'rock') === 3, '取出归还背包')
ok(Object.keys(store.mx.boxes[0].items).length === 0, '取出后箱清空')
ok(campHooks('onClick')(g, C1(L.close).x, C1(L.close).y) === true, '关闭按钮返回 true')
ok(campHooks('onClick')(g, 10, 10) === undefined, '面板关闭后 onClick 返回 undefined')
}
{
const W = 800, H = 600
const g = mkCG()
const store = { type: 'campstore', x: 0, y: 0, id: 8 }
rt.entKinds.campstore.init(store)
const codes = ['log', 'rock', 'flint', 'grass', 'twig', 'berry', 'gold']
const p = mkPG(codes.map((c) => [c, 2]))
g.players = [p]; g.me = () => p; g.entities.push(store)
rt.entKinds.campstore.interact(p, store, g)
campHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 })
const L = campLayout(W, H)
campHooks('onClick')(g, C1(L.rows[0].dep).x, C1(L.rows[0].dep).y)
ok(Object.keys(store.mx.boxes[0].items).length === 6, `单箱最多 6 种(实际 ${Object.keys(store.mx.boxes[0].items).length})`)
ok(Object.values(store.mx.boxes[0].items).every((n) => n === 2), '6 种各存入 2 个')
campHooks('onClick')(g, C1(L.close).x, C1(L.close).y)
}
{
const W = 800, H = 600
const g = mkCG()
const store = { type: 'campstore', x: 0, y: 0, id: 9 }
rt.entKinds.campstore.init(store)
store.mx.boxes[0].items.log = 990
const p = mkPG([['log', 100]])
g.players = [p]; g.me = () => p; g.entities.push(store)
rt.entKinds.campstore.interact(p, store, g)
campHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 })
const L = campLayout(W, H)
campHooks('onClick')(g, C1(L.rows[0].dep).x, C1(L.rows[0].dep).y)
ok(store.mx.boxes[0].items.log === 999, `单种封顶 999(实际 ${store.mx.boxes[0].items.log})`)
ok(countP(p, 'log') === 91, `超出部分留在背包(实际 ${countP(p, 'log')})`)
campHooks('onClick')(g, C1(L.close).x, C1(L.close).y)
}
{
const W = 800, H = 600
const g = mkCG()
const store = { type: 'campstore', x: 0, y: 0, id: 10 }
rt.entKinds.campstore.init(store)
const p = mkPG()
g.players = [p]; g.me = () => p; g.entities.push(store)
rt.entKinds.campstore.interact(p, store, g)
campHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 })
const L = campLayout(W, H)
let err = null
try { campHooks('onClick')(g, C1(L.rows[2].wit).x, C1(L.rows[2].wit).y) } catch (e) { err = e.message }
ok(err === null, `空箱取出不崩 (${err || 'ok'})`)
campHooks('onClick')(g, C1(L.close).x, C1(L.close).y)
}
{
const hook = campHooks('onMobVictim')
const gIn = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }] })
ok(hook(gIn, { kind: 'spider' }, { x: 100, y: 0 }) === false, '敌对怪对营地内玩家返回 false')
ok(hook(gIn, { kind: 'spider' }, { x: 419, y: 0 }) === false, '边界 419 内 → false')
ok(hook(gIn, { kind: 'spider' }, { x: 421, y: 0 }) !== false, '边界 421 外 → 不否决')
ok(hook(gIn, { kind: 'beefalo' }, { x: 10, y: 0 }) !== false, '非敌对生物不处理')
// 没有营地房屋 = 没有安全区(不应凭空在世界中心生成无敌区)
const gNo = mkCG({ worldW: 1000, worldH: 800 })
ok(hook(gNo, { kind: 'spider' }, { x: 500, y: 400 }) !== false, '无房屋时不否决(不在世界中心凭空生成安全区)')
// 自建的分基地同样生效:任意一座房屋的 CAMP_R 内都算营地
const gTwo = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }, { type: 'camphouse', x: 5000, y: 0, id: 2 }] })
ok(hook(gTwo, { kind: 'spider' }, { x: 300, y: 0 }) === false, '一号房屋内 → false')
ok(hook(gTwo, { kind: 'spider' }, { x: 5300, y: 0 }) === false, '二号房屋(自建分基地)内 → 同样 false')
ok(hook(gTwo, { kind: 'spider' }, { x: 2500, y: 0 }) !== false, '两座房屋之间 → 不否决')
}
{
const g = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }], monsters: [] })
const guard = { kind: 'guard', x: 0, y: 0, id: 50, hp: 500, cd: 0 }
const spider = { kind: 'spider', x: 10, y: 0, id: 51, hp: 100 }
g.monsters.push(guard, spider)
rt.mobKinds.guard.update(guard, 0.1, g)
ok(guard.hp >= 1e8, `守卫无限血(hp=${guard.hp})`)
ok(g.calls.damageMob.length >= 1 && g.calls.damageMob[0][0] === spider, '守卫攻击营地内敌对生物')
const g2 = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }], monsters: [] })
const guard2 = { kind: 'guard', x: 0, y: 0, id: 1, hp: 1 }
g2.monsters.push(guard2)
let err = null
try { rt.mobKinds.guard.update(guard2, 0.1, g2) } catch (e) { err = e.message }
ok(err === null && g2.calls.damageMob.length === 0, `守卫无敌人时巡逻不崩 (${err || 'ok'})`)
}
{
const g = mkCG({ entities: [{ type: 'camphouse', x: 0, y: 0, id: 1 }] })
let err = null
try { campHooks('onDraw')(g, makeCtx2(), { camX: 0, camY: 0, W: 800, H: 600, time: 0 }) } catch (e) { err = e.message }
ok(err === null, `camp onDraw 假 ctx 不崩 (${err || 'ok'})`)
}
// labor 面板布局(与 labor.js storeLayout/whLayout 一致)
function laborStoreLayout(W, H) {
const pw = Math.min(560, W * 0.86)
const headH = 54, rowH = 78, footH = 78, nRows = 2
const ph = headH + nRows * rowH + footH
const px = (W - pw) / 2
const py = Math.max(14, (H - ph) / 2)
const bw = Math.min(88, pw * 0.22), bh = 42
const btns = []
const roles = ['farmer', 'herder']
for (let i = 0; i < nRows; i++) {
const rowY = py + headH + i * rowH
const by = rowY + (rowH - bh) / 2
btns.push({ id: roles[i] + ':hire', x: px + pw - bw * 2 - 30, y: by, w: bw, h: bh })
btns.push({ id: roles[i] + ':fire', x: px + pw - bw - 16, y: by, w: bw, h: bh })
}
const closeW = 104, closeH = 40
btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + ph - closeH - 14, w: closeW, h: closeH })
return { px, py, pw, ph, btns }
}
function laborWhLayout(W, H) {
const pw = Math.min(560, W * 0.86)
const headH = 46, footH = 46
const rowH = Math.max(16, Math.min(30, (H - headH - footH - 28) / 12))
const ph = headH + 12 * rowH + footH
const px = (W - pw) / 2
const py = Math.max(10, (H - ph) / 2)
const bw = Math.min(62, pw * 0.16), bh = Math.max(14, Math.min(20, rowH - 4))
const btns = []
for (let i = 0; i < 12; i++) {
const rowY = py + headH + i * rowH
const by = rowY + (rowH - bh) / 2
btns.push({ id: 'dep:' + i, x: px + pw - bw * 2 - 20, y: by, w: bw, h: bh })
btns.push({ id: 'wd:' + i, x: px + pw - bw - 10, y: by, w: bw, h: bh })
}
const closeW = 100, closeH = 30
btns.push({ id: 'close', x: px + (pw - closeW) / 2, y: py + headH + 12 * rowH + 6, w: closeW, h: closeH })
return { px, py, pw, ph, btns }
}
const btnC = (L, id) => { const b = L.btns.find((x) => x.id === id); return { x: b.x + b.w / 2, y: b.y + b.h / 2 } }
console.log('== D. 劳务市场 mod 行为级验证 ==')
const laborRt2 = activateMods(['labor'])
const laborHooks = (name) => hookFn(rt, name, 'labor')
const farmerUpdate = laborRt2.mobKinds.laborfarmer.update
const herderUpdate = laborRt2.mobKinds.laborherder.update
const mkLabG = (o = {}) => mkCG({ items: laborRt2.items, mon: laborRt2.mon, ...o })
ok(Object.keys(laborRt2.entKinds).join(',') === 'laboroffice,laborwarehouse', `labor 单激活实体齐全 (${Object.keys(laborRt2.entKinds).join(',')})`)
ok(!!laborRt2.mobKinds.laborfarmer && !!laborRt2.mobKinds.laborherder, 'labor 单激活生物齐全')
{
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'ready', mx: { seed: 'wheat', t: 999 } }
g.entities.push(wh, plot)
g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300, owner: null })
farmerUpdate(g.monsters[0], 0.1, g)
ok(wh.mx.boxes.some((b) => (b.items.wheat || 0) >= 3), `农民收割入库 wheat (${JSON.stringify(wh.mx.boxes[0].items)})`)
ok(plot.state === 'empty' && plot.mx === null, '收割后良田重置 empty')
}
{
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
wh.mx.boxes[0].items.wheatseeds = 1
const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'empty', mx: null }
g.entities.push(wh, plot)
g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300 })
farmerUpdate(g.monsters[0], 0.1, g)
ok(plot.state === 'grow0' && plot.mx && plot.mx.seed === 'wheat', `播种后 grow0 且 seed=作物 code (${plot.state}/${plot.mx && plot.mx.seed})`)
ok(plot.mx.seed !== 'wheatseeds' && !wh.mx.boxes.some((b) => b.items.wheatseeds), 'seed 是作物 code 而非种子 code,且种子被消耗')
}
{
const g = mkLabG({ findLand: () => ({ x: 500, y: 500 }) })
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
const grow = { type: 'agrifarm', x: 1000, y: 1000, id: 2, state: 'grow0', mx: { seed: 'wheat', t: 0 } }
g.entities.push(wh, grow)
g.monsters.push({ kind: 'laborfarmer', x: 500, y: 500, id: 3, hp: 300 })
farmerUpdate(g.monsters[0], 0.1, g)
ok(g.calls.addEntity.some((c) => c[0] === 'agrifarm'), '空闲田不足 3 时开垦新田 addEntity')
}
{
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
g.entities.push(wh)
g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300 })
let err = null
try { farmerUpdate(g.monsters[0], 0.1, g) } catch (e) { err = e.message }
ok(err === null && g.calls.addEntity.length === 0, `无农业 mod 时农民原地待命不崩 (${err || 'ok'})`)
}
{
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'ready', mx: { seed: 'wheat', t: 9 } }
g.entities.push(wh, plot)
g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300, strike: true })
farmerUpdate(g.monsters[0], 0.1, g)
ok(plot.state === 'ready' && !wh.mx.boxes.some((b) => b.items.wheat), '罢工农民无任何收割副作用')
}
{
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
wh.mx.boxes[0].items.berry = 1
const trough = { type: 'trough', x: 0, y: 0, id: 2, mx: { feed: 2 } }
g.entities.push(wh, trough)
g.monsters.push({ kind: 'laborherder', x: 0, y: 0, id: 3, hp: 300 })
herderUpdate(g.monsters[0], 0.1, g)
ok(trough.mx.feed === 3, `牧工投喂 feed 2→3 (${trough.mx.feed})`)
ok(!wh.mx.boxes.some((b) => (b.items.berry || 0) > 0), '投喂消耗仓库一份 berry')
}
{
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
const coop = { type: 'coop', x: 0, y: 0, id: 2, mx: { stored: 3 } }
g.entities.push(wh, coop)
g.monsters.push({ kind: 'laborherder', x: 0, y: 0, id: 3, hp: 300 })
herderUpdate(g.monsters[0], 0.1, g)
ok(wh.mx.boxes.some((b) => (b.items.egg || 0) >= 3), '牧工收蛋入库')
ok(coop.mx.stored === 0, '收蛋后鸡舍清零')
}
{
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
g.entities.push(wh)
g.monsters.push({ kind: 'laborherder', x: 0, y: 0, id: 3, hp: 300 })
let err = null
try { herderUpdate(g.monsters[0], 0.1, g) } catch (e) { err = e.message }
ok(err === null, `无饲料槽/鸡舍时牧工原地待命不崩 (${err || 'ok'})`)
}
// 面板:招聘所
{
const W = 800, H = 600
const g = mkLabG()
const office = { type: 'laboroffice', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laboroffice.init(office)
g.entities.push(office)
const p = mkPG([['berry', 20]])
g.players = [p]; g.me = () => p
laborRt2.entKinds.laboroffice.interact(p, office, g)
laborHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 })
const L = laborStoreLayout(W, H)
ok(laborHooks('onClick')(g, btnC(L, 'farmer:hire').x, btnC(L, 'farmer:hire').y) === true, '招聘按钮返回 true')
ok(g.calls.spawnMob.some((s) => s[0] === 'laborfarmer'), '招聘生成 laborfarmer')
ok(office.mx.farmer === 1, `office.mx.farmer=1 (${office.mx.farmer})`)
ok(countP(p, 'berry') === 12, `招聘扣 8 粮食 (剩 ${countP(p, 'berry')})`)
ok(laborHooks('onClick')(g, btnC(L, 'farmer:fire').x, btnC(L, 'farmer:fire').y) === true, '解雇按钮返回 true')
ok(office.mx.farmer === 0 && g.monsters.length === 0, `解雇后人数归零、mob 移除 (${office.mx.farmer}/${g.monsters.length})`)
ok(laborHooks('onClick')(g, btnC(L, 'close').x, btnC(L, 'close').y) === true, '关闭按钮返回 true')
ok(laborHooks('onClick')(g, 5, 5) === undefined, '面板关闭后 onClick 返回 undefined')
}
// 面板:粮食不足 / 上限 / 点面板外
{
const W = 800, H = 600
const g = mkLabG()
const office = { type: 'laboroffice', x: 0, y: 0, id: 2 }
laborRt2.entKinds.laboroffice.init(office)
g.entities.push(office)
const p = mkPG([['berry', 3]])
g.players = [p]; g.me = () => p
laborRt2.entKinds.laboroffice.interact(p, office, g)
laborHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 })
const L = laborStoreLayout(W, H)
laborHooks('onClick')(g, btnC(L, 'farmer:hire').x, btnC(L, 'farmer:hire').y)
ok(g.calls.spawnMob.length === 0 && countP(p, 'berry') === 3, '粮食不足招不到人且不扣粮')
office.mx.farmer = 4
const pRich = mkPG([['berry', 100]])
g.players = [pRich]; g.me = () => pRich
laborHooks('onClick')(g, btnC(L, 'farmer:hire').x, btnC(L, 'farmer:hire').y)
ok(g.calls.spawnMob.length === 0 && countP(pRich, 'berry') === 100, '达上限 4 招不到人且不扣粮')
ok(laborHooks('onClick')(g, 2, 2) === true, '点面板外返回 true(吃掉点击)')
ok(laborHooks('onClick')(g, 2, 2) === undefined, '点面板外后面板关闭(再次返回 undefined)')
}
// 面板:大仓库存取
{
const W = 800, H = 600
const g = mkLabG()
const wh = { type: 'laborwarehouse', id: 4 }
laborRt2.entKinds.laborwarehouse.init(wh)
g.entities.push(wh)
const p = mkPG([['log', 5]])
g.players = [p]; g.me = () => p
laborRt2.entKinds.laborwarehouse.interact(p, wh, g)
laborHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 })
const L = laborWhLayout(W, H)
laborHooks('onClick')(g, btnC(L, 'dep:0').x, btnC(L, 'dep:0').y)
ok(wh.mx.boxes[0].items.log === 5 && countP(p, 'log') === 0, `大仓库存入箱0 (${JSON.stringify(wh.mx.boxes[0].items)})`)
laborHooks('onClick')(g, btnC(L, 'wd:0').x, btnC(L, 'wd:0').y)
ok(countP(p, 'log') === 5 && Object.keys(wh.mx.boxes[0].items).length === 0, '大仓库取出归还')
laborHooks('onClick')(g, btnC(L, 'close').x, btnC(L, 'close').y)
ok(laborHooks('onClick')(g, 5, 5) === undefined, '仓库面板关闭后 onClick 返回 undefined')
}
// 连续招聘到满员
{
const W = 800, H = 600
const g = mkLabG()
const office = { type: 'laboroffice', x: 0, y: 0, id: 5 }
laborRt2.entKinds.laboroffice.init(office)
g.entities.push(office)
const p = mkPG([['berry', 100]])
g.players = [p]; g.me = () => p
laborRt2.entKinds.laboroffice.interact(p, office, g)
laborHooks('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W, H, time: 0 })
const L = laborStoreLayout(W, H)
for (let i = 0; i < 5; i++) laborHooks('onClick')(g, btnC(L, 'farmer:hire').x, btnC(L, 'farmer:hire').y)
ok(office.mx.farmer === 4, `连续招聘封顶 4 (${office.mx.farmer})`)
ok(g.monsters.length === 4, `工人数 4 (${g.monsters.length})`)
ok(countP(p, 'berry') === 68, `招聘扣费 4×8 (剩 ${countP(p, 'berry')})`)
laborHooks('onClick')(g, btnC(L, 'close').x, btnC(L, 'close').y)
}
// 工资结算
{
const g = mkLabG()
const office = { type: 'laboroffice', x: 0, y: 0, id: 5 }
laborRt2.entKinds.laboroffice.init(office); office.mx.farmer = 1
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 6 }
laborRt2.entKinds.laborwarehouse.init(wh)
wh.mx.boxes[0].items.berry = 10
g.entities.push(office, wh)
const m1 = { kind: 'laborfarmer', x: 0, y: 0, id: 901, office: 5 }
g.monsters.push(m1)
const p = mkPG(); g.players = [p]; g.me = () => p
laborHooks('onNewDay')(g)
ok(!m1.strike, '粮食充足 → 不罢工')
ok(wh.mx.boxes.reduce((n, b) => n + (b.items.berry || 0), 0) === 8, `工资从仓库扣 2 (${wh.mx.boxes.reduce((n, b) => n + (b.items.berry || 0), 0)})`)
const g2 = mkLabG()
const office2 = { type: 'laboroffice', x: 0, y: 0, id: 5 }
laborRt2.entKinds.laboroffice.init(office2); office2.mx.farmer = 1
const wh2 = { type: 'laborwarehouse', x: 0, y: 0, id: 6 }
laborRt2.entKinds.laborwarehouse.init(wh2)
g2.entities.push(office2, wh2)
const m2 = { kind: 'laborfarmer', x: 0, y: 0, id: 902, office: 5 }
g2.monsters.push(m2)
const p2 = mkPG(); g2.players = [p2]; g2.me = () => p2
laborHooks('onNewDay')(g2)
ok(m2.strike === true, '粮食不足 → 罢工')
}
// 存档往返
{
const g = mkLabG()
const office = { type: 'laboroffice', x: 0, y: 0, id: 5 }
laborRt2.entKinds.laboroffice.init(office); office.mx.farmer = 2; office.mx.hired = 2
g.entities.push(office)
g.monsters.push({ kind: 'laborfarmer', x: 100, y: 120, id: 901, office: 5, strike: true })
g.monsters.push({ kind: 'laborherder', x: 130, y: 140, id: 902, office: 5 })
const p = mkPG(); g.players = [p]; g.me = () => p
const data = laborHooks('save')(g)
let jsonOk = true
try { JSON.parse(JSON.stringify(data)) } catch (e) { jsonOk = false }
ok(jsonOk && data && data.offices.length === 1 && data.workers.length === 2, 'save 可 JSON 序列化且含 1 所 2 工')
const g2 = mkLabG()
const office2 = { type: 'laboroffice', x: 0, y: 0, id: 5 }
laborRt2.entKinds.laboroffice.init(office2)
g2.entities.push(office2)
const p2 = mkPG(); g2.players = [p2]; g2.me = () => p2
laborHooks('load')(g2, data)
ok(office2.mx.farmer === 2, `load 复原 office.mx (${office2.mx.farmer})`)
ok(g2.monsters.length === 2, `load 复原 2 名工人 (${g2.monsters.length})`)
ok(g2.monsters.some((m) => m.strike === true), 'load 复原罢工状态')
const g3 = mkLabG()
const p3 = mkPG(); g3.players = [p3]; g3.me = () => p3
let err = null
try { laborHooks('load')(g3, data) } catch (e) { err = e.message }
ok(err === null && g3.monsters.length === 0, `load 失配数据不抛异常 (${err || 'ok'})`)
}
console.log('== E. 持久化迁移回归(e.mx.*)==')
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
{
const g = mkCG()
const p = mkPG([['wheat', 4]])
g.players = [p]; g.me = () => p
const gr = { type: 'granary', id: 1 }
rt.entKinds.granary.init(gr)
rt.entKinds.granary.interact(p, gr, g)
ok(gr.mx && gr.mx.store && gr.mx.store.wheat === 4, `粮仓存 e.mx.store (${JSON.stringify(gr.mx && gr.mx.store)})`)
ok(!has(gr, 'store') && !has(gr, 'feed') && !has(gr, 'stored'), '粮仓无残留 e.store/e.feed/e.stored')
}
{
const g = mkCG()
const p = mkPG([['berry', 2]])
g.players = [p]; g.me = () => p
const tr = { type: 'trough', id: 1 }
rt.entKinds.trough.init(tr)
rt.entKinds.trough.interact(p, tr, g)
ok(tr.mx && tr.mx.feed === 1, `饲料槽存 e.mx.feed (${tr.mx && tr.mx.feed})`)
ok(!has(tr, 'feed') && !has(tr, 'store') && !has(tr, 'stored'), '饲料槽无残留旧字段')
}
{
const coop = { type: 'coop', x: 0, y: 0, id: 1 }
rt.entKinds.coop.init(coop)
const g = mkCG({ entities: [coop] })
hookFn(rt, 'onNewDay', 'husbandry')(g)
ok(coop.mx && coop.mx.stored >= 1, `鸡舍存 e.mx.stored (${coop.mx && coop.mx.stored})`)
ok(!has(coop, 'stored') && !has(coop, 'feed') && !has(coop, 'store'), '鸡舍无残留旧字段')
}
{
let err = null
;['agri', 'husbandry', 'camp', 'labor'].forEach((id) => {
const r = activateMods([id])
Object.entries(r.entKinds).forEach(([type, def]) => {
if (!def) return
const e = { type, id: 1 }
if (typeof def.init === 'function') {
try { def.init(e) } catch (ex) { err = `${id}/${type}.init: ${ex.message}` }
}
if (has(e, 'store')) err = `${id}/${type} 残留 e.store`
if (has(e, 'feed')) err = `${id}/${type} 残留 e.feed`
if (has(e, 'stored')) err = `${id}/${type} 残留 e.stored`
})
})
ok(err === null, `空实体 init 全部安全、无残留旧字段 (${err || 'ok'})`)
}
console.log('== F. 边界与错误路径 ==')
{
const rc = activateMods(['camp'])
const ch = (n) => hookFn(rc, n, 'camp')
const g = mkCG({ mon: rc.mon, items: rc.items })
let err = null
try {
ch('onWorldGen')(g)
const t = ch('onTick'); if (t) t(g, 0.016)
ch('onDraw')(g, makeCtx2(), { camX: 0, camY: 0, W: 800, H: 600, time: 0 })
ch('onMobVictim')(g, { kind: 'spider' }, { x: 0, y: 0 })
ch('onDrawUI')(g, makeCtx2(), { camX: 0, camY: 0, W: 800, H: 600, time: 0 })
} catch (e) { err = e.message }
ok(err === null, `仅激活 camp 各钩子不崩 (${err || 'ok'})`)
}
{
const rw = activateMods(['wideworld'])
ok(rw.flags.worldScale === 1.5, '仅激活 wideworld worldScale=1.5')
ok(Object.keys(rw.entKinds).length === 0, '仅激活 wideworld 无实体')
}
{
// 没种子 → 退回默认作物
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'empty', mx: null }
g.entities.push(wh, plot)
g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300 })
farmerUpdate(g.monsters[0], 0.1, g)
ok(plot.state === 'grow0' && plot.mx && plot.mx.seed === 'wheat', `无种子退回默认作物 wheat (${plot.mx && plot.mx.seed})`)
}
{
// 仓库满 → 收获溢出掉落
const g = mkLabG()
const wh = { type: 'laborwarehouse', x: 0, y: 0, id: 1 }
laborRt2.entKinds.laborwarehouse.init(wh)
wh.mx.boxes.forEach((b) => { b.items = { wheat: 999, berry: 999, log: 999, rock: 999, flint: 999, grass: 999 } })
const plot = { type: 'agrifarm', x: 0, y: 0, id: 2, state: 'ready', mx: { seed: 'wheat', t: 9 } }
g.entities.push(wh, plot)
g.monsters.push({ kind: 'laborfarmer', x: 0, y: 0, id: 3, hp: 300 })
farmerUpdate(g.monsters[0], 0.1, g)
ok(g.calls.drops.some((d) => d[0] === 'wheat'), '仓库满 → 收获溢出掉落')
ok(plot.state === 'empty', '仓库满仍完成收割')
}
{
// 缺 mx / 缺 boxes 状态下 draw 不崩
const ctx = makeCtx2()
let err = null
try {
Object.entries(activateMods(['camp']).entKinds).forEach(([t, def]) => {
const e = { type: t, id: 1, x: 0, y: 0 }
if (def.draw) def.draw(ctx, e, { time: 0 })
if (def.update) def.update(e, 0.1, mkCG())
})
Object.entries(activateMods(['labor']).entKinds).forEach(([t, def]) => {
const e = { type: t, id: 1, x: 0, y: 0 }
if (def.draw) def.draw(ctx, e)
if (def.update) def.update(e, 0.1, mkLabG())
})
laborRt2.entKinds.laborwarehouse.draw(ctx, { type: 'laborwarehouse', id: 1, mx: null })
laborRt2.entKinds.laboroffice.draw(ctx, { type: 'laboroffice', id: 1, mx: null })
} catch (e) { err = e.message }
ok(err === null, `缺 mx/boxes 状态下实体 draw/update 不崩 (${err || 'ok'})`)
}
{
// camp/labor 工人 draw 假 ctx 不崩(含罢工标记分支)
const ctx = makeCtx2()
let err = null
try {
rt.mobKinds.guard.draw(ctx, { dir: 1, moving: true, bob: 0, id: 1 }, 1)
laborRt2.mobKinds.laborfarmer.draw(ctx, { dir: 1, moving: true, bob: 0, strike: true, id: 1 }, 1)
laborRt2.mobKinds.laborherder.draw(ctx, { dir: -1, moving: false, bob: 0, strike: true, id: 2 }, 1)
} catch (e) { err = e.message }
ok(err === null, `守卫/农民/牧工 draw 假 ctx 不崩 (${err || 'ok'})`)
}
console.log('== 核心钩子契约(防回归)==')
{
const fs = await import('node:fs')
const url = (p) => new URL(p, import.meta.url)
const vue = fs.readFileSync(url('./src/games/components/StarveGame.vue'), 'utf8')
const mobsSrc = fs.readFileSync(url('./src/games/starve/mobs.js'), 'utf8')
// 1) g.allows 必须把 gCtx 作为钩子首参,否则 mod 侧 (g, ...) 签名整体错位
// 真实事故:onMobVictim 实际收到 (怪, 玩家, undefined),camp.js 里 g.mon 变成 undefined 而崩
const ad = vue.match(/allows:\s*\(name,\s*\.\.\.args\)\s*=>\s*callAllows\([^\n]*/)
ok(!!ad && ad[0].includes('gCtx'), `g.allows 前置 gCtx (${ad ? ad[0].trim() : '未找到 allows 定义'})`)
// 2) 所有 core→mod 钩子调用都必须以 gCtx 作为第一个可变参
const bad = []
let hookCalls = 0
for (const m of vue.matchAll(/call(?:Hook|Handled|Allows)\(\s*modRt\s*,\s*'([^']+)'\s*,\s*([^)]*)/g)) {
hookCalls++
if (!m[2].trim().startsWith('gCtx')) bad.push(m[1])
}
ok(bad.length === 0, `核心钩子全部传 gCtx (${bad.length ? '缺: ' + bad.join(',') : hookCalls + ' 处'})`)
// 3) mod 侧每个钩子的首参必须是 g(位置契约一致)
const modFiles = fs.readdirSync(url('./src/games/starve/mods'))
.filter((f) => f.endsWith('.js') && f !== 'index.js' && f !== 'registry.js')
const badSig = []
let hookDefs = 0
for (const f of modFiles) {
const src = fs.readFileSync(url('./src/games/starve/mods/' + f), 'utf8')
for (const m of src.matchAll(/^\s{4}(on[A-Z][A-Za-z]*)\(([^)]*)\)/gm)) {
hookDefs++
const first = (m[2].split(',')[0] || '').trim()
if (first !== 'g') badSig.push(`${f}:${m[1]}(${m[2]})`)
}
}
ok(badSig.length === 0, `mod 钩子首参都是 g (${badSig.length ? badSig.join('; ') : hookDefs + ' 个钩子 / ' + modFiles.length + ' 个 mod'})`)
// 4) 否决钩子的调用点必须真实存在,否则 onMobVictim 形同虚设
ok(/g\.allows\(/.test(mobsSrc), 'mobs.js 里有 g.allows 调用点')
// 5) 实体/生物回调签名(核心侧)
ok(/modEnt\.interact\(p, e, gCtx\)|mk\.interact\(p, ref, gCtx\)/.test(vue), '核心以 (p, e, g) 调实体 interact')
ok(/mk\.update\(m, dt, gCtx\)|modEnt\.update\?\.\(e, dt, gCtx\)/.test(vue), '核心以 (m/e, dt, g) 调 update')
}
console.log(fails ? `\n${fails} 项失败` : '\n全部通过')
process.exit(fails ? 1 : 0)