第 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 里(由主控维护,本卷只读):
| 档位 | 价格(分) | 玉璧 | 赠送 | 到手玉璧 |
|---|---|---|---|---|
| t1 | 600 | 60 | 0% | 60 |
| t2 | 3000 | 300 | 5% | 315 |
| t3 | 9800 | 980 | 10% | 1078 |
| t4 | 32800 | 3280 | 15% | 3772 |
| t5 | 64800 | 6480 | 20% | 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 这类浮点误差更会直接让对账失败。所以本套的硬规则是:系数用整数百分比,除法一律整数地板除。
锚点的两个用途
定价锚点在本章有两个直接用途:
- 定梯度:相邻档位的相对提升是一条可核对的红线。目标区间
[45, 55]是设计约束,B 基准负责守线。 - 做参照:任何一次促销或折扣,玩家都会拿折后价与这张表比——10.2 会把「折扣击穿锚点」量化成一个数。
最后强调边界:档位与 bonusPct 的具体数值是设计选择,不是客观最优。它们来自对支付意愿的假设,真实的支付意愿需要真实投放数据回归(X 层——需真实业务环境,命令行不可复现)。本节能做的是:给定档位表,算出每档单位价值与梯度,并对目标区间守线;做不了的是:告诉你 bonusPct 该定 5% 还是 8%。
小结
定价不是 5 个孤立的数,而是一张锚点表:顶档立锚,各档按梯度往上走。本节把这张表变成可复算的输出——每元玉璧数 [10000, 10500, 11000, 11500, 12000],梯度 [50, 47, 45, 43],并发现 t4 → t5 掉了 2‰ 出带。梯度是一条能被脚本守住的红线,而不是一句「看着差不多」。下一节进入更麻烦的部分:折扣如何打破这张表的秩序。
阅读导航:上一节:目录 · 下一节:10.2 折扣、限时与稀缺 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。