2.3 礼包、通行证与订阅

礼包、通行证、订阅是三种成型的付费产品,它们的账不能都用「单价 × 购买率」来算:礼包卖的是感知价值,通行证卖的是赛季留存,订阅卖的是续费衰减。本节给《合成小岛》一份描述六件产品的 config,用 P 层内核分别建模这三类形态,再用 S 层 Python 仿真算清各自的日均收入贡献。

本节目标:把「礼包 / 通行证 / 订阅」三种成型的付费产品拆成三种不同的收入公式,并让订阅的续费衰减第一次被算进账里。

站内有一组 25 篇的小游戏服务平台专题 ,讲的是做一个小游戏服务平台——SaaS 创业口径:平台怎么搭、开发者怎么接。本节和它不重叠:那边是平台侧,这边是在一款游戏里把三种成型付费产品各自怎么算账讲清楚,主角是《合成小岛》。

广告形态与 eCPM 模型是第 1 章的事,LTV 与回本是第 3 章的事,价格弹性是第 4 章的事。本节只处理一个交叉问题:礼包、通行证、订阅这三种产品,收入公式长得不一样,怎么各算各的。

2.3 礼包、通行证与订阅

2.3.1 三种产品,三种账

单个内购点(第 2.1 节)可以统一成「单价 × 购买率」,但成型的付费产品不能。它们的商业逻辑不同,账也不同:

  • 礼包卖的是感知价值。玩家掏 600 分买一个「价值 2400 分」的包,赚的是划算感。所以礼包的账里必须有一个「感知价值比」,它决定了这个包卖得动不动。
  • 通行证卖的是赛季留存。它先收一笔钱,再用一整个赛季的奖励把玩家留下来。它的账要单独看毛利——奖励本身有成本(配发的道具、货币),收入减掉奖励成本才是真赚。
  • 订阅卖的是续费衰减。月卡第一次收 1800 分,但只有一部分人下个月接着续,再下个月又少一批。它的账是一条衰减曲线,不是一次性收入。

把这三条公式写进同一份模型,才能横向比「哪个产品更值」。本节的内核就把它们放在一起算。

2.3.2 六件产品的 config(D 层)

下面这份是 D 层配置:六件产品,两件礼包、两件通行证、两件订阅,各自带上自己的专属字段(礼包带 valueFen、通行证带 rewardCostFen、订阅带 renewRatePermille)。它是 P 层内核与 S 层仿真共用的同一份输入。

{
  "version": 1,
  "game": "merge-island",
  "note": "礼包 / 通行证 / 订阅三类付费产品(D 层)。价格、购买率、订阅续费率均为示例假设;真实数据需真实业务环境,命令行不可复现(X)。",
  "dau": 10000,
  "products": [
    { "id": "starter-bundle", "kind": "bundle",       "name": "新手礼包",   "priceFen": 600,  "buyersPerDauPermille": 9, "valueFen": 2400 },
    { "id": "deco-bundle",    "kind": "bundle",       "name": "装扮礼包",   "priceFen": 1200, "buyersPerDauPermille": 4, "valueFen": 3600 },
    { "id": "season-pass",    "kind": "pass",         "name": "赛季通行证", "priceFen": 1800, "buyersPerDauPermille": 6, "seasonDays": 56, "rewardCostFen": 900 },
    { "id": "deluxe-pass",    "kind": "pass",         "name": "豪华通行证", "priceFen": 3000, "buyersPerDauPermille": 2, "seasonDays": 56, "rewardCostFen": 1500 },
    { "id": "monthly-card",   "kind": "subscription", "name": "月卡",       "priceFen": 1800, "buyersPerDauPermille": 5, "renewRatePermille": 600, "horizonMonths": 6 },
    { "id": "adfree-sub",     "kind": "subscription", "name": "去广告订阅", "priceFen": 1200, "buyersPerDauPermille": 3, "renewRatePermille": 720, "horizonMonths": 6 }
  ]
}

三类产品的专属字段就是三条公式的入口:valueFen 喂给礼包的价值比,rewardCostFen 喂给通行证的毛利,renewRatePermille 喂给订阅的衰减曲线。buyersPerDauPermille 是三类共用的购买率(千分比),统一口径才能横向比。

2.3.3 三类产品的建模内核(P 层)

下面这段是 P 层内核:它按 kind 分派到三条公式——礼包算价值比、通行证算毛利、订阅按「仍在订阅概率」逐月累乘算期望周期收入。全程整数运算,订阅的续费衰减用千分比整数逐月递推,没有任何浮点与随机。

// file: core/src/l02/bundle.ts
// tier: P
// verify: node --test 'core/test/l02_*.test.ts'
//
// 礼包 / 通行证 / 订阅三类付费产品的收入内核,与 sim/l02_bundle.py 逐位一致。
// 订阅用「仍在订阅概率」逐月累乘(千分比整数),求每付费人的期望周期收入。
// 全整数运算(deciFen = 0.1 分/DAU),除法一律地板除,无 Math.random()。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface Product {
  readonly id: string; readonly kind: string; readonly name: string; readonly priceFen: number;
  readonly buyersPerDauPermille: number; readonly valueFen?: number; readonly rewardCostFen?: number;
  readonly seasonDays?: number; readonly renewRatePermille?: number; readonly horizonMonths?: number;
}
export interface ProductRow {
  readonly id: string; readonly kind: string; readonly name: string; readonly priceFen: number;
  readonly revenueDeciFen: number; readonly valueRatioPermille?: number;
  readonly rewardCostRatioPermille?: number; readonly marginDeciFen?: number;
  readonly expRevFenPerBuyer?: number; readonly effMonthsPermille?: number; readonly curvePermille?: number[];
}
export interface KindTotal { readonly kind: string; readonly productCount: number; readonly revenueDeciFen: number }
export interface BundleResult {
  readonly rows: ProductRow[]; readonly kindTotals: KindTotal[]; readonly totalDeciFen: number;
  readonly violations: string[]; readonly ok: boolean;
}

const KINDS = ["bundle", "pass", "subscription"];
const fdiv = (a: number, b: number): number => Math.floor(a / b);

/** 订阅期望曲线:p_0=1000,逐月 ×renewRate‰;返回期望周期收入与期望有效月数(千分月)。 */
export function subscriptionCurve(priceFen: number, renewPermille: number, horizonMonths: number) {
  let p = 1000;
  let expRevFenPerBuyer = 0;
  let effMonthsPermille = 0;
  const curve: number[] = [];
  for (let m = 0; m < horizonMonths; m += 1) {
    expRevFenPerBuyer += fdiv(priceFen * p, 1000);
    effMonthsPermille += p;
    curve.push(p);
    p = fdiv(p * renewPermille, 1000);
  }
  return { expRevFenPerBuyer, effMonthsPermille, curve };
}

export function analyzeProducts(products: readonly Product[]): BundleResult {
  const violations: string[] = [];
  const rows: ProductRow[] = [];
  for (const p of products) {
    if (!KINDS.includes(p.kind)) violations.push(`产品 ${p.id} 的 kind 非法:${p.kind}`);
    const base = {
      id: p.id, kind: p.kind, name: p.name, priceFen: p.priceFen,
      revenueDeciFen: fdiv(p.priceFen * p.buyersPerDauPermille, 100),
    };
    if (p.kind === "bundle") {
      const valueFen = p.valueFen ?? 0;
      if (valueFen < p.priceFen) violations.push(`产品 ${p.id}:礼包感知价值低于售价`);
      rows.push({ ...base, valueRatioPermille: fdiv(valueFen * 1000, p.priceFen) });
    } else if (p.kind === "pass") {
      const cost = p.rewardCostFen ?? 0;
      if (cost >= p.priceFen) violations.push(`产品 ${p.id}:通行证奖励成本不低于售价`);
      rows.push({
        ...base, rewardCostRatioPermille: fdiv(cost * 1000, p.priceFen),
        marginDeciFen: fdiv((p.priceFen - cost) * p.buyersPerDauPermille, 100),
      });
    } else {
      const renew = p.renewRatePermille ?? 0;
      const horizon = p.horizonMonths ?? 0;
      if (!(renew >= 1 && renew <= 1000)) violations.push(`产品 ${p.id}:续费率不在 1–1000‰`);
      if (horizon < 1) violations.push(`产品 ${p.id}:订阅期数小于 1`);
      const c = subscriptionCurve(p.priceFen, renew, horizon);
      rows.push({
        ...base, expRevFenPerBuyer: c.expRevFenPerBuyer, effMonthsPermille: c.effMonthsPermille,
        curvePermille: c.curve,
        revenueDeciFen: fdiv(c.expRevFenPerBuyer * p.buyersPerDauPermille, 100),
      });
    }
  }
  let totalDeciFen = 0;
  for (const r of rows) totalDeciFen += r.revenueDeciFen;
  const kindTotals: KindTotal[] = KINDS.map((k) => {
    const pts = rows.filter((r) => r.kind === k);
    let rev = 0;
    for (const r of pts) rev += r.revenueDeciFen;
    return { kind: k, productCount: pts.length, revenueDeciFen: rev };
  });
  return { rows, kindTotals, totalDeciFen, violations, ok: violations.length === 0 };
}

内核自带一个测试 core/test/l02_bundle.test.ts,本机实跑(node --test 'core/test/l02_bundle.test.ts')输出:

✔ 三类汇总:礼包 102 / 通行证 168 / 订阅 324,合计 594
✔ 礼包感知价值比:新手 4000‰、装扮 3000‰
✔ 通行证毛利:赛季 (1800−900)×6//100=54,豪华 (3000−1500)×2//100=30
✔ 订阅期望:月卡 2.382 月 / 4286 分,去广告订阅 3.069 月 / 3681 分
✔ 坏配置可被检出:礼包价值低于售价 / 通行证成本不低于售价 / 续费率越界
ℹ tests 5
ℹ pass 5
ℹ fail 0

三条公式的读法:

  • 礼包:新手礼包价值比 4000‰(感知价值是售价的 4 倍),装扮礼包 3000‰。价值比越高越「划算」,但高到离谱会伤定价锚点——它是礼包的核心杠杆。
  • 通行证:赛季通行证毛利 (1800−900)×6//100 = 54,豪华通行证 (3000−1500)×2//100 = 30。豪华版单价更高、毛利反而更低,因为它的奖励成本也更高——通行证不能只看售价。
  • 订阅:月卡的期望有效周期是 2382‰ ≈ 2.38 个月,每付费人期望贡献 4286 分;去广告订阅续费率更高(720‰),期望周期 3.07 个月。续费率每高一点,期望周期就长一截——这是订阅与一次性内购最大的差别。

2.3.4 三类收入的横向对比(S 层)

内核讲完了逻辑,但它只在命令行里跑。下面这段是 S 层仿真:读同一份 config,逐产品算日均收入,再按三类汇总,产出 JSON + CSV。

#!/usr/bin/env python3
# file: sim/l02_bundle.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l02_bundle.py --config config/ --out out/
"""礼包 / 通行证 / 订阅三类付费产品的收入结构(S 层)。读 config/l02_bundle.json,按产品
形态分别建模,产出 JSON + CSV。全程整数(deciFen = 0.1 分/DAU),除法一律地板除。

  bundle(礼包):revenueDeciFen = priceFen * buyersPerDauPermille // 100
                  valueRatioPermille = valueFen * 1000 // priceFen
  pass(通行证):revenueDeciFen = priceFen * buyersPerDauPermille // 100
                  marginDeciFen = (priceFen - rewardCostFen) * buyersPerDauPermille // 100
  subscription:p_0=1000,p_m = p_{m-1} * renewRatePermille // 1000
                expRevFenPerBuyer = sum_m (priceFen * p_m // 1000);effMonthsPermille = sum_m p_m
                revenueDeciFen    = expRevFenPerBuyer * buyersPerDauPermille // 100

注意:所有参数均为示例假设;真实付费分布需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os

KINDS = ("bundle", "pass", "subscription")


def load(cfg_dir):
    with open(os.path.join(cfg_dir, "l02_bundle.json"), encoding="utf-8") as f:
        return json.load(f)


def build_row(p):
    row = {"id": p["id"], "kind": p["kind"], "name": p["name"], "priceFen": p["priceFen"],
           "buyersPerDauPermille": p["buyersPerDauPermille"],
           "revenueDeciFen": p["priceFen"] * p["buyersPerDauPermille"] // 100}
    if p["kind"] == "bundle":
        row["valueRatioPermille"] = p["valueFen"] * 1000 // p["priceFen"]
    elif p["kind"] == "pass":
        row["marginDeciFen"] = (p["priceFen"] - p["rewardCostFen"]) * p["buyersPerDauPermille"] // 100
    elif p["kind"] == "subscription":
        prob, exp_rev, eff = 1000, 0, 0
        for _ in range(p["horizonMonths"]):
            exp_rev += p["priceFen"] * prob // 1000
            eff += prob
            prob = prob * p["renewRatePermille"] // 1000
        row["expRevFenPerBuyer"] = exp_rev
        row["effMonthsPermille"] = eff
        row["revenueDeciFen"] = exp_rev * p["buyersPerDauPermille"] // 100
    return row


def violations_of(products):
    v = []
    for p in products:
        if p["kind"] == "bundle" and p["valueFen"] < p["priceFen"]:
            v.append(f'{p["id"]}:礼包感知价值低于售价')
        if p["kind"] == "pass" and p["rewardCostFen"] >= p["priceFen"]:
            v.append(f'{p["id"]}:通行证奖励成本不低于售价')
        if p["kind"] == "subscription" and not (1 <= p["renewRatePermille"] <= 1000):
            v.append(f'{p["id"]}:续费率不在 1–1000‰')
        if p["kind"] not in KINDS:
            v.append(f'{p["id"]}:kind 非法')
    return v


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = load(args.config)
    rows = [build_row(p) for p in cfg["products"]]
    total = sum(r["revenueDeciFen"] for r in rows)
    for r in rows:
        r["sharePermille"] = r["revenueDeciFen"] * 1000 // total if total else 0
    kind_totals = [{"kind": k, "revenueDeciFen": sum(r["revenueDeciFen"] for r in rows if r["kind"] == k)}
                   for k in KINDS]
    v = violations_of(cfg["products"])
    os.makedirs(args.out, exist_ok=True)
    report = {"version": cfg["version"], "game": cfg["game"], "arpdauDeciFen": total,
              "kindTotals": kind_totals, "products": rows, "violations": v, "ok": len(v) == 0}
    with open(os.path.join(args.out, "l02_bundle.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "l02_bundle.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=["id", "kind", "name", "priceFen", "buyersPerDauPermille",
                                          "revenueDeciFen", "sharePermille"], extrasaction="ignore")
        w.writeheader()
        w.writerows(rows)
    print(json.dumps({
        "arpdauDeciFen": total, "kindTotals": {t["kind"]: t["revenueDeciFen"] for t in kind_totals},
        "subscriptions": [{"id": r["id"], "revenueDeciFen": r["revenueDeciFen"],
                           "effMonthsPermille": r["effMonthsPermille"],
                           "expRevFenPerBuyer": r["expRevFenPerBuyer"]}
                          for r in rows if r["kind"] == "subscription"],
        "violations": v}, ensure_ascii=False))
    return 0


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

本机实跑(/opt/homebrew/bin/python3 sim/l02_bundle.py --config config/ --out out/)输出摘要:

{"arpdauDeciFen": 594, "kindTotals": {"bundle": 102, "pass": 168, "subscription": 324},
 "subscriptions": [{"id": "monthly-card", "revenueDeciFen": 214, "effMonthsPermille": 2382, "expRevFenPerBuyer": 4286},
                   {"id": "adfree-sub", "revenueDeciFen": 110, "effMonthsPermille": 3069, "expRevFenPerBuyer": 3681}],
 "violations": []}

三类汇总 102 : 168 : 324,合计 594(0.1 分/DAU)——订阅这一类贡献最大(324,占 55%),尽管它的单价不是最高。这正是续费衰减被算进来之后才看得见的结构:一次性卖出的礼包只贡献 102,而两件订阅靠「滚起来」的续费周期贡献了 324。命令行内核与 Python 仿真给出的 594 / 102 / 168 / 324 / 214 / 2382 / 4286 / 110 / 3069 / 3681 逐位一致——同一份 config,两个宿主,同一组数字。

2.3.5 三条判断

  1. 礼包看价值比,别只看售价。感知价值比太低没人买,太高会拉低整店定价锚点;两件礼包一高一低是刻意的档位设计。
  2. 通行证看毛利,不看售价。奖励有成本,豪华版售价更高但毛利更低是常态;先把 rewardCostFen 标出来再定价。
  3. 订阅看期望周期,不看首月。续费率把一次性收入拉成一条曲线;月卡 2.38 个月、去广告订阅 3.07 个月的差别,全部来自续费率这一个数字。

小结

  • 礼包、通行证、订阅是三种不同的账:价值比 / 毛利 / 续费衰减,不能统一成「单价 × 购买率」。
  • D 层配置把六件产品与三条专属字段写成唯一真源;P 层内核算三类公式;S 层仿真按类汇总出 102 : 168 : 324。
  • 本机实跑:合计 594(0.1 分/DAU),订阅贡献最大(324,占 55%),月卡期望周期 2.38 个月。与站内小游戏服务平台专题 的分工:那边搭平台,这边算清一款游戏里三类付费产品的账。这些数字是同机相对量级,绝对数值不可移植,可移植的是这套三类公式。
  • 参数口径:价格、购买率、续费率均为示例假设;真实付费分布需真实业务环境,命令行不可复现(X)。

三类产品的账算清了,接下来要问的是:这些收入加起来,一款游戏的 DAU × ARPDAU 到底能不能覆盖买量成本、多久回本。这是下一章的事。

阅读导航:上一节:混合变现的比例与协同 · 下一节:收入建模:DAU×ARPDAU 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制