5.2 构筑的方差与平衡
《独立游戏平衡与经济设计》 谈平衡时用的是「太强就削、太弱就加」这类迭代经验,方差只在「别让运气决定胜负」一句话里带过;本节把方差量成整数,并说明它为什么不是要削掉的东西。前一节把《灰烬牌库》拆成五个字段,本节只动其中一个:洗牌袋——它是构筑与体验之间唯一的随机接口。
构筑 = 只改 cards 段
在这套规格里,「构筑」不是新机制,是改 cards 段。三副牌组规模都是 12 张,只有伤害分布不同:
| 牌组 | 牌面(伤害 × 张数) | 总伤害 | 每张均值 |
|---|---|---|---|
| 基线 A | 6×5、14×3、3×2、0×2 | 78 | 6.5 |
| 均匀 B | 4×6、8×6 | 72 | 6.0 |
| 起伏 C | 0×3、12×3、6×6 | 72 | 6.0 |
B 与 C 的总伤害相同、每张均值相同,差别只在分布的形状:B 是两档紧挨着的台阶,C 是「三张废牌 + 三张大牌」的两头沉。这一对是本节的核心对照组。
精确枚举:792 种起手
起手强度定义为开局 5 张牌的伤害之和。手牌从 12 张里无放回地抽 5 张,组合数恰是 C(12,5)=792——数量小到可以全部枚举,因此均值与方差是精确值,不是采样估计。测试对三副牌各枚举 792 次:
// file: core/test/gd_proto_05_variance.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_05_variance.test.ts'
//
// 5.2 构筑的方差与平衡(P 层):用**整数**量度三副牌组的起手强度方差——一副精确枚举全部
// C(12,5)=792 种起手,一副在 128 个种子上实测洗牌袋。两个关键读数:(1) 「均匀」与「起伏」
// 两副牌的起手期望完全相同(30000‰,即均值 30),方差却相差 4.5 倍;(2) 起手方差几乎不改变
// 胜率,却成倍放大「结束回合数」的方差——方差不是坏东西,它是节奏的来源。
// 胜率与方差都是**代理指标(proxy)**,只做相对比较,禁绝对化。全整数、无浮点、无 random。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../src/ashdeck/combat.ts";
import { shuffleDeck } from "../src/ashdeck/rng.ts";
import { buildDeck } from "../src/ashdeck/cards.ts";
import type { Card, Spec } from "../src/ashdeck/types.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const SPEC: Spec = JSON.parse(readFileSync(join(ROOT, "config", "play_spec.json"), "utf8"));
const POOL = Array.from({ length: 128 }, (_, i) => 301 + i);
const K = SPEC.params.handSize; // 起手张数 = 5
/** 构筑 = 只改 cards 段。三副牌组:基线 / 两档均匀 / 高低起伏。 */
function cards(rows: [string, number, number, number, number][]): Card[] {
return rows.map(([id, damage, cost, block, count]) => ({ id, damage, cost, block, count, rarity: "common" }));
}
const A = cards([["strike", 6, 1, 0, 5], ["heavy", 14, 2, 0, 3], ["jab", 3, 0, 0, 2], ["guard", 0, 1, 5, 2]]);
const B = cards([["flat4", 4, 1, 0, 6], ["flat8", 8, 1, 0, 6]]);
const D = cards([["zero", 0, 1, 0, 3], ["big", 12, 1, 0, 3], ["mid", 6, 1, 0, 6]]);
function damages(cs: Card[]): number[] {
const xs: number[] = [];
for (const c of cs) for (let i = 0; i < c.count; i += 1) xs.push(c.damage);
return xs;
}
/** 精确枚举全部 K-子集:整数统计量,无采样误差。 */
function exact(xs: number[]): { hands: number; meanMilli: number; varMilli: number; lo: number; hi: number } {
const n = xs.length;
let s1 = 0, s2 = 0, cnt = 0, lo = 1e9, hi = -1e9;
for (let a = 0; a < n; a += 1) for (let b = a + 1; b < n; b += 1)
for (let c = b + 1; c < n; c += 1) for (let d = c + 1; d < n; d += 1)
for (let e = d + 1; e < n; e += 1) {
const v = xs[a] + xs[b] + xs[c] + xs[d] + xs[e];
s1 += v; s2 += v * v; cnt += 1;
if (v < lo) lo = v;
if (v > hi) hi = v;
}
return { hands: cnt, meanMilli: Math.floor((1000 * s1) / cnt),
varMilli: Math.floor((1000 * (cnt * s2 - s1 * s1)) / (cnt * cnt)), lo, hi };
}
/** 在种子池上实测洗牌袋:起手强度与结束回合的均值/方差(千分比缩放)。 */
function pooled(cs: Card[]): { win: number; oMean: number; oVar: number; tVar: number; lo: number; hi: number } {
let wins = 0, o1 = 0, o2 = 0, t1 = 0, t2 = 0, lo = 1e9, hi = -1e9;
for (const sd of POOL) {
const sp: Spec = JSON.parse(JSON.stringify(SPEC));
sp.seed = sd;
sp.cards = cs;
const s = runSpec(sp);
const deck = shuffleDeck(buildDeck(cs), sd);
let o = 0;
for (let i = 0; i < K; i += 1) o += deck[i].damage;
o1 += o; o2 += o * o; t1 += s.turn; t2 += s.turn * s.turn;
if (s.won) wins += 1;
if (o < lo) lo = o;
if (o > hi) hi = o;
}
const n = POOL.length;
return { win: Math.floor((1000 * wins) / n), oMean: Math.floor((1000 * o1) / n),
oVar: Math.floor((1000 * (n * o2 - o1 * o1)) / (n * n)),
tVar: Math.floor((1000 * (n * t2 - t1 * t1)) / (n * n)), lo, hi };
}
test("精确枚举:三副牌组的起手张数都是 C(12,5)=792", () => {
for (const cs of [A, B, D]) assert.equal(exact(damages(cs)).hands, 792);
});
test("起手强度的精确均值与方差(千分比)", () => {
assert.deepEqual(exact(damages(A)), { hands: 792, meanMilli: 32500, varMilli: 73977, lo: 12, hi: 54 });
assert.deepEqual(exact(damages(B)), { hands: 792, meanMilli: 30000, varMilli: 12727, lo: 20, hi: 40 });
assert.deepEqual(exact(damages(D)), { hands: 792, meanMilli: 30000, varMilli: 57272, lo: 12, hi: 48 });
});
test("同期望不同方差:均匀与起伏的均值相同,方差相差 4 倍以上", () => {
const b = exact(damages(B)), d = exact(damages(D));
assert.equal(b.meanMilli, d.meanMilli);
assert.equal(b.meanMilli, 30000);
assert.ok(d.varMilli > b.varMilli * 4, `起伏方差 ${d.varMilli} 应远高于均匀 ${b.varMilli}`);
});
test("实测洗牌袋复现精确方差(128 个种子)", () => {
assert.deepEqual(pooled(A), { win: 1000, oMean: 32484, oVar: 73921, tVar: 277, lo: 12, hi: 54 });
assert.deepEqual(pooled(B), { win: 1000, oMean: 29687, oVar: 13902, tVar: 97, lo: 20, hi: 40 });
assert.deepEqual(pooled(D), { win: 1000, oMean: 29906, oVar: 51178, tVar: 221, lo: 12, hi: 42 });
});
test("方差不是坏东西:起手方差几乎不改胜率,却成倍放大结束回合的方差", () => {
const b = pooled(B), d = pooled(D);
assert.equal(b.win, d.win); // 胜率都被压满,方差在此不体现为胜负
assert.ok(d.tVar > b.tVar * 2, `起伏的回合方差 ${d.tVar} 应高于均匀的 ${b.tVar}`);
assert.ok(d.oVar > b.oVar * 3);
});
实跑 node --test 'core/test/gd_proto_05_variance.test.ts':
✔ 精确枚举:三副牌组的起手张数都是 C(12,5)=792
✔ 起手强度的精确均值与方差(千分比)
✔ 同期望不同方差:均匀与起伏的均值相同,方差相差 4 倍以上
✔ 实测洗牌袋复现精确方差(128 个种子)
✔ 方差不是坏东西:起手方差几乎不改胜率,却成倍放大结束回合的方差
ℹ tests 5
ℹ pass 5
ℹ fail 0
精确值:三副牌的起手分布
均值与方差都写成千分比缩放的整数(meanMilli 是均值的 1000 倍,varMilli 是方差的 1000 倍),全程整数地板除,无浮点:
| 牌组 | 起手种类 | 均值 meanMilli | 方差 varMilli | 起手最小值 | 起手最大值 |
|---|---|---|---|---|---|
| 基线 A | 792 | 32500 | 73977 | 12 | 54 |
| 均匀 B | 792 | 30000 | 12727 | 20 | 40 |
| 起伏 C | 792 | 30000 | 57272 | 12 | 48 |
把 792 种起手按伤害和归档,B 与 C 的分布形状一目了然(表内是出现次数,两行各自求和都是 792):
| 起手伤害和 | 12 | 18 | 20 | 24 | 28 | 30 | 32 | 36 | 40 | 42 | 48 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 均匀 B | 0 | 0 | 6 | 90 | 300 | 0 | 300 | 90 | 6 | 0 | 0 |
| 起伏 C | 15 | 78 | 0 | 183 | 0 | 240 | 0 | 183 | 0 | 78 | 15 |
B 的全部概率压在 24–36 这五个相邻值上,C 则摊在 12–48 七个值上、两端各留 15 次「最坏起手」与「最好起手」。期望一样,宽度差一倍多——这就是方差。
同期望,不同方差
把 B 与 C 并排看,这是本节最该记住的一对读数:
| 量 | 均匀 B | 起伏 C | 相对关系 |
|---|---|---|---|
| 起手均值 meanMilli | 30000 | 30000 | 完全相同 |
| 起手方差 varMilli | 12727 | 57272 | C 是 B 的 4.5 倍 |
| 实测起手方差(128 种子) | 13902 | 51178 | 复现精确值,采样误差约 1000 |
| 胜率(千分比,proxy) | 1000 | 1000 | 都被压满,方差在此不体现 |
| 结束回合方差(千分比) | 97 | 221 | C 是 B 的 2.3 倍 |
第二行是本节的结论:平衡与方差是两个正交的轴。调平衡动的是均值(每张牌该打多少),调方差动的是分布(伤害该集中还是摊开);两者可以独立设定,B 与 C 就是「同均值、异方差」的一对实证。
方差不是坏东西,是节奏的来源
第三、四行的对比才是设计上真正有用的部分。B 与 C 的胜率完全相同(都是 1000‰)——起手方差没有把胜负翻过来;但它把结束回合的分布改了。128 个种子上,三副牌各自的结束回合分布:
| 结束回合 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|
| 基线 A | 0 | 58 | 68 | 2 |
| 均匀 B | 0 | 114 | 14 | 0 |
| 起伏 C | 1 | 89 | 38 | 0 |
均匀牌组 128 局里有 114 局在第 3 回合结束,几乎是同一条直线;起伏牌组摊在 2–4 回合上,最快一局 2 回合打完、最慢拖到 4 回合。同一份胜率,不同的时间形状——玩家感受到的「这一局顺、那一局卡」正是从这里来的。把方差压到零,胜率不变,但每一局都长得一模一样,节奏也就死了。
这里必须说清楚:胜率、起手方差、结束回合方差都是代理指标(proxy),只用于相对比较,不能推出「方差越大越好玩」这类绝对结论。本节能证明的只有一件事——方差是可独立调节的参数,它改变的是过程而不是结果;至于某副牌组「更好玩」,需要真实玩家数据(X 层),命令行不可复现。
方差是旋钮:从全平到全散
把方差当成一个可以拧的旋钮,四副牌组按「分布宽度」排开。前一行是解析结论,后三行都来自 792 种起手的精确枚举:
| 构筑 | 每张伤害的分布 | 起手均值 meanMilli | 起手方差 varMilli |
|---|---|---|---|
| 全同(解析) | 12 张全是 6 | 30000 | 0 |
| 两档均匀 B | 4×6、8×6 | 30000 | 12727 |
| 高低起伏 C | 0×3、12×3、6×6 | 30000 | 57272 |
| 基线 A | 6×5、14×3、3×2、0×2 | 32500 | 73977 |
「全同」一行是解析结论而非实跑:12 张牌伤害全为 6 时,任意 5 张起手的伤害和恒为 30,方差恒为 0——这是方差轴的下界。三副实牌里,B 与 C 的均值都是 30000,方差却从 12727 拉到 57272;基线 A 的均值高一点(32500),方差也最高(73977),说明均值与方差可以各自设定,不是一根轴上的两个点。
与「平衡」的关系
回到小节标题。平衡问的是期望对不对齐:B 与 C 的起手期望都是 30,说明这两副牌在「平均强度」上是对齐的,谁也没有白拿一张牌。方差问的是期望之外还剩多少不确定:C 的 15 次最坏起手(伤害和 12)意味着有 19‰ 的局一开局就落后,B 则一次都不会。两件事分开量、分开调,才谈得上「平衡而不无聊」。
| 问题 | 量什么 | 本节读数 |
|---|---|---|
| 两副牌平均强度一样吗 | 起手均值 | B=C=30000,对齐 |
| 两副牌的不确定一样吗 | 起手方差 | 12727 对 57272,差 4.5 倍 |
| 不确定落在体验的哪里 | 结束回合方差 | 97 对 221,差 2.3 倍 |
| 谁更好玩 | —— | 代理指标答不了,须 X 层真实玩家数据 |
一句话收口:平衡是把不同构筑的期望拉到同一水平线上,方差是在这条线上决定每一局离它多远。只调平衡,牌组会变得可预测而乏味;只调方差,牌组会变得有起伏但不可比。两件事各有自己的整数读数,本节把它们分开量了。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
node --test 'core/test/gd_proto_05_variance.test.ts' | 断言结果 | 5 测试全绿,0 失败 |
起手方差是一条能独立调的轴。下一节换个问法:把牌组规模当成被扫的参数,一路加废牌,看这副构筑在哪个规模上「被解开」——从能赢到赢不动,中间那个点在哪里。
阅读导航:上一节:5.1 卡牌构筑的机制解剖 · 下一节:5.3 构筑的相变 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。