把代谢从三万行里搬出来:一次不拷贝状态的抽取
跨世界快照那篇讲的是一件事:把状态抄出来(capture / restore),一个字节都不能漏。
这篇讲反过来的事:把一段代码搬出去,而状态留在原地。
对象是 game.cpp 里那段代谢结算——卡路里池、水分池、胃里食物转热量、池空之后的饥饿口渴累积、体重漂移。主文件两万六千多行,这段逻辑埋在里面,混在生存体征、疲劳、体力恢复、世界 tick 之间。这次把它搬进独立模块 survival_runtime。
一、先看改动的账
| 文件 | 变化 |
|---|---|
game.cpp | −86 行(逻辑搬走,原地只留一次调用) |
survival_runtime.cpp | +105 行(新模块实现) |
survival_runtime.hpp | +53 行(配置结构 + 余数绑定) |
CMakeLists.txt | +1 行 |
tools/verify_d1_metabolic_pools.py | +52 行(专项门禁) |
改动面五个文件,没有碰任何存档格式、数据文件或既有规则。抽取的目标是”行为零变化”,不是”顺手把这段重写一遍”。所以后面每一条都是”原样搬走”,包括看着别扭的地方。
二、抽取的第一个决定:状态放哪
最自然的写法是给这个函数造一个参数结构体:
struct MetabolicPools {
float cal_frac, water_frac, stomach_frac,
hunger_frac, thirst_frac, weight_frac;
};
然后 pools = collect(); advance(pools); apply(pools);。三种写法都能跑通,但三个地方会出错:
- 忘了
apply()—— 结算结果留在副本里,游戏里什么都没发生; - 中途有人只 update 了一半字段;
- 存档读写、tick 调试打印、门禁探测全都直接看
Game的成员cal_frac_等,现在多了一份副本,两边可能不同步。
真正决定这次抽取形态的约束是第三条:这六个余数是 Game 的成员,抽取之后必须还是。因为存档序列化读的是它们,调试打印打的是它们,门禁断言量的也是它们。
所以结构体里不放值,放引用:
// References bind the existing six save fields; no copied remainder state.
struct MetabolicPoolRemainders {
float& calories;
float& water;
float& stomach;
float& hunger;
float& thirst;
float& weight;
};
调用点当场绑定:
if (dt_sec > 0) {
const float dm = dcfg_.metab_rate[difficulty_];
const MetabolicPoolRemainders fractions{cal_frac_, water_frac_, stomach_frac_,
hunger_frac_, thirst_frac_, weight_frac_};
advance_metabolic_pools(vital, dt_sec, dm, metab_, fractions,
debug_lock_hunger_, debug_lock_thirst_);
}
新模块的函数体开头再把这些引用改回原来的局部名字:
auto& cal_frac_ = fractions.calories;
auto& water_frac_ = fractions.water;
// ...
于是那 86 行的函数体一行都没改,整段平移过去。这是这次抽取最省事的一步,也是风险最大的一步——后面第三节说。
头文件里那句注释是刻意写的:这些引用绑的是既有的六个存档字段,这里没有第二份余数状态。 引用参数没有所有权,读代码的人必须在一眼之内看出”这六个是谁的”。这句话就是那个”一眼”。
顺带一个诚实的瑕疵:函数签名里两个调试锁写的是 const bool&。传 bool 用引用在 C++ 里是反模式(拷贝一个 bool 比解引用便宜),它纯粹是抽取时原样照抄留下的痕迹。如果今天重写,我会把它们改成值传递。抽取不顺手改东西,是为了让”行为零变化”这件事可验证;但痕迹要认领,不能装作是深思熟虑。
三、引用参数是”隐式输出”
新函数签名长这样:
void advance_metabolic_pools(engine::Vital& vital, std::int64_t dt_sec, float dm,
const MetabolismCfg& metabolism,
const MetabolicPoolRemainders& fractions,
const bool& lock_hunger, const bool& lock_thirst);
一眼看过去,输出只有一个 vital。实际上写出去的还有 fractions 里的六个小数——它们是引用,改的就是主对象。
这就是”抽取之后行为没变”的另一面:能改的状态没有变少,只是从”函数体内的成员变量”变成了”通过引用传进来的同名变量”。
所以边界要写清楚,注释里定死:这个函数只做池的推进。日志、体力恢复、疲劳累积、世界 tick、事件——全部留在 Game。难度倍率 dm 作为参数传进来,且只在 dt_sec > 0 时生效。一个”纯粹一点的结算内核”,比一个”顺便干点别的”的大函数好测得多。
四、搬的时候最容易丢的不是注释,是顺序
这 86 行里有六条东西是语义而不是风格。它们后来被我一条条写进文档,因为抽取过程中任何一条被动到,都是静默的行为变化:
1. 池先行。 先扣卡路里/水分池,再判断”池空了没有”,最后才涨饥饿口渴。顺序反了,池空的那一瞬间会先多涨一口饥渴。
2. 难度倍率的位置。 dm 同时乘在”池消耗”和”池空后的涨速”上。
const double CAL_BURN_PER_SEC = metab_.cal_burn_per_sec() * dm;
历史事故是这样的:最初 dm 只乘在池空之后的涨速上,于是简单模式照样 1.2 天把水和卡耗空,玩家对难度毫无感知——只有饿死更快。这种”半程生效的难度”比完全不生效更难查。
3. 空池丢弃余数,这是故意的。
if (vital.calories <= 0) cal_frac_ = 0.0f; // 池空: 代谢转入累积段, 丢弃不足1点的余数
if (vital.water <= 0) water_frac_ = 0.0f;
看着像 bug(凭啥把攒了半天的 0.8 扔掉),但它和第五节的等待分片长度是锁在一起的两件事,不能单独动。
4. 空胃不能预支消化。
if (vital.stomach_kcal > 0)
stomach_frac_ += static_cast<float>(dt_sec * DIGEST_PER_SEC);
else
stomach_frac_ = 0.0f;
胃空着的时候不攒消化额度。否则玩家”饿一整夜不吃饭”,醒来吃东西,胃里会凭空多出一份消化进度——那是在为还没吃进去的饭预支时间。
5. clamp 是单一真源。 体重 std::clamp(..., 45, 140)、饥饿上限 100、口渴上限 150。不 clamp 的历史后果是累加点一路涨到饥饿 10666 / 口渴 48000(阈值只有 100/150),而且这些值会跟着存档一起带上。
6. 标尺注释要跟着代码走。 比如水这一段:
// 水: 按 CDDA 标尺校准 — 一次饮水约 +70(见物品 quench), 目标 3~4 次/天,
// 故日耗 ≈250; 池 500 → 约 2 天耗尽, 之后渴值涨(约 1 天到 150 渴死) ≈ 3 天不喝水死
这类注释是设计意图的唯一载体。代码搬走了,注释没搬,半年后没人知道 250 这个数字是怎么来的。所以这次是把注释当代码搬的。
五、一条注释把两件事锁在一起
等待快进的分片长度是 3600 秒(1 小时)。为什么不是 600 秒——代码里写着:
// ⚠️ 实测教训:曾试过细化到 600 秒/片(想让预警档位更细腻),结果改变了代谢总量 ——
// 分片越细 → 余数丢弃次数越多 → 口渴/热量推进变慢(实测 3 天口渴 150 → 109,
// 死因从"渴死"变成"缺乏症")。要细化分片,必须先让 tick_vitals() 的余数累积跨片保留。
翻译一下:因为空池会丢余数(第四节第 3 条),分片越细,丢的次数越多,同样长的游戏时间里代谢总量就变小了。实测 3 天等待,渴值从 150 掉到 109,死因从”渴死”变成”缺乏症”。
抽取时代码原样搬走,这个 3600 原样留下。如果哪天有人觉得”这只是个常量,改小点让预警更细腻”,他会先改到一个看似无关的数字,然后在均衡性报告里看见死因变了,再花一天回到这条注释上来。
六、验证:89 个真进程,一条正则
这类抽取不用单元测试,用真 exe 走 CLI:--wait-sec 快进、存档、读档,比较结构化输出。理由是要验的是”结算 + 持久化”两个系统交界的真实行为,不是某个函数在真空里的返回值。
专项门禁 verify_d1_metabolic_pools.py 里 89 个真实进程,覆盖:
- 零时间读档:六个数值和六个余数相位都不许动;
- 旧档没有这段字段:默认 0,且六个相位归零;
- 六个余数各自的 1 秒边界:先塞一个”差一点点就进位”的值(
1 - 2^-17),等 1 秒,看它是否恰好进位 1; - 0.5 往返两次读档:位相等(0.5 是二进制精确值,用来排除打印/解析误差);
- 分块 vs 连续:同样 35 秒、同样 346 秒,一次走完和拆两段,终态逐位相同;
- 空胃闲置 1 小时:确认没攒下消化额度;
- 体重上下界:在边界值上再推一秒,看是否被 clamp 住。
最后一个有意思的细节:门禁查体重边界的上下限,不是硬编码 45 / 140,而是从源码里正则读出来:
bounds = re.search(
r'vital\.body_weight\s*=\s*std::clamp\(vital\.body_weight\s*\+\s*drift,\s*(\d+),\s*(\d+)\)',
source.read_text(encoding='utf-8'))
assert bounds
low, high = map(int, bounds.groups())
好处:抽取过程里如果有人把 45 悄悄改成 40,门禁跟着改,而不是”代码写 45、门禁也写 45,两边一起错”。
代价(诚实说):它防不住”这个常数本身就不对”,而且源码格式一改正则就抓不到——所以后面跟了 assert bounds,抓不到就直接崩,不许静默跳过。
七、门禁的第一条断言是”什么都没变”
这类门禁跑完的最后几项断言是这次抽取里我最满意的部分,跟功能无关:
- 源码与数据文件 SHA 不变;
- exe SHA 不变;
- 正式存档目录树不变。
因为门禁自己就是最容易改坏东西的脚本——它要起进程、要存读档、要造场景。所以它必须在结构上证明自己是只读的:造出来的存档全部落在私有临时目录,正规 saves/ 一个字节都不许动。
同一套逻辑在回归层更严:跑完要核 exe 的字节,确认”被测的那份二进制”从头到尾没被换过。
八、这次抽取真正留下的东西
不是”主文件少了 86 行”。是六条以前只活在两万六千行里的隐式知识,被写下来了:结算顺序、难度倍率乘在哪一层、什么情况下余数该被丢弃、空胃不预支、clamp 是单一真源、分片长度和丢弃余数是绑定的。
抽取的验收标准也很简单:所有历史门禁的行为不变,存档字节不变,常规套件一步不变差。
不过这次抽取只搬走了一半的生存逻辑。疲劳累积和营养消耗还在 Game 里——搬它们之前,得先解决一个更前置的问题:那三个余数(疲劳、宏量营养、微量营养)根本不在存档里。 你在内存里搬得再干净,读一次档就全丢了。
下一篇写这个。