11.2 增长与衰减的闭式与递推

增长与衰减曲线在整数域上必须闭式与递推逐项一致,否则同一份规格在不同宿主会跑出两条曲线。本节用有理数 115/100 与 90/100 各跑 11 项,证明保分子的递推与闭式逐项恒等,而逐步地板除的朴素递推会累积漂移:十级之后差 8,幂运算必须走 BigInt 才不丢位。

11.2 增长与衰减的闭式与递推

《成长曲线》那篇 讲的是曲线形状——什么时候陡、什么时候平、玩家的能力怎么跟上;本节讲的是更靠下的一件事:同一条曲线,用闭式算和用递推算,在整数域上必须逐项对上。如果对不上,那这份规格就是「看你用哪个公式」的两份规格——同一个种子、同一个初始值,换一条实现路径就长出两条曲线。11.1 已经把单点计算统一到整数,本节把「随步数长起来」的量也钉死。

两条路:闭式与递推

几何增长与衰减有两条等价的实现路径:

  • 闭式:直接算第 k 项,v_k = N0 * num^k // den^k。
  • 递推:从 v_0 = N0 出发,v_{k+1} = v_k * num // den。

在实数域上两者恒等(都等于 N0 * (num/den)^k)。在整数域上就不一定了——因为地板除会丢余数,而递推每步都丢一次。于是「闭式 ≡ 递推」不是天上掉下来的,是必须主动维护的一条性质。维护的办法很简单:递推不要中途截断,把分子一路留着,最后一次性地板除。

三条路线与它们的分歧

路线递推式是否截断与闭式的关系
闭式N0 * num^k // den^k只除一次基准
精确递推保分子:N_k = N0 * num^k,值 = N_k // den^k只除一次逐项恒等
朴素递推u_{k+1} = u_k * num // den每步都除逐项偏低,漂移累积

三者的差别只在「什么时候除」。闭式与精确递推都在最后除一次,所以它们必然恒等——这不是巧合,是同一表达式的两种写法。朴素递推每步都除,余数在每一步被扔掉,误差只增不减。下面用两组真实参数把分歧量出来:增长用 115/100(每级 +15%,初始 playerHp=60),衰减用 90/100(每级 -10%,初始 enemyHp=50)。

#!/usr/bin/env python3
# file: sim/gd_mech_11_growth.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_11_growth.py
"""11.2 增长与衰减的闭式与递推(S 层)。

增长与衰减在整数域上必须「闭式 ≡ 递推」逐项一致,否则同一份规格在不同宿主上
会算出不同的曲线。比例写成有理数 num/den,三条路线:

  闭式      v_k = N0 * num**k // den**k          (只除一次)
  精确递推  N_k = N0 * num**k,v_k = N_k // den**k(保分子,与闭式逐项恒等)
  朴素递推  u_{k+1} = u_k * num // den            (每步地板除,截断漂移累积)

前两者是本节要证的等价;第三者是反例——用来量出「中途截断」的代价。
两组参数:增长 115/100(每级 +15%),衰减 90/100(每级 -10%)。全程整数,无浮点。
"""
from __future__ import annotations

import os

import ashlib

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
STEPS = 10


def closed(n0: int, num: int, den: int, k: int) -> int:
    return n0 * num ** k // den ** k


def exact_recurrence(n0: int, num: int, den: int, k: int) -> int:
    acc = n0
    for _ in range(k):
        acc *= num
    return acc // den ** k


def naive_recurrence(n0: int, num: int, den: int, k: int) -> int:
    acc = n0
    for _ in range(k):
        acc = acc * num // den
    return acc


def series(name: str, n0: int, num: int, den: int) -> list:
    rows = []
    for k in range(STEPS + 1):
        c = closed(n0, num, den, k)
        e = exact_recurrence(n0, num, den, k)
        u = naive_recurrence(n0, num, den, k)
        rows.append([name, k, c, e, u, u - c])
    return rows


def main() -> int:
    spec = ashlib.load_spec()
    p = spec["params"]

    rows = series("growth", p["playerHp"], 115, 100) + series("decay", p["enemyHp"], 90, 100)
    header = ["series", "k", "closed", "exact", "naive", "drift"]
    out = os.path.join(ROOT, "out", "mech11_growth.csv")
    n = ashlib.write_csv(out, header, rows)

    print(" ".join(header))
    for r in rows:
        print(" ".join(str(x) for x in r))

    g = series("growth", p["playerHp"], 115, 100)
    d = series("decay", p["enemyHp"], 90, 100)
    closed_eq_exact = all(r[2] == r[3] for r in rows)
    drift_max = max(abs(r[5]) for r in rows)
    print(f"closed_eq_exact={closed_eq_exact}")
    print(f"growth_k10_closed={g[10][2]} growth_k10_naive={g[10][4]}")
    print(f"decay_k10_closed={d[10][2]} decay_k10_naive={d[10][4]}")
    print(f"drift_max={drift_max}")
    print(f"wrote out/mech11_growth.csv rows={n}")
    return 0 if closed_eq_exact else 1


if __name__ == "__main__":
    raise SystemExit(main())

实跑 /opt/homebrew/bin/python3 sim/gd_mech_11_growth.py:

series k closed exact naive drift
growth 0 60 60 60 0
growth 1 69 69 69 0
growth 2 79 79 79 0
growth 3 91 91 90 -1
growth 4 104 104 103 -1
growth 5 120 120 118 -2
growth 6 138 138 135 -3
growth 7 159 159 155 -4
growth 8 183 183 178 -5
growth 9 211 211 204 -7
growth 10 242 242 234 -8
decay 0 50 50 50 0
decay 1 45 45 45 0
decay 2 40 40 40 0
decay 3 36 36 36 0
decay 4 32 32 32 0
decay 5 29 29 28 -1
decay 6 26 26 25 -1
decay 7 23 23 22 -1
decay 8 21 21 19 -2
decay 9 19 19 17 -2
decay 10 17 17 15 -2
closed_eq_exact=True
growth_k10_closed=242 growth_k10_naive=234
decay_k10_closed=17 decay_k10_naive=15
drift_max=8
wrote out/mech11_growth.csv rows=22

closed_eq_exact=True 是本节要证的等价:闭式与精确递推 22 项全部相等。而 naive 列从第 3 级起就落后:增长十级后 242 对 234,差 8;衰减十级后 17 对 15,差 2。drift 列全是负数,因为截断只会让值偏低——误差方向是确定的,这本身就是一条可以写进测试的判据。

为什么必须走 BigInt

num^k 长得很快:115^10 = 404555773570791015625,早已越过 2^53 ≈ 9.007e15。若用 JS 的 Number 算 115 ** 10,中间就丢位,闭式与递推各自都不可信,更谈不上互相对齐。所以 core/src/ashdeck/growth.ts 全程用 BigInt:

// snippet: core/src/ashdeck/growth.ts 的关键函数(完整版见工作区)
/** 闭式:N0 * num^k / den^k,只做一次地板除。 */
export function closedForm(n0: number, num: number, den: number, k: number): bigint {
  return (BigInt(n0) * bigPow(num, k)) / bigPow(den, k);
}

/** 精确递推:保分子,逐步只乘不除,最后一次性除以 den^k。 */
export function exactRecurrence(n0: number, num: number, den: number, k: number): bigint {
  let acc = BigInt(n0);
  const n = BigInt(num);
  for (let i = 0; i < k; i += 1) acc *= n;
  return acc / bigPow(den, k);
}

BigInt 的 / 对非负数就是地板除,与 Python 的 // 同义——这正是跨语言对齐的支点。P 层测试把「逐项恒等」与「漂移量」都断言下来。

// file: core/test/gd_mech_11_growth.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_11_growth.test.ts'
//
// 11.2 增长与衰减的闭式与递推:断言 closedForm 与 exactRecurrence 对 k = 0..10
// **逐项恒等**,且与 Python 侧 sim/gd_mech_11_growth.py 的实跑输出逐位相同。
// 反例:naiveRecurrence 每步地板除,漂移随 k 单调扩大——这就是「必须保分子」的理由。

import { test } from "node:test";
import assert from "node:assert/strict";

import {
  bigPow, closedForm, exactRecurrence, naiveRecurrence,
} from "../src/ashdeck/growth.ts";

const STEPS = 10;
const GROWTH: [number, number, number] = [60, 115, 100]; // playerHp, +15%/级
const DECAY: [number, number, number] = [50, 90, 100]; // enemyHp, -10%/级

function closedSeries(p: [number, number, number]): bigint[] {
  const out: bigint[] = [];
  for (let k = 0; k <= STEPS; k += 1) out.push(closedForm(p[0], p[1], p[2], k));
  return out;
}

test("闭式 ≡ 精确递推:k = 0..10 逐项恒等(增长与衰减)", () => {
  for (const p of [GROWTH, DECAY]) {
    for (let k = 0; k <= STEPS; k += 1) {
      assert.equal(exactRecurrence(p[0], p[1], p[2], k), closedForm(p[0], p[1], p[2], k));
    }
  }
});

test("闭式序列与 Python 侧逐位相同(增长 k=10 = 242,衰减 k=10 = 17)", () => {
  const g = closedSeries(GROWTH);
  const d = closedSeries(DECAY);
  assert.deepEqual(g, [60n, 69n, 79n, 91n, 104n, 120n, 138n, 159n, 183n, 211n, 242n]);
  assert.deepEqual(d, [50n, 45n, 40n, 36n, 32n, 29n, 26n, 23n, 21n, 19n, 17n]);
});

test("反例:朴素递推逐步截断,漂移随 k 扩大", () => {
  const g = closedSeries(GROWTH);
  const gNaive = Array.from({ length: STEPS + 1 }, (_, k) => naiveRecurrence(60, 115, 100, k));
  assert.equal(gNaive[3], 90n); // 闭式 91,已差 -1
  assert.equal(gNaive[10], 234n); // 闭式 242,差 -8
  assert.equal(g[10] - gNaive[10], 8n);
  const drift = g.map((v, i) => v - gNaive[i]);
  assert.equal(drift.every((x) => x >= 0n), true); // 截断只可能偏低,闭式 >= 朴素
  assert.equal(drift[0], 0n);
  assert.equal(drift[10], 8n);
});

test("整数幂必须走 BigInt:115^10 已越过 2^53", () => {
  const p10 = bigPow(115, 10);
  assert.equal(p10 > BigInt(Number.MAX_SAFE_INTEGER), true);
  assert.equal(p10, 404555773570791015625n);
});

实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_11_growth.test.ts':

✔ 闭式 ≡ 精确递推:k = 0..10 逐项恒等(增长与衰减) (0.544959ms)
✔ 闭式序列与 Python 侧逐位相同(增长 k=10 = 242,衰减 k=10 = 17) (0.454416ms)
✔ 反例:朴素递推逐步截断,漂移随 k 扩大 (0.12825ms)
✔ 整数幂必须走 BigInt:115^10 已越过 2^53 (0.103334ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0

判读:等价是靠「只除一次」换来的

  • 第一条断言覆盖两条曲线、22 个点,exactRecurrence === closedForm 无一处例外。这说明等价不是运气,是构造。
  • 第二条把两侧的具体数字钉死:[60,69,79,91,104,120,138,159,183,211,242] 与 [50,45,40,36,32,29,26,23,21,19,17],与 Python 侧打印的 closed 列逐位相同。
  • 第三条是反例:它要求朴素递推必须漂移,并且漂移量正好是 8。一条「恒等于闭式」的朴素递推会让这条断言失败——这正是我们要的,反例自己也得能被证伪。
  • 第四条守住前提:115^10 > Number.MAX_SAFE_INTEGER,所以 BigInt 不是可选项。
检查项合格标准
闭式 ≡ 精确递推每个 k 都相等,无例外
递推保分子中途只乘不除,末尾除一次
反例朴素递推必须漂移,且漂移量可断言
幂运算num^k 越界前走 BigInt / Python 大整数
跨语言两侧序列逐位相同

曲线对上了,单点也对上了。但「可复现」还有一层没讲:哪些量可以要求逐字相等,哪些量只能要求落在容差内。下一节把这条边界划清楚。

阅读导航:上一节:11.1 整数化与跨语言一致 · 下一节:11.3 数值的可复现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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