From d7ef97d46e643dc216a855a731010a10effac047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B9=B4=E7=B3=95=E5=B4=BD=E5=B4=BD?= Date: Thu, 24 Sep 2026 10:52:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=A5=A5=E8=8D=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/games/components/StarveGame.vue | 3 +- src/games/starve/mods/agri.js | 14 +++-- src/games/starve/mods/camp.js | 27 ++++++---- src/games/starve/mods/husbandry.js | 7 ++- src/games/starve/mods/industry.js | 24 +++++---- test-starve.mjs | 84 +++++++++++++++++++++++------ 6 files changed, 114 insertions(+), 45 deletions(-) diff --git a/src/games/components/StarveGame.vue b/src/games/components/StarveGame.vue index c360aeb..39815de 100644 --- a/src/games/components/StarveGame.vue +++ b/src/games/components/StarveGame.vue @@ -1384,7 +1384,8 @@ const gCtx = { return f }, // 否决型钩子调用器(供 mod 使用):任一 mod 返回 false 即返回 false - allows: (name, ...args) => callAllows(modRt, name, ...args), + // 与核心其它钩子一致,gCtx 作为钩子首参自动前置,mod 侧签名统一为 (g, ...) + allows: (name, ...args) => callAllows(modRt, name, gCtx, ...args), me: () => player, isHost: () => isHostRole(), invalidate: () => { invDirty = true }, diff --git a/src/games/starve/mods/agri.js b/src/games/starve/mods/agri.js index e0e808b..2f14e1c 100644 --- a/src/games/starve/mods/agri.js +++ b/src/games/starve/mods/agri.js @@ -338,18 +338,22 @@ export default { name: '水井', conf: { size: 30, struct: true }, init(e) { - if (e.cd == null) e.cd = 0 + // 冷却必须放在 e.mx 里:核心只序列化 e.mx(+ e.state),放 e.cd 会存档/联机丢失 + if (!e.mx) e.mx = { cd: 0 } + if (e.mx.cd == null) e.mx.cd = 0 }, update(e, dt, g) { - if (e.cd > 0) e.cd = Math.max(0, e.cd - dt) + const cd = e.mx?.cd || 0 + if (cd > 0) e.mx.cd = Math.max(0, cd - dt) }, interact(p, e, g) { - if (e.cd > 0) { - g.ptip(p, `井水还没续满……(${Math.ceil(e.cd)} 秒)`) + const cd = e.mx?.cd || 0 + if (cd > 0) { + g.ptip(p, `井水还没续满……(${Math.ceil(cd)} 秒)`) return } p.san += 5 - e.cd = 60 + e.mx = { ...(e.mx || {}), cd: 60 } g.markDirty(e) g.swing(p, 'eat') g.ptip(p, '打了一瓢清水,理智 +5') diff --git a/src/games/starve/mods/camp.js b/src/games/starve/mods/camp.js index 280b07a..80578b6 100644 --- a/src/games/starve/mods/camp.js +++ b/src/games/starve/mods/camp.js @@ -94,6 +94,15 @@ function campCenter(g) { if (house) return { x: house.x, y: house.y } return { x: g.worldW / 2, y: g.worldH / 2 } } +// 全部营地房屋(用于绘制每一座的安全区边界) +function campHouseList(g) { + return g.entities.filter((e) => e.type === 'camphouse') +} +// 玩家在「任意一座」营地房屋的 CAMP_R 内都算进入营地 —— 自建的分基地同样生效。 +// ⚠️ 这里不能用 find 只取第一座,否则玩家再造房屋不会扩展安全区。 +function inCamp(g, x, y) { + return g.entities.some((e) => e.type === 'camphouse' && Math.hypot(x - e.x, y - e.y) < CAMP_R) +} export default { id: 'camp', name: '营地', @@ -435,21 +444,21 @@ export default { // 只对敌对生物生效(保留「怪可以去打守卫/猪人」的能力)。 if (!g.mon[m.kind]?.hostile) return // 判断依据是「受害者在不在营地内」,怪本身不做任何位移(无跳变)。 - const center = g.entities.find((e) => e.type === 'camphouse') || { x: g.worldW / 2, y: g.worldH / 2 } - if (Math.hypot(victim.x - center.x, victim.y - center.y) < CAMP_R) return false + if (inCamp(g, victim.x, victim.y)) return false }, onDraw(g, ctx, env) { - // 虚线画出营地领地边界(世界坐标位置,画布为屏幕坐标系,故减去相机) - const c = campCenter(g) - const sx = c.x - env.camX - const sy = c.y - env.camY + // 虚线画出**每一座**营地房屋的领地边界(世界坐标 → 画布屏幕坐标,故减去相机) + const houses = campHouseList(g) + const list = houses.length ? houses : [campCenter(g)] ctx.save() ctx.strokeStyle = 'rgba(255,220,140,0.35)' ctx.lineWidth = 2 ctx.setLineDash([10, 8]) - ctx.beginPath() - ctx.ellipse(sx, sy, CAMP_R, CAMP_R * 0.62, 0, 0, TAU) - ctx.stroke() + for (const h of list) { + ctx.beginPath() + ctx.ellipse(h.x - env.camX, h.y - env.camY, CAMP_R, CAMP_R * 0.62, 0, 0, TAU) + ctx.stroke() + } ctx.restore() }, onDrawUI(g, ctx, env) { diff --git a/src/games/starve/mods/husbandry.js b/src/games/starve/mods/husbandry.js index 0545b78..9b48f79 100644 --- a/src/games/starve/mods/husbandry.js +++ b/src/games/starve/mods/husbandry.js @@ -341,12 +341,15 @@ export default { g.ptip(p, '需要剪刀才能剪羊毛') return } - if (m.shearDay === g.day) { + // 剪毛冷却记在 m.state 上:mob 只持久化 state / loyalT 等少数字段, + // 自定义字段(如 m.shearDay)存档与联机都会丢,会导致读档后可重复剪毛 + const sheared = Number(String(m.state || '').replace('sheared:', '')) || -1 + if (sheared === g.day) { g.ptip(p, '羊毛还没长回来(需等到明天)') return } g.addItem(p, 'wool', 2) - m.shearDay = g.day + m.state = `sheared:${g.day}` g.ptip(p, '给绵羊剪下一团羊毛') g.swing(p, 'chop') }, diff --git a/src/games/starve/mods/industry.js b/src/games/starve/mods/industry.js index 92acdec..db828db 100644 --- a/src/games/starve/mods/industry.js +++ b/src/games/starve/mods/industry.js @@ -277,18 +277,20 @@ export default { name: '蒸汽机', conf: { size: 38, struct: true }, init(e) { - if (e.fuel == null) e.fuel = 0 - if (e.on == null) e.on = false + // 燃料必须放在 e.mx 里:核心只序列化 e.mx(+ e.state),放 e.fuel 会存档/联机丢失 + if (!e.mx) e.mx = { fuel: 0 } + if (e.mx.fuel == null) e.mx.fuel = 0 + e.on = e.mx.fuel > 0 }, update(e, dt, g) { - e.on = (e.fuel || 0) > 0 + e.on = (e.mx?.fuel || 0) > 0 }, interact(p, e, g) { if (g.hasCost(p, { log: 1 })) { g.payCost(p, { log: 1 }) - e.fuel = (e.fuel || 0) + 3 + e.mx = { ...(e.mx || {}), fuel: (e.mx?.fuel || 0) + 3 } g.markDirty(e) - g.ptip(p, `投入木头,蒸汽机燃料剩余 ${e.fuel}`) + g.ptip(p, `投入木头,蒸汽机燃料剩余 ${e.mx.fuel}`) } else { g.ptip(p, '蒸汽机需要木头作燃料') } @@ -341,7 +343,7 @@ export default { name: '工厂', conf: { size: 40, struct: true }, interact(p, e, g) { - const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - e.x, s.y - e.y) < STEAM_RANGE) + const powered = g.entities.some((s) => s.type === 'steamengine' && (s.mx?.fuel || 0) > 0 && Math.hypot(s.x - e.x, s.y - e.y) < STEAM_RANGE) if (!powered) { g.ptip(p, '需要通蒸汽动力(在附近建蒸汽机并加燃料)') return @@ -402,7 +404,7 @@ export default { name: '自动矿机', conf: { size: 34, struct: true }, interact(p, e, g) { - const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - e.x, s.y - e.y) < STEAM_RANGE) + const powered = g.entities.some((s) => s.type === 'steamengine' && (s.mx?.fuel || 0) > 0 && Math.hypot(s.x - e.x, s.y - e.y) < STEAM_RANGE) g.ptip(p, powered ? '矿机正在满速运转(每台每天自动开采)' : '缺少蒸汽动力,效率减半') }, draw(ctx, e, env) { @@ -450,14 +452,14 @@ export default { onNewDay(g) { // 自动矿机开采:附近有供能的蒸汽机则翻倍 g.entities.filter((e) => e.type === 'miner').forEach((m) => { - const powered = g.entities.some((s) => s.type === 'steamengine' && s.on && Math.hypot(s.x - m.x, s.y - m.y) < STEAM_RANGE) + const powered = g.entities.some((s) => s.type === 'steamengine' && (s.mx?.fuel || 0) > 0 && Math.hypot(s.x - m.x, s.y - m.y) < STEAM_RANGE) g.dropLoot(m.x + g.rand(-24, 24), m.y + g.rand(-24, 24), 'rock', powered ? 4 : 2) g.dropLoot(m.x + g.rand(-24, 24), m.y + g.rand(-24, 24), 'flint', powered ? 2 : 1) }) - // 蒸汽机每日消耗 1 份燃料 + // 蒸汽机每日消耗 1 份燃料(燃料存在 e.mx.fuel,随存档与联机同步) g.entities.filter((e) => e.type === 'steamengine').forEach((s) => { - s.fuel = Math.max(0, (s.fuel || 0) - 1) - s.on = s.fuel > 0 + s.mx = { ...(s.mx || {}), fuel: Math.max(0, (s.mx?.fuel || 0) - 1) } + s.on = s.mx.fuel > 0 g.markDirty(s) }) }, diff --git a/test-starve.mjs b/test-starve.mjs index e2c56ce..20689a8 100644 --- a/test-starve.mjs +++ b/test-starve.mjs @@ -375,9 +375,9 @@ console.log('== 新 mod 行为级验证 ==') { const g = makeG() const p = { san: 50 } - const well = { type: 'well', cd: 0, id: 1 } + const well = { type: 'well', mx: { cd: 0 }, id: 1 } rt.entKinds.well.interact(p, well, g) - ok(p.san === 55 && well.cd === 60, `打水 san+5 冷却 60s (${p.san})`) + ok(p.san === 55 && well.mx.cd === 60, `打水 san+5 冷却 60s (${p.san})`) rt.entKinds.well.interact(p, well, g) ok(p.san === 55, '冷却中不再加理智') } @@ -409,7 +409,7 @@ console.log('== 新 mod 行为级验证 ==') 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})`) + 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, '同日重复剪毛不再产出') @@ -453,15 +453,15 @@ console.log('== 新 mod 行为级验证 ==') 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 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', on: false, x: 0, y: 0, id: 10 } + 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', on: true, x: 999, y: 0, id: 11 } + 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 不出齿轮') @@ -476,7 +476,7 @@ console.log('== 新 mod 行为级验证 ==') })) 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 } + 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]) }, @@ -486,20 +486,22 @@ console.log('== 新 mod 行为级验证 ==') // --- 工业蒸汽机 --- { const g = makeG({ grant: [['log', 1]] }) - const eng = { type: 'steamengine', fuel: 0, on: false, id: 1 } + const eng = { type: 'steamengine', mx: { fuel: 0 }, id: 1 } rt.entKinds.steamengine.interact({}, eng, g) - ok(eng.fuel === 3 && g.inv.log === 0, `投 log×1 燃料+3 (fuel=${eng.fuel})`) + 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', fuel: 0, id: 2 }, gNo) + 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.fuel === 2, `蒸汽机每日耗 1 (fuel=${eng.fuel})`) - const eng2 = { type: 'steamengine', fuel: 1, on: true, id: 3 } + 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.fuel === 0 && eng2.on === false, `燃料归零停转 (fuel=${eng2.fuel}, on=${eng2.on})`) - eng.fuel = 2; eng.on = false + 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 独立性 ==') @@ -514,7 +516,7 @@ console.log('== 跨 mod 独立性 ==') 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 }], + 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'})`) @@ -822,9 +824,14 @@ const campHooks = (name) => hookFn(rt, name, 'camp') 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, '无房屋时中心外 → 不否决') + 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: [] }) @@ -1243,5 +1250,48 @@ console.log('== F. 边界与错误路径 ==') 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)