2.2 付费意愿分层

把「谁愿意花钱」从一句运营直觉变成一条可仿真的意愿轴:定义《王朝纪元》五层意愿分层,用动机原型的意愿基准加扰动抽出万人样本,算出各层占比、相对目标的偏差与每层的主导原型,并把同一份配置在 Python 与 TypeScript 两侧复算,全程只做画像、不碰任何定价。

上一节按动机把玩家分了层,但动机不等于钱包。一个把「竞争」权重拉满的玩家未必愿意花钱,一个只想安静造房子的玩家也可能为了一块好地一掷千金。所以第二节要做的是另一条正交的切分线:付费意愿。

动手前先划两条硬边界。第一,本节只做意愿分层与人群画像,不做任何定价——首充、月卡、通行证这些具体付费点的设计是卷二第 9 章的内容,本节一个价格数字都不写。第二,本节不碰 LTV 建模(那是卷二第 11 章),只回答「人群怎么分层、每层长什么样」。如果你想要的是通用的付费设计清单,站内的经济平衡长文 用的是放置类口径、以挂机收益为起点;SLG 的付费意愿和大地图节奏、联盟关系强耦合,所以本卷换用 SLG 大地图的口径,从人群画像讲起。

意愿 ≠ 已付费:为什么先分层再定价

把「付费玩家」直接等同于「已经充过钱的人」,是很多数据看板的默认口径,但它对设计几乎没有指导意义:一个玩家没充钱,可能是不想充,也可能是还没遇到值得充的时刻。这两种情况在数据上长得一样,在设计上完全不同。

所以本节引入一个中间变量:付费意愿分(willingness score)。它是一个 0–100 的整数,描述玩家「在遇到合适付费点时掏钱的倾向」,与「是否已经掏过钱」无关。意愿分高但尚未付费的人,是设计要争取的对象;意愿分低的人,再好的付费点也很难撬动。分层就是给这条意愿轴切几刀。

需要强调:意愿分本身是一个潜变量(X),它不可直接观测,只能通过调研或行为代理指标间接估计。本节的可复现部分,是「给定意愿分的分布假设,人群会分成什么样的比例」,而不是「真实玩家的意愿分是多少」——后者需要真实业务环境,命令行不可复现。

五层意愿分层

《王朝纪元》把 0–100 的意愿轴切成五层,覆盖从完全不付费到超高付费的整条谱:

层id意愿分区间设计含义(公开可观察的运营共识)
免费玩家free0–39几乎不会付费,靠玩法留存与口碑贡献活跃
小额玩家minnow40–64偶尔为小额点付费,看重性价比与低门槛
中额玩家mid65–84有稳定付费习惯,是营收的基本盘
高额玩家high85–94为强度与排名付费,对体验中断敏感
超高额玩家whale95–100极端少数,贡献不成比例的收入,需单独服务

这张表里的区间边界是设计假设(X),不是对任何具体产品的还原。它的价值在于把「大 R / 小 R」这类模糊说法,换成一条有刻度、可仿真、可校准的轴。

意愿与动机的耦合

意愿分不是凭空抽的。上一节的五种原型,对「掏钱倾向」有系统性的差异:竞争者和征服者为了赢和扩张更愿意付费,建设者次之,社交者与休闲者最低。所以本节的做法是以原型为条件抽意愿——每种原型给一个意愿基准,再叠加个体扰动:

willingness = archetypeWillingnessBase[原型] + noise(±spread)

这样两条分类线就耦合起来了:动机决定意愿的起点,扰动决定同一类人内部的离散度。耦合的好处是,当你调整某类原型的人群占比时,付费分层的比例会自动跟着变,而不是两套数据各说各话。

分层粒度:为什么是五层

分层切几刀,是一个粒度权衡。切得太粗(比如只分「付费 / 不付费」),信息量不足以指导设计;切得太细(比如按每 5 分一档切成 20 层),每层的样本量会小到没有统计意义,运营也记不住。

五层是 SLG 里一个常见的平衡点,理由是它把两条尾巴单独切了出来:

  • 免费层单独一层,因为它的人群占比最大、设计目标也最特殊(不追求付费,追求留存与传播)。
  • 超高额层单独一层,因为它人数极少但贡献不成比例的收入,服务方式与其余各层完全不同,混进「高额层」会被平均数掩盖。

中间三层(小额 / 中额 / 高额)承担的是「连续谱」的刻画,边界不必精确到分,只要能把「偶尔充一点」和「有稳定付费习惯」区分开即可。本节的配置把这五个区间写成整数边界,正是为了让这条轴可复现、可校准——你随时可以改边界重跑,看漏斗怎么变。

D 层:把分层与基准写进配置

下面这份是 D 层配置:它定义了五层的意愿区间、各原型的意愿基准、扰动半径,以及一组目标占比(千分比整数),用于后面的校准对照。

{
  "version": 1,
  "note": "意愿分(willingness)为 0–100 整数;目标占比用千分比整数,避免浮点",
  "seed": 20261012,
  "population": 10000,
  "willingnessSpreadPct": 18,
  "tiers": [
    { "id": "free", "name": "免费玩家", "willingnessLo": 0, "willingnessHi": 39 },
    { "id": "minnow", "name": "小额玩家", "willingnessLo": 40, "willingnessHi": 64 },
    { "id": "mid", "name": "中额玩家", "willingnessLo": 65, "willingnessHi": 84 },
    { "id": "high", "name": "高额玩家", "willingnessLo": 85, "willingnessHi": 94 },
    { "id": "whale", "name": "超高额玩家", "willingnessLo": 95, "willingnessHi": 100 }
  ],
  "archetypeWillingnessBase": {
    "conqueror": 52,
    "builder": 30,
    "socializer": 25,
    "competitor": 78,
    "casual": 18
  },
  "targetSharePermille": {
    "free": 570,
    "minnow": 255,
    "mid": 125,
    "high": 40,
    "whale": 10
  }
}

配置里再一次没有浮点:意愿基准、扰动半径、目标占比全是整数,占比用千分比。这是为了让本节与上一节共用同一套整数 PRNG,Python 与 TypeScript 两侧能跑到逐位一致。

S 层:抽出万人样本并分层

下面这段是 S 层仿真脚本。它复用上一节同一套 xorshift32(同一份种子、同一条序列),先按原型占比抽原型、再按原型意愿基准加扰动抽意愿分、最后按区间落到五层,并统计每层的主导原型作为人群画像。

# file: sim/ch02_payer_tiers.py
# tier: S
# verify: python3 sim/ch02_payer_tiers.py --config config/
"""付费意愿分层仿真:把每个玩家的「意愿分」映射到分层,输出各层占比(千分比)、
相对目标占比的偏差,以及每层的主导原型(人群画像)。全程整数运算。"""

from __future__ import annotations

import argparse
import json
import os
import xml.etree.ElementTree as ET


def xorshift32(seed: int):
    s = seed & 0xFFFFFFFF or 0x9E3779B9

    def nxt() -> int:
        nonlocal s
        s ^= (s << 13) & 0xFFFFFFFF
        s ^= s >> 17
        s ^= (s << 5) & 0xFFFFFFFF
        s &= 0xFFFFFFFF
        return s

    return nxt


def pick(rand, weights: list[int]) -> int:
    total = sum(weights)
    r = rand() % total
    acc = 0
    for i, w in enumerate(weights):
        acc += w
        if r < acc:
            return i
    return len(weights) - 1


def load_config(cfg_dir: str, name: str) -> dict:
    with open(os.path.join(cfg_dir, name), encoding="utf-8") as f:
        return json.load(f)


def tier_of(tiers: list[dict], score: int) -> int:
    for i, t in enumerate(tiers):
        if t["willingnessLo"] <= score <= t["willingnessHi"]:
            return i
    return len(tiers) - 1


def simulate(cfg: dict, seg_cfg: dict) -> dict:
    rand = xorshift32(cfg["seed"])
    pop, tiers = cfg["population"], cfg["tiers"]
    archetypes = seg_cfg["archetypes"]
    shares = [a["sharePct"] for a in archetypes]
    base, spread = cfg["archetypeWillingnessBase"], cfg["willingnessSpreadPct"]

    counts = [0] * len(tiers)
    arch_by_tier = [[0] * len(archetypes) for _ in tiers]
    for _ in range(pop):
        ai = pick(rand, shares)
        score = base[archetypes[ai]["id"]] + (rand() % (2 * spread + 1) - spread)
        score = 0 if score < 0 else (100 if score > 100 else score)
        ti = tier_of(tiers, score)
        counts[ti] += 1
        arch_by_tier[ti][ai] += 1

    realized = [c * 1000 // pop for c in counts]
    target = [cfg["targetSharePermille"][t["id"]] for t in tiers]
    dominant = []
    for row in arch_by_tier:
        best = max(range(len(row)), key=lambda j: row[j])
        dominant.append(archetypes[best]["id"] if row[best] > 0 else None)
    return {"version": 1, "population": pop, "tiers": [t["id"] for t in tiers],
            "realizedSharePermille": realized, "targetSharePermille": target,
            "deviationPermille": [realized[i] - target[i] for i in range(len(tiers))],
            "dominantArchetype": dominant}


def write_svg(path: str, cfg: dict, res: dict) -> int:
    W, H, pl, pr, pt, pb = 640, 360, 56, 24, 32, 48
    labels = [t["name"] for t in cfg["tiers"]]
    realized, target = res["realizedSharePermille"], res["targetSharePermille"]
    hi, iw, ih, n = max(realized + target) or 1, W - pl - pr, H - pt - pb, len(labels)
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#fff"})
    slot, bw = iw // n, (iw // n) // 3
    for i in range(n):
        cx = pl + slot * i + slot // 2
        for k, (series, color) in enumerate(((realized, "#1f6feb"), (target, "#d29922"))):
            h = ih * series[i] // hi
            ET.SubElement(svg, "rect", {"x": str(cx - bw + k * bw), "y": str(pt + ih - h), "width": str(bw), "height": str(h), "fill": color})
        lab = ET.SubElement(svg, "text", {"x": str(cx), "y": str(H - pb + 18), "font-size": "11", "text-anchor": "middle", "fill": "#333"})
        lab.text = labels[i]
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    assert ET.parse(path).getroot().tag.endswith("svg")
    return os.path.getsize(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_config(args.config, "ch02_payer.json")
    seg_cfg = load_config(args.config, "ch02_segments.json")
    res = simulate(cfg, seg_cfg)
    with open(os.path.join(args.out, "ch02_payer.json"), "w", encoding="utf-8") as f:
        json.dump(res, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch02_payer.csv"), "w", encoding="utf-8") as f:
        f.write("tier,realizedPermille,targetPermille,deviationPermille,dominantArchetype\n")
        for i, t in enumerate(cfg["tiers"]):
            f.write(f"{t['id']},{res['realizedSharePermille'][i]},{res['targetSharePermille'][i]},{res['deviationPermille'][i]},{res['dominantArchetype'][i]}\n")
    size = write_svg(os.path.join(args.out, "ch02_payer.svg"), cfg, res)
    print(json.dumps(res, ensure_ascii=False))
    print(f"# svg bytes={size}")
    return 0


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

脚本除 JSON 外还写出 out/ch02_payer.csv(每层的占比与主导原型)和 out/ch02_payer.svg(仿真占比 vs 目标占比的双柱图)。上面同样是完整文件,与上一节共用同一套整数 PRNG。

P 层:同一份配置在 TypeScript 侧复算

分层逻辑如果只在分析脚本里跑,就永远是个「报告」而不是「产品」。要让它进服务端或工具链,必须能被第二语言复算。下面这段是 P 层 TypeScript 原型,它读同一份 ch02_payer.json 与上一节的 ch02_segments.json,用与 Python 完全相同的整数 PRNG 和取模抽样复算分层,node --test 的断言里写死了与 Python 完全一致的占比。

// file: proto/src/ch02_payer.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 第 2 章原型层(付费意愿):意愿分 → 分层映射与人群画像,与 sim/ch02_payer_tiers.py 逐位一致。

import { loadConfig } from "./core.ts";
import { xorshift32, pick, type SegmentsConfig } from "./ch02_motivation.ts";

export interface PayerTierDef {
  id: string; name: string; willingnessLo: number; willingnessHi: number;
}
export interface PayerConfig {
  seed: number; population: number; willingnessSpreadPct: number;
  tiers: PayerTierDef[];
  archetypeWillingnessBase: Record<string, number>;
  targetSharePermille: Record<string, number>;
}
export interface PayerResult {
  version: number; population: number; tiers: string[];
  realizedSharePermille: number[]; targetSharePermille: number[];
  deviationPermille: number[]; dominantArchetype: (string | null)[];
}

function tierOf(tiers: PayerTierDef[], score: number): number {
  for (let i = 0; i < tiers.length; i++) {
    if (tiers[i].willingnessLo <= score && score <= tiers[i].willingnessHi) return i;
  }
  return tiers.length - 1;
}

export function simulatePayer(cfg: PayerConfig, seg: SegmentsConfig): PayerResult {
  const rand = xorshift32(cfg.seed);
  const pop = cfg.population;
  const tiers = cfg.tiers;
  const archetypes = seg.archetypes;
  const shares = archetypes.map((a) => a.sharePct);
  const base = cfg.archetypeWillingnessBase;
  const spread = cfg.willingnessSpreadPct;

  const counts = new Array(tiers.length).fill(0) as number[];
  const archByTier: number[][] = tiers.map(() => new Array(archetypes.length).fill(0));

  for (let n = 0; n < pop; n++) {
    const ai = pick(rand, shares);
    const noise = (rand() % (2 * spread + 1)) - spread;
    let score = base[archetypes[ai].id] + noise;
    score = score < 0 ? 0 : score > 100 ? 100 : score;
    const ti = tierOf(tiers, score);
    counts[ti]++;
    archByTier[ti][ai]++;
  }

  const realizedSharePermille = counts.map((c) => Math.floor((c * 1000) / pop));
  const targetSharePermille = tiers.map((t) => cfg.targetSharePermille[t.id]);
  const dominantArchetype = archByTier.map((row) => {
    let best = 0;
    for (let j = 1; j < row.length; j++) if (row[j] > row[best]) best = j;
    return row[best] > 0 ? archetypes[best].id : null;
  });
  return {
    version: 1, population: pop, tiers: tiers.map((t) => t.id),
    realizedSharePermille, targetSharePermille,
    deviationPermille: realizedSharePermille.map((v, i) => v - targetSharePermille[i]),
    dominantArchetype,
  };
}

export function loadPayer(): PayerConfig {
  return loadConfig<PayerConfig>("ch02_payer");
}

两侧能对齐,靠的还是上一节那三条硬规则:整数系数、无浮点、整数地板除。分层这一步只用到取模抽样与整数比较,所以只要 PRNG 一致,两侧的占比就逐位一致。

读结果:一个高免费、长尾付费的漏斗

在本机(Python 3.14.6,种子 20261012,N=10000)跑出来的结果是:

层仿真占比‰目标占比‰偏差‰主导原型
免费玩家572570+2socializer
小额玩家252255−3conqueror
中额玩家1251250competitor
高额玩家4240+2competitor
超高额玩家710−3competitor

三个可以直接读出来的结论:

  • 漏斗是「高免费、长尾付费」:免费层 572‰ 占了近六成,而 high + whale 合计不到 50‰。这和公开可观察的 SLG 付费结构一致——大多数人不付费,少数人贡献大头。注意这里说的是意愿,不是实际流水。
  • 偏差全在个位数千分比:仿真的实测占比与目标占比最大差 3 个千分比,说明这套「原型意愿基准 + 扰动」的模型能稳定复现设计假设。这里同样是同机相对量级,绝对数值不可移植。
  • 高意愿层被竞争者主导:中额及以上的三层主导原型都是 competitor。这不是巧合——竞争者的意愿基准 78 最高,且其在免费层的占比几乎为零。它给出一条明确的画像:为强度付费的核心人群,是冲着排名与对抗来的。而免费层的主导是 socializer,说明社交型玩家是活跃盘的主体,却不是营收盘的主体。

分层只到画像为止

必须再强调一次边界:本节产出的是人群画像,不是付费点方案。知道了「谁是高意愿人群」,接下来「给他们设计什么付费点、定什么价、放什么位置」是另一卷的事——具体付费点设计在卷二第 9 章,LTV 与付费点的映射建模在卷二第 11 章。本节只保证一件事:当你在后续章节里说「大 R」时,背后有一条 0–100 的意愿轴和一份可复现的人群占比。

人群画像回答的是「谁在玩、谁愿意花钱」。但玩家不是静态的——同一个人在第 1 天、第 7 天、第 30 天的体验目标完全不同。下一节就把时间轴加上,讲生命周期阶段与体验目标。

阅读导航:上一节:玩家类型与动机模型 · 下一节:生命周期阶段与体验目标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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