14.3 里程碑与风险缓冲

把静态的工时与成本排成一条带缓冲的时间线:给每个里程碑一个乐观/悲观人天区间,用可播种的整数 LCG 抽样算出各里程碑最晚完成时间与缓冲耗尽概率,再用 B 层扫描缓冲比例做敏感性分析,讲清 20% 缓冲为什么不够、以及该配多少才压得住超期风险。

先把边界交代清楚。站内那组游戏引擎平台(24 篇) 讲的是平台型产品的研发组织与工程排期——它排的是「平台什么时候发下一个大版本」这种面向多用户、多团队的工程节奏;本章是单款小游戏的人力工时、外包成本与缓冲估算——它排的是「做《合成小岛》这一款,每个里程碑什么时候必须收口、要留多少缓冲才不至于全线延期」。前者的里程碑服务于平台迭代,后者服务于单款交付,风险来源和缓冲口径都不一样。

前两节把账算成了静态的总额:180 人天、234,000 元人力(示例假设),加 26,000 元素材现金、8 人天内制工时。但真实项目不是一次性花完这些——它们被分段消耗,每一段(里程碑)都可能超期,而早期超期会一路顶穿后面的所有节点。这一节把静态的账排成一条带缓冲的时间线,并回答一个很硬的问题:缓冲到底该留多少?

一、为什么排期必须带缓冲

上一节算出的 45 个工作日工期,是「六种角色完全并行、零等待、零返工」的下界。真实项目里,估算是会飘的——每个任务「大概几天」的估计,天然带着不确定性。如果不给这份不确定性留余地,排期就成了一句空头承诺。

缓冲(buffer)不是「偷懒的借口」,而是为不确定性预留的时间预算。它有两种加法:

  • 全局缓冲:在总工期末尾加一截。简单,但风险发生时你才知道是哪一段超的,已经来不及救。
  • 里程碑缓冲:在每个里程碑节点上留缓冲,超期在当段就被发现,可以当段调整。

本节用的是里程碑缓冲:每个里程碑都算出「最晚完成时间」,真实进度一旦逼近它,就是一个可观测的预警信号。

二、里程碑与三点估算的简化版

标准的三点估算(PERT)给每个任务三个值:乐观、最可能、悲观。本节把它简化成两个值:乐观人天与悲观人天,最可能值取两者的中点。这份简化够用,因为我们关心的是区间宽度(不确定性有多大),而不是精确的众数。

下面这份是 D 层配置,它给出五个里程碑的乐观/悲观区间、缓冲比例参数、抽样次数与随机种子。

{
  "version": 1,
  "game": "merge-island",
  "note": "里程碑与风险缓冲模型(S 层)。每个里程碑给出乐观/悲观人天的整数区间 [bestDays, worstDays];bufferPct 是缓冲比例参数。用可播种 LCG 在区间上均匀抽样 trials 次,统计「任一里程碑超出其最晚完成时间」的比例(万分比 permyriad)= 缓冲耗尽概率。seed 固定,回放确定。",
  "seed": 20261012,
  "trials": 20000,
  "bufferPct": 20,
  "benchBufferPct": [0, 10, 20, 30, 40],
  "milestones": [
    { "id": "m1", "name": "可玩原型",     "bestDays": 12, "worstDays": 24 },
    { "id": "m2", "name": "核心循环闭环", "bestDays": 20, "worstDays": 40 },
    { "id": "m3", "name": "内容与关卡",   "bestDays": 25, "worstDays": 55 },
    { "id": "m4", "name": "变现接入",     "bestDays": 10, "worstDays": 22 },
    { "id": "m5", "name": "测试与提审",   "bestDays": 15, "worstDays": 30 }
  ]
}

几个设计点。区间用整数人天,worstDays 普遍是 bestDays 的两倍左右——这是「估算经常翻倍」的经验抽象,属于示例假设,不是从真实项目数据标定出来的。seed 固定:随机数用可播种的整数 LCG,同一份配置回放出的结果逐位一致,这是小游戏工程「确定性回放」要求的延伸——排期模型也必须可复现,否则没人信。trials = 20000 是抽样次数,越大越稳。

三、S 层:算最晚完成时间与缓冲耗尽概率

有了区间和缓冲比例,就能算两个核心量:每个里程碑的最晚完成时间(累计期望 × 缓冲系数)和缓冲耗尽概率(真实工期超过最晚完成时间的比例)。下面这段是 S 层仿真脚本,全程整数、可播种、产 JSON 与 CSV。

#!/usr/bin/env python3
# file: sim/ch14_milestone.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch14_milestone.py --config config/ --out out/
"""里程碑与风险缓冲模型:把缓冲比例当参数,算出各里程碑最晚完成时间与缓冲耗尽概率。

整数化口径(全程整数地板除,无浮点):
  expectedDays   = (bestDays + worstDays) // 2
  cumulativeExp  = 前 i 个里程碑的 expectedDays 之和
  deadlineDays   = cumulativeExp * (100 + bufferPct) // 100   # 各里程碑最晚完成时间
  bufferDays     = deadlineDays - cumulativeExp
每个里程碑的真实工期在 [bestDays, worstDays] 上均匀抽样(可播种 LCG,回放确定);
一次试验中只要任一里程碑的累计实际工期 > 它的 deadlineDays,就记一次「缓冲耗尽」。
  缓冲耗尽概率 = 耗尽次数 * 10000 // trials   (万分比 permyriad)
产出:out/ch14_milestone.json + out/ch14_milestone.csv
"""

from __future__ import annotations

import argparse
import csv
import json
import os


def load(cfg_dir):
    with open(os.path.join(cfg_dir, "ch14_milestone.json"), encoding="utf-8") as f:
        return json.load(f)


def make_rng(seed):
    """可播种整数 LCG:给定 seed,序列完全确定,用于确定性回放。"""
    state = [seed & 0x7FFFFFFF]

    def nxt(mod):
        state[0] = (state[0] * 1103515245 + 12345) & 0x7FFFFFFF
        return state[0] % mod

    return nxt


def plan(cfg, buffer_pct):
    """给定缓冲比例,算出各里程碑的累计期望工期与最晚完成时间。"""
    ms = cfg["milestones"]
    cum_exp, deadlines, buffers = [], [], []
    acc = 0
    for m in ms:
        acc += (m["bestDays"] + m["worstDays"]) // 2
        cum_exp.append(acc)
        dl = acc * (100 + buffer_pct) // 100
        deadlines.append(dl)
        buffers.append(dl - acc)
    return cum_exp, deadlines, buffers


def exhaust_permyriad(cfg, buffer_pct):
    """蒙特卡洛:返回 (任一里程碑耗尽万分比, 各里程碑耗尽万分比列表)。"""
    ms = cfg["milestones"]
    _, deadlines, _ = plan(cfg, buffer_pct)
    rng = make_rng(cfg["seed"])
    trials = cfg["trials"]
    miss = [0] * len(ms)
    any_miss = 0
    for _ in range(trials):
        acc = 0
        hit = False
        for i, m in enumerate(ms):
            span = m["worstDays"] - m["bestDays"] + 1
            acc += m["bestDays"] + rng(span)
            if acc > deadlines[i]:
                miss[i] += 1
                hit = True
        if hit:
            any_miss += 1
    any_pm = any_miss * 10000 // trials
    per_ms = [c * 10000 // trials for c in miss]
    return any_pm, per_ms


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    buffer_pct = cfg["bufferPct"]
    cum_exp, deadlines, buffers = plan(cfg, buffer_pct)
    any_pm, per_ms = exhaust_permyriad(cfg, buffer_pct)

    milestones = []
    for i, m in enumerate(cfg["milestones"]):
        milestones.append({
            "id": m["id"], "name": m["name"],
            "bestDays": m["bestDays"], "worstDays": m["worstDays"],
            "expectedDays": (m["bestDays"] + m["worstDays"]) // 2,
            "cumulativeExpected": cum_exp[i],
            "deadlineDays": deadlines[i],
            "bufferDays": buffers[i],
            "missPermyriad": per_ms[i],
        })

    os.makedirs(args.out, exist_ok=True)
    report = {
        "version": cfg["version"],
        "game": cfg["game"],
        "seed": cfg["seed"],
        "trials": cfg["trials"],
        "bufferPct": buffer_pct,
        "totalExpectedDays": cum_exp[-1],
        "totalDeadlineDays": deadlines[-1],
        "totalBufferDays": buffers[-1],
        "exhaustPermyriad": any_pm,
        "milestones": milestones,
    }
    with open(os.path.join(args.out, "ch14_milestone.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch14_milestone.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "bestDays", "worstDays", "expectedDays",
                    "cumulativeExpected", "deadlineDays", "bufferDays", "missPermyriad"])
        for m in milestones:
            w.writerow([m["id"], m["name"], m["bestDays"], m["worstDays"], m["expectedDays"],
                        m["cumulativeExpected"], m["deadlineDays"], m["bufferDays"], m["missPermyriad"]])

    print(json.dumps({"bufferPct": buffer_pct, "totalExpectedDays": cum_exp[-1],
                      "totalDeadlineDays": deadlines[-1], "totalBufferDays": buffers[-1],
                      "exhaustPermyriad": any_pm,
                      "perMilestonePermyriad": per_ms}, ensure_ascii=False))
    return 0


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

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"bufferPct": 20, "totalExpectedDays": 126, "totalDeadlineDays": 151, "totalBufferDays": 25, "exhaustPermyriad": 2960, "perMilestonePermyriad": [2303, 1047, 624, 419, 231]}

四、读表:20% 缓冲其实不够

把输出翻译成人话:五个里程碑的期望工期合计 126 人天;按 20% 缓冲,最晚完成时间是第 151 天(总缓冲 25 天);但在这个假设下,缓冲耗尽概率高达 2960‱,也就是 29.6%——将近三成的概率,至少有一个里程碑会顶穿它的最晚完成时间。

逐里程碑看(out/ch14_milestone.csv):

里程碑乐观悲观期望累计期望最晚完成缓冲耗尽概率
可玩原型1224181821323.0%
核心循环闭环2040304857910.5%
内容与关卡25554088105176.2%
变现接入102216104124204.2%
测试与提审153022126151252.3%

这里藏着一个非常反直觉、但极其重要的结论:耗尽概率最高的不是最后那个大里程碑,而是最前面的「可玩原型」(23.0%)。原因不难想:缓冲比例是统一的 20%,而「可玩原型」的区间是 [12, 24],期望 18、最晚完成 21——一个均匀分布在 [12, 24] 上的变量,超过 21 的概率就是 3 ÷ 13 ≈ 23%。短里程碑的相对波动更大,统一比例对它反而不够用。

这直接推翻了一个流行做法:「总工期加 20% 缓冲就万事大吉」。统一缓冲比例对早段里程碑是偏薄的,而早段超期又会顺延到后面所有节点。要压住整体风险,缓冲要么分段给足,要么整体抬高比例——后者正是下一节 B 层要量化的。

五、B 层:缓冲比例敏感性

「20% 不够」是个定性判断,B 层把它量化:缓冲比例每加 10 个点,耗尽概率降多少? 下面这段是 B 层基准脚本,把缓冲比例扫过 [0, 10, 20, 30, 40],对每个值重跑蒙特卡洛。B 层是软告警层:当前配置的耗尽概率超阈值只打 WARN,不判失败。

#!/usr/bin/env python3
# file: sim/ch14_buffer_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch14_buffer_bench.py --config config/ --out out/
"""B 基准:缓冲比例敏感性。

把 config/ch14_milestone.json 里的 bufferPct 扫过 benchBufferPct 一列取值,
对每个比例重跑里程碑蒙特卡洛,看「缓冲耗尽概率」随缓冲比例怎么变。
软告警:当前配置的 bufferPct 下耗尽概率超过阈值只打 WARN,不返回非 0。
产出:out/ch14_buffer_bench.json
"""

from __future__ import annotations

import argparse
import json
import os

from ch14_milestone import exhaust_permyriad, load, plan

WARN_EXHAUST_PERMYRIAD = 1000  # 耗尽概率超过 10%(1000‱)就打软告警


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    base_pct = cfg["bufferPct"]
    rows = []
    for pct in cfg["benchBufferPct"]:
        any_pm, _ = exhaust_permyriad(cfg, pct)
        _, deadlines, buffers = plan(cfg, pct)
        rows.append({"bufferPct": pct, "totalDeadlineDays": deadlines[-1],
                     "totalBufferDays": buffers[-1], "exhaustPermyriad": any_pm})

    base_row = next(r for r in rows if r["bufferPct"] == base_pct)
    within = base_row["exhaustPermyriad"] <= WARN_EXHAUST_PERMYRIAD

    os.makedirs(args.out, exist_ok=True)
    report = {
        "version": cfg["version"],
        "seed": cfg["seed"],
        "trials": cfg["trials"],
        "baseBufferPct": base_pct,
        "baseExhaustPermyriad": base_row["exhaustPermyriad"],
        "cases": rows,
        "warnThresholdPermyriad": WARN_EXHAUST_PERMYRIAD,
        "withinTolerance": within,
    }
    with open(os.path.join(args.out, "ch14_buffer_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    print(json.dumps({"baseBufferPct": base_pct, "baseExhaustPermyriad": base_row["exhaustPermyriad"],
                      "cases": rows, "withinTolerance": within}, ensure_ascii=False))
    if not within:
        print(f"WARN 缓冲 {base_pct}% 下耗尽概率 {base_row['exhaustPermyriad']}‱ > {WARN_EXHAUST_PERMYRIAD}‱")
    return 0  # B 层软告警:恒返回 0


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

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"baseBufferPct": 20, "baseExhaustPermyriad": 2960, "cases": [{"bufferPct": 0, "totalDeadlineDays": 126, "totalBufferDays": 0, "exhaustPermyriad": 7761}, {"bufferPct": 10, "totalDeadlineDays": 138, "totalBufferDays": 12, "exhaustPermyriad": 5723}, {"bufferPct": 20, "totalDeadlineDays": 151, "totalBufferDays": 25, "exhaustPermyriad": 2960}, {"bufferPct": 30, "totalDeadlineDays": 163, "totalBufferDays": 37, "exhaustPermyriad": 807}, {"bufferPct": 40, "totalDeadlineDays": 176, "totalBufferDays": 50, "exhaustPermyriad": 0}], "withinTolerance": false}
WARN 缓冲 20% 下耗尽概率 2960‱ > 1000‱

扫出来的曲线很干净:

缓冲比例最晚完成缓冲天数耗尽概率
0%126077.6%
10%1381257.2%
20%1512529.6%
30%163378.1%
40%176500.0%

(40% 那一行的 0.0% 是万分比取整的结果,代表「在 20000 次抽样里没抽到一次耗尽」,不等于数学上的绝对零风险。)

三条结论:

  1. 缓冲的收益是递减的。 从 0% 到 10%,耗尽概率从 77.6% 降到 57.2%,只降 20 个点;从 20% 到 30%,从 29.6% 降到 8.1%,降了 21 个点。在 20% 附近每加一个点缓冲,换来的风险下降比在 0% 附近更大——因为曲线在这里最陡。
  2. 配置里的 20% 确实不够。 它对应的 29.6% 远超 B 层 10% 的告警线,脚本因此打出 WARN。这是一个真实被触发的软告警,不是摆设。
  3. 要压到 10% 以下,需要约 30% 缓冲。 30% 对应 8.1%,第一次落到告警线以下。也就是说,本假设下「可接受的缓冲」大约是三成,而不是常见直觉里的两成。

六、结论:该配多少缓冲

把这一节的结论收成一条可执行的建议:在本节的示例假设下,把缓冲从 20% 提到 30%——最晚完成时间从第 151 天推到第 163 天(多 12 天缓冲),换来耗尽概率从 29.6% 降到 8.1%。多花的 12 天,买的是「三成变八成」的按期交付概率。

但必须再强调一次口径:这条建议依赖「工期在区间上均匀分布」这个简化假设。真实的工期分布需要真实项目的历史数据标定,属于「需真实业务环境,命令行不可复现」(X)。本节能确定的是方法(怎么算最晚完成时间与耗尽概率)和相对关系(缓冲越多、耗尽概率越低,且在 20% 附近收益最陡)——这两个结论换一组假设仍然成立,只是具体数字会漂。

七、落到设计上的规则

  1. 排期必须带缓冲,缓冲要挂在里程碑上。 全局缓冲让你「事后才知道超了」,里程碑缓冲让你「当段就能救」。早段超期会顶穿后面所有节点,所以要盯住最前面的里程碑。
  2. 别用统一的缓冲比例糊弄所有里程碑。 短里程碑的相对波动更大,统一比例对它偏薄。「可玩原型」23.0% 的耗尽概率就是证据——它比最后一个大里程碑更危险。
  3. 用 B 层量化,而不是靠直觉拍缓冲。 「加 20% 就够」是直觉,「20% 对应 29.6%、30% 才降到 8.1%」才是决策依据。敏感性扫描把「该留多少」从争论变成了计算。

到这里,一卷的成本侧就闭合了:14.1 算人、14.2 算物、14.3 把人财物排成带缓冲的时间线。三张表——人力表、素材表、里程碑表——加上第 13 章的收入预测,构成了一整套立项决策的数字底座。下一章(第 15 章)把这些数字装进评审材料,交付出去。

阅读导航:上一节:外包与素材成本 · 下一节:立项评审材料与流程 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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