8.3 老玩家回归与新玩家追赶

8.2 让赛季起点收敛了,但赛季内新玩家怎么追?本节跑一个赛季 60 天:老玩家带 2 倍永久领先,新玩家拿 150% 追赶产出、前 72 小时有护盾。结果差距收窄 83% 却填不平,追赶窗口一关只剩固定日产出在慢慢磨,护盾值约 5% 的赛季末指数。

8.2 解决的是「赛季起点会不会失控」——分层重置把综合指数收敛进了健康带。但起点收敛不等于过程公平:赛季一开,老玩家带着永久资产(英雄 100% 保留、科技半衰)领先,新玩家从零起步,赛季内这 60 天怎么追,是另一个问题。本节跑的就是这条追赶曲线。

追赶不是「给新手加倍」,是两条时间线

很多设计者把追赶理解成「给新玩家一个产出加成」。加成有用,但它只是一条时间线上的加速,而老玩家有两条优势:

  • 永久领先:8.2 里英雄保留 100%、科技保留 50%,这些跨赛季的资产让老玩家赛季起点就高于新人。本节的模型里,老玩家起点 3,000、新玩家起点 1,000,是 3 倍关系。
  • 先发优势:即使起点相同,老玩家也可能因为在线时长、联盟资源更早进入高增长。

产出加成只能对抗「先发优势」的一部分,对抗不了「永久领先」——因为加成是限时的,永久领先是永久的。这是本节要量出来的核心张力。

三种玩家:老玩家、有盾新人、无盾新人

模型里放三个队列,跑同一个赛季:

队列起点追赶产出护盾说明
老玩家3,000100%有带永久领先,无追赶加成
有盾新人1,000150%(前 30 天)有(前 72h)完整追赶包
无盾新人1,000150%(前 30 天)无只给产出、不给保护

护盾的作用是挡住开局掠夺:没有护盾的新人,前 3 天会被掠夺掉 10% 的存量,差距会先被拉开再收窄。追赶产出与护盾时长都取自共享的 config/season.json 的 compression 段。

先把追赶写进配置

下面这份是 D 层(静态层)配置。它给出三个队列的起点与成长律、追赶窗口、脆弱期与掠夺损失;追赶产出比例与护盾时长从 season.json 读,不在这里重复:

{
  "version": 1,
  "note": "第 8 章 8.3 老玩家回归与新玩家追赶(n08 前缀)。复用 season.json 的 compression.newbieCatchupOutputPct(追赶产出)与 newbieShieldHours(护盾时长);本文件补充成长律、永久领先、掠夺损失与追赶判据。整数百分比与整数,禁浮点。",
  "catchupWindowDays": 30,
  "vulnerableDays": 3,
  "raidLossPct": 10,
  "targetGapBp": 500,
  "cohorts": [
    { "id": "veteran", "name": "老玩家(永久领先)", "startIndex": 3000, "dailyGain": 80, "growBpPerDay": 20, "catchupPct": 100, "shielded": true },
    { "id": "newbie_catchup", "name": "新玩家(150% 追赶 + 72h 护盾)", "startIndex": 1000, "dailyGain": 80, "growBpPerDay": 20, "usesSeasonCatchup": true, "shielded": true },
    { "id": "newbie_noshield", "name": "新玩家(150% 追赶,无护盾)", "startIndex": 1000, "dailyGain": 80, "growBpPerDay": 20, "usesSeasonCatchup": true, "shielded": false }
  ]
}

S 层:一个赛季跑 60 天

下面这段是 S 层仿真脚本。它逐日推进三个队列,追赶窗口内新玩家产出按 150% 计,脆弱期内未受护盾保护则被掠夺,逐日记录「老玩家 − 新玩家」的相对差距:

# file: sim/n08_catchup.py
# tier: S
# verify: python3 sim/n08_catchup.py --config config/ --out out/
"""8.3 老玩家回归与新玩家追赶:一个赛季内,老玩家带着永久领先,新玩家在前 catchupWindowDays 天
拿 150% 产出(season.json 的 newbieCatchupOutputPct)、并在前 newbieShieldHours(72h)有护盾。
比较「有护盾 / 无护盾」新玩家的追赶轨迹:差距能否收敛、护盾值多少。
产出 out/n08_catchup.json + out/n08_catchup.csv + out/n08_catchup.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 step(p: int, daily_gain: int, grow_bp: int, day: int, catchup_pct: int, window: int,
         vuln_days: int, raid_pct: int, shielded: bool) -> int:
    """一天:复利 + 产出(追赶窗口内按 catchupPct 放大);脆弱期内未受护盾保护则被掠夺。"""
    mult = catchup_pct if day < window else 100
    p = p + p * grow_bp // 10000 + daily_gain * mult // 100
    if day < vuln_days and not shielded:
        p = p - p * raid_pct // 100
    return p


def run(cohort: dict, catchup_pct: int, days: int, window: int, vuln_days: int, raid_pct: int) -> list[int]:
    p, path = cohort["startIndex"], [cohort["startIndex"]]
    for day in range(days):
        p = step(p, cohort["dailyGain"], cohort["growBpPerDay"], day, catchup_pct, window, vuln_days, raid_pct, cohort["shielded"])
        path.append(p)
    return path


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_catchup")
    season = load(args.config, "season")
    days = season["seasonLengthDays"]
    window = cfg["catchupWindowDays"]
    vuln = cfg["vulnerableDays"]
    raid = cfg["raidLossPct"]
    season_catchup = season["compression"]["newbieCatchupOutputPct"]
    shield_days = season["compression"]["newbieShieldHours"] // 24

    cohorts = cfg["cohorts"]
    vet = next(c for c in cohorts if c["id"] == "veteran")
    vet_path = run(vet, 100, days, window, vuln, raid)

    rows = []
    for c in cohorts:
        if c["id"] == "veteran":
            continue
        cp = season_catchup if c.get("usesSeasonCatchup") else c["catchupPct"]
        path = run(c, cp, days, window, vuln, raid)
        gaps = [(vet_path[d] - path[d]) * 10000 // vet_path[d] for d in range(days + 1)]
        catchup_day = next((d for d in range(days + 1) if path[d] >= vet_path[d]), -1)
        rows.append({"id": c["id"], "name": c["name"], "catchupPct": cp, "shielded": c["shielded"],
                     "startIndex": path[0], "endIndex": path[-1], "vetEndIndex": vet_path[-1],
                     "gapBpDay0": gaps[0], "gapBpWindowEnd": gaps[window], "gapBpFinal": gaps[-1],
                     "absGapWindowEnd": vet_path[window] - path[window], "absGapFinal": vet_path[-1] - path[-1],
                     "catchupDay": catchup_day, "path": path})

    for r in rows:
        r["reachedTarget"] = r["gapBpFinal"] <= cfg["targetGapBp"]
        r["shrinkPct"] = (r["gapBpDay0"] - r["gapBpFinal"]) * 100 // r["gapBpDay0"]

    with open(os.path.join(args.out, "n08_catchup.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["cohort", "day", "index", "veteranIndex", "gapBp"])
        for r in rows:
            for d in range(days + 1):
                w.writerow([r["id"], d, r["path"][d], vet_path[d],
                            (vet_path[d] - r["path"][d]) * 10000 // vet_path[d]])

    render(rows[0]["path"], os.path.join(args.out, "n08_catchup.svg"),
           f"newbie_catchup vs veteran across {days} days (window={window}d, shield={shield_days}d)")

    summary = {"version": 1, "days": days, "windowDays": window, "shieldDays": shield_days,
               "vulnerableDays": vuln, "raidLossPct": raid, "targetGapBp": cfg["targetGapBp"],
               "veteranEnd": vet_path[-1],
               "cohorts": [{k: r[k] for k in ("id", "catchupPct", "shielded", "endIndex", "gapBpDay0",
                                              "gapBpWindowEnd", "gapBpFinal", "catchupDay", "reachedTarget", "shrinkPct")} for r in rows]}
    with open(os.path.join(args.out, "n08_catchup.json"), "w", encoding="utf-8") as fh:
        json.dump(summary, fh, ensure_ascii=False, indent=2)
    print(json.dumps({"veteranEnd": vet_path[-1],
                      "gapFinalBp": {r["id"]: r["gapBpFinal"] for r in rows},
                      "gapWindowEndBp": {r["id"]: r["gapBpWindowEnd"] for r in rows},
                      "reachedTarget": {r["id"]: r["reachedTarget"] for r in rows}}, ensure_ascii=False))
    return 0


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

本机实跑得到:

{"veteranEnd": 8446, "gapFinalBp": {"newbie_catchup": 1120, "newbie_noshield": 1562}, "gapWindowEndBp": {"newbie_catchup": 1579, "newbie_noshield": 2203}, "reachedTarget": {"newbie_catchup": false, "newbie_noshield": false}}

结果:差距收窄 83%,但填不平永久领先

一个赛季 60 天,三个关键时点的相对差距(万分比):

队列第 0 天第 30 天(窗口关闭)第 60 天(赛季末)收窄幅度
有盾新人6,666 bp1,579 bp1,120 bp83%
无盾新人6,666 bp2,203 bp1,562 bp76%

读三个数:

  • 差距确实在收窄,而且收窄得很猛:有盾新人从 66.7% 的差距压到赛季末的 11.2%,收窄了 83%。150% 追赶产出 + 72 小时护盾这套组合是有效的。
  • 但填不平。赛季末仍有 1,120 bp(11.2%)的差距,catchupDay = -1 表示整个赛季里新玩家从未追平过老玩家。原因就是前面说的:追赶产出只在前 30 天生效,而老玩家的 2,000 点永久领先一直挂在那里。限时加成填不平永久领先。
  • reachedTarget 为 false:目标是把差距压到 5%(500 bp)以内,实际停在 11.2%——差了一倍多。这说明「150% + 72h」这个默认档,只够把差距减半,不够让新玩家在赛季内追上。

护盾值多少

把两个新人队列相减,护盾的价值就出来了:

  • 赛季末指数:有盾 7,500,无盾 7,126——护盾多带来 374 点指数(约 5.3%)。
  • 赛季末差距:有盾 1,120 bp,无盾 1,562 bp——护盾把残差砍掉 442 bp(约 28%)。

护盾不是「送资源」,是挡住开局那 3 天的掠夺。无盾新人在前 3 天被掠夺 10% 的存量,这个损失会被复利放大到赛季末,最终表现为 442 bp 的差距。72 小时的护盾看起来是个小福利,换算到赛季末却值 5% 的进度——因为早期的存量损失会被复利放大。

B 层:追赶收敛——窗口内 vs 窗口后

追赶是一条曲线,不是两个端点。下面这段是 B 层基准脚本,它检验两件事:① 相对差距是否单调收窄(追赶方向正确、不反弹);② 追赶窗口内(150% 加成)与窗口后(仅靠固定日产出)两段各贡献了多少收窄。软告警,退出码恒为 0:

# file: sim/n08_catchup_bench.py
# tier: B
# verify: python3 sim/n08_catchup_bench.py --config config/ --out out/
"""8.3 追赶收敛基准(软告警,退出码恒 0):检验相对差距是否「单调收窄」,并对比
追赶窗口内(150% 加成)与窗口后(仅靠固定日产出)两段的收窄速度,量出加成到底贡献了多少。
若差距出现反弹(非单调)或赛季末残差未达目标,只打 WARN。"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n08_catchup import load, run  # noqa: E402


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_catchup")
    season = load(args.config, "season")
    days = season["seasonLengthDays"]
    window, vuln, raid = cfg["catchupWindowDays"], cfg["vulnerableDays"], cfg["raidLossPct"]
    season_catchup = season["compression"]["newbieCatchupOutputPct"]

    cohorts = cfg["cohorts"]
    vet = next(c for c in cohorts if c["id"] == "veteran")
    vet_path = run(vet, 100, days, window, vuln, raid)

    rows, warns = [], []
    for c in cohorts:
        if c["id"] == "veteran":
            continue
        cp = season_catchup if c.get("usesSeasonCatchup") else c["catchupPct"]
        path = run(c, cp, days, window, vuln, raid)
        gaps = [(vet_path[d] - path[d]) * 10000 // vet_path[d] for d in range(days + 1)]
        monotonic = all(gaps[d + 1] <= gaps[d] for d in range(days))
        in_win = (gaps[0] - gaps[window]) * 100 // gaps[0]
        post_win = (gaps[window] - gaps[-1]) * 100 // gaps[window] if gaps[window] else 0
        rows.append({"id": c["id"], "gapBpDay0": gaps[0], "gapBpWindowEnd": gaps[window],
                     "gapBpFinal": gaps[-1], "monotonic": monotonic,
                     "shrinkInWindowPct": in_win, "shrinkPostWindowPct": post_win,
                     "postWindowResidualBp": gaps[-1], "reachedTarget": gaps[-1] <= cfg["targetGapBp"]})
        if not monotonic:
            warns.append(c["id"] + " 相对差距出现反弹")
        if not rows[-1]["reachedTarget"]:
            warns.append(c["id"] + " 赛季末残差未达目标")

    rep = {"version": 1, "windowDays": window, "days": days, "targetGapBp": cfg["targetGapBp"],
           "cohorts": rows, "warnings": warns}
    with open(os.path.join(args.out, "n08_catchup_bench.json"), "w", encoding="utf-8") as fh:
        json.dump(rep, fh, ensure_ascii=False, indent=2)
    print(json.dumps({"monotonic": {r["id"]: r["monotonic"] for r in rows},
                      "shrinkInWindowPct": {r["id"]: r["shrinkInWindowPct"] for r in rows},
                      "shrinkPostWindowPct": {r["id"]: r["shrinkPostWindowPct"] for r in rows},
                      "warnings": warns}, ensure_ascii=False))
    return 0


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

本机实跑得到:

{"monotonic": {"newbie_catchup": true, "newbie_noshield": false}, "shrinkInWindowPct": {"newbie_catchup": 76, "newbie_noshield": 66}, "shrinkPostWindowPct": {"newbie_catchup": 29, "newbie_noshield": 29}, "warnings": ["newbie_catchup 赛季末残差未达目标", "newbie_noshield 相对差距出现反弹", "newbie_noshield 赛季末残差未达目标"]}

两个结论:

  • 有盾新人的差距单调收窄,无盾新人的会反弹。 无盾新人在前 3 天被掠夺,差距先扩大(monotonic = false),护盾的作用正是消除这个开局反弹。一条会反弹的追赶曲线,对玩家的心理打击比「差距大」更严重——他明明在努力,差距却在变大。
  • 追赶窗口贡献了绝大部分收窄。 有盾新人在窗口内(前 30 天)收窄了 76%,窗口后(后 30 天)只收窄 29%。窗口一关,150% 加成消失,只剩固定日产出在慢慢磨——而固定日产出对小号的相对帮助有限。想让追赶更彻底,要么延长窗口,要么给新人一条永久性的追赶轨道(如持续到某等级的低保产出)。

B 层的两个 WARN 都是设计信号,不是代码 bug:残差未达目标,说明「150% + 72h」这套默认参数不够激进;无盾反弹,说明护盾是必需的、不是可选项。

设计要点

  1. 限时加成填不平永久领先。 追赶产出只在窗口内有效,老玩家的永久资产(8.2 里英雄 100% 保留)一直挂在那里。差距收窄 83% 也停在 11.2%——想要赛季内追平,得给新人一条持续的轨道,而不是一段限时的加速。
  2. 护盾是追赶的一部分,不是福利。 72 小时护盾挡住开局掠夺,换来赛季末 442 bp 的差距收窄与 5.3% 的指数——因为早期损失会被复利放大。去掉护盾,追赶曲线还会先反弹。
  3. 追赶目标要显式定,并接受它可能达不到。 本节把目标设为 5%,实跑 11.2%,reachedTarget 为 false。这个「不达标」本身就是结论:默认档参数只够减半差距。要么调参数(延长窗口 / 提高追赶比例),要么承认新玩家需要跨 2 个赛季才能追平,并据此设计留存节奏。

至此第 8 章收尾:8.1 把膨胀称重,8.2 用分层重置让赛季起点收敛,8.3 处理赛季内新玩家的追赶。三者合起来,是一个「不失控、可收敛、能追赶」的赛季数值体系。下一章进入商业化——玩家为什么掏钱。

阅读导航:上一节:8.2 重置、继承与压缩 · 下一节:9.1 付费动机与付费点布局 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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