留存设计清单 那一篇说的是「用签到与赛季把人拉回来」;本节做的是反面——把人推走的那一半,也要能算。8.1 的谱只画了收益:奖励停在第 8 场,后 8 场颗粒无收。但收益停不等于事情结束,惩罚仍在继续,而「玩家还能忍多久」在多数策划案里只是一句体感。本节把它变成一个整数阈值,并用一条不含浮点的越界判据来判断它何时被突破。
8.2 惩罚机制与挫败预算
两条独立通道
惩罚不是一个数,而是两条并行记账的通道。把它们分开是本节的核心设计判断:
| 通道 | 触发事件 | 每次代价 | 终止条件 |
|---|---|---|---|
| 远征血量 | 一场失败 | params.punishHp = 4 | 血量 ≤ 0 |
| 挫败预算 | 一场失败 | punishHp × lossWeight = 40 分 | 累计 ≥ budget = 100 |
| 挫败预算 | 一条被拒指令 | rejectWeight = 1 分 | 同上 |
第二、三行是同一个账户,因为它们在体感上是同一件事的两种强度:一次失败是明确的坏消息,一条被拒指令则机制上什么都没发生——玩家点了「打击」,能量不够,手牌没动、血量没变、日志没写,只有玩家自己知道自己白按了一下。这类零反馈的输入在卡牌游戏里极常见,而任何只看胜负的指标都看不见它。
血量和预算必须分开,理由是一个具体的数字:playerHp = 60、punishHp = 4,向上取整需要 15 次失败才耗尽。如果一次远征只有 16 场,那么即使玩家从第 8 场开始连败,跑到最后血量还剩 28——血量机制认为「你还能玩」,玩家早就合上了游戏。血量量的是角色的生存,预算量的是玩家的耐心,后者先耗尽才是常态。
越界判据
越界写成闭区间上界的整数比较,不含任何浮点或近似:
| 项 | 定义 |
|---|---|
累计挫败 cumFrustration | Σ(失败 ? punishHp × lossWeight : 0) + Σ(被拒数 × rejectWeight) |
| 越界判据 | cumFrustration >= budget,取首次成立的场次为越界点 |
| 越界点唯一性 | 断言 rows[crossing][5] >= budget 且 rows[crossing−1][5] < budget |
params.punishHp 与 8.1 的 rewardGold 是同一类字段:第 1 章把它们标成「规格里有、实现里没读」的孤儿。gd_mech_08_punish_budget.py 里的 run_hp -= spec["params"]["punishHp"] 就是它第一次被真正消费——第 1 章的规格-实现漂移,在 8.1 与 8.2 两处一起闭合。
插桩版与共享底座的一致性
数被拒指令需要在战斗循环里加一个计数器,而共享底座 ashlib.run_spec 不暴露它。本节的 trace() 是 run_spec 的插桩副本,规则逐条照抄。抄写必然带来漂移风险,所以在每一场都做一次对照断言:
# snippet: 插桩版与共享底座的一致性自检(摘自 gd_mech_08_punish_budget.py)
r, ref = trace(s), ashlib.run_spec(s) # 自检:插桩版不得与共享底座漂移
assert (r["won"], r["played"], r["turn"]) == (ref["won"], ref["played"], ref["turn"])
这条断言只要有一次不成立,脚本就崩——它保证「被拒数」这个新指标是长在真实规则上的,而不是长在一个悄悄改过的循环里。
可跑脚本
#!/usr/bin/env python3
# file: sim/gd_mech_08_punish_budget.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_08_punish_budget.py
"""卷一 8.2 惩罚机制与挫败预算(S 层):把「玩家还能忍多久」算成一个整数阈值。
惩罚有两条独立通道,都是整数:① 远征血量,每负一场扣 params.punishHp,耗尽(≤ 0)即终止;
② 挫败预算,每负一场记 punishHp × lossWeight 分,每次**被拒指令**(能量不足——机制上什么
都没发生,但玩家已经输入了)记 rejectWeight 分,累计分 ≥ budget 即**越界**。越界判据写成
闭区间上界 `cumFrustration >= budget`,不含任何浮点与近似。
`params.punishHp` 与 8.1 的 rewardGold 一样,是第 1 章标出的未消费预留位,此处闭合。
本脚本另带自检:自写的 trace 与 ashlib.run_spec 在 won/played/turn 上必须逐场一致,否则说明
插桩版规则与共享底座漂移了。
产出 out/mech08_budget.csv + out/mech08_budget.svg + out/mech08_budget.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_budget.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
HEADER = ["encounter", "won", "rejects", "lossPoints", "rejectPoints", "cumFrustration",
"runHp", "overBudget"]
def trace(spec: dict) -> dict:
"""与 ashlib.run_spec 逐条同规则的插桩版:额外数出被拒指令数。"""
p = spec["params"]
deck = ashlib.shuffle_deck([c for c in spec["cards"] for _ in range(c["count"])], spec["seed"])
hand, draw = deck[: p["handSize"]], deck[p["handSize"]:]
turn, energy, block = 1, p["energyMax"], 0
php, ehp, over, won, played, rejects = p["playerHp"], p["enemyHp"], False, False, 0, 0
for cmd in spec["strategy"]:
if over or turn > p["turnLimit"]:
break
if cmd.startswith("p"):
i = int(cmd[1:])
if i < 0 or i >= len(hand) or hand[i]["cost"] > energy:
rejects += 1 # 输入发生了,状态没变:零反馈
continue
c = hand.pop(i)
energy, block = energy - c["cost"], block + c["block"]
ehp, played = ehp - min(c["damage"], ehp), played + 1
if ehp <= 0:
over = won = True
elif cmd == "e":
absorbed = min(block, p["enemyAttack"])
block = block - absorbed
php = max(0, php - (p["enemyAttack"] - absorbed))
if php <= 0:
over = True
break
turn, block, energy = turn + 1, 0, p["energyMax"]
while len(hand) < p["handSize"] and draw:
hand.append(draw.pop(0))
return {"won": won, "turn": turn, "played": played, "rejects": rejects}
def run_run(spec: dict, cfg: dict) -> dict:
"""一次远征:血量按 step 爬升、种子链递推;两条惩罚通道同时记账。"""
base, step = spec["params"]["enemyHp"], cfg["difficultyStep"]
seed, rows, frust, run_hp = spec["seed"], [], 0, spec["params"]["playerHp"]
losses = rejects_total = 0
crossing = None
for k in range(cfg["encounters"]):
s = copy.deepcopy(spec)
s["seed"], s["params"]["enemyHp"] = seed, base + k * step
r, ref = trace(s), ashlib.run_spec(s) # 自检:插桩版不得与共享底座漂移
assert (r["won"], r["played"], r["turn"]) == (ref["won"], ref["played"], ref["turn"])
loss_pts = 0
if not r["won"]:
losses += 1
run_hp -= spec["params"]["punishHp"]
loss_pts = spec["params"]["punishHp"] * cfg["lossWeight"]
reject_pts = r["rejects"] * cfg["rejectWeight"]
frust += loss_pts + reject_pts
rejects_total += r["rejects"]
over = frust >= cfg["budget"]
crossing = k if over and crossing is None else crossing
rows.append([k, r["won"], r["rejects"], loss_pts, reject_pts, frust, run_hp, over])
seed = ashlib.rng_next(seed)
if over or run_hp <= 0:
break
hp, punish = spec["params"]["playerHp"], spec["params"]["punishHp"]
return {"rows": rows, "crossing": crossing, "losses": losses, "rejectsTotal": rejects_total,
"frustration": frust, "runHp": run_hp, "encountersRun": len(rows), "hpBudget": hp,
"punishHp": punish, "lossesToExhaust": (hp + punish - 1) // punish}
def draw(rows: list[list], budget: int, crossing: int, path: str) -> int:
"""阶跃图:横轴 = 场次,纵轴 = 累计挫败;红线 = 预算阈值,蓝阶 = 每场增量。"""
x0, x1, y0, y1 = 60, 620, 40, 190
top = max(budget + 40, rows[-1][5])
root = ashlib.svg_begin(660, 250)
ashlib.svg_text(root, 40, 24, "《灰烬牌库》远征挫败累计 vs 预算阈值", 14)
ashlib.svg_text(root, 40, 42, f"budget={budget}(红线);越界点 = 第 {crossing} 场", 11, "#94a3b8")
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1),
"stroke": "#64748b"})
yb = y1 - budget * (y1 - y0) // top
ET.SubElement(root, "line", {"x1": str(x0), "y1": str(yb), "x2": str(x1), "y2": str(yb),
"stroke": "#ef4444", "stroke-width": "2"})
bw, prev_y = (x1 - x0) // max(1, len(rows)), y1
for row in rows:
x, y = x0 + row[0] * bw, y1 - row[5] * (y1 - y0) // top
ET.SubElement(root, "line", {"x1": str(x), "y1": str(prev_y), "x2": str(x), "y2": str(y),
"stroke": "#38bdf8", "stroke-width": "2"})
ET.SubElement(root, "rect", {"x": str(x), "y": str(y), "width": str(bw),
"height": str(y1 - y), "fill": "#38bdf8",
"data-value": str(row[5])})
ashlib.svg_text(root, x + bw // 2, y - 4, row[5], 10, "#cbd5e1", "middle")
ashlib.svg_text(root, x + bw // 2, y1 + 16, row[0], 10, "#94a3b8", "middle")
prev_y = y
ashlib.svg_text(root, x0, y1 + 40, "横轴 = 场次;纵轴 = 累计挫败分;蓝阶 = 每场增量。", 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)
res = run_run(spec, cfg)
for row in res["rows"]:
print(f"enc{row[0]:>2} {'胜' if row[1] else '负'} 被拒={row[2]:>2} 失分={row[3]:>2} "
f"拒分={row[4]:>2} 累计={row[5]:>3} 远征血量={row[6]:>2} {'越界' if row[7] else ''}")
print(f"punishHp={res['punishHp']} lossWeight={cfg['lossWeight']} "
f"rejectWeight={cfg['rejectWeight']} budget={cfg['budget']}")
print(f"越界场次={res['crossing']} 累计挫败={res['frustration']} "
f"越界时远征血量={res['runHp']} 失败 {res['losses']} 次 被拒 {res['rejectsTotal']} 次")
print(f"血量预算={res['hpBudget']},耗尽需 {res['lossesToExhaust']} 次失败;"
f"本远征跑满 {res['encountersRun']} 场")
assert res["crossing"] is not None, "预算未越界,阈值形同虚设"
assert res["rows"][res["crossing"]][5] >= cfg["budget"], "越界判据不成立"
assert res["rows"][res["crossing"] - 1][5] < cfg["budget"], "越界点不是首次跨越"
assert res["runHp"] > 0, "血量先于挫败耗尽,本节结论需改写"
n = ashlib.write_csv(os.path.join(OUT, "mech08_budget.csv"), HEADER, res["rows"])
size = draw(res["rows"], cfg["budget"], res["crossing"], os.path.join(OUT, "mech08_budget.svg"))
report = {"id": "gd_mech_08_punish_budget", "config": cfg, "punishHp": res["punishHp"],
"lossWeight": cfg["lossWeight"], "rejectWeight": cfg["rejectWeight"],
"budget": cfg["budget"], "encounters": res["rows"],
"crossingEncounter": res["crossing"], "frustrationAtCrossing": res["frustration"],
"runHpAtCrossing": res["runHp"], "losses": res["losses"],
"rejectsTotal": res["rejectsTotal"], "hpBudget": res["hpBudget"],
"lossesToExhaust": res["lossesToExhaust"], "encountersRun": res["encountersRun"]}
with open(os.path.join(OUT, "mech08_budget.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_budget.svg"))
print(f"wrote out/mech08_budget.csv({n} 行 + 表头) out/mech08_budget.svg({size} B) "
f"out/mech08_budget.json")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_08_punish_budget.py:
enc 0 胜 被拒= 4 失分= 0 拒分= 4 累计= 4 远征血量=60
enc 1 胜 被拒= 4 失分= 0 拒分= 4 累计= 8 远征血量=60
enc 2 胜 被拒= 7 失分= 0 拒分= 7 累计= 15 远征血量=60
enc 3 胜 被拒= 3 失分= 0 拒分= 3 累计= 18 远征血量=60
enc 4 胜 被拒= 7 失分= 0 拒分= 7 累计= 25 远征血量=60
enc 5 胜 被拒= 5 失分= 0 拒分= 5 累计= 30 远征血量=60
enc 6 胜 被拒= 6 失分= 0 拒分= 6 累计= 36 远征血量=60
enc 7 胜 被拒= 5 失分= 0 拒分= 5 累计= 41 远征血量=60
enc 8 负 被拒= 8 失分=40 拒分= 8 累计= 89 远征血量=56
enc 9 负 被拒= 8 失分=40 拒分= 8 累计=137 远征血量=52 越界
punishHp=4 lossWeight=10 rejectWeight=1 budget=100
越界场次=9 累计挫败=137 越界时远征血量=52 失败 2 次 被拒 57 次
血量预算=60,耗尽需 15 次失败;本远征跑满 10 场
wrote out/mech08_budget.csv(10 行 + 表头) out/mech08_budget.svg(4868 B) out/mech08_budget.json
判读:预算先于血量越界
| 量 | 实测值 | 读法 |
|---|---|---|
| 越界场次 | 9 | 第 10 场(索引 9)首次越过阈值 |
| 累计挫败 | 137 | ≥ budget 100,且上一场是 89 < 100,是首次跨越 |
| 越界时远征血量 | 52 | 血量还差 13 次失败才耗尽,远未触发 |
| 失败次数 | 2 | 只输了 2 场,其中 1 场就够把预算推过线 |
| 被拒指令 | 57 | 占 137 分里的 57 分,41.6% |
最有信息量的是最后一行。如果只看胜负,这次远征的「惩罚」只是 2 次失败;但 57 条被拒指令贡献了 41.6% 的挫败分。更值得警惕的是奖励期的 41 分全部来自被拒——前 8 场全胜,一场没输,可玩家已经攒了 41 分挫败,等于预算的 41% 在「一切顺利」的阶段就被吃掉了。这正是零反馈惩罚的隐蔽之处:它不发生在失败时,而发生在每一次手牌与能量对不上的瞬间。
两条通道的触发顺序也值得记一笔。血量需要 15 次失败,而本远征在 16 场里最多只能输 16 次、且第 9 场就已越界——预算总是先响。这不是本规格的巧合,而是两个阈值的量级差:punishHp = 4 对 playerHp = 60 是 6.7% 一次,lossWeight = 10 对 budget = 100 是 40% 一次。若想让血量通道有意义,要么提高 punishHp,要么拉长远征;两者都是整数调参,改一个数字就能在 8.3 的基准表上看到越界点移动。
| 检查项 | 合格标准 |
|---|---|
| 阈值是整数 | budget、punishHp × lossWeight、rejectWeight 全是整数 |
| 判据是闭区间上界 | cumFrustration >= budget,取首次成立者为越界点 |
| 越界点唯一 | 断言越界场次 ≥ 阈值、上一场 < 阈值 |
| 两条通道可比 | 报告血量耗尽所需失败次数,与预算越界场次对照 |
| 零反馈可见 | 被拒指令单独记账,不并入失败次数 |
越界点算出来了,但它只对一组参数成立。punishHp 从 2 调到 8、难度步长从 2 拉到 8,越界点会跳到哪一场?下一节把两个数放到同一根轴上,并做一张参数基准表。
阅读导航:上一节:8.1 奖励节奏谱 · 下一节:8.3 节奏与惩罚的度量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。