留存设计清单 那一篇回答的是「该做什么」:签到、日常、赛季、召回,一条条列开。本章回答另一个问题——这些奖励在时间轴上长什么样。清单说「要有奖励」,谱说「奖励之间隔了多少、密度是多少、什么时候断」。差别在于后者可以算:只要规格里有奖励粒度,就能把一次远征的奖励事件全部落在累计动作轴上,量出间隔、极差与逆序对。全章共用一个整数模型:8.1 做奖励那一半,8.2 做惩罚那一半,8.3 把两半放到同一根轴上比。
8.1 奖励节奏谱
三个要素:粒度、位置、间隔
一份「奖励节奏」要能被算,必须先回答三件事,缺一件就只能退回散文:
| 要素 | 在规格里的位置 | 在《灰烬牌库》里是什么 |
|---|---|---|
| 粒度 | params.rewardGold | 每个胜场发 12 金币;粒度决定「一次奖励有多重」 |
| 位置 | 一次远征的场次序列 | 第 k 场敌人血量 = enemyHp + k × difficultyStep |
| 间隔 | 快照的 played(累计) | 两次奖励之间玩家出了多少张牌 |
第三行是本节唯一的设计判断:节奏单位取玩家动作数,不取回合数。回合是机制的内部计数,动作才是玩家真正付出的东西——「打了 4 回合」和「出了 12 张牌」在体感上不是一回事,而后者可以直接从快照的 played 累加出来,不需要任何新字段。
这里必须交代一处旧账。第 1 章做机制解剖时发现:params 里的 rewardGold(12)与 punishHp(4)没有任何一条规则读它们,是规格里有、实现里没有的孤儿字段。那一章把它们标成「第 8 章的预留位」。本节就是兑现:gd_mech_08_reward_rhythm.py 是全卷第一个真正消费 rewardGold 的脚本——goldTotal == rewards × rewardGold 这条断言如果失败,说明它仍然没被读。第 1 章的规格-实现漂移,在这一行断言上闭合。
规格字段与派生量
远征模型只用三个整数配置加两个规格字段,全部来自 config/gd_mech_08_rhythm.json 与 play_spec.json:
| 量 | 取值 | 含义 |
|---|---|---|
encounters | 16 | 一次远征的场次上限 |
difficultyStep | 4 | 每场敌人血量的爬升步长 |
enemyHp | 50 + 4k | 第 k 场的敌人血量(规格基准值 + 爬升) |
rewardGold | 12 | 每个胜场的金币(规格字段,本节首次消费) |
| 种子链 | rng_next 递推 | seed_{k+1} = seed_k × 48271 mod 2147483647 |
派生量同样只有整数:金币密度 = 1000 × 总金币 // 到末次奖励的动作数(千分比);间隔极差 = max(间隔) − min(间隔);逆序对 = 间隔序列里 i < j 且 gaps[i] > gaps[j] 的数对个数,用来量「节奏有多乱」。间隔序列单调递增记为减速、单调递减记为加速,其余记为波动。
下面这段是 8.1 的 S 层脚本。它做四件事:按种子链与血量爬升跑完一次远征;把每个胜场记成一个奖励事件并算间隔;把谱落成 CSV 与纯 xml.etree 的 SVG 时间轴;用三条断言锁住「金币合计 = 奖励数 × rewardGold」。
#!/usr/bin/env python3
# file: sim/gd_mech_08_reward_rhythm.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_08_reward_rhythm.py
"""卷一 8.1 奖励节奏谱(S 层):把「多久给一次奖励」算成一条可比的谱。
一次远征 = E 场遭遇,第 k 场敌人血量 = enemyHp + k*step(整数爬升),种子链由共享 PRNG
递推(seed_{k+1} = rng_next(seed_k)),故同一份规格在任何语言下得到同一张谱。节奏单位取
**玩家动作数**(快照的 played)而非回合数:卡牌游戏里「做了多少事才换到一次奖励」才是
玩家真正感到的间隔。每个胜场发 params.rewardGold 金币——`rewardGold` 正是第 1 章标出的
「规格里有、没人读」的预留位,本脚本是它第一次被真正消费,规格-实现漂移在此闭合。
产出 out/mech08_rhythm.csv + out/mech08_rhythm.svg + out/mech08_rhythm.json。
"""
from __future__ import annotations
import copy
import json
import os
import sys
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_mech_08_rhythm.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
HEADER = ["encounter", "seed", "enemyHp", "won", "turn", "actions", "cumActions", "gold", "gap"]
def run_run(spec: dict, cfg: dict) -> list[dict]:
"""一次远征:种子链递推 + 敌人血量按 step 爬升;每场规则全部来自共享底座。"""
base, step = spec["params"]["enemyHp"], cfg["difficultyStep"]
seed, recs = spec["seed"], []
for k in range(cfg["encounters"]):
s = copy.deepcopy(spec)
s["seed"], s["params"]["enemyHp"] = seed, base + k * step
snap = ashlib.run_spec(s)
recs.append({"encounter": k, "seed": seed, "enemyHp": base + k * step, "won": snap["won"],
"turn": snap["turn"], "actions": snap["played"]})
seed = ashlib.rng_next(seed)
return recs
def shape(gaps: list[int]) -> str:
if len(gaps) < 2:
return "样本不足"
if all(gaps[i] <= gaps[i + 1] for i in range(len(gaps) - 1)):
return "减速(间隔递增)"
if all(gaps[i] >= gaps[i + 1] for i in range(len(gaps) - 1)):
return "加速(间隔递减)"
return "波动(非单调)"
def spectrum(recs: list[dict], gold: int) -> dict:
"""节奏谱:奖励事件落在累计动作轴上的位置 + 相邻奖励的间隔序列。"""
cum, prev, rows, gaps = 0, 0, [], []
for r in recs:
cum += r["actions"]
gap = 0
if r["won"]:
gap, prev = cum - prev, cum # 距上一次奖励用了多少动作
gaps.append(gap)
rows.append([r["encounter"], r["seed"], r["enemyHp"], r["won"], r["turn"],
r["actions"], cum, gold if r["won"] else 0, gap])
total, rewards = cum, len(gaps)
return {
"rows": rows, "gaps": gaps, "rewards": rewards, "rewardGold": gold,
"actionsTotal": total, "actionsToLastReward": prev, "deadActions": total - prev,
"goldTotal": rewards * gold,
"densityPermille": 0 if prev == 0 else 1000 * rewards * gold // prev,
"gapMin": min(gaps), "gapMax": max(gaps), "gapSpread": max(gaps) - min(gaps),
"inversions": sum(1 for i in range(rewards) for j in range(i + 1, rewards)
if gaps[i] > gaps[j]),
"shape": shape(gaps),
}
def draw(rows: list[list], total: int, gaps: list[int], path: str) -> int:
"""时间轴:横轴 = 累计动作数;金色条 = 奖励事件(宽 = 该次间隔),红条 = 未胜场。"""
x0, x1, y1 = 40, 620, 150
root = ashlib.svg_begin(660, 210)
ashlib.svg_text(root, 40, 26, "《灰烬牌库》远征奖励节奏谱(单位:玩家动作数)", 14)
ashlib.svg_text(root, 40, 46, f"总动作 {total};金色 = 奖励 {len(gaps)} 次;红色 = 未胜场", 11,
fill="#94a3b8")
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1),
"stroke": "#64748b"})
for row in rows:
cum, acts, won = row[6], row[5], row[3]
xa, xb = x0 + (cum - acts) * (x1 - x0) // total, x0 + cum * (x1 - x0) // total
if won:
ET.SubElement(root, "rect", {"x": str(xa), "y": str(y1 - 30),
"width": str(max(2, xb - xa)), "height": "30",
"fill": "#f59e0b", "data-value": str(acts)})
ashlib.svg_text(root, (xa + xb) // 2, y1 - 36, acts, 10, "#fbbf24", "middle")
else:
ET.SubElement(root, "rect", {"x": str(xa), "y": str(y1 + 4),
"width": str(max(2, xb - xa)), "height": "10",
"fill": "#ef4444"})
ashlib.svg_text(root, x0, y1 + 34, "0", 10, "#94a3b8", "middle")
ashlib.svg_text(root, x1, y1 + 34, str(total), 10, "#94a3b8", "middle")
ashlib.svg_text(root, 40, y1 + 62, "间隔序列(动作数):" + "/".join(str(g) for g in gaps), 12)
ashlib.svg_text(root, 40, y1 + 84, "前段每次奖励约 9–12 个动作;后段 0 次奖励,动作全成空转。",
11, fill="#94a3b8")
return ashlib.svg_save(root, path)
def main() -> int:
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
gold = spec["params"]["rewardGold"]
sp = spectrum(run_run(spec, cfg), gold)
for r in sp["rows"]:
print(f"enc{r[0]:>2} seed={r[1]:>10} ehp={r[2]:>3} {'胜' if r[3] else '负'} turn={r[4]:>2} "
f"actions={r[5]:>2} cum={r[6]:>3} gold={r[7]:>2} gap={r[8]:>2}")
print(f"rewardGold={sp['rewardGold']} 奖励 {sp['rewards']} 次 金币合计 {sp['goldTotal']}")
print(f"总动作={sp['actionsTotal']} 到末次奖励={sp['actionsToLastReward']} "
f"空转={sp['deadActions']}")
print(f"金币密度={sp['densityPermille']}‰ 间隔 min/max/spread={sp['gapMin']}/{sp['gapMax']}/"
f"{sp['gapSpread']} 逆序对={sp['inversions']} 形状={sp['shape']}")
assert sum(r[7] for r in sp["rows"]) == sp["goldTotal"], "金币合计对不上"
assert sp["rewards"] * gold == sp["goldTotal"], "rewardGold 未被真正消费"
n = ashlib.write_csv(os.path.join(OUT, "mech08_rhythm.csv"), HEADER, sp["rows"])
size = draw(sp["rows"], sp["actionsTotal"], sp["gaps"], os.path.join(OUT, "mech08_rhythm.svg"))
report = {"id": "gd_mech_08_reward_rhythm", "config": cfg, "rewardGold": gold,
"encounters": sp["rows"], "gaps": sp["gaps"], "rewards": sp["rewards"],
"goldTotal": sp["goldTotal"], "actionsTotal": sp["actionsTotal"],
"actionsToLastReward": sp["actionsToLastReward"], "deadActions": sp["deadActions"],
"densityPermille": sp["densityPermille"], "gapMin": sp["gapMin"],
"gapMax": sp["gapMax"], "gapSpread": sp["gapSpread"],
"inversions": sp["inversions"], "shape": sp["shape"]}
with open(os.path.join(OUT, "mech08_rhythm.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
ET.parse(os.path.join(OUT, "mech08_rhythm.svg"))
print(f"wrote out/mech08_rhythm.csv({n} 行 + 表头) out/mech08_rhythm.svg({size} B) "
f"out/mech08_rhythm.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_08_reward_rhythm.py:
enc 0 seed= 20261014 ehp= 50 胜 turn= 4 actions= 9 cum= 9 gold=12 gap= 9
enc 1 seed= 914347409 ehp= 54 胜 turn= 4 actions= 9 cum= 18 gold=12 gap= 9
enc 2 seed=1379866695 ehp= 58 胜 turn= 5 actions=10 cum= 28 gold=12 gap=10
enc 3 seed=1192438993 ehp= 62 胜 turn= 4 actions=11 cum= 39 gold=12 gap=11
enc 4 seed=1218440562 ehp= 66 胜 turn= 5 actions=10 cum= 49 gold=12 gap=10
enc 5 seed= 62244266 ehp= 70 胜 turn= 4 actions=10 cum= 59 gold=12 gap=10
enc 6 seed= 263341933 ehp= 74 胜 turn= 5 actions=11 cum= 70 gold=12 gap=11
enc 7 seed= 822741250 ehp= 78 胜 turn= 5 actions=12 cum= 82 gold=12 gap=12
enc 8 seed=1127794779 ehp= 82 负 turn= 6 actions=12 cum= 94 gold= 0 gap= 0
enc 9 seed=1071325659 ehp= 86 负 turn= 6 actions=12 cum=106 gold= 0 gap= 0
enc10 seed= 407182182 ehp= 90 负 turn= 6 actions=12 cum=118 gold= 0 gap= 0
enc11 seed=1320769978 ehp= 94 负 turn= 6 actions=12 cum=130 gold= 0 gap= 0
enc12 seed= 393095902 ehp= 98 负 turn= 6 actions=12 cum=142 gold= 0 gap= 0
enc13 seed=2114264197 ehp=102 负 turn= 6 actions=11 cum=153 gold= 0 gap= 0
enc14 seed= 634213359 ehp=106 负 turn= 6 actions=12 cum=165 gold= 0 gap= 0
enc15 seed=1733664304 ehp=110 负 turn= 6 actions=12 cum=177 gold= 0 gap= 0
rewardGold=12 奖励 8 次 金币合计 96
总动作=177 到末次奖励=82 空转=95
金币密度=1170‰ 间隔 min/max/spread=9/12/3 逆序对=2 形状=波动(非单调)
wrote out/mech08_rhythm.csv(16 行 + 表头) out/mech08_rhythm.svg(3354 B) out/mech08_rhythm.json
判读:谱断在哪一场
这张谱的形状一句话说完:前 8 场每 9–12 个动作换一次奖励,后 8 场 95 个动作颗粒无收。
| 量 | 实测值 | 读法 |
|---|---|---|
| 奖励次数 / 金币合计 | 8 次 / 96 | 等于 8 × rewardGold(12),漂移已闭合 |
| 到末次奖励的动作数 | 82 | 有奖励的那一段总共只花了 82 个动作 |
| 空转动作 | 95 | 全程 177 个动作里有 95 个换不回任何东西 |
| 金币密度 | 1170‰ | 有奖励区间内每 1 个动作换到 1.17 金币 |
| 间隔 min/max/极差 | 9 / 12 / 3 | 间隔被压在 3 个动作的带宽里,节奏很稳 |
| 逆序对 | 2 | 间隔序列只有两处逆序,接近单调 |
| 形状 | 波动(非单调) | 既非加速也非减速,是「稳中带抖」 |
真正值钱的不是 1170‰ 这个数,而是空转 95。它把「节奏断掉」从一句抱怨变成一个整数:奖励期只占 82 个动作,剩下 95 个动作(53.7%)是纯消耗。难度爬升 difficultyStep = 4 到第 8 场(enemyHp = 82)就把固定策略打穿了——从那一刻起,玩家的每一次输入都换不回奖励,而惩罚还在继续(8.2 会把这半段算出来)。
逆序对 2 这个数也值得停一下。间隔序列是 9/9/10/11/10/10/11/12,看上去几乎递增,但第 4 个间隔(11)比第 5 个(10)大、比第 6 个(10)也大,于是产生两处逆序。这说明即使平均间隔在拉长,局部仍会有回抽——只报「平均间隔 10.25」会把这两处回抽抹掉,而回抽正是玩家感觉「这局有点顺」的来源。
| 检查项 | 合格标准 |
|---|---|
| 粒度真被消费 | goldTotal == rewards × rewardGold,否则仍是孤儿字段 |
| 位置可复现 | 种子链由共享 PRNG 递推,同规格任何语言同谱 |
| 间隔有单位 | 单位是玩家动作数(played 累计),不是回合数 |
| 谱能断 | 报告空转动作数与它占全程的比例,而不是只报平均值 |
谱画出来之后,下一个问题顺理成章:断了之后会怎样。奖励停了,惩罚不会停——下一节把「玩家还能忍多久」也算成一个整数阈值。
阅读导航:上一节:7.3 循环的稳定性 · 下一节:8.2 惩罚机制与挫败预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。