// 临时冒烟测试:纯逻辑模块(不触 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)