04 · 市民集体饥饿(B-7)· 正式 Bug 报告 + 技术根因
本文件的用途:一份两用文档。
- 第 I 部分 = 正式 Bug 报告(
SUK-HUNGER-001):可直接提交给模组作者,含严重级、发生次数、复现步骤、影响范围、期望/实际。
- 第 II 部分 = 技术根因:逐行字节码取证,供开发者定位与修 bug、供你自查。
关联:缺陷台账见 03;饥饿引发的税收/商店停摆见 02 与 03 的 B-10/B-11。
📐 城市区块双量纲(计量关系 · 重点):模组存在两个不同量纲,务必区分:
21×21 = 441 = 城市面积配额(本城已用满):city_levels.json 的 unlocks.chunks 为奇数平方序列 9,9,25,49,81,121,169,225,289,361,**441**,-1(=3²,5²,…,19²,21²)。本城 city_level=11(大都市)→ 配额 441 = 21×21 区块;city_chunks 实测 441 行已占满。
1 区块 = 16×16 方块(MC 真实长度):用户实测城市区块约 19×16 = 304 平方单位("19"为含间隙的粗略测值,对齐值是 16)。即模组的「城市区块」是自有计量单位,不等于 MC 原生区块;换算真实世界长度按 16×16 方块/区块 计。
- 注:外卖员属于另一个模组(外卖来了之类),与本案饥饿无关。
第 I 部分 · 正式 Bug 报告
| 字段 |
内容 |
| Bug ID |
SUK-HUNGER-001 |
| 标题 |
面包店建筑、店内小麦均充足,但店员/外卖员/周边居民集体饥饿;解雇重雇即"变饱"(约 90 分钟后复发);部分市民手持面包 |
| 严重级别 |
Critical / 严重 —— 市民持续掉饥饿 → 店员罢工 → 商铺停摆 → 企业税(25%)同步归零 → 人口幸福受损直至饿死 |
| 当前状态 |
未修复(机制已 100% 定位,待官方跟进) |
| 发生次数 |
第 5 次(本次),累计已观察 5 次 |
| 第 1 次 |
2026-09-10 夜(存档快照 22:22–22:23) |
| 第 2 次 |
2026-09-11 清晨 06:52(跨几十个城市区块、2 家不同面包店同时命中) |
| 第 3 次 |
2026-09-11 16:23 |
| 第 4 次 |
2026-09-12(用户从西部传送回东部后复现;东部 1 号店、东部 2 号店、西部店同症状;西部 2 号店「刚建好、刚雇佣」反而正常) |
| 第 5 次(本次) |
2026-09-12 17:50(用户故意保持故障态、不解雇重雇,直接顺故障排查)。站到问题面包店内(区块已加载)⇒ 店员头顶仍「工作中 / 很饿了」、commercial_boxes 与 simukraft_commercial_boxes.dat 仍为 0 行 / 空列表 ⇒ 推翻"走远才注销、站回即恢复"的单一解释,坐实为「注册表已被清空 + 无任何自动重建路径」 |
| 复现性质 |
确定性复现(同一套建筑模板必现);跨区块、跨店铺稳定再现 |
| 版本 |
New-Sim-U-Kraft-2.2.0-neoforge-1.21.1(整合包《新:模拟大都市:启航 2.8fix5》) |
| 存档 |
saves/新:模拟大都市:启航 2_8fix5/ |
| 城市 |
新模拟大都市启航(city_id=8bd15f58-6f5d-4cb7-ba09-221cd3dbc2b9);当前等级 11(大都市,配额 441 已占满);早期报告曾记等级 9(等级随发展上升) |
1. 摘要(一句话,2026-09-12 修订版)
「面包店还在、箱里有大量小麦、盒子甚至显示营业中」 ≠ 「市民能买到面包」。 能否供食卡在「食物来源(面包店盒子)是否处于可运行态」上——一旦盒子因区块卸载被 B-10 静默注销、或盒内店员自身饥饿罢工(isOnHungerStrike,hunger≤0) 导致 tickBox 早退不放货,市民侧 findPurchasePlan 就找不到任何候选店 → 进入 too_hungry_on_strike(太饿罢工)并永久卡死、系统无任何自愈路径。
⚠️ 第 4 次复现推翻了前 3 次的「小麦 < 3 / 不在供给窗口」假设:本次用户明确「盒 2 格区域的箱子里有非常多小麦」,店员与居民仍全体饥饿。小麦数量不再是决定因素,决定因素变成「盒子是否在运行 / 店员是否罢工」。前 3 次报告里的「小麦耗尽」是误归因(详见 §9.5 / §19)。
解雇重雇之所以"就好",有两个叠加效果:① 移除旧实体、生成新 CitizenEntity(hunger 重置为满值 20.0);② 重新右键/重新登记会触发 openForWorker/synchronizeAssignedWorkerMetadata,把盒子重新登记为 running(区块加载时)。② 才是关键——但玩家走远 2 小时后盒子被 B-10 再次注销(见 §17 实测 dump8),故非永久修复,是临时重启盒子。
⚠️ 第 5 次复现(2026-09-12 17:50)推翻了"区块卸载是终态、站回即恢复"的隐含假设:本次用户故意不解雇重雇、保持故障态,并站在问题面包店内(区块已加载、isCommercialControlBox=true)。结果店员仍「很饿了」、commercial_boxes 与 simukraft_commercial_boxes.dat 仍为 0 行 / 空列表。这证明根因不在"区块加载与否"本身,而在「注册表已被清空且全代码库无任何自动重建路径」——区块加载只让 isCommercialControlBox 返回 true,但没有任何代码路径会在"方块在、区块加载"时把空注册表补回;重建唯一入口是用户交互(右键控制盒 GUI / 雇佣 / 右键在职店员)。详见 §20(第 5 次纵深结论)与 00 修订记录 #20 / #21。
2. 本题重点:反复性与发生次数
| # |
时间 |
现场 |
观测到的异常 |
| 1 |
09-10 夜(快照 22:22–22:23) |
面包店 box 23639518421056 / 77790466089024 |
店在、箱内有小麦;店员与周边居民集体饥饿;解雇重雇即变饱;部分市民手持面包 |
| 2 |
09-11 06:52 |
同上 2 家面包店(跨几十个城市区块) |
现象完全一致 |
| 3(本轮前) |
09-11 16:23 |
同一存档 |
第三次完全复现;新增「旁人吃饱、只有店员饿」 |
| 4(本次) |
09-12 |
东部 1 号店 / 东部 2 号店(相距 1–2 城市区块)/ 西部店,三店同症状 |
箱里有大量小麦(推翻小麦耗尽说);东部 1 号店「造面包的吃饱、外卖员+居民全饿」;重雇同一对工人 → 全饱;西部 2 号店(刚建刚雇)正常 |
为什么必须按"反复故障"处理
- 已达 4 次,且两次之间没有任何玩家侧动作能改变结果(解雇重雇只是临时清零 / 临时重启盒子)。
- 跨 2 个彼此独立的面包店同时命中(坐标相距约 197 格)⇒ 指向代码/数据层面的系统性缺陷,而非某栋建筑摆放失误。
- 可确定性复现:沿用同一套模板摆放面包店 → 必现。
3. 复现现象(第 4 次,2026-09-12)
3.1 建筑与库存均"正常"(反而证明旧假设错)
建筑实体完整、未拆除;盒 2 格区域内的箱子里有「非常多小麦」(用户原话);商铺状态为营业中。小麦充足却仍集体饥饿 ⇒ 饥饿与小麦数量无关,与「盒子是否运行 / 员工是否罢工」相关。
3.2 分工错位饥饿
东部 1 号店:「造面包的」吃饱,但外卖员和附近居民全饿;东部 2 号店除老板也饿外,症状一致;西部店同症状;西部 2 号店(刚建好、刚雇佣)反而正常吃饱。
3.3 解雇并重新雇佣后"全体又都吃饱了"
用户对每个问题店「解雇 + 再雇佣同一个造面包的 + 同一个外卖员」→ 面包店、外卖员、附近市民全部重回吃饱。原因见 §11(重置饥饿 + 重新登记盒子)。
3.4 部分市民手中持有面包
多个市民(尤其店铺老板类)主手渲染出面包;与"是否饥饿"互不排斥(饥饿的店员同样端着面包)。见 §13,属职业装扮,非异常。
4. 影响范围
| 维度 |
实测 |
说明 |
| 涉及建筑 |
至少 3~4 家面包店 |
东部 1 号 / 东部 2 号 / 西部 / 西部 2 号 |
| 城市登记区块 |
441(已满) |
city_chunks 实测;city_level=11(大都市) |
| 已放置建筑总数 |
64+ |
placed_buildings 实测 |
- 直接受害:面包店老板(店员)、外卖员、周边居民。
- 间接受害:全城所有依赖"食品市场"进食的市民 —— 只要
hunger ≤ 5 且全城找不到可运行的食品候选,就集体进入罢工。
- 关键机制:老板/外卖员/居民共用同一套 citizen 进食代码,没有任何"店员豁免/店主特权"⇒ 同一家店失败,店里店外一起饿。
城市级后果
| 后果 |
链条 |
| 商铺停摆 |
店员饥饿罢工 / 盒子注销 → 商业盒不卖货、自动补货停摆 |
| 经济受损 |
生意中断 → 收入归零 → 企业税(25%)同步归零 |
| 人口受损 |
饥饿持续扣幸福(每 6 分钟 −0.1)→ 最终饿死(存档已实测到 dead 市民) |
| 反复返工 |
玩家反复"解雇重雇"→ 约 90 分钟后复发 → 无效劳动循环 |
5. 复现步骤
| 步骤 |
操作 |
预期 |
实际 |
| 1 |
按模板摆放面包店,把小麦放入常规输入/物流箱(放很多) |
店员正常进食、居民饱食 |
初始正常 |
| 2 |
推进游戏时间(玩家走远 / 区块卸载) |
持续饱食 |
店员与周边居民开始显示饥饿(即便小麦仍在) |
| 3 |
打开箱子检查 |
—— |
小麦仍大量在箱中(但市民仍饿) |
| 4 |
观察状态 |
应自动去买食物 |
停在"太饿罢工",不再自动恢复 |
| 5 |
解雇并重新雇佣该店员 |
问题应被修复 |
立刻变"吃饱"(走远后再次饥饿) |
| 6 |
观察其他店员 |
—— |
主手持续显示面包(职业装扮,与饥饿无关) |
复现率:确定性复现(同一模板建筑、同一摆放方式 → 必现)。
6. 期望行为 vs 实际行为
| 项 |
期望 |
实际 |
| 面包店有小麦、盒子营业时 |
正常向市民供给 / 售卖 |
仍可能供不上(盒子已被 B-10 注销 / 店员已罢工) |
| 饿了但买不到时 |
有兜底(放宽范围 / 降级购买 / 至少不无限掉) |
永久罢工、无自愈,一路掉到饿死 |
| 周边市民与店员 |
进食条件一致 |
旁人能捡地上食物吃饱,店员被 isProtectingWorkProduct 禁止捡食 → "店员独饿" |
| 解雇重雇 |
—— |
仅把饥饿值重置为 20 + 重启盒子,走远后复发 |
| 手持面包 |
—— |
职业装扮(job.heldItem),与饥饿无关 |
第 II 部分 · 技术根因(代码级)
7. 饥饿的产生与阈值
// CitizenManager(衰减链)
1181: 106: ldc2_w #689 // long 7200l ← 衰减周期 = 7200 tick = 6 游戏分钟
1201: 149: CitizenEntity.getHungerValue:()D
1203: 153: dsub ← hunger - 1
1204: 154: CitizenEntity.setHunger:(D)V
// 同方法后半段:判定显示状态
1231: 214: ldc2_w #717 // double 6.0d ← 显示阈值 6.0
1235: 222: ldc_w #719 // String "hungry" ← 置 status = hungry
1241: 236: dsub ← happiness - 0.1
// CitizenEntity
3: public static final double DEFAULT_HUNGER = 20.0d;
76: ldc2_w #9 / putfield hunger ← 新实体构造即 20.0(满)
| 饥饿区间 |
系统判定 |
表现 |
= 20.0 |
满值(新实体默认) |
吃饱 |
< 6.0 |
CitizenManager 置 status=hungry |
显示"饥饿",但尚未开始找吃的 |
≤ 5.0 |
CitizenSelfFeedingService.shouldStart 通过 |
才开始尝试进食 |
≤ 5.0 且找不到食物 |
too_hungry_on_strike |
永久罢工,无自愈 |
≤ 0 |
isOnHungerStrike = true |
彻底饿死(此时才清空手持物) |
衰减:7200 tick(6 游戏分钟)掉 1 点 ⇒ 从满值 20 掉到"开始进食"的 5.0 约需 90 游戏分钟。
8. 主死锁链(决定"一直饿、不自愈")
饥饿每 7200 tick 掉 1 点
↓
hunger ≤ 5.0 才开始找吃的(findPurchasePlan)
↓
【上游触发】食物来源(面包店盒子)不在可运行态:
├─ (A) B-10 区块卸载 → isCommercialControlBox 返回 false → tickBox 注销盒子
│ → commercial_boxes 无该行 → findPurchasePlan 无候选
└─ (B) 盒内店员 hunger ≤ 0 → isOnHungerStrike → tickBox 早退不放货
↓
findPurchasePlan 返回 null
↓
enterStrike → status = too_hungry_on_strike(每 200 tick 重试一次)
↓
重试仍为 null → 永远停在罢工 ← 无任何自愈 / 降级 / 兜底
// CitizenSelfFeedingService
9: public static final String TOO_HUNGRY_STRIKE_STATUS = "gui.npc.status.too_hungry_on_strike";
13: private static final double START_HUNGER_THRESHOLD = 5.0d;
15: private static final double FULL_HUNGER = 20.0d;
// shouldStart(L242–302): hunger ≤ 5.0 才继续;workStatus==RESTING/DEAD、医生餐进行中、冷却未过 均不启动
// start(L304–352)
20: CommercialFoodMarketService.findPurchasePlan(...) → astore 7
66: ifnonnull 80
69-76: enterStrike(...) ← plan == null → 直接罢工
80-89: enterTravel(...) ← 有 plan → 走过去买
// tickStrike(L435–464):每 200 tick 重新要 plan;仍 null → 再 enterStrike
// enterStrike(L578–604)
4: putfield phase = Phase.STRIKE
9: putfield plan = null
13: ldc2_w #345 // long 200l ← 200 tick 后重试
45: setStatus("gui.npc.status.too_hungry_on_strike")
夜间 isRestTime 直接 cancelAllForRest(全体取消进食)⇒ 夜间观察到的"饥饿"会持续到白天。
9. 闸门①:供给判定 canSupply(次级机制,非第 4 次主因)
findPurchasePlan 的候选过滤链:
candidates()(全世界的食品候选,缓存 CANDIDATE_CACHE_TICKS=200)
→ filter(候选.cityId == 市民.cityId) // 同城
→ filter(候选.boxPos.distSqr(市民) ≤ 65536) // 256 格
→ filter(canSupply(boxPos, offer, 1))
→ min(score)
任一环筛空 ⇒ null
面包店的核心售卖报价是 materialBacked(材料支撑型):
// CommercialTradeSupplyService
3: MATERIAL_RADIUS_XZ = 5
5: MATERIAL_RADIUS_Y = 2
// validateMaterials(L133)→ availableFromMaterials = countMaterial(item) / max(1, count)
// 对 wheat×3 即 countMaterial(wheat) / 3;< 1(即麦 < 3)→ "insufficient_materials"
// nearbyContainers(L470–518):dx ∈ [-5,5] / dy ∈ [-2,2] / dz ∈ [-5,5],必须是容器
⇒ canSupply 成立 ⇔ 以"控制箱"为中心、11×5×11 立方体内、被识别为容器的方块里,小麦 ≥ 3。
⚠️ 第 4 次复现已证明这不是主因:用户明确箱子里有大量小麦(≥3 且就在 2 格内),市民仍饿。故「小麦 < 3 / 不在窗口」只解释部分早期个案,不能解释本案。本案主因见 §9.5。
9.5 闸门②(第 4 次主因):盒子「不在可运行态」→ 无候选 → 罢工
这是 2026-09-12 第 4 次复现后补入的主导机制,代码两级闭合(★★★★):
(A) 盒子因区块卸载被 B-10 静默注销 —— 市民侧 candidatesForBox 直接迭代 commercial_boxes 注册表,表里没行就无候选:
// CommercialWorkService.tickBox(L0–11)—— 注销链起点
if (!isCommercialControlBox(level, boxPos)) { // ★ 要求 level.isLoaded(boxPos)
CommercialBoxManager.remove(level, boxPos);
CommercialStockService.removeBox(level, boxPos); // ★ 顺手删 sqlite 库存行
return;
}
// isCommercialControlBox(L492–515)
return level.isLoaded(boxPos) && (block == COMMERCIAL_CONTROL_BOX);
⇒ 区块卸载(玩家走远 > 加载半径 160 格)即注销;commercial_boxes 无该行 → findPurchasePlan 无候选 → 市民罢工饿。
→ 这正好是 B-10(见 03 / 02),B-7 的饥饿是 B-10 的下游症状之一。
(B) 盒内店员自身饥饿罢工 —— 即便盒子在注册表、canSupply 通过,tickBox 仍会在店员 hunger≤0 时早退不放货:
// CommercialWorkService.tickBox(L294–324)
if (isOnHungerStrike(level, worker.uuid)) { // hunger ≤ 0 且实体已加载
return; // ★ 本 tick 不放货
}
// isOnHungerStrike(citizen 包)
e = findCitizenEntity(level, uuid); // 仅认已加载区块的实体
if (e == null) return false;
return e.getHungerValue() <= 0.0;
// hasReachedHungerStrikeThreshold(v) { return v <= 0.0; }
⇒ 店员一旦饿到 0,盒子对其"名存实亡"——既不自动卖货,也不给市民供餐。
第 4 次的关键观察(用户原话锚点):
- 东部 1 号店「造面包的吃饱、外卖员+居民全饿」:店员(造面包的)本身没罢工(hunger 高),但外卖员与居民作为"去买的人"找不到可运行候选 → 饿。
- 重雇同一对工人 → 全饱:因为重雇同时重置饥饿 + 重新登记盒子(
openForWorker,见 §11)。
- 西部 2 号店「刚建好、刚雇佣」正常:盒子刚登记、店员满饥饿,两种失败模式都不触发。
10. 为什么麦被"极快"吃光:一次进食 = 18~33 个麦(次级,仅在前 3 次「小麦耗尽」个案里成立)
// CitizenSelfFeedingService.tickTravel(L512–532)购买成功后:
104: iconst_5 / 105: bipush 10 / 107: RandomSource.nextIntBetweenInclusive(5,10)
112: buyExtraForBackpack(level, data, entity, plan, n) ← 额外再买 5~10 个
// buyExtraForBackpack(L782–819)是真循环 n 次,每次走 executePurchase
对 shop_sells_bread(materials = wheat×3),每次购买扣 3 个麦 ⇒ 一次进食 = 主餐 3 + 背包 510 × 3 = 1833 个麦。
⇒ 在那些盒子确实在运行、但小麦只放了一点的个案里,64 个麦只够 2~3 人次,很快 <3 ⇒ 该店对所有饥饿市民全部失效。但第 4 次实测小麦极多,此路径未触发。
11. 为什么"解雇重新雇佣就全饱了"(双效果)
// beginEating(L606–618)
617: 20: ldc2_w #361 // double 20.0d
618: 23: CitizenEntity.setHunger:(D)V ← 吃一口 → hunger = 20
// 重雇 = 移除旧实体 + 生成全新 CitizenEntity(构造器 hunger=20.0)
- 效果①(重置饥饿):旧实体饥饿值清零。
canSupply 没变、麦没补 ⇒ 约 (20−5)×6 = 90 分钟后又会饿到 ≤5.0。
- 效果②(重启盒子,关键):重新解雇+雇佣触发
synchronizeAssignedWorkerMetadata / openForWorker(玩家右键在职店员或重新绑定),把盒子重新登记为 running(区块加载时)。这才是"全店+全居民都饱"的真正原因——食物源重新上线。
⚠️ 非永久修复:玩家走远 2 小时后,盒子被 B-10 再次注销(见 §17 dump8 实测 commercial_boxes 由 3 行变 0 行)。故重雇 = 临时重启盒子,约 90 分钟 ~ 走远即复发。
12. 为什么"旁人吃饱、店员独饿"(isProtectingWorkProduct)
除"去买"主路径外,市民还有一条次级免费进食路径:捡食周围 2 格内的掉落食物(每 5 tick 扫一次)。但存在一道闸:
// CitizenDroppedFoodService
164: isProtectingWorkProduct(...)
170: iconst_0 / ireturn ← 保护中 → 不能吃
// isProtectingWorkProduct(L95–107):WORKING 且非玩家投掷 → true(保护中)
⇒ 上班市民(WORKING)拒绝吃"非玩家投掷"的掉落食物。
| 人员 |
能捡食掉落物 |
能"去买" |
结果 |
| 不上班的普通市民 |
✅ |
✅ |
长期吃饱 |
| 上班店员 / 老板 / 外卖员 |
❌ 被保护闸禁止 |
❌ 面包店 canSupply 失败 / 盒子注销 |
独饿 |
为什么"手动喂面包就好了":玩家把面包丢到地上 → 掉落物 owner = Player → isPlayerThrownDrop = true → 保护闸失效 → 店员吃下。等价地,右键把面包塞进市民背包走 tryEatBackpackFood 旁路,同样不受该闸影响。
⚠️ 缺陷点:该机制本意是"上班别去捡自己正在生产的产物",但实现过度泛化——屏蔽了上班市民对任何掉落食物的进食。
13. "手持面包"的真实来源:job.heldItem 职业装扮
绝大多数情况不是进食残留,而是职业装扮数据,属正常渲染,不是饥饿症状。
// CommercialWorkService.tickBox(店员在岗、未医疗假、未休息、未关门、未饿死之后)
327: CommercialDefinition.job()
332: JobDefinition.heldItem:()Ljava/lang/String;
338: applyHeldItem(uuid, heldItem)
// applyHeldItem(L351–366):heldItem == AIR 直接 return,否则 new ItemStack(item) → setMainHandOverride
CitizenJobVisualService:MAIN_HAND_OVERRIDES 是静态内存 ConcurrentMap<UUID,ItemStack>(无持久化、不存档)。
全量建筑统计(all_buildings 全集 116 个 JSON):
- 70 个建筑定义
job.heldItem;其中 26 个 = minecraft:bread(官方 bread_shop + commercial_029 + import_wulei 的 25 个系列)。
- 关键边界:
tickBox 里"清手持"分支仅命中 isOnHungerStrike(hunger ≤ 0)。"显示饥饿(<6.0)"与"进食罢工(≤5.0)"都远早于 0:
| 饥饿区间 |
显示状态 |
是否清手持 |
手里是否端面包 |
(5, 6] |
hungry |
否 |
✅ 端 |
(0, 5] |
too_hungry_on_strike |
否 |
✅ 仍端 |
≤ 0 |
饿死 |
是 |
❌ 空手 |
⇒ 店员从"微饿"到"饿死前"整段都端着面包,只有真饿到 0 才放下 —— "饥饿 + 手持面包"是同一视觉逻辑的必然结果,完全正常。
14. 为什么"跨几十个区块的多个面包店都是同一个 Bug"
| 因素 |
说明 |
| 几何确定性 |
同一模板建筑 → 盒子相对控制箱位置固定 → 一旦玩家走远,多个店同时被 B-10 注销 → 全城食品候选集体消失 → 大规模罢工 |
| 状态叠加 |
叠加 isProtectingWorkProduct → 店员既买不到、也不能捡 → 必然饿 |
| 第 4 次新特征 |
小麦充足也饿 ⇒ 主因是「盒子可运行态」而非「小麦数量」;重雇能恢复是因为重启盒子而非补麦 |
| 结论 |
同样的面包店、同样的摆法、同样的饿法 → 确定性复现,不是偶发 |
14.5 第 5 次复现纵深结论:注册表清空后无自动重建(核心根因升级)
本节是 2026-09-12 17:50 第 5 次复现(用户保持故障态、不解雇重雇、站在店内)后补入的决定性结论,把 B-7 从"区块卸载症状"推进到"注册表空 + 无自愈"的机制本质(★★★★★ 三证合一)。
(一) 现象推翻单一解释
第 4 次结论把饥饿归因为 B-10「区块卸载即注销」——隐含"玩家走远才丢、站回就恢复"。第 5 次用户站在问题店内(区块加载、isCommercialControlBox=true),但:
commercial_boxes 表 = 0 行;
data/simukraft_commercial_boxes.dat = 82 字节、TAG_List "Boxes" len=0(空列表),且该文件 15:40 写入后不再变化(同期工业 .dat 971B/8 盒、农场 185B/2 盒均正常、17:51 还在更新);
- 店员头顶「工作中 / 很饿了」持续不变。
⇒ "站回店旁即恢复"不成立。区块加载只决定 isCommercialControlBox 的返回值,没有任何代码路径会在"方块在、区块已加载"时把空注册表补回。
(二) 注册表清空 = 铁证,且清空后无重建
// CommercialBoxManager —— 注册表(内存 ConcurrentMap<BlockPos, CommercialBoxData>)+ sqlite 镜像
remove(level, pos): map.containsKey(pos) → map.remove; setDirty; deleteCommercialBox(sqlite)
persist(data): map.put(pos,data); setDirty; saveCommercialBox(sqlite) // 仅"写"路径
loadFromSqlite(): if (sqliteLoaded) return; // ★ 幂等守卫:首载后永远不再重读 sqlite
all(): List.copyOf(boxes.values()) // tickBox 遍历的就是它;空则循环体零执行
// CommercialWorkService.tickBox(L95–110)—— 唯一从注册表"读/用"的循环
for (CommercialBoxData data : mgr.all()) { tickBox(data); } // 表空 → 永不重建
// tickBox 开头:
if (!isCommercialControlBox(level, boxPos)) { mgr.remove(boxPos); CommercialStockService.removeBox(level,boxPos); return; }
// 注册入口(全代码库仅 5 处,全在用户交互路径)
① CommercialControlBoxService.buildView // ★ 右键控制盒 → GUI(getOrCreate 新建 running=true)
② CommercialControlBoxService.buildTradeView // 交易界面
③ CommercialControlBoxService.fireWorker // 解雇(先 running=false,再交互才可能重建)
④ CommercialControlBoxService.synchronizeAssignedWorkerMetadata // 雇佣/同步:getOrCreate + setRunning(true)
⑤ CitizenEntity.mobInteract → openForWorker // 右键在职店员实体(≤8 格、非潜行)→ getOrCreate + setRunning(true)
// 注销入口(2 处):tickBox 开头(区块卸载)/ onRemoved(方块破坏)
⇒ 关键三连:
- 注册入口 5 处全在用户交互(右键 GUI / 交易 / 解雇 / 雇佣 / 右键店员),零处在"区块加载 / 每日 tick / 玩家上线"级;
tickBox 在 !isCommercialControlBox(含区块未加载)时永久 remove 且不会反向创建(它只 all() 遍历、不 persist);
reloadFromSqlite 首载后 sqliteLoaded=true → guardians 生效 → 即使 sqlite 里还有数据也不会二次回灌内存;而本局 sqlite 商业盒本身也已 0 行(/simukraft 重载命令若此时触发会把内存也清空,见 §15 风险)。
⇒ 注册表一旦空,除用户交互外永不复活 → 无商业盒 → findPurchasePlan 无候选 → 市民 STRIKE 饿。这就是第 5 次"站店内也不恢复"的真相。
(三) 工业 / 农场幸免的分水岭(商业侧缺自愈)
// IndustrialWorkService.tickBox(L381–)
if (!isLoaded(level, pos)) return; // ★ 未加载仅跳过,不销毁
if (!isIndustrialControlBox) { dropAndClear; remove; reset; return; }
// tryAutoStartStoppedBox(L196–275)—— 工业自愈
if (!isIndustrialControlBox) return false;
... data.setRunning(true); mgr.persist(data); return true; // ★ stopped 但方块在 → 自动重启
⇒ 工业侧有 tryAutoStartStoppedBox 自愈;商业侧无对等方法。NpcWorkChunkLoadService(强制区块加载票 addRegionTicket)仅被 BuilderConstructionService/PlannerWorkService 调用——商业与工业都没用它做持续锚定,工业能活全靠"未加载时 return 跳过 + 自愈重启",商业则是"未加载即销毁 + 无自愈"。叠加商业盒在南侧(z≈4429–4499)、工业/农场盒在北侧(z≈4532–4563)持续加载区,最终 8 工业盒 / 2 农场盒存活、商业盒全灭(活存档实测 industrial_boxes=8/farmland_boxes=2/commercial_boxes=0)。
(四) 结论
- B-7 饥饿的本质 = 商业盒注册表丢失后无自动重建,并非"某一刻区块没加载";
- B-10「区块卸载即注销」是触发清空的一种手段,但不是终态;终态是"清了就没人补";
- 唯一恢复 = 用户交互(右键控制盒触发
buildView→getOrCreate / 雇佣触发 synchronizeAssignedWorkerMetadata / 右键在职店员触发 openForWorker),见 §15;
- 治本修复必须给商业盒加"区块加载时自动
getOrCreate + setRunning(true)"的自动重建/自愈路径(对齐工业 tryAutoStartStoppedBox),见 §16 P0。
第 III 部分 · 处置与修复
15. 玩家侧(立即可用)
- 店饿先看盒子是否在运行:不解雇重雇也能恢复——站到面包店控制盒旁(≤8 格)右键打开商业 GUI(
buildView→getOrCreate 新建 running=true 盒),或右键在职店员实体(≤8 格、非潜行)触发 openForWorker 重启盒子。两者都不需要"解雇+重雇"。
- 把小麦放进"控制箱 ±5 格水平、±2 格垂直"范围内的容器并持续补(仅在前 3 种「小麦耗尽」个案里有效;第 4/5 次证明光补麦不够,盒必须在运行态)。
- 急救单个饿肚子店员:把面包丢到地上让他捡,或右键把面包塞进他的背包 —— 两者都绕过
WORKING 保护闸。
- 不要依赖"解雇重雇"永久修复:它只重置饥饿 + 临时重启盒子,走远后 B-10 再次注销、约 90 分钟后复发。第 5 次复现已证明即使不重雇、站在店内也恢复不了——因为缺的是"自动重建路径"而非"饥饿值/站位"。
- 验证手段:重启盒子后,等饥饿掉到 ≤5.0(约 90 游戏分钟),观察该市民是否自动走向面包店、状态是否离开"太饿罢工";若仍罢工 ⇒ 盒子又离线(B-10)。
- ⚠️
/simukraft 重载命令是高风险恢复手段:该命令会 CommercialBoxManager.reloadFromSqlite——先清内存 Map 再读 sqlite 回灌;若此刻 sqlite 商业盒已 0 行,重载会把内存也一并清空(永久丢失)。仅在"sqlite 里还能查到商业盒行"时可用;本局第 5 次 sqlite 商业盒已 0 行,禁止用重载命令当恢复手段,应走第 1 条的用户交互重建。
16. 模组侧(治本,按优先级)
| 优先级 |
方向 |
具体建议 |
| P0 |
修 tickBox 注销条件(消 B-10) |
区分"方块真的没了"与"区块没加载",后者应 continue 跳过、绝不能 remove;注销时不要连带删库存 → 食物源不再随站位消失 |
| P0 |
给商业盒加自动重建/自愈(对齐工业 tryAutoStartStoppedBox) |
第 5 次证实商业盒注册表清空后无任何自动重建路径(注册入口 5 处全在用户交互)。应在"区块加载且方块仍在"时自动 getOrCreate + setRunning(true) + persist,或复制工业 tryAutoStartStoppedBox 逻辑;并让 reloadFromSqlite 在注册表为空时允许从 sqlite 回灌(去掉/放宽 sqliteLoaded 守卫的副作用) → 站回店内即自动恢复,不再依赖玩家手动交互 |
| P0 |
给"饿到罢工"加自愈兜底 |
罢工连续 N 次 / 超过一定时长后,放宽 findPurchasePlan(允许更远距离,或降级为"付费购入背包"),绝不允许饥饿值无限掉到死 |
| P1 |
修正 isProtectingWorkProduct 过度泛化 |
保护范围收窄到"自己正在生产的那一类产物",而非屏蔽一切掉落食物 |
| P1 |
约束囤货 |
buyExtraForBackpack 的 5~10 连购应受库存约束,避免一名市民一次掏空城市粮仓 |
| P2 |
饥饿状态可视化 |
市民信息面板直接显示饥饿值(弥补"SQLite 无 hunger 列、玩家无法自查"的盲区) |
| P2 |
供给/离线失败提示 |
canSupply 失败或盒子离线时在商业盒界面提示,避免误判为"店坏了" |
第 IV 部分 · 取证
17. 存档取证(含"哪些不能作证据")
复制存档到 C:\Users\Administrator\dump\(live.sqlite = simukraft.sqlite、buildings.sqlite = simukraft_buildings.sqlite)
⚠️ Windows 原生 Python 必须用 Windows 绝对路径连接;git-bash 的 /tmp/ 映射会让 sqlite 读到空库。
第 4 次复现的两份差分快照(2026-09-12):
| 取证项 |
dump7(15:31 故障态) |
dump8(17:34 用户重雇修好后+走远 2h) |
说明 |
commercial_boxes |
3 行全 open(均 bread_shop,box 23639518421056/57724378882112/77790466089024) |
0 行(又注销) |
盒子随玩家走远被 B-10 反复注销/复活 |
commercial_stock |
0 行 |
0 行 |
面包店 mixed 报价 materialBacked,不落 SQLite |
胡砚月 status_label |
gui.simukraft.commercial.status.open |
空(is_working=1) |
状态随盒子注册波动 |
status='hungry' 行数 |
1 人 |
2 人 |
饥饿在市民间轮转 |
cities.funds / city_level |
— |
222100.64 / 11 |
大都市,配额 441 |
city_chunks |
— |
441 行(已满) |
城市面积配额 21×21 已用满 |
关键读法:dump7 盒子"在"只是因为用户当时在店旁;dump8 用户走远 2h → 盒子又 0 行。这直接证明 B-7 饥饿是 B-10 盒子注销的下游症状,且重雇非永久修复。citizens 表无 hunger 列,饥饿是运行时实体属性,只能由代码机制推断。
| 取证项 |
结果 |
说明 |
commercial_boxes 行为盒子的唯一真相 |
行忽有忽无 |
与玩家站位强相关(B-10);不能用"行缺失"推断"方块被拆" |
finance_transactions |
128 行窗口 |
低频 commercial_npc_food_tax 会被物流流水挤出,不能据此断言市民没买过食物 |
| 第 3 次复现观测(16:27/16:29 两次连读) |
idle46/working30/resting4/hungry2/dead1 → resting76/idle3/working2/hungry1/dead1 |
resting 骤增 = 夜间;hungry 姓名在两次读数间变化(童踏歌→何既明)⇒ 饥饿在不同市民间轮转 |
18. 证据清单
| 类型 |
路径 / 内容 |
| 主模组 JAR |
D:\新:模拟大都市:启航 2.8fix5\.minecraft\versions\新:模拟大都市:启航\mods\[新:模拟大都市] New-Sim-U-Kraft-2.2.0-neoforge-1.21.1.jar |
| 反汇编产物 |
C:\Users\Administrator\hunger\(citizen_CitizenSelfFeedingService.txt、citizen_CitizenDroppedFoodService.txt、citizen_CitizenJobVisualService.txt、commercial_CommercialWorkService.txt、commercial_CommercialFoodMarketService.txt、commercial_CommercialControlBoxService.txt、commercial_StockService.txt) |
| 建筑定义全集 |
C:\Users\Administrator\all_buildings\(116 个 JSON,utf-8-sig 解析;官方文件带 BOM) |
| 面包店定义 |
all_buildings\official\buildings\commercial\bread_shop.json(shop_sells_bread:visibleTo=mixed、money 0.25、结果面包×1、材料 wheat×3) |
| 城市等级配置 |
C:\Users\Administrator\jarlang\data\simukraft\city_levels\city_levels.json(unlocks.chunks 奇数平方序列,L11=441=21²) |
| 存档(活) |
…\saves\新:模拟大都市:启航 2_8fix5\simukraft\simukraft.sqlite |
| 存档(建筑) |
…\simukraft\simukraft_buildings.sqlite(约 170 MB,含 placed_buildings) |
19. 待确认项(不影响主结论)
- HUD「世界人口」与
citizens 表行数的差异(531 vs 83):需确认 SQLite 是否采用分片/延迟刷写。
- 外卖员身份确认:本专题采用"外卖员 = 普通 citizen(同一套进食逻辑)"的判断;外卖员属另一模组(外卖来了之类),其饥饿与本案面包店机制相互独立,建议在物流/外卖相关市民上单独复测。
- 「盒子在运行 + 小麦充足 + 市民仍饿」的子情形:在
dump7 这类"盒子 open 却仍饿"的快照里,究竟是 (a) 市民距盒子 >256 格(findPurchasePlan 距离过滤)还是 (b) 店员已 isOnHungerStrike 导致不放货,需一次"盒子 open 当场的市民坐标 vs 盒坐标"差分确认。两种都属 §9.5 的(B)类失败,不影响主结论。
- 前 3 次「小麦 < 3」假设的修正:第 4 次证明小麦数量非主因;前 3 次报告里的"小麦耗尽"应视为误归因(可能当时盒子也处于注销/罢工态,只是表象像小麦不够)。本报告 §9 / §10 已相应降级为"次级机制"。
代码块为 javap -c -p 反汇编输出;行号为对应 .txt 文件内行号,左侧数字为字节码 OFFSET/PC。存档数值取自查证时刻副本(游戏运行中会变化),机制结论稳定成立。
第 V 部分 · 字节码级精确根因(哪几行出问题 + 完整因果族谱)
2026-09-12 第 5 次复现后,重新反编译 CommercialBoxManager / CommercialWorkService / CommercialControlBoxService / CommercialFoodMarketService / IndustrialWorkService(产物 boxmgr_full.txt、cws_full.txt、ccbs_full.txt、cfms_full.txt、iws_full.txt,均存于 C:\Users\Administrator\),把"注册表清空 + 无自愈"从结论推进到具体哪一行。行号为各 .txt 反编译文件内行号。
V.1 注册表本体:写路径齐全、重建路径为零(CommercialBoxManager)
boxes = ConcurrentMap<BlockPos, CommercialBoxData>(boxmgr:7)是唯一内存真源,双写 commercial_boxes.dat(SavedData)与 SQLite。
remove(level,pos)(boxmgr:210-233):ConcurrentMap.remove(:221)+ setDirty()(:224)+ SimuSqliteStorage.deleteCommercialBox(sqlite)(:232)—— 删内存 + 删库 + 标脏待存盘,三连清空。
save(CompoundTag)(boxmgr:76-94):把 boxes.values() 写进 NBT Boxes 列表;boxes 为空即写成 82 字节空列表 → 即 simukraft_commercial_boxes.dat 15:40 后定格为空的原因。
loadFromSqlite()(boxmgr:124-155):首载后被 sqliteLoaded 守卫(:127-129 if(sqliteLoaded) return)永久不再重读 SQLite;即便重读也是 boxes.clear() 后 putAll(:149-154)——若 SQLite 已空则内存仍空。
getOrCreate(pos)(boxmgr:171-180):computeIfAbsent + 构造引用 → 只建一个空白新盒(不带任何已保存状态),且全代码库只在用户交互路径被调用。
persist(data)(boxmgr:182-208):仅"写"路径(put + 存盘 + 写 SQLite)。
⇒ 注册表只有"删 + 写",没有任何"按世界里的建筑扫描补建"的入口。
V.2 触发缺陷:tickBox 在"区块未加载"时 DESTRUCTIVE remove(B-10 本体)
CommercialWorkService.tickBox(cws:95-110)是无可争议的起点:
100: isCommercialControlBox(level, boxPos) // ccbs:491,499 = level.isLoaded(boxPos) && block==COMMERCIAL_CONTROL_BOX
101: ifne 28 // 为 true 才正常处理
105: CommercialBoxManager.remove(boxPos) // ★ 未加载即永久删除注册表项
109: CommercialStockService.removeBox(...) // ★ 顺手删库存行
110: return
玩家走远 > 加载半径(160 格) → 区块卸载 → isCommercialControlBox 返 false → remove。这是"区域卸载"分支的唯一落点:它是触发,不是终态。
V.3 注册入口 5 处全在用户交互(为什么站回店内不恢复)
全 jar 检索 getOrCreate 仅出现在 CommercialControlBoxService 的 5 个方法(ccbs 行号):
buildView(:9/14)右键开控制盒 GUI → getOrCreate
buildTradeView(:135/202)交易界面 → getOrCreate
synchronizeAssignedWorkerMetadata(:441/474/478/488)雇佣/同步 → getOrCreate + setRunning(true) + persist ★关键恢复口
fireWorker(:215/231/235)解雇 → getOrCreate + setRunning(false)
openForWorker(:248/314/328)右键在职店员 → setRunning(true) + persist(由 CitizenEntity.mobInteract 触发)
零处在"区块加载 / 每日 tick / 玩家上线"级。⇒ 区块重新加载时,没有任何代码把"方块还在"翻译成"补回注册表"。这正是第 5 次"站店内也不恢复"的字节码解释。
V.4 下游:空注册表如何变成饥饿(B-7 症状链)
CommercialFoodMarketService.buildCandidates(cfms:271-283):mgr.all()(:275)遍历注册表。注册表空 → 候选空 → findPurchasePlan 返回 null(cfms:28-29 早退 aconst_null)。市民侧 CitizenSelfFeedingService.enterStrike → too_hungry_on_strike,每 200 tick 重试仍 null、无自愈/降级/兜底 → 饥饿一路掉到 0 → isOnHungerStrike(hunger≤0)→ tickBox 早退不放货(自锁加剧)→ 饿死;店铺停摆 → 企业税 25% 归零(见 01/02)。
V.5 对照:工业为何不灭(证明商业缺自愈,非"运气")
IndustrialWorkService.tryAutoStartStoppedBox(iws:196-275):方块仍在世界(isIndustrialControlBox 真)即 setRunning(true)(:261)+ persist(:273),由 tickBox 自动调用(iws:84)。且工业 tickBox 在 !isLoaded 时 return 跳过、不删除。商业侧两者皆无 → 商业盒清零后永不复活。实测 industrial_boxes=8/farmland_boxes=2/commercial_boxes=0(dump10)即分水岭。
V.6 完整因果族谱(非矛盾共存)
[架构根因] 商业盒注册表「清空后无自动重建路径」
├─(直接缺陷①) tickBox !isCommercialControlBox → DESTRUCTIVE remove [cws:105]
├─(直接缺陷②) 注册入口×5 全在用户交互(getOrCreate 仅用户触发)
│
├─[触发·非矛盾] 区域卸载(玩家走远>160格)→ isLoaded=false
│ └─► 流入 缺陷① 的 remove 分支
│
├─[清空机制] remove 三连 → boxes.remove+setDirty+deleteSQLite [boxmgr:221/224/232]
│ ├─ setDirty→save() 写空列表 → dat 82B 空(15:40 定格) [boxmgr:76-94]
│ └─ loadFromSqlite 守卫 sqliteLoaded 永不重读 [boxmgr:127]
│
├─[终态] commercial_boxes = 0(内存+sqlite+dat 全空,无回源)
│
├─[下游] buildCandidates→mgr.all() 空 → findPurchasePlan=null [cfms:275]
│ └─► enterStrike → too_hungry_on_strike(无自愈)
│ ├─ 饥饿≤0 → isOnHungerStrike → tickBox 早退(自锁)
│ └─ 店铺停 → 企业税25% 归零
│ └─► 市民饿死 + 城市经济崩坏
│
└─[对照] 工业 tryAutoStartStoppedBox 自愈 + 卸载return跳过 → 工业盒8存活 / 商业盒0全灭
「区域卸载」「站回不恢复」「注册表=0 不重建」三者互不矛盾:卸载是触发、注册表空是状态、无重建是根因;卸载把注册表清空,而无重建导致站回也补不回。B-7(饥饿症状)与 B-10(生命周期注销)是同一根因的两张面孔。
可视化族谱图见同目录 B7-因果图.html(SVG 网状因果图 + 精确行号证据表)。
第 VI 部分 · 用户交互重建注册表的实证对照(2026-09-13 东部面包店2)
VI.1 新发现(用户观察)
东部面包店2:用户解雇了"造面包的"工人,没有雇佣该店的外卖员,结果周边市民从饥饿变为吃饱。
这是 B-7 第 5 次("站店内不恢复")之后的正面对照实验,直接坐实了"注册表清空后,唯一恢复口是用户交互式 getOrCreate"这一根因。
VI.2 代码机制(为什么解雇能恢复)
CommercialControlBoxService.fireWorker(ccbs_full.txt:215-246)字节码:
:221-226 CitizenEmploymentService.fireAssigned(...) // 解绑工人(清除在职)
:228-231 CommercialBoxManager.getOrCreate(boxPos) // ★ 重种注册表(registry 0→1)
:235 CommercialBoxData.setRunning(false) // 标记未运行
:237 setStatusKey("...status.worker_fired")
:245 CommercialBoxManager.persist(...) // 写回
关键:解雇工人这一用户动作,内部会调用 getOrCreate —— 而 getOrCreate 正是把盒子重新插回 commercial_boxes 注册表的唯一方法。注册表从 0 变 1,盒子重新成为食品分销网络的可见节点(isCommercialControlBox=true),tickBox 不再把它 remove,findPurchasePlan→buildCandidates→mgr.all() 又能找到它 → 供应链接通 → 市民吃饱。
这与 B-7 第 5 次的"站店内不恢复"完全自洽:站立不调用任何 getOrCreate(5 个入口无一被触发)→ 注册表仍 0 → 不恢复。解雇 vs 站立,正是阳性对照 vs 阴性对照。
VI.3 活存档实证(dump11,2026-09-13 18:34,WAL 三件套)
| 指标 |
第5次(09-12 17:50) |
本次(09-13 18:34) |
含义 |
commercial_boxes 行数 |
0 |
1 |
注册表被解雇动作重建 |
| 重种盒子 |
— |
(210,64,4498) bread_shop,running=1,status=open |
东部面包店2 一带 |
citizens hungry |
4 |
69 |
全局仍饿(其余店未重建) |
commercial_stock |
— |
0 行 |
无库存 ⇒ 恢复不靠存量分发,靠"节点重新可见"后从上游/农场补给 |
VI.4 与 B-7 根因的关系(不矛盾,是正面对照)
- 不推翻 B-7:根因仍是"注册表清空后无自动/后台重建路径(tick/区块加载/登录/每日结算均不补)"。第5次已证明"站回不恢复",本次证明"解雇才恢复"——两者共同钉死"重建只能由用户显式盒子交互触发"。
- 修正措辞:B-7 旧表述"全代码库没有任何路径把它补回"应精确为"全代码库没有任何自动路径把它补回;唯一补回口是 5 处用户交互式
getOrCreate(buildView 右键GUI / syncWorker 雇佣 / fireWorker 解雇 / openForWorker 右键店员 / buildTradeView 交易面板)"。
- "没雇外卖员反而吃饱"的解释:用户预期需雇外卖员才恢复,但触发重建的是
getOrCreate,解雇工人这一步本身就调到了它;雇外卖员(openForWorker)也会调 getOrCreate,但不是必要条件。
VI.5 诚实标注的细节
重种盒子最终 running=1 / status=open(而非 fireWorker 写的 running=false/worker_fired)⇒ 解雇之后用户还重新打开了盒子 UI(再触发一次 getOrCreate+setRunning(true))。这不影响结论:本质触发是 getOrCreate 重种注册表,与是否雇外卖员无关。游戏于 09-13 重启(新 PID 14296;昨崩溃卡死 PID 28196 已不在),本快照为重启后用户操作所致。
VI.6 实用结论(玩家自救)
- 面包店/食品店集体饥饿时,不要只站在店里干等(第5次已证无效)。
- 有效自救 = 触发一次盒子交互以调
getOrCreate:右键控制盒 GUI、右键在职店员(openForWorker)、解雇/重雇任一店员(fireWorker/syncWorker)、打开交易面板(buildTradeView)。任一动作都会重种该店注册表并接通食品分销。
- 逐店处理:注册表重建是逐盒子的(本次仅东部面包店2 回到 1 行,其余店仍 0 行→其市民仍 hungry=69)。全城所有饥饿店都需各自触发一次交互,否则未处理的店继续饿。
04 · 市民集体饥饿(B-7)· 正式 Bug 报告 + 技术根因
第 I 部分 · 正式 Bug 报告
SUK-HUNGER-001commercial_boxes与simukraft_commercial_boxes.dat仍为 0 行 / 空列表 ⇒ 推翻"走远才注销、站回即恢复"的单一解释,坐实为「注册表已被清空 + 无任何自动重建路径」New-Sim-U-Kraft-2.2.0-neoforge-1.21.1(整合包《新:模拟大都市:启航 2.8fix5》)saves/新:模拟大都市:启航 2_8fix5/新模拟大都市启航(city_id=8bd15f58-6f5d-4cb7-ba09-221cd3dbc2b9);当前等级 11(大都市,配额 441 已占满);早期报告曾记等级 9(等级随发展上升)1. 摘要(一句话,2026-09-12 修订版)
「面包店还在、箱里有大量小麦、盒子甚至显示营业中」 ≠ 「市民能买到面包」。 能否供食卡在「食物来源(面包店盒子)是否处于可运行态」上——一旦盒子因区块卸载被 B-10 静默注销、或盒内店员自身饥饿罢工(
isOnHungerStrike,hunger≤0) 导致tickBox早退不放货,市民侧findPurchasePlan就找不到任何候选店 → 进入too_hungry_on_strike(太饿罢工)并永久卡死、系统无任何自愈路径。解雇重雇之所以"就好",有两个叠加效果:① 移除旧实体、生成新
CitizenEntity(hunger重置为满值 20.0);② 重新右键/重新登记会触发openForWorker/synchronizeAssignedWorkerMetadata,把盒子重新登记为running(区块加载时)。② 才是关键——但玩家走远 2 小时后盒子被 B-10 再次注销(见 §17 实测dump8),故非永久修复,是临时重启盒子。2. 本题重点:反复性与发生次数
23639518421056/77790466089024为什么必须按"反复故障"处理
3. 复现现象(第 4 次,2026-09-12)
3.1 建筑与库存均"正常"(反而证明旧假设错)
建筑实体完整、未拆除;盒 2 格区域内的箱子里有「非常多小麦」(用户原话);商铺状态为营业中。小麦充足却仍集体饥饿 ⇒ 饥饿与小麦数量无关,与「盒子是否运行 / 员工是否罢工」相关。
3.2 分工错位饥饿
东部 1 号店:「造面包的」吃饱,但外卖员和附近居民全饿;东部 2 号店除老板也饿外,症状一致;西部店同症状;西部 2 号店(刚建好、刚雇佣)反而正常吃饱。
3.3 解雇并重新雇佣后"全体又都吃饱了"
用户对每个问题店「解雇 + 再雇佣同一个造面包的 + 同一个外卖员」→ 面包店、外卖员、附近市民全部重回吃饱。原因见 §11(重置饥饿 + 重新登记盒子)。
3.4 部分市民手中持有面包
多个市民(尤其店铺老板类)主手渲染出面包;与"是否饥饿"互不排斥(饥饿的店员同样端着面包)。见 §13,属职业装扮,非异常。
4. 影响范围
city_chunks实测;city_level=11(大都市)placed_buildings实测hunger ≤ 5且全城找不到可运行的食品候选,就集体进入罢工。城市级后果
dead市民)5. 复现步骤
复现率:确定性复现(同一模板建筑、同一摆放方式 → 必现)。
6. 期望行为 vs 实际行为
isProtectingWorkProduct禁止捡食 → "店员独饿"job.heldItem),与饥饿无关第 II 部分 · 技术根因(代码级)
7. 饥饿的产生与阈值
= 20.0< 6.0CitizenManager置status=hungry≤ 5.0CitizenSelfFeedingService.shouldStart通过≤ 5.0且找不到食物too_hungry_on_strike≤ 0isOnHungerStrike = true衰减:7200 tick(6 游戏分钟)掉 1 点 ⇒ 从满值 20 掉到"开始进食"的 5.0 约需 90 游戏分钟。
8. 主死锁链(决定"一直饿、不自愈")
9. 闸门①:供给判定
canSupply(次级机制,非第 4 次主因)findPurchasePlan的候选过滤链:面包店的核心售卖报价是 materialBacked(材料支撑型):
⇒
canSupply成立 ⇔ 以"控制箱"为中心、11×5×11立方体内、被识别为容器的方块里,小麦 ≥ 3。9.5 闸门②(第 4 次主因):盒子「不在可运行态」→ 无候选 → 罢工
这是 2026-09-12 第 4 次复现后补入的主导机制,代码两级闭合(★★★★):
(A) 盒子因区块卸载被 B-10 静默注销 —— 市民侧
candidatesForBox直接迭代commercial_boxes注册表,表里没行就无候选:⇒ 区块卸载(玩家走远 > 加载半径 160 格)即注销;
commercial_boxes无该行 →findPurchasePlan无候选 → 市民罢工饿。→ 这正好是 B-10(见
03/02),B-7 的饥饿是 B-10 的下游症状之一。(B) 盒内店员自身饥饿罢工 —— 即便盒子在注册表、
canSupply通过,tickBox仍会在店员hunger≤0时早退不放货:⇒ 店员一旦饿到 0,盒子对其"名存实亡"——既不自动卖货,也不给市民供餐。
第 4 次的关键观察(用户原话锚点):
openForWorker,见 §11)。10. 为什么麦被"极快"吃光:一次进食 = 18~33 个麦(次级,仅在前 3 次「小麦耗尽」个案里成立)
对
shop_sells_bread(materials = wheat×3),每次购买扣 3 个麦 ⇒ 一次进食 = 主餐 3 + 背包 510 × 3 = 1833 个麦。⇒ 在那些盒子确实在运行、但小麦只放了一点的个案里,64 个麦只够 2~3 人次,很快
<3⇒ 该店对所有饥饿市民全部失效。但第 4 次实测小麦极多,此路径未触发。11. 为什么"解雇重新雇佣就全饱了"(双效果)
canSupply没变、麦没补 ⇒ 约 (20−5)×6 = 90 分钟后又会饿到 ≤5.0。synchronizeAssignedWorkerMetadata/openForWorker(玩家右键在职店员或重新绑定),把盒子重新登记为running(区块加载时)。这才是"全店+全居民都饱"的真正原因——食物源重新上线。12. 为什么"旁人吃饱、店员独饿"(
isProtectingWorkProduct)除"去买"主路径外,市民还有一条次级免费进食路径:捡食周围 2 格内的掉落食物(每 5 tick 扫一次)。但存在一道闸:
⇒ 上班市民(
WORKING)拒绝吃"非玩家投掷"的掉落食物。canSupply失败 / 盒子注销为什么"手动喂面包就好了":玩家把面包丢到地上 → 掉落物
owner = Player→isPlayerThrownDrop = true→ 保护闸失效 → 店员吃下。等价地,右键把面包塞进市民背包走tryEatBackpackFood旁路,同样不受该闸影响。13. "手持面包"的真实来源:
job.heldItem职业装扮绝大多数情况不是进食残留,而是职业装扮数据,属正常渲染,不是饥饿症状。
CitizenJobVisualService:MAIN_HAND_OVERRIDES是静态内存ConcurrentMap<UUID,ItemStack>(无持久化、不存档)。全量建筑统计(
all_buildings全集 116 个 JSON):job.heldItem;其中 26 个 =minecraft:bread(官方bread_shop+commercial_029+ import_wulei 的 25 个系列)。tickBox里"清手持"分支仅命中isOnHungerStrike(hunger ≤ 0)。"显示饥饿(<6.0)"与"进食罢工(≤5.0)"都远早于 0:(5, 6]hungry(0, 5]too_hungry_on_strike≤ 0⇒ 店员从"微饿"到"饿死前"整段都端着面包,只有真饿到 0 才放下 —— "饥饿 + 手持面包"是同一视觉逻辑的必然结果,完全正常。
14. 为什么"跨几十个区块的多个面包店都是同一个 Bug"
isProtectingWorkProduct→ 店员既买不到、也不能捡 → 必然饿14.5 第 5 次复现纵深结论:注册表清空后无自动重建(核心根因升级)
(一) 现象推翻单一解释
第 4 次结论把饥饿归因为
B-10「区块卸载即注销」——隐含"玩家走远才丢、站回就恢复"。第 5 次用户站在问题店内(区块加载、isCommercialControlBox=true),但:commercial_boxes表 = 0 行;data/simukraft_commercial_boxes.dat= 82 字节、TAG_List "Boxes" len=0(空列表),且该文件 15:40 写入后不再变化(同期工业.dat971B/8 盒、农场 185B/2 盒均正常、17:51 还在更新);⇒ "站回店旁即恢复"不成立。区块加载只决定
isCommercialControlBox的返回值,没有任何代码路径会在"方块在、区块已加载"时把空注册表补回。(二) 注册表清空 = 铁证,且清空后无重建
⇒ 关键三连:
tickBox在!isCommercialControlBox(含区块未加载)时永久remove且不会反向创建(它只all()遍历、不persist);reloadFromSqlite首载后sqliteLoaded=true → guardians 生效 → 即使 sqlite 里还有数据也不会二次回灌内存;而本局 sqlite 商业盒本身也已 0 行(/simukraft重载命令若此时触发会把内存也清空,见 §15 风险)。⇒ 注册表一旦空,除用户交互外永不复活 → 无商业盒 →
findPurchasePlan无候选 → 市民STRIKE饿。这就是第 5 次"站店内也不恢复"的真相。(三) 工业 / 农场幸免的分水岭(商业侧缺自愈)
⇒ 工业侧有
tryAutoStartStoppedBox自愈;商业侧无对等方法。NpcWorkChunkLoadService(强制区块加载票addRegionTicket)仅被BuilderConstructionService/PlannerWorkService调用——商业与工业都没用它做持续锚定,工业能活全靠"未加载时return跳过 + 自愈重启",商业则是"未加载即销毁 + 无自愈"。叠加商业盒在南侧(z≈4429–4499)、工业/农场盒在北侧(z≈4532–4563)持续加载区,最终 8 工业盒 / 2 农场盒存活、商业盒全灭(活存档实测industrial_boxes=8/farmland_boxes=2/commercial_boxes=0)。(四) 结论
buildView→getOrCreate/ 雇佣触发synchronizeAssignedWorkerMetadata/ 右键在职店员触发openForWorker),见 §15;getOrCreate+setRunning(true)"的自动重建/自愈路径(对齐工业tryAutoStartStoppedBox),见 §16 P0。第 III 部分 · 处置与修复
15. 玩家侧(立即可用)
buildView→getOrCreate新建running=true盒),或右键在职店员实体(≤8 格、非潜行)触发openForWorker重启盒子。两者都不需要"解雇+重雇"。WORKING保护闸。/simukraft重载命令是高风险恢复手段:该命令会CommercialBoxManager.reloadFromSqlite——先清内存 Map 再读 sqlite 回灌;若此刻 sqlite 商业盒已 0 行,重载会把内存也一并清空(永久丢失)。仅在"sqlite 里还能查到商业盒行"时可用;本局第 5 次 sqlite 商业盒已 0 行,禁止用重载命令当恢复手段,应走第 1 条的用户交互重建。16. 模组侧(治本,按优先级)
tickBox注销条件(消 B-10)continue跳过、绝不能 remove;注销时不要连带删库存 → 食物源不再随站位消失tryAutoStartStoppedBox)getOrCreate+setRunning(true)+persist,或复制工业tryAutoStartStoppedBox逻辑;并让reloadFromSqlite在注册表为空时允许从 sqlite 回灌(去掉/放宽sqliteLoaded守卫的副作用) → 站回店内即自动恢复,不再依赖玩家手动交互findPurchasePlan(允许更远距离,或降级为"付费购入背包"),绝不允许饥饿值无限掉到死isProtectingWorkProduct过度泛化buyExtraForBackpack的 5~10 连购应受库存约束,避免一名市民一次掏空城市粮仓canSupply失败或盒子离线时在商业盒界面提示,避免误判为"店坏了"第 IV 部分 · 取证
17. 存档取证(含"哪些不能作证据")
复制存档到
C:\Users\Administrator\dump\(live.sqlite=simukraft.sqlite、buildings.sqlite=simukraft_buildings.sqlite)第 4 次复现的两份差分快照(2026-09-12):
dump7(15:31 故障态)dump8(17:34 用户重雇修好后+走远 2h)commercial_boxesopen(均bread_shop,box23639518421056/57724378882112/77790466089024)commercial_stockstatus_labelgui.simukraft.commercial.status.openis_working=1)status='hungry'行数cities.funds/city_level222100.64/11city_chunkscommercial_boxes行为盒子的唯一真相finance_transactionscommercial_npc_food_tax会被物流流水挤出,不能据此断言市民没买过食物idle46/working30/resting4/hungry2/dead1→resting76/idle3/working2/hungry1/dead1resting骤增 = 夜间;hungry姓名在两次读数间变化(童踏歌→何既明)⇒ 饥饿在不同市民间轮转18. 证据清单
D:\新:模拟大都市:启航 2.8fix5\.minecraft\versions\新:模拟大都市:启航\mods\[新:模拟大都市] New-Sim-U-Kraft-2.2.0-neoforge-1.21.1.jarC:\Users\Administrator\hunger\(citizen_CitizenSelfFeedingService.txt、citizen_CitizenDroppedFoodService.txt、citizen_CitizenJobVisualService.txt、commercial_CommercialWorkService.txt、commercial_CommercialFoodMarketService.txt、commercial_CommercialControlBoxService.txt、commercial_StockService.txt)C:\Users\Administrator\all_buildings\(116 个 JSON,utf-8-sig解析;官方文件带 BOM)all_buildings\official\buildings\commercial\bread_shop.json(shop_sells_bread:visibleTo=mixed、money 0.25、结果面包×1、材料wheat×3)C:\Users\Administrator\jarlang\data\simukraft\city_levels\city_levels.json(unlocks.chunks奇数平方序列,L11=441=21²)…\saves\新:模拟大都市:启航 2_8fix5\simukraft\simukraft.sqlite…\simukraft\simukraft_buildings.sqlite(约 170 MB,含placed_buildings)19. 待确认项(不影响主结论)
citizens表行数的差异(531 vs 83):需确认 SQLite 是否采用分片/延迟刷写。dump7这类"盒子 open 却仍饿"的快照里,究竟是 (a) 市民距盒子 >256 格(findPurchasePlan距离过滤)还是 (b) 店员已isOnHungerStrike导致不放货,需一次"盒子 open 当场的市民坐标 vs 盒坐标"差分确认。两种都属 §9.5 的(B)类失败,不影响主结论。代码块为
javap -c -p反汇编输出;行号为对应.txt文件内行号,左侧数字为字节码 OFFSET/PC。存档数值取自查证时刻副本(游戏运行中会变化),机制结论稳定成立。第 V 部分 · 字节码级精确根因(哪几行出问题 + 完整因果族谱)
V.1 注册表本体:写路径齐全、重建路径为零(
CommercialBoxManager)boxes = ConcurrentMap<BlockPos, CommercialBoxData>(boxmgr:7)是唯一内存真源,双写commercial_boxes.dat(SavedData)与 SQLite。remove(level,pos)(boxmgr:210-233):ConcurrentMap.remove(:221)+setDirty()(:224)+SimuSqliteStorage.deleteCommercialBox(sqlite)(:232)—— 删内存 + 删库 + 标脏待存盘,三连清空。save(CompoundTag)(boxmgr:76-94):把boxes.values()写进 NBTBoxes列表;boxes 为空即写成 82 字节空列表 → 即simukraft_commercial_boxes.dat15:40 后定格为空的原因。loadFromSqlite()(boxmgr:124-155):首载后被sqliteLoaded守卫(:127-129if(sqliteLoaded) return)永久不再重读 SQLite;即便重读也是boxes.clear()后putAll(:149-154)——若 SQLite 已空则内存仍空。getOrCreate(pos)(boxmgr:171-180):computeIfAbsent+ 构造引用 → 只建一个空白新盒(不带任何已保存状态),且全代码库只在用户交互路径被调用。persist(data)(boxmgr:182-208):仅"写"路径(put + 存盘 + 写 SQLite)。⇒ 注册表只有"删 + 写",没有任何"按世界里的建筑扫描补建"的入口。
V.2 触发缺陷:
tickBox在"区块未加载"时 DESTRUCTIVE remove(B-10 本体)CommercialWorkService.tickBox(cws:95-110)是无可争议的起点:玩家走远 > 加载半径(160 格) → 区块卸载 →
isCommercialControlBox返 false →remove。这是"区域卸载"分支的唯一落点:它是触发,不是终态。V.3 注册入口 5 处全在用户交互(为什么站回店内不恢复)
全 jar 检索
getOrCreate仅出现在CommercialControlBoxService的 5 个方法(ccbs 行号):buildView(:9/14)右键开控制盒 GUI → getOrCreatebuildTradeView(:135/202)交易界面 → getOrCreatesynchronizeAssignedWorkerMetadata(:441/474/478/488)雇佣/同步 → getOrCreate +setRunning(true)+ persist ★关键恢复口fireWorker(:215/231/235)解雇 → getOrCreate +setRunning(false)openForWorker(:248/314/328)右键在职店员 → setRunning(true) + persist(由CitizenEntity.mobInteract触发)零处在"区块加载 / 每日 tick / 玩家上线"级。⇒ 区块重新加载时,没有任何代码把"方块还在"翻译成"补回注册表"。这正是第 5 次"站店内也不恢复"的字节码解释。
V.4 下游:空注册表如何变成饥饿(B-7 症状链)
CommercialFoodMarketService.buildCandidates(cfms:271-283):mgr.all()(:275)遍历注册表。注册表空 → 候选空 →findPurchasePlan返回 null(cfms:28-29 早退aconst_null)。市民侧CitizenSelfFeedingService.enterStrike→too_hungry_on_strike,每 200 tick 重试仍 null、无自愈/降级/兜底 → 饥饿一路掉到 0 →isOnHungerStrike(hunger≤0)→tickBox早退不放货(自锁加剧)→ 饿死;店铺停摆 → 企业税 25% 归零(见01/02)。V.5 对照:工业为何不灭(证明商业缺自愈,非"运气")
IndustrialWorkService.tryAutoStartStoppedBox(iws:196-275):方块仍在世界(isIndustrialControlBox真)即setRunning(true)(:261)+persist(:273),由 tickBox 自动调用(iws:84)。且工业tickBox在!isLoaded时return跳过、不删除。商业侧两者皆无 → 商业盒清零后永不复活。实测industrial_boxes=8/farmland_boxes=2/commercial_boxes=0(dump10)即分水岭。V.6 完整因果族谱(非矛盾共存)
「区域卸载」「站回不恢复」「注册表=0 不重建」三者互不矛盾:卸载是触发、注册表空是状态、无重建是根因;卸载把注册表清空,而无重建导致站回也补不回。B-7(饥饿症状)与 B-10(生命周期注销)是同一根因的两张面孔。
第 VI 部分 · 用户交互重建注册表的实证对照(2026-09-13 东部面包店2)
VI.1 新发现(用户观察)
这是 B-7 第 5 次("站店内不恢复")之后的正面对照实验,直接坐实了"注册表清空后,唯一恢复口是用户交互式
getOrCreate"这一根因。VI.2 代码机制(为什么解雇能恢复)
CommercialControlBoxService.fireWorker(ccbs_full.txt:215-246)字节码:关键:解雇工人这一用户动作,内部会调用
getOrCreate—— 而getOrCreate正是把盒子重新插回commercial_boxes注册表的唯一方法。注册表从 0 变 1,盒子重新成为食品分销网络的可见节点(isCommercialControlBox=true),tickBox不再把它remove,findPurchasePlan→buildCandidates→mgr.all()又能找到它 → 供应链接通 → 市民吃饱。VI.3 活存档实证(dump11,2026-09-13 18:34,WAL 三件套)
commercial_boxes行数(210,64,4498)bread_shop,running=1,status=opencitizenshungrycommercial_stockVI.4 与 B-7 根因的关系(不矛盾,是正面对照)
getOrCreate(buildView 右键GUI / syncWorker 雇佣 / fireWorker 解雇 / openForWorker 右键店员 / buildTradeView 交易面板)"。getOrCreate,解雇工人这一步本身就调到了它;雇外卖员(openForWorker)也会调getOrCreate,但不是必要条件。VI.5 诚实标注的细节
重种盒子最终
running=1 / status=open(而非fireWorker写的running=false/worker_fired)⇒ 解雇之后用户还重新打开了盒子 UI(再触发一次getOrCreate+setRunning(true))。这不影响结论:本质触发是getOrCreate重种注册表,与是否雇外卖员无关。游戏于 09-13 重启(新 PID 14296;昨崩溃卡死 PID 28196 已不在),本快照为重启后用户操作所致。VI.6 实用结论(玩家自救)
getOrCreate:右键控制盒 GUI、右键在职店员(openForWorker)、解雇/重雇任一店员(fireWorker/syncWorker)、打开交易面板(buildTradeView)。任一动作都会重种该店注册表并接通食品分销。