12.3 原型迭代与决策

把可玩性观测翻译成继续、调整、砍掉三个决策:用 P 层 TypeScript 内核按北极星达成率与止损线判定,用 node --test 锁死四个构建的结论,再打包成单文件 HTML 交互原型,用 Playwright 无头采样状态、断言 canvas、验证确定性并截图。

站内的 PRD 系列 会把「下一步计划」写成一段需求描述——那是文档口径;本章的原型是可跑、可调参、可断言、可浏览器打开的工程,所以「下一步做什么」必须由一段能跑出结论的代码来回答。12.1 定了「测什么」,12.2 定了「怎么测」,这一节把前两节的产物接起来:把带区间的观测翻译成三个词。

决策不是拍脑袋,而是把观测翻译成三个词

有了数据却不敢下决定,是原型迭代最常见的失败:完成率 61%、重玩率 54%,到底继续、调整还是砍掉?判据不写死,这个决定就会被「再测一轮」无限拖延。本节的规则只有三条,核心是北极星:北极星达成率低于止损线 → cut(连底线都够不到,任何微调都是浪费);北极星过且全部目标过 → continue;其余 → adjust(方向对、火候差,回去调参再来一轮)。关键在于达成率 = 值 × 100 // 阈值 是整数、止损线是配置里的整数百分比,于是「该不该砍」变成一个确定性的整数比较。

P 层:迭代决策内核

下面这段是 P 层内核,读 config/ch12_iteration.json(四个目标与 12.1 一致、四个候选构建的真实指标、采样抖动幅度),导出达成率、单目标判定、整体决策,全部整数、无 Math.random();runBuild 的种子取 cfg.seed + buildIndex * 7919,同一构建编号永远得到同一组观测。下面用 node --test 把达成率三类公式、单目标判定、四个构建各得一种结论、采样与跑构建可复现一并锁死。

// file: core/src/ch12/iteration.ts
// tier: P
// verify: node --test 'core/test/ch12_*.test.ts'
//
// 原型迭代决策内核:把一次试玩采样的指标对照验证目标,判出 continue / adjust / cut。
// 全整数、无 Math.random()、只用可擦除语法。

export type Metrics = Record<string, number>;

export interface IterGoal { id: string; name: string; metric: string; direction: string; threshold: number; lower: number; upper: number; northStar: boolean }
export interface IterBuild { id: string; name: string; metrics: Metrics }
export interface IterConfig { seed: number; killLinePct: number; softlockTolerance: number; rngMultiplier: number; rngIncrement: number; goals: IterGoal[]; noiseAmp: Metrics; builds: IterBuild[] }
export interface Rng { next(): number; state(): number }
export interface GoalVerdict { id: string; metric: string; value: number; pass: boolean; attainmentPct: number }
export interface Decision { verdict: string; passCount: number; total: number; goals: GoalVerdict[] }
export interface BuildRun { build: IterBuild; obs: Metrics; decision: Decision; rngState: number }

function idiv(a: number, b: number): number { return Math.floor(a / b); }

/** 可播种整数 LCG:s = (s * mul + inc) mod 2^32。同一 seed 必得同一序列。 */
export function createRng(seed: number, cfg: IterConfig): Rng {
  let s = seed >>> 0;
  return {
    next() { s = (Math.imul(s, cfg.rngMultiplier) + cfg.rngIncrement) >>> 0; return s; },
    state() { return s; },
  };
}

/** 达成率(%):up 越大越好;down 越小越好;range 落在区间内即 100。全整数。 */
export function attainmentPct(g: IterGoal, v: number): number {
  if (g.direction === "down") return v <= g.threshold ? 100 : idiv(g.threshold * 100, v);
  if (g.direction === "range") {
    if (v >= g.lower && v <= g.upper) return 100;
    return v < g.lower ? (g.lower > 0 ? idiv(v * 100, g.lower) : 0) : idiv(g.upper * 100, v);
  }
  return g.threshold > 0 ? idiv(v * 100, g.threshold) : 100;
}

/** 单个目标判定:up 看 ≥阈值,down 看 ≤阈值,range 看落在 [lower, upper]。 */
export function evaluateGoal(g: IterGoal, v: number): GoalVerdict {
  const pass = g.direction === "up" ? v >= g.threshold : g.direction === "down" ? v <= g.threshold : v >= g.lower && v <= g.upper;
  return { id: g.id, metric: g.metric, value: v, pass, attainmentPct: attainmentPct(g, v) };
}

/**
 * 迭代决策:北极星达成率 < killLinePct → cut;北极星过且全过且无死锁 → continue;其余 → adjust。
 * 一个表达式判完,不含任何浮点比较。
 */
export function decide(obs: Metrics, cfg: IterConfig): Decision {
  const goals = cfg.goals.map((g) => evaluateGoal(g, obs[g.metric] ?? 0));
  const total = goals.length;
  const passCount = goals.filter((g) => g.pass).length;
  const nv = goals[Math.max(0, cfg.goals.findIndex((g) => g.northStar))];
  const deadlock = (obs["softlockCount"] ?? 0) > cfg.softlockTolerance;
  const belowKill = !nv.pass && nv.attainmentPct < cfg.killLinePct;
  const verdict = belowKill ? "cut" : nv.pass ? (passCount === total && !deadlock ? "continue" : "adjust") : "adjust";
  return { verdict, passCount, total, goals };
}

/** 给构建的真实指标叠加整数抖动(幅度 ±amp),模拟一次试玩采样;下限截到 0。 */
export function sampleObservations(build: IterBuild, cfg: IterConfig, rng: Rng): Metrics {
  const out: Metrics = {};
  for (const key of Object.keys(build.metrics)) {
    const amp = cfg.noiseAmp[key] ?? 0;
    out[key] = amp > 0 ? Math.max(0, build.metrics[key] + (rng.next() % (amp * 2 + 1)) - amp) : build.metrics[key];
  }
  return out;
}

/** 确定性跑一个构建:seed 与 buildIndex 决定采样与决策,可逐位回放。 */
export function runBuild(buildIndex: number, cfg: IterConfig): BuildRun {
  const build = cfg.builds[buildIndex % cfg.builds.length];
  const rng = createRng((cfg.seed + buildIndex * 7919) >>> 0, cfg);
  const obs = sampleObservations(build, cfg, rng);
  return { build, obs, decision: decide(obs, cfg), rngState: rng.state() };
}
// file: core/test/ch12_iteration.test.ts
// tier: P
// verify: node --test 'core/test/ch12_*.test.ts'

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createRng, attainmentPct, evaluateGoal, decide, sampleObservations, runBuild } from "../src/ch12/iteration.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(readFileSync(join(HERE, "..", "..", "config", "ch12_iteration.json"), "utf-8")) as Parameters<typeof createRng>[1];
const goal = (id: string) => cfg.goals.find((g) => g.id === id)!;
const byId = (id: string) => cfg.builds.find((b) => b.id === id)!;

test("LCG 可播种:同一 seed 得同一前 3 个输出", () => {
  const rng = createRng(cfg.seed, cfg);
  assert.deepEqual([rng.next(), rng.next(), rng.next()], [1891695331, 1516053222, 1468488973]);
});
test("达成率与判定:up / down / range 三类全整数", () => {
  assert.equal(attainmentPct(goal("g1"), 400), 80);
  assert.equal(attainmentPct(goal("g1"), 600), 120);
  assert.equal(attainmentPct(goal("g4"), 3), 0);
  assert.equal(attainmentPct(goal("g4"), 0), 100);
  assert.equal(attainmentPct(goal("g2"), 150), 100);
  assert.equal(attainmentPct(goal("g2"), 300), 80);
  assert.equal(attainmentPct(goal("g2"), 70), 77);
  assert.equal(evaluateGoal(goal("g1"), 500).pass, true);
  assert.equal(evaluateGoal(goal("g1"), 499).pass, false);
  assert.equal(evaluateGoal(goal("g4"), 1).pass, false);
  assert.equal(evaluateGoal(goal("g2"), 240).pass, true);
  assert.equal(evaluateGoal(goal("g2"), 241).pass, false);
});
test("决策:四个构建真实指标各得一种结论,逼近与远低分界清晰", () => {
  assert.equal(decide(byId("v0.1").metrics, cfg).verdict, "adjust");
  assert.equal(decide(byId("v0.2").metrics, cfg).verdict, "adjust");
  assert.equal(decide(byId("v0.3").metrics, cfg).verdict, "continue");
  assert.equal(decide(byId("v0.4").metrics, cfg).verdict, "cut");
  const mk = (f: number) => ({ firstRunCompletionPerMille: f, meanSessionSec: 150, replayPerMille: 600, softlockCount: 0 });
  assert.equal(decide(mk(700), cfg).verdict, "continue");
  assert.equal(decide(mk(420), cfg).goals[0].attainmentPct, 84);
  assert.equal(decide(mk(420), cfg).verdict, "adjust");
  assert.equal(decide(mk(200), cfg).goals[0].attainmentPct, 40);
  assert.equal(decide(mk(200), cfg).verdict, "cut");
});
test("采样可复现:同 seed 同构建得同一组观测", () => {
  const a = sampleObservations(cfg.builds[0], cfg, createRng(cfg.seed, cfg));
  const b = sampleObservations(cfg.builds[0], cfg, createRng(cfg.seed, cfg));
  assert.deepEqual(a, b);
  assert.deepEqual(a, { firstRunCompletionPerMille: 426, meanSessionSec: 135, replayPerMille: 351, softlockCount: 0 });
});
test("确定性跑构建:v0.1 观测/结论/RNG 终态与四构建结论序列", () => {
  const r = runBuild(0, cfg);
  assert.equal(r.build.id, "v0.1");
  assert.deepEqual(r.obs, { firstRunCompletionPerMille: 426, meanSessionSec: 135, replayPerMille: 351, softlockCount: 0 });
  assert.equal(r.decision.verdict, "adjust");
  assert.equal(r.rngState, 1468488973);
  assert.deepEqual(cfg.builds.map((_, i) => runBuild(i, cfg).decision.verdict), ["adjust", "adjust", "continue", "cut"]);
  assert.equal(runBuild(2, cfg).rngState, 3227781955);
});

跑 node --test 'core/test/ch12_*.test.ts',五条断言全过(tests 5 / pass 5 / fail 0)。最有分量的是结论序列 ["adjust", "adjust", "continue", "cut"]:v0.1 合成手感成立但留存不足(北极星 400 千分、达成率 80%,未过但高于止损线 → 调整);v0.2 把单局调到 306 秒越过心流上界(北极星过、时长未过 → 调整);v0.3 收束节奏后四项齐过(继续);v0.4 激进改动把首局完成率砸到 220 千分(达成率 44% < 止损线 60% → 砍掉)。把「该砍了」写成一条会变红的断言,就是让止损不再靠人情。

W 层:打包并验证

P 层逻辑对了,还要在浏览器里跑通、且和逻辑层逐位一致。web/build_ch12.mjs 把 core/ 的 TS 内联进单文件 HTML(复用 web/pack.mjs 的官方擦除器),按按钮采样一轮试玩,canvas 实时画出四个目标的达成率条和本轮结论。

#!/usr/bin/env node
// file: web/build_ch12.mjs
// tier: W
// verify: node web/build_ch12.mjs
//
// 把 core/src/ch12/iteration.ts 的迭代决策内核打成单文件 HTML 交互原型:
// 按按钮采样一轮试玩,立刻看到这一版构建该 continue / adjust / cut。产物 web/dist/ch12.html。

import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const config = JSON.parse(readFileSync(join(ROOT, "config", "ch12_iteration.json"), "utf8"));
const js = bundle(ROOT, ["core/src/ch12/iteration.ts"]);

const script = `const cfg = ${inlineConfig(config)};
/* ---- core bundle: TS -> strip -> inline ---- */
${js}
/* ---- end bundle ---- */
let rng = createRng(cfg.seed, cfg), buildIndex = 0, obs = {}, decision = null;
const cv = document.getElementById("board"), g = cv.getContext("2d");
const COLORS = { continue: "#22c55e", adjust: "#eab308", cut: "#ef4444" };
function resample() { obs = sampleObservations(cfg.builds[buildIndex], cfg, rng); decision = decide(obs, cfg); draw(); }
function draw() {
  const W = cv.width, v = decision.verdict;
  g.clearRect(0, 0, W, cv.height);
  g.fillStyle = COLORS[v] || "#94a3b8"; g.font = "bold 26px system-ui"; g.fillText(v.toUpperCase(), 20, 40);
  g.fillStyle = "#e2e8f0"; g.font = "13px system-ui";
  g.fillText(cfg.builds[buildIndex].name + " · 通过 " + decision.passCount + "/" + decision.total, 20, 64);
  decision.goals.forEach(function (gv, i) {
    const y = 100 + i * 48, bx = 300, bw = W - bx - 24;
    g.fillStyle = "#e2e8f0"; g.font = "13px system-ui"; g.fillText(gv.metric + " = " + gv.value, 20, y + 8);
    g.fillStyle = "#1e293b"; g.fillRect(bx, y - 14, bw, 24);
    g.fillStyle = gv.pass ? "#22c55e" : "#ef4444";
    g.fillRect(bx, y - 14, Math.max(2, Math.floor(bw * Math.min(gv.attainmentPct, 120) / 120)), 24);
    g.fillStyle = "#0b1220"; g.font = "12px system-ui"; g.fillText(gv.attainmentPct + "%", bx + 6, y + 4);
  });
  document.getElementById("verdict").textContent = v;
  globalThis.__pebble = { buildIndex, buildId: cfg.builds[buildIndex].id, obs: Object.assign({}, obs), verdict: v,
    passCount: decision.passCount, total: decision.total, rngState: rng.state(), canvasDrawn: true, api: api };
}
const api = {
  sample() { resample(); return decision.verdict; },
  nextBuild() { buildIndex = (buildIndex + 1) % cfg.builds.length; resample(); return buildIndex; },
  reset() { rng = createRng(cfg.seed, cfg); buildIndex = 0; resample(); return 0; },
};
document.getElementById("sample").addEventListener("click", () => api.sample());
document.getElementById("next").addEventListener("click", () => api.nextBuild());
document.getElementById("reset").addEventListener("click", () => api.reset());
cv.addEventListener("click", () => api.sample());
resample();
`;

const html = page({
  title: "《合成小岛》可玩性验证原型 · ch12",
  css: "#board { height: auto; max-width: 700px; }",
  body: `  <h1>《合成小岛》· 可玩性验证原型</h1>
  <div class="sub">决策内核由 core/src/ch12/iteration.ts 提供:可播种、无 Math.random,判定全整数</div>
  <div class="card"><div class="k">本轮结论</div><div class="big" id="verdict">-</div></div>
  <div class="card"><canvas id="board" width="700" height="300"></canvas></div>
  <div class="card row">
    <button id="sample">采样一轮试玩</button>
    <button id="next">切换构建</button>
    <button id="reset">重置</button>
  </div>`,
  script,
});

const bytes = writePage(join(ROOT, "web/dist/ch12.html"), html);
console.log(JSON.stringify({ html: "web/dist/ch12.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));

web/verify_ch12.mjs 做无头验证:加载页面、点真实按钮采样、读 DOM 与采样状态、检查 canvas 真画了东西、切到 v0.4 应判 cut,再全新加载重跑一遍验确定性,最后截图存档。

#!/usr/bin/env node
// file: web/verify_ch12.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ch12.mjs web/dist/ch12.html
//
// W 层验收:Playwright 无头打开单文件 HTML,点真实按钮 + 调暴露的 api,采样 globalThis.__pebble、
// 读 DOM、检查 canvas 真画了东西、跑两遍验确定性、截图。

import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";

const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/ch12.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const errors = [];
const sample = (p) => p.evaluate(() => {
  const s = globalThis.__pebble;
  return { buildId: s.buildId, obs: s.obs, verdict: s.verdict, passCount: s.passCount, total: s.total, rngState: s.rngState };
});
const painted = (p) => p.evaluate(() => {
  const cv = document.getElementById("board");
  const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
  let n = 0;
  for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) n += 1;
  return n;
});
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const open = async () => {
  const pg = await browser.newPage({ viewport: { width: 820, height: 820 } });
  pg.on("pageerror", (e) => errors.push(String(e)));
  await pg.goto(url);
  await pg.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
  return pg;
};
// 第一遍:加载采样 → 点按钮再采样 → 切到 v0.4 → 截图
const page = await open();
const initial = await sample(page);
await page.click("#sample");
const snap = await sample(page);
const domVerdict = (await page.textContent("#verdict")).trim();
const px = await painted(page);
await page.evaluate(() => { const a = globalThis.__pebble.api; a.reset(); a.nextBuild(); a.nextBuild(); a.nextBuild(); });
const cut = await page.evaluate(() => ({ buildId: globalThis.__pebble.buildId, verdict: globalThis.__pebble.verdict }));
await page.screenshot({ path: join(ROOT, "out/shots/ch12_prototype.png"), fullPage: true });
await page.close();
// 第二遍:全新加载,重复同样两步,验确定性
const page2 = await open();
const initial2 = await sample(page2);
await page2.click("#sample");
const again = await sample(page2);
await page2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(initial.total, 4, "应有 4 项验证目标");
assert.equal(initial.buildId, "v0.1", "初始构建应为 v0.1");
assert.equal(initial.obs.firstRunCompletionPerMille, 426, "初始采样首局完成率应为 426");
assert.equal(initial.verdict, "adjust", "v0.1 初始应判 adjust");
assert.equal(initial.rngState, 1468488973, "初始 RNG 状态应为 1468488973");
assert.equal(domVerdict, snap.verdict, "DOM 结论应与采样状态一致");
assert.equal(cut.buildId, "v0.4", "三次 nextBuild 后应为 v0.4");
assert.equal(cut.verdict, "cut", "v0.4 应判 cut");
assert.ok(px > 500, `canvas 应有绘制内容,实测 ${px} px`);
assert.deepEqual(initial2, initial, "同 seed 全新加载初始状态应一致");
assert.deepEqual(again, snap, "同 seed 同样两步后状态应一致");

console.log(JSON.stringify({
  page: "web/dist/ch12.html", initialVerdict: initial.verdict, initialObs: initial.obs, initialRngState: initial.rngState,
  sampledVerdict: snap.verdict, cutBuild: cut.buildId, cutVerdict: cut.verdict, domVerdict, canvasPaintedPx: px,
  deterministic: true, screenshot: "out/shots/ch12_prototype.png",
}, null, 2));
console.log("SMOKE-W12: PASS");

先构建(node web/build_ch12.mjs,产物 8987 字节、内核 66 行),再跑验证,输出(同机相对量级,绝对数值不可移植):

{ "initialVerdict": "adjust", "initialObs": {"firstRunCompletionPerMille":426,"meanSessionSec":135,"replayPerMille":351,"softlockCount":0}, "initialRngState": 1468488973, "cutBuild": "v0.4", "cutVerdict": "cut", "canvasPaintedPx": 41954, "deterministic": true }  ·  SMOKE-W12: PASS

三个词背后的分工

看这份输出,最有价值的不是「adjust」这个结论,而是浏览器里的初始观测 426 / 135 / 351 / 0 与 rngState=1468488973 和 P 层测试里的数字逐位一致:同一份 config、同一份内核,在 Node 与 Chromium 里跑出了同一个世界;「v0.4 判 cut」是任何机器都能复现的事实;止损线 60%、心流区间 [90, 240]、北极星是谁全在 config 里——把决策规则从人脑搬进代码,是这一章与前两章合起来想说的那句话。 本章判的是「这个玩法值不值得继续做」,第 13 章才把「继续做」之后能赚多少钱算清楚。

阅读导航:上一节:可玩性指标与测试方法 · 下一节:收入拆解:DAU × ARPDAU 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制