站内的 PRD 系列 会把「验收标准」写成一句功能描述——那是文档口径的约定;本章要的是可跑、可调参、可断言、可浏览器打开的工程口径:验收标准必须落成能算出来的数字,还要带「这个数字有多可信」。上一节我们把「测什么」用范围门禁钉死了:首局完成率、单局时长、重玩率、回放死锁。这一节解决「怎么测」。
为什么占比指标必须带置信区间
先看一个反面例子。你找 20 个朋友试玩,14 个人完成了第一局——完成率 70%。这个数字能不能下结论说「超过 50% 的目标线」?如果换一批 20 人再测,可能变成 11 个人(55%),也可能变成 17 个人(85%)。20 人的样本,一个百分点的波动都可能是随机的。
所以任何占比指标都不能只报一个点估计,必须报一个区间。区间告诉你的不是「结果是多少」,而是「结果的不确定性有多大」:样本越少,区间越宽,你越不该拿它做决定。这在 12.3 的迭代决策里是刚需——一个 70% 的点估计如果区间横跨 50% 的目标线,那这一版到底算不算过,根本判不了。
本节用的区间是 Agresti–Coull 区间(对标准 Wald 区间的小样本修正),取 z=2 近似 95% 置信。选它有两个理由:一是小样本表现比朴素公式稳,二是它可以纯整数实现——这正是本套「config 禁浮点、跨语言逐位一致」的硬要求。区间公式里的平方根用 math.isqrt(整数平方根),全程不碰浮点。
S 层:模拟采样与区间
下面这段是 S 层脚本,对 sampleSize 个模拟测试者逐人推进一条整数递推。配置在 config/ch12_playability.json:每个测试者先掷一次「是否完成首局」(state % 1000 < firstRunCompletePerMille),再进入「是否再来一局」的循环,每续一局单局时长按整数比 durGrowthNum/durGrowthDen 递增(round_sec * 106 // 100,整数地板除)。三个占比/时长指标算完后,用 Agresti–Coull 区间给占比配 95% 置信区间,产出 JSON + CSV + SVG。
# file: sim/ch12_playability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch12_playability.py --config config/ --out out/
"""可玩性指标的模拟采样与置信区间(全整数)。
对 sampleSize 个模拟测试者逐人推进一条整数递推:
完成首局? state % 1000 < firstRunCompletePerMille
再来一局? state % 1000 < continuePerMille(否则本会话结束)
单局时长 round_sec = round_sec * durGrowthNum // durGrowthDen(每轮递增,整数地板除)
占比用 Agresti–Coull 区间(z=2,全整数、math.isqrt)。真实测试数据不可得:这是**显式标注的模拟样本**。
产出 out/ch12_playability.json + .csv + .svg。
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import xml.etree.ElementTree as ET
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch12_playability.json"), encoding="utf-8") as f:
return json.load(f)
def lcg(s, mul, inc):
return (s * mul + inc) & 0xFFFFFFFF
def ac(k, n, z, scale=100000):
"""Agresti–Coull 区间 (lo, center, hi),以 scale 为单位,全整数。"""
na = n + z * z
c = (k + z * z // 2) * scale // na
half = z * math.isqrt(c * (scale - c) // na)
return c - half, c, c + half
def simulate(cfg):
mul, inc = cfg["rngMultiplier"], cfg["rngIncrement"]
n = cfg["sampleSize"]
s = cfg["seed"] & 0xFFFFFFFF
done = replay = total = 0
secs = []
for _ in range(n):
s = lcg(s, mul, inc)
if s % 1000 < cfg["firstRunCompletePerMille"]:
done += 1
rounds, rsec = 1, cfg["baseRoundSec"]
sec = rsec
while rounds < cfg["maxRounds"]:
s = lcg(s, mul, inc)
if s % 1000 >= cfg["continuePerMille"]:
break
rounds += 1
rsec = rsec * cfg["durGrowthNum"] // cfg["durGrowthDen"]
sec += rsec
if rounds >= cfg["minReplayRounds"]:
replay += 1
total += sec
secs.append(sec)
z = cfg["zScore"]
mean = total // n
var = sum((x - mean) ** 2 for x in secs) // n
lo, cf, hi = ac(done, n, z)
lr, cr, hr = ac(replay, n, z)
return {"n": n, "completed": done, "replay": replay, "state": s,
"first": cf * 1000 // 100000, "firstLo": lo * 1000 // 100000, "firstHi": hi * 1000 // 100000,
"meanSec": mean, "secHalf": z * math.isqrt(var // n),
"replayPm": cr * 1000 // 100000, "replayLo": lr * 1000 // 100000, "replayHi": hr * 1000 // 100000}
def draw(path, m, target):
W, H = 720, 200
x0, x1 = 60, W - 40
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
f"首局完成率(千分)· 模拟样本 n={m['n']}"
ax = 120
sx = lambda v: x0 + (x1 - x0) * v // 1000
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(ax), "x2": str(x1), "y2": str(ax), "stroke": "#9ca3af"})
for t in range(0, 1001, 250):
ET.SubElement(svg, "text", {"x": str(sx(t)), "y": str(ax + 20), "font-size": "11",
"text-anchor": "middle", "fill": "#6b7280"}).text = str(t)
ET.SubElement(svg, "line", {"x1": str(sx(target)), "y1": "46", "x2": str(sx(target)), "y2": str(ax),
"stroke": "#dc2626", "stroke-dasharray": "5 4"})
ET.SubElement(svg, "text", {"x": str(sx(target) + 6), "y": "58", "font-size": "12", "fill": "#dc2626"}).text = \
f"目标 {target}"
ET.SubElement(svg, "rect", {"x": str(sx(m["firstLo"])), "y": "86",
"width": str(max(1, sx(m["firstHi"]) - sx(m["firstLo"]))), "height": "18",
"fill": "#bfdbfe", "rx": "4"})
ET.SubElement(svg, "circle", {"cx": str(sx(m["first"])), "cy": "95", "r": "6", "fill": "#2563eb"})
ET.SubElement(svg, "text", {"x": "60", "y": "180", "font-size": "12", "fill": "#374151"}).text = \
f"点估计 {m['first']} 千分 · 95% 区间 [{m['firstLo']}, {m['firstHi']}] · 单局均值 {m['meanSec']}s ± {m['secHalf']}s"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config)
m = simulate(cfg)
t = cfg["targets"]
rows = [
{"metric": "firstRunCompletionPerMille", "value": m["first"], "ciLo": m["firstLo"], "ciHi": m["firstHi"],
"target": t["firstRunCompletionPerMille"], "unit": "per-mille",
"pass": m["first"] >= t["firstRunCompletionPerMille"]},
{"metric": "meanSessionSec", "value": m["meanSec"], "ciLo": m["meanSec"] - m["secHalf"],
"ciHi": m["meanSec"] + m["secHalf"], "target": t["meanSessionSec"], "unit": "second",
"pass": 90 <= m["meanSec"] <= 240},
{"metric": "replayPerMille", "value": m["replayPm"], "ciLo": m["replayLo"], "ciHi": m["replayHi"],
"target": t["replayPerMille"], "unit": "per-mille", "pass": m["replayPm"] >= t["replayPerMille"]},
{"metric": "softlockCount", "value": 0, "ciLo": 0, "ciHi": 0, "target": t["softlockCount"],
"unit": "count", "pass": True},
]
os.makedirs(a.out, exist_ok=True)
with open(os.path.join(a.out, "ch12_playability.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
svg_bytes = draw(os.path.join(a.out, "ch12_playability.svg"), m, t["firstRunCompletionPerMille"])
report = {"version": cfg["version"], "simulatedSample": True,
"disclaimer": "模拟样本,非真实用户数据;需真实用户测试,命令行不可复现",
"seed": cfg["seed"], "sampleSize": m["n"], "rngState": m["state"],
"metrics": {r["metric"]: r["value"] for r in rows},
"intervals": {r["metric"]: [r["ciLo"], r["ciHi"]] for r in rows},
"passCount": sum(1 for r in rows if r["pass"]), "total": len(rows),
"svg": os.path.join(a.out, "ch12_playability.svg"), "svgBytes": svg_bytes}
with open(os.path.join(a.out, "ch12_playability.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"sampleSize": m["n"], "firstRunCompletionPerMille": m["first"],
"firstRunCi": [m["firstLo"], m["firstHi"]], "meanSessionSec": m["meanSec"],
"sessionCiHalf": m["secHalf"], "replayPerMille": m["replayPm"],
"replayCi": [m["replayLo"], m["replayHi"]], "passCount": report["passCount"]},
ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
跑 /opt/homebrew/bin/python3 sim/ch12_playability.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):
{"sampleSize": 400, "firstRunCompletionPerMille": 616, "firstRunCi": [567, 664], "meanSessionSec": 113, "sessionCiHalf": 8, "replayPerMille": 542, "replayCi": [492, 591], "passCount": 4}
三项占比/时长的模拟结果:首局完成率 616 千分(95% 区间 [567, 664])、单局均值 113 秒(±8 秒)、重玩率 542 千分(区间 [492, 591])。四点结论。
第一,区间下界才是决策依据。首局完成率的点估计 616 千分远高于 500 的目标线,但真正该问的是「区间的下界 567 是否也在目标线之上」——这里 567 > 500,说明即便按最保守的估计这一版也过了。如果下界掉到 500 以下,那这个「过」就是统计噪声,不能采信。
第二,单局时长 113 秒落在 [90, 240] 的心流区间内,但它离下界 90 只差 23 秒,±8 秒的波动还够不到下界,属于「安全但不高」的区间。这提示节奏偏快,是下一版可以考虑放大的方向。
第三,重玩率 542 千分说明超过一半的测试者至少重开了一局,这比完成率更能反映「循环有没有吸引力」。
第四,softlockCount 为 0——它不由本脚本模拟,而由 12.3 的确定性回放断言覆盖,这里只占位对齐目标表。
B 层:样本量敏感性
上面的区间有多宽,取决于样本量。下面这段是 B 层基准脚本,把样本量从 50 拉到 3200,看首局完成率的区间半宽怎么变。它复用 S 层的 simulate 与 ac,不重复实现。B 层只做软告警:半宽超过 80 千分(8 个百分点)只打 WARN,不返回非 0。
# file: sim/ch12_sample_sensitivity.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch12_sample_sensitivity.py --config config/ --out out/
"""B 基准:样本量敏感性。半宽随 n 大致按 1/sqrt(n) 收缩;超容忍只软告警,不返回非 0。"""
from __future__ import annotations
import argparse
import json
import os
import xml.etree.ElementTree as ET
from ch12_playability import ac, load, simulate
TOL = 80 # 半宽超过 80 千分(8 个百分点)软告警
GRID = [50, 100, 200, 400, 800, 1600, 3200]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
base = load(a.config)
z = base["zScore"]
rows = []
for n in GRID:
cfg = dict(base)
cfg["sampleSize"] = n
m = simulate(cfg)
lo, _, hi = ac(m["completed"], n, z)
rows.append({"n": n, "completed": m["completed"], "pointPerMille": m["first"],
"halfWidthPerMille": (hi - lo) * 1000 // 100000 // 2})
chosen = base["sampleSize"]
ch = next(r["halfWidthPerMille"] for r in rows if r["n"] == chosen)
W, H = 720, 280
x0, x1, y0, y1 = 64, W - 40, H - 44, 48
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
"样本量 → 首局完成率 95% 区间半宽(千分)"
mx = max(r["halfWidthPerMille"] for r in rows) or 1
sx = lambda n: x0 + (x1 - x0) * (n - GRID[0]) // (GRID[-1] - GRID[0])
sy = lambda h: y1 + (y0 - y1) * (1 - h / mx)
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y1), "x2": str(x1), "y2": str(y1), "stroke": "#9ca3af"})
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(y0), "x2": str(x0), "y2": str(y1), "stroke": "#9ca3af"})
ET.SubElement(svg, "polyline", {"points": " ".join(f"{sx(r['n']):.1f},{sy(r['halfWidthPerMille']):.1f}" for r in rows),
"fill": "none", "stroke": "#2563eb", "stroke-width": "2"})
for r in rows:
ET.SubElement(svg, "circle", {"cx": f"{sx(r['n']):.1f}", "cy": f"{sy(r['halfWidthPerMille']):.1f}", "r": "4",
"fill": "#2563eb"})
ET.SubElement(svg, "text", {"x": f"{sx(r['n']):.1f}", "y": str(y1 + 18), "font-size": "10",
"text-anchor": "middle", "fill": "#6b7280"}).text = str(r["n"])
ET.SubElement(svg, "line", {"x1": str(x0), "y1": str(sy(TOL)), "x2": str(x1), "y2": str(sy(TOL)),
"stroke": "#dc2626", "stroke-dasharray": "5 4"})
os.makedirs(a.out, exist_ok=True)
ET.ElementTree(svg).write(os.path.join(a.out, "ch12_sample_sensitivity.svg"),
encoding="utf-8", xml_declaration=True)
report = {"version": base["version"], "chosenSampleSize": chosen, "chosenHalfWidthPerMille": ch,
"tolerancePerMille": TOL, "withinTolerance": ch <= TOL, "grid": rows}
with open(os.path.join(a.out, "ch12_sample_sensitivity.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(json.dumps({"chosenSampleSize": chosen, "chosenHalfWidthPerMille": ch, "tolerance": TOL,
"withinTolerance": report["withinTolerance"],
"grid": {r["n"]: r["halfWidthPerMille"] for r in rows}}, ensure_ascii=False))
if not report["withinTolerance"]:
print(f"WARN 样本量 {chosen} 处半宽 {ch} > {TOL} 千分")
return 0 # B 层软告警:恒返回 0
if __name__ == "__main__":
raise SystemExit(main())
跑 /opt/homebrew/bin/python3 sim/ch12_sample_sensitivity.py --config config/ --out out/,输出(同机相对量级,绝对数值不可移植):
{"chosenSampleSize": 400, "chosenHalfWidthPerMille": 48, "tolerance": 80, "withinTolerance": true, "grid": {"50": 133, "100": 91, "200": 67, "400": 48, "800": 34, "1600": 24, "3200": 17}}
半宽随样本量的序列是 50→133、100→91、200→67、400→48、800→34、1600→24、3200→17(千分)。看两个特征:一是单调收缩,样本越大区间越窄;二是收缩变慢,从 50 到 100(翻倍)半宽从 133 掉到 91,但从 1600 到 3200(同样翻倍)只从 24 掉到 17——这是 1/sqrt(n) 的直接后果:样本量翻四倍,区间才减半。本套配置选的 400 人对应半宽 48 千分,低于 80 的容忍线,所以 withinTolerance 为 true,不告警。
这条基准的实际用途是反推样本量。如果你要求半宽不超过 40 千分,从序列看 400 不够(48),得往 800(34)靠——也就是把试玩人数从 400 提到 800 左右。它把「要招多少人」从一个拍脑袋的数字,变成一个能算出来的数字。
模拟样本不等于真实数据(X)
必须说清楚:以上所有数字都来自本机模拟样本,不是真实用户。脚本用可播种 LCG 造出的「测试者」行为是配置里写死的概率,它只能验证统计方法本身(区间怎么算、半宽怎么随样本量变),不能告诉你《合成小岛》的真实完成率是多少。真实的可玩性测试需真实用户测试,命令行不可复现(X)——那需要在真机上招募测试者、埋点采集、剔除异常样本,本节的脚本只提供「拿到数据后该怎么算」的工具链。
把「怎么测」固定下来之后,最后一节要回答的是:测出来的数字,怎么变成「继续 / 调整 / 砍掉」的决策。12.2 给的是带区间的观测,12.3 给的是把这些观测翻译成决策的确定性内核。
阅读导航:上一节:原型范围与验证目标 · 下一节:原型迭代与决策 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。