7.1 版本列车与赛季结构

版本列车决定 SLG 的内容供给节奏。本节把赛季结构、里程碑与依赖写成可校验的配置,用 bastion 脚本逐日扫描团队负载,一次抓出依赖倒置、里程碑间隔不足与资源冲突三类排期事故,并用《王朝纪元》的四车列车跑出可复现的真实报告与对应修复动作。

7.1 版本列车与赛季结构

SLG 是长线运营品,内容供给一旦断档,活跃与付费会同步下滑。产业侧那篇 SLG 经济系统运营 2026 清单 给的是「该做哪些事」的检查项——产消盘点、活动排期、风控预案都在里面。本节不重复那份清单,只把其中排期这一栏做成可跑的东西:把版本写成配置,用脚本逐日扫描,自动抓出依赖倒置、里程碑间隔不足、团队资源冲突三类排期事故。赛季的数值膨胀与货币回收留给本卷 13.3,这里只谈版本层面的编排。

列车不等人:日期驱动而非版本号驱动

大多数团队早期是「版本号驱动」:做完什么发什么,1.2 攒够了就发 1.3。这套在小规模时能用,一旦进入赛季制就崩了——赛季有固定的开赛日,玩家的期待、买量投放、联赛排期都锚在那一天上,发版日期不能因为功能没做完就往后拖。

于是换成「日期驱动」的版本列车:列车按固定班次发车,到点就走。没做完的内容不上车,等下一班。这条纪律的代价是内容可能被砍,收益是节奏可预期、跨部门能对齐。列车有两个尺度:

  • 内容车:每 cadenceDays(《王朝纪元》取 28 天)一班,装玩法迭代、活动、平衡调整。
  • 赛季车:每 seasonLengthDays(取 84 天)一班,装地图扩边、赛季规则、结算流程。赛季车比内容车重,占用的团队槽位更多。

本卷 3.2 已经把「日常—周常—赛季」三级节奏讲成了玩家侧的时间轴;这里把同一条轴翻到团队侧:玩家看到的赛季边界,在团队侧就是一条不可移动的 launch 里程碑。

六个里程碑与依赖规则

一个版本从立项到上线,拆成六个里程碑。它们既是对玩家不可见的内部节点,也是排期校验器的输入:

里程碑含义关键约束
kickoff立项,需求进池必须晚于上一版本的 featureLock
designLock设计封版距 kickoff 有最小间隔
featureLock功能封版,停止加需求距 designLock 有最小间隔
contentFreeze内容封版,美术/文案冻结距 featureLock 有最小间隔
launch上线(含灰度窗口)距 contentFreeze 有最小间隔

依赖规则只有一条主规则:kickoffAfterFeatureLock——后一版本的 kickoff 必须不早于前一版本的 featureLock。理由很直接:上一版本需求没冻结,就并行开下一版本,团队会在两版之间反复横跳,谁都做不完。这跟本卷 4.1 讲的系统解锁链是同一套「前置未满足则后置不可达」的逻辑,只不过作用在时间轴上。

里程碑间隔是软下限:designLock 到 featureLock 至少 5 天,否则设计还没落地就催功能;contentFreeze 到 launch 至少 4 天,否则美术资源来不及进包和过审。这些下限写进配置的 minGapDays,低于下限就报违规。

把排期写成配置

排期不是甘特图截图,而是一份数据。下面这段是 D 层配置节选(完整文件 config/ch07_train.json),描述一列 4 车的列车:三个内容车 V3.1/V3.2/V4.1 加一个赛季车 V3.3,每车给出里程碑日与占用的团队槽位。

<!-- snippet: config/ch07_train.json(节选) -->
{
  "train": {
    "cadenceDays": 28,
    "seasonLengthDays": 84,
    "minGapDays": { "kickoffToDesignLock": 5, "designLockToFeatureLock": 5,
                    "featureLockToContentFreeze": 2, "contentFreezeToLaunch": 4 }
  },
  "teamCapacity": { "design": 3, "art": 4, "client": 3, "server": 3, "qa": 3 },
  "versions": [
    { "id": "V3.3", "kind": "season", "season": 3,
      "milestones": { "kickoff": 42, "designLock": 56, "featureLock": 63, "contentFreeze": 70, "launch": 84 },
      "teamLoad": { "design": 2, "art": 2, "client": 2, "server": 2, "qa": 2 } }
  ],
  "dependencies": [
    { "from": "V3.2", "to": "V3.3", "rule": "kickoffAfterFeatureLock", "minDays": 0 }
  ]
}

teamLoad 是关键设计:它声明某版本在其活跃窗口 [kickoff, launch) 内并行占用几个团队槽位。资源冲突于是变成一个可以逐日累加的整数问题,而不需要人工比对甘特图。

校验器:依赖、间隔、冲突一次抓出

下面这段是 S 层仿真脚本(完整文件 sim/ch07_train.py),读上面的配置,跑三类校验并产出 JSON、CSV 与 SVG。它全程整数运算(天为整数),因此可以和 TS 镜像 proto/src/ch07_train.ts 逐位对照。

# file: sim/ch07_train.py
# tier: S
# verify: python3 sim/ch07_train.py --config config/
"""版本列车校验:依赖顺序、里程碑间隔、团队资源冲突。读 config/ch07_train.json。

全程整数运算(天为整数),可与 proto/src/ch07_train.ts 逐位对照。
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "out")

TEAMS = ["design", "art", "client", "server", "qa"]
GAP_RULES = [
    ("kickoffToDesignLock", "kickoff", "designLock"),
    ("designLockToFeatureLock", "designLock", "featureLock"),
    ("featureLockToContentFreeze", "featureLock", "contentFreeze"),
    ("contentFreezeToLaunch", "contentFreeze", "launch"),
]


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


def apply_overrides(cfg: dict, team_overrides: list[str], pull_ins: list[str]) -> dict:
    """CLI 覆盖:--team art=3 降容量;--pull-in V3.3=7 把某版本整体前移。"""
    cfg = json.loads(json.dumps(cfg))
    for spec in team_overrides:
        name, _, val = spec.partition("=")
        cfg["teamCapacity"][name] = int(val)
    for spec in pull_ins:
        vid, _, val = spec.partition("=")
        shift = int(val)
        for v in cfg["versions"]:
            if v["id"] == vid:
                for m in list(v["milestones"]):
                    v["milestones"][m] -= shift
    return cfg


def check_dependencies(cfg: dict) -> list[dict]:
    by_id = {v["id"]: v for v in cfg["versions"]}
    out = []
    for dep in cfg["dependencies"]:
        a, b = by_id[dep["from"]], by_id[dep["to"]]
        if dep["rule"] == "kickoffAfterFeatureLock":
            need = a["milestones"]["featureLock"] + dep.get("minDays", 0)
            got = b["milestones"]["kickoff"]
            if got < need:
                out.append({
                    "from": dep["from"], "to": dep["to"], "rule": dep["rule"],
                    "required": need, "actual": got, "shortByDays": need - got,
                })
    return out


def check_gaps(cfg: dict) -> list[dict]:
    out = []
    for v in cfg["versions"]:
        for key, a, b in GAP_RULES:
            need = cfg["train"]["minGapDays"][key]
            got = v["milestones"][b] - v["milestones"][a]
            if got < need:
                out.append({"version": v["id"], "gap": key, "required": need, "actual": got})
    return out


def check_conflicts(cfg: dict):
    """逐日扫描团队负载,超过容量即为冲突;返回 (每日矩阵, 冲突明细, 归并区间)。"""
    caps = cfg["teamCapacity"]
    lo = min(v["milestones"]["kickoff"] for v in cfg["versions"])
    hi = max(v["milestones"]["launch"] for v in cfg["versions"])
    rows, conflicts = [], []
    for day in range(lo, hi):
        for team in TEAMS:
            load = 0
            for v in cfg["versions"]:
                if v["milestones"]["kickoff"] <= day < v["milestones"]["launch"]:
                    load += v["teamLoad"].get(team, 0)
            cap = caps.get(team, 0)
            over = 1 if load > cap else 0
            rows.append({"day": day, "team": team, "load": load, "capacity": cap, "over": over})
            if over:
                conflicts.append({"day": day, "team": team, "load": load, "capacity": cap})
    ranges = []
    for c in conflicts:
        if ranges and ranges[-1]["team"] == c["team"] and ranges[-1]["toDay"] == c["day"] - 1:
            ranges[-1]["toDay"] = c["day"]
            ranges[-1]["peakLoad"] = max(ranges[-1]["peakLoad"], c["load"])
        else:
            ranges.append({"team": c["team"], "fromDay": c["day"], "toDay": c["day"],
                           "peakLoad": c["load"], "capacity": c["capacity"]})
    return rows, conflicts, ranges


def validate(cfg: dict) -> dict:
    deps = check_dependencies(cfg)
    gaps = check_gaps(cfg)
    rows, conflicts, ranges = check_conflicts(cfg)
    makespan = max(v["milestones"]["launch"] for v in cfg["versions"]) - \
        min(v["milestones"]["kickoff"] for v in cfg["versions"])
    return {
        "verdict": "OK" if not (deps or gaps or conflicts) else "FAIL",
        "versions": len(cfg["versions"]),
        "makespanDays": makespan,
        "dependencyViolations": deps,
        "gapViolations": gaps,
        "conflictDays": len(conflicts),
        "conflictRanges": ranges,
        "loadMatrix": rows,
    }


def write_csv(rows: list[dict], path: str) -> None:
    with open(path, "w", encoding="utf-8") as f:
        f.write("day,team,load,capacity,over\n")
        for r in rows:
            f.write(f"{r['day']},{r['team']},{r['load']},{r['capacity']},{r['over']}\n")


def write_svg(cfg: dict, ranges: list[dict], path: str) -> None:
    W, H = 760, 300
    PAD_L, PAD_R, PAD_T, PAD_B = 70, 20, 24, 40
    lo = min(v["milestones"]["kickoff"] for v in cfg["versions"])
    hi = max(v["milestones"]["launch"] for v in cfg["versions"])
    span = hi - lo
    inner_w = W - PAD_L - PAD_R
    row_h = (H - PAD_T - PAD_B) / max(len(cfg["versions"]), 1)

    def x_of(day: int) -> float:
        return PAD_L + inner_w * (day - lo) / span

    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
                             "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#ffffff"})
    # 冲突竖带
    for rng in ranges:
        x0, x1 = x_of(rng["fromDay"]), x_of(rng["toDay"] + 1)
        ET.SubElement(svg, "rect", {"x": f"{x0:.1f}", "y": str(PAD_T),
                                    "width": f"{max(x1 - x0, 1):.1f}", "height": f"{H - PAD_T - PAD_B:.1f}",
                                    "fill": "#ffd7d7", "opacity": "0.8"})
    # 版本条
    for i, v in enumerate(cfg["versions"]):
        y = PAD_T + row_h * i + row_h * 0.25
        x0 = x_of(v["milestones"]["kickoff"])
        x1 = x_of(v["milestones"]["launch"])
        ET.SubElement(svg, "rect", {"x": f"{x0:.1f}", "y": f"{y:.1f}",
                                    "width": f"{x1 - x0:.1f}", "height": f"{row_h * 0.5:.1f}",
                                    "fill": "#1f6feb", "opacity": "0.85"})
        t = ET.SubElement(svg, "text", {"x": str(PAD_L - 8), "y": f"{y + row_h * 0.35:.1f}",
                                        "font-size": "12", "text-anchor": "end", "fill": "#333"})
        t.text = f"{v['id']}({v['kind']})"
    # 时间轴
    ET.SubElement(svg, "line", {"x1": str(PAD_L), "y1": str(H - PAD_B), "x2": str(W - PAD_R),
                                "y2": str(H - PAD_B), "stroke": "#333", "stroke-width": "1"})
    for d in range(lo, hi + 1, 28):
        x = x_of(d)
        ET.SubElement(svg, "line", {"x1": f"{x:.1f}", "y1": str(H - PAD_B), "x2": f"{x:.1f}",
                                    "y2": str(H - PAD_B + 4), "stroke": "#333", "stroke-width": "1"})
        t = ET.SubElement(svg, "text", {"x": f"{x:.1f}", "y": str(H - PAD_B + 18),
                                        "font-size": "11", "text-anchor": "middle", "fill": "#555"})
        t.text = f"D{d}"
    ttl = ET.SubElement(svg, "text", {"x": str(PAD_L), "y": "16", "font-size": "13", "fill": "#111"})
    ttl.text = "《王朝纪元》版本列车甘特与资源冲突(红带)"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default=os.path.join(HERE, "..", "config"))
    ap.add_argument("--team", action="append", default=[], help="覆盖团队容量,如 art=3")
    ap.add_argument("--pull-in", action="append", default=[], help="某版本整体前移,如 V3.3=7")
    args = ap.parse_args()

    cfg = apply_overrides(load_config(args.config, "ch07_train"), args.team, args.pull_in)
    rep = validate(cfg)
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ch07_train_report.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    write_csv(rep["loadMatrix"], os.path.join(OUT, "ch07_train_load.csv"))
    write_svg(cfg, rep["conflictRanges"], os.path.join(OUT, "ch07_train.svg"))

    print(f"verdict={rep['verdict']} versions={rep['versions']} makespan={rep['makespanDays']}d")
    print(f"dependencyViolations={len(rep['dependencyViolations'])} gapViolations={len(rep['gapViolations'])} conflictDays={rep['conflictDays']}")
    for r in rep["conflictRanges"]:
        print(f"  conflict {r['team']} D{r['fromDay']}..D{r['toDay']} peak={r['peakLoad']}>{r['capacity']}")
    for d in rep["dependencyViolations"]:
        print(f"  dep {d['from']}->{d['to']} need kickoff>={d['required']} got {d['actual']} short {d['shortByDays']}")
    return 0 if rep["verdict"] == "OK" else 2


if __name__ == "__main__":
    sys.exit(main())

三种场景的真实输出

基准配置是一列「排得下」的列车,直接跑应当零违规。下面三条命令与输出都是本机实跑(python3,同机相对量级,绝对数值不可移植):

$ python3 sim/ch07_train.py --config config/
verdict=OK versions=4 makespan=112d
dependencyViolations=0 gapViolations=0 conflictDays=0

$ python3 sim/ch07_train.py --config config/ --team art=3
verdict=FAIL versions=4 makespan=112d
dependencyViolations=0 gapViolations=0 conflictDays=14
  conflict art D42..D55 peak=4>3

$ python3 sim/ch07_train.py --config config/ --pull-in V3.3=7
verdict=FAIL versions=4 makespan=112d
dependencyViolations=1 gapViolations=0 conflictDays=0
  dep V3.2->V3.3 need kickoff>=42 got 35 short 7

三条输出各自对应一类事故:

  • 基准:4 车、总跨度 makespan=112 天,依赖、间隔、冲突全过。注意 V3.2 的活跃窗口 [28,56) 与赛季车 V3.3 的 [42,84) 有 14 天重叠,重叠期美术槽位是 2+2=4,刚好等于容量 4——贴边但不越界,这是列车排期里最危险的状态。
  • --team art=3:模拟一名美术休假、容量从 4 降到 3。同一段重叠立刻越界,脚本报出 art 团队在 D42..D55 连续 14 天 load=4 > capacity=3。这就是「资源冲突」类事故——不是逻辑错,是容量与重叠撞车。
  • --pull-in V3.3=7:把赛季车整体前移 7 天。它自身的里程碑间隔没变(内部间隔守恒),但 V3.3.kickoff=35 早于 V3.2.featureLock=42,触发依赖倒置,shortByDays=7。这正是「需求没冻结就并行开工」的可视化。

三类校验对应三种不同的修复动作:冲突要错峰或扩容,依赖倒置要推迟或砍需求,间隔不足要压缩评审。把它们写成可跑的检查,好处是排期评审时不用再靠人对甘特图——跑一遍,违规清单直接列出来。

列车到站之后

版本列车解决的是「什么时候发」。列车到站之后还有两个问题没答:内容产能够不够按期做完(下一节 7.2 用人天与产能模型算),以及上线之后怎么安全放量(7.3 用分桶与阈值判定)。三者串起来,才是一条完整的「排期—产能—发布」链路。

阅读导航:上一节:6.3 新手引导与信息架构 · 下一节:7.2 内容产能与排期 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引