存档里少三个小数,读档就在偷时间
上一篇(代谢池抽取)结尾留了一句:疲劳和营养还没搬,因为那三个小数根本不在存档里。
这篇解决它。
一、先看这三个小数在干什么
疲劳、宏量营养、微量营养都不是”每回合 +1”的,是按时间累积的小数累加器:
| 累加器 | 攒满 1 点要多久 | 效果 |
|---|---|---|
fatigue_frac_ | 清醒 648 秒(约 11 分钟) | 疲劳 +1 |
macro_frac_ | 3456 秒(约 58 分钟) | 碳水 / 蛋白 / 脂肪各 −1 |
micro_frac_ | 4320 秒(1.2 小时) | 维生素 / 矿物质各 −1 |
(数值来自 data/base/metabolism.json:清醒 18 小时攒满 100 疲劳;宏量 4 个游戏日耗尽 100 点;微量 5 个游戏日。)
看这张表就知道问题在哪了:它们的周期是小时级的。不像”冷却还剩 37 秒”那种秒级余数,这三个丢一次,损失的是几十分钟的进度。而丢的方式极其安静——不信看下面的实测。
二、三个真实反例
验证方法是老三样:同一个输入、同样的总时长,分「连续跑」和「分两半、中间存读档」两条路,终态必须相同。
跑的是一整周期(疲劳 648 秒 / 宏量 3456 秒 / 微量 4320 秒),全部用真 exe、真存档、两个独立进程:
| 量 | 连续跑一整周期 | 分两半 + 存读档 | 差 |
|---|---|---|---|
| 疲劳 | 1 | 0 | −1 |
| 碳水(宏量代表) | 99 | 100 | +1 |
| 维生素(微量代表) | 99 | 100 | +1 |
三个数字的对比已经很清楚了:连续玩整整一个周期刚好推进 1 点,中间存一次档就一点都没推进。
这意味着什么?玩家每个游戏日的进度(疲劳涨 100 里的那 1/100、营养掉 100 里的那 1/100),会在每次读档时被清零。玩得越久、存读档越勤,亏得越多——而且没有任何报错。数值疲劳看起来永远比”应该”的少一点,营养永远比”应该”的多一点。
这种 bug 的特点是:果在游戏里,因在存档格式里。
三、为什么它比”冷取余数”那种 bug 更隐蔽
上一篇那种秒级余数(冷却、腐烂、消化)丢了,症状是”同一段时间走法不同结果不同”,还能靠对比实验抓出来。
这三个不一样,它们有一层心理掩护:
- 疲劳值不到 100 之前,玩家看到的永远是整数(0、1、2……)。少 1 点疲劳,感受上叫”我好像没那么累”,不叫”游戏错了”。
- 营养值更妙:读档之后它偏高(99→100),而偏高在体感上等于”我这段时间白吃了东西还满营养”,像是好事。
我后来把这类 bug 归纳成一句话:整数部分存的是”结果”,小数部分存的是”过程”。 存档的人(和写存档的代码)脑子里想的是”现在疲劳值是 3”,但真正决定下次何时 +1 的,是”这 3 里面有多少是这段时间攒的、多少是上次留下的”。
四、修法:19 行
改动如下(同一次提交):
保存(save_state)3 行:
j["survival_remainders"] = {
{"fatigue", fatigue_frac_}, {"macro", macro_frac_}, {"micro", micro_frac_}
};
恢复(load_state)16 行,含一段校验 lambda:
const auto survival = root.value("survival_remainders", json::object());
if (!survival.is_object()) throw std::runtime_error("invalid survival remainders");
const auto read_survival_fraction = [&](const char* key) -> float {
const auto saved = survival.find(key);
if (saved == survival.end()) return 0.0f; // 旧档缺键 → 0
if (!saved->is_number()) throw ...; // 非数值 → 拒
const double value = saved->get<double>();
if (!std::isfinite(value) || value < 0.0 || value >= 1.0) // NaN/Inf/负/≥1 → 拒
throw std::runtime_error("invalid survival fraction range");
const float result = static_cast<float>(value);
if (result >= 1.0f) throw ...; // 转 float 之后四舍五入到 1 → 拒
return result;
};
三行保存、十六行恢复。修的是一个”偷时间”的 bug,代价不到 20 行。
修完复核:三个等时间反例全部消失,分块跑和连续跑终态一致;并且检查了半段相位确实被保存——在中间那次读档返回的状态里,相位值是大于 0 的实测值,不是”碰巧最后数字一样”。
最后一条检查值得单独说。如果这三个余数藏在私有成员里、外界只能看到疲劳值,那么”它保存了没有”就无法直接断言,只能靠最终数值差异反推——而最终差异可能被别的效应盖掉,也可能是巧合。内部状态可观测,是这类验证的前提。
五、一个不显眼但很尖锐的拒绝条件
上面那段校验里最后一行是最有意思的:
if (result >= 1.0f) throw ...; // double 合法,转成 float 之后变成 1.0
因为 0.999999999 这个 double 在 JSON 里完全合法,但存进 float 就四舍五入成了 1.0——而”余数”的定义就是”不足一点”。一个等于 1.0 的余数一旦被接受,下一次累积就会立刻进位,等于凭空多送 1 点。
这类校验的边界不在”值本身合不合法”,而在值经过了哪几层表示。JSON 里的 double → C++ 的 float,中间那次精度折叠也得算进契约。所以坏档测试里专门放了 0.999999999 这个用例。
六、旧门禁必须是红的
这次改动有一个别处不常见的要求:旧门禁跑出来必须失败。
原因:新加的断言(“分两半的终态必须等于连续跑的终态”)用来验证新行为。如果拿旧代码跑,这些断言必须挂——否则说明断言是空转的,验不出任何东西。
实测结果:
| native 进程 | 断言 | |
|---|---|---|
| 旧代码(改前) | 137 个进程全部正常退出 | 445 / 489,恰好 44 项失败,失败名字逐条对得上 |
| 新代码(改后) | 137 个进程全部正常退出 | 489 / 489 |
这里有个必须写清楚的区分:旧代码是断言失败(RED),不是进程崩溃。 两边的真实进程都是 0 退出码、都正常跑到输出。把”我造了一个会崩的场景”和”旧行为确实不满足新契约”混为一谈,是在给结果贴金——真实的 RED 长什么样,就该怎么写。
红的那份日志、那份存档、那个 exe 的 SHA,全部留档不删。重跑成功不覆盖原始失败,这是这个项目的硬规矩:后来排查驱动问题时,正是靠一份”保留的原始失败截图”才分清了”逻辑错”和”环境错”。
七、跑一轮”完整套件”是 102 分钟
这次验证要求无筛选全量套件:203 步,实测 6123 秒(约 102 分钟),编号和描述连续、无跳过、无 FAIL,20 项保护检查全过。
102 分钟意味着”跑一轮”是个需要规划的动作,不能随手重跑。所以流程里有几条配套纪律:
- 跑套件期间绝不构建。 链接器要重命名输出 exe,会导致套件从某一步开始全部报”文件找不到”——看着像大面积回归,其实是 exe 在脚下被换了。
- 跑之前冻结被保护的东西:源码、数据、正式存档目录,跑完逐个核对 SHA。
- 一轮完整套件 ≠ 一个大脚本里塞 203 个小测试。 每一步的记录里都有编号、描述、退出码和原始产物路径,方便事后逐条核查”这一步到底跑了什么”。
- 跑完做独立闭合校验(这次 34/34),对整个存档树算 SHA 留底。
还有一条我最喜欢的:全量套件跑的时候,被测的构建产物被暂存到独立候选目录再跑,主工作区的产物冻在那儿不动。因为”验证的是哪个二进制”这件事,一旦含糊,后面所有结论都得打问号。
八、旧档怎么办
改了存档格式,三条纪律(都是从事故里长出来的):
- 缺对象 / 缺键 → 默认 0。 旧档里确实不知道这两个月攒了多少余数,设 0 是诚实的。宁可少算,不要猜算——按游戏时钟反推一个”看起来合理”的值,等于在存档里写了个假数,而且它会污染后续所有结算。
- 拒绝,不是修正。 非法值一律抛异常、原档文件一字不动,而不是”帮你 clamp 一下继续跑”。理由是存档是玩家唯一的数据:被”智能修正”过的坏档,玩家永远不知道自己丢了什么;被明确拒绝的坏档,至少玩家知道”这一档读不了,但它还在”。
- 部分失败不是正常继续。 余数对象不合法就整档拒绝,不许”好的部分先读上”——那会让玩家带着一个半真半假的世界继续玩,之后所有的”为什么数值不对”都无从查起。
实测的坏档矩阵:32 个非法输入(bool、越界、float 舍入到 1、非法 root 对象……),每一个都确认读档被拒绝、原存档字节未变、游戏回退到新游戏。另外核对时还有个副产品:105 个合法存档 JSON 里,只有新增的 survival_remainders 和 5 处明确记录的进度差异,没有排序或其他任何归一化——这证明新字段是”插进去的”,不是”顺手把存档重写了一遍”。
九、这次改动还留了一个尾巴
同一段代码里其实还有第四个余数:睡眠恢复用的 rest_frac_。
它是”还”的那一侧——疲劳是按时间攒的,睡眠是按时间还的,两边各有一个小数累加器。这次刻意没动它:它是独立的恢复策略(恢复速率受床铺、debuff 影响),不在”保存推进量”的范围内。
但既然看见了就得记下来。余数总是一对一对出现的:攒的、还的,消耗的、恢复的,你只存一半,那个系统就还是半吊子。
十、收尾
存档格式到现在为止有两组余数:六个代谢池(热量/水分/胃/体重/饥饿/口渴),三个生存推进量(疲劳/宏量/微量)。
每加一个字段的成本都不是那 3 行序列化,而是给所有未来的读档路径加一次契约:缺了怎么办、坏了怎么办、精度折叠了怎么办。这一课在这次改动里体现得特别干净——19 行代码,换回来的是”存档续玩”和”一直挂着玩”终于变成同一个游戏。