站内的经济平衡长文 用放置类讲「产出要配消耗」的通用道理,产业运营清单 则列了一串可以做的回收活动。两篇都停在「做什么」。这一节往前走一步:同一段经济,叠加不同的沉淀与回收组合,存量曲线会走到哪——用能跑出数字的模型回答,而不是靠感觉。
上一节我们把经济诊断成通胀、通缩或健康,并归因到产出侧 / 消耗侧 / 外部注入。诊断解决「知道有病」,这一节解决「开什么药」。药只有两类:沉淀机制与回收活动。
沉淀与回收,是两种不同的动作
这两个词经常被混着用,但它们在守恒式里的位置完全不同:
- 沉淀机制是常驻的消耗槽。它天天在扣,把资源从存量里持续抽走。典型形态有三种:升级消耗(固定量,玩家升建筑/科技时扣)、仓库税(按存量比例扣,存量越高扣得越狠)、交易税(按交易量比例扣)。
- 回收活动是脉冲式的抽取。它在某个时间窗内集中把存量抽掉一大块,然后结束。典型形态有三种:限时兑换(活动期内用存量换道具)、赛季宝箱(赛季节点集中消耗)、拍卖行抽水(按交易额比例持续抽,介于两者之间)。
区别在于曲线形状。常驻沉淀在存量曲线上表现为「压低整条线并改变它的斜率」;脉冲回收表现为「在某一处砸出一个坑,然后曲线自己爬回来」。很多团队只做回收活动,觉得「活动一开存量就掉了」,但活动一结束曲线又爬回去——因为回收没有改变稳态,只改变了到达稳态的路径。
三档沉淀,三档回收
《王朝纪元》里我们各挑三档,做成可开关的项:
| 类别 | 机制 | 形态 | 参数 |
|---|---|---|---|
| 沉淀 | 升级消耗 | 固定量/日 | 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,000 | 100% | 3,800,000 | +58 bp/日 | 否 |
| 仅沉淀 | 609,708 | 16% | 796,000 | −4 bp/日 | 是 |
| 仅回收 | 2,197,282 | 57% | 2,197,282 | +59 bp/日 | 否 |
| 沉淀+回收 | 482,984 | 12% | 792,800 | +11 bp/日 | 是 |
三个数字值得记住:
- 不治理就是线性发散。基线经济每天净流入 20,000,150 天累积到 380 万,末段还在以 58 bp/日往上走——它没有天花板。
- 沉淀把终值压到 16%,并且让曲线走平。仓库税 + 交易税共 200 bp/日的比例项,把稳态锁在 60 万附近(
endStock609,708,末段 −4 bp/日)。比例项自带天花板这件事,在这一行被量化了。 - 沉淀+回收进一步压到 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 个赛季之后,谁的赛季初存量能长期收敛在健康带里。
阅读导航:上一节:通胀症状与成因 · 下一节:赛季重置与货币回收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。