5.2 构筑的方差与平衡

把构筑的方差量成整数:精确枚举 C(12,5)=792 种起手,三副牌组的起手强度方差相差 5.8 倍;「均匀」与「起伏」两副牌起手期望完全相同(均值 30),方差却是 12727 对 57272,胜率一样而结束回合的方差相差 2.3 倍——方差不是坏东西,它是节奏的来源。

5.2 构筑的方差与平衡

《独立游戏平衡与经济设计》 谈平衡时用的是「太强就削、太弱就加」这类迭代经验,方差只在「别让运气决定胜负」一句话里带过;本节把方差量成整数,并说明它为什么不是要削掉的东西。前一节把《灰烬牌库》拆成五个字段,本节只动其中一个:洗牌袋——它是构筑与体验之间唯一的随机接口。

构筑 = 只改 cards 段

在这套规格里,「构筑」不是新机制,是改 cards 段。三副牌组规模都是 12 张,只有伤害分布不同:

牌组牌面(伤害 × 张数)总伤害每张均值
基线 A6×5、14×3、3×2、0×2786.5
均匀 B4×6、8×6726.0
起伏 C0×3、12×3、6×6726.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起手最小值起手最大值
基线 A79232500739771254
均匀 B79230000127272040
起伏 C79230000572721248

把 792 种起手按伤害和归档,B 与 C 的分布形状一目了然(表内是出现次数,两行各自求和都是 792):

起手伤害和1218202428303236404248
均匀 B00690300030090600
起伏 C157801830240018307815

B 的全部概率压在 24–36 这五个相邻值上,C 则摊在 12–48 七个值上、两端各留 15 次「最坏起手」与「最好起手」。期望一样,宽度差一倍多——这就是方差。

同期望,不同方差

把 B 与 C 并排看,这是本节最该记住的一对读数:

量均匀 B起伏 C相对关系
起手均值 meanMilli3000030000完全相同
起手方差 varMilli1272757272C 是 B 的 4.5 倍
实测起手方差(128 种子)1390251178复现精确值,采样误差约 1000
胜率(千分比,proxy)10001000都被压满,方差在此不体现
结束回合方差(千分比)97221C 是 B 的 2.3 倍

第二行是本节的结论:平衡与方差是两个正交的轴。调平衡动的是均值(每张牌该打多少),调方差动的是分布(伤害该集中还是摊开);两者可以独立设定,B 与 C 就是「同均值、异方差」的一对实证。

方差不是坏东西,是节奏的来源

第三、四行的对比才是设计上真正有用的部分。B 与 C 的胜率完全相同(都是 1000‰)——起手方差没有把胜负翻过来;但它把结束回合的分布改了。128 个种子上,三副牌各自的结束回合分布:

结束回合2345
基线 A058682
均匀 B0114140
起伏 C189380

均匀牌组 128 局里有 114 局在第 3 回合结束,几乎是同一条直线;起伏牌组摊在 2–4 回合上,最快一局 2 回合打完、最慢拖到 4 回合。同一份胜率,不同的时间形状——玩家感受到的「这一局顺、那一局卡」正是从这里来的。把方差压到零,胜率不变,但每一局都长得一模一样,节奏也就死了。

这里必须说清楚:胜率、起手方差、结束回合方差都是代理指标(proxy),只用于相对比较,不能推出「方差越大越好玩」这类绝对结论。本节能证明的只有一件事——方差是可独立调节的参数,它改变的是过程而不是结果;至于某副牌组「更好玩」,需要真实玩家数据(X 层),命令行不可复现。

方差是旋钮:从全平到全散

把方差当成一个可以拧的旋钮,四副牌组按「分布宽度」排开。前一行是解析结论,后三行都来自 792 种起手的精确枚举:

构筑每张伤害的分布起手均值 meanMilli起手方差 varMilli
全同(解析)12 张全是 6300000
两档均匀 B4×6、8×63000012727
高低起伏 C0×3、12×3、6×63000057272
基线 A6×5、14×3、3×2、0×23250073977

「全同」一行是解析结论而非实跑: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 构筑的相变 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡