4.2 解锁节奏与前置链

解锁节奏不是拍脑袋定天数:本节把每个系统的前置链与天数门槛写成一张门槛表,用 S 层脚本逐日推进,算出「第 N 天解锁第几个系统」的时间轴与累计曲线,再用 TypeScript 原型测试断言它与 Python 侧逐位一致,并解释为什么「前置满足」不等于「当天就能开」。

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 的关键节点:

dayorderidname
11account账号
22city主城
34production资源产出
610troop兵种训练
711battle战斗结算
1216alliance联盟
1517alliance-war联盟战
2018season赛季

一个值得注意的现象: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 耦合与解耦:什么时候不该拆 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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