本节目标:给资源与货币加第二个维度——归属,讲清同一资源在不同归属下能不能流通,并把它写成一份可校验的归属矩阵。
站内那篇经济平衡长文 也提过「绑定货币」的概念,但它是单机口径:通关就结束,绑定与否只影响体验,不影响生态。SLG 是持续运营——绑定与非绑定直接决定工作室能不能搬砖、付费货币会不会被洗成通用资源。本节给的是大地图口径的归属矩阵:它和 9.1 的稀缺度分层正交,合起来才是资源/货币的完整定义。
9.2 绑定与非绑定、账号与角色
9.1 回答了「这种资源多难得」,但没回答两个同样致命的问题:它属于谁?它能流到哪?
考虑同一个「银币」的两种设定:如果它是绑定的,玩家赚到的银币只能自己花,工作室刷再多也卖不出去;如果它是非绑定的,它就能在玩家之间流通,工作室立刻有了搬砖的动机。同一份产出速率,两种设定下的经济生态完全不同。
所以资源的第二个维度是归属,它由两个正交的问题构成。
9.2.1 两个正交的维度
| 维度 | 取值 | 回答的问题 |
|---|---|---|
| 绑定(binding) | bound / unbound | 它能不能离开当前玩家? |
| 作用域(scope) | account / role | 它属于整个账号还是单个角色? |
- 绑定决定「跨玩家交易」:
unbound的东西可以挂到市场卖给别人,bound的东西一离开就销毁。 - 作用域决定「账号内共享」:
account的东西同账号下所有角色共用一个池,role的东西只属于当前角色。
这两个维度是正交的——绑定与否不影响账号内是否共享,作用域也不影响能否对外交易。把它们交叉,得到四种组合,覆盖了 SLG 里所有资源/货币的归属形态。
9.2.2 四种组合与它们的流通边界
| 组合 | 含义 | 本章例子 | 跨玩家交易 | 账号内共享 |
|---|---|---|---|---|
| bound / role | 绑角色,最严 | 木材、石料、秘银、功勋 | 否 | 否 |
| unbound / role | 非绑定但属单角色 | 粮草 | 是 | 否 |
| bound / account | 绑账号,账号内共享 | 铁矿 | 否 | 是 |
| unbound / account | 账号级且可交易 | 银币、玉璧 | 是 | 是 |
从表里能读出两条设计规律:
- 越是往经济外圈走的东西,越该是
unbound / account。银币、玉璧可以跨玩家交易、账号内共享,所以它们必须被最严地监控投放。 - 代币必须
bound / role。赛季代币(功勋)如果可交易,玩家就会在赛季末互相倒卖、把赛季奖励洗成通用资源,赛季回收就失去意义。
一个高频 bug 是把 unbound 当成「不重要」:随便给个活动奖励标成非绑定,结果它成了工作室的搬砖出口。归属不是体验设定,是风控边界。
9.2.3 归属矩阵与它的完备性
把每种资源的归属写成一行,就是一张归属矩阵。它的正确性靠两条:
- 完备性——9.1 定义的每一个资源/货币都必须有且只有一行。缺一行,就意味着有个东西「归属未定义」,运行时只能猜。
- 一致性——
bound的东西不能标成可交易;unbound的东西必须可交易;赛季代币必须绑角色。这些约束一旦违反,配置本身就自相矛盾。
下面是本章的归属矩阵配置,属于 D 层(静态配置):
{
"version": 1,
"dimensions": {
"binding": ["bound", "unbound"],
"scope": ["account", "role"]
},
"items": [
{ "id": "wood", "name": "木材", "binding": "bound", "scope": "role", "tradable": false },
{ "id": "stone", "name": "石料", "binding": "bound", "scope": "role", "tradable": false },
{ "id": "grain", "name": "粮草", "binding": "unbound", "scope": "role", "tradable": true },
{ "id": "iron", "name": "铁矿", "binding": "bound", "scope": "account", "tradable": false },
{ "id": "mithril", "name": "秘银", "binding": "bound", "scope": "role", "tradable": false },
{ "id": "gold", "name": "银币", "binding": "unbound", "scope": "account", "tradable": true },
{ "id": "jade", "name": "玉璧", "binding": "unbound", "scope": "account", "tradable": true },
{ "id": "merit", "name": "功勋", "binding": "bound", "scope": "role", "tradable": false }
],
"tokenScopedItems": ["merit"]
}
下面这段是 S 层仿真脚本,它读上面的归属矩阵,与 9.1 的清单对齐,校验完备性与一致性,并推导出每个物品的流通边界(能否跨玩家交易、能否账号内共享)。它同样带一个反例自检:删掉一行,确认完备性校验能抓到缺行。
# file: sim/ch09_ownership.py
# tier: S
# verify: python3 sim/ch09_ownership.py --config config/ --out out/
"""归属维度(绑定/非绑定 × 账号/角色)的完备性与流通边界校验。
读 config/ch09_ownership.json 的归属矩阵,与 ch09_layering 定义的资源/货币清单
对齐,判定:1) 完备性——清单每一项恰好一行;2) 取值合法;3) 一致性——绑定物不可
交易、非绑定物必须可交易、赛季代币必须绑定且角色级。
产出 out/ch09_ownership.json + out/ch09_ownership_matrix.csv + out/ch09_ownership.svg。
"""
from __future__ import annotations
import argparse, csv, json, os, sys, xml.etree.ElementTree as ET
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch09_layering import build_nodes # noqa: E402
BINDINGS, SCOPES = ["bound", "unbound"], ["account", "role"]
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 flow_rule(it):
"""由 binding + scope 推导该物品的流通边界。
binding 决定能不能离开玩家(unbound ⇒ 可跨玩家交易);
scope 决定能不能在账号内共享(account ⇒ 账号内角色共享)。
"""
return {"crossPlayerTrade": it["binding"] == "unbound",
"accountShare": it["scope"] == "account", "characterUse": True}
def validate(cfg, expected, tokens):
errs, seen = [], set()
for it in cfg.get("items", []):
iid = it.get("id")
if not iid:
errs.append("矩阵存在无 id 的行")
continue
if iid in seen:
errs.append(f"重复行:{iid}")
seen.add(iid)
if it.get("binding") not in BINDINGS:
errs.append(f"{iid}: binding 非法 {it.get('binding')!r}")
if it.get("scope") not in SCOPES:
errs.append(f"{iid}: scope 非法 {it.get('scope')!r}")
if not isinstance(it.get("tradable"), bool):
errs.append(f"{iid}: tradable 必须为布尔")
continue
b, s, t = it["binding"], it["scope"], it["tradable"]
if b == "bound" and t:
errs.append(f"{iid}: 绑定物不可交易(bound ⇒ tradable=false)")
if b == "unbound" and not t:
errs.append(f"{iid}: 非绑定物应可交易(unbound ⇒ tradable=true)")
if iid in tokens and (s != "role" or b != "bound"):
errs.append(f"{iid}: 赛季代币必须绑定且角色级")
missing, extra = sorted(expected - seen), sorted(seen - expected)
errs += [f"缺行:{m} 没有归属定义" for m in missing] + [f"越界行:{e} 不在清单里" for e in extra]
coverage = {(b, s): [] for b in BINDINGS for s in SCOPES}
for it in cfg.get("items", []):
if (it.get("binding"), it.get("scope")) in coverage:
coverage[(it["binding"], it["scope"])].append(it["id"])
return {"itemCount": len(cfg.get("items", [])), "missing": missing, "extra": extra,
"coverage": {f"{b}/{s}": sorted(v) for (b, s), v in coverage.items()},
"rules": {it["id"]: flow_rule(it) for it in cfg.get("items", []) if it.get("id")},
"errors": errs, "ok": not errs}
def self_test(cfg, expected, tokens):
"""反例自检:删掉一行,确认完备性校验能抓到缺行。"""
poisoned = dict(cfg, items=[it for it in cfg["items"] if it["id"] != "mithril"])
return {"detectsMissing": "mithril" in validate(poisoned, expected, tokens)["missing"],
"ok": validate(poisoned, expected, tokens)["ok"]}
def draw_svg(path, cfg):
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": "0 0 640 340"})
for ci, s in enumerate(SCOPES):
for ri, b in enumerate(BINDINGS):
x, y = 70 + ci * 260, 50 + ri * 110
ids = [it["name"] for it in cfg["items"] if it["binding"] == b and it["scope"] == s]
ET.SubElement(svg, "rect", {"x": f"{x}", "y": f"{y}", "width": "240", "height": "90",
"rx": "8", "fill": "#eef2f7", "stroke": "#8aa0b6"})
ET.SubElement(svg, "text", {"x": f"{x + 12}", "y": f"{y + 24}", "font-size": "12",
"fill": "#1f2937"}).text = f"{b} / {s}"
ET.SubElement(svg, "text", {"x": f"{x + 12}", "y": f"{y + 50}", "font-size": "11",
"fill": "#475569"}).text = "、".join(ids) if ids else "(空)"
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()
cfg = load(args.config, "ch09_ownership")
expected, tokens = {n["id"] for n in build_nodes(args.config)}, set(cfg.get("tokenScopedItems", []))
rep, st = validate(cfg, expected, tokens), self_test(cfg, expected, tokens)
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "ch09_ownership.json"), "w", encoding="utf-8") as f:
json.dump({"version": 1, **rep, "selfTest": st}, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch09_ownership_matrix.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "name", "binding", "scope", "tradable", "crossPlayerTrade", "accountShare"])
for it in cfg["items"]:
r = flow_rule(it)
w.writerow([it["id"], it["name"], it["binding"], it["scope"], it["tradable"],
r["crossPlayerTrade"], r["accountShare"]])
draw_svg(os.path.join(args.out, "ch09_ownership.svg"), cfg)
trade = sorted(i for i, r in rep["rules"].items() if r["crossPlayerTrade"])
share = sorted(i for i, r in rep["rules"].items() if r["accountShare"])
print(f"items={rep['itemCount']} missing={rep['missing']} extra={rep['extra']}")
print(f"crossPlayerTrade={trade} accountShare={share}")
print(f"selfTest(detectsMissing)={st['detectsMissing']}")
print("== 归属校验:OK" if rep["ok"] and st["detectsMissing"] else "== 归属校验:FAIL")
return 0 if rep["ok"] and st["detectsMissing"] else 1
if __name__ == "__main__":
sys.exit(main())
本机实跑输出:
items=8 missing=[] extra=[]
crossPlayerTrade=['gold', 'grain', 'jade'] accountShare=['gold', 'iron', 'jade']
selfTest(detectsMissing)=True
== 归属校验:OK
八行齐全、无缺行、无越界,可跨玩家交易的只有粮草、银币、玉璧三样,账号内共享的是铁矿、银币、玉璧。反例自检确认了「删掉秘银那一行会被抓到」。
9.2.4 归属如何影响别的系统
归属不是孤立设定,它像一层滤镜罩在所有涉及资源的系统上:
- 交易市场(第 14 章)只能上架
unbound的东西。绑定物进不了市场,这是市场设计的第一条约束。 - 风控与打金识别(第 15 章)盯的就是
unbound货币的异常流向。绑定货币刷再多也出不了号,风险敞口小得多。 - 账号内共享影响的是「小号养大号」:
account级的资源可以在小号和大号之间搬,role级的搬不动。
把归属写进配置、再用脚本校验,好处是这些下游系统不用各自去猜某个资源能不能交易——它们读同一份矩阵。
小结
- 归属由两个正交维度构成:绑定与否决定能否跨玩家交易,作用域决定能否账号内共享。
- 四种组合覆盖所有形态;越靠经济外圈的东西越该是
unbound / account,赛季代币必须bound / role。 - 归属矩阵的正确性靠完备性(每个物品恰好一行)与一致性(bound/unbound 与 tradable 匹配)两条。
- 归属是风控边界,不是体验设定;下游的交易、风控、账号共享系统都读同一份矩阵。
分层与归属都定完,资源的「身份」就齐了。下一节进入本章的核心:用复式账本把每一次资源变动记成一笔借贷,并证明「总量守恒」。
阅读导航:上一节:9.1 资源分类与货币分层 · 下一节:9.3 账本模型与守恒约束 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。