4.2 解锁节奏与前置链
4.1 给出了系统之间的依赖图,但依赖图只说「谁必须先有」,不说「隔多久放给玩家」。同样一张 18 节点的图,可以在第一天全部开放,也可以分二十天逐层解锁——前者让新手在前两小时被淹没,后者让老玩家在开荒期一直有目标。解锁节奏就是决定这二十天里每一天放出什么的规则。
和前几节一样,这里也要划清与技术架构的边界。站内 SLG 技术架构系列 讲的是系统怎么被拆分和部署,不讨论「什么时候放给玩家」;本节关心的是产品侧的开荒节奏——前置链决定「能不能开」,天数门槛决定「最早第几天开」,两者叠加才是玩家实际感受到的解锁时间轴。
4.2.1 为什么解锁节奏要单独建模
很多团队把解锁条件直接写死在代码里:某个按钮的 onclick 里判断「主城到 5 级了没、科技研究了没」。这样做的后果是,策划想问「如果我要求玩家第 3 天才能看到英雄系统,整个开荒曲线会变成什么样」,没人答得上来——因为规则散落在十几个判断里,没有一个地方能一次性算出全貌。
把解锁条件抽出来建模,好处有三。第一,可算:给定前置链与门槛,脚本能算出每个系统最早解锁的天数、以及「第 N 天累计解锁了几个系统」。第二,可调:想知道把「联盟」从第 12 天提前到第 8 天会怎样,改一个数字重跑即可。第三,可对账:仿真出的时间轴可以和真实埋点里玩家的实际解锁天数比对,偏差就是设计意图与体验的差距。
这里要区分两个概念。前置链是硬约束,来自 4.1 的依赖图——没有前置系统,本系统在业务上根本跑不起来。天数门槛是软约束,是产品意图——即使前置都满足了,也故意压后几天,用来控制开荒节奏。本节把两者合在一张门槛表里,用「两者取更晚」的规则推进。
4.2.2 门槛表的字段
门槛表沿用 4.1 的系统 id,每个系统补三个字段:prereq 是前置系统 id 列表(须全部已解锁),dayGate 是最早可解锁天数,pacing 给出全局节奏(起始天与每日解锁上限)。下面这份是 D 层配置 config/ch04_unlock.json 的结构:
| 字段 | 位置 | 含义 |
|---|---|---|
prereq | 每个系统 | 前置系统 id 列表,须全部已解锁 |
dayGate | 每个系统 | 最早可解锁天数(软约束) |
pacing.startDay | 全局 | 仿真的起始天 |
pacing.maxUnlocksPerDay | 全局 | 每日解锁数量上限,表达开荒节奏 |
maxUnlocksPerDay 是这套模型的关键旋钮。它把「前置都满足了」和「今天就能开」拆开:即使五个系统的前置在同一天全部满足,一天也只放两个,剩下的排队等明天。这就是把「一次给太多」变成「细水长流」的手段。本节的基准配置用 startDay=1、maxUnlocksPerDay=2。
4.2.3 逐日推进仿真
下面这段是 S 层仿真脚本,读门槛表逐日推进,产出解锁时间轴 CSV、累计解锁曲线 SVG 与 JSON 摘要。规则是确定性的:某系统可解锁当且仅当全部 prereq 已解锁、且当前天不早于 dayGate;就绪数超过每日上限时,先解锁「过期最久」者(dayGate 最小、再按 id 字典序)。
# file: sim/ch04_unlock_pacing.py
# tier: S
# verify: python3 sim/ch04_unlock_pacing.py --config config/ --days 30
"""解锁节奏仿真:给定前置链与天数门槛,逐日推进,算出「第 N 天解锁第几个系统」。
规则(确定性):
- 某系统可解锁 <=> 全部 prereq 已解锁 且 当前天 >= dayGate。
- 每日最多解锁 pacing.maxUnlocksPerDay 个;就绪数超上限时先解锁「过期最久」者
(dayGate 最小、再按 id 字典序)。
产出:解锁时间轴 CSV + 累计解锁曲线 SVG + JSON 摘要。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "out")
NS = "http://www.w3.org/2000/svg"
def load(path: str) -> dict:
with open(path, encoding="utf-8") as f:
return json.load(f)
def simulate(cfg: dict, names: dict, max_days: int) -> dict:
gates = {g["id"]: g for g in cfg["gates"]}
ids = [g["id"] for g in cfg["gates"]]
cap, start = cfg["pacing"]["maxUnlocksPerDay"], cfg["pacing"]["startDay"]
unlocked: set[str] = set()
timeline: list[dict] = []
order, day = 0, start
while len(unlocked) < len(ids) and day < start + max_days:
ready = sorted((i for i in ids if i not in unlocked and gates[i]["dayGate"] <= day
and all(p in unlocked for p in gates[i]["prereq"])),
key=lambda s: (gates[s]["dayGate"], s))
for i in ready[:cap]:
order += 1
unlocked.add(i)
timeline.append({"day": day, "order": order, "id": i, "name": names.get(i, i)})
day += 1
curve, run = [], 0
for d in range(start, day + 1):
run += sum(1 for r in timeline if r["day"] == d)
curve.append({"day": d, "unlocked": run})
return {"totalSystems": len(ids), "unlocked": len(unlocked),
"pending": [i for i in ids if i not in unlocked], "timeline": timeline,
"curve": curve, "pacingCap": cap,
"lastUnlockDay": timeline[-1]["day"] if timeline else None}
def write_svg(path: str, res: dict) -> None:
W, H, PL, PR, PT, PB = 640, 360, 56, 24, 24, 40
curve, hi_v = res["curve"], res["totalSystems"]
lo_d, hi_d = curve[0]["day"], curve[-1]["day"]
iw, ih = W - PL - PR, H - PT - PB
span = (hi_d - lo_d) or 1
pts = " ".join(f"{PL + iw * (r['day'] - lo_d) / span:.2f},{PT + ih * (1 - r['unlocked'] / hi_v):.2f}" for r in curve)
svg = ET.Element("svg", {"xmlns": NS, "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#fff"})
ET.SubElement(svg, "line", {"x1": str(PL), "y1": str(PT), "x2": str(PL), "y2": str(H - PB), "stroke": "#333"})
ET.SubElement(svg, "line", {"x1": str(PL), "y1": str(H - PB), "x2": str(W - PR), "y2": str(H - PB), "stroke": "#333"})
ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
for k in range(5):
ET.SubElement(svg, "text", {"x": str(PL - 8), "y": f"{PT + ih * k / 4 + 4:.1f}", "font-size": "11", "text-anchor": "end"}).text = str(round(hi_v * (1 - k / 4)))
ET.SubElement(svg, "text", {"x": str(PL), "y": "16", "font-size": "13"}).text = \
f"《王朝纪元》累计解锁曲线({hi_v} 系统 / 末次解锁 D{res['lastUnlockDay']} / 每日上限 {res['pacingCap']})"
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="config/")
ap.add_argument("--days", type=int, default=30)
args = ap.parse_args()
cfg_dir = args.config if os.path.isabs(args.config) else os.path.join(HERE, "..", args.config)
cfg = load(os.path.join(cfg_dir, "ch04_unlock.json"))
names = {s["id"]: s["name"] for s in load(os.path.join(cfg_dir, "ch04_systems.json"))["systems"]}
res = simulate(cfg, names, args.days)
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "ch04_unlock.json"), "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
with open(os.path.join(OUT, "ch04_unlock.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["day", "order", "id", "name"])
for r in res["timeline"]:
w.writerow([r["day"], r["order"], r["id"], r["name"]])
write_svg(os.path.join(OUT, "ch04_unlock.svg"), res)
print(json.dumps({"totalSystems": res["totalSystems"], "unlocked": res["unlocked"],
"pending": res["pending"], "lastUnlockDay": res["lastUnlockDay"],
"dayN": {d: sum(1 for r in res["timeline"] if r["day"] <= d) for d in (1, 3, 7, 15, 20)}},
ensure_ascii=False))
return 0 if not res["pending"] else 1
if __name__ == "__main__":
raise SystemExit(main())
注意 names 是从 ch04_systems.json 取的,不是门槛表里自带的——门槛表只存 id 与条件,展示名统一从系统清单查。这是「一份配置多处消费」的又一次体现:4.1 的清单是系统名与依赖的唯一真源,门槛表只补节奏信息,不重复维护名字。
4.2.4 实跑结果
本机运行 python3 sim/ch04_unlock_pacing.py --config config/ --days 30,输出:
{"totalSystems": 18, "unlocked": 18, "pending": [], "lastUnlockDay": 20, "dayN": {"1": 1, "3": 5, "7": 11, "15": 17, "20": 18}}
解读:18 个系统全部解锁,末次解锁在第 20 天;第 1 天只解锁 1 个(账号),到第 3 天累计 5 个,第 7 天累计 11 个,第 15 天 17 个,第 20 天满 18 个。同机相对量级下,这条曲线是「前期陡、后期平」:开荒头三天就放出三分之一,之后因为 dayGate 被越拉越长(联盟第 12 天、联盟战第 15 天、赛季第 20 天),新增系统越来越稀疏。
时间轴 CSV 的关键节点:
| day | order | id | name |
|---|---|---|---|
| 1 | 1 | account | 账号 |
| 2 | 2 | city | 主城 |
| 3 | 4 | production | 资源产出 |
| 6 | 10 | troop | 兵种训练 |
| 7 | 11 | battle | 战斗结算 |
| 12 | 16 | alliance | 联盟 |
| 15 | 17 | alliance-war | 联盟战 |
| 20 | 18 | season | 赛季 |
一个值得注意的现象:hero 的 dayGate 是 3,但它在第 5 天才解锁。原因是每日上限为 2,前几天的名额被更早满足条件的系统占满了——这正是「前置都满足 ≠ 今天就能开」的体现。如果想让英雄更早出现,要么放宽每日上限,要么把它的 dayGate 压到更前并调整排序键。
4.2.5 原型层交叉验证
同一份 config/,TypeScript 侧用 node --test 把契约固化下来。下面这段是 P 层测试文件,它既断言 4.1 的依赖图性质(无环、无孤儿、拓扑序合法),也断言解锁时间轴与 Python 侧逐日一致:
// file: proto/test/ch04.test.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 第 4 章原型测试:依赖图校验与解锁节奏,断言值来自本机实跑,与 Python 侧交叉一致。
import { test } from "node:test";
import assert from "node:assert/strict";
import { analyzeGraph, simulateUnlock, unlockedByDay, loadConfig, type SystemsConfig, type UnlockConfig } from "../src/ch04_graph.ts";
const systems = loadConfig<SystemsConfig>("ch04_systems");
const unlock = loadConfig<UnlockConfig>("ch04_unlock");
const graph = analyzeGraph(systems);
test("P-D:清单规模与校验四问全部通过", () => {
assert.equal(systems.systems.length, 18);
assert.deepEqual(graph.dangling, []);
assert.deepEqual(graph.cycle, []);
assert.deepEqual(graph.orphan, []);
assert.deepEqual(graph.unreachable, []);
});
test("P-D:唯一根为账号,最大层数 12", () => {
const roots = systems.systems.filter((s) => s.deps.length === 0).map((s) => s.id);
assert.deepEqual(roots, ["account"]);
assert.equal(Math.max(...Object.values(graph.layers)), 12);
assert.equal(graph.layers["season"], 12);
});
test("P-D:拓扑序满足全部前置先于本节点", () => {
const pos = new Map(graph.order.map((id, i) => [id, i]));
for (const s of systems.systems) {
for (const p of s.deps) assert.ok(pos.get(p)! < pos.get(s.id)!, `${p} 应先于 ${s.id}`);
}
});
test("P-P:解锁时间轴与 Python 侧逐日一致", () => {
const tl = simulateUnlock(unlock, 30);
assert.equal(tl.length, 18);
assert.equal(tl[tl.length - 1].day, 20);
assert.equal(unlockedByDay(tl, 1), 1);
assert.equal(unlockedByDay(tl, 3), 5);
assert.equal(unlockedByDay(tl, 7), 11);
assert.equal(unlockedByDay(tl, 15), 17);
assert.equal(unlockedByDay(tl, 20), 18);
});
test("P-P:每日解锁数不超过节奏上限", () => {
const tl = simulateUnlock(unlock, 30);
const perDay = new Map<number, number>();
for (const r of tl) perDay.set(r.day, (perDay.get(r.day) ?? 0) + 1);
for (const n of perDay.values()) assert.ok(n <= unlock.pacing.maxUnlocksPerDay, `某日解锁 ${n} 个超上限`);
});
test("P-P:前置未解锁则本系统不解锁(troop 不早于 research)", () => {
const tl = simulateUnlock(unlock, 30);
const day = (id: string) => tl.find((r) => r.id === id)!.day;
for (const g of unlock.gates) {
for (const p of g.prereq) assert.ok(day(p) <= day(g.id), `${p} 应不晚于 ${g.id}`);
}
});
test("P-D:环会被检出(构造 a->b->c->a)", () => {
const bad: SystemsConfig = { systems: [
{ id: "a", name: "A", category: "x", deps: ["c"] },
{ id: "b", name: "B", category: "x", deps: ["a"] },
{ id: "c", name: "C", category: "x", deps: ["b"] },
] };
assert.deepEqual(analyzeGraph(bad).cycle, ["a", "b", "c"]);
});
本机跑 node --test 'proto/**/*.test.ts' 时,本文件的七条断言全部通过;其中 unlockedByDay 的五个数字与 Python 输出的 dayN 完全一致。这就是跨语言交叉验证的价值:如果哪天有人只在 TS 侧改了排序键、Python 侧没跟上,这条测试会立刻失败,而不是等到上线后玩家发现解锁顺序变了。
4.2.6 从解锁链到耦合判断
解锁链回答的是「什么时候放出来」,但它默认了「放出来就能用」。现实里还有个更隐蔽的问题:某些系统之间虽然业务上不互为前置,却在数据上紧紧缠在一起,改一个就得动另一个。下一节就用同一张系统清单,把这种「非前置的耦合」量化出来,并讨论什么时候该拆、什么时候不该拆。
阅读导航:上一节:4.1 系统清单与依赖图 · 下一节:4.3 耦合与解耦:什么时候不该拆 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。