12.1 存量/流量模型

把「存量 = 历史产出 − 历史消耗」写成方程,推导稳态条件:产出速率 = 消耗速率。用伐木场产出曲线与消耗曲线求数值解,讲清楚稳态是设计出来的——产出增速只要不低于消耗增速,稳态就不存在。附可跑的 S 层脚本与 TypeScript 原型。

第 10 章讲了资源从哪来,第 11 章讲了资源到哪去。这一章把两者合起来,回答那个最要命的问题:产出的水龙头和消耗的排水口,哪个更大?如果产出一直跑赢消耗,玩家仓库里堆的资源就会无限增长,通胀迟早把经济压垮。这一节先把这个问题写成一个方程,再解出「稳态」在哪里。

站内已经有一篇讲经济平衡的通用框架(经济平衡长文 ),它用放置类游戏举例:离线收益、挂机产出、一次性升级。那套骨架是对的,但放置类没有「大地图地块」这一层。本卷换成 SLG 大地图口径——伐木场建在具体地块上、产出按小时结算、消耗来自征兵与建筑升级摊销,而且这里的每一张曲线都是真跑出来的。

存量与流量:经济只用两个数描述

任何一个资源池,都只有两个量:

  • 流量(flow):单位时间的进出。产出速率 P(木材/小时)、消耗速率 C(木材/小时)。
  • 存量(stock):某一时刻池子里的总量。玩家仓库里的木材。

流量决定存量的变化方向,存量本身不决定任何事。这是经济建模里最容易搞混的一点:玩家仓库里有多少木材,是过去所有流量累加的结果,不是设计出来的。设计师真正能控制的,是每一级的 P 和 C。

《王朝纪元》里,这两条流量都随建筑等级 L 增长,但增长速度不同:

  • 产出速率读 buildings.json 的伐木场:P(L) = 60 · (122/100)^(L-1),每级 +22%。
  • 消耗速率读 ch12_stockflow.json:C(L) = 38 · (135/100)^(L-1),每级 +35%。

消耗涨得更快,这不是巧合,而是 SLG 经济的常见设计:军队维护与建筑升级的摊销,增长得比采集产出快。第 11 章讲过消耗槽,这里把「一次性升级成本」按升级周期摊到每天,得到一个连续的消耗流。

存量方程:存量 = 历史产出 − 历史消耗

把上面两个流量放进同一个方程,就是本节的核心:

S(t+1) = S(t) + P(L(t)) − C(L(t))

用一句话说:明天的存量 = 今天的存量 + 今天产出的 − 今天消耗的。展开到整段时间,就是「存量 = 历史产出 − 历史消耗」。若 P/C 为每小时速率,日步长还须乘以 hoursPerDay。S 在这里是理论净余额,可以为负,不代表允许玩家实际透支。这是一个差分方程——它描述的不是某一刻的状态,而是状态怎么一步步演化。

关键在 L(t):等级不是外生的,它由存量反馈驱动。当净流量 P(L) − C(L) > 0 时,玩家有富余资源,就会继续升级,L 上升;L 上升让 C 涨得比 P 快,净流量随之缩小。这个负反馈就是闭环,也是经济能收敛的机制本身。

稳态条件:产出速率 = 消耗速率

当净流量归零,升级停摆,存量不再变化。这时:

P(L*) = C(L*)        ← 稳态条件

稳态不是「存量不变」,而是「流量相等」。 存量在稳态下停在某个平台值,但那个值是历史积累的结果;真正被设计的,是流量相等时对应的等级 L*。三条结论值得记住:

  1. 在本例 P0>C0、等级增长、无其他反馈的前提下,消耗增长更快才可能在有限等级内追平产出;还要检查交点是否在 1 到 maxLevel 内。相同增长率且相同基数时每级都相等,不能笼统说没有稳态。
  2. L 越低,在这个模型中到达经济平台前的成长空间越小*。满级 10 级而 L*=6,意味着玩家在赛季中段就摸到天花板,之后经济自我封顶。
  3. P 和 C 的基数(60 与 38)只决定 L 落在哪,增速差才决定稳态存不存在*。这是下一节敏感性分析的主角。

数值解:用脚本求出稳态等级 L*

下面这段是 S 层仿真脚本,读 config/ 求出稳态等级:它按整数等级扫描,找首个满足 C(L) >= P(L) 的等级(阈值,不等于速率恰好相等),同时给出连续解析解与确定性存量轨迹。

本章完整配置

缺失的输入在此补齐;专用 ch12_buildings.json 不替代其他章节尚待补齐的通用建筑表。整数等级速率使用整数幂/地板除,连续等级模型使用浮点,不承诺跨语言逐位一致。

{
  "note": "本章合成设计参数,非真实玩家经济数据",
  "resource": "wood",
  "building": "lumberyard",
  "seasonDays": 180,
  "hoursPerDay": 12,
  "levelStepMax": 0.1,
  "levelSensitivity": 0.001,
  "entryTolerancePct": 1,
  "consumption": {
    "base": 38,
    "growthNum": 135,
    "growthDen": 100
  }
}
{
  "buildings": [
    {
      "id": "lumberyard",
      "maxLevel": 10,
      "outputBase": 60,
      "outputGrowthNum": 122,
      "outputGrowthDen": 100
    }
  ]
}
# file: sim/ch12_stock_flow.py
# tier: S
# verify: python3 sim/ch12_stock_flow.py --config config/ --out out/
"""存量/流量模型(12.1):把「存量 = 历史产出 − 历史消耗」写成方程,解出稳态等级 L*。

产出侧读 config/ch12_buildings.json 的伐木场(木材/小时,整数比增长),消耗侧读
config/ch12_stockflow.json(维护消耗 + 升级摊销)。两者相交处即稳态:P(L*) = C(L*)。
产出:out/ch12_stock_flow.json + out/ch12_stock_flow.csv
"""

from __future__ import annotations

import argparse
import csv
import json
import math
import os


def load_json(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def load_params(cfg_dir):
    """返回(模型参数, 建筑定义)。产出侧只读 buildings.json,不修改。"""
    sf = load_json(os.path.join(cfg_dir, "ch12_stockflow.json"))
    b = next(x for x in load_json(os.path.join(cfg_dir, "ch12_buildings.json"))["buildings"]
             if x["id"] == sf["building"])
    if not all(type(v) is int and v > 0 for v in [b["outputBase"], b["outputGrowthNum"], b["outputGrowthDen"], b["maxLevel"], sf["consumption"]["base"], sf["consumption"]["growthNum"], sf["consumption"]["growthDen"], sf["seasonDays"], sf["hoursPerDay"]]):
        raise ValueError("rates, denominators, levels and duration must be positive integers")
    if sf["levelStepMax"] <= 0 or sf["levelSensitivity"] <= 0 or sf["entryTolerancePct"] <= 0:
        raise ValueError("feedback/tolerance must be positive")
    return sf, b


def prod_at(b, level):
    """整数产出速率(木材/小时)。level 从 1 起。"""
    e = level - 1
    return (b["outputBase"] * b["outputGrowthNum"] ** e) // b["outputGrowthDen"] ** e


def cons_at(cons, level):
    """整数消耗速率(木材/小时)。level 从 1 起。"""
    e = level - 1
    return (cons["base"] * cons["growthNum"] ** e) // cons["growthDen"] ** e


def prod_float(b, level):
    return b["outputBase"] * (b["outputGrowthNum"] / b["outputGrowthDen"]) ** (level - 1)


def cons_float(cons, level):
    return cons["base"] * (cons["growthNum"] / cons["growthDen"]) ** (level - 1)


def solve_integer(b, cons):
    """数值解:首个满足「消耗速率 >= 产出速率」的整数等级;满级内无交点返回 -1。"""
    for lv in range(1, b["maxLevel"] + 1):
        if cons_at(cons, lv) >= prod_at(b, lv):
            return lv
    return -1


def solve_closed_form(b, cons):
    """解析交点:限定 1..maxLevel;同增长同基数返回代表点 1,否则无唯一交点。"""
    rp = b["outputGrowthNum"] / b["outputGrowthDen"]
    rc = cons["growthNum"] / cons["growthDen"]
    if rp == rc:
        return 1.0 if cons["base"] == b["outputBase"] else None
    level = 1.0 + math.log(cons["base"] / b["outputBase"]) / math.log(rp / rc)
    return level if 1.0 <= level <= b["maxLevel"] else None


def simulate(sf, b, cons):
    """一个代表性聚合体的存量轨迹:净流量 > 0 就继续升级,净流量趋零即收敛。"""
    level, stock, rows = 1.0, 0.0, []
    for day in range(sf["seasonDays"] + 1):
        net = prod_float(b, level) - cons_float(cons, level)
        if day > 0:
            stock += net * sf["hoursPerDay"]
        rows.append({"day": day, "level": round(level, 4),
                     "netPerHour": round(net, 3), "stock": round(stock, 2)})
        if net > 0 and level < b["maxLevel"]:
            root = solve_closed_form(b, cons)
            ceiling = root if root is not None and root >= level else b["maxLevel"]
            level = min(ceiling, level + min(sf["levelStepMax"], net * sf["levelSensitivity"]))
    return rows


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    sf, b = load_params(args.config)
    cons = sf["consumption"]
    star = solve_integer(b, cons)
    star_cf = solve_closed_form(b, cons)
    rows = simulate(sf, b, cons)
    plateau = rows[-1]["stock"]
    tol = abs(plateau) * sf["entryTolerancePct"] / 100.0
    entry = next((r["day"] for r in rows if r["day"] > 0 and abs(r["stock"] - plateau) <= tol),
                 sf["seasonDays"])
    report = {
        "version": 1, "resource": sf["resource"], "building": b["id"],
        "prodBasePerHour": prod_at(b, 1), "consBasePerHour": cons_at(cons, 1),
        "prodGrowthPct": round(100 * (b["outputGrowthNum"] / b["outputGrowthDen"] - 1), 3),
        "consGrowthPct": round(100 * (cons["growthNum"] / cons["growthDen"] - 1), 3),
        "steadyStateLevelInt": star,
        "steadyStateLevelClosedForm": None if star_cf is None else round(star_cf, 3),
        "hasFiniteSteadyState": star_cf is not None,
        "productionAtSteadyState": None if star == -1 else prod_at(b, star),
        "consumptionAtSteadyState": None if star == -1 else cons_at(cons, star),
        "plateauStock": round(plateau, 2), "plateauLevel": rows[-1]["level"],
        "entryDay": entry, "seasonDays": sf["seasonDays"],
    }
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch12_stock_flow.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch12_stock_flow.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["level", "productionPerHour", "consumptionPerHour", "netPerHour"])
        for lv in range(1, b["maxLevel"] + 1):
            w.writerow([lv, prod_at(b, lv), cons_at(cons, lv), prod_at(b, lv) - cons_at(cons, lv)])
    print(f"产出基速 P(1)={prod_at(b, 1)}/h 增长 {report['prodGrowthPct']}%")
    print(f"消耗基速 C(1)={cons_at(cons, 1)}/h 增长 {report['consGrowthPct']}%")
    print(f"稳态等级 L*(整数解)={star}  连续解={report['steadyStateLevelClosedForm']}")
    if star != -1:
        print(f"整数阈值处 P={prod_at(b, star)}/h  平台存量={plateau:.0f}  进入平台第 {entry} 天")
    else:
        print("满级内没有消费占优的整数阈值;不能据此断言连续模型存量无界")
    return 0


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

本机实跑输出(同机相对量级,绝对数值不可移植):

产出基速 P(1)=60/h 增长 22.0%
消耗基速 C(1)=38/h 增长 35.0%
稳态等级 L*(整数解)=6  连续解=5.511
整数阈值处 P=162/h  平台存量=10298  进入平台第 35 天

out/ch12_stock_flow.csv 把每一级的 P、C、净流量列成表,稳态就藏在那行由正转负的净流量里:

等级 L产出 P(L)消耗 C(L)净流量
38969+20
410893+15
5132126+6
6162170−8
7197230−33
10359565−206

第 5 级净流量还是 +6,第 6 级翻成 −8。整数解 L*=6,连续解 5.511 说明真实交点在第 5、6 级之间。这就是「稳态条件的数值解」——不需要解方程,扫一遍等级就能读出来。

稳态是设计出来的

把同一个脚本的参数改一改,结论会完全不同。下面这段是 P 层原型的核心函数,与 S 层读同一份配置、逐位一致:它证明「一份配置两处消费」在这里也成立。

// file: proto/src/ch12_stock_flow.ts
// tier: P
// verify: node --test proto/test/ch12.test.ts
export interface Rate { base:number; growthNum:number; growthDen:number; }
export function rateAt(rate:Rate,level:number):number {
  if (![rate.base,rate.growthNum,rate.growthDen,level].every(v=>Number.isSafeInteger(v)&&v>0)) throw new Error("positive integer rate/level required");
  const value=BigInt(rate.base)*BigInt(rate.growthNum)**BigInt(level-1)/BigInt(rate.growthDen)**BigInt(level-1);
  if (value>BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("unsafe result");
  return Number(value);
}
export function threshold(prod:Rate,cons:Rate,maxLevel:number):number {
  if (!Number.isSafeInteger(maxLevel)||maxLevel<1)throw new Error("bad max level");
  for (let level=1;level<=maxLevel;level++)if(rateAt(cons,level)>=rateAt(prod,level))return level;
  return -1;
}

先运行 python3 sim/ch12_parity.py --config config/ --out out/,再运行 node --test proto/test/ch12.test.ts。TS 用 BigInt 计算整数幂,逐项与同配置的 Python 产物核对;连续浮点轨迹不纳入逐位一致声明。

# file: sim/ch12_parity.py
# tier: S
# verify: python3 sim/ch12_parity.py --config config/ --out out/
import argparse, json
from pathlib import Path
from ch12_stock_flow import load_params,prod_at,cons_at,solve_integer
ap=argparse.ArgumentParser();ap.add_argument("--config",default="config/");ap.add_argument("--out",default="out/")
a=ap.parse_args();sf,b=load_params(a.config)
result={"production":[prod_at(b,l) for l in range(1,11)],"consumption":[cons_at(sf["consumption"],l) for l in range(1,11)],"threshold":solve_integer(b,sf["consumption"])}
Path(a.out).mkdir(parents=True,exist_ok=True)
Path(a.out,"ch12_parity.json").write_text(json.dumps(result))
print(json.dumps(result))
// file: proto/test/ch12.test.ts
// tier: P
// verify: node --test proto/test/ch12.test.ts
import {test} from "node:test";
import assert from "node:assert/strict";
import {readFileSync} from "node:fs";
import {rateAt,threshold} from "../src/ch12_stock_flow.ts";
const cfg=JSON.parse(readFileSync(new URL("../../config/ch12_stockflow.json",import.meta.url),"utf8"));
const b=JSON.parse(readFileSync(new URL("../../config/ch12_buildings.json",import.meta.url),"utf8")).buildings[0];
const prod={base:b.outputBase,growthNum:b.outputGrowthNum,growthDen:b.outputGrowthDen};
const source=JSON.parse(readFileSync(new URL("../../out/ch12_parity.json",import.meta.url),"utf8"));
test("same configuration: all integer rate values match Python",()=>{
  assert.deepEqual(Array.from({length:10},(_,i)=>rateAt(prod,i+1)),source.production);
  assert.deepEqual(Array.from({length:10},(_,i)=>rateAt(cfg.consumption,i+1)),source.consumption);
  assert.equal(threshold(prod,cfg.consumption,10),source.threshold);
});
test("reject invalid domains and unsafe Number conversion",()=>{
  for(const level of [0,-1,1.5])assert.throws(()=>rateAt(prod,level));
  assert.throws(()=>rateAt({...prod,growthDen:0},2));
  assert.throws(()=>rateAt({base:2,growthNum:10,growthDen:1},30));
  assert.throws(()=>threshold(prod,cfg.consumption,0));
});

但把产出增速从 22% 提到 46%(outputGrowthNum 122 → 146),solveInteger 立刻返回 −1:满级 10 级内都没有交点。这不是「稳态往后挪了几级」,而是稳态消失了——产出一直跑赢消耗,存量无界增长。这就是「稳态是设计出来的,不是自然形成的」:它要求消耗增速严格大于产出增速,差一点点都不行。

B 基准:稳态可达性

稳态存在,不等于赛季内到得了。下面这段是 B 层基准脚本,量两个指标:进入 ±2% 平台带所需天数、进入之后的残余波动。两者超阈值只软告警——产品上允许赛季末仍在爬坡。

# file: sim/ch12_stock_flow_bench.py
# tier: B
# verify: python3 sim/ch12_stock_flow_bench.py --config config/ --out out/
"""稳态可达性基准(B 层,软告警不硬失败)。

稳态不是「有没有」,而是「赛季内能不能到、到了之后稳不稳」。跑一遍确定性存量
轨迹,量两个指标:进入 ±tol 平台带所需的天数、进入之后的残余波动幅度。超阈值
只告警——产品上允许赛季末仍在爬坡。

产出:out/ch12_stock_flow_bench.json
"""

from __future__ import annotations

import argparse
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch12_stock_flow import load_params, simulate  # noqa: E402


def entry_day(rows, tol_pct):
    """首个「此后一直落在 ±tol% 平台带内」的天数。"""
    plateau = rows[-1]["stock"]
    tol = abs(plateau) * tol_pct / 100.0
    n = len(rows)
    for i in range(n):
        if all(abs(rows[j]["stock"] - plateau) <= tol for j in range(i, n)):
            return rows[i]["day"]
    return rows[-1]["day"]


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    sf, b = load_params(args.config)
    days = sf["seasonDays"]
    rows = simulate(sf, b, sf["consumption"])
    plateau = rows[-1]["stock"]
    e = entry_day(rows, sf["entryTolerancePct"])
    tail = [r["stock"] for r in rows if r["day"] >= e]
    mean = sum(tail) / len(tail) if tail else 0.0
    residual = round(100.0 * (max(tail) - min(tail)) / mean, 3) if mean else 0.0
    rep = {"version": 1, "seasonDays": days, "plateauStock": round(plateau, 2),
           "entryDay": e, "entryPctOfSeason": round(100.0 * e / days, 1),
           "postEntryResidualPct": residual, "entryWarnPct": 50.0, "residualWarnPct": 2.0}
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch12_stock_flow_bench.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(f"稳态可达性:第 {e} 天进入 ±2% 平台带(赛季的 {rep['entryPctOfSeason']}%)")
    print(f"平台存量={plateau:.0f}  进入后残余波动={residual}%")
    if rep["entryPctOfSeason"] > rep["entryWarnPct"]:
        print("WARN 赛季过半才到稳态:经济大部分时间在爬坡")
    if residual > rep["residualWarnPct"]:
        print(f"WARN 进入平台后仍有 {residual}% 波动")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
稳态可达性:第 35 天进入 ±2% 平台带(赛季的 19.4%)
平台存量=10298  进入后残余波动=1.788%

确定性轨迹在第 35 天进入平台(赛季的 19.4%),之后残余波动 1.788%,都在阈值内。这条基准的可达性说明:L*=6 这个设计点,赛季内到得了,而且到了之后稳。 真正会把稳态推走的,不是这里的基数,而是下一节要排的增速参数。

阅读导航:上一节:消耗曲线仿真 · 下一节:闭环仿真与稳态判定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引