「做完了」这三个字,在这个项目里不算数
本文目录
前三篇讲的都是技术:时间系统、测试、改代码的手法。
这一篇不太一样。它讲的是**“做完了”这件事怎么证明**。
背景交代两句:这个项目的代码有相当一部分不是我一行行敲的——一部分交给 AI 协作者,一部分同时有好几条线在改(这个仓库现在有294 个分支、1131 个提交)。
在这种工作方式下,“完成”这件事就不再是”我记得我写了”,而变成需要证据的东西。
一、勾全打了,功能在游戏里零效果
这条是我建立整套验收纪律的起因。
有一批功能,记录里 A1 到 E6 全部勾选完成,配套的自动化测试全绿。按任何常规标准,这批活是交付了。
然后我实机跑了一遍,发现有 3 处功能在游戏里完全没有效果。
它们有一个共同的形状:首尾都写了,中间断了。
具体到代码,一个”数据驱动”的功能要真正生效,需要四环俱全:
| 环 | 怎么查 | 当时的实况 |
|---|---|---|
| ① 结构体声明了字段 | grep 结构体头文件 | ✅ 有 |
| ② 加载器解析了这个字段 | grep 加载器实现 | ❌ 加载器从没读它 → 字段永远是空的 |
| ③ 数据文件填了值 | grep -rl 整个数据目录 | ❌ 一个都没填 → 零效果 |
| ④ 加载器接受的形状 == 数据的形状 | 比对加载器入口的容器判断与文件顶层 | ❌ 加载器要 {"groups":[...]},数据是裸数组 → 静默加载 0 条 |
①②③④里,只有①是”写代码”的时候会自然完成的。②③是”接线”,④是”两边的约定”。
而 ④ 是最隐蔽的一环:加载器里一句
if (!j.contains("groups")) return 0;
这类守卫无报错、无日志,只表现为”功能没反应”。你对着代码看,每一行都对。
教训:只查”代码写了”会漏掉中间两环。数据驱动功能的验收必须逐环 grep,缺一即”未接线”。
二、“编译过 + 分支存在”不算证据
四环 grep 还是静态的。静态手段查不出”运行时到底走没走到”。
所以第二步是运行期证据:在功能入口加一个环境变量门控的诊断计数,跑一次实例读数字。
int used_a = 0, used_b = 0, spawned = 0;
if (std::getenv("OUTLAND_LOOT_DUMP"))
std::printf("[loot] 建筑=%d 用战利品组=%d 用原stuff=%d 生成物品=%d\n",
total, used_a, used_b, spawned);
实测数字是这样的:
- 修之前:
建筑=4 用组=0 用原stuff=4→ 明确没接线 - 修之后:
用组=4 生成物品=5→ 通了
判据看计数,不看有没有打印。
这个手段还有第二个价值:这类 dump 顺手就能写进门禁脚本,变成一条可重跑的回归。一次排查变成一道永久护栏。
三、功能存在 ≠ 玩家碰得到
第三层是可达性。这层是我被一个具体 bug 教会的。
项目里有”世界门槛钥匙”——要进某个世界得先拿到对应的钥匙。钥匙物品定义好了,数据也对,代码也读。
但它们只存在于物品表里。
没有配方、没有任务奖励、没有战利品组、没有建筑蓝图——没有任何一条获取路径。 结果就是三个世界永远进不去。
从此凡是新增门槛、钥匙、任务物品,我都会跑一遍获取源 grep:
for k in <新增 id>; do
echo "$k 配方:$(grep -c $k data/base/recipes.json) \
任务:$(grep -c $k data/base/quests.json) \
战利品组:$(grep -c $k data/base/itemgroups.json)"
done
规则:新增”进入条件/门槛道具”的同时,必须给它至少一条获取路径。
反方向也要查:新增的 NPC、任务、变异、法术,要确认被世界池或生成流程引用——否则它们永远不会出现在游戏里。还有一类更尴尬的:某个法术有 dmg_base(基础伤害)和 dmg_per_level(每级加成),代码也真按 基础 + 等级 × 每级 结算了,但数据里这些字段全是空的——机制在位,数值为零。
四、程序化生成:阈值区间必须覆盖分布中心
这条坑的形状很好玩,值得单独讲。
世界的生成是分层的,地形按噪声值走一串 else-if,粗看是这样:
if (v > tree_level) → 树
else if (v > feature_level) → 特征物
else if (v > water_level) → 水
有一次某个新建的世界一座建筑都没有。生成流程没报错,就是空。
排查的办法是在放置循环里加个 dump,打印”被拒的原因”:风格 / 蓝图数 / 放置数 / 被拒数 / 通过样本地形 / 被拒样本地形。
出来两个数字,一眼就明白了:
800/800 次放置被拒,而且被拒样本全是同一种地形。
根因在那个世界风格的参数上:
tree_level = 0.1
feature_level = 0.4
tree_level 比 feature_level 低——意味着大部分格子在第一层就被判成”树”。而建筑要求一整块空地,于是一次都放不下去。
健康世界实测的参数关系是:
tree_level > feature_level > water_level
tree_level - max(water_level, feature_level) ≥ 0.2 (实测健康值 0.20~0.35)
修法是改数据,不是改代码。 不要去放宽”这里能不能放”的谓词——那会把所有真实约束一起淹没。
(这条还有一个副产品:“被拒样本全是同一种地形”是个特别好用的诊断信号。 遇到”某类东西一个都没生成”,先 dump 被拒原因,看它是不是全落在同一个分支。)
五、数据审计的口径错了,数字会虚高好几倍
有一阵子我给数据做缺口审计,扫出来一堆问题。印象最深的是两个数字:
- 建造可建率:21%
- 配方里的死配方(产物不存在):58%
看着像个废了的数据层。差点就按这个结论去开一堆修复任务。
后来发现口径错了:这个项目的数据在加载时会被补丁表改写——有一张材料别名表把中间材料名映射到本库的实际物品,另有产物映射和 disabled 跳过。
没复刻这套改写就直接扫数据,会得到严重虚高的缺口。
套上别名表之后,真实数字是:
- 建造可建率:80%
- 死配方:8%
教训:数据审计必须按”运行时口径”算——也就是和门禁、和游戏加载走同一套改写。 两个口径给出不同数字时,以门禁脚本的输出为准,并且要在报告里写明口径差异,不要并列两个数让读者自己猜。
顺带说,那张别名表本身也藏着问题:它是一对一硬映射,不是”任意一种材料”的语义——某个 any_butter_or_oil(黄油或油都行)恒被映射成 cooking_oil,玩家拿黄油就是做不出来。它能提高可建率,同时掩盖真实的物品缺口。而且它自己还会有自环(某条映射 "socks": "socks",而 socks 根本不在物品全集里 = 永久悬空)。
审”数据缺口”的时候,这类东西要跟”命名不一致”分开列。
六、294 个分支,怎么合回来
这个项目同时有好几条线在改:代码线、素材线、试玩线。分支积累到 294 个。
把多线产出合回主线时,我用的是”一条一建”的纪律:
merge → 过闸门 → build → 跑这条分支自己的门禁。绿了留下;红了单条 revert 转专项。
绝不把”半合”的活先堆进主线再统一修——堆起来之后红绿边界就消失了,一次修不动。
几个具体的坑:
① git apply --3way 打印”Applied patch with conflicts”之后直接提交 = 把冲突标记写进主线
所以提交前必须过一道闸门脚本,查四件事:未合并条目(UU/AA/DU…)、冲突标记(<<<<<<<)、大括号净差、必须出现/必须消失的符号。
② 签名变宽是合并后最常见的”编译不过”来源
某条分支把一个函数改宽了(加参数、改语义键),主线这一侧的旧调用不会自己跟上:
C2064 项不会计算为接受 4 个参数的函数
合并后要 grep 同名调用点逐个补齐。
③ 别拿 git diff main <branch> 当分支的改动量
陈旧分支会把主线上的新内容显示成删除。实测一次输出是 +1347/−8486——完全误导。
④ 两种”红”要分开
| 红的样子 | 判据 | 处置 |
|---|---|---|
| 接口/机制真不匹配 | 门禁拿不到引擎的 dump 行、驱动方式变了、断言的符号已不存在 | 单条 revert → 转专项 |
| 基线过时 | 其余断言全过,只有”与改动前基线相等”那条红 | 重新基线,并注明为何重定 |
把过时基线当成”功能有回归”去回退功能,是这类收口里最贵的误判。
⑤ 多 worktree 并行还有一堆物理问题
git checkout main报'main' is already used by worktree at ...→ 让占用者进 detached 状态就能释放,文件一个字节不变,正在那边跑的测试也不受影响- 某个子目录在 A 分支不被跟踪、在 B 分支是跟踪目录 → 切分支会因”未跟踪文件会被覆盖”失败,先备份再删
- 别人未提交的改动,先
git add && commit到一个临时分支,同时整体 copy 到仓外备份。两处都留,才敢说”丢了也没事”
七、进度文档是”声称”,不是”事实”
这条是我后来才彻底想明白的。
用户时不时会问一句”这项目还剩什么没做”。我一开始的做法是打开进度文档,把清单转述一遍。
这个做法是错的。 因为 HANDOFF 和 PLAN 文档是历史快照,与实况有四种系统性偏差,转述等于把过期的结论再传一代:
- 文档表格滞后于提交——待做表里还列着某项,而 git 里早有专做它的提交(提交信息甚至写着”更正审计误判”),表格没跟着改。
- 门禁脚本存在 ≠ 功能完成——脚本存在只说明有护栏,不代表覆盖面够。断言数远少于计划定义的验收面是常态。
- 计划的粒度 ≠ 落地的粒度——计划写”三步 + HUD 角标 + 打勾”,实况可能只有三条提示行。部分落地,报成”已完成”或”零代码”都错。
- “缺口清单”里混着已被别的机制兜底的项目——文档说”缺 8 条音频绑定”,实况可能是文件名表已经把这件事做了。
所以”还剩什么”这个问题,正确的答法是逐个实况核对,四个探针从便宜到贵:
git log --oneline --all | grep -i "<关键词>"—— 有没有专做它的提交grep -rn "<符号>" docs/—— 它在卡文档里记到什么程度- 源码里 grep 使用点,而不是字段/枚举的存在性
- 直接数实际内容:读门禁脚本的断言数、
json.load()后数条目——文档里写的条数一律重数
还有一条比上面四条更狠的教训,是自查自己的旧审计得来的。
我抽查了自己过去写的 22 条”未完成/缺失/没接线”结论,结果:
11 条已经解决,或者根本是误判。
而它们的失效方向高度一致:
绝大多数是”把已完成写成未完成”,不是漏报。
这个方向比漏报危险得多——它会让后续的会话去重写一个已经存在的机制,也会让用户误判项目状态。
所以现在每个”未做/缺失/没接线”的断言,我默认当待证伪处理。而这里最容易犯的错误是:
grep -rn "<符号>" src/ # 搜不到 → 得出结论"没实现"
“搜不到符号”不等于”没实现”。 实测的反例是持久化:这个项目有两条持久化路径——一条存玩家与全局字段,另一条存世界内实体快照(怪物、物品、尸体、NPC、家具、载具、事件场)。只 grep 其中一条,就会得出”载具不存档”这种错误结论——而实际上载具完整存档(位置、燃料、朝向、13 个部件与耐久全在)。
只看字段有没有被读,也判不出”有没有生效”——要看使用点。
最要紧的是这条:一条结论被推翻,同一方法得出的兄弟结论必须一起复核。 我用同一个手法得出的其他结论,全部无条件回炉。实测”载具不存档”被推翻之后,我回头重跑了另两条同类结论(种植季节、畜牧繁殖),两条都成立,才敢继续引用。
八、推断当结论,会导致改错地方
最后一条,是纪律里最软、但最容易犯的。
有一条 bug 我判断得很早:某个世界生成了”两套村庄定义”,看着就是个明显的重复定义。
我把它当根因写进了待办清单。
后来真去读代码,发现两侧调用的其实是同一个函数——真正的根因在调用时机上(锚点依赖的底基层在布点时可能还没就绪)。
如果我照着自己写的待办去改,会去删一套根本不存在的重复定义。
现在的写法是把两栏分开:
- 已核实的代码事实:
文件:行+ 实际调用链(读代码得到的,可复述) - 候选机制(未验证):假说 + 验证方法(在哪儿加一次性日志、比对哪两个时刻的量)
未验证的机制不得当根因写进文档,更不能照着它改代码。
这条在别处也验证过一次:我用一个”某段代码应该在 shot 之前注入”的静态断言钉着实现,后来要放宽契约时才发现——改门禁比迁就门禁去扭曲实现要对。方向一旦搞反,代码会为了迎合工具而变丑。
九、试玩那条线:审方不修方
这个项目有一条独立的”试玩”线,专门跑游戏找 bug。
它有一条我认为最关键的纪律:审方不修方。
试玩线只出 bug 清单和建议,禁止改源码、禁止提交。
理由有两条:既写又审会丢掉独立性(同一方容易共谋式放行);而且”是谁引入的”会变得无法追查。
配套还有两条:
① 定量结论必须给像素坐标。 “看起来有点挤”不算结论;截断、重叠这些必须量出坐标。实测视觉模型会把 32×32 的图集说成 64×64,会误报折行续行为截断——所以几何类判断只认像素测量,视觉模型只做粗检。
② 候选 bug 必须由另一家族的模型带原始证据独立复核,两边都判为 bug 才算高危。只把结论丢给复核者 = 共谋式放行——子代理会附和。
还有一条边界写得很清楚:自动试玩不能替代真人实机验收。 headless 路径全绿 ≠ 玩家能玩到(第三篇里”帮助页写着 5、键位表只有 KP_5”就是这类)。手感、音质、美术取向、长时游玩的体感——只能由人判定,必须单列成”本次未能验证的部分”。
十、这篇其实在讲什么
我把它压到四条:
- “完成”的定义要具体到能证伪。 勾选不算、编译过不算、“代码写了”不算。要真实入口调用 + 外部行为断言 + 状态往返 + 全量套件。
- 静态查四环,动态看计数。 首尾都写了、中间断掉,这是最典型的假完成形状。
- 进度文档是声称不是事实,自己的旧审计也一样。 尤其要防”把已完成写成未完成”——它的破坏力比漏报大。
- 未验证的机制不许当根因。 分开写”代码事实”和”候选机制+验证法”。
写这四篇的时候我反复意识到同一件事:这个项目上真正花时间的,从来不是”写出功能”,而是”确认它真的在工作”。
一篇文章写”我做了 X 系统”可能只要一句;确认 X 系统在玩家那边真的成立,要花的功夫是前者的好几倍。
但我觉得这笔账是划算的。因为那个多花的功夫,换来的是我每次说”做完了”的时候,是真的。