10.1 定价锚点与档位设计

定价不是拍脑袋。本节用 config/pricing.json 的 5 档定价算出每档「每元玉璧数」与相邻档位的梯度,跑出可复算的档位梯度表,再用 B 基准把梯度与目标区间对照,并用 TypeScript 原型与 Python 逐位对照,守住这条梯度红线。

第 9 章把付费点摆好了位置,本节开始回答第二个问题:这些东西卖多少钱。定价这个话题在网上已经有大量策略清单——既有文章游戏定价和折扣策略 讲的是买断、DLC、捆绑包、促销怎么定,那是一份跨品类的策略清单;本节不重复它,而是把 SLG 内购的 5 个充值档位拆成可复算的数字:每档的单位价值是多少、相邻档位的梯度是多少、这个梯度该落在什么区间。付费点布局(首充、月卡、通行证)已经在第 9 章讲完,本节只管「标价」这一件事。

定价在直觉里是一个数,在产品里是一张表。玩家看到的不是「玉璧 6 元」,而是「6 元 / 30 元 / 98 元 / 328 元 / 648 元」这一排选项。这张表的每一格都不是孤立的——它们彼此参照,共同构成一个价格锚点系统。

定价锚点:价格从来不是孤立的

一个玩家判断「6 元贵不贵」,靠的不是 6 元本身,而是它旁边的 648 元。顶档是最贵的那个数,也是全表的锚:648 元立在那里,6 元就显得像「试一下也不亏」。这就是锚点效应——价格的绝对值不重要,相对位置才决定感知。

SLG 的充值档位是一套沿用了多年的心理价位:6 / 30 / 98 / 328 / 648(单位:元)。这些数不是随机的:它们贴近各大应用商店的常见内购档(公开可观察的机制,各品类通行,观察时点 2026 年),每一档都比前一档「看起来更划算一点点」,让玩家有往上买的理由。本套的档位写在 config/pricing.json 里(由主控维护,本卷只读):

档位价格(分)玉璧赠送到手玉璧
t1600600%60
t230003005%315
t3980098010%1078
t432800328015%3772
t564800648020%7776

金额一律用整数分(600 分 = 6 元),比率一律用整数百分比。之所以不用「元」,是因为元会引入小数,而本套要求 Python 与 TypeScript 两侧逐位一致——整数是唯一不会漂移的表示。

档位梯度:为什么要分 5 档而不是 1 档

档位存在的意义是筛支付意愿。不同玩家愿意花的钱不一样,一个档位只能抓住一个人群;5 个档位能覆盖从「试水」到「重氪」的整条曲线。但档位一旦分级,就有一个硬约束:高档的单位价值必须不低于低档,否则玩家只买低档,梯度倒挂。

看表里最后一列:到手玉璧从 60 涨到 7776,但价格也从 600 分涨到 64800 分。不能直接比绝对值,要比单位价值——同样 1 元钱,哪一档买到的玉璧更多。这个比值就是档位的「尺子」。

单位价值:把 5 档放到同一把尺子

单位价值定义为每元玉璧数,含赠送:

每元玉璧数 = 到手玉璧 / 价格(元) = 到手玉璧 × 100 / 价格(分)

用整数千分比表示(× 1000,避免小数)。t1 是 60 × 100 × 1000 / 600 = 10000,即 10.0 玉璧/元;t5 是 7776 × 100 × 1000 / 64800 = 12000,即 12.0 玉璧/元。顶档每元比首档多给 20%,这就是锚点系统的核心设计:越往上买,单位价值越高,给玩家「买大的更划算」的激励。

本章的补充参数(梯度目标区间)新建在 config/n10_pricing.json 里。下面这段是 D 层配置,只做 schema 声明:

{
  "version": 1,
  "note": "第 10 章补充参数(n10 前缀,代理新建):档位梯度目标、折扣窗口与稀缺限购。金额一律整数分,比率一律整数千分比,禁止浮点。",
  "tierAnchor": {
    "note": "档位梯度目标区间(千分比):相邻档位每元玉璧数的相对提升应落在 [stepMinPermille, stepMaxPermille]",
    "stepMinPermille": 45,
    "stepMaxPermille": 55
  },
  "discountWindow": {
    "note": "折扣窗口:限时天数、玩家池、需求响应假设(非实测,设计假设)",
    "days": 7,
    "playerPool": 1000,
    "liftPermillePerOffPct": 30
  },
  "scarcity": {
    "note": "稀缺:每赛季每档限购次数(0 表示不限购)",
    "limitPerSeasonByTier": { "d10": 0, "d30": 1, "d50": 1 }
  }
}

tierAnchor 里的 [45, 55] 是梯度目标区间(千分比):相邻档位的每元玉璧数相对提升,应该落在这条带子里。为什么要有带子?太陡,玩家会觉得「低档是坑」(梯度像断崖);太平,玩家没有往上买的理由。带子是设计约束,不是客观真理,后面的 B 基准会拿它来量当前的档位表。

仿真:一张可复算的档位梯度表

下面这段是 S 层仿真脚本,读 config/pricing.json 与 config/n10_pricing.json,算每档的每元玉璧数与相邻梯度,输出 JSON + CSV + SVG:

# file: sim/n10_tier_ladder.py
# tier: S
# verify: python3 sim/n10_tier_ladder.py --config config/ --out out/
"""档位梯度仿真:读 config/pricing.json 的 5 档定价与 config/n10_pricing.json 的梯度目标,
算每档「每元玉璧数」(千分比,含 bonusPct),以及相邻档位的相对提升(梯度千分比)。
产出 out/n10_tier_ladder.json + out/n10_tier_ladder.csv + out/n10_tier_ladder.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 effective_jade(tier: dict) -> int:
    """含赠送后的到手玉璧:jade × (100 + bonusPct) / 100,整数地板除。"""
    return tier["jade"] * (100 + tier["bonusPct"]) // 100


def per_yuan_milli(tier: dict) -> int:
    """每元玉璧数 × 1000(整数)。1 元 = 100 分,故 = effJade × 100 × 1000 / priceCents。"""
    return effective_jade(tier) * 100 * 1000 // tier["priceCents"]


def ladder(pricing: dict) -> list[dict]:
    rows = []
    prev = None
    for t in pricing["tiers"]:
        pym = per_yuan_milli(t)
        step = 0 if prev is None else (pym - prev) * 1000 // prev
        rows.append({
            "id": t["id"], "priceCents": t["priceCents"], "jade": t["jade"],
            "bonusPct": t["bonusPct"], "effectiveJade": effective_jade(t),
            "perYuanJadeMilli": pym, "stepPermille": step,
        })
        prev = pym
    return rows


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)

    pricing = load(args.config, "pricing")
    n10 = load(args.config, "n10_pricing")
    rows = ladder(pricing)

    with open(os.path.join(args.out, "n10_tier_ladder.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["id", "priceCents", "jade", "bonusPct", "effectiveJade", "perYuanJadeMilli", "stepPermille"])
        for r in rows:
            w.writerow([r["id"], r["priceCents"], r["jade"], r["bonusPct"],
                        r["effectiveJade"], r["perYuanJadeMilli"], r["stepPermille"]])

    render([r["perYuanJadeMilli"] for r in rows], os.path.join(args.out, "n10_tier_ladder.svg"),
           "per-yuan jade millis (ladder)")

    report = {
        "version": 1,
        "perYuanJadeMilli": [r["perYuanJadeMilli"] for r in rows],
        "stepPermille": [r["stepPermille"] for r in rows],
        "tiers": rows,
        "topOverBasePermille": (rows[-1]["perYuanJadeMilli"] - rows[0]["perYuanJadeMilli"]) * 1000 // rows[0]["perYuanJadeMilli"],
        "monotonic": all(rows[i]["perYuanJadeMilli"] >= rows[i - 1]["perYuanJadeMilli"] for i in range(1, len(rows))),
        "anchorTierId": rows[-1]["id"],
        "targetBand": [n10["tierAnchor"]["stepMinPermille"], n10["tierAnchor"]["stepMaxPermille"]],
    }
    with open(os.path.join(args.out, "n10_tier_ladder.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
    print(json.dumps(report, ensure_ascii=False))
    return 0


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

本机实跑产出的档位梯度表 out/n10_tier_ladder.csv:

id,priceCents,jade,bonusPct,effectiveJade,perYuanJadeMilli,stepPermille
t1,600,60,0,60,10000,0
t2,3000,300,5,315,10500,50
t3,9800,980,10,1078,11000,47
t4,32800,3280,15,3772,11500,45
t5,64800,6480,20,7776,12000,43

几个读法:

  • 每元玉璧数单调不减:10000 → 10500 → 11000 → 11500 → 12000。脚本里 monotonic 字段为 true。这是档位表健康的底线,一旦某档低于前一档,玩家就会绕过它。
  • 梯度递减:相邻档位的相对提升是 50‰ → 47‰ → 45‰ → 43‰。同样是每档多送 5 个百分点的 bonusPct,相对提升却在缩小。原因是 bonusPct 是「加在玉璧上」的绝对百分点,而梯度是「相对前一档」的比值——基数越来越大,同样的绝对增幅对应的相对增幅就越来越小。
  • 顶档相对首档 +20%(topOverBasePermille = 200):这是锚点的「拉力」,也是玩家愿意一次性充 648 的理由。

基准:梯度落在目标区间吗

上面这张表的梯度是 50 / 47 / 45 / 43,而 tierAnchor 给的目标区间是 [45, 55]。下面这段是 B 层基准脚本(软告警,不硬失败),把每个梯度与目标带子对照:

# file: sim/n10_tier_ladder_bench.py
# tier: B
# verify: python3 sim/n10_tier_ladder_bench.py --config config/ --out out/
"""档位梯度基准(B 层,软告警不硬失败):把每档相对提升与该档目标区间对照,
列出越界档位与偏离量,并检查每元玉璧数是否单调不减。产出 out/n10_tier_ladder_bench.json。"""

from __future__ import annotations

import argparse
import json
import os

from n10_tier_ladder import ladder, load


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)

    pricing = load(args.config, "pricing")
    n10 = load(args.config, "n10_pricing")
    lo, hi = n10["tierAnchor"]["stepMinPermille"], n10["tierAnchor"]["stepMaxPermille"]
    rows = ladder(pricing)

    out_of_band = []
    for i in range(1, len(rows)):
        step = rows[i]["stepPermille"]
        if step < lo or step > hi:
            out_of_band.append({"from": rows[i - 1]["id"], "to": rows[i]["id"], "stepPermille": step,
                                "deviationPermille": (step - lo) if step < lo else (step - hi)})

    report = {
        "version": 1, "kind": "baseline", "band": [lo, hi],
        "steps": [r["stepPermille"] for r in rows],
        "monotonic": all(rows[i]["perYuanJadeMilli"] >= rows[i - 1]["perYuanJadeMilli"] for i in range(1, len(rows))),
        "outOfBand": out_of_band, "pass": len(out_of_band) == 0,
    }
    with open(os.path.join(args.out, "n10_tier_ladder_bench.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
    print(json.dumps(report, ensure_ascii=False))
    return 0


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

本机实跑输出:

{"version": 1, "kind": "baseline", "band": [45, 55], "steps": [0, 50, 47, 45, 43], "monotonic": true, "outOfBand": [{"from": "t4", "to": "t5", "stepPermille": 43, "deviationPermille": -2}], "pass": false}

基准的结论是一处越界:t4 → t5 的梯度 43‰ 比目标下界 45‰ 低了 2‰。这不是 bug,而是一个可解释的设计事实——bonusPct 的线性增长(0/5/10/15/20)撑不住等比的相对梯度。要让每一档的相对提升都稳在 45‰ 以上,顶档的 bonusPct 得从 20% 抬到约 22% 以上。基准脚本的价值不是判对错,而是把「凭感觉」的梯度变成一条可核对的红线:改了档位或 bonusPct,跑一次就知道有没有掉出带子。

原型:跨语言逐位一致

同一份 config/pricing.json,TypeScript 侧要算出完全相同的每元玉璧数。下面这段是 P 层原型,与 sim/n10_tier_ladder.py 用同样的整数地板除:

// file: proto/src/n10_price.ts
// tier: P
// verify: node --test proto/test/n10.test.ts
// 档位梯度原型:与 sim/n10_tier_ladder.py 消费同一份 config/pricing.json,
// 整数地板除,输出逐位一致。

import { loadConfig } from "./core.ts";

export interface Tier { id: string; priceCents: number; jade: number; bonusPct: number }
export interface PricingConfig { currency: { id: string; perYuan: number }; tiers: Tier[] }

export interface LadderRow {
  id: string; priceCents: number; effectiveJade: number;
  perYuanJadeMilli: number; stepPermille: number;
}

export function effectiveJade(t: Tier): number {
  return Math.floor((t.jade * (100 + t.bonusPct)) / 100);
}

export function perYuanMilli(t: Tier): number {
  return Math.floor((effectiveJade(t) * 100 * 1000) / t.priceCents);
}

export function ladder(cfg: PricingConfig): LadderRow[] {
  const rows: LadderRow[] = [];
  let prev = 0;
  for (const t of cfg.tiers) {
    const pym = perYuanMilli(t);
    const step = rows.length === 0 ? 0 : Math.floor(((pym - prev) * 1000) / prev);
    rows.push({ id: t.id, priceCents: t.priceCents, effectiveJade: effectiveJade(t), perYuanJadeMilli: pym, stepPermille: step });
    prev = pym;
  }
  return rows;
}

export function loadPricing(): PricingConfig {
  return loadConfig<PricingConfig>("pricing");
}

本机实跑 node --test proto/test/n10.test.ts 的输出:

✔ 每元玉璧数与 Python 逐位一致 (1.422125ms)
✔ 档位梯度与 Python 逐位一致 (0.530333ms)
✔ 含赠送到手玉璧正确(t2 = 300 × 105 / 100 = 315) (0.40075ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0

三条断言全过。跨语言逐位一致不是形式主义:定价数字一旦在两侧算出不同结果,就意味着某个除法用了浮点——Math.floor 与 Python // 在负数上的行为不同,0.1 + 0.2 这类浮点误差更会直接让对账失败。所以本套的硬规则是:系数用整数百分比,除法一律整数地板除。

锚点的两个用途

定价锚点在本章有两个直接用途:

  1. 定梯度:相邻档位的相对提升是一条可核对的红线。目标区间 [45, 55] 是设计约束,B 基准负责守线。
  2. 做参照:任何一次促销或折扣,玩家都会拿折后价与这张表比——10.2 会把「折扣击穿锚点」量化成一个数。

最后强调边界:档位与 bonusPct 的具体数值是设计选择,不是客观最优。它们来自对支付意愿的假设,真实的支付意愿需要真实投放数据回归(X 层——需真实业务环境,命令行不可复现)。本节能做的是:给定档位表,算出每档单位价值与梯度,并对目标区间守线;做不了的是:告诉你 bonusPct 该定 5% 还是 8%。

小结

定价不是 5 个孤立的数,而是一张锚点表:顶档立锚,各档按梯度往上走。本节把这张表变成可复算的输出——每元玉璧数 [10000, 10500, 11000, 11500, 12000],梯度 [50, 47, 45, 43],并发现 t4 → t5 掉了 2‰ 出带。梯度是一条能被脚本守住的红线,而不是一句「看着差不多」。下一节进入更麻烦的部分:折扣如何打破这张表的秩序。

阅读导航:上一节:目录 · 下一节:10.2 折扣、限时与稀缺 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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