13.2 沉淀机制与回收活动

同一段经济,叠加不同的沉淀与回收组合,存量曲线会走到哪?本节用《王朝纪元》跑 150 天:升级消耗、仓库税、交易税三档沉淀,限时兑换、赛季宝箱、拍卖行抽水三档回收,对比四种组合的终值与末段斜率,得出「沉淀决定稳态高度、回收决定到达速度」的结论。

站内的经济平衡长文 用放置类讲「产出要配消耗」的通用道理,产业运营清单 则列了一串可以做的回收活动。两篇都停在「做什么」。这一节往前走一步:同一段经济,叠加不同的沉淀与回收组合,存量曲线会走到哪——用能跑出数字的模型回答,而不是靠感觉。

上一节我们把经济诊断成通胀、通缩或健康,并归因到产出侧 / 消耗侧 / 外部注入。诊断解决「知道有病」,这一节解决「开什么药」。药只有两类:沉淀机制与回收活动。

沉淀与回收,是两种不同的动作

这两个词经常被混着用,但它们在守恒式里的位置完全不同:

  • 沉淀机制是常驻的消耗槽。它天天在扣,把资源从存量里持续抽走。典型形态有三种:升级消耗(固定量,玩家升建筑/科技时扣)、仓库税(按存量比例扣,存量越高扣得越狠)、交易税(按交易量比例扣)。
  • 回收活动是脉冲式的抽取。它在某个时间窗内集中把存量抽掉一大块,然后结束。典型形态有三种:限时兑换(活动期内用存量换道具)、赛季宝箱(赛季节点集中消耗)、拍卖行抽水(按交易额比例持续抽,介于两者之间)。

区别在于曲线形状。常驻沉淀在存量曲线上表现为「压低整条线并改变它的斜率」;脉冲回收表现为「在某一处砸出一个坑,然后曲线自己爬回来」。很多团队只做回收活动,觉得「活动一开存量就掉了」,但活动一结束曲线又爬回去——因为回收没有改变稳态,只改变了到达稳态的路径。

三档沉淀,三档回收

《王朝纪元》里我们各挑三档,做成可开关的项:

类别机制形态参数
沉淀升级消耗固定量/日8000
沉淀仓库税按存量比例150 bp/日
沉淀交易税按存量比例50 bp/日
回收限时兑换脉冲(第 25–30 天)900 bp/日
回收赛季宝箱脉冲(第 60–62 天)700 bp/日
回收拍卖行抽水按存量比例40 bp/日

注意沉淀里的两个比例项——仓库税和交易税都是「按存量比例」扣。这是关键设计:比例项让消耗随存量自动放大,存量越高抽得越狠,曲线因此自带天花板。而固定量的升级消耗没有这个性质,它只能平移曲线,不能封顶。

把机制写成配置

下面这份是 D 层(静态层)配置:一段基线经济(初始存量、每日产出、每日基础消耗),加上三档沉淀、三档回收,以及四个待对比的组合。为了让沉淀/回收的效果看得清,基线经济刻意不做产出增长——把变量控制住,才看得见药效。

{
  "version": 1,
  "note": "沉淀机制与回收活动的效果仿真。基线经济只有固定产出与固定基础消耗;三档沉淀(升级消耗/仓库税/交易税)与三档回收(限时兑换/赛季宝箱/拍卖行抽水)作为可开关项叠加其上。rateBp/drawBp 均为整数基点(1bp=万分之一)。",
  "days": 150,
  "economy": {
    "stock0": 800000,
    "outputPerDay": 90000,
    "outputGrowthBp": 0,
    "baseSinkPerDay": 70000,
    "baseSinkGrowthBp": 0
  },
  "sinks": [
    { "id": "upgrade", "name": "升级消耗", "kind": "flat", "amountPerDay": 8000 },
    { "id": "warehouse_tax", "name": "仓库税", "kind": "proportional", "rateBp": 150 },
    { "id": "trade_tax", "name": "交易税", "kind": "proportional", "rateBp": 50 }
  ],
  "recoveries": [
    { "id": "exchange", "name": "限时兑换", "kind": "burst", "fromDay": 25, "toDay": 30, "drawBp": 900 },
    { "id": "season_chest", "name": "赛季宝箱", "kind": "burst", "fromDay": 60, "toDay": 62, "drawBp": 700 },
    { "id": "auction_cut", "name": "拍卖行抽水", "kind": "proportional", "rateBp": 40 }
  ],
  "combos": [
    { "id": "none", "name": "无沉淀无回收", "sinks": [], "recoveries": [] },
    { "id": "sinks", "name": "仅沉淀", "sinks": ["upgrade", "warehouse_tax", "trade_tax"], "recoveries": [] },
    { "id": "recovery", "name": "仅回收", "sinks": [], "recoveries": ["exchange", "season_chest", "auction_cut"] },
    { "id": "both", "name": "沉淀+回收", "sinks": ["upgrade", "warehouse_tax", "trade_tax"], "recoveries": ["exchange", "season_chest", "auction_cut"] }
  ]
}

S 层:同一段经济,四种组合

下面这段是 S 层仿真脚本,读上面那份配置,对四个组合各跑 150 天逐日整数结算,落成 JSON(每组合的终值/峰值/末段斜率)、CSV(逐日明细)和一张纯 Python 画出来的 SVG 曲线图。

# file: sim/ch13_sinks.py
# tier: S
# verify: python3 sim/ch13_sinks.py --config config/ --out out/
"""沉淀机制与回收活动的效果仿真:对同一段经济分别叠加不同的沉淀 / 回收组合,看存量曲线怎么变。

产出 out/ch13_sinks.json(每组合的峰值/终值/末段斜率)+ out/ch13_sinks.csv(逐日明细)
+ out/ch13_sinks.svg(四组合存量曲线,纯 Python SVG,不依赖 matplotlib)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

COLORS = {"none": "#ef4444", "sinks": "#1f6feb", "recovery": "#22c55e", "both": "#a855f7"}


def sink_of(day: int, stock: int, cfg: dict, enabled: list[str]) -> int:
    total = 0
    for s in cfg["sinks"]:
        if s["id"] in enabled:
            total += s["amountPerDay"] if s["kind"] == "flat" else stock * s["rateBp"] // 10000
    return total


def recovery_of(day: int, stock: int, cfg: dict, enabled: list[str]) -> int:
    total = 0
    for r in cfg["recoveries"]:
        if r["id"] not in enabled:
            continue
        if r["kind"] == "proportional":
            total += stock * r["rateBp"] // 10000
        elif r["fromDay"] <= day <= r["toDay"]:
            total += stock * r["drawBp"] // 10000
    return total


def run_combo(cfg: dict, combo: dict) -> list[dict]:
    eco, stock = cfg["economy"], cfg["economy"]["stock0"]
    output, base_sink = eco["outputPerDay"], eco["baseSinkPerDay"]
    rows = []
    for d in range(cfg["days"]):
        sk = base_sink + sink_of(d, stock, cfg, combo["sinks"])
        rc = recovery_of(d, stock, cfg, combo["recoveries"])
        stock = max(0, stock + output - sk - rc)
        rows.append({"day": d, "combo": combo["id"], "stock": stock, "output": output, "sink": sk, "recovery": rc})
        output = output * (10000 + eco["outputGrowthBp"]) // 10000
        base_sink = base_sink * (10000 + eco["baseSinkGrowthBp"]) // 10000
    return rows


def summarize(combo: dict, rows: list[dict]) -> dict:
    stocks = [r["stock"] for r in rows]
    tail = stocks[-21:]
    slope = (tail[-1] - tail[0]) * 10000 // (tail[0] * (len(tail) - 1)) if tail[0] > 0 else 0
    return {"id": combo["id"], "name": combo["name"], "endStock": stocks[-1], "peakStock": max(stocks),
            "peakDay": stocks.index(max(stocks)), "minStock": min(stocks), "tailSlopeBp": slope,
            "bounded": abs(slope) <= 20, "totalSink": sum(r["sink"] for r in rows),
            "totalRecovery": sum(r["recovery"] for r in rows)}


def draw_svg(path: str, series: dict, titles: dict) -> None:
    W, H, pl, pr, pt, pb = 720, 400, 60, 20, 28, 44
    lo = min(min(v) for v in series.values())
    hi = max(max(v) for v in series.values())
    span, iw, ih = (hi - lo) or 1, W - pl - pr, H - pt - pb
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    for i in range(5):
        y = pt + ih * i / 4
        ET.SubElement(svg, "line", {"x1": str(pl), "y1": f"{y:.1f}", "x2": str(W - pr), "y2": f"{y:.1f}",
                                    "stroke": "#e2e8f0", "stroke-width": "1"})
        ET.SubElement(svg, "text", {"x": str(pl - 6), "y": f"{y + 4:.1f}", "font-size": "10",
                                    "text-anchor": "end", "fill": "#64748b"}).text = str(lo + span * (4 - i) // 4)
    for key, vals in series.items():
        n = max(len(vals) - 1, 1)
        pts = " ".join(f"{pl + iw * i / n:.1f},{pt + ih * (1 - (v - lo) / span):.1f}" for i, v in enumerate(vals))
        ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": COLORS[key], "stroke-width": "2"})
    ET.SubElement(svg, "text", {"x": str(pl), "y": "18", "font-size": "13", "fill": "#111"}).text = \
        "《王朝纪元》资源存量:沉淀 / 回收组合对比"
    for i, (key, name) in enumerate(titles.items()):
        x = pl + i * 168
        ET.SubElement(svg, "rect", {"x": str(x), "y": str(H - 18), "width": "14", "height": "10", "fill": COLORS[key]})
        ET.SubElement(svg, "text", {"x": str(x + 20), "y": str(H - 9), "font-size": "11", "fill": "#334155"}).text = 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/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "ch13_sinks.json"), encoding="utf-8") as fh:
        cfg = json.load(fh)
    all_rows, summaries, series, titles = [], [], {}, {}
    for combo in cfg["combos"]:
        rows = run_combo(cfg, combo)
        all_rows.extend(rows)
        summaries.append(summarize(combo, rows))
        series[combo["id"]] = [r["stock"] for r in rows]
        titles[combo["id"]] = combo["name"]
    base = next(s["endStock"] for s in summaries if s["id"] == "none")
    for s in summaries:
        s["endPctOfNone"] = s["endStock"] * 100 // base
    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "ch13_sinks.json"), "w", encoding="utf-8") as fh:
        json.dump({"version": 1, "days": cfg["days"], "combos": summaries}, fh, ensure_ascii=False, indent=2)
    with open(os.path.join(a.out, "ch13_sinks.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["day", "combo", "stock", "output", "sink", "recovery"])
        for r in all_rows:
            w.writerow([r["day"], r["combo"], r["stock"], r["output"], r["sink"], r["recovery"]])
    draw_svg(os.path.join(a.out, "ch13_sinks.svg"), series, titles)
    print(json.dumps({"endStock": {s["id"]: s["endStock"] for s in summaries},
                      "endPctOfNone": {s["id"]: s["endPctOfNone"] for s in summaries},
                      "bounded": {s["id"]: s["bounded"] for s in summaries}}, ensure_ascii=False))
    return 0


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

在本机跑它(同机相对量级,绝对数值不可移植):

{"endStock": {"none": 3800000, "sinks": 609708, "recovery": 2197282, "both": 482984},
 "endPctOfNone": {"none": 100, "sinks": 16, "recovery": 57, "both": 12},
 "bounded": {"none": false, "sinks": true, "recovery": false, "both": true}}

结果:沉淀决定稳态高度

把四个组合放在一张表里看(bounded 表示末段 20 天的日均斜率绝对值不超过 20 bp,即曲线已经走平):

组合150 天终值占无治理峰值末段斜率走平?
无沉淀无回收3,800,000100%3,800,000+58 bp/日否
仅沉淀609,70816%796,000−4 bp/日是
仅回收2,197,28257%2,197,282+59 bp/日否
沉淀+回收482,98412%792,800+11 bp/日是

三个数字值得记住:

  1. 不治理就是线性发散。基线经济每天净流入 20,000,150 天累积到 380 万,末段还在以 58 bp/日往上走——它没有天花板。
  2. 沉淀把终值压到 16%,并且让曲线走平。仓库税 + 交易税共 200 bp/日的比例项,把稳态锁在 60 万附近(endStock 609,708,末段 −4 bp/日)。比例项自带天花板这件事,在这一行被量化了。
  3. 沉淀+回收进一步压到 12%,且峰值更低。两个脉冲回收事件(第 25–30 天、第 60–62 天)在曲线上砸出两个坑,终值降到 482,984。

为什么「只回收」拦不住

仅回收 这一行是全表最值得琢磨的:它有 40 bp/日的拍卖行抽水(比例项)加两次脉冲,终值 219 万,比无治理的 380 万低了不少,但它没有走平——末段斜率 +59 bp/日,和完全不管的 +58 bp/日几乎一样。

原因就在上面那句「回收没有改变稳态」。40 bp/日的比例抽水对应的稳态是 (90000 − 70000) / 0.004 = 5,000,000——500 万的稳态,150 天根本走不到。脉冲回收把曲线从 800k 砸下来两次,但每次砸完,曲线又朝 500 万爬回去。回收改变的是路径,不是终点。

反过来看 仅沉淀:200 bp/日的比例项对应稳态 (90000 − 70000 − 8000) / 0.02 = 600,000,150 天足够走到,所以曲线走平了。这就是「沉淀决定稳态高度、回收决定到达速度」这句话的定量版本。

这条结论有一个直接推论:想让曲线长期封顶,必须有一个随存量放大的比例沉淀。固定量的升级消耗只能平移曲线(把净流入从 20000 降到 12000),比例项才能改变斜率。如果你的经济里全是固定消耗,通胀迟早会回来——只是晚几天。

曲线长什么样

脚本会顺手把四条曲线画进 out/ch13_sinks.svg(纯 Python xml.etree 生成,不依赖 matplotlib):横轴 150 天,纵轴存量。肉眼能看到的是——红色(无治理)一条直线向上,蓝色(仅沉淀)早期下探后拉平在 60 万,绿色(仅回收)两次下探后仍朝上爬,紫色(沉淀+回收)两次下探后拉平在 48 万。红色和绿色都没有封顶,蓝色和紫色封顶了,这张图就是上面那张表的可视化。

落到配置:三个可以拧的旋钮

真要把这套用到自己的项目上,旋钮只有三个:

  • 比例沉淀的强度:它直接决定稳态高度。想让稳态从 60 万降到 40 万,把比例从 200 bp 提到 300 bp 即可((90000−70000−8000)/0.03 ≈ 400,000)。
  • 脉冲回收的时点与幅度:它决定曲线在到达稳态前被砸多深。幅度太大、太频繁会伤害玩家体验(「我攒的东西老是被活动抽走」),所以脉冲回收更适合做赛季节点,而不是日常。
  • 固定沉淀的量:它只影响曲线的前段高度,不影响稳态。适合用来调「开服前 30 天的资源松紧」,而不适合当长期封顶手段。

这一节讲的是同一个赛季内怎么治通胀。但 SLG 还有一个更彻底的手段——赛季边界。下一节看四种赛季重置方案(不清零 / 全额清零 / 按比例压缩 / 压缩+保底)跑 10 个赛季之后,谁的赛季初存量能长期收敛在健康带里。

阅读导航:上一节:通胀症状与成因 · 下一节:赛季重置与货币回收 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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