8.2 重置、继承与压缩

把玩家进度拆成资源、城建、英雄、科技、荣誉五个维度,每季各自成长、赛季边界按 season.json 的重置/继承/压缩处理,跑 10 个赛季看综合指数能否收敛。四种方案对照:无重置发散到 411 万、全额清零塌到 2 万、赛季默认档收敛进健康带、激进压缩偏低。

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,封顶 300%(floor 1)纯赛季资产:每季重新盖
英雄养成每 4 天 +1,封顶 60100%永久资产:绝不重置
科技等级每 2 天 +1,封顶 2550%半衰:留一半,起点固定
赛季荣誉逐日复利 + 固定进账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,0001,954,4962,699,2164,117,490+823 bp否否
全额清零1,200,00020,00020,00020,0000 bp是否
赛季默认档1,200,000869,490874,282872,783−1 bp是是
激进压缩1,200,000407,946276,981266,9570 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,000100,78269,17754,407收敛到固定点
城建等级20111每季归零到 floor
英雄养成40556060永久保留并封顶
科技等级12121212半衰,起点固定
赛季荣誉5,0004,6774,6114,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 级。

设计要点

把这一节压成三条:

  1. 三个旋钮分维度调,不分方案调。 城建 0%、英雄 100%、科技 50%、资源 30%、荣誉 20% 是「分层重置」,不是「统一重置」。统一 0% 会塌成 20,000,统一 100% 会涨到 411 万。
  2. 健康带是判断收敛的标尺。 只有「赛季默认档」落在 700,000 ~ 1,100,000 之间;激进压缩虽然收敛(266,957)却在带外——收敛不等于健康,收敛到一个太低的固定点同样是失败。
  3. 继承把「清零」变成「换一种形式保留」。 荣誉保留 20%,但另有 1/4 折算成新赛季声望(封顶 25%)。老玩家的战功没有消失,只是从「存量」变成了「起手资本」——这是重置方案能被玩家接受的关键。

下一节处理另一半:新赛季里,一个刚加入的新玩家,靠 150% 追赶产出与 72 小时护盾,到底能追到什么程度、护盾又值多少。

阅读导航:上一节:8.1 跨赛季膨胀的成因与代价 · 下一节:8.3 老玩家回归与新玩家追赶 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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