本节目标:给《王朝纪元》的资源与货币定出层级,讲清每一层的作用与流通边界,并把它写成一份可校验的配置。
站内那篇经济平衡长文 给了一套通用的经济平衡框架,但它的例子是放置类:玩家挂机产币、线性消耗,货币只有一两种。SLG 不一样——大地图上同时跑着木材、石料、粮草、铁矿四种资源,外加软货币、硬货币、赛季代币三种货币,它们各有各的产出源、各有各的流通范围。本章要给的,是 SLG 大地图口径的分层与账本:先用这一节把「有哪些层、每层的边界在哪」钉死,再用 9.3 的复式账本证明「总量守恒」。通用框架负责讲道理,这一章负责把它跑成数字。
9.1 资源分类与货币分层
经济系统最容易翻车的地方,不是数值算错,而是层级没定清楚:一种资源既能直接买兵、又能当货币换别的资源、还能在玩家之间自由交易,结果它到底是「资源」还是「货币」都说不清,产出、消耗、回收三张表就永远对不齐。
所以设计经济的第一步不是调数值,是分层。分层解决两个问题:这种资源稀缺到什么程度(决定它在成长曲线里的位置),以及这种资源能流到哪里去(决定它会不会成为通胀的出口)。
9.1.1 分层的两条判据
给一样东西归类,只看两条:
- 稀缺度——它多难获得。越难获得,越该往上升层,越该被谨慎投放。
- 流通范围——它能流到多远。能跨玩家交易的,必须是最外层、最受控的;只能自己用的,可以放在内层。
把这两条交叉,就得到 SLG 最常见的六层结构:三档资源(基础 / 进阶 / 稀有)加三档货币(软 / 硬 / 代币)。
9.1.2 三档资源
资源是「用来造东西的原料」,它们的层级由获取难度决定,而不是由数值大小决定。
| 层 | 典型资源 | 获取方式 | 在成长曲线里的角色 |
|---|---|---|---|
| 基础资源 | 木材、石料、粮草 | 采集建筑持续产出,量大可再生 | 铺量消耗,新手期的主力 |
| 进阶资源 | 铁矿 | 需专门建筑、产量低 | 中后期升级的瓶颈 |
| 稀有资源 | 秘银 | 限时活动、高级地块产出 | 卡节奏、控进度 |
基础资源的关键性质是可再生且量大:它们会一直堆积,所以必须配足够大的消耗槽,否则前期就溢出。进阶资源是瓶颈资源——升级到某个等级后,卡你的往往就是它。稀有资源则刻意限量,它存在的意义不是「数值更高」,而是「不让所有人都能立刻拿到」。
9.1.3 三种货币
货币是「用来交换的媒介」。它们的层级由流通范围决定,这是本节最容易被忽略的一条。
| 层 | 典型货币 | 来源 | 流通边界 |
|---|---|---|---|
| 软货币 | 银币 | 日常任务、资源出售 | 账号内通用,部分可交易 |
| 硬货币 | 玉璧 | 付费、稀有兑换 | 账号级,可交易但受控 |
| 代币 | 功勋 | 赛季内行为结算 | 绑角色,赛季结束回收 |
软货币是经济的润滑剂:来源广、消耗也广,玩家天天在动它,它的健康靠「产出速率 ≈ 消耗速率」维持。硬货币是付费与稀有价值的载体,它的投放必须极谨慎——多给一点,付费点就废了。代币是赛季制的产物:它在一个赛季内累积、结算,赛季结束要么清零要么折算,是控制长期数值膨胀的阀门。
一个常见错误是把硬货币当软货币用:让玉璧可以买日常资源。这会立刻击穿付费设计——玩家不再买软货币礼包,直接囤硬货币就行。层级一旦定死,就不能让上层货币向下层无限兑换。
9.1.4 分层的两条硬约束
把上面的结构写成配置,就要能自动校验两条约束:
- 每层至少一种资源——不存在空层。一个「稀有资源层」如果什么资源都没有,说明分层定义和实际投放脱节了。
- 不存在孤立资源——每个资源或货币至少参与一条流转边。孤立节点意味着这个东西「进得来、出不去」或「根本没人用」,是设计里的僵尸资源。
下面这段是 S 层仿真脚本,它读 config/resources.json 拿到基础与进阶资源,再读本章的 config/ch09_layering.json 补上稀有资源、三种货币与流转边,把整张分层图建出来并校验上面两条约束。脚本还带一个反例自检:临时塞一个孤立节点,确认校验真的能抓到它。
# file: sim/ch09_layering.py
# tier: S
# verify: python3 sim/ch09_layering.py --config config/ --out out/
"""资源与货币的分层校验。
读 config/resources.json(基础/进阶资源)与 config/ch09_layering.json(层级定义、
货币、稀有资源、流转边),把「资源 + 货币」建成分层图,判定:
1. 每一层至少有一种成员(不存在空层);
2. 不存在孤立节点(每个资源/货币至少参与一条流转边)。
产出 out/ch09_layering.json + out/ch09_layering_nodes.csv + out/ch09_layering.svg。
"""
from __future__ import annotations
import argparse, csv, json, os, sys, xml.etree.ElementTree as ET
LAYER_ORDER = ["basic", "advanced", "rare", "soft", "hard", "token"]
def load(cfg_dir, name):
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def build_nodes(cfg_dir):
"""节点 = resources.json 的资源 + 本章补的稀有资源 + 货币。"""
lay = load(cfg_dir, "ch09_layering")
nodes = [{"id": r["id"], "name": r["name"], "layer": r["tier"], "kind": "resource"}
for r in load(cfg_dir, "resources")["resources"]]
nodes += [{"id": r["id"], "name": r["name"], "layer": r["tier"], "kind": "resource"}
for r in lay.get("rareResources", [])]
nodes += [{"id": c["id"], "name": c["name"], "layer": c["layer"], "kind": "currency"}
for c in lay.get("currencies", [])]
return nodes
def validate(nodes, flows):
ids = {n["id"] for n in nodes}
layers = {L: [] for L in LAYER_ORDER}
for n in nodes:
layers.setdefault(n["layer"], []).append(n["id"])
errs = [f"空层:{L} 没有任何资源或货币" for L in LAYER_ORDER if not layers.get(L)]
for f in flows:
if f["from"] not in ids:
errs.append(f"流转边起点不存在:{f['from']}")
if f["to"] not in ids:
errs.append(f"流转边终点不存在:{f['to']}")
incident = {n["id"]: 0 for n in nodes}
for f in flows:
for end in (f["from"], f["to"]):
if end in incident:
incident[end] += 1
isolated = sorted(i for i, c in incident.items() if c == 0)
errs += [f"孤立节点:{i} 不参与任何流转" for i in isolated]
return {"layers": {L: sorted(layers.get(L, [])) for L in LAYER_ORDER},
"layerCount": sum(1 for L in LAYER_ORDER if layers.get(L)),
"emptyLayers": [L for L in LAYER_ORDER if not layers.get(L)],
"isolated": isolated, "flowCount": len(flows), "errors": errs, "ok": not errs}
def self_test(nodes, flows):
"""反例自检:塞一个孤立节点,确认校验能抓到它。"""
rep = validate(nodes + [{"id": "salt", "name": "盐", "layer": "basic", "kind": "resource"}], flows)
return {"detectsIsolated": "salt" in rep["isolated"], "ok": rep["ok"]}
def draw_svg(path, nodes, flows):
rows = {L: i for i, L in enumerate(LAYER_ORDER)}
by_layer = {}
for n in nodes:
by_layer.setdefault(n["layer"], []).append(n)
pos = {n["id"]: (60 + j * 150, 40 + rows[n["layer"]] * 58)
for L in LAYER_ORDER for j, n in enumerate(by_layer.get(L, []))}
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": "0 0 720 380"})
for f in flows:
if f["from"] in pos and f["to"] in pos:
(x1, y1), (x2, y2) = pos[f["from"]], pos[f["to"]]
ET.SubElement(svg, "line", {"x1": f"{x1}", "y1": f"{y1}", "x2": f"{x2}", "y2": f"{y2}",
"stroke": "#8aa0b6", "stroke-width": "1.2"})
for n in nodes:
x, y = pos[n["id"]]
ET.SubElement(svg, "rect", {"x": f"{x - 26}", "y": f"{y - 12}", "width": "52", "height": "24",
"rx": "6", "fill": "#1f6feb" if n["kind"] == "currency" else "#22a06b"})
ET.SubElement(svg, "text", {"x": f"{x}", "y": f"{y + 4}", "font-size": "11",
"text-anchor": "middle", "fill": "#fff"}).text = n["name"]
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
nodes, flows = build_nodes(args.config), load(args.config, "ch09_layering")["flows"]
rep, st = validate(nodes, flows), self_test(nodes, flows)
report = {"version": 1, "nodeCount": len(nodes),
"currencyCount": sum(1 for n in nodes if n["kind"] == "currency"), **rep, "selfTest": st}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch09_layering.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch09_layering_nodes.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "layer", "kind"])
w.writerows([[n["id"], n["name"], n["layer"], n["kind"]] for n in nodes])
draw_svg(os.path.join(args.out, "ch09_layering.svg"), nodes, flows)
print(f"nodes={report['nodeCount']} currencies={report['currencyCount']} flows={report['flowCount']}")
print(f"layerCount={report['layerCount']}/6 emptyLayers={report['emptyLayers']} isolated={report['isolated']}")
print(f"selfTest(detectsIsolated)={st['detectsIsolated']}")
print("== 分层校验:OK" if rep["ok"] and st["detectsIsolated"] else "== 分层校验:FAIL")
return 0 if rep["ok"] and st["detectsIsolated"] else 1
if __name__ == "__main__":
sys.exit(main())
本机实跑输出:
nodes=8 currencies=3 flows=7
layerCount=6/6 emptyLayers=[] isolated=[]
selfTest(detectsIsolated)=True
== 分层校验:OK
八种成员(4 种资源 + 1 种稀有资源 + 3 种货币)全部落进六个层里,每层非空,没有孤立节点,反例自检也确认了「塞进孤立节点会被抓到」。
9.1.5 跨语言复核
同一份配置,TypeScript 侧也要算出一样的结果,否则「唯一真源」就是假的。P 层原型 proto/src/ch09_economy.ts 复刻了同样的分层校验,proto/test/ch09.test.ts 断言它与 Python 逐位一致:
// file: proto/test/ch09.test.ts
// tier: P
// verify: node --test proto/test/ch09.test.ts
// 第 9 章断言:分层、归属、账本守恒,数值与 Python 侧逐位一致。
import { test } from "node:test";
import assert from "node:assert/strict";
import { validateLayering, validateOwnership, runLedger } from "../src/ch09_economy.ts";
test("9.1 分层校验:8 节点 / 3 货币 / 7 流转边 / 6 层全非空", () => {
const r = validateLayering();
assert.equal(r.nodeCount, 8);
assert.equal(r.currencyCount, 3);
assert.equal(r.flowCount, 7);
assert.equal(r.layerCount, 6);
assert.deepEqual(r.emptyLayers, []);
assert.deepEqual(r.isolated, []);
assert.equal(r.ok, true);
});
test("9.2 归属矩阵:8 行齐全,可交易为粮草/银币/玉璧", () => {
const r = validateOwnership();
assert.equal(r.itemCount, 8);
assert.deepEqual(r.missing, []);
assert.deepEqual(r.extra, []);
assert.deepEqual(r.crossPlayerTrade, ["gold", "grain", "jade"]);
assert.deepEqual(r.accountShare, ["gold", "iron", "jade"]);
assert.equal(r.ok, true);
});
test("9.3 复式账本:每笔借贷平衡、玩家总量守恒", () => {
const r = runLedger();
assert.equal(r.transactionCount, 8);
assert.equal(r.doubleEntryBalanced, true);
assert.equal(r.transferNeutral, true);
assert.deepEqual(r.start, { wood: 2400, gold: 1000 });
assert.deepEqual(r.produced, { wood: 350, gold: 300 });
assert.deepEqual(r.consumed, { wood: 50, gold: 70 });
assert.deepEqual(r.end, { wood: 2700, gold: 1230 });
assert.deepEqual(r.conservation, { wood: true, gold: true });
assert.equal(r.ok, true);
});
test("9.3 转移不改总量:wood 转移 180 但期末 = 期初 + 产出 − 消耗", () => {
const r = runLedger();
assert.equal(r.transferred.wood, 180);
assert.equal(r.end.wood, r.start.wood + r.produced.wood - r.consumed.wood);
});
跑 node --test proto/test/ch09.test.ts 的四个用例全绿。这条复核的意义是:分层不是文档里的一张表,而是一份两个语言都能读、都能验的配置。
小结
- 分层的两条判据是稀缺度与流通范围,交叉得到三档资源加三档货币。
- 基础资源量大可再生、进阶资源是瓶颈、稀有资源刻意限量;软货币广流通、硬货币受控、代币按赛季回收。
- 两条硬约束要能自动校验:每层至少一种资源、不存在孤立资源。
- 上层货币不能向下层无限兑换,否则付费设计被击穿。
- 分层写成配置后,Python 与 TypeScript 两边都能复算,结果逐位一致。
资源分层定完,下一节要回答一个更细的问题:同一资源在不同归属下能不能流通——绑定与非绑定、账号级与角色级,这是分层的第二个维度。
阅读导航:上一节:8.3 数据看板与告警 · 下一节:9.2 绑定与非绑定、账号与角色 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。