Skip to content

04-市民集体饥饿-技术根因与正式Bug报告(新发现和修正) #26

Description

@wqj11a

04 · 市民集体饥饿(B-7)· 正式 Bug 报告 + 技术根因

本文件的用途:一份两用文档

  • 第 I 部分 = 正式 Bug 报告SUK-HUNGER-001):可直接提交给模组作者,含严重级、发生次数、复现步骤、影响范围、期望/实际。
  • 第 II 部分 = 技术根因:逐行字节码取证,供开发者定位与修 bug、供你自查。
    关联:缺陷台账见 03;饥饿引发的税收/商店停摆见 0203 的 B-10/B-11。

📐 城市区块双量纲(计量关系 · 重点):模组存在两个不同量纲,务必区分:

  • 21×21 = 441 = 城市面积配额(本城已用满)city_levels.jsonunlocks.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_boxessimukraft_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 静默注销、或盒内店员自身饥饿罢工(isOnHungerStrikehunger≤0 导致 tickBox 早退不放货,市民侧 findPurchasePlan找不到任何候选店 → 进入 too_hungry_on_strike(太饿罢工)并永久卡死、系统无任何自愈路径

⚠️ 第 4 次复现推翻了前 3 次的「小麦 < 3 / 不在供给窗口」假设:本次用户明确「盒 2 格区域的箱子里有非常多小麦」,店员与居民仍全体饥饿。小麦数量不再是决定因素,决定因素变成「盒子是否在运行 / 店员是否罢工」。前 3 次报告里的「小麦耗尽」是误归因(详见 §9.5 / §19)。

解雇重雇之所以"就好",有两个叠加效果:① 移除旧实体、生成新 CitizenEntityhunger 重置为满值 20.0);② 重新右键/重新登记会触发 openForWorker/synchronizeAssignedWorkerMetadata,把盒子重新登记为 running(区块加载时)。② 才是关键——但玩家走远 2 小时后盒子被 B-10 再次注销(见 §17 实测 dump8),故非永久修复,是临时重启盒子

⚠️ 第 5 次复现(2026-09-12 17:50)推翻了"区块卸载是终态、站回即恢复"的隐含假设:本次用户故意不解雇重雇、保持故障态,并站在问题面包店内(区块已加载、isCommercialControlBox=true)。结果店员仍「很饿了」、commercial_boxessimukraft_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 号店(刚建刚雇)正常

为什么必须按"反复故障"处理

  1. 已达 4 次,且两次之间没有任何玩家侧动作能改变结果(解雇重雇只是临时清零 / 临时重启盒子)。
  2. 跨 2 个彼此独立的面包店同时命中(坐标相距约 197 格)⇒ 指向代码/数据层面的系统性缺陷,而非某栋建筑摆放失误。
  3. 可确定性复现:沿用同一套模板摆放面包店 → 必现。

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 CitizenManagerstatus=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_breadmaterials = 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 = PlayerisPlayerThrownDrop = 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

CitizenJobVisualServiceMAIN_HAND_OVERRIDES静态内存 ConcurrentMap<UUID,ItemStack>无持久化、不存档)。

全量建筑统计all_buildings 全集 116 个 JSON):

  • 70 个建筑定义 job.heldItem;其中 26 个 = minecraft:bread(官方 bread_shop + commercial_029 + import_wulei 的 25 个系列)。
  • 关键边界:tickBox 里"清手持"分支命中 isOnHungerStrikehunger ≤ 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(方块破坏)

关键三连

  1. 注册入口 5 处全在用户交互(右键 GUI / 交易 / 解雇 / 雇佣 / 右键店员),零处在"区块加载 / 每日 tick / 玩家上线"级;
  2. tickBox!isCommercialControlBox(含区块未加载)时永久 remove不会反向创建(它只 all() 遍历、不 persist);
  3. 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(强制区块加载票 addRegionTicketBuilderConstructionService/PlannerWorkService 调用——商业与工业都没用它做持续锚定,工业能活全靠"未加载时 return 跳过 + 自愈重启",商业则是"未加载即销毁 + 无自愈"。叠加商业盒在南侧(z≈4429–4499)、工业/农场盒在北侧(z≈4532–4563)持续加载区,最终 8 工业盒 / 2 农场盒存活、商业盒全灭(活存档实测 industrial_boxes=8/farmland_boxes=2/commercial_boxes=0)。

(四) 结论

  • B-7 饥饿的本质 = 商业盒注册表丢失后无自动重建,并非"某一刻区块没加载";
  • B-10「区块卸载即注销」是触发清空的一种手段,但不是终态;终态是"清了就没人补";
  • 唯一恢复 = 用户交互(右键控制盒触发 buildViewgetOrCreate / 雇佣触发 synchronizeAssignedWorkerMetadata / 右键在职店员触发 openForWorker),见 §15;
  • 治本修复必须给商业盒加"区块加载时自动 getOrCreate + setRunning(true)"的自动重建/自愈路径(对齐工业 tryAutoStartStoppedBox),见 §16 P0。

第 III 部分 · 处置与修复

15. 玩家侧(立即可用)

  1. 店饿先看盒子是否在运行不解雇重雇也能恢复——站到面包店控制盒旁(≤8 格)右键打开商业 GUIbuildViewgetOrCreate 新建 running=true 盒),或右键在职店员实体(≤8 格、非潜行)触发 openForWorker 重启盒子。两者都不需要"解雇+重雇"。
  2. 把小麦放进"控制箱 ±5 格水平、±2 格垂直"范围内的容器并持续补(仅在前 3 种「小麦耗尽」个案里有效;第 4/5 次证明光补麦不够,盒必须在运行态)。
  3. 急救单个饿肚子店员:把面包丢到地上让他捡,或右键把面包塞进他的背包 —— 两者都绕过 WORKING 保护闸。
  4. 不要依赖"解雇重雇"永久修复:它只重置饥饿 + 临时重启盒子,走远后 B-10 再次注销、约 90 分钟后复发。第 5 次复现已证明即使不重雇、站在店内也恢复不了——因为缺的是"自动重建路径"而非"饥饿值/站位"。
  5. 验证手段:重启盒子后,等饥饿掉到 ≤5.0(约 90 游戏分钟),观察该市民是否自动走向面包店、状态是否离开"太饿罢工";若仍罢工 ⇒ 盒子又离线(B-10)。
  6. ⚠️ /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.sqlitebuildings.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 盒子注销的下游症状,且重雇非永久修复citizenshunger,饥饿是运行时实体属性,只能由代码机制推断。

取证项 结果 说明
commercial_boxes 行为盒子的唯一真相 行忽有忽无 与玩家站位强相关(B-10);不能用"行缺失"推断"方块被拆"
finance_transactions 128 行窗口 低频 commercial_npc_food_tax 会被物流流水挤出,不能据此断言市民没买过食物
第 3 次复现观测(16:27/16:29 两次连读) idle46/working30/resting4/hungry2/dead1resting76/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.txtcitizen_CitizenDroppedFoodService.txtcitizen_CitizenJobVisualService.txtcommercial_CommercialWorkService.txtcommercial_CommercialFoodMarketService.txtcommercial_CommercialControlBoxService.txtcommercial_StockService.txt
建筑定义全集 C:\Users\Administrator\all_buildings\(116 个 JSON,utf-8-sig 解析;官方文件带 BOM)
面包店定义 all_buildings\official\buildings\commercial\bread_shop.jsonshop_sells_breadvisibleTo=mixedmoney 0.25、结果面包×1、材料 wheat×3
城市等级配置 C:\Users\Administrator\jarlang\data\simukraft\city_levels\city_levels.jsonunlocks.chunks 奇数平方序列,L11=441=21²)
存档(活) …\saves\新:模拟大都市:启航 2_8fix5\simukraft\simukraft.sqlite
存档(建筑) …\simukraft\simukraft_buildings.sqlite(约 170 MB,含 placed_buildings

19. 待确认项(不影响主结论)

  1. HUD「世界人口」与 citizens 表行数的差异(531 vs 83):需确认 SQLite 是否采用分片/延迟刷写。
  2. 外卖员身份确认:本专题采用"外卖员 = 普通 citizen(同一套进食逻辑)"的判断;外卖员属另一模组(外卖来了之类),其饥饿与本案面包店机制相互独立,建议在物流/外卖相关市民上单独复测。
  3. 「盒子在运行 + 小麦充足 + 市民仍饿」的子情形:在 dump7 这类"盒子 open 却仍饿"的快照里,究竟是 (a) 市民距盒子 >256 格(findPurchasePlan 距离过滤)还是 (b) 店员已 isOnHungerStrike 导致不放货,需一次"盒子 open 当场的市民坐标 vs 盒坐标"差分确认。两种都属 §9.5 的(B)类失败,不影响主结论。
  4. 前 3 次「小麦 < 3」假设的修正:第 4 次证明小麦数量非主因;前 3 次报告里的"小麦耗尽"应视为误归因(可能当时盒子也处于注销/罢工态,只是表象像小麦不够)。本报告 §9 / §10 已相应降级为"次级机制"。

代码块为 javap -c -p 反汇编输出;行号为对应 .txt 文件内行号,左侧数字为字节码 OFFSET/PC。存档数值取自查证时刻副本(游戏运行中会变化),机制结论稳定成立。


第 V 部分 · 字节码级精确根因(哪几行出问题 + 完整因果族谱)

2026-09-12 第 5 次复现后,重新反编译 CommercialBoxManager / CommercialWorkService / CommercialControlBoxService / CommercialFoodMarketService / IndustrialWorkService(产物 boxmgr_full.txtcws_full.txtccbs_full.txtcfms_full.txtiws_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.enterStriketoo_hungry_on_strike,每 200 tick 重试仍 null、无自愈/降级/兜底 → 饥饿一路掉到 0 → isOnHungerStrikehunger≤0)→ tickBox 早退不放货(自锁加剧)→ 饿死;店铺停摆 → 企业税 25% 归零(见 01/02)。

V.5 对照:工业为何不灭(证明商业缺自愈,非"运气")

IndustrialWorkService.tryAutoStartStoppedBox(iws:196-275):方块仍在世界(isIndustrialControlBox 真)即 setRunning(true)(:261)+ persist(:273),由 tickBox 自动调用(iws:84)。且工业 tickBox!isLoadedreturn 跳过、不删除。商业侧两者皆无 → 商业盒清零后永不复活。实测 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 不再把它 removefindPurchasePlan→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)。全城所有饥饿店都需各自触发一次交互,否则未处理的店继续饿。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions