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 | 差值 | 读法 |
|---|---|---|---|---|
| 1 | 10 | 10 | 0 | 第 1 项恒等于 baseCost |
| 3 | 12 | 11 | 1 | 递推从这里开始领先 |
| 7 | 16 | 13 | 3 | 每步上取整的误差开始显形 |
| 30 | 52 | 32 | 20 | 差 62% |
| 100 | 988 | 486 | 502 | 递推是闭式的 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 个里程碑全通,升级成本涨得比产出慢,买买买没有阻力 |
| 1042 | 428‰ | 临界点:一半以上的种子开始推不动 |
| ≥ 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 放置的动因 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。