9.2 自走棋的经济与平衡

把自走棋的三个花钱方向——存钱吃利息、刷新换棋子、升级扩容量——都写成整数模型,在 32 个种子上实测:利息是 min(金币整除台阶, 上限),刷新不亏利息的阈值是 12 金;实跑给出一条可算判据(升级换的是新位、刷新只换最弱)和两个平衡破坏点。

9.2 自走棋的经济与平衡

上一节把自走棋拆成六个机制字段,其中「利息」只是一行 min(gold // 10, cap),看起来最不起眼;但玩家真正的决策全压在这行上。《数值平衡与商业化运营》 那套关心的是赛季通胀与付费曲线,属于长线运营的数值;本节关心的是一局之内的三次选择:这回合的金币,是存起来吃利息、花掉刷新商店、还是花掉升级扩容量。这不是运营问题,是自走棋品类独有的经济结构问题。

本节把三个方向都写成整数模型,在 32 个种子上真跑,目标有两个:给出一条可算判据(什么时候该存、什么时候该花),以及找出平衡破坏点(参数扫到哪一格,某个方向彻底失效)。所有胜率、血量余裕、战力都是代理指标(proxy),只做相对比较,禁绝对化——625‰ 不比 687‰「更平衡」,只说明在这套种子池与规格下前者赢的局更少。

三个花钱方向,其实是三个边际战力

先统一口径。金币只有三种去处,每一种换回来的东西不同:

方向花在哪换回什么边际战力来自本节实测胜率‰
存钱(save)不花,攒够吃利息下回合更多金币利息入账,但本回合战力不变625
刷新(refresh)refreshCost = 2 金 / 次商店换一批新棋子商店里最强可买的那张687
升级(level)levelCost 金 / 级上阵容量 +1待命里最强的那个1000

「存钱」这条线看着最反直觉:它不产生战力,怎么会不亏?因为利息是按回合复利的——min(gold // interestStep, interestCap) 每回合都入账,只要不越过台阶,存钱几乎零成本。真正要算的是刷新阈值:刷新一次花 2 金,只有当金币从「台阶之上」掉到「台阶之下」时,才会损失 1 金 / 回合的利息。所以不亏利息的刷新阈值 = refreshCost + interestStep = 2 + 10 = 12 金。低于 12 金时刷新是纯亏,高于 12 金时刷新只是「用未来的利息换现在的战力」。

可算判据:升级换的是新位,刷新只换最弱

判据不能靠感觉,得从机制里推。看两边各自替换的是哪个位置:

  • 升级扩容量 +1,新位上的是待命里最强的那个棋子。待命是已经买好、随时能顶上去的库存,它的强度随时间累积。
  • 刷新换的是商店,而商店买的棋子要先进待命、再等回合末上阵;它顶掉的通常是场上最弱的那个。

所以两条线的边际战力可以直接比:待命最强 vs 场上最弱。实测三轮(第 4 / 8 / 12 回合)都是待命最强 ≥ 场上最弱,且差距随回合拉大(22/22/14 → 19/26/17 → 19/34/20)。这就是「升级是支配变量」的来源:升级的边际战力恒高于刷新的边际战力,只要 levelCost 不太离谱,钱就该先砸升级。

P 层:把三个方向在 32 个种子上跑一遍

判据写进代码才算数。core/test/gd_proto_09_2_economy.test.ts 在 economy.seedPool 的 32 个种子上跑五条策略,实测胜率、血量、战力、结束金币,并扫 levelCost 与 interestCap 找破坏点:

// file: core/test/gd_proto_09_2_economy.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_09_2_economy.test.ts'
//
// 卷三 9.2 自走棋的经济与平衡(P 层):把「存钱吃利息 / 刷新换棋子 / 升级扩容量」三个花钱
// 方向都写成整数模型,在 32 个种子上实测,给出可算判据与平衡破坏点。胜率、血量余裕、战力
// 都是**代理指标(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 { abRunPolicy, abPiecePower } from "../genre/autobattler.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const load = (name: string) => JSON.parse(readFileSync(join(ROOT, "config", name), "utf8"));
const ECON = load("gd_proto_09_econ.json");
const POOL: number[] = ECON.economy.seedPool;
const N = POOL.length;

/** 在种子池上跑一条策略,返回千分比胜率 / 平均剩余血量 / 平均末回合战力 / 平均结束金币。 */
function pool(policy: [string, string, string], mut?: (s: any) => void) {
  let win = 0, hp = 0, bp = 0, gold = 0;
  for (const sd of POOL) {
    const sp = JSON.parse(JSON.stringify(ECON));
    sp.seed = sd;
    if (mut) mut(sp);
    const s = abRunPolicy(sp, policy[1], policy[2]);
    if (s.won) win += 1;
    hp += s.hp; bp += s.boardPower; gold += s.gold;
  }
  return {
    win: Math.floor((1000 * win) / N), hp: Math.floor(hp / N),
    bp: Math.floor(bp / N), gold: Math.floor(gold / N),
  };
}

test("利息是整数模型:interest = min(gold // interestStep, interestCap),刷新阈值 = 12", () => {
  assert.equal(ECON.rules.interestStep, 10);
  assert.equal(ECON.rules.interestCap, 5);
  assert.equal(ECON.rules.refreshCost, 2);
  // 刷新一次会花掉 2 金;只有金从「台阶之上」掉到「台阶之下」才损失 1 金/回合的利息,
  // 所以「不亏利息的刷新阈值」= refreshCost + interestStep = 12。
  assert.equal(ECON.rules.refreshCost + ECON.rules.interestStep, ECON.economy.refreshThreshold);
  assert.equal(ECON.economy.refreshThreshold, 12);
});

test("五条策略在 32 个种子上的战绩(胜率‰ / 血量 / 战力 / 结束金币)", () => {
  const got: Record<string, any> = {};
  for (const p of ECON.economy.policies) got[p[0]] = pool(p);
  console.log("  policy    win‰   hp   bp  gold");
  for (const p of ECON.economy.policies) {
    const r = got[p[0]];
    console.log(`  ${p[0].padEnd(9)} ${String(r.win).padStart(4)} ${String(r.hp).padStart(4)} ` +
      `${String(r.bp).padStart(4)} ${String(r.gold).padStart(5)}`);
  }
  assert.deepEqual(got.save, { win: 625, hp: 5, bp: 88, gold: 56 });
  assert.deepEqual(got.refresh, { win: 687, hp: 6, bp: 91, gold: 15 });
  assert.deepEqual(got.step, { win: 593, hp: 5, bp: 89, gold: 24 });
  assert.deepEqual(got.level, { win: 1000, hp: 35, bp: 163, gold: 43 });
  assert.deepEqual(got.mix, { win: 656, hp: 5, bp: 115, gold: 18 });
});

test("刷新不是支配变量:三种刷新时机之间胜率差不超过 94‰", () => {
  const a = pool(["save", "never", "never"]), b = pool(["step", "step", "never"]);
  const c = pool(["refresh", "always", "never"]);
  assert.equal(Math.max(a.win, b.win, c.win) - Math.min(a.win, b.win, c.win), 94);
});

test("升级是支配变量:只升级不刷新就把胜率从 687‰ 抬到 1000‰", () => {
  assert.equal(pool(["level", "never", "always"]).win, 1000);
  assert.equal(pool(["refresh", "always", "never"]).win, 687);
});

test("可算判据的由来:升级换的是新位(待命最强),刷新只换最弱(场上最弱)", () => {
  const traitOf = (r: number) => ECON.rules.enemyTraits[(r - 1) % ECON.rules.enemyTraits.length];
  const rows: [number, number, number, number][] = [];
  for (const R of [4, 8, 12]) {
    let shopTop = 0, benchTop = 0, boardWorst = 0;
    for (const sd of POOL) {
      const sp = JSON.parse(JSON.stringify(ECON));
      sp.seed = sd; sp.params.rounds = R;
      const s = abRunPolicy(sp, "save", "never");
      const t = traitOf(R);
      let st = 0, bt = 0;
      for (const id of s.shop) {
        if (id === "") continue;
        const u = sp.rules.units.find((x: any) => x.id === id);
        if (u.cost <= s.gold) st = Math.max(st, abPiecePower(sp, { id, star: 1 }, t));
      }
      for (const b of s.bench) {
        const [id, s2] = b.split(":");
        bt = Math.max(bt, abPiecePower(sp, { id, star: Number(s2) }, t));
      }
      shopTop += st; benchTop += bt;
      let worst = 1 << 30;
      for (const b of s.board) {
        const [id, st] = b.split(":");
        worst = Math.min(worst, abPiecePower(sp, { id, star: Number(st) }, t));
      }
      boardWorst += worst === 1 << 30 ? 0 : worst;
    }
    rows.push([R, Math.floor(shopTop / N), Math.floor(benchTop / N), Math.floor(boardWorst / N)]);
  }
  console.log("  round  商店最强可买  待命最强  场上最弱");
  for (const [r, st, bt, bw] of rows) {
    console.log(`  ${String(r).padStart(5)} ${String(st).padStart(12)} ${String(bt).padStart(8)} ${String(bw).padStart(8)}`);
  }
  assert.deepEqual(rows, [[4, 22, 22, 14], [8, 19, 26, 17], [12, 19, 34, 20]]);
  // 判据:待命最强 > 场上最弱 —— 升级的边际战力恒高于刷新的边际战力。
  assert.equal(rows.every(([, , bt, bw]) => bt >= bw), true);
});

test("平衡破坏点:levelCost 扫到 24 金时升级与刷新打平,64 金时升级彻底失效", () => {
  const sweep: [number, number, number][] = [];
  for (const c of ECON.economy.levelCostSweep) {
    const lv = pool(["level", "never", "always"], (s) => { s.rules.levelCost = c; });
    const rf = pool(["refresh", "always", "never"], (s) => { s.rules.levelCost = c; });
    sweep.push([c, lv.win, rf.win]);
  }
  console.log("  levelCost  升级‰  刷新‰");
  for (const [c, lv, rf] of sweep) {
    console.log(`  ${String(c).padStart(9)} ${String(lv).padStart(6)} ${String(rf).padStart(6)}`);
  }
  const at = (c: number) => sweep.find((x) => x[0] === c) as [number, number, number];
  assert.deepEqual(at(4), [4, 1000, 687]);
  assert.deepEqual(at(16), [16, 937, 687]);
  assert.deepEqual(at(20), [20, 812, 687]);
  assert.deepEqual(at(24), [24, 687, 687]); // 交叉点:升级不再支配刷新
  assert.deepEqual(at(28), [28, 656, 687]);
  assert.deepEqual(at(64), [64, 625, 687]); // 升级完全失效,退回 save 的 625
  assert.equal(at(64)[1], pool(["save", "never", "never"]).win);
});

test("利息敏感度为零:interestCap 0 → 8 只改结束金币,三策略胜率一字不变", () => {
  const wins: number[][] = [];
  for (const c of ECON.economy.interestCapSweep) {
    wins.push([
      pool(["save", "never", "never"], (s) => { s.rules.interestCap = c; }).win,
      pool(["level", "never", "always"], (s) => { s.rules.interestCap = c; }).win,
      pool(["refresh", "always", "never"], (s) => { s.rules.interestCap = c; }).win,
    ]);
  }
  console.log("  interestCap   save‰  level‰  refresh‰");
  for (let i = 0; i < ECON.economy.interestCapSweep.length; i += 1) {
    console.log(`  ${String(ECON.economy.interestCapSweep[i]).padStart(11)} ` +
      `${String(wins[i][0]).padStart(6)} ${String(wins[i][1]).padStart(7)} ${String(wins[i][2]).padStart(8)}`);
  }
  assert.equal(new Set(wins.map((w) => w.join("/"))).size, 1);
  const gold0 = pool(["save", "never", "never"], (s) => { s.rules.interestCap = 0; }).gold;
  const gold8 = pool(["save", "never", "never"], (s) => { s.rules.interestCap = 8; }).gold;
  assert.deepEqual([gold0, gold8], [38, 56]); // 存钱的收益只落在金币上,没落到胜负上
});
# snippet: 跑本节 P 层
node --test 'core/test/gd_proto_09_2_economy.test.ts'
  policy    win‰   hp   bp  gold
  save       625    5   88    56
  refresh    687    6   91    15
  step       593    5   89    24
  level     1000   35  163    43
  mix        656    5  115    18
  round  商店最强可买  待命最强  场上最弱
      4           22       22       14
      8           19       26       17
     12           19       34       20
  levelCost  升级‰  刷新‰
          4   1000    687
          8   1000    687
         12   1000    687
         16    937    687
         20    812    687
         24    687    687
         28    656    687
         32    625    687
         40    625    687
         48    625    687
         64    625    687
         80    625    687
  interestCap   save‰  level‰  refresh‰
            0    625    1000      687
            1    625    1000      687
            2    625    1000      687
            3    625    1000      687
            4    625    1000      687
            5    625    1000      687
            6    625    1000      687
            8    625    1000      687
✔ 利息是整数模型:interest = min(gold // interestStep, interestCap),刷新阈值 = 12
✔ 五条策略在 32 个种子上的战绩(胜率‰ / 血量 / 战力 / 结束金币)
✔ 刷新不是支配变量:三种刷新时机之间胜率差不超过 94‰
✔ 升级是支配变量:只升级不刷新就把胜率从 687‰ 抬到 1000‰
✔ 可算判据的由来:升级换的是新位(待命最强),刷新只换最弱(场上最弱)
✔ 平衡破坏点:levelCost 扫到 24 金时升级与刷新打平,64 金时升级彻底失效
✔ 利息敏感度为零:interestCap 0 → 8 只改结束金币,三策略胜率一字不变
ℹ tests 7
ℹ pass 7
ℹ fail 0

七条全绿。三张表各说一件事:策略表说升级把胜率抬到 1000‰、血量从 5 涨到 35;边际表说升级换的位始终不弱于刷新换的位;扫表说 levelCost 到 24 金时升级与刷新打平。

平衡破坏点:24 金打平,64 金失效

levelCost 从 4 金扫到 80 金,有两个明确的拐点:

  • 24 金是交叉点。 升级胜率从 1000‰ 一路掉到 687‰,正好等于刷新策略的 687‰。此点之上,升级不再支配刷新。
  • 64 金是失效点。 升级胜率掉到 625‰,等于「只存钱不花」的 save。因为一局只有 12 回合、baseIncome 5 金 + 利息,根本攒不出 64 金来升一级——升级这条线完全没被走到,退化成纯存钱。

最反直觉的是利息敏感度:interestCap 从 0 扫到 8,三条策略的胜率一字不变(save 625 / level 1000 / refresh 687 全程不动),只有结束金币从 38 涨到 56。这说明在这套规格里,利息只影响钱包,不影响胜负——它是个「资源可见性」机制,不是「胜负」机制。想靠调利息上限来改平衡的人,会白调。

本节实跑清单

命令产物实测
node --test 'core/test/gd_proto_09_2_economy.test.ts'断言结果7 测试全绿,0 失败
五策略 32 种子策略表save 625 / refresh 687 / step 593 / level 1000 / mix 656
levelCost 扫描 4→80扫表交叉点 24 金,失效点 64 金
interestCap 扫描 0→8扫表三策略胜率恒定,仅金币 38→56

经济模型到此为止:钱怎么花有了可算判据,破坏点也标了出来。但「花对钱」只保证赢,「能不能拼出不同花样」是另一回事——下一节把阵容当成组合空间来数,量一量这套规格到底容得下多少种打法。

阅读导航:上一节:9.1 自走棋的机制解剖 · 下一节:9.3 自走棋的涌现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.1 自走棋的机制解剖