更新饥荒

This commit is contained in:
2026-09-24 10:52:55 +08:00
parent 1df33337e9
commit d7ef97d46e
6 changed files with 114 additions and 45 deletions

View File

@@ -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 },

View File

@@ -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')

View File

@@ -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) {

View File

@@ -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')
},

View File

@@ -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)
})
},

View File

@@ -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)