8.1 把病称了重:无压缩下,老玩家的进度指数 10 季涨到新人的 85 倍,追赶要 540 天。本节开药。处方只有三个旋钮——重置(赛季边界把哪些进度清掉)、继承(清掉的进度里有多少折算成新赛季的起点)、压缩(新赛季的产出基准与追赶加成)。卷一第 13 章《通胀症状与成因》诊断的是单一货币存量的趋势方向,本节诊断的是多维进度指数跨赛季能不能收敛——口径不同,但都靠跑出来的数说话。
三个旋钮各管一件事
- 重置(reset):赛季结束时,各类进度按保留比例折算。资源保留 30%、城建等级保留 0%(清到 floor 1)、英雄保留 100%、科技保留 50%、荣誉保留 20%——这些值都写在共享的
config/season.json里。 - 继承(inherit):清掉的荣誉不是白清,按
prestigeNum / prestigeDen = 1/4折算成新赛季声望,且封顶prestigeCapPct = 25%。它把「上赛季的战功」变成「下赛季的一点起手资本」。 - 压缩(compression):新赛季产出基准保持 100%(不额外放水),靠重置与继承把存量压回起点;追赶一侧则用
newbieCatchupOutputPct = 150与newbieShieldHours = 72给新人托底。
关键认知:三个旋钮不是「一起调」,而是按维度分开调。 城建可以清得最狠,英雄必须保住,资源折中——这才是 SLG 赛季设计的真正结构。
五维进度:哪些该重置、哪些该永久
把玩家进度拆成五个维度,每维一条成长律、一个保留率:
| 维度 | 赛季内成长 | 保留率 | 设计定位 |
|---|---|---|---|
| 资源存量 | 逐日复利 + 固定进账 | 30% | 折中:留一点,别留太多 |
| 城建等级 | 每 3 天 +1,封顶 30 | 0%(floor 1) | 纯赛季资产:每季重新盖 |
| 英雄养成 | 每 4 天 +1,封顶 60 | 100% | 永久资产:绝不重置 |
| 科技等级 | 每 2 天 +1,封顶 25 | 50% | 半衰:留一半,起点固定 |
| 赛季荣誉 | 逐日复利 + 固定进账 | 20% | 赛季战绩,低保留 + 继承 |
综合进度指数 = 各维度值乘以权重求和。权重把「1 级城建」「1 级科技」这种量纲不同的东西拉到可比区间,让指数成为一条能判断收敛的单一曲线。
S 层:四种方案跑 10 个赛季
配置放在 config/n08_compress.json:它给出五维的成长律与权重、健康带(700,000 ~ 1,100,000),以及四个对照方案——keep_all(全 100%)、zero_all(全 0%)、season_default(从 season.json 读默认档)、aggressive(更低的保留率)。重置/继承/压缩的默认值不在这里重复。
下面这段是 S 层仿真脚本。它读上面那份配置与共享的 season.json,对四个方案各跑 10 个赛季,看赛季初综合进度指数能否收敛进健康带:
# file: sim/n08_compress.py
# tier: S
# verify: python3 sim/n08_compress.py --config config/ --out out/
"""8.2 重置 / 继承 / 压缩:把玩家进度拆成五个维度(资源 / 城建 / 英雄 / 科技 / 荣誉),
每赛季内各自成长,赛季边界按 season.json 的 reset/inherit/compression 处理,
看综合进度指数跨赛季能否收敛进健康带。四种方案对照:无重置 / 全额清零 / 赛季默认档 / 激进压缩。
产出 out/n08_compress.json + out/n08_compress.csv + out/n08_compress.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_dim(dim: dict, v: int, days: int) -> int:
"""维度一赛季的成长:等级类按 daysPerLevel 递增并封顶;存量类逐日复利 + 固定进账。"""
if "daysPerLevel" in dim:
return min(dim["maxLevel"], v + days // dim["daysPerLevel"])
for _ in range(days):
v = v + dim["dailyGain"] + v * dim["growBpPerDay"] // 10000
return v
def reset_dim(dim: dict, v: int, keep_pct: int) -> int:
"""赛季边界处理:按 keepPct 折算,带 floor 的维度取 max(floor, 折算值)。"""
kept = v * keep_pct // 100
if "floorKey" in dim:
return max(dim["_floor"], kept)
return kept
def index_of(dims: list[dict], values: dict) -> int:
return sum(values[d["id"]] * d["weight"] for d in dims)
def keep_pct_for(scheme: dict, dim: dict, season_cfg: dict) -> int:
key = dim["keepPctKey"]
if scheme.get("fromSeason"):
return season_cfg["reset"][key]
return scheme["keepPct"][key]
def simulate(cfg: dict, season_cfg: dict, scheme: dict, days: int) -> list[dict]:
dims = cfg["dims"]
# 维度预算:floor 值预先塞进 dim
for d in dims:
if "floorKey" in d:
d["_floor"] = season_cfg["reset"][d["floorKey"]]
values = {d["id"]: d["start"] for d in dims}
rows = []
for s in range(1, cfg["seasons"] + 1):
start_index = index_of(dims, values)
end_values = {d["id"]: grow_dim(d, values[d["id"]], days) for d in dims}
end_index = index_of(dims, end_values)
prestige = min(end_values["honor"] * season_cfg["inherit"]["prestigeNum"] // season_cfg["inherit"]["prestigeDen"],
end_values["honor"] * season_cfg["inherit"]["prestigeCapPct"] // 100)
rows.append({"season": s, "scheme": scheme["id"], "indexStart": start_index, "indexEnd": end_index,
"prestigeStart": prestige, **{d["id"]: values[d["id"]] for d in dims}})
values = {d["id"]: reset_dim(d, end_values[d["id"]], keep_pct_for(scheme, d, season_cfg)) for d in dims}
return rows
def summarize(cfg: dict, scheme: dict, rows: list[dict]) -> dict:
# 第 2 季起看收敛(第 1 季起点是开服初始值,不是重置后的值)
starts = [r["indexStart"] for r in rows]
tail = starts[-3:]
prev = starts[-2]
converge_bp = (starts[-1] - prev) * 10000 // prev if prev > 0 else 0
band = cfg["healthyBand"]
in_band = all(band["lo"] <= x <= band["hi"] for x in tail)
converged = abs(converge_bp) <= 200
return {"id": scheme["id"], "name": scheme["name"], "indexStarts": starts,
"finalIndexStart": starts[-1], "convergeBp": converge_bp, "converged": converged,
"inBand": in_band, "healthy": converged and in_band}
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_compress")
season_cfg = load(args.config, "season")
days = season_cfg["seasonLengthDays"]
all_rows, summaries, series, titles = [], [], {}, {}
for scheme in cfg["schemes"]:
rows = simulate(cfg, season_cfg, scheme, days)
all_rows.extend(rows)
summaries.append(summarize(cfg, scheme, rows))
series[scheme["id"]] = [r["indexStart"] for r in rows]
titles[scheme["id"]] = scheme["name"]
with open(os.path.join(args.out, "n08_compress.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
cols = ["season", "scheme", "indexStart", "indexEnd", "resources", "buildingLevel", "heroLevel", "techLevel", "honor"]
w.writerow(cols)
for r in all_rows:
w.writerow([r[c] for c in cols])
render(series["season_default"], os.path.join(args.out, "n08_compress.svg"),
f"season-default progress index across {cfg['seasons']} seasons")
report = {"version": 1, "days": days, "healthyBand": cfg["healthyBand"], "schemes": summaries}
with open(os.path.join(args.out, "n08_compress.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps({"finalIndexStart": {s["id"]: s["finalIndexStart"] for s in summaries},
"converged": {s["id"]: s["converged"] for s in summaries},
"inBand": {s["id"]: s["inBand"] for s in summaries}}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑得到:
{"finalIndexStart": {"keep_all": 4117490, "zero_all": 20000, "season_default": 872783, "aggressive": 266957}, "converged": {"keep_all": false, "zero_all": true, "season_default": true, "aggressive": true}, "inBand": {"keep_all": false, "zero_all": false, "season_default": true, "aggressive": false}}
结果:只有分层重置把指数收敛进健康带
四个方案的赛季初综合指数(健康带 700,000 ~ 1,100,000):
| 方案 | 第 1 季 | 第 2 季 | 第 5 季 | 第 10 季 | 相邻差 | 收敛? | 在带内? |
|---|---|---|---|---|---|---|---|
| 无重置 | 1,200,000 | 1,954,496 | 2,699,216 | 4,117,490 | +823 bp | 否 | 否 |
| 全额清零 | 1,200,000 | 20,000 | 20,000 | 20,000 | 0 bp | 是 | 否 |
| 赛季默认档 | 1,200,000 | 869,490 | 874,282 | 872,783 | −1 bp | 是 | 是 |
| 激进压缩 | 1,200,000 | 407,946 | 276,981 | 266,957 | 0 bp | 是 | 否 |
只有「赛季默认档」同时满足收敛与健康带。 逐条看为什么:
- 无重置:五维全部 100% 保留,指数每季被复利推高,10 季涨到 4,117,490,相邻差 +823 bp,还在往上走。它就是 8.1 那条膨胀曲线的多维版本。
- 全额清零:每季起点回到
floor(城建 1 级),综合指数塌到 20,000,形式上收敛但落在健康带下方。玩家每季末的一切积累归零,是留存杀手。 - 赛季默认档:分层重置——城建清到 1、英雄保 100%、科技保 50%、资源保 30%、荣誉保 20%。指数第 2 季落到 869,490,此后在 872,783 ~ 887,621 之间微幅摆动,相邻差 −1 bp,稳稳落在健康带正中。
- 激进压缩:把资源压到 10%、英雄只保 50%、科技 20%,指数收敛到 266,957,低于健康带下沿——砍过头了,和清零在体验上差别不大。
分层重置的真相:五个维度各归各位
把「赛季默认档」的五个维度逐季摊开(赛季初的值):
| 维度 | 第 1 季 | 第 2 季 | 第 3 季 | 第 10 季 | 行为 |
|---|---|---|---|---|---|
| 资源存量 | 200,000 | 100,782 | 69,177 | 54,407 | 收敛到固定点 |
| 城建等级 | 20 | 1 | 1 | 1 | 每季归零到 floor |
| 英雄养成 | 40 | 55 | 60 | 60 | 永久保留并封顶 |
| 科技等级 | 12 | 12 | 12 | 12 | 半衰,起点固定 |
| 赛季荣誉 | 5,000 | 4,677 | 4,611 | 4,594 | 收敛到固定点 |
这张表是整节的落点:赛季设计不是「重置」或「不重置」的二选一,而是给每个维度单独选一个保留率。 城建等级是纯赛季资产,每季重新盖,让新赛季有开荒感;英雄是永久资产,绝不重置,让老玩家的投入有归属;科技半衰、资源与荣誉收敛——三个层次合起来,才让综合指数收敛进健康带。如果所有维度用同一个保留率(无论 0 还是 100),要么塌、要么涨,没有中间态。
P 层:同一份配置,TypeScript 侧再算一遍
赛季结算这种规则,后台要跑、运营后台要做预测,两侧算错一个数就对不上账。下面这段是 P 层原型,读同一份 config/n08_compress.json 与 config/season.json,把五维成长与边界处理重算一遍,与 Python 侧逐位一致:
// file: proto/src/n08_season.ts
// tier: P
// verify: node --test proto/test/n08.test.ts
// 与 sim/n08_compress.py 同构:读同一份 config/n08_compress.json + config/season.json,
// 五维进度赛季内成长 + 赛季边界重置/继承/压缩,全程整数(Math.floor 地板除),与 Python 逐位一致。
import { loadConfig } from "./core.ts";
export interface Dim {
id: string; name: string; start: number; weight: number; keepPctKey: string;
dailyGain?: number; growBpPerDay?: number; daysPerLevel?: number; maxLevel?: number; floorKey?: string;
}
export interface Scheme { id: string; name: string; fromSeason?: boolean; keepPct?: Record<string, number> }
export interface CompressConfig { seasons: number; dims: Dim[]; healthyBand: { lo: number; hi: number }; schemes: Scheme[] }
export interface SeasonReset { reset: Record<string, number>; inherit: Record<string, number>; seasonLengthDays: number }
export function loadCompress(): CompressConfig {
return loadConfig<CompressConfig>("n08_compress");
}
export function loadSeasonCfg(): SeasonReset {
return loadConfig<SeasonReset>("season");
}
export function growDim(dim: Dim, v: number, days: number): number {
if (dim.daysPerLevel !== undefined) return Math.min(dim.maxLevel!, v + Math.floor(days / dim.daysPerLevel));
for (let d = 0; d < days; d++) v = v + dim.dailyGain! + Math.floor((v * dim.growBpPerDay!) / 10000);
return v;
}
export function resetDim(dim: Dim, v: number, keepPct: number, floor: number): number {
const kept = Math.floor((v * keepPct) / 100);
return dim.floorKey !== undefined ? Math.max(floor, kept) : kept;
}
export function indexOf(dims: Dim[], values: Record<string, number>): number {
return dims.reduce((acc, d) => acc + values[d.id] * d.weight, 0);
}
export function keepPctFor(scheme: Scheme, dim: Dim, seasonCfg: SeasonReset): number {
return scheme.fromSeason ? seasonCfg.reset[dim.keepPctKey] : scheme.keepPct![dim.keepPctKey];
}
export interface CompressRow { season: number; scheme: string; indexStart: number; indexEnd: number; values: Record<string, number> }
export function simulate(cfg: CompressConfig, seasonCfg: SeasonReset, scheme: Scheme): CompressRow[] {
const days = seasonCfg.seasonLengthDays;
const floorOf = (d: Dim) => (d.floorKey ? seasonCfg.reset[d.floorKey] : 0);
let values: Record<string, number> = {};
for (const d of cfg.dims) values[d.id] = d.start;
const rows: CompressRow[] = [];
for (let s = 1; s <= cfg.seasons; s++) {
const startIndex = indexOf(cfg.dims, values);
const endValues: Record<string, number> = {};
for (const d of cfg.dims) endValues[d.id] = growDim(d, values[d.id], days);
rows.push({ season: s, scheme: scheme.id, indexStart: startIndex, indexEnd: indexOf(cfg.dims, endValues), values: { ...values } });
const next: Record<string, number> = {};
for (const d of cfg.dims) next[d.id] = resetDim(d, endValues[d.id], keepPctFor(scheme, d, seasonCfg), floorOf(d));
values = next;
}
return rows;
}
export function summarize(cfg: CompressConfig, scheme: Scheme, rows: CompressRow[]) {
const starts = rows.map((r) => r.indexStart);
const prev = starts[starts.length - 2];
const convergeBp = prev > 0 ? Math.floor(((starts[starts.length - 1] - prev) * 10000) / prev) : 0;
const tail = starts.slice(-3);
const inBand = tail.every((x) => x >= cfg.healthyBand.lo && x <= cfg.healthyBand.hi);
const converged = Math.abs(convergeBp) <= 200;
return { id: scheme.id, name: scheme.name, indexStarts: starts, finalIndexStart: starts[starts.length - 1], convergeBp, converged, inBand, healthy: converged && inBand };
}
export function seasonReport() {
const cfg = loadCompress();
const sc = loadSeasonCfg();
return { seasons: cfg.seasons, schemes: cfg.schemes.map((s) => summarize(cfg, s, simulate(cfg, sc, s))) };
}
断言写进 proto/test/n08.test.ts,跑 node --test proto/test/n08.test.ts:
✔ 8.2 赛季默认档:赛季初指数轨迹与 Python 一致
✔ 8.2 赛季默认档:收敛且落在健康带
✔ 8.2 无重置发散、全额清零塌陷、激进压缩偏低
✔ 8.2 分层重置:英雄封顶、城建归零、科技半衰
ℹ tests 4 ℹ pass 4 ℹ fail 0
4 条全过。TypeScript 侧算出的 872783 / 4117490 / 20000 / 266957 与 Python 侧逐位一致——这就是「一份配置、两处消费」:赛季结算的规则改一个保留率,两侧结果同步变,不会一边清到 1 级、一边还留着 20 级。
设计要点
把这一节压成三条:
- 三个旋钮分维度调,不分方案调。 城建 0%、英雄 100%、科技 50%、资源 30%、荣誉 20% 是「分层重置」,不是「统一重置」。统一 0% 会塌成 20,000,统一 100% 会涨到 411 万。
- 健康带是判断收敛的标尺。 只有「赛季默认档」落在 700,000 ~ 1,100,000 之间;激进压缩虽然收敛(266,957)却在带外——收敛不等于健康,收敛到一个太低的固定点同样是失败。
- 继承把「清零」变成「换一种形式保留」。 荣誉保留 20%,但另有 1/4 折算成新赛季声望(封顶 25%)。老玩家的战功没有消失,只是从「存量」变成了「起手资本」——这是重置方案能被玩家接受的关键。
下一节处理另一半:新赛季里,一个刚加入的新玩家,靠 150% 追赶产出与 72 小时护盾,到底能追到什么程度、护盾又值多少。
阅读导航:上一节:8.1 跨赛季膨胀的成因与代价 · 下一节:8.3 老玩家回归与新玩家追赶 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。