站内那篇赛季重置与货币回收 讲的是通用重置原则——为什么要重置、重置时该保住什么,是一篇跨品类的产业向文章。本章不重复它:本章给的是 SLG 跨赛季膨胀控制的可跑方案——世界地图重开、排行榜结算、继承与压缩各自怎么算,以及一个赛季接一个赛季跑下去,进度指数会不会失控。第 8 章是全卷的一个差异化落点,因为它面对的是别的品类没有的问题:赛季会重开,但玩家不会重来。
膨胀不是「涨了」,是三个成因叠加
新手以为跨赛季膨胀就是「老玩家资源多」。真正让数值失控的是三个机制叠加:
- 存量复利:赛季内资源按固定百分比逐日复利增长,赛季边界又不清零,于是复利跨赛季叠加——第 N 季的起点是第 N−1 季的终点。
- 继承无衰减:城建等级、科技、英雄、荣誉这些进度,如果原样带进下赛季,等于给老玩家一笔永久领先,新玩家从第一天起就落后。
- 产出基准不降:新赛季的产出基准不变(
newSeasonOutputPct = 100),老玩家的高存量继续以同样的相对速率产出,差距只会被自己的基数放大。
三者里任意一个单独存在都不致命,叠在一起就会让赛季初的进度指数一季比一季高。判断失控不能靠体感,得把它算出来。
代价要能量:差距、倍数、追赶天数
膨胀的代价可以压成三个能算的数:
- 绝对差距:老玩家进度指数减去新玩家的绝对差。
- 相对倍数:老玩家指数是新人起点的几倍(用万分比表示)。
- 追赶天数:一个同速成长的新玩家,要追到老玩家当前指数需要多少天。
前两个描述「差多少」,第三个描述「追不追得上」——它才是真正决定留存的那个数。如果一个新玩家要几百天才能追平,那他在追上之前早就流失了。
先把膨胀写进配置
下面这份是 D 层(静态层)配置。它给出老玩家与新玩家两套成长参数,以及代价阈值;赛季长度直接复用共享的 config/season.json,不在这里重复:
{
"version": 1,
"note": "第 8 章 8.1 跨赛季膨胀核算(n08 前缀,独立于卷一 ch*)。无重置、无压缩时,老玩家进度指数跨赛季复利膨胀,新玩家始终从起点开始。season.json 提供赛季长度;此处补充成长律与代价阈值。整数百分比与整数,禁浮点。",
"seasons": 10,
"cohorts": [
{ "id": "veteran", "name": "老玩家(无压缩)", "startIndex": 1000, "dailyGain": 60, "growBpPerDay": 30 },
{ "id": "newbie", "name": "新玩家(同速)", "startIndex": 1000, "dailyGain": 60, "growBpPerDay": 30 }
],
"costBands": {
"note": "代价阈值:老新差距超过 gapWarnBp(相对倍数)或追赶天数超过 catchupWarnDays 即视为失控;catchupCapDays 为追赶模拟的封顶天数,跑满即判「不可达」。",
"gapWarnBp": 50000,
"catchupWarnDays": 180,
"catchupCapDays": 720
}
}
growBpPerDay 是逐日复利(万分比),dailyGain 是每日固定进账。两者一起决定进度指数的走势:复利决定长期斜率,固定进账决定早期抬升。
S 层:无压缩下跑 10 个赛季
下面这段是 S 层仿真脚本。它读上面那份配置与共享的 season.json,把老玩家的进度指数一季一季往下跑,每个赛季初都量一次「绝对差距 / 相对倍数 / 追赶天数」,落成 JSON + CSV + SVG:
# file: sim/n08_inflation.py
# tier: S
# verify: python3 sim/n08_inflation.py --config config/ --out out/
"""8.1 跨赛季膨胀核算:无重置、无压缩时,老玩家进度指数跨赛季复利膨胀,
新玩家始终从起点开始,量化「绝对差距 / 相对差距 / 追赶天数」三项代价。
赛季长度取自共享的 config/season.json;成长律与阈值在 config/n08_inflation.json。
产出 out/n08_inflation.json + out/n08_inflation.csv + out/n08_inflation.svg。整数运算,禁浮点。"""
from __future__ import annotations
import argparse
import csv
import json
import os
from svg_line import render
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as fh:
return json.load(fh)
def grow_day(p: int, daily_gain: int, grow_bp: int) -> int:
"""进度指数一天的演化:固定进账 + 存量复利(整数地板除)。"""
return p + daily_gain + p * grow_bp // 10000
def season_end(p: int, days: int, daily_gain: int, grow_bp: int) -> int:
for _ in range(days):
p = grow_day(p, daily_gain, grow_bp)
return p
def catchup_days(target: int, start: int, daily_gain: int, grow_bp: int, cap: int) -> tuple[int, int]:
"""从 start 出发、按同一成长律追到 target 需要的天数;跑满 cap 即判不可达。"""
p, d = start, 0
while p < target and d < cap:
p = grow_day(p, daily_gain, grow_bp)
d += 1
return d, p
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "n08_inflation")
season = load(args.config, "season")
days = season["seasonLengthDays"]
vet = next(c for c in cfg["cohorts"] if c["id"] == "veteran")
newbie = next(c for c in cfg["cohorts"] if c["id"] == "newbie")
bands = cfg["costBands"]
starts = [vet["startIndex"]]
for _ in range(1, cfg["seasons"]):
starts.append(season_end(starts[-1], days, vet["dailyGain"], vet["growBpPerDay"]))
rows = []
for n, v in enumerate(starts, start=1):
cd, _ = catchup_days(v, newbie["startIndex"], newbie["dailyGain"],
newbie["growBpPerDay"], bands["catchupCapDays"])
rows.append({"season": n, "veteranStart": v, "newbieStart": newbie["startIndex"],
"absoluteGap": v - newbie["startIndex"], "gapRatioBp": v * 10000 // newbie["startIndex"],
"catchupDays": cd, "catchupReachable": cd < bands["catchupCapDays"]})
last = rows[-1]
summary = {"seasons": cfg["seasons"], "seasonLengthDays": days,
"finalVeteranStart": last["veteranStart"], "finalAbsoluteGap": last["absoluteGap"],
"finalGapRatioBp": last["gapRatioBp"], "finalCatchupDays": last["catchupDays"],
"catchupReachable": last["catchupReachable"],
"gapWarn": last["gapRatioBp"] >= bands["gapWarnBp"],
"catchupWarn": (not last["catchupReachable"]) or last["catchupDays"] > bands["catchupWarnDays"]}
with open(os.path.join(args.out, "n08_inflation.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["season", "veteranStart", "newbieStart", "absoluteGap", "gapRatioBp", "catchupDays", "catchupReachable"])
for r in rows:
w.writerow([r["season"], r["veteranStart"], r["newbieStart"], r["absoluteGap"],
r["gapRatioBp"], r["catchupDays"], r["catchupReachable"]])
render([r["veteranStart"] for r in rows], os.path.join(args.out, "n08_inflation.svg"),
f"veteran progress index across {cfg['seasons']} seasons (no compression)")
report = {"version": 1, "days": days, "rows": rows, "summary": summary}
with open(os.path.join(args.out, "n08_inflation.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(summary, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(--config config/ --out out/)得到:
{"seasons": 10, "seasonLengthDays": 60, "finalVeteranStart": 85176, "finalAbsoluteGap": 84176, "finalGapRatioBp": 851760, "finalCatchupDays": 540, "catchupReachable": true, "gapWarn": true, "catchupWarn": true}
out/n08_inflation.csv 摊开就是这条曲线:
| 赛季 | 老玩家指数 | 新玩家起点 | 绝对差距 | 相对倍数 | 追赶天数 |
|---|---|---|---|---|---|
| 1 | 1,000 | 1,000 | 0 | 1.00× | 0 |
| 2 | 5,103 | 1,000 | 4,103 | 5.10× | 60 |
| 3 | 10,012 | 1,000 | 9,012 | 10.01× | 120 |
| 5 | 22,919 | 1,000 | 21,919 | 22.92× | 240 |
| 10 | 85,176 | 1,000 | 84,176 | 85.18× | 540 |
结果:追赶天数恒等于领先时长
这张表里最值得盯住的不是倍数,是追赶天数那一列:60、120、240、540——它精确地等于老玩家的领先天数(第 N 季领先 N−1 个赛季,每个赛季 60 天)。这不是巧合:当老玩家和新玩家用同一套成长律时,新人追平老玩家当前进度所需的,正是老玩家已经跑过的那段时间。 追赶天数不可能小于领先时长,除非给新人额外的手段(这正是 8.3 要处理的事)。
三个代价信号同时亮红灯(gapWarn 与 catchupWarn 均为 true):第 10 季老玩家指数是新人的 85.18 倍,绝对差距 84,176,追赶要 540 天——而配置里的失控线是 180 天。540 天远超任何一款 SLG 的留存窗口,意味着无压缩下新玩家在追上之前就已经走光了。
B 层:闭式对照与「纯复利」陷阱
S 层跑出来的是整数逐日仿真。它到底对不对?把膨胀当纯复利估又会错多少?下面这段是 B 层基准脚本,做两个口径的对照:① 精确闭式解,验证整数仿真与实数数学一致;② 纯复利简化模型(忽略固定进账),量出「把膨胀当纯复利估」的偏差。软告警,退出码恒为 0:
# file: sim/n08_inflation_bench.py
# tier: B
# verify: python3 sim/n08_inflation_bench.py --config config/ --out out/
"""8.1 膨胀偏差基准(软告警,退出码恒 0)。两个口径:
① 精确闭式对照:M(n) = (M(1) + K/(A-1))·A^(n-1) − K/(A-1),验证整数逐日仿真与实数闭式一致;
② 纯复利简化对照:M_simple(n) = M(1)·A^(n-1)(忽略固定进账),量出「把膨胀当纯复利估」的偏差有多大。
闭式用 numpy 实数,仿真用整数地板除;偏差一律千分比。"""
from __future__ import annotations
import argparse
import json
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n08_inflation import load, season_end # noqa: E402
CLOSED_FORM_THRESHOLD_PERMILLE = 30
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "n08_inflation")
season = load(args.config, "season")
days = season["seasonLengthDays"]
vet = next(c for c in cfg["cohorts"] if c["id"] == "veteran")
g, gain, start = vet["growBpPerDay"], vet["dailyGain"], vet["startIndex"]
sim = [start]
for _ in range(1, cfg["seasons"]):
sim.append(season_end(sim[-1], days, gain, g))
a_day = 1.0 + g / 10000.0
A = a_day ** days
K = gain * (A - 1.0) / (a_day - 1.0)
n = np.arange(1, cfg["seasons"] + 1, dtype=float)
closed = (start + K / (A - 1.0)) * np.power(A, n - 1) - K / (A - 1.0)
simple = start * np.power(A, n - 1)
closed_dev = int(round((sim[-1] - closed[-1]) * 1000 / closed[-1]))
simple_dev = int(round((sim[-1] - simple[-1]) * 1000 / simple[-1]))
rep = {"version": 1, "seasons": cfg["seasons"], "simFinal": sim[-1],
"closedFormFinal": int(round(closed[-1])), "closedFormDeviationPermille": closed_dev,
"simpleCompoundFinal": int(round(simple[-1])), "simpleDeviationPermille": simple_dev,
"thresholdPermille": CLOSED_FORM_THRESHOLD_PERMILLE,
"warn": abs(closed_dev) > CLOSED_FORM_THRESHOLD_PERMILLE}
with open(os.path.join(args.out, "n08_inflation_bench.json"), "w", encoding="utf-8") as fh:
json.dump(rep, fh, ensure_ascii=False, indent=2)
print(f"{'WARN' if rep['warn'] else 'OK'} simFinal={sim[-1]} closedFinal={rep['closedFormFinal']} "
f"closedDev={closed_dev}permille simpleFinal={rep['simpleCompoundFinal']} simpleDev={simple_dev}permille")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑得到:
OK simFinal=85176 closedFinal=85858 closedDev=-8permille simpleFinal=5041 simpleDev=15897permille
两个数讲两件事:
- 闭式偏差 −8‰。整数逐日仿真跑出的 85,176 与实数闭式解的 85,858 只差 8‰,远在 30‰ 阈值内。这说明 S 层的实现是对的——两个独立推导(逐日迭代 vs 几何级数求和)给出同一个数。
- 纯复利偏差 +15897‰(约 15.9 倍)。如果策划图省事,把跨赛季膨胀当成纯复利
M(n)=M(1)·A^(n-1)来估,会得到第 10 季 5,041——而真实值是 85,176,低估了 15.9 倍。原因是固定进账dailyGain项在早期被复利滚起来,贡献了绝大部分存量。这就是为什么膨胀不能靠公式手估、必须跑仿真。
这两个数字合起来是一句方法论:闭式解用来验证仿真没写错,简化模型用来暴露直觉的错。把两者都放进 B 层,才不会既骗自己又骗策划。
与卷一、与 8.2 的分工
卷一第 13 章《赛季重置与货币回收》讲的是单一货币存量在四种赛季边界方案(不清零 / 清零 / 压缩 / 压缩+保底)下的长期行为——它关心的是「货币会不会通胀到失控」。本节讲的是多维进度指数在无任何控制时的膨胀轨迹与代价核算——它回答的是「如果我们什么都不做,老新差距会长到多大、追赶要多久」。前者是处方,本节是先把病称重。
本节只诊断,不开药。下一节把处方拿出来:重置、继承、压缩三个旋钮分别怎么拧,才能让五维进度指数长期收敛进一个健康带。
阅读导航:上一节:目录 · 下一节:8.2 重置、继承与压缩 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。