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 | 处置 |
|---|---|---|---|---|
| R1 | rewardGold 5→7 | 870 | 1 | 接受 |
| R2 | enemyAttack 4→5 | 807 | 0 | 接受 |
| R3 | handSize 3→4 | 807 | 1 | 接受 |
| R4 | turnLimit 10→12 | 807 | 2 | 接受 |
| R5 | punishHp 2→4 | 807 | 3 | 止损触发 |
| R6 | rewardGold 5→9 | — | — | 未跑 |
止损判据的边界也要说清:它判的是代理指标停没停,不是「这个原型好不好玩」。指标不动只是「在当前参数空间里、当前策略下」不动的证据,换策略、换种子池可能就动了——所以止损触发是「该换维度了」的信号,不是「这个方向死了」的判决。真正的方向决策留给产品判断,本节只负责把「继续无变化地改下去」这条最贵的路堵上。
阅读导航:上一节:2.2 数字原型的迭代循环 · 下一节:3.1 可玩原型的规格内联 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。