1248 lines
61 KiB
JavaScript
1248 lines
61 KiB
JavaScript
// 临时冒烟测试:纯逻辑模块(不触 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', cd: 0, id: 1 }
|
||
rt.entKinds.well.interact(p, well, g)
|
||
ok(p.san === 55 && well.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.shearDay === 3, `剪毛得羊毛2 (wool=${g.inv.wool})`)
|
||
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', on: true, 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', on: false, 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', on: true, 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', on: true, 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', fuel: 0, on: false, id: 1 }
|
||
rt.entKinds.steamengine.interact({}, eng, g)
|
||
ok(eng.fuel === 3 && g.inv.log === 0, `投 log×1 燃料+3 (fuel=${eng.fuel})`)
|
||
const gNo = makeG()
|
||
rt.entKinds.steamengine.interact({}, { type: 'steamengine', fuel: 0, id: 2 }, gNo)
|
||
ok(gNo.inv.log === undefined, '无燃料不消耗')
|
||
hookFn(rt, 'onNewDay', 'industry')(makeG({ entities: [eng] }))
|
||
ok(eng.fuel === 2, `蒸汽机每日耗 1 (fuel=${eng.fuel})`)
|
||
const eng2 = { type: 'steamengine', fuel: 1, on: true, id: 3 }
|
||
hookFn(rt, 'onNewDay', 'industry')(makeG({ entities: [eng2] }))
|
||
ok(eng2.fuel === 0 && eng2.on === false, `燃料归零停转 (fuel=${eng2.fuel}, on=${eng2.on})`)
|
||
eng.fuel = 2; eng.on = false
|
||
rt.entKinds.steamengine.update(eng, 0.1, makeG())
|
||
ok(eng.on === true, 'fuel>0 时 update 置 on=true')
|
||
}
|
||
|
||
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', on: true, 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, '无房屋时以世界中心判定 → false')
|
||
ok(hook(gNo, { kind: 'spider' }, { x: 920, y: 400 }) !== 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(fails ? `\n${fails} 项失败` : '\n全部通过')
|
||
process.exit(fails ? 1 : 0)
|