14.2 活动模板与奖励预算

把登录签到、成长竞赛、联盟战、通行证、累充等活动拆成面额、参与率、次数三个数,逐项累加算出赛季奖励预算,再折算成赛季产出预算的比例:按参与率加权后 29%、落在 30%±8% 的目标带内;而把面额直接相加会得到 44%、越界 15 个百分点。用 B 层基准量出「忽略参与率」这个最常见的估算错误。

14.1 解决了「哪天排什么」,本节解决「排完要发多少」。这两件事必须分开算:一个赛季里塞十个活动不难,难的是这十个活动一共要发出去多少奖励、占赛季总产出多少。发少了玩家没动力,发多了直接把赛季产出的水放干——下一季经济就通胀了。

先划边界。第 9 章《付费点设计》讲的是付费点该布在哪、通行证该分几轨、付费节奏怎么护体验——那是「卖什么」。第 10 章《定价与折扣》讲的是每档卖多少钱、折扣怎么打——那是「卖多少」。本节不碰这两件事,本节只做一件事:把已经决定要做的活动,逐个算出它的奖励预算,加起来和赛季产出对账。通行证在本节只作为一个「奖励预算项」出现,它的轨数设计是 9.2 的事。

活动不是越多越好,是要有预算

策划排活动时最容易犯的错,是按活动个数排,而不是按奖励预算排。十个「送资源」的活动,每个都单独看着不多,加起来可能就吃掉了赛季产出的半壁江山。预算失控不会在单个活动上暴露,只会在赛季结束时从经济报表里冒出来——那时候已经发完了。

把每个活动压成三个数,预算就能算:

  • 面额:这个活动给一个参与者发多少奖励(用玉璧计价,方便跨活动比较)。
  • 参与率:有多大比例的玩家会参与并拿满(不是所有玩家都会做每个活动)。
  • 次数:一个赛季里这个活动来几次。

赛季奖励预算 = Σ(面额 × 参与率 × 次数)。注意那个参与率——它是整个预算里最容易被忽略、也最影响结果的因子。

四类活动模板

《王朝纪元》的活动分四类,按「谁出奖励」和「多久来一次」两个维度切:

模板类型奖励来源典型参与率设计目的
登录签到免费常驻赛季产出高(约 90%)拉 DAU,门槛极低
成长/竞赛免费限时赛季产出中(约 70%)给养成一个短期目标
联盟战免费限时赛季产出中低(约 55%)社交驱动,奖励绑定协作
通行证免费轨 + 付费轨产出 + 内购高 / 低长期目标 + 付费转化
累充活动付费限时内购收入低(约 8%)付费点,不占用赛季产出预算

关键区分是「免费轨」和「付费轨」的预算口径不同。 免费活动的奖励从赛季产出里出,必须算进产出预算;付费活动的奖励由玩家的内购买单,不进产出预算。把两者混在一起算总账,就会得出「活动发太多」的假警报。下面的模型把免费与付费分开核。

把模板写进配置

下面这份是 D 层(静态层)配置。赛季长度复用共享的 config/season.json,resourcePerJade 与共享的 config/pricing.json 里 bundleAnchor 的口径一致(1 玉璧 = 100 资源),用于把奖励折算成资源等价:

{
  "version": 1,
  "note": "第 14 章 14.2 活动模板与奖励预算(n14 前缀)。season.json 提供赛季长度;pricing.json 的 bundleAnchor.resourcePerJade 用于把奖励折算成资源等价。本文件给出活动模板的奖励额度、参与率与赛季产出预算,用于核算「奖励预算 / 产出预算」偏差。整数与整数百分比,禁浮点。",
  "resourcePerJade": 100,
  "seasonProductionPerPlayer": 1500000,
  "targetRewardBudgetPct": 30,
  "budgetWarnBandPct": 8,
  "events": [
    { "id": "login", "name": "登录签到", "kind": "free", "rewardJade": 300, "participationPct": 90, "occurrences": 1 },
    { "id": "growth_race", "name": "成长竞赛", "kind": "free", "rewardJade": 500, "participationPct": 70, "occurrences": 4 },
    { "id": "alliance_war", "name": "联盟战", "kind": "free", "rewardJade": 800, "participationPct": 55, "occurrences": 4 },
    { "id": "season_pass_free", "name": "通行证免费轨", "kind": "free", "rewardJade": 1200, "participationPct": 85, "occurrences": 1 },
    { "id": "season_pass_paid", "name": "通行证付费轨", "kind": "paid", "rewardJade": 3600, "participationPct": 12, "occurrences": 1 },
    { "id": "recharge_event", "name": "累充活动", "kind": "paid", "rewardJade": 2000, "participationPct": 8, "occurrences": 2 }
  ]
}

targetRewardBudgetPct = 30 是目标:免费活动的奖励预算应落在赛季产出的 30% 左右;budgetWarnBandPct = 8 是允许的偏差带。这两个数是这套模型的「设计意图」,仿真要回答的就是「实际排出来的活动,离这个意图有多远」。

S 层:算赛季奖励预算

下面这段是 S 层仿真脚本。它把每个活动按「面额 × 次数」与「面额 × 参与率 × 次数」两种口径各算一遍,免费与付费分开累计,再把免费加权预算折算成赛季产出预算的百分比,与目标带对照:

# file: sim/n14_event_budget.py
# tier: S
# verify: python3 sim/n14_event_budget.py --config config/ --out out/
"""14.2 活动模板与奖励预算:把每个活动模板的「面额 × 参与率 × 次数」累加,得到
赛季实际奖励预算(按参与者加权),再折算成赛季产出预算的比例,与目标带对照。
产出 out/n14_event_budget.json + out/n14_event_budget.csv + out/n14_event_budget.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 event_budget(ev: dict) -> dict:
    """单个活动的赛季预算:面额 × 次数(未加权)与 面额 × 参与率 × 次数(加权)。"""
    nominal = ev["rewardJade"] * ev["occurrences"]
    weighted = (ev["rewardJade"] * ev["participationPct"] // 100) * ev["occurrences"]
    return {"id": ev["id"], "name": ev["name"], "kind": ev["kind"],
            "nominalJade": nominal, "weightedJade": weighted,
            "expectedPerPlayerJade": weighted}


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, "n14_event")
    season = load(args.config, "season")
    days = season["seasonLengthDays"]

    rows = [event_budget(e) for e in cfg["events"]]
    free = [r for r in rows if r["kind"] == "free"]
    paid = [r for r in rows if r["kind"] == "paid"]
    free_weighted = sum(r["weightedJade"] for r in free)
    free_nominal = sum(r["nominalJade"] for r in free)
    paid_weighted = sum(r["weightedJade"] for r in paid)

    prod_jade = cfg["seasonProductionPerPlayer"] // cfg["resourcePerJade"]
    free_pct = free_weighted * 100 // prod_jade
    nominal_pct = free_nominal * 100 // prod_jade
    target, band = cfg["targetRewardBudgetPct"], cfg["budgetWarnBandPct"]
    summary = {
        "seasonLengthDays": days, "seasonProductionPerPlayer": cfg["seasonProductionPerPlayer"],
        "seasonProductionJade": prod_jade,
        "freeWeightedJade": free_weighted, "freeNominalJade": free_nominal,
        "paidWeightedJade": paid_weighted, "totalWeightedJade": free_weighted + paid_weighted,
        "freeRewardBudgetPct": free_pct, "nominalBudgetPct": nominal_pct,
        "targetPct": target, "bandPct": band,
        "deviationPct": free_pct - target,
        "inBand": abs(free_pct - target) <= band,
        "nominalInBand": abs(nominal_pct - target) <= band,
    }

    with open(os.path.join(args.out, "n14_event_budget.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["id", "kind", "nominalJade", "weightedJade"])
        for r in rows:
            w.writerow([r["id"], r["kind"], r["nominalJade"], r["weightedJade"]])

    render([r["weightedJade"] for r in rows], os.path.join(args.out, "n14_event_budget.svg"),
           "season reward budget by event (participation-weighted)")

    report = {"version": 1, "events": rows, "summary": summary}
    with open(os.path.join(args.out, "n14_event_budget.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/)得到:

{"seasonLengthDays": 60, "seasonProductionPerPlayer": 1500000, "seasonProductionJade": 15000, "freeWeightedJade": 4450, "freeNominalJade": 6700, "paidWeightedJade": 752, "totalWeightedJade": 5202, "freeRewardBudgetPct": 29, "nominalBudgetPct": 44, "targetPct": 30, "bandPct": 8, "deviationPct": -1, "inBand": true, "nominalInBand": false}

结果:加权 29% 合格,面额直加 44% 越界

把六个活动摊开(out/n14_event_budget.csv,面额单位玉璧):

活动类型面额次数面额合计加权(×参与率)
登录签到免费3001300270
成长竞赛免费50042,0001,400
联盟战免费80043,2001,760
通行证免费轨免费1,20011,2001,020
通行证付费轨付费3,60013,600432
累充活动付费2,00024,000320

赛季产出预算 = 1,500,000 资源 ÷ 100 = 15,000 玉璧等价。免费活动的加权预算 = 270 + 1,400 + 1,760 + 1,020 = 4,450,占产出 29%——落在 30% ± 8% 的目标带内(偏差 −1 个百分点,inBand = true)。付费活动加权只有 752,由内购买单,不进产出预算。

结论:这套活动排期的免费奖励预算是健康的。 玩家一个赛季从活动里拿到的免费奖励,约等于自己赛季产出的三成——既够形成正反馈,又不至于把产出冲淡到让建造、升级失去意义。

B 层:忽略参与率会高估多少

上面那个 29% 是按参与率加权算的。如果策划图省事,直接把每个活动的面额乘以次数加起来(忽略参与率),会得到什么?下面这段是 B 层基准脚本,做两个口径的对照——加权预算 vs 面额直加预算,量出「忽略参与率」这个最常见的估算错误会造成多大的偏差。软告警,退出码恒为 0:

# file: sim/n14_event_budget_bench.py
# tier: B
# verify: python3 sim/n14_event_budget_bench.py --config config/ --out out/
"""14.2 奖励预算偏差基准(软告警,退出码恒 0)。两个口径对照:
  ① 参与率加权预算 vs 面额直加预算:量出「把奖励面额直接相加、忽略参与率」会高估多少个百分点;
  ② 与目标带的偏差:加权预算落在 target ± band 内为健康,面额直加通常越界。
偏差一律整数百分点。"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n14_event_budget import load  # 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, "n14_event")
    prod_jade = cfg["seasonProductionPerPlayer"] // cfg["resourcePerJade"]
    free = [e for e in cfg["events"] if e["kind"] == "free"]
    weighted = sum((e["rewardJade"] * e["participationPct"] // 100) * e["occurrences"] for e in free)
    nominal = sum(e["rewardJade"] * e["occurrences"] for e in free)
    w_pct = weighted * 100 // prod_jade
    n_pct = nominal * 100 // prod_jade

    target, band = cfg["targetRewardBudgetPct"], cfg["budgetWarnBandPct"]
    overestimate_pp = n_pct - w_pct
    rep = {
        "version": 1, "productionJade": prod_jade,
        "weightedJade": weighted, "nominalJade": nominal,
        "weightedBudgetPct": w_pct, "nominalBudgetPct": n_pct,
        "targetPct": target, "bandPct": band,
        "overestimatePp": overestimate_pp,
        "weightedInBand": abs(w_pct - target) <= band,
        "nominalInBand": abs(n_pct - target) <= band,
        "warn": (abs(w_pct - target) > band) or (abs(n_pct - target) > band),
    }
    with open(os.path.join(args.out, "n14_event_budget_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'} weighted={w_pct}pct nominal={n_pct}pct "
          f"overestimate={overestimate_pp}pp target={target}+-{band} "
          f"weightedInBand={rep['weightedInBand']} nominalInBand={rep['nominalInBand']}")
    return 0


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

本机实跑得到:

WARN weighted=29pct nominal=44pct overestimate=15pp target=30+-8 weightedInBand=True nominalInBand=False

两个数讲两件事:

  • 加权 29% 在带内,面额直加 44% 越界。 同样一批活动,按参与率加权是 29%(健康),按面额直加是 44%(超出目标带上沿 38%)。差出来的 15 个百分点,全部来自「忽略参与率」这一个简化。
  • 越界的方向是「高估」,不是「低估」。 面额直加假设每个玩家都拿满每个活动,显然偏大——联盟战只有 55% 的人参与,直加却按 100% 算。于是策划会得出「活动发太多、要砍奖励」的错误结论,把本来健康的排期砍过头。

这就是为什么奖励预算必须按参与率加权。面额是「上限」,参与率加权才是「期望」。 用上限做预算,等于用最高销量估营收——数字一定好看,也一定错。这也和卷一 12.2《闭环仿真与稳态判定》是同一条纪律:发出去的量要按真实概率算,不能按理论最大值算。

设计要点

把这一节压成三条:

  1. 活动预算按「面额 × 参与率 × 次数」算,不按面额直加。 本例里两者的差是 15 个百分点,足以让预算判断从「健康」翻成「越界」。
  2. 免费轨与付费轨分开核预算。 免费奖励从赛季产出里出,必须进产出预算;付费奖励由内购买单,不进产出预算。混在一起算会凭空多出一块预算。
  3. 目标带(30% ± 8%)是设计意图,不是硬约束。 它是「我们希望活动奖励占产出多少」的声明,仿真用来核对实际排期离这个意图有多远;偏离了就回去调面额或参与率假设,而不是改这条线。

下一节处理赛季的最后一环:赛季结束时,排名奖励怎么按分段发、发放有多集中,以及结算到新赛季之间的过渡期,玩家到底在「空转」多久。

阅读导航:上一节:14.1 赛季结构与节奏 · 下一节:14.3 赛季结算与过渡 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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