第 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*。三条结论值得记住:
- 在本例
P0>C0、等级增长、无其他反馈的前提下,消耗增长更快才可能在有限等级内追平产出;还要检查交点是否在 1 到 maxLevel 内。相同增长率且相同基数时每级都相等,不能笼统说没有稳态。 - L 越低,在这个模型中到达经济平台前的成长空间越小*。满级 10 级而 L*=6,意味着玩家在赛季中段就摸到天花板,之后经济自我封顶。
- 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) | 净流量 |
|---|---|---|---|
| 3 | 89 | 69 | +20 |
| 4 | 108 | 93 | +15 |
| 5 | 132 | 126 | +6 |
| 6 | 162 | 170 | −8 |
| 7 | 197 | 230 | −33 |
| 10 | 359 | 565 | −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 这个设计点,赛季内到得了,而且到了之后稳。 真正会把稳态推走的,不是这里的基数,而是下一节要排的增速参数。
阅读导航:上一节:消耗曲线仿真 · 下一节:闭环仿真与稳态判定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。