14.2 关键决策复盘

《合成小岛》一路走来做过不少岔路口选择:引擎怎么选、变现怎么配、包体怎么裁、难度怎么排、增长靠买量还是自然、新版本全量还是灰度、运营有没有活动。这一节把七条关键决策做成一份可评分的台账,量化每条决策的预测偏差与机会成本,算出「决策质量分」,再用 ±15% 缩放做一次对照,看复盘结论稳不稳。

上一节把《合成小岛》的时间线跑成了一条可复现的曲线,这一节处理另一半——做过的那些决策,现在回看对不对。先说边界:本节不重新论证「引擎该怎么选」「变现该怎么配」这些方法本身,那些分别属于引擎选型评分 、混合变现比例 、包体预算 、难度曲线 、自然增长 与灰度放量 各章。本节只做一件事:把这些岔路口当时的选择、当时的依据、后来的结果摆在一起,量化「当时想对了没有、有没有更好的选项」。

复盘不是检讨,是把岔路口量化

大多数复盘会开成两种:一种是「复盘会 = 甩锅会」,争谁的责任;另一种是「复盘会 = 表扬会」,结论永远是「下次继续努力」。两种都缺同一个东西——可比较的量。要判断一条决策做得好不好,需要三个数:当时的预期(决定时你觉得它能带来多少)、后来的实际(它最终带来了多少)、当时的最优选项(如果选了另一条路,最多能带来多少)。有了这三个数,「决策做得好不好」就变成一次减法,而不是一次表态。

对每条决策,我们算两样东西。预测偏差 predGap = 预期 − 实际:正值说明高估了(以为能拿 120,实际只有 115),负值说明低估了(以为 120,实际 135)。机会成本 regretVsBest = 最优选项 − 实际选择:它衡量的是「当初如果选了别的,能多拿多少」——这才是复盘真正该看的东西,因为一条决策就算预测得准,也可能选错了选项。全部用千分比表示,全整数。

D 层:把决策台账写进配置

下面这份是 D 层(静态层)配置,把七条关键决策做成一份台账:每条给出决策点、备选方案及各方案对北极星的贡献、当时的选择、当时的预期与信心、以及当时的依据。所有贡献值与预期值都是示例假设,真实数值需真实业务环境,命令行不可复现(X)。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "关键决策复盘台账(D 层)。每条决策记录:决策点、备选方案及其对北极星的贡献 effectPermille、当时选择 chosen、当时预期 expectedPermille、决策时信心 confidencePermille、当时依据 rationale。chosen 的 effect 即复盘实际贡献。effectPermille 与 expectedPermille 均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。全整数(千分比),禁浮点。",
  "units": {
    "effectPermille": "该选项对北极星复合分的贡献(千分比,示例假设)",
    "expectedPermille": "决策时的预期贡献(千分比)",
    "confidencePermille": "决策时的信心(千分比)"
  },
  "decisions": [
    {
      "id": "engine", "point": "引擎选型", "chosen": "引擎+自研适配层",
      "expectedPermille": 120, "confidencePermille": 800,
      "rationale": "包体预算与平台 API 差异需要一层自己的适配,纯引擎不够、纯自研太重",
      "options": [
        { "name": "纯自研", "effectPermille": 90 },
        { "name": "现成引擎", "effectPermille": 110 },
        { "name": "引擎+自研适配层", "effectPermille": 135 }
      ]
    },
    {
      "id": "monetize", "point": "变现结构", "chosen": "混合(IAA 为主 + 小额 IAP)",
      "expectedPermille": 125, "confidencePermille": 700,
      "rationale": "轻度用户不付费但看广告,重度用户愿意去广告,两条腿走路比单腿稳",
      "options": [
        { "name": "纯 IAA", "effectPermille": 100 },
        { "name": "纯 IAP", "effectPermille": 95 },
        { "name": "混合(IAA 为主 + 小额 IAP)", "effectPermille": 130 }
      ]
    },
    {
      "id": "package", "point": "包体裁剪", "chosen": "首包精简+分包",
      "expectedPermille": 130, "confidencePermille": 850,
      "rationale": "首包只留启动必需,其余分包按需加载,换取冷启动时长",
      "options": [
        { "name": "全量首包", "effectPermille": 95 },
        { "name": "首包精简+分包", "effectPermille": 125 }
      ]
    },
    {
      "id": "difficulty", "point": "难度曲线", "chosen": "平滑心流",
      "expectedPermille": 120, "confidencePermille": 750,
      "rationale": "轻度用户对挫败极敏感,前期必须顺滑,难度只在后期抬",
      "options": [
        { "name": "线性递增", "effectPermille": 100 },
        { "name": "平滑心流", "effectPermille": 125 },
        { "name": "陡峭挑战", "effectPermille": 90 }
      ]
    },
    {
      "id": "growth", "point": "增长方式", "chosen": "自然为主",
      "expectedPermille": 118, "confidencePermille": 650,
      "rationale": "预算有限,先靠分享与平台自然流量,买量只做小规模验证",
      "options": [
        { "name": "重买量", "effectPermille": 105 },
        { "name": "自然为主", "effectPermille": 120 },
        { "name": "自然+小预算买量", "effectPermille": 130 }
      ]
    },
    {
      "id": "canary", "point": "放量方式", "chosen": "灰度放量",
      "expectedPermille": 118, "confidencePermille": 900,
      "rationale": "新版本先给小流量观察指标,没问题再逐档放大,出事可回滚",
      "options": [
        { "name": "全量上线", "effectPermille": 100 },
        { "name": "灰度放量", "effectPermille": 120 }
      ]
    },
    {
      "id": "ops", "point": "运营节奏", "chosen": "节点活动",
      "expectedPermille": 120, "confidencePermille": 600,
      "rationale": "先把节假日节点活动跑通,赛季结构留到有产能时再上",
      "options": [
        { "name": "无活动", "effectPermille": 90 },
        { "name": "节点活动", "effectPermille": 115 },
        { "name": "赛季+节点", "effectPermille": 130 }
      ]
    }
  ]
}

每条决策里,chosen 那个选项的 effectPermille 就是复盘实际值——比如引擎选型选了「引擎+自研适配层」,它的实际贡献是 135。expectedPermille 是当时预期(120),confidencePermille 是当时的信心(800‰,说明当时也没底)。其余选项给出「如果当初选它」的贡献,用来算机会成本。

S 层:预测偏差与机会成本

下面这段是 S 层脚本,读上面这份台账,对每条决策算预测偏差、机会成本与校准率,再汇总成「决策质量分」;最后做一次对照——把所有实际效果按 ±15% 缩放,看「最大机会成本的决策」排名是否稳定。

#!/usr/bin/env python3
# file: sim/l14_decisions.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l14_decisions.py --config config/ --out out/
"""关键决策复盘评分:读 config/l14_decisions.json。

每条决策有若干备选方案,各带对北极星的贡献 effectPermille;chosen 的贡献即复盘实际值。
对每条决策算三样东西:
- 预测偏差 predGap = expected - chosenEffect(正=高估,负=低估);
- 机会成本 regretVsBest = bestEffect - chosenEffect(当初若选最优能多拿多少);
- 校准率 alignment = 1000 - |predGap|*1000/chosenEffect。
汇总得到「决策质量分」= 1000 - ΣregretVsBest*1000/ΣbestEffect。
再做一次对照:把每条决策的 chosen 效果按 ±15% 缩放,看「最大机会成本决策」排名是否稳定。
产出 JSON + CSV + SVG。effect/expected 均为示例假设值,真实数值需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load(d: str) -> dict:
    with open(os.path.join(d, "l14_decisions.json"), encoding="utf-8") as f:
        return json.load(f)


def evaluate(dec: dict) -> dict:
    opts = dec["options"]
    chosen = next(o for o in opts if o["name"] == dec["chosen"])
    actual = chosen["effectPermille"]
    best = max(o["effectPermille"] for o in opts)
    best_name = max(opts, key=lambda o: o["effectPermille"])["name"]
    pred_gap = dec["expectedPermille"] - actual
    regret = best - actual
    alignment = 1000 - abs(pred_gap) * 1000 // actual if actual > 0 else 0
    return {"id": dec["id"], "point": dec["point"], "chosen": dec["chosen"],
            "chosenEffect": actual, "bestOption": best_name, "bestEffect": best,
            "expected": dec["expectedPermille"], "predGap": pred_gap, "regretVsBest": regret,
            "alignmentPermille": alignment, "confidencePermille": dec["confidencePermille"]}


def rank_regret(rows: list, scale_permille: int) -> list:
    """按 chosen 效果缩放 scale_permille‰ 后,机会成本从大到小排序的决策 id。"""
    scored = []
    for r in rows:
        actual = r["chosenEffect"] * scale_permille // 1000
        best = r["bestEffect"] * scale_permille // 1000
        scored.append((r["id"], best - actual))
    scored.sort(key=lambda kv: (-kv[1], kv[0]))
    return [i for i, _ in scored]


def draw_svg(path: str, rows: list) -> int:
    W, PAD, ROW = 720, 240, 40
    H = 60 + ROW * len(rows)
    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 = \
        "关键决策:预期 vs 复盘实际(千分比,示例假设)"
    maxv = max(max(r["expected"], r["chosenEffect"], r["bestEffect"]) for r in rows) or 1
    scale = (W - PAD - 120) // maxv
    for i, r in enumerate(rows):
        y = 46 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
                                    "fill": "#374151"}).text = r["point"]
        # 预期条(浅)与 实际条(深)叠放
        we = r["expected"] * scale
        wa = r["chosenEffect"] * scale
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(max(we, 2)),
                                    "height": "10", "rx": "2", "fill": "#93c5fd"})
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 18), "width": str(max(wa, 2)),
                                    "height": "10", "rx": "2", "fill": "#2563eb"})
        ET.SubElement(svg, "text", {"x": str(PAD + max(wa, we) + 8), "y": str(y + 22),
                                    "font-size": "11", "fill": "#6b7280"}).text = \
            f"预期 {r['expected']} / 实际 {r['chosenEffect']}"
    ET.SubElement(svg, "text", {"x": str(PAD), "y": str(H - 8), "font-size": "12",
                                "fill": "#374151"}).text = "上条=当时预期,下条=复盘实际"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    rows = [evaluate(d) for d in cfg["decisions"]]
    total_actual = sum(r["chosenEffect"] for r in rows)
    total_best = sum(r["bestEffect"] for r in rows)
    total_regret = total_best - total_actual
    total_abs_gap = sum(abs(r["predGap"]) for r in rows)
    quality = 1000 - total_regret * 1000 // total_best
    pred_bias = total_abs_gap * 1000 // total_actual

    by_regret = sorted(rows, key=lambda r: (-r["regretVsBest"], r["id"]))
    worst = by_regret[0]
    # 对照:缩放 ±15%,看最大机会成本决策是否仍是同一个
    scale_ranks = {s: rank_regret(rows, s) for s in (850, 1000, 1150)}
    stable = all(v[0] == worst["id"] for v in scale_ranks.values())

    os.makedirs(a.out, exist_ok=True)
    sb = draw_svg(os.path.join(a.out, "l14_decisions.svg"), rows)
    with open(os.path.join(a.out, "l14_decisions.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "point", "chosen", "expected", "chosenEffect", "bestOption",
                    "bestEffect", "predGap", "regretVsBest", "alignmentPermille", "confidencePermille"])
        for r in rows:
            w.writerow([r["id"], r["point"], r["chosen"], r["expected"], r["chosenEffect"],
                        r["bestOption"], r["bestEffect"], r["predGap"], r["regretVsBest"],
                        r["alignmentPermille"], r["confidencePermille"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "decisionCount": len(rows), "totalActualPermille": total_actual,
           "totalBestPermille": total_best, "totalRegretPermille": total_regret,
           "qualityScore": quality, "predictionBiasPermille": pred_bias,
           "worstRegretDecision": worst["id"], "worstRegretPoint": worst["point"],
           "worstRegretPermille": worst["regretVsBest"],
           "regretRankingStableAcrossScale": stable,
           "regretRankingByScale": scale_ranks,
           "perDecision": rows, "svgBytes": sb}
    with open(os.path.join(a.out, "l14_decisions.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"qualityScore": quality, "totalActual": total_actual, "totalBest": total_best,
                      "totalRegret": total_regret, "predictionBiasPermille": pred_bias,
                      "worst": worst["id"], "worstRegret": worst["regretVsBest"],
                      "stableAcrossScale": stable,
                      "ranking": [r["id"] for r in by_regret]}, ensure_ascii=False))
    return 0


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

在本机跑它,输出如下(同机相对量级,绝对数值不可移植):

{"qualityScore": 973, "totalActual": 870, "totalBest": 895, "totalRegret": 25, "predictionBiasPermille": 44, "worst": "ops", "worstRegret": 15, "stableAcrossScale": true, "ranking": ["ops", "growth", "canary", "difficulty", "engine", "monetize", "package"]}

七条决策的实际贡献合计 870‰,如果每条都选最优能到 895‰,机会成本合计 25‰,于是决策质量分 = 973‰——也就是说,这七条岔路口我们拿下了九成七,剩下 2.7% 丢在「当初可以选得更好」上。预测偏差率是 44‰(平均每条决策的预期和实际差了约 5.6‰),说明当时的判断相当准,没有系统性高估也没有系统性低估。

三个真正该记住的结论

第一,决策质量 ≠ 预测准确。七条决策里,预测偏差最大的是引擎选型(预期 120、实际 135,低估了 15),但它没有机会成本——因为当时就选了最优的那个选项(135)。反过来,运营节奏的预测偏差只有 5(预期 120、实际 115),看着挺准,但它机会成本最大(15‰):因为当时只上了节点活动(115),而「赛季+节点」(130)才是最优解。预测准不准,和选得对不对,是两码事——复盘要看后者。

第二,机会成本集中在一两条决策上。按机会成本排序是 ops(15) > growth(10) > 其余全为 0。也就是说,25‰ 的机会成本里,运营节奏占 15、增长方式占 10,其余五条一分没丢。这个结论很实用:下次资源紧张时,最该补的短板是运营节奏(把赛季结构补上)和增长方式(在自然增长上叠小预算买量),而不是去动那些已经做对的决策。

第三,结论稳不稳要看对照。把七条决策的实际效果统一按 850‰ / 1000‰ / 1150‰ 三档缩放,最大机会成本决策始终是 ops(regretRankingStableAcrossScale 为 true)。这说明「运营节奏是最大短板」不是被某一组具体数字凑出来的,换个量级结论不变——这正是复盘结论该有的稳健性。反过来,如果换个缩放结论就翻盘,那这条结论就不该写进复盘纪要。

小结与下一步

这一节把七条关键决策做成了一份可评分的台账:每条算预测偏差与机会成本,汇总出决策质量分 973‰,再用 ±15% 缩放确认结论稳定。三件事记住就够——预测准不代表选得对,复盘要看机会成本;机会成本往往集中在少数几条决策上;任何复盘结论都要做一次量级对照,稳了才写进纪要。

到这里,「怎么做的」和「做得对不对」都盘完了,还剩最后一环——做完之后,数据到底动了没有。下一节把上线后的留存、ARPDAU 逐周铺开,看每次迭代到底拉动了哪条曲线、每投入一天的产出是多少。

阅读导航:上一节:14.1 立项到上线的时间线 · 下一节:14.3 数据表现与迭代 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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