9.1 资源分类与货币分层

经济系统的第一块地基是分层:把资源按稀缺度分成基础、进阶、稀有,把货币按流通范围分成软货币、硬货币、代币,讲清每层的作用与流通边界,并用一段可跑的 Python 脚本从 config 里读出分层、校验「每层至少一种资源」与「不存在孤立资源」。

本节目标:给《王朝纪元》的资源与货币定出层级,讲清每一层的作用与流通边界,并把它写成一份可校验的配置。

站内那篇经济平衡长文 给了一套通用的经济平衡框架,但它的例子是放置类:玩家挂机产币、线性消耗,货币只有一两种。SLG 不一样——大地图上同时跑着木材、石料、粮草、铁矿四种资源,外加软货币、硬货币、赛季代币三种货币,它们各有各的产出源、各有各的流通范围。本章要给的,是 SLG 大地图口径的分层与账本:先用这一节把「有哪些层、每层的边界在哪」钉死,再用 9.3 的复式账本证明「总量守恒」。通用框架负责讲道理,这一章负责把它跑成数字。

9.1 资源分类与货币分层

经济系统最容易翻车的地方,不是数值算错,而是层级没定清楚:一种资源既能直接买兵、又能当货币换别的资源、还能在玩家之间自由交易,结果它到底是「资源」还是「货币」都说不清,产出、消耗、回收三张表就永远对不齐。

所以设计经济的第一步不是调数值,是分层。分层解决两个问题:这种资源稀缺到什么程度(决定它在成长曲线里的位置),以及这种资源能流到哪里去(决定它会不会成为通胀的出口)。

9.1.1 分层的两条判据

给一样东西归类,只看两条:

  1. 稀缺度——它多难获得。越难获得,越该往上升层,越该被谨慎投放。
  2. 流通范围——它能流到多远。能跨玩家交易的,必须是最外层、最受控的;只能自己用的,可以放在内层。

把这两条交叉,就得到 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 绑定与非绑定、账号与角色 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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