走到这里,前 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 全链路仿真验收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。