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,面额单位玉璧):
| 活动 | 类型 | 面额 | 次数 | 面额合计 | 加权(×参与率) |
|---|---|---|---|---|---|
| 登录签到 | 免费 | 300 | 1 | 300 | 270 |
| 成长竞赛 | 免费 | 500 | 4 | 2,000 | 1,400 |
| 联盟战 | 免费 | 800 | 4 | 3,200 | 1,760 |
| 通行证免费轨 | 免费 | 1,200 | 1 | 1,200 | 1,020 |
| 通行证付费轨 | 付费 | 3,600 | 1 | 3,600 | 432 |
| 累充活动 | 付费 | 2,000 | 2 | 4,000 | 320 |
赛季产出预算 = 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《闭环仿真与稳态判定》是同一条纪律:发出去的量要按真实概率算,不能按理论最大值算。
设计要点
把这一节压成三条:
- 活动预算按「面额 × 参与率 × 次数」算,不按面额直加。 本例里两者的差是 15 个百分点,足以让预算判断从「健康」翻成「越界」。
- 免费轨与付费轨分开核预算。 免费奖励从赛季产出里出,必须进产出预算;付费奖励由内购买单,不进产出预算。混在一起算会凭空多出一块预算。
- 目标带(30% ± 8%)是设计意图,不是硬约束。 它是「我们希望活动奖励占产出多少」的声明,仿真用来核对实际排期离这个意图有多远;偏离了就回去调面额或参与率假设,而不是改这条线。
下一节处理赛季的最后一环:赛季结束时,排名奖励怎么按分段发、发放有多集中,以及结算到新赛季之间的过渡期,玩家到底在「空转」多久。
阅读导航:上一节:14.1 赛季结构与节奏 · 下一节:14.3 赛季结算与过渡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。