3.1 资源循环与行为闭环

把 SLG 的核心循环从「一张画着箭头的示意图」变成可判定的有向图:用配置驱动的《王朝纪元》行为闭环(7 节点 9 边),校验弱连通、强连通分量、最短回环与悬空汇点,并讲清为什么闭环的定量仿真要留到第 12 章再做,Python 与 TypeScript 两侧判定逐位一致。

如果你去问一个 SLG 策划「你们的核心循环是什么」,多数人会画一张箭头图:产出 → 消耗 → 成长 → 再产出。这张图没错,但它几乎从不被检验——箭头画反了没人发现,某个环节其实是死路也没人发现,某个资源产出了却没有任何消耗槽也没人发现。这一节要做的事只有一件:把这张图变成一个可以判定的对象。

动手前先划清边界。站内的经济平衡长文 给了一套通用的经济平衡框架,但它举例用的是放置类:离线收益、线性挂机、指数成长。那套框架在放置类里很好用,因为放置类的「循环」几乎是隐式的——玩家关掉游戏,经济照跑。SLG 不一样:SLG 的循环由玩家的行为驱动,玩家不上线,地块不产出、军队不出征、资源不进账。所以本卷第 9–15 章换用 SLG 的大地图地块口径,而本章先只做一件事:把循环的结构刻画清楚。

另外,站内已有一个 SLG 技术架构专题 ,那 9 篇讲的是前后端分层、模块拆分与协议设计,明确写了「不讨论玩法数值」。本章讲的正好是它没碰的那一半:产品与数值里的循环结构。

循环不是四个箭头,是一张有向图

「产出 → 消耗 → 成长 → 再产出」是四个箭头串成一个圈。但真实的 SLG 循环没这么干净:产出不是一种,消耗不是一种,成长会反过来提高产出,而掠夺又会在中间插一脚。把它写成一串箭头,你迟早会画错。

更好的做法是把它建成一张有向图:节点是「阶段」,边是「资源或行为的流向」,每条边上标一个载体(木材、粮草、行动力、战力……)。这样「循环是否闭合」就等价于一个标准的图论问题:这张图是不是一个环。

下面这份配置定义了《王朝纪元》的核心循环。它是 D 层(静态层)的产物——一张被配置化的系统图,本身不产生数字,但可以被脚本判定。

{
  "version": 1,
  "root": "gather",
  "note": "核心循环的结构刻画。节点=阶段,边=资源/行为流向。本章只做闭包与回环判定,不做定量结算(定量闭环见第 12 章)。",
  "stages": [
    { "id": "gather", "name": "采集产出", "kind": "source" },
    { "id": "build",  "name": "城建消耗", "kind": "sink" },
    { "id": "train",  "name": "征兵消耗", "kind": "sink" },
    { "id": "tech",   "name": "科技消耗", "kind": "sink" },
    { "id": "grow",   "name": "产能成长", "kind": "transform" },
    { "id": "march",  "name": "行军队列", "kind": "transform" },
    { "id": "raid",   "name": "交战掠夺", "kind": "source" }
  ],
  "edges": [
    { "from": "gather", "to": "build", "carrier": "木材/石料" },
    { "from": "gather", "to": "train", "carrier": "粮草/铁矿" },
    { "from": "gather", "to": "tech",  "carrier": "石料/铁矿" },
    { "from": "build",  "to": "grow",  "carrier": "战力" },
    { "from": "tech",   "to": "grow",  "carrier": "产能系数" },
    { "from": "train",  "to": "march", "carrier": "军队" },
    { "from": "march",  "to": "raid",  "carrier": "行动力" },
    { "from": "raid",   "to": "gather","carrier": "掠夺资源回流" },
    { "from": "grow",   "to": "gather","carrier": "更高产能" }
  ]
}

注意这里其实藏着两条闭合路径:一条是「采集 → 城建 → 产能成长 → 更高产能」的三步小循环,另一条是「采集 → 征兵 → 行军 → 交战掠夺 → 资源回流」的四步大循环。一个好的核心循环从来不是单环,而是多条环路共享同一个起点——玩家无论偏内政还是偏军事,最终都会回到「采集」。

闭包判定要回答四个问题

有了图,判定就变成四个可以机械回答的问题:

  1. 弱连通:把每条边当成无向的,整张图是不是连在一起?如果「交战掠夺」孤立成一个岛,玩家永远走不到它,那它就不是循环的一部分。
  2. 强连通分量(SCC):整张图有几个「互相都可达」的极大子集?核心循环应该恰好是一个包含根节点的强连通分量;如果拆成两个,说明循环断成了两截。
  3. 最短回环:从根节点出发、再回到根节点,最短要走几步?这个数字直接告诉玩家「多久能感到一次完整闭环」。
  4. 悬空汇点:有没有哪个节点只有进、没有出?那就是一个「资源黑洞」——玩家把资源投进去,却拿不到任何反馈。

四个问题的答案里,只要有一个不对,循环就是坏的。下面这段是 S 层仿真脚本,读 config/ 里的图配置,把四个判定一次算完,并画出循环结构图。

# file: sim/ch03_loop.py
# tier: S
# verify: python3 sim/ch03_loop.py --config config/ --out out/
"""核心循环的结构刻画:把「产出→消耗→成长→再产出」建成有向图,判定它是否闭合成环。

只做结构判定(弱连通 / 强连通分量 / 最短回环 / 悬空汇点);定量闭环见第 12 章。
产出:out/ch03_loop.json + out/ch03_loop_edges.csv + out/ch03_loop.svg
"""

from __future__ import annotations

import argparse, csv, json, math, os, xml.etree.ElementTree as ET
from collections import defaultdict, deque


def load_graph(cfg_dir):
    with open(os.path.join(cfg_dir, "ch03_loop.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    return cfg["root"], cfg["stages"], cfg["edges"]


def adjacency(stages, edges):
    adj = {s["id"]: [] for s in stages}
    for e in edges:
        adj[e["from"]].append(e["to"])
    return adj


def weakly_connected(stages, edges) -> bool:
    und = defaultdict(set)
    for s in stages:
        und[s["id"]]
    for e in edges:
        und[e["from"]].add(e["to"]); und[e["to"]].add(e["from"])
    seen, dq = set(), deque([stages[0]["id"]])
    while dq:
        n = dq.popleft()
        if n in seen:
            continue
        seen.add(n); dq.extend(und[n] - seen)
    return len(seen) == len(stages)


def tarjan_scc(adj):
    index, low, on, stack, res, cnt = {}, {}, {}, [], [], [0]

    def sc(v):
        index[v] = low[v] = cnt[0]; cnt[0] += 1
        stack.append(v); on[v] = True
        for w in adj[v]:
            if w not in index:
                sc(w); low[v] = min(low[v], low[w])
            elif on.get(w):
                low[v] = min(low[v], index[w])
        if low[v] == index[v]:
            comp = []
            while True:
                w = stack.pop(); on[w] = False; comp.append(w)
                if w == v:
                    break
            res.append(sorted(comp))

    for v in adj:
        if v not in index:
            sc(v)
    return res


def shortest_cycle(adj, root) -> int:
    """从 root 出发再回到 root 的最短有向回环长度(边数);无回环返回 -1。"""
    dq, seen = deque((n, 1) for n in adj[root]), {root}
    while dq:
        node, dist = dq.popleft()
        if node == root:
            return dist
        if node in seen:
            continue
        seen.add(node)
        for w in adj[node]:
            dq.append((w, dist + 1))
    return -1


def draw_svg(path, stages, edges, main_loop):
    W, H, R, n = 680, 420, 150, len(stages)
    cx, cy = W / 2, H / 2 + 12
    ang = lambda i: 2 * math.pi * i / n - math.pi / 2
    pos = {s["id"]: (cx + R * math.cos(ang(i)), cy + R * math.sin(ang(i))) for i, s in enumerate(stages)}
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}"})
    for e in edges:
        (x1, y1), (x2, y2) = pos[e["from"]], pos[e["to"]]
        ET.SubElement(svg, "line", {"x1": f"{x1:.0f}", "y1": f"{y1:.0f}", "x2": f"{x2:.0f}", "y2": f"{y2:.0f}",
                                    "stroke": "#8aa0b6", "stroke-width": "1.4"})
    for s in stages:
        x, y = pos[s["id"]]
        ET.SubElement(svg, "circle", {"cx": f"{x:.0f}", "cy": f"{y:.0f}", "r": "30",
                                      "fill": "#1f6feb" if s["id"] in main_loop else "#cbd5e1"})
        ET.SubElement(svg, "text", {"x": f"{x:.0f}", "y": f"{y + 4:.0f}", "font-size": "11",
                                    "text-anchor": "middle", "fill": "#fff"}).text = s["name"]
    os.makedirs(os.path.dirname(path), exist_ok=True)
    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()
    root, stages, edges = load_graph(args.config)
    adj = adjacency(stages, edges)
    sccs = tarjan_scc(adj)
    main_loop = next((c for c in sccs if root in c), [])
    sinks = sorted(n for n, outs in adj.items() if not outs)
    report = {"version": 1, "nodes": len(stages), "edges": len(edges),
              "weaklyConnected": weakly_connected(stages, edges), "sccCount": len(sccs),
              "mainLoopSize": len(main_loop), "loopLength": shortest_cycle(adj, root),
              "danglingSinks": sinks, "closed": len(main_loop) == len(stages) and not sinks}
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch03_loop.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch03_loop_edges.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["from", "to", "carrier"])
        for e in edges:
            w.writerow([e["from"], e["to"], e["carrier"]])
    draw_svg(os.path.join(args.out, "ch03_loop.svg"), stages, edges, main_loop)
    print(json.dumps(report, ensure_ascii=False))
    return 0 if report["closed"] else 1


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"version": 1, "nodes": 7, "edges": 9, "weaklyConnected": true, "sccCount": 1,
 "mainLoopSize": 7, "loopLength": 3, "danglingSinks": [], "closed": true}

四个判定全部通过:7 个节点、9 条边,弱连通为真,强连通分量恰好 1 个,主循环覆盖全部 7 个节点,最短回环 3 步,悬空汇点为空,整张图 closed: true。脚本还会顺手画出 out/ch03_loop.svg(循环结构图)和 out/ch03_loop_edges.csv(边表),退出码为 0。

最短回环 3 步,说明什么

loopLength: 3 是这张图里信息量最大的一个数。它来自 gather → build → grow → gather 这条路径——玩家采集资源、投进城建、城建提高产能、产能带来更多采集。三步,就是玩家能感到一次完整闭环的最短路径。

这个数字是设计上的「呼吸频率」:三步太短,循环会显得廉价(玩家一眼看到头);但如果最短回环要七八步,玩家要很久才能感到「我做的事有回报」。好的做法是让最短回环足够短(3–4 步,给新玩家即时反馈),同时让最长回环足够长(这里的大循环是 4 步:采集 → 征兵 → 行军 → 掠夺 → 回流),让老玩家在同一个循环里有更深的事可做。

这一步只判定了「循环存在且闭合」。循环跑起来会不会崩——产出与消耗能不能长期平衡、稳态在哪里——那是存量流量模型的事,本章不做。第 12 章会用同一份 config/ 跑 180 天闭环,把这里的结构图变成一条条会动的曲线。本章给结构,第 12 章给动态。

行为闭环:玩家的每一步都要有下一步

有向图刻画的是「资源怎么流」,但 SLG 的循环真正的主角是玩家行为。同一个节点,从资源视角看是「消耗」,从行为视角看是「玩家点了一下建造按钮」。一个健康的循环,要求每个行为都能接上下一个行为:

  • 采集让你有资源可花(下一步:建造);
  • 建造让你有产能可涨(下一步:采集更多);
  • 征兵让你有兵可派(下一步:行军);
  • 行军让你有仗可打(下一步:掠夺);
  • 掠夺让你有资源回流(下一步:回到建造或征兵)。

如果某个行为做完之后玩家「不知道下一步干嘛」,那这个行为就是行为闭环的悬空汇点——图上可能还连着边,但玩家体验上断了。所以上面脚本里的 danglingSinks 检查,本质上是在替玩家问一句:这个节点做完,下一步往哪走?

P 层:同一张图,TypeScript 侧也判一遍

结构判定不该只活在 Python 里。原型层用同一份 config/ch03_loop.json 再判一次——如果两边算出的 loopLength 不一致,说明「唯一真源」名不副实。下面这段是 P 层原型,用 Kosaraju/Tarjan 的同构实现重算一遍闭包。

// file: proto/src/ch03_loop.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch03_loop.py 同构:读同一份 config/ch03_loop.json,判定核心循环是否闭合成环。

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const HERE = dirname(fileURLToPath(import.meta.url));
const CONFIG_DIR = join(HERE, "..", "..", "config");

interface Stage { id: string; name: string; kind: string }
interface Edge { from: string; to: string; carrier: string }
interface LoopConfig { root: string; stages: Stage[]; edges: Edge[] }

export function loadLoopConfig(): LoopConfig {
  return JSON.parse(readFileSync(join(CONFIG_DIR, "ch03_loop.json"), "utf-8")) as LoopConfig;
}

export function adjacency(cfg: LoopConfig): Record<string, string[]> {
  const adj: Record<string, string[]> = {};
  for (const s of cfg.stages) adj[s.id] = [];
  for (const e of cfg.edges) adj[e.from].push(e.to);
  return adj;
}

export function tarjanScc(adj: Record<string, string[]>): string[][] {
  const index: Record<string, number> = {};
  const low: Record<string, number> = {};
  const onStack: Record<string, boolean> = {};
  const stack: string[] = [];
  const result: string[][] = [];
  let counter = 0;
  const sc = (v: string): void => {
    index[v] = low[v] = counter++;
    stack.push(v); onStack[v] = true;
    for (const w of adj[v]) {
      if (!(w in index)) { sc(w); low[v] = Math.min(low[v], low[w]); }
      else if (onStack[w]) { low[v] = Math.min(low[v], index[w]); }
    }
    if (low[v] === index[v]) {
      const comp: string[] = [];
      for (;;) { const w = stack.pop()!; onStack[w] = false; comp.push(w); if (w === v) break; }
      result.push(comp.sort());
    }
  };
  for (const v of Object.keys(adj)) if (!(v in index)) sc(v);
  return result;
}

export function shortestCycle(adj: Record<string, string[]>, root: string): number {
  const q: Array<[string, number]> = adj[root].map((n) => [n, 1] as [string, number]);
  const seen = new Set<string>([root]);
  while (q.length) {
    const [node, dist] = q.shift()!;
    if (node === root) return dist;
    if (seen.has(node)) continue;
    seen.add(node);
    for (const w of adj[node]) q.push([w, dist + 1]);
  }
  return -1;
}

export function closureReport() {
  const cfg = loadLoopConfig();
  const adj = adjacency(cfg);
  const sccs = tarjanScc(adj);
  const mainLoop = sccs.find((c) => c.includes(cfg.root)) ?? [];
  const sinks = Object.keys(adj).filter((n) => adj[n].length === 0).sort();
  return {
    nodes: cfg.stages.length, edges: cfg.edges.length, sccCount: sccs.length,
    mainLoopSize: mainLoop.length, loopLength: shortestCycle(adj, cfg.root),
    danglingSinks: sinks, closed: mainLoop.length === cfg.stages.length && sinks.length === 0,
  };
}

对应的断言写进 proto/test/ch03.test.ts,跑 node --test proto/test/ch03.test.ts,5 条全过(含本节这一条):nodes=7、edges=9、sccCount=1、mainLoopSize=7、loopLength=3、danglingSinks=[]、closed=true。Python 与 TypeScript 对同一份配置算出逐位一致的结果,这才是「一份配置、两处消费」的意义。

把闭包判定接进回归

结构判定的价值不在跑一次,而在每次改配置都跑。做法很简单:把 ch03_loop.json 的 closed 当成一条断言——策划往循环里加了一个新系统(比如「宝物强化」),只要它没接回主循环,danglingSinks 就会冒出一个新节点,closed 立刻变 false,CI 直接红。这样,「核心循环闭合」就从一个口头共识变成了一个有守卫的不变量。

下一节我们会给这个循环装上时钟:一天之内玩家的行为密度怎么分布、一周的峰值压在哪、一个赛季分成几个相位——把静态的闭环变成有节拍的东西。

阅读导航:上一节:生命周期阶段与体验目标 · 下一节:日/周/赛季三级节奏 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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