站内那篇游戏定价和折扣策略 是一份面向买断、DLC、捆绑包与促销的策略清单,它的落点是「价格怎么定、折扣怎么放」。本节和下一节管的是另一件事:一个概率型付费点(卡池)的结构怎么被定义、怎么被公示、抽一次要花多少。礼包的性价比留到 6.3,定价档位与折扣的副作用留给第 10 章。第 5 章已经把伪随机与保底机制 讲透,本节只谈卡池结构与公示口径——保底参数怎么被卡池引用、公示表怎么被算出来。
卡池是什么:一张可公示的概率表
一个卡池在工程上就是一张表:稀有度 → 概率 → 卡数。玩家看到的「抽中概率」不是拍脑袋写的一句宣传语,而是这张表算出来的确定值。SLG 的卡池通常分三档稀有度(SSR / SR / R,也就是名将 / 良将 / 普通),每档挂若干张卡,卡池公布的是每档概率,而玩家真正关心的是具体某张卡的概率——后者等于每档概率除以该档卡数。
这里有一个容易被忽略的设计选择:同一档概率下,卡池里放多少张卡。限时池里 SSR 只有 1 张(当期主推),常驻池里 SSR 可能有 8 张。两档 SSR 概率都设 16‰,但限时池抽到「就是那张卡」的概率是 16‰,常驻池抽到某一张的概率只有 2‰。这就是为什么限时池更适合做「定向获取」,常驻池更适合做「广谱补充」。
分档、单卡概率与「歪」
把结构写成整数千分比,一个池需要四个字段:
- rarityPermille:每档概率,单位千分比(16 表示 16‰,即 1.6%)。三档之和必须恰为 1000‰,这是公示的硬约束。
- cardsPerRarity:每档的卡数。它决定单卡概率 =
rarityPermille // cardsPerRarity。 - pity:保底参数。保底机制本身在第 5 章定义,卡池只引用它的三个数:硬保底抽数、软保底起点、软保底每抽增量。
- offBannerPermille:出 SSR 时「歪到非 UP 卡」的比例。限时池设 500(50% 歪),常驻池设 1000(没有 UP 概念,全歪)。
「歪」是 SLG 抽卡里最容易被公示规则漏掉的一环。玩家算的不是「出 SSR 的期望」,而是「出我想要的那张的期望」——这两者在有 UP 机制时差得很远。本节先把结构钉死,出货期望留到 6.2。
公示口径:基础概率与综合概率
监管与平台对抽卡公示有两条口径,两条都必须给:
- 基础概率:单抽的瞬时概率,也就是
rarityPermille这一列。它是静态的、与「抽了多少次」无关。 - 综合概率:把保底摊进去之后的长期平均出货率,通常写作「含保底综合概率」。它由保底参数和基础概率共同决定,是玩家体感到的真实出货率。
只公示基础概率、不公示综合概率,会让玩家按 1.6% 去估算,而实际体感是 2.4% 左右(6.2 会跑出这个数)——公示少一项,体验预期就错一档。下面是这份卡池配置,也是本节的唯一真源:
{
"version": 1,
"note": "第 6 章卡池与礼包参数(n06 前缀,独立于卷一 ch*)。概率一律整数千分比(permille),金额一律整数分,比率整数百分比,禁止浮点。保底机制的定义与推导在第 5 章,此处只引用其参数。",
"currency": { "id": "jade", "name": "玉璧", "perYuan": 10 },
"pullCostJade": 150,
"pools": [
{
"id": "limited",
"name": "限时名将池",
"rarityPermille": { "ssr": 16, "sr": 84, "r": 900 },
"cardsPerRarity": { "ssr": 1, "sr": 3, "r": 9 },
"pity": { "hardPityPulls": 80, "softPityStart": 60, "softPityStepPermille": 40 },
"offBannerPermille": 500
},
{
"id": "standard",
"name": "常驻名将池",
"rarityPermille": { "ssr": 16, "sr": 84, "r": 900 },
"cardsPerRarity": { "ssr": 8, "sr": 12, "r": 20 },
"pity": { "hardPityPulls": 80, "softPityStart": 60, "softPityStepPermille": 40 },
"offBannerPermille": 1000
}
],
"disclosure": { "note": "公示口径:单抽基础概率与含保底的综合概率都要公示", "minSamplePulls": 100000 },
"bundles": [
{ "id": "b_starter", "name": "新手礼包", "priceCents": 600, "jade": 0, "resource": 9000, "speedupMinutes": 0 },
{ "id": "b_resource", "name": "资源补给包", "priceCents": 1200, "jade": 0, "resource": 12000, "speedupMinutes": 0 },
{ "id": "b_speedup", "name": "加速包", "priceCents": 3000, "jade": 0, "resource": 0, "speedupMinutes": 9000 },
{ "id": "b_jade", "name": "玉璧包", "priceCents": 6800, "jade": 680, "resource": 0, "speedupMinutes": 0 },
{ "id": "b_mixed", "name": "混装大礼包", "priceCents": 32800, "jade": 3280, "resource": 50000, "speedupMinutes": 3000 }
]
}
用脚本把公示表算出来
结构一旦落成配置,公示表就不该手写——手写会算错,改了配置还会忘同步。下面这段是 S 层仿真脚本,它读上面那份配置,重算每个稀有度的公示概率与单卡概率,并校验三档之和是否恰为 1000‰:
# file: sim/n06_gacha.py
# tier: S
# verify: python3 sim/n06_gacha.py --config config/ --out out/
"""卡池结构与概率公示校验:读 config/n06_gacha.json,重算每个稀有度的公示概率(整数千分比)
与单卡概率,校验三档之和恰为 1000‰,输出公示表 JSON + CSV + SVG。只读 config/。"""
from __future__ import annotations
import argparse
import csv
import json
import os
from svg_line import render
RARITY_ORDER = ("ssr", "sr", "r")
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 rarity_table(pool: dict) -> tuple[list[dict], int]:
"""算出每个稀有度的公示概率与单卡概率(整数千分比)。"""
rows, total = [], 0
for rar in RARITY_ORDER:
permille, ncards = pool["rarityPermille"][rar], pool["cardsPerRarity"][rar]
per_card = permille // ncards
rows.append({"rarity": rar, "permille": permille, "cards": ncards,
"perCardPermille": per_card, "exact": per_card * ncards == permille})
total += permille
return rows, total
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, "n06_gacha")
pools_out, csv_rows = [], []
for pool in cfg["pools"]:
rows, total = rarity_table(pool)
pools_out.append({"id": pool["id"], "name": pool["name"], "totalPermille": total,
"sumOk": total == 1000, "allExact": all(r["exact"] for r in rows),
"rarities": rows})
csv_rows += [[pool["id"], r["rarity"], r["permille"], r["cards"], r["perCardPermille"]] for r in rows]
with open(os.path.join(args.out, "n06_gacha.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["pool", "rarity", "permille", "cards", "perCardPermille"])
w.writerows(csv_rows)
first = pools_out[0]
render([r["perCardPermille"] for r in first["rarities"]],
os.path.join(args.out, "n06_gacha.svg"), f"{first['id']} per-card permille")
report = {"version": 1, "pullCostJade": cfg["pullCostJade"], "pools": pools_out,
"allSumOk": all(p["sumOk"] for p in pools_out),
"allExact": all(p["allExact"] for p in pools_out)}
with open(os.path.join(args.out, "n06_gacha.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())
本机实跑(Python 3.14.6)产出的公示表 out/n06_gacha.csv 与两个校验标志:
pool,rarity,permille,cards,perCardPermille
limited,ssr,16,1,16
limited,sr,84,3,28
limited,r,900,9,100
standard,ssr,16,8,2
standard,sr,84,12,7
standard,r,900,20,45
allSumOk=true allExact=true
两件事被这张表钉死了:限时池单卡 16‰、常驻池单卡 2‰(同为 16‰ 的档位概率,单卡概率差了 8 倍);allExact=true 说明每档概率都能被卡数整除——如果某档放 7 张卡而概率是 84‰,84 // 7 = 12 没问题,但若放 8 张,84 // 8 = 10 会丢掉 4‰,公示表就对不上账。卡数与概率必须能整除,这是卡池配置的第一条硬约束。
跨语言对照:同一份 PRNG
本套的规矩是「一份配置、两处消费」。卡池的随机抽取也要在 Python 仿真和 TypeScript 原型两侧逐位一致,否则两边对同一个池会算出不同的出货分布。做法是自实现确定性 PRNG(mulberry32),两侧同算法同种子。下面这段是 P 层原型模块,它读同一份 config/n06_gacha.json,并在 TypeScript 侧复刻 mulberry32:
// file: proto/src/n06_gacha.ts
// tier: P
// verify: node --test proto/test/n06.test.ts
// 卡池原型:与 sim/n06_gacha.py 消费同一份 config/n06_gacha.json,逐位复刻 mulberry32,
// 用于跨语言对照与卡池公示表校验。
import { loadConfig } from "./core.ts";
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = (a ^ (a >>> 15)) >>> 0;
t = Math.imul(t, a | 1) >>> 0;
const u = (t ^ (t >>> 7)) >>> 0;
const im = Math.imul(u, t | 61) >>> 0;
t = ((t + im) >>> 0) ^ t;
return (t ^ (t >>> 14)) >>> 0;
};
}
export interface GachaPool {
id: string;
name: string;
rarityPermille: Record<string, number>;
cardsPerRarity: Record<string, number>;
pity: { hardPityPulls: number; softPityStart: number; softPityStepPermille: number };
offBannerPermille: number;
}
export interface GachaConfig {
pullCostJade: number;
pools: GachaPool[];
bundles: Array<Record<string, number | string>>;
}
export interface RarityRow {
rarity: string;
permille: number;
cards: number;
perCardPermille: number;
exact: boolean;
}
export function rarityTable(pool: GachaPool): { rows: RarityRow[]; total: number } {
const rows: RarityRow[] = [];
let total = 0;
for (const rarity of ["ssr", "sr", "r"]) {
const permille = pool.rarityPermille[rarity];
const cards = pool.cardsPerRarity[rarity];
const perCardPermille = Math.floor(permille / cards);
rows.push({ rarity, permille, cards, perCardPermille, exact: perCardPermille * cards === permille });
total += permille;
}
return { rows, total };
}
export function ssrPermilleAt(pullIndex: number, pool: GachaPool): number {
const { hardPityPulls, softPityStart, softPityStepPermille } = pool.pity;
if (pullIndex >= hardPityPulls) return 1000;
let p = pool.rarityPermille.ssr;
if (pullIndex >= softPityStart) p += softPityStepPermille * (pullIndex - softPityStart + 1);
return Math.min(1000, p);
}
export function pullsToFirstSsr(pool: GachaPool, nextU32: () => number): number {
let pulls = 0;
for (;;) {
pulls += 1;
const p = ssrPermilleAt(pulls, pool);
const draw = Math.floor((nextU32() * 1000) / 4294967296);
if (draw < p) return pulls;
}
}
export function loadGacha(): GachaConfig {
return loadConfig<GachaConfig>("n06_gacha");
}
这份模块的 verify 指向 proto/test/n06.test.ts:6.2 会给出那份测试,它断言 TS 侧的 mulberry32 输出与 Python 侧逐位相同、公示表与 Python 一致、首个 SSR 抽数分布与 Python 一致。随机不是「大概一样」,是「每一位都一样」——这是跨语言对照能成立的前提。
卡池结构的三个决定
回到设计本身,一个卡池结构定下来,等于定了三件事:
- 档位概率决定了「白嫖体感」。三档之和 1000‰,R 档占 900‰ 意味着九成的抽取是保底素材,玩家不会因为「什么都没抽到」而愤怒——因为 R 档本身就是设计给玩家的常规收益。R 档压得太低(比如 700‰),玩家会频繁抽到「空手」,体感会崩。
- 卡数决定了「定向获取难度」。同一档概率下卡越多,单卡越难。限时池用 1 张 SSR 做定向,常驻池用 8 张做广度,这是两种不同的商业化目标。
- 保底参数决定了「综合概率」,而综合概率才是要公示给玩家的体感值。参数由第 5 章定义,卡池只负责引用——保底是概率的一部分,不是概率之外的补偿。
小结
卡池结构 = 一张「稀有度 → 概率 → 卡数」的公示表,加上一组引用自第 5 章的保底参数。本节用 S 层脚本把这张表从配置里算出来并校验了 1000‰ 与整除性,用 P 层原型证明了同一份配置能在两种语言里逐位复现。结构定完,下一节回答玩家最关心的问题:这个池子,平均要抽多少下、花多少钱才出。
阅读导航:上一节:目录 · 下一节:6.2 期望成本与「多少抽出货」 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。