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 内容产能与排期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。