8.2 惩罚机制与挫败预算

惩罚有两条独立通道:远征血量(每负一场扣 params.punishHp)与挫败预算(失败扣分 + 被拒指令扣分,累计 ≥ 100 即越界)。实测第 9 场累计挫败 137 越界,此时远征血量还剩 52——血量阈值要 15 次失败才耗尽,远慢于玩家的耐心。把「还能忍多久」写成一个整数阈值,并给出闭区间上界的越界判据。

留存设计清单 那一篇说的是「用签到与赛季把人拉回来」;本节做的是反面——把人推走的那一半,也要能算。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 节奏与惩罚的度量 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡