8.3 节奏与惩罚的度量
奖励给多少、惩罚忍多久,只有放进同一根轴才能比。本节把金币密度、挫败密度、净节奏、兑换率四个千分比算出来:奖励期每动作 1170‰ 金币、惩罚期每动作 1010‰ 挫败,全程净节奏 -232‰。再扫一张 5×4 参数基准表,越界场次从 15 提前到 4,两条软告警标出「预算形同虚设」与「节奏对难度过敏」。
category
奖励给多少、惩罚忍多久,只有放进同一根轴才能比。本节把金币密度、挫败密度、净节奏、兑换率四个千分比算出来:奖励期每动作 1170‰ 金币、惩罚期每动作 1010‰ 挫败,全程净节奏 -232‰。再扫一张 5×4 参数基准表,越界场次从 15 提前到 4,两条软告警标出「预算形同虚设」与「节奏对难度过敏」。
惩罚有两条独立通道:远征血量(每负一场扣 params.punishHp)与挫败预算(失败扣分 + 被拒指令扣分,累计 ≥ 100 即越界)。实测第 9 场累计挫败 137 越界,此时远征血量还剩 52——血量阈值要 15 次失败才耗尽,远慢于玩家的耐心。把「还能忍多久」写成一个整数阈值,并给出闭区间上界的越界判据。
《游戏设计》卷一《玩法机制与可执行规格》附录:把全卷 16 章散落的玩法规格字段、机制整数公式、四条不变量与数据集汇成四张表,给出卷内文件命名约定与 S / P / W 三层完整复现命令,并附一段与工作区逐字节一致的跨语言摘要内核,作为「一份规格四处消费」的可验证落点。
随机一旦可播种,整局就是一段确定性函数。本节把同一份规格在 Python(sim/ashlib.py)与 TypeScript(core/src/ashdeck/)两处各跑一遍,终态 digest 逐字相同;再换 seed 证明种子真的进了洗牌。P 层 10 条断言全绿,其中两条是负例,证明「回放真的依赖输入」不是一句空话。
同一份机制在两种发牌结构下,体验能差多少?本节取「起手 5 张的总伤害」为体验代理量,对 200 组显式种子分别用洗牌袋(真实发牌)与可放回(假想)各算一次:洗牌袋极差 42、方差代理量 2505 千分比;可放回极差 64、方差代理量 3878。可放回把哑火局与爆开局的占比都抬高了一档。
同一句「随机」在实现层是三种结构:均匀(可放回等概率)、加权(整数权重前缀和落点)、洗牌袋(打乱一副袋不放回抽)。本节对三者各抽 600 次,用整数与千分比量出重复率、最长连号与方差代理量——洗牌袋频次恰好每面 100 次、方差代理量为 0,均匀是 10020 千分比,加权偏斜到 186420。
奖励节奏不是「多久给一次」的拍脑袋,而是一条可算的谱。一次 16 场远征里,每个胜场发 params.rewardGold 金币,节奏单位取玩家动作数而非回合数:实测前 8 场每 9–12 个动作换一次奖励、金币密度 1170‰,后 8 场 95 个动作零奖励。间隔序列、极差、逆序对把「节奏什么时候断」写成数字,同时闭合第 1 章的规格-实现漂移。
渐近稳定只由增益决定:G<1000‰ 收敛、=1000‰ 临界、>1000‰ 发散,与延迟无关。但游戏只有有限回合——本机按 play_spec 的 turnLimit=20 扫 (G, d) 平面:有限时域稳定域从 d=0 的 G≤950‰ 收缩到 d=4 的 G≤850‰,B 层再把设计点放进 ±10% 增益扰动网格,失稳只发软告警。
延迟不改增益,只把递推的读指针往后挪 d 回合:x_{t+1} = trunc(G·x_{t-d}/1000) + c。本机实跑同一份 G=900‰:d=0 单调逼近(方向反转 0 次、6 回合稳定),d=1/2/4 变成振荡逼近(反转 18/12/7 次、稳定要 10/16/21 回合);发散档同理,延迟把爆发拆成阶梯。
反馈循环不是「赢了更强」这句形容词,而是一条整数递推:x_{t+1} = trunc(G·x_t/1000) + c。本节把回路增益 G 跑成三档——G=1100‰ 发散、G=1000‰ 临界线性、G=900‰ 收敛到不动点区间 [111,120],产出逐回合实测增益、CSV 与纯 xml.etree 曲线,并说清为什么判据是参数 G 而不是观测到的比值。
把临界点附近的胜率逐格分类成稳健步与脆弱步,输出软告警清单:本节给出可跑的 S 层告警脚本、临界点邻域的胜率表、out/mech06_warnings.json 的告警条目,以及一个 B 层基准——把分类阈值本身缩放 50%~200%,看脆弱区间会不会翻盘。
把「游戏被解开的点」定义成序参量(胜率)跌破 500‰ 的参数值:先粗扫定位跨越区间,再二分细化到 ±1,最后在临界点邻域逐点细扫算出临界宽度。本节给出可跑的 S 层相变扫描脚本、实测临界点、临界宽度与总耗时,并解释临界点为什么恰好落在固定策略的伤害预算边界上。
固定 16 个显式整数种子,把 params.enemyHp 从 20 扫到 80、步长 5,逐格统计胜率千分比(1000×wins÷total,禁浮点)与平均剩余 HP:本节给出 B 层单参数敏感度的完整做法、可跑的 S 层脚本、CSV 与纯 xml.etree 折线图,并说明为什么「胜率全程 1000‰」不等于机制平衡。
浏览器与 Node 跑的不是两份对齐的实现,而是 web/pack.mjs 的 bundle() 把同一个 combat.ts 擦类型后内联的同一段源码,所以结果必须逐字相同。给出 Node / 浏览器 / Python 三方 digest 对照、产物级同源与自足检查(内核标记在、外部引用为 0),并说清 Python 侧是移植而非同源。
用 web/verify_mech05.mjs 做无头真玩:注入 25 步整段策略序列,断言页面 digest 与 Node 侧 digestSnap(runSpec(spec)) 逐字相同,再换一段截断序列证明注入真被消费。含预热页的必要性、canvas 已绘制断言、两遍确定性、48443 字节截图与完整实跑输出。
用 web/pack.mjs 的 bundle() 把 core/src/ashdeck/ 内核按手工拓扑序擦类型,再与 play_spec.json 一起内联进一个 9448 字节的 HTML:无 CDN、无 import、离线双击可跑。页面画敌方血条、我方血条与手牌,并暴露 globalThis.__ashdeck 的 run/snapshot 接口,附构建脚本与实跑输出。
死锁是「未结束却无事可做」,不可解是「怎么打都赢不了」,两者都不是崩溃,因而不会被异常捕获,只能靠搜索证明。本节给《灰烬牌库》写一个带三重上限的广度优先穷举器:基线 spec 8 步可解、不可解夹具穷尽前沿后判不可解,并区分「有取胜序列作证据」与「前沿穷尽才是证明」这两种可靠性等级。
不变量就是「永远不该出现的状态」。本节把《灰烬牌库》的不变量分成三类:守恒(牌数恒 12、伤害恒等于已扣敌方血量)、不可达(非法动作与欠费动作执行后状态逐字不变)、单调(enemyHp 与 playerHp 逐步不增)。关键手法是逐步查而非只看终态,并且每一类都配一个负例断言为 false。
状态机不是「带 if-else 的循环」,而是三件东西:一个状态集合、一个转移函数、一条输入序列。《灰烬牌库》的 runSpec 把三者写成不到 60 行的纯函数,本节用 9 条断言把它钉死——同 seed 同输入 200 次回放 digest 全同、换输入 digest 必变、换 seed digest 必变,最后扫一遍内核源码证明它既不读时钟也不读随机源。
误差表是给机器看的,血线图是给眼睛看的。本节用纯 xml.etree 画一张多局面血线对比图 out/mech03_hp.svg,每行一种子、每回合一根条,画出敌方 HP 从 50 掉到 0 的过程;再回读该 SVG 断言矩形数量与 data-value 逐个正确,并踩一次 xmlns 命名空间的坑。