追逃 AI 不开全图:让猎犬只能闻¶
算法实验室 TSP 复盘 · 第 4 篇。断踪是一款回合制反追踪游戏:玩家在野外移动会留下痕迹,追踪小队循痕迹收敛。这一篇记录追捕者 AI 的信息结构设计,以及调参撞上"跷跷板"之后,我改调什么。
现象¶
追逃玩法要成立,追捕者必须"讲道理":太弱则毫无压力,太强则必死。断踪的最终形态里,追踪者的世界没有玩家坐标,只有一片痕迹场——每格一个强度值,它的一切行为都从这片场上读出来。
有意思的是,追逃设计空间里两个平庸端点,在最终形态里其实都还在:CHASE 状态直取玩家真实位置——这是"有界的全知";PATROL 状态无所闻时随机踱步——这是"有界的瞎转"。区别在于它们现在都被信息阈值驱动:全知是"闻到之后"的奖励,瞎转是"没闻到"的默认。整个设计的分量,全压在信息场上。
我一开始的错误判断¶
这一篇的试错有记录在案,而且结论很反直觉:我一开始以为难度旋钮在运动参数里。 调参文档里追击回合数、追击速度这些旋钮一应俱全,直觉上"难度 = 追得多快、追多久"。后来的撞墙记录证明这两个旋钮根本不在关键路径上(见排查)。
真正需要防的两个端点,是这个设计空间的固有陷阱:全知寻路让玩家没有生存空间,随机巡逻让玩家毫无压力。信息场方案是两条死路之间唯一的活路——难的不是写出来,是调出来。
排查与关键证据¶
信息结构的规则(trace.ts):
- 沉积:玩家进入格子,按地形系数叠加痕迹强度。注释原话"WATER 为负 = 冲刷,下限 0"——蹚水会洗掉痕迹。
- 衰减:每回合全场衰减,雨天倍率更高(注释:×4)。
- 嗅探:强度决定传播距离。注释原话:"痕迹越强传得越远(MUD 可传 ~5 格,ROCK 仅基础半径)",感知半径之外按距离线性衰减(每格 0.8)。
行为只在信息阈值上跃迁(trackers.ts 三态机): PATROL 闻到痕迹 → 走向最强格;痕迹"新鲜"(强度 ≥ freshThreshold)→ 升级 CHASE,直取玩家真实位置;耐力耗尽或距离重新拉开 → LOST,沿最后已知方向直走一段 → 回 PATROL。CHASE 期间玩家入水立即断追,注释写明:"入水即断追踪:溪流冲掉气味(水中也不被贴身抓捕)"。
不对称优势是设计出来的,不是漏出来的。 trackers.ts 头注释只有一行:"追踪者不进入溪流(玩家的水线走廊不对称优势)"。AI 的禁入地形,就是玩家的策略空间。
然后是调参撞墙——本篇最硬的一段记录。 试玩报告原话:
识路 bot 全程未被追——追击压力对人玩家才会出现(人不会最优绕行);参数线上调不了(调过 5 组,谨慎/鲁莽跷跷板)
背景是:识路 bot 用 Dijkstra 规划路线、主动避开巡逻带,全程未被追——机制对"完美玩家"成立得太好;而人不会最优绕行,追击压力只对人出现。想把压力调出来,改沉积、改衰减、改感知半径,调了 5 组参数,谨慎策略和鲁莽策略的胜负像跷跷板:压下这头,翘起那头。
调参文档最终把 deposit 标成"整个游戏的手感锚点"。参数冻结,只留一个条件旋钮:"人类试玩若『从没被追过』,把 deposit.MUD 3.4 → 3.8"——注意这是发布前预留的调整预案,不是已执行的修改。
bot 断言守住底线。 平衡测试:谨慎穿越者 ≥6/12 局胜、鲁莽穿越者 ≤3/12 局胜,测试名后面挂着那行注释:"bot 无全图信息,人类玩家可见整图,实际难度更低"。
补测实验:把跷跷板放进扫描仪(成稿时补做,可复跑:npx vitest run src/evidence-sweep.test.ts)。 历史记录只留下"调过 5 组"的结论;成稿时把单旋钮扫描当场跑了一遍:deposit.MUD 从 2.6 到 4.2 扫五档 × 12 seed × 双 bot(bot 的路径权重读取扫描参数,与游戏世界自洽),逐行数据落盘 data/deposit-sweep-trace-break.json:
| MUD | 谨慎胜局(闸门 ≥6) | 鲁莽胜局(闸门 ≤3) | 谨慎败因分布 | 鲁莽被抓局数 |
|---|---|---|---|---|
| 2.6 | 7 ✓ | 5 ✗ | 被抓 3 / 饿死 2 | 7 |
| 3.0 | 7 ✓ | 5 ✗ | 被抓 2 / 饿死 3 | 7 |
| 3.4(默认) | 6 ✓ | 3 ✓ | 被抓 3 / 饿死 3 | 9 |
| 3.8 | 5 ✗ | 3 ✓ | 饿死 5 / 被抓 2 | 9 |
| 4.2 | 5 ✗ | 3 ✓ | 饿死 4 / 被抓 3 | 9 |
三个发现:一,单旋钮可行域窄到一个点——本次扫描里只有 3.4 同时满足两道闸门,低档鲁莽赢太多(5 局 > 闸门 3),高档谨慎先出局(5 局 < 闸门 6):两道闸门从两头往中间挤,把可行区间挤成了一个点。二,加压对两类策略同时起效,但败因不同——MUD 调高不是简单的一升一降:谨慎的败因从"被抓"迁移为"饿死"(追击变频繁 → 绕行变多 → 回合预算耗尽),鲁莽则卡死在 9 局被抓。三,复刻自检——默认档跑出 6/3,与仓库闸门下限(≥6、≤3)逐位吻合,说明这次补测的策略复刻没有走样。历史上那句"调过 5 组,谨慎/鲁莽跷跷板",现在既有机制解释,也有现场复现。
根因¶
追逃游戏的难度来自追捕者的信息结构,不是运动参数。 信息场参数互相锁死:沉积强度 × 衰减率 × 感知半径 × 追击耐力,共同决定"痕迹存活多久、多远能闻到、闻到后追多久"。这四个数任何一个单独拧,都是在两类策略之间搬跷跷板——"调过 5 组"就是这条结论的实验记录。正确的调参顺序是先锁信息结构(谁能感知什么、以什么速率衰减),再看策略族的胜负带宽。
bot 与人类的差异在这里最大,过度拟合 bot 是这类游戏的死法。 bot 最优绕行永远不被追;为 bot 调出来的"压力",对人类可能是灾难。所以参数冻结在 bot 底线达标的位置,最后一段调参留给真人数据——"条件旋钮"就是这个交接仪式:引擎能验证的到此为止,剩下的是人类的。
约束与取舍¶
- 放弃全知/脚本化 AI。 换来公平感和策略空间(水线走廊、路线选择);代价是 AI 在特定地形下可被构造性利用——沿溪流走可以完全断追。这是特性还是漏洞?当时的判定写进了注释:特性。
- 放弃把跷跷板调到"完美"。 参数冻结在 bot 底线达标处;代价是上线初版对人类可能偏松(报告的建议原话是"保留现值"观察),换来不过度拟合 bot。
- 留条件旋钮而不是继续调。 把"如果人类反馈 X 就调 Y"写成文档:代价是发布后可能还要动一次参数;换来调参有据可依,而不是再撞一次跷跷板。
可复用检查清单¶
- 先定追捕者的信息结构(感知什么、怎么衰减、何时升级),再调运动参数。
- 识别跷跷板:一次改动让策略 A 变好同时策略 B 变坏 → 停止单旋钮调参,回到信息结构层。
- 给玩家留不对称优势(AI 的禁入地形/弱势地形),并写成显式注释——它是设计还是漏洞,要有个说法。
- 平衡断言按策略族成对写(谨慎应赢 / 鲁莽应输),单边断言不构成平衡证据。
- bot 调到底线达标即止,剩余偏差留给真人试玩,用"条件旋钮"文档化,不过度拟合 bot。
记录核对情况(发布前删除本节)¶
已按仓库记录落实:
- "调过 5 组,谨慎/鲁莽跷跷板"及"追击压力对人玩家才会出现":
docs/playtest-report.md#8 原话。 deposit为手感锚点、条件旋钮(MUD 3.4 → 3.8):docs/tuning-guide.md#8 行、docs/playtest-report.md#8。- 信息场规则与三态机、水线不对称、入水断追:
trace.ts、trackers.ts注释原文。 - 平衡断言与 bot 信息面注释:
balance.test.ts。 - 补测实验 B(MUD 五档扫描、可行域单点、默认档 6/3 自检):
src/evidence-sweep.test.ts、docs/articles/data/deposit-sweep-trace-break.json。方法论说明:扫描中的 bot 路径权重读取被扫描的参数(与游戏世界自洽);谨慎 bot 的两段逃生分支在原测试文件里语义重复,复刻时合并为一段(行为等价)。
仍属重建、无记录支撑(发布前请按真实经历改写或删除):
- "我一开始以为难度旋钮在运动参数里"的心理过程是按调参文档的旋钮清单与撞墙记录反推的;事实部分(旋钮存在、调参失败、最终冻结)全部有据。
- "全知寻路 / 随机巡逻两个端点曾各自实现过"未做声称——正文已改写为设计空间分析 + 代码事实(CHASE 有界全知、PATROL 随机踱步都存在于最终形态)。
- "调过 5 组"具体是哪五组参数、各组结果:文档只有结论。若记得请补一段;不记得可删去具体组数或标注为记忆。
引用出处¶
src/algorithms/trace-break/engine/trace.ts、trackers.ts(注释原文)src/algorithms/trace-break/engine/balance.test.tsdocs/playtest-report.md(#8)docs/tuning-guide.md(#8 行)src/evidence-sweep.test.ts(补测实验 B:deposit.MUD 扫描)docs/articles/data/deposit-sweep-trace-break.json(逐档数据 + 复现命令)