10.2 放置的曲线与相变

把放置的增长曲线钉成整数:升级成本给递推与 BigInt 闭式两种口径并实测整数溢出边界(Number 幂在 n=7 越过 2^53、n=103 起失真、n=104 起 NaN);再扫升级成本增长率,粗扫加二分找到里程碑达成率跌破 500‰ 的临界点 1042,端相分别对应进度失控与进度卡死。

10.2 放置的曲线与相变

《小游戏开发实战》的成长曲线 讲的是成长曲线怎么设计:曲线陡一点还是缓一点、什么时候插一个断点,那是产品口径的曲线。本节是放置的相变与动因里偏「相变」的那一半:把曲线的口径钉死成整数(递推还是闭式、什么时候溢出),再把增长率推过临界点,看进度在哪一格从「失控」翻成「卡死」。10.1 拆的五个字段里,本节只用第三行(升级成本)。

曲线与相变是两件事,但用同一根参数。曲线问「第 n 个生产者要多少钱」,相变问「增长率调到多大,这一局就再也推进不下去了」。前者是函数问题,后者是参数扫描问题。

曲线的两种口径:递推 vs 闭式

第 n 个生产者的价格有两条写法。递推:c(1)=baseCost,c(n)=ceil(c(n-1)×g/1000)——全程 Number,每一步都上取整,永远不会溢出,但舍入误差会一步步累积。闭式:c(n)=ceil(baseCost×g^(n-1)/1000^(n-1))——用 BigInt 幂一次算出来,精确,但中间量 g^(n-1) 会爆炸。

两种口径在早期完全一致,越往后差得越远。以基线 g=1040(即每买一个贵 4%)实测:

n递推 idleCostRec闭式 idleCostClosed差值读法
110100第 1 项恒等于 baseCost
312111递推从这里开始领先
716133每步上取整的误差开始显形
30523220差 62%
100988486502递推是闭式的 2.03 倍

取舍:要「同一份规格在 Python 与 TS 里逐位一致」,就得用递推——因为递推只依赖上一步的结果,两种语言在每一步都做同一个上取整。要「同一份规格在数学上是那根曲线」,就得用闭式。本节两个都实现、都断言,但运行时用的是递推:idleRun 里调的是 idleCostRec。闭式只作为对照与真值(exactCeil)。

整数溢出边界

闭式如果用 Number 幂写(idleCostPowF),会踩两个边界。第一个是精度:g^(n-1) 一旦越过 2^53-1,整数精度就没了。基线 g=1040 下 1040^5 = 1216652902400000 ≤ 9007199254740991,而 1040^6 = 1265319018496000000 已经越界——所以首个越界点是 n-1=6,即 n=7。第二个是量级:1000^(n-1) 在 n=104 起变成 Infinity,Infinity/Infinity 直接给 NaN;在它之前,n=103 起 Number 幂的闭式已经与精确值分道扬镳。

所以「闭式」只有在 BigInt 下才成立:idleCostClosed 在 n=110 仍然精确等于 exactCeil(110),而 idleCostPowF 已经返回 NaN。整数溢出边界不是一个抽象警告,它有确切的格号。

相变:粗扫 + 二分找卡死点

序参量取里程碑达成率(千分比):32 个固定种子各跑一局,把「命中的里程碑数 / 里程碑总数 7」在种子池上取平均,就是这一格的值。临界判据 crossPermille=500——达成率跌破 500‰ 的最小 costGrowthPermille。这个点两侧是两个端相:低于它,达成率还在 500‰ 以上;高于它,进度开始被卡住。达成率是代理指标,只做相对比较,禁绝对化:达成率高只说明这批种子下推进得动,不等于「这个数值更好玩」。

"scan": {
  "swept": "rules.producer.costGrowthPermille",
  "from": 1000,
  "to": 1200,
  "coarseStep": 20,
  "crossPermille": 500,
  "maxRounds": 8,
  "bandLoPermille": 100,
  "bandHiPermille": 900,
  "seedPool": [10101, 10102, 10103, 10104, 10105, 10106, 10107, 10108,
               10109, 10110, 10111, 10112, 10113, 10114, 10115, 10116,
               10117, 10118, 10119, 10120, 10121, 10122, 10123, 10124,
               10125, 10126, 10127, 10128, 10129, 10130, 10131, 10132],
  "budget": { "maxCells": 300, "maxRuns": 12000 }
}

全网格逐点扫(step=1,1000 到 1200)要 201 格、6432 次运行。做法是粗扫 + 二分:先以 step=20 粗扫,找到第一段「前一点 ≥ 500‰、后一点 < 500‰」的相邻区间;再在区间内二分到宽度为 1。曲线口径与相变扫描写在同一个 P 层测试里,一次跑完:

// file: core/test/gd_proto_10_2_curve_phase.test.ts
// tier: P
// verify: node --test 'core/test/gd_proto_10_2_curve_phase.test.ts'
//
// 卷三 10.2 放置的曲线与相变(P 层):两件事。
//   一、曲线口径:升级成本给「递推」(每步上取整,无中间溢出但有舍入漂移)与「闭式」
//       (BigInt 幂,精确)两种写法,并与「闭式但用 Number 幂」逐项对照,钉出整数溢出边界。
//   二、相变:扫升级成本增长率 costGrowthPermille,序参量取种子池上的**里程碑达成率**
//       (千分比)。粗扫定位跨越 500‰ 的相邻区间,二分钉到 ±1,再与步长 1 的全网格比对。
// 达成率是代理指标,只做相对比较,禁绝对化;脚本报告格数、run 数与总耗时。

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 {
  idleRun, idleCostRec, idleCostClosed, idleCostPowF,
} from "../genre/idle.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const BASE = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_10_idle.json"), "utf8"));
const SCAN = JSON.parse(readFileSync(join(ROOT, "config", "gd_proto_10_scan.json"), "utf8"));
const S = SCAN.scan;
const MAX_SAFE = Number.MAX_SAFE_INTEGER;
const TOTAL = BASE.rules.milestones.length;

/** 精确上取整闭式:BigInt 幂,任意 n 都精确,作为「真值」。 */
function exactCeil(n: number): number {
  const g = BigInt(BASE.rules.producer.costGrowthPermille);
  const den = 1000n ** BigInt(n - 1);
  const num = BigInt(BASE.rules.producer.baseCost) * g ** BigInt(n - 1);
  return Number((num + den - 1n) / den);
}

let runs = 0;
const cache = new Map<number, number>();
/** 序参量:种子池上的里程碑达成率(千分比)。代理指标,只做相对比较。 */
const ratePermille = (g: number): number => {
  const hit = cache.get(g);
  if (hit !== undefined) return hit;
  let sum = 0;
  for (const sd of S.seedPool) {
    const sp = JSON.parse(JSON.stringify(SCAN));
    sp.seed = sd;
    sp.rules.producer.costGrowthPermille = g;
    runs += 1;
    sum += idleRun(sp).milestonesHit;
  }
  const p = Math.floor((1000 * sum) / (S.seedPool.length * TOTAL));
  cache.set(g, p);
  return p;
};

const t0 = process.hrtime.bigint();

// 粗扫:步长 coarseStep
const coarse: number[] = [];
for (let g = S.from; g <= S.to; g += S.coarseStep) coarse.push(g);
let bracket = -1;
for (let i = 0; i + 1 < coarse.length; i += 1) {
  if (ratePermille(coarse[i]) >= S.crossPermille && ratePermille(coarse[i + 1]) < S.crossPermille) {
    bracket = i;
    break;
  }
}
const coarseCells = cache.size;
assert.ok(bracket >= 0, "粗扫应找到一个跨越 500‰ 的相邻区间");

// 二分细化到 ±1
let a = coarse[bracket];
let b = coarse[bracket + 1];
let rounds = 0;
while (b - a > 1 && rounds < S.maxRounds) {
  const mid = (a + b) >> 1;
  rounds += 1;
  if (ratePermille(mid) >= S.crossPermille) a = mid;
  else b = mid;
}
const critical = b;
const scanCells = coarseCells + rounds;
const scanRuns = runs;
const scanMs = Number(process.hrtime.bigint() - t0) / 1e6;

// 全网格(步长 1)作真值
const t1 = process.hrtime.bigint();
const full: number[] = [];
for (let g = S.from; g <= S.to; g += 1) full.push(ratePermille(g));
const fullCells = S.to - S.from + 1;
const fullRuns = fullCells * S.seedPool.length;
const fullMs = Number(process.hrtime.bigint() - t1) / 1e6;
const fullCritical = S.from + full.findIndex((v) => v < S.crossPermille);
const band = full.filter((v) => v > S.bandLoPermille && v < S.bandHiPermille).length;
const trivial = full.filter((v) => v === 1000).length;
const stuck = full.filter((v) => v === 0).length;
const elapsedMs = Number(process.hrtime.bigint() - t0) / 1e6;

test("成本曲线:递推与闭式早期一致、后期分道扬镳,且闭式恒等于精确值", () => {
  const rows: string[] = [];
  for (const n of [1, 2, 3, 7, 15, 30, 60, 100]) {
    const rec = idleCostRec(BASE, n);
    const closed = idleCostClosed(BASE, n);
    assert.equal(closed, exactCeil(n), `n=${n} 闭式应等于精确上取整`);
    rows.push(`n=${n} rec=${rec} closed=${closed}`);
  }
  assert.equal(idleCostRec(BASE, 2), idleCostClosed(BASE, 2));
  assert.equal(idleCostRec(BASE, 3) > idleCostClosed(BASE, 3), true);
  assert.equal(idleCostRec(BASE, 30), 52);
  assert.equal(idleCostClosed(BASE, 30), 32);
  assert.equal(idleCostRec(BASE, 100), 988);
  assert.equal(idleCostClosed(BASE, 100), 486);
  console.log("=== 升级成本:递推 vs 闭式 ===");
  console.log("  " + rows.join("  "));
});

test("整数溢出边界:Number 幂的中间量 g^(n-1) 在 n=7 越过 2^53", () => {
  const g = BigInt(BASE.rules.producer.costGrowthPermille);
  let k = 1;
  while (g ** BigInt(k) <= BigInt(MAX_SAFE)) k += 1;
  assert.equal(k, 6);
  assert.equal(k + 1, 7);
  assert.equal(g ** BigInt(5) <= BigInt(MAX_SAFE), true);
  assert.equal(g ** BigInt(6) > BigInt(MAX_SAFE), true);
  console.log("=== 溢出边界 ===");
  console.log("  g^(n-1) <= 2^53-1 的最大 n-1 = " + (k - 1) + "  首个越界 n = " + (k + 1));
  console.log("  g^5 = " + String(g ** BigInt(5)) + "  g^6 = " + String(g ** BigInt(6)) +
    "  MAX_SAFE = " + MAX_SAFE);
});

test("Number 幂闭式的失真点:n=103 起与精确值分道,n=104 起直接 NaN", () => {
  let firstDev = -1, firstNaN = -1;
  for (let n = 2; n <= 140; n += 1) {
    const v = idleCostPowF(BASE, n);
    if (Number.isNaN(v)) { if (firstNaN < 0) firstNaN = n; }
    else if (firstDev < 0 && v !== exactCeil(n)) firstDev = n;
  }
  assert.equal(firstDev, 103);
  assert.equal(firstNaN, 104);
  assert.equal(idleCostPowF(BASE, 100), exactCeil(100));
  assert.equal(Number.isNaN(idleCostPowF(BASE, 110)), true);
  assert.equal(idleCostClosed(BASE, 110), exactCeil(110));
  console.log("=== Number 幂闭式 ===");
  console.log("  首个失真 n = " + firstDev + "  首个 NaN n = " + firstNaN +
    "  (1000^(n-1) 在 n=104 起变成 Infinity,Infinity/Infinity = NaN)");
});

test("相变:粗扫 + 二分找到临界点,且与步长 1 的全网格一致", () => {
  assert.equal(bracket, 2);
  assert.equal(coarse[2], 1040);
  assert.equal(coarse[3], 1060);
  assert.equal(rounds, 4);
  assert.equal(critical, 1042);
  assert.equal(fullCritical, 1042);
  assert.equal(critical, fullCritical);
});

test("临界点两侧单调:1041 仍在 500‰ 之上,1042 跌到其下", () => {
  assert.equal(ratePermille(1041), 526);
  assert.equal(ratePermille(1042), 428);
  assert.equal(ratePermille(1041) > S.crossPermille, true);
  assert.equal(ratePermille(1042) < S.crossPermille, true);
  assert.equal(ratePermille(1000), 1000);
  assert.equal(ratePermille(1200), 0);
});

test("两个端相:低增长端达成率 1000‰(进度失控),高增长端 0‰(进度卡死)", () => {
  assert.equal(trivial > 0, true);
  assert.equal(stuck > 0, true);
  assert.equal(ratePermille(S.from), 1000);
  assert.equal(ratePermille(S.to), 0);
});

test("相变带宽:达成率落在 (100‰, 900‰) 开区间内的增长率格数", () => {
  assert.equal(full.length, fullCells);
  assert.equal(fullCells, 201);
  assert.equal(band, 118);
  assert.equal(band + trivial + stuck < fullCells, true);
});

test("扫描比全网格省:扫描 run 数只占全网格的 39‰", () => {
  assert.equal(coarseCells, 4);
  assert.equal(scanCells, 8);
  assert.equal(scanRuns, 256);
  assert.equal(fullRuns, 6432);
  assert.equal(Math.floor((1000 * scanRuns) / fullRuns), 39);
});

test("预算判据:二维全网格超过 maxRuns,须退回采样", () => {
  const grid2dCells = fullCells * 9;
  const grid2dRuns = grid2dCells * S.seedPool.length;
  assert.equal(grid2dCells, 1809);
  assert.equal(grid2dRuns, 57888);
  assert.equal(grid2dRuns > S.budget.maxRuns, true);
});

test("扫描是确定性的,且总耗时在分钟级以内", () => {
  assert.equal(scanMs + fullMs <= elapsedMs + 1, true);
  assert.equal(elapsedMs < 60000, true);
  const again = ratePermille(1042);
  assert.equal(again, 428);
  // 二分探到的 4 个中点全在 [1000, 1200] 内,跑完全网格后缓存就是全网格的格数。
  assert.equal(cache.size, fullCells);
});

console.log("=== 10.2 相变扫描(costGrowthPermille " + S.from + " → " + S.to + ")===");
console.log("粗扫(步长 " + S.coarseStep + "): " +
  coarse.map((g) => g + ":" + ratePermille(g)).join("  "));
console.log("跨越 " + S.crossPermille + "‰ 的区间: [" + coarse[bracket] + ", " + coarse[bracket + 1] +
  "]  二分轮数=" + rounds + "  临界=" + critical + "  全网格临界=" + fullCritical);
console.log("端相: 达成率=1000‰ 的格数=" + trivial + "  达成率=0‰ 的格数=" + stuck +
  "  带宽(100‰,900‰)=" + band);
console.log("格数: 扫描=" + scanCells + " 全网格=" + fullCells +
  "  runs: 扫描=" + scanRuns + " 全网格=" + fullRuns);
console.log("elapsed=" + elapsedMs.toFixed(1) + " ms(扫描 " + scanMs.toFixed(1) +
  " ms + 全网格 " + fullMs.toFixed(1) + " ms)");
=== 升级成本:递推 vs 闭式 ===
  n=1 rec=10 closed=10  n=2 rec=11 closed=11  n=3 rec=12 closed=11  n=7 rec=16 closed=13  n=15 rec=24 closed=18  n=30 rec=52 closed=32  n=60 rec=197 closed=102  n=100 rec=988 closed=486
=== 溢出边界 ===
  g^(n-1) <= 2^53-1 的最大 n-1 = 5  首个越界 n = 7
  g^5 = 1216652902400000  g^6 = 1265319018496000000  MAX_SAFE = 9007199254740991
=== Number 幂闭式 ===
  首个失真 n = 103  首个 NaN n = 104  (1000^(n-1) 在 n=104 起变成 Infinity,Infinity/Infinity = NaN)
=== 10.2 相变扫描(costGrowthPermille 1000 → 1200)===
粗扫(步长 20): 1000:1000  1020:1000  1040:571  1060:285  1080:285  1100:142  1120:142  1140:142  1160:0  1180:0  1200:0
跨越 500‰ 的区间: [1040, 1060]  二分轮数=4  临界=1042  全网格临界=1042
端相: 达成率=1000‰ 的格数=25  达成率=0‰ 的格数=57  带宽(100‰,900‰)=118
格数: 扫描=8 全网格=201  runs: 扫描=256 全网格=6432
elapsed=2826.6 ms(扫描 169.6 ms + 全网格 2656.9 ms)
✔ 成本曲线:递推与闭式早期一致、后期分道扬镳,且闭式恒等于精确值 (0.731208ms)
✔ 整数溢出边界:Number 幂的中间量 g^(n-1) 在 n=7 越过 2^53 (0.182209ms)
✔ Number 幂闭式的失真点:n=103 起与精确值分道,n=104 起直接 NaN (0.264542ms)
✔ 相变:粗扫 + 二分找到临界点,且与步长 1 的全网格一致 (0.06725ms)
✔ 临界点两侧单调:1041 仍在 500‰ 之上,1042 跌到其下 (0.063333ms)
✔ 两个端相:低增长端达成率 1000‰(进度失控),高增长端 0‰(进度卡死) (0.059459ms)
✔ 相变带宽:达成率落在 (100‰, 900‰) 开区间内的增长率格数 (0.059625ms)
✔ 扫描是确定性的,且总耗时在分钟级以内 (0.122375ms)
ℹ tests 10
ℹ pass 10
ℹ fail 0

粗扫在 1000, 1020, 1040, 1060 处停下(1000→1000‰、1020→1000‰、1040→571‰、1060→285‰),锁定区间 [1040, 1060];二分四轮收敛到 1042——1041 还是 526‰(≥ 500),1042 已经跌到 428‰。临界点两侧与全网格逐点比对一致。

相变的读法

costGrowthPermille里程碑达成率读法
≤ 1020(25 格)1000‰进度失控:7 个里程碑全通,升级成本涨得比产出慢,买买买没有阻力
1042428‰临界点:一半以上的种子开始推不动
≥ 1145(57 格)0‰进度卡死:连第一个里程碑(拥有 45 个)都攒不出来

三个数字值得记:临界点 1042、相变带宽 118 格(201 格里的 59%)、扫描只用 8 格 256 次运行(全网格 201 格 6432 次,省到 39‰)。带宽这么宽说明这个序参量是「缓相变」——放置品类的卡死不是一刀切,而是随着增长率升高逐渐掐死;这与 6.3 塔防那条「临界宽度只有 1」的陡相变正好相反。

本节实跑清单

命令产物实测
node --test 'core/test/gd_proto_10_2_curve_phase.test.ts'断言结果10 测试全绿,0 失败;总耗时 2826.6 ms
扫描config/gd_proto_10_scan.json 的 scan 段8 格 256 run,临界 1042
溢出边界数值实测Number 幂 n=7 越界、n=103 失真、n=104 NaN

曲线钉成整数、相变找到临界点,这一节回答的是「进度会不会卡死」。但「不卡死」不等于「有动因」——下一节把自我决定理论的三要素映射成三个可算的代理指标,只做相对比较,不证明「好玩」。

阅读导航:上一节:10.1 放置的机制解剖 · 下一节:10.3 放置的动因 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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