9.2 绑定与非绑定、账号与角色

上一节按稀缺度给资源分了层,但稀缺度不回答「它归谁、能流到哪」。本节给资源加第二个正交维度:绑定与非绑定、账号级与角色级,用一张四组合表讲清同一资源在不同归属下能不能跨玩家交易、能不能账号内共享,并配一段可跑的 Python 脚本校验归属矩阵的完备性、一致性与反例自检。

本节目标:给资源与货币加第二个维度——归属,讲清同一资源在不同归属下能不能流通,并把它写成一份可校验的归属矩阵。

站内那篇经济平衡长文 也提过「绑定货币」的概念,但它是单机口径:通关就结束,绑定与否只影响体验,不影响生态。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账号级且可交易银币、玉璧是是

从表里能读出两条设计规律:

  1. 越是往经济外圈走的东西,越该是 unbound / account。银币、玉璧可以跨玩家交易、账号内共享,所以它们必须被最严地监控投放。
  2. 代币必须 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 账本模型与守恒约束 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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