16.1 把两卷装配成完整设计案

前 15 章各产出一批 config、sim、proto 与 out 产物,收尾这一章要做的不是再写一套结论,而是把它们装配成一份可交付的设计案。本篇给出一份装配清单,把两卷 30 章的代表性交付物登记成可追溯索引,跑一个脚本逐条核对「配置源 + 产物」是否真的在磁盘上,并报告覆盖度。

走到这里,前 15 章已经把 SLG 的数值与商业化拆开讲完了:一支队伍为什么强(第 1–4 章)、一个玩家多久长大(第 2 章)、一次抽卡花多少钱(第 6 章)、钱从哪来、人怎么留、一个赛季怎么收尾(第 9–15 章)。每一章都留下了自己的 config/、sim/、proto/ 与 out/ 产物;站内那篇在线原型全集第 10 章:mini SLG 则是一份单个原型的 PRD。收尾这一章要做的事,不是再写一套结论,而是把这些散落的产物装配成一份可交付的设计案。

先把分工讲明。那份 PRD 是单个原型的纸面规格——它描述「这个原型要做什么」。本卷的 bastion 不是纸面规格,而是一套可跑、可调参、可断言的工程:同一份 config/ 驱动 Python 仿真与 TypeScript 原型,改一个数字两边结果都变,所有结论都能被重新算出来。PRD 回答「打算做成什么样」,本卷回答「算出来的数是不是这样」。站内 SLG 技术架构专题 讲的是前后端分层与协议,明确不碰玩法数值;本卷交付的正是它没碰的那一半。

装配的判据:每条结论都要能指回一个 config 与一个产物

「装配设计案」听起来像排版工作,其实是一条硬判据:设计案里写的每一条结论,都要能指回一个 config/ 输入和一个 out/ 产物。做不到这一条,设计案就退化成散文——道理都对,落不了地,下一版没人知道那个数字是怎么来的、改了会不会崩。

所以装配清单登记的每一条,都是三件东西的绑定:

字段含义例子
config这条结论的输入真源n08_compress(五维进度与重置方案)
artifacts这条结论的可复算产物n08_compress、n08_inflation
tier产物来自哪一层(S 仿真 / P 原型 / B 基准)S

装配脚本不去判断结论「对不对」——那是各章自己的事,也在16.2 全链路仿真验收 里统一跑。它只核对可追溯性:清单里写的 config 和产物,是不是真的存在于磁盘上、是不是非空。这是设计案能交付的底线。

下面这段是 D 层配置:两卷 30 章的装配清单。config 与 artifacts 只写不带扩展名的基名,脚本按 --config / --out 目录拼接。

{
  "version": 1,
  "book": "SLG 游戏产品设计",
  "note": "设计案装配清单:把卷一《产品架构与经济系统》与卷二《数值平衡与商业化运营》每章的代表性交付物登记成一份可交付索引。config/artifacts 只写不带扩展名的基名,脚本按 --config / --out 目录拼接并核对是否存在且非空。",
  "volumes": [
    {
      "id": "arch",
      "title": "卷一《产品架构与经济系统》",
      "chapters": [
        { "chapter": 1, "title": "产品定位与品类地图", "tier": "S", "config": "ch01_category_map", "artifacts": ["ch01_category_map"] },
        { "chapter": 2, "title": "玩家与生命周期", "tier": "S", "config": "ch02_lifecycle", "artifacts": ["ch02_lifecycle"] },
        { "chapter": 3, "title": "核心循环与目标节奏", "tier": "S", "config": "ch03_goals", "artifacts": ["ch03_goals"] },
        { "chapter": 4, "title": "系统耦合与解锁节奏", "tier": "S", "config": "ch04_coupling", "artifacts": ["ch04_coupling"] },
        { "chapter": 5, "title": "功能架构与模块职责", "tier": "S", "config": "ch05_feature_tree", "artifacts": ["ch05_feature_tree"] },
        { "chapter": 6, "title": "主界面信息层级与点击成本", "tier": "S", "config": "ch06_onboarding", "artifacts": ["ch06_onboarding"] },
        { "chapter": 7, "title": "版本列车与灰度回滚", "tier": "S", "config": "ch07_canary", "artifacts": ["ch07_canary_report"] },
        { "chapter": 8, "title": "北极星指标树与埋点", "tier": "S", "config": "ch08_metric_tree", "artifacts": ["ch08_metric_tree"] },
        { "chapter": 9, "title": "资源与货币分层账本", "tier": "S", "config": "ch09_ledger", "artifacts": ["ch09_ledger"] },
        { "chapter": 10, "title": "产出曲线仿真", "tier": "S", "config": "ch10_curve", "artifacts": ["ch10_curve"] },
        { "chapter": 11, "title": "消耗曲线仿真", "tier": "S", "config": "ch11_consumption", "artifacts": ["ch11_consumption"] },
        { "chapter": 12, "title": "存量-流量与稳态", "tier": "S", "config": "ch12_stockflow", "artifacts": ["ch12_stock_flow"] },
        { "chapter": 13, "title": "通胀诊断与回收", "tier": "S", "config": "ch13_season", "artifacts": ["ch13_season"] },
        { "chapter": 14, "title": "交易市场与定价流动性", "tier": "S", "config": "ch14_market", "artifacts": ["ch14_market"] },
        { "chapter": 15, "title": "异常检测与回滚", "tier": "S", "config": "ch15_rollback", "artifacts": ["ch15_rollback"] }
      ]
    },
    {
      "id": "num",
      "title": "卷二《数值平衡与商业化运营》",
      "chapters": [
        { "chapter": 1, "title": "属性与战力模型", "tier": "S", "config": "n01_power", "artifacts": ["n01_power", "n01_power_bench"] },
        { "chapter": 2, "title": "成长曲线设计", "tier": "S", "config": "n02_curve", "artifacts": ["n02_cost", "n02_bench"] },
        { "chapter": 3, "title": "战斗数值基础", "tier": "P", "config": "n03_battle", "artifacts": ["n03_damage", "n03_round_cross"] },
        { "chapter": 4, "title": "兵种与阵容数值", "tier": "S", "config": "n04_counter", "artifacts": ["n04_counter_matrix", "n04_strength"] },
        { "chapter": 5, "title": "掉落与概率", "tier": "S", "config": "n05_drops", "artifacts": ["n05_drops", "n05_expect"] },
        { "chapter": 6, "title": "抽卡与礼包数值", "tier": "S", "config": "n06_gacha", "artifacts": ["n06_gacha", "n06_cost"] },
        { "chapter": 7, "title": "匹配与分段", "tier": "S", "config": "n07_match", "artifacts": ["n07_elo", "n07_fairness"] },
        { "chapter": 8, "title": "赛季数值膨胀控制", "tier": "S", "config": "n08_compress", "artifacts": ["n08_compress", "n08_inflation"] },
        { "chapter": 9, "title": "付费点设计", "tier": "S", "config": "n09_payment", "artifacts": ["n09_layout", "n09_protection"] },
        { "chapter": 10, "title": "定价与折扣", "tier": "S", "config": "n10_pricing", "artifacts": ["n10_tier_ladder", "n10_elasticity"] },
        { "chapter": 11, "title": "LTV / ARPU 与回本", "tier": "S", "config": "n11_ltv", "artifacts": ["n11_ltv", "n11_ltv_bench"] },
        { "chapter": 12, "title": "留存与漏斗分析", "tier": "S", "config": "n12_retention", "artifacts": ["n12_funnel", "n12_churn"] },
        { "chapter": 13, "title": "A/B 实验与灰度", "tier": "S", "config": "n13_experiment", "artifacts": ["n13_stats", "n13_decision"] },
        { "chapter": 14, "title": "活动与赛季制", "tier": "S", "config": "n14_season_phases", "artifacts": ["n14_season_phases", "n14_settlement"] },
        { "chapter": 15, "title": "联盟与 GVG 运营", "tier": "S", "config": "n15_alliance", "artifacts": ["n15_gvg", "n15_health"] }
      ]
    }
  ],
  "delivery": {
    "note": "交付件本身:装配清单与全链路验收报告。",
    "items": [
      { "id": "manifest", "config": "n16_manifest", "artifact": "n16_manifest" },
      { "id": "acceptance", "config": "n16_acceptance", "artifact": "n16_acceptance" }
    ]
  }
}

清单里能看出一个约定:每章的 config 就是它那一节正文里出现过的那一份。例如第 8 章的 n08_compress,正是 8.2 重置、继承与压缩 里跑四种方案时读的那份配置。装配清单不新建任何配置,它只是把已有的配置登记进索引。

一张可交付的索引

下面这段是 S 层脚本,读上面那份清单,逐条核对 config/ 与 out/ 里对应的文件是否存在且非空,产出一份带覆盖度的索引 JSON + CSV + SVG。

# file: sim/n16_assemble.py
# tier: S
# verify: python3 sim/n16_assemble.py --config config/ --out out/
"""设计案装配:读 config/n16_manifest.json,把两卷每章的代表性交付物登记成一份可交付索引,
并核对每个「配置源 + 产物」是否真的存在于磁盘上。装配的判据是「可追溯」——
设计案里写的每一条结论,都要能指回一个 config 与一个 out 产物。

不生成新结论,只做索引与覆盖度核对。纯标准库,图表用 xml.etree 手写 SVG,零依赖。"""

from __future__ import annotations

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


def load(path: str) -> dict:
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def exists_nonempty(path: str) -> tuple[bool, int]:
    if os.path.isfile(path):
        size = os.path.getsize(path)
        return size > 0, size
    return False, 0


def check_chapter(ch: dict, config_dir: str, out_dir: str) -> dict:
    cfg_path = os.path.join(config_dir, f"{ch['config']}.json")
    cfg_ok, _ = exists_nonempty(cfg_path)
    arts = []
    for name in ch["artifacts"]:
        ok, size = exists_nonempty(os.path.join(out_dir, f"{name}.json"))
        arts.append({"name": name, "ok": ok, "bytes": size})
    chapter_ok = cfg_ok and all(a["ok"] for a in arts)
    return {"chapter": ch["chapter"], "title": ch["title"], "tier": ch["tier"],
            "config": ch["config"], "configOk": cfg_ok, "artifacts": arts, "ok": chapter_ok}


def write_svg(path: str, volumes: list[dict]) -> None:
    width, height, top, bh, gap, left = 640, 44, 48, 30, 14, 200
    rows = [v for v in volumes]
    height = top + len(rows) * (bh + gap) + 20
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(width),
                             "height": str(height), "viewBox": f"0 0 {width} {height}"})
    ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "16",
                                "font-family": "sans-serif"}).text = "两卷交付物覆盖度(章节数 已就绪 / 总数)"
    span = width - left - 60
    for i, v in enumerate(rows):
        y = top + i * (bh + gap)
        pct = v["chaptersOk"] * 100 // max(v["chapters"], 1)
        ET.SubElement(svg, "text", {"x": str(left - 8), "y": str(y + bh - 9), "font-size": "12",
                                    "text-anchor": "end", "font-family": "sans-serif"}).text = v["title"]
        ET.SubElement(svg, "rect", {"x": str(left), "y": str(y), "width": str(span), "height": str(bh),
                                    "fill": "#eaeef2"})
        w = max(int(span * pct / 100), 1)
        ET.SubElement(svg, "rect", {"x": str(left), "y": str(y), "width": str(w), "height": str(bh),
                                    "fill": "#1f6feb"})
        ET.SubElement(svg, "text", {"x": str(left + span + 8), "y": str(y + bh - 9), "font-size": "12",
                                    "font-family": "sans-serif"}).text = f"{v['chaptersOk']}/{v['chapters']}"
    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("--out", default="out/")
    args = ap.parse_args()
    cfg = load(os.path.join(args.config, "n16_manifest.json"))

    volumes, rows = [], []
    tot_ch = tot_ok = tot_art = art_ok = 0
    for vol in cfg["volumes"]:
        chapters = [check_chapter(c, args.config, args.out) for c in vol["chapters"]]
        cok = sum(1 for c in chapters if c["ok"])
        vols = {"id": vol["id"], "title": vol["title"], "chapters": len(chapters),
                "chaptersOk": cok, "chapterRows": chapters}
        volumes.append(vols)
        tot_ch += len(chapters)
        tot_ok += cok
        for c in chapters:
            tot_art += len(c["artifacts"])
            art_ok += sum(1 for a in c["artifacts"] if a["ok"])
            rows.append({"volume": vol["id"], "chapter": c["chapter"], "title": c["title"],
                         "tier": c["tier"], "config": c["config"], "configOk": c["configOk"],
                         "artifacts": len(c["artifacts"]), "artifactsOk": sum(1 for a in c["artifacts"] if a["ok"]),
                         "ok": c["ok"]})

    delivery = []
    for item in cfg["delivery"]["items"]:
        cfg_ok, _ = exists_nonempty(os.path.join(args.config, f"{item['config']}.json"))
        art_ok2, size = exists_nonempty(os.path.join(args.out, f"{item['artifact']}.json"))
        delivery.append({"id": item["id"], "configOk": cfg_ok, "artifact": item["artifact"],
                         "artifactOk": art_ok2, "bytes": size})

    summary = {"version": 1, "book": cfg["book"], "volumes": volumes,
               "coverage": {"chapters": tot_ch, "chaptersOk": tot_ok,
                            "artifacts": tot_art, "artifactsOk": art_ok},
               "delivery": delivery, "allChaptersOk": tot_ok == tot_ch}
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n16_manifest.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n16_manifest.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["volume", "chapter", "title", "tier", "config", "configOk", "artifacts", "artifactsOk", "ok"])
        for r in rows:
            w.writerow([r["volume"], r["chapter"], r["title"], r["tier"], r["config"],
                        r["configOk"], r["artifacts"], r["artifactsOk"], r["ok"]])
    write_svg(os.path.join(args.out, "n16_manifest.svg"), volumes)

    print(f"两卷章节 {tot_ok}/{tot_ch} 就绪 · 产物 {art_ok}/{tot_art} 就绪")
    for v in volumes:
        print(f"  {v['title']}: {v['chaptersOk']}/{v['chapters']}")
    for d in delivery:
        print(f"  交付件 {d['id']}: config={'OK' if d['configOk'] else 'MISSING'} "
              f"artifact={'OK' if d['artifactOk'] else 'MISSING'}")
    print("OK 装配清单已生成" if summary["allChaptersOk"] else "FAIL 有章节产物缺失")
    return 0 if summary["allChaptersOk"] else 1


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

本机实跑(python3 sim/n16_assemble.py --config config/ --out out/):

两卷章节 30/30 就绪 · 产物 45/45 就绪
  卷一《产品架构与经济系统》: 15/15
  卷二《数值平衡与商业化运营》: 15/15
  交付件 manifest: config=OK artifact=OK
  交付件 acceptance: config=OK artifact=OK
OK 装配清单已生成

读这份索引:装配完成意味着什么

30/30 与 45/45 这两个数字,就是「设计案装配完成」的可核对定义:两卷 30 章,每章都有一份 config 源与至少一个 out 产物,一个不缺。这不是文字上的「完成了」,而是磁盘上数得出来的。

  • chaptersOk 是硬指标。缺任何一个 config 或产物,对应章就标 ok: false,脚本退出码为 1。装配门禁和16.2 的验收是两回事:装配只问「东西在不在」,验收才问「数对不对」。
  • tier 标注的是产物来自哪一层。清单里卷二第 3 章标 P,因为它的代表产物 n03_round_cross 是原型层的回放指纹对照;其余章标 S,代表产物是仿真输出。一张索引同时覆盖了 S 与 P 两层,16.2 还会把 B 层基准产物也纳进验收。
  • delivery 是自指的。清单最后登记了两个交付件本身——n16_manifest(这份索引)与 n16_acceptance(下一节的验收报告)。设计案把自己也当成一件交付物来核对,交付件缺了同样报 MISSING。

到这里,「把两卷装配成完整设计案」就落地了:一份 30 章的可追溯索引,一张覆盖度图,两个自指的交付件。但装配只保证了「东西齐全」,还没保证「数是对的」。下一节把这 45 个产物汇总进一次全链路验收,逐项断言关键不变量。

小结

  • 装配设计案不是排版,是可追溯性核对:每条结论都要能指回一个 config/ 输入与一个 out/ 产物。
  • 装配清单(config/n16_manifest.json)登记两卷 30 章的代表交付物;S 层脚本(sim/n16_assemble.py)核对存在性并产出带覆盖度的索引 JSON + CSV + SVG。
  • 本机实跑:两卷章节 30/30、产物 45/45 全部就绪,两个交付件自身也在核对范围内。
  • 装配保证「齐全」,不保证「正确」——后者交给下一节的全链路仿真验收 。

阅读导航:上一节:目录 · 下一节:16.2 全链路仿真验收 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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