13.3 赛季重置与货币回收

赛季边界是 SLG 最彻底的货币回收手段。本节让四种方案(不清零 / 全额清零 / 按比例压缩 / 压缩+保底)各跑 10 个赛季、每赛季 90 天:不清零的赛季初存量膨胀到 2500 万,全额清零永远停在 0,按比例压缩收敛到 13 万却低于健康带,只有压缩+保底收敛到 20 万。附纯 Python SVG 与 TypeScript 对照。

站内的赛季重置通用原则 把「为什么要重置、重置要保住什么」讲清楚了——那是一篇通用原则。本节补的是 SLG 特有、且能跑出数字的那一半:世界地图重开之后,货币到底该怎么处理,四种方案的长期行为分别是什么。

上一节的沉淀与回收都在一个赛季内部起作用。但 SLG 有一个别的品类没有的手段:赛季边界会把整个大地图重开——地块清零、联盟解散、赛季货币作废。这是一次性、彻底的回收。用得好,它能让经济长期健康;用不好,它会把玩家一整个赛季的积累一把火烧掉。

赛季边界的四种处理方式

赛季结束时,手里那笔存量怎么办?业界常见四种做法:

  • 不清零:原样带进下赛季。玩家体验最好,但存量跨赛季累积,长期必然通胀。
  • 全额清零:一笔归零,所有人从同一起跑线开始。公平感最强,但玩家赛季末的一切积累瞬间蒸发,是最伤留存的选项。
  • 按比例压缩:按固定比例折算,比如 10% 带进下赛季。兼顾「有保留」和「有削减」,但比例定死了以后,长期会走向某个固定点。
  • 压缩 + 保底:先按比例压缩,再设一个下限——压缩后低于保底值就按保底值算。它给「老玩家」和「新玩家」各留了一条腿。

这四种方案的长期行为不能靠直觉判断——尤其是「按比例压缩」,它到底是收敛、发散还是归零,取决于压缩比例和赛季内增长率的乘积。必须跑起来看。

赛季内怎么长,赛季边界怎么切

模型分两层:赛季内逐日结算(存量按 growBp 增长,每天还有固定进账 income),赛季边界做一次上面四种处理之一。下面这份是 D 层(静态层)配置。

{
  "version": 1,
  "note": "赛季重置与货币回收:四种赛季边界处理方案跑多赛季,看赛季初存量能否长期收敛到健康带。赛季内为逐日整数结算;赛季边界只做一次「保留 / 清零 / 压缩 / 压缩+保底」。",
  "seasonDays": 90,
  "seasons": 10,
  "withinSeason": { "startStock": 150000, "incomePerDay": 12000, "growBpPerDay": 20 },
  "healthyBand": { "lo": 150000, "hi": 300000 },
  "convergeBandBp": 500,
  "schemes": [
    { "id": "none", "name": "不清零", "kind": "keep" },
    { "id": "full", "name": "全额清零", "kind": "zero" },
    { "id": "compress", "name": "按比例压缩", "kind": "compress", "compressNum": 10, "compressDen": 100 },
    { "id": "compress_floor", "name": "压缩+保底", "kind": "compressFloor", "compressNum": 10, "compressDen": 100, "floor": 200000 }
  ]
}

healthyBand 是赛季初存量的健康带:赛季刚开始时,存量落在 15 万到 30 万之间,既不会因为太少而卡住开荒,也不会因为太多而让新赛季失去意义。convergeBandBp 定义「收敛」:相邻两个赛季的赛季初存量差异小于 500 bp(5%)。

S 层:四种方案跑 10 个赛季

下面这段是 S 层仿真脚本,读上面那份配置,对四种方案各跑 10 个赛季、每赛季 90 天,落成 JSON + CSV,并把四条赛季初存量曲线画成一张 SVG(纵轴取 log10,因为「不清零」会涨到千万级,线性轴会把其他三条压成一条平线)。

# file: sim/ch13_season.py
# tier: S
# verify: python3 sim/ch13_season.py --config config/ --out out/
"""赛季重置与货币回收:四种方案跑 10 个赛季(每赛季 90 天),看赛季初存量能否长期收敛。

赛季内逐日整数结算;赛季边界做一次保留 / 清零 / 按比例压缩 / 压缩+保底。
产出 out/ch13_season.json + out/ch13_season.csv + out/ch13_season.svg(纵轴 log10,纯 Python SVG)。
"""

from __future__ import annotations

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

COLORS = {"none": "#ef4444", "full": "#94a3b8", "compress": "#1f6feb", "compress_floor": "#22c55e"}


def run_season(start: int, days: int, income: int, grow_bp: int) -> int:
    """赛季内逐日结算:存量按 growBp 增长,同时每天固定进账 income。整数地板除。"""
    s = start
    for _ in range(days):
        s = s + income + s * grow_bp // 10000
    return s


def apply_reset(kind: str, end: int, scheme: dict) -> int:
    if kind == "keep":
        return end
    if kind == "zero":
        return 0
    compressed = end * scheme["compressNum"] // scheme["compressDen"]
    if kind == "compress":
        return compressed
    return max(scheme["floor"], compressed) if kind == "compressFloor" else end


def simulate(cfg: dict, scheme: dict) -> list[dict]:
    ws, start, rows = cfg["withinSeason"], cfg["withinSeason"]["startStock"], []
    for season in range(1, cfg["seasons"] + 1):
        end = run_season(start, cfg["seasonDays"], ws["incomePerDay"], ws["growBpPerDay"])
        reset = apply_reset(scheme["kind"], end, scheme)
        rows.append({"season": season, "scheme": scheme["id"], "startStock": start,
                     "endStock": end, "resetStock": reset})
        start = reset
    return rows


def summarize(cfg: dict, scheme: dict, rows: list[dict]) -> dict:
    starts = [r["startStock"] for r in rows]
    prev = starts[-2] if len(starts) >= 2 else 0
    converge_bp = (starts[-1] - prev) * 10000 // prev if prev > 0 else 0
    band = cfg["healthyBand"]
    converged = abs(converge_bp) <= cfg["convergeBandBp"]
    return {"id": scheme["id"], "name": scheme["name"], "startStocks": starts,
            "finalStartStock": starts[-1], "finalEndStock": rows[-1]["endStock"],
            "convergeBp": converge_bp, "converged": converged,
            "healthy": converged and band["lo"] <= starts[-1] <= band["hi"]}


def draw_svg(path: str, series: dict, titles: dict, band: dict) -> None:
    W, H, pl, pr, pt, pb = 720, 400, 64, 24, 30, 44
    top = math.log10(1 + max(max(v) for v in series.values()))
    iw, ih = 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 t in range(0, int(top) + 1):
        y = pt + ih * (1 - t / top)
        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(10 ** t)
    for key, vals in series.items():
        n = max(len(vals) - 1, 1)
        pts = " ".join(f"{pl + iw * i / n:.1f},{pt + ih * (1 - math.log10(1 + v) / top):.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 = \
        f"《王朝纪元》赛季初货币存量(log10,健康带 {band['lo']}~{band['hi']})"
    for i, (key, name) in enumerate(titles.items()):
        x = pl + i * 164
        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_season.json"), encoding="utf-8") as fh:
        cfg = json.load(fh)
    all_rows, summaries, series, titles = [], [], {}, {}
    for scheme in cfg["schemes"]:
        rows = simulate(cfg, scheme)
        all_rows.extend(rows)
        summaries.append(summarize(cfg, scheme, rows))
        series[scheme["id"]] = [r["startStock"] for r in rows]
        titles[scheme["id"]] = scheme["name"]
    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "ch13_season.json"), "w", encoding="utf-8") as fh:
        json.dump({"version": 1, "seasons": cfg["seasons"], "healthyBand": cfg["healthyBand"],
                   "schemes": summaries}, fh, ensure_ascii=False, indent=2)
    with open(os.path.join(a.out, "ch13_season.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["season", "scheme", "startStock", "endStock", "resetStock"])
        for r in all_rows:
            w.writerow([r["season"], r["scheme"], r["startStock"], r["endStock"], r["resetStock"]])
    draw_svg(os.path.join(a.out, "ch13_season.svg"), series, titles, cfg["healthyBand"])
    print(json.dumps({"finalStartStock": {s["id"]: s["finalStartStock"] for s in summaries},
                      "convergeBp": {s["id"]: s["convergeBp"] for s in summaries},
                      "healthy": {s["id"]: s["healthy"] for s in summaries}}, ensure_ascii=False))
    return 0


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

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

{"finalStartStock": {"none": 25025257, "full": 0, "compress": 134268, "compress_floor": 200000},
 "convergeBp": {"none": 2563, "full": 0, "compress": 0, "compress_floor": 0},
 "healthy": {"none": false, "full": false, "compress": false, "compress_floor": true}}

结果:只有「压缩+保底」落在健康带

把四个方案的赛季初存量轨迹摊开看:

方案第 1 季第 2 季第 5 季第 10 季相邻差收敛?健康?
不清零150,0001,361,5126,625,41825,025,257+2563 bp否否
全额清零150,0000000 bp是否
按比例压缩150,000136,151134,271134,2680 bp是否
压缩+保底150,000200,000200,000200,0000 bp是是

四个方案里,只有「压缩+保底」同时满足「收敛」和「落在健康带」。逐条看为什么:

  • 不清零:赛季内从 15 万长到 136 万(×9),原样带进下赛季,第二季起点就是 136 万。之后每季再 ×9,10 季之后赛季初存量到了 2500 万——相邻差 +2563 bp,远没有收敛。赛季初就有 2500 万的货币,意味着物价、战力、一切数值都失去意义。
  • 全额清零:赛季初永远是 0,相邻差 0,形式上「收敛」了。但它收敛到一个错误的值:0 不在健康带里。更糟的是赛季内的数据——从 0 出发,90 天冲到 118 万,然后全部烧掉。玩家每赛季末看着 118 万蒸发,这是留存杀手。
  • 按比例压缩:10% 的压缩比,配合赛季内约 ×9 的增长,乘积小于 1,所以它收敛——但收敛到 134,268,低于健康带下沿 15 万。它跑得太狠了:老玩家每个赛季都被削掉九成,长期看和清零的体验差别不大,只是没那么决绝。
  • 压缩+保底:同样的 10% 压缩比,加一条 20 万的保底线。压缩结果低于 20 万时按 20 万算,于是曲线在第 2 季就落到 20 万并钉在那里。它收敛,而且收敛到健康带正中。

「压缩+保底」的妙处在于它把两个目标拆给了两个参数:压缩比决定曲线下降得多快,保底值决定它最终停在哪。想让经济更紧,把保底从 20 万降到 16 万即可;想给老玩家更多连续性,把压缩比从 10% 提到 20%(只要乘积仍小于 1,就还是收敛)。两个旋钮互不干扰。

P 层:同一份配置,TypeScript 侧再算一遍

赛季边界的算术不该只活在 Python 里。下面这段是 P 层原型,读同一份 config/ch13_season.json,把赛季内结算与四种边界处理重算一遍。

// file: proto/src/ch13_season.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch13_season.py 同构:读同一份 config/ch13_season.json,赛季内逐日整数结算 + 赛季边界处理。
// 全程整数(Math.floor 地板除),与 Python 侧结果逐位一致。

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

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

export interface Scheme { id: string; name: string; kind: string; compressNum?: number; compressDen?: number; floor?: number }
export interface SeasonConfig {
  seasonDays: number; seasons: number;
  withinSeason: { startStock: number; incomePerDay: number; growBpPerDay: number };
  healthyBand: { lo: number; hi: number }; convergeBandBp: number; schemes: Scheme[];
}
export interface SeasonRow { season: number; scheme: string; startStock: number; endStock: number; resetStock: number }

export function loadSeasonConfig(): SeasonConfig {
  return JSON.parse(readFileSync(join(CONFIG_DIR, "ch13_season.json"), "utf-8")) as SeasonConfig;
}

export function runSeason(start: number, days: number, income: number, growBp: number): number {
  let s = start;
  for (let d = 0; d < days; d++) s = s + income + Math.floor((s * growBp) / 10000);
  return s;
}

export function applyReset(kind: string, end: number, scheme: Scheme): number {
  if (kind === "keep") return end;
  if (kind === "zero") return 0;
  const compressed = Math.floor((end * (scheme.compressNum ?? 0)) / (scheme.compressDen ?? 1));
  if (kind === "compress") return compressed;
  return kind === "compressFloor" ? Math.max(scheme.floor ?? 0, compressed) : end;
}

export function simulate(cfg: SeasonConfig, scheme: Scheme): SeasonRow[] {
  const ws = cfg.withinSeason;
  let start = ws.startStock;
  const rows: SeasonRow[] = [];
  for (let season = 1; season <= cfg.seasons; season++) {
    const end = runSeason(start, cfg.seasonDays, ws.incomePerDay, ws.growBpPerDay);
    const reset = applyReset(scheme.kind, end, scheme);
    rows.push({ season, scheme: scheme.id, startStock: start, endStock: end, resetStock: reset });
    start = reset;
  }
  return rows;
}

export function summarize(cfg: SeasonConfig, scheme: Scheme, rows: SeasonRow[]) {
  const starts = rows.map((r) => r.startStock);
  const prev = starts.length >= 2 ? starts[starts.length - 2] : 0;
  const convergeBp = prev > 0 ? Math.floor(((starts[starts.length - 1] - prev) * 10000) / prev) : 0;
  const last = starts[starts.length - 1];
  const converged = Math.abs(convergeBp) <= cfg.convergeBandBp;
  return {
    id: scheme.id, name: scheme.name, startStocks: starts, finalStartStock: last,
    finalEndStock: rows[rows.length - 1].endStock, convergeBp, converged,
    healthy: converged && last >= cfg.healthyBand.lo && last <= cfg.healthyBand.hi,
  };
}

export function seasonReport() {
  const cfg = loadSeasonConfig();
  return { seasons: cfg.seasons, schemes: cfg.schemes.map((s) => summarize(cfg, s, simulate(cfg, s))) };
}

断言写进 proto/test/ch13.test.ts,跑 node --test proto/test/ch13.test.ts:

✔ 13.3 赛季内逐日结算:90 天后 150000 涨到 1361512
✔ 13.3 四种赛季边界处理:保留 / 清零 / 压缩 / 压缩+保底
✔ 13.3 不清零:赛季初存量逐季膨胀,未收敛
✔ 13.3 全额清零:赛季初恒为 0,收敛但不健康
✔ 13.3 按比例压缩:收敛到 134268,低于健康带下沿
✔ 13.3 压缩+保底:收敛到保底值 200000,落在健康带内
✔ 13.3 唯一健康方案:四种里只有压缩+保底同时满足收敛与健康带
ℹ tests 7  ℹ pass 7  ℹ fail 0

7 条全过。TypeScript 侧算出的 25025257 / 0 / 134268 / 200000 与 Python 侧逐位一致——这才是「一份配置、两处消费」的意义:赛季重置这种既要在后台结算、又要在运营后台做预测的规则,两侧算错一个数就会对不上账。

设计要点

把这一节压成三条可以直接拿去用的结论:

  1. 赛季边界的本质是「给长期通胀一个硬性上限」。赛季内不管你怎么调沉淀,只要赛季边界不清零,存量就会跨赛季累积;而赛季边界一开,存量曲线就被强制截断一次。
  2. 「不清零」不是温和,是失控。它的赛季初存量 10 季涨到 2500 万,而且相邻差 +2563 bp,完全没有收敛迹象。任何「先不清零、以后再调」的方案,本质都是把问题推给未来。
  3. 「压缩+保底」是目前唯一同时满足收敛与健康带的方案,因为它的两个参数各自负责一个目标:压缩比管下降速度,保底值管最终落点。这是 SLG 赛季货币回收最值得采用的默认档。

至此,第 13 章「通胀与通缩治理」收尾:13.1 教你诊断,13.2 给赛季内的处方,13.3 给跨赛季的硬性上限。三者合起来,是一个能跑、能对账、能长期收敛的经济治理闭环。下一章进入玩家之间的交换——交易与市场。

阅读导航:上一节:沉淀机制与回收活动 · 下一节:市场形态 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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