6.1 卡池结构与概率公示

把《王朝纪元》的抽卡拆成一张可公示的概率表:稀有度分档、单卡概率、限时池与常驻池的差异,以及保底参数如何在池之间继承。用一段 S 层脚本从 config/n06_gacha.json 重算公示概率并校验三档之和恰为 1000‰,再用一段 P 层原型在 TypeScript 侧复刻同一份 mulberry32,验证公示表与随机序列跨语言逐位一致。

站内那篇游戏定价和折扣策略 是一份面向买断、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。

公示口径:基础概率与综合概率

监管与平台对抽卡公示有两条口径,两条都必须给:

  1. 基础概率:单抽的瞬时概率,也就是 rarityPermille 这一列。它是静态的、与「抽了多少次」无关。
  2. 综合概率:把保底摊进去之后的长期平均出货率,通常写作「含保底综合概率」。它由保底参数和基础概率共同决定,是玩家体感到的真实出货率。

只公示基础概率、不公示综合概率,会让玩家按 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 期望成本与「多少抽出货」 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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