2.3 原型的范围与止损

原型最大的风险不是做不出来,而是做不完:范围一旦失控,迭代就变成无限拉锯。本节给「该停了」两条可算的判据——连续 3 轮代理指标无变化即停、总轮数上限兜底——并真跑到触发为止:主循环停在第 5 轮,抽掉唯一让指标动过的那轮后 control 停在第 3 轮。

2.3 原型的范围与止损

《小游戏开发实战》的最小可玩原型 管的是「做到多小就够玩」,原型迭代决策 管的是「什么时候继续、什么时候改方向」;本节把后者从判断变成可算的判据——连续 N 轮代理指标无变化就停,而不是靠感觉「再改改看」。本节出现的可玩性 / 迭代收益数字都是代理指标(proxy),只用于同一套内核内的相对比较,不是对玩家体验的测量,也不能跨项目绝对值化。上一节算清了一轮迭代的代价,本节算的是什么时候停止付这个代价。

止损不是放弃,是把「停」写成判据

止损听起来像放弃,其实相反:它是把「继续还是停」从会议桌上的立场之争,降成一条能在脚本里跑的规则。范围失控的原型有个典型症状——每轮都在改,代理指标却不动;改动本身成了目的,迭代变成了拉锯。判据要能拦住这种拉锯,就不能只看「改了几轮」,要看「改出了什么变化」。

判据只有两条,都写进 config 的 policy 段:停滞止损 stallLimit = 3,连续 3 轮代理指标无变化即停;预算止损 roundBudget = 8,兜住「一直在变但没变好」的那种情况。另加两条硬闸:不变量 fail(坏参数)当轮拒绝并回滚,代理指标只做相对比较、不设绝对值门槛。三条都写进同一份 config/gd_proto_02_stop_loss.json,判据与数据同源,不散落在文档里。

P 层:把止损写成可失败的断言

判据必须是断言,否则它只是文档里的一句话。P 层把「停在第几轮、为什么停」钉成可失败的断言:主循环必须停在第 5 轮且 reason === "stall",第 6 轮根本没跑;control 抽掉唯一让指标动过的那轮后必须停在第 3 轮——若判据只是「到点就停」,control 会停在第 5 轮,测试当场红。

// file: core/test/gd_proto_02_stop_loss.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_02_stop_loss.test.ts'
//
// 2.3 范围与止损的 P 层:把「该停了」写成可算的判据并跑到底。判据两条——连续 3 轮代理指标
// (hpMarginPermille,种子池 101..132 上的血量余裕度)无变化即停;总轮数上限 8 兜底。
// 断言:主循环停在第 5 轮(reason=stall),第 6 轮根本没跑;control 抽掉唯一让指标动过的
// enemyAttack 轮后停在第 3 轮——证明判据不是「到点就停」。代理指标只做相对比较,禁绝对化。

import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

import { runSpec, invariantsHold } from "../src/ashdeck/combat.ts";
import type { Params, Spec } from "../src/ashdeck/types.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const SPEC: Spec = JSON.parse(
  readFileSync(join(ROOT, "config", "gd_proto_02_minimal_kernel.json"), "utf8"),
);
const POOL = Array.from({ length: 32 }, (_, i) => 101 + i);
const STALL_LIMIT = 3;
const BUDGET = 8;

type Round = { round: number; param: keyof Params; to: number };

const ROUNDS: Round[] = [
  { round: 1, param: "rewardGold", to: 7 },
  { round: 2, param: "enemyAttack", to: 5 },
  { round: 3, param: "handSize", to: 4 },
  { round: 4, param: "turnLimit", to: 12 },
  { round: 5, param: "punishHp", to: 4 },
  { round: 6, param: "rewardGold", to: 9 },
];

function margin(spec: Spec): number {
  let sum = 0;
  for (const sd of POOL) {
    const sp: Spec = JSON.parse(JSON.stringify(spec));
    sp.seed = sd;
    sum += runSpec(sp).playerHp;
  }
  return Math.floor((1000 * sum) / (POOL.length * spec.params.playerHp));
}

function withParam(spec: Spec, key: keyof Params, val: number): Spec {
  const c: Spec = JSON.parse(JSON.stringify(spec));
  c.params[key] = val;
  return c;
}

function runLoop(rounds: Round[]) {
  let work = SPEC;
  let prev = margin(work);
  let streak = 0;
  const rows: { round: number; proxy: number; moved: boolean; streak: number }[] = [];
  for (const r of rounds) {
    if (rows.length >= BUDGET) return { rows, stopRound: -1, reason: "budget", streak };
    const cand = withParam(work, r.param, r.to);
    const snap = runSpec(cand);
    if (!invariantsHold(cand, snap)) return { rows, stopRound: r.round, reason: "invariant", streak };
    const m = margin(cand);
    const moved = m !== prev;
    streak = moved ? 0 : streak + 1;
    work = cand;
    prev = m;
    rows.push({ round: r.round, proxy: m, moved, streak });
    if (streak >= STALL_LIMIT) return { rows, stopRound: r.round, reason: "stall", streak };
  }
  return { rows, stopRound: -1, reason: "exhausted", streak };
}

test("主循环:第 5 轮 streak 到 3,止损触发,第 6 轮未跑", () => {
  const r = runLoop(ROUNDS);
  assert.equal(r.reason, "stall");
  assert.equal(r.stopRound, 5);
  assert.equal(r.rows.length, 5);
  assert.equal(r.streak, 3);
  assert.deepEqual(r.rows.map((x) => x.proxy), [870, 807, 807, 807, 807]);
  assert.deepEqual(r.rows.map((x) => x.streak), [1, 0, 1, 2, 3]);
  assert.equal(r.rows[1].moved, true);
});

test("control:抽掉 enemyAttack 轮后停在第 3 轮(判据不空转)", () => {
  const ctrl = ROUNDS.filter((r) => r.param !== "enemyAttack")
    .map((r, i) => ({ round: i + 1, param: r.param, to: r.to }));
  const r = runLoop(ctrl);
  assert.equal(r.reason, "stall");
  assert.equal(r.stopRound, 3);
  assert.deepEqual(r.rows.map((x) => x.proxy), [870, 870, 870]);
  assert.deepEqual(r.rows.map((x) => x.streak), [1, 2, 3]);
});

test("代理指标只做相对比较:基线 870 > 覆盖 enemyAttack=5 后的 807", () => {
  assert.equal(margin(SPEC), 870);
  assert.equal(margin(withParam(SPEC, "enemyAttack", 5)), 807);
});

test("若 S 层记录已存在,止损轮与 reason 必须与记录一致", () => {
  const p = join(ROOT, "out", "gd_proto_02_stop_loss.json");
  if (!existsSync(p)) return; // 未跑过 S 层则跳过。
  const rec = JSON.parse(readFileSync(p, "utf8"));
  assert.equal(rec.main.reason, "stall");
  assert.equal(rec.main.stopRound, 5);
  assert.equal(rec.control.reason, "stall");
  assert.equal(rec.control.stopRound, 3);
  assert.equal(rec.savedRounds, 1);
});

P 实跑输出

✔ 主循环:第 5 轮 streak 到 3,止损触发,第 6 轮未跑
✔ control:抽掉 enemyAttack 轮后停在第 3 轮(判据不空转)
✔ 代理指标只做相对比较:基线 870 > 覆盖 enemyAttack=5 后的 807
✔ 若 S 层记录已存在,止损轮与 reason 必须与记录一致
ℹ tests 4
ℹ pass 4
ℹ fail 0

四条断言全绿。第 2 条是本节的关键:control 与主循环用同一份判据、同一组轮次(只少一轮),却停在不同轮号——这说明停下来的原因是指标确实不动了,而不是轮数到了。第 3 条把「代理指标只做相对比较」也写成断言:870 与 807 只比较大小,谁都不许被当成「可玩性分数」。这条判据在 W 层也被真跑过一遍:out/proto02.html 的 globalThis.__ashdeck.stopLossRound() 在浏览器里重算止损循环,返回字符串 5,与 S / P 层的止损轮号逐字一致。

S 层:真跑到触发为止

P 层断言的是判据的形状,S 层真把它跑到触发。同一份 config,从基线出发逐轮只改一个参数,记录每轮的代理指标(种子池 101..132 上的血量余裕度千分比)与连续无变化计数 streak;streak 一到 3 立刻停,后面的轮次不执行。再跑一个 control:把唯一让指标动过的 enemyAttack 那轮抽掉,其余不变。

#!/usr/bin/env python3
# file: sim/gd_proto_02_stop_loss.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_02_stop_loss.py
"""卷三 2.3 原型的范围与止损(S 层):给「该停了」一个可算的判据。

止损判据只有两条,都写进 config 的 policy 段:
  停滞止损 stallLimit —— 连续 N 轮代理指标**无变化**即停(N=3);
  预算止损 roundBudget —— 总轮数上限(8),兜住「一直在变但没变好」。
另有两条硬闸:不变量 fail(坏参数)当轮拒绝并回滚;代理指标只做相对比较。

本脚本真跑到触发为止:从基线出发逐轮改一个参数,记录 streak,第 5 轮 streak 到 3,止损触发,
第 6 轮**根本没跑**。再跑一个 control(把唯一让指标动过的 enemyAttack 那轮抽掉),证明判据不是
「到点就停」——它停在第 3 轮,因为那三轮代理指标确实一直没动。

产物 out/gd_proto_02_stop_loss.json。全整数、无浮点、无 random。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

CFG = os.path.join(ROOT, "config", "gd_proto_02_stop_loss.json")


def deep(o: dict) -> dict:
    return json.loads(json.dumps(o))


def invariants(spec: dict, snap: dict) -> bool:
    return (
        snap["cardCount"] == ashlib.deck_size(spec)
        and snap["damageConserved"] is True
        and 0 <= snap["energy"] <= spec["params"]["energyMax"]
        and snap["block"] >= 0
    )


def proxy_margin(spec: dict, pool: list) -> int:
    total = 0
    for sd in pool:
        s = deep(spec)
        s["seed"] = sd
        total += ashlib.run_spec(s)["playerHp"]
    return 1000 * total // (len(pool) * spec["params"]["playerHp"])


def loop(base: dict, rounds: list, pool: list, stall_limit: int, budget: int) -> dict:
    work = deep(base)
    prev = proxy_margin(work, pool)
    streak = 0
    rows = []
    for r in rounds:
        if len(rows) >= budget:
            return {"rows": rows, "stopRound": None, "reason": "budget",
                    "ranRounds": len(rows), "finalProxy": prev, "finalStreak": streak}
        cand = deep(work)
        frm = cand["params"][r["param"]]
        cand["params"][r["param"]] = r["to"]
        snap = ashlib.run_spec(cand)
        inv = invariants(cand, snap)
        margin = proxy_margin(cand, pool)
        if not inv:
            rows.append({"round": r["round"], "param": r["param"], "from": frm, "to": r["to"],
                         "proxy": margin, "moved": None, "streak": streak, "accepted": False})
            return {"rows": rows, "stopRound": r["round"], "reason": "invariant",
                    "ranRounds": len(rows), "finalProxy": prev, "finalStreak": streak}
        moved = margin != prev
        streak = 0 if moved else streak + 1
        work = cand
        prev = margin
        rows.append({"round": r["round"], "param": r["param"], "from": frm, "to": r["to"],
                     "proxy": margin, "moved": moved, "streak": streak, "accepted": True})
        if streak >= stall_limit:
            return {"rows": rows, "stopRound": r["round"], "reason": "stall",
                    "ranRounds": len(rows), "finalProxy": prev, "finalStreak": streak}
    return {"rows": rows, "stopRound": None, "reason": "exhausted",
            "ranRounds": len(rows), "finalProxy": prev, "finalStreak": streak}


def main() -> int:
    cfg = json.load(open(CFG, encoding="utf-8"))
    base = ashlib.load_spec(os.path.join(ROOT, cfg["base"]))
    pr = cfg["proxy"]
    pool = list(range(pr["poolStart"], pr["poolStart"] + pr["poolCount"]))
    limit = cfg["policy"]["stallLimit"]
    budget = cfg["policy"]["roundBudget"]

    run = loop(base, cfg["rounds"], pool, limit, budget)
    # control:抽掉唯一让指标动过的 enemyAttack 轮,其余不变(轮号重排为 1..N)。
    ctrl_rounds = [
        {"round": i + 1, "param": r["param"], "to": r["to"]}
        for i, r in enumerate(x for x in cfg["rounds"] if x["param"] != "enemyAttack")
    ]
    control = loop(base, ctrl_rounds, pool, limit, budget)

    print(f"=== 2.3 止损判据:连续 {limit} 轮代理指标无变化即停(预算 {budget} 轮)===")
    for r in run["rows"]:
        mv = "-" if r["moved"] is None else ("动" if r["moved"] else "平")
        print(f"  R{r['round']} {r['param']:12s} {r['from']}→{r['to']:<3} proxy={r['proxy']} "
              f"{mv} streak={r['streak']} {'接受' if r['accepted'] else '拒绝'}")
    print(f"主循环:跑到第 {run['ranRounds']} 轮,触发={run['reason']} "
          f"止损轮={run['stopRound']} 共 {len(cfg['rounds'])} 轮中第 {run['stopRound']+1} 轮起未跑")
    print(f"control(抽掉 enemyAttack 轮):跑到第 {control['ranRounds']} 轮,"
          f"触发={control['reason']} 止损轮={control['stopRound']}")

    report = {
        "id": "gd_proto_02_stop_loss",
        "config": "config/gd_proto_02_stop_loss.json",
        "policy": {"stallLimit": limit, "roundBudget": budget},
        "baseProxy": proxy_margin(base, pool),
        "main": run, "control": {"rounds": ctrl_rounds, **control},
        "candidateRounds": len(cfg["rounds"]), "ranRounds": run["ranRounds"],
        "savedRounds": len(cfg["rounds"]) - run["ranRounds"],
        "proxyRelativeOnly": True,
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "gd_proto_02_stop_loss.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"省下 {report['savedRounds']} 轮(候选 {len(cfg['rounds'])} 轮,实跑 {run['ranRounds']} 轮)")
    print("wrote out/gd_proto_02_stop_loss.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

S 实跑输出

=== 2.3 止损判据:连续 3 轮代理指标无变化即停(预算 8 轮)===
  R1 rewardGold   5→7   proxy=870 平 streak=1 接受
  R2 enemyAttack  4→5   proxy=807 动 streak=0 接受
  R3 handSize     3→4   proxy=807 平 streak=1 接受
  R4 turnLimit    10→12  proxy=807 平 streak=2 接受
  R5 punishHp     2→4   proxy=807 平 streak=3 接受
主循环:跑到第 5 轮,触发=stall 止损轮=5 共 6 轮中第 6 轮起未跑
control(抽掉 enemyAttack 轮):跑到第 3 轮,触发=stall 止损轮=3
省下 1 轮(候选 6 轮,实跑 5 轮)
wrote out/gd_proto_02_stop_loss.json

把这六轮按止损账列出来,就能看清判据到底省了什么——省下的不是时间,是没有被证据支持的改动:

轮次改的参数代理指标streak处置
R1rewardGold 5→78701接受
R2enemyAttack 4→58070接受
R3handSize 3→48071接受
R4turnLimit 10→128072接受
R5punishHp 2→48073止损触发
R6rewardGold 5→9——未跑

止损判据的边界也要说清:它判的是代理指标停没停,不是「这个原型好不好玩」。指标不动只是「在当前参数空间里、当前策略下」不动的证据,换策略、换种子池可能就动了——所以止损触发是「该换维度了」的信号,不是「这个方向死了」的判决。真正的方向决策留给产品判断,本节只负责把「继续无变化地改下去」这条最贵的路堵上。

阅读导航:上一节:2.2 数字原型的迭代循环 · 下一节:3.1 可玩原型的规格内联 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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