前两节分别给了两样东西:一张产出速率表(10.1)和一组时间规则(10.2)。站内那篇通用的经济平衡框架 把它们合起来的方式是画三条定性曲线;本节做得更硬一点——把速率表和时间规则一起喂进一段脚本,跑出一条 60 天的可复现产出曲线,并给它做拟合与残差检验。产出曲线是产出端的最终交付物,也是第 11 章消耗端、第 12 章闭环仿真的共同输入。
从两条规则到一条曲线
把前两节的输入拼起来,一条产出曲线由三部分叠加而成:
- 建筑产出的成长:玩家会不断升级建筑,所以连续速率不是常数,而是一条阶梯上升的曲线。10.1 的等级表给了每一级的值,本节再补一条升级节奏(每几天升一级、按什么顺序升)。
- 离线/在线的时间折算:10.2 的离线规则给出「一天的满产按几小时算」。本节用一个效率系数把它折成一个整数(本方案 750‰,即满产的 75%),把「玩家不可能 24 小时满产」这件事写进曲线。
- 脉冲型产出:任务、活动、赠送这些一次性发放,按日程表在特定日子叠加。它们是曲线上的尖峰。
三者相加,就是这条产出曲线。注意本节只做产出侧——消耗侧(升级花费、征兵、行军的资源消耗)留到第 11 章,产出与消耗一起构成的闭环留到第 12 章。
汇总:一条 N 天产出曲线
下面这份是 D 层配置,定义了升级节奏、效率系数、脉冲日程与锚点目标:
{
"version": 1,
"note": "产出曲线仿真:把 10.1/10.2 的产出源汇总成 N 天曲线,做拟合与仿真。整数口径,拟合残差在 S/B 层用浮点报告。",
"days": 60,
"resource": "wood",
"levels": { "lumberyard": 5, "quarry": 5, "ironworks": 3, "farm": 6 },
"maxLevel": 10,
"upgradeEveryDays": 5,
"upgradeOrder": ["lumberyard", "quarry", "farm", "ironworks"],
"efficiencyPermille": 750,
"burstSchedule": [
{ "day": 3, "amount": 800, "source": "quest" },
{ "day": 7, "amount": 1500, "source": "event" },
{ "day": 14, "amount": 2200, "source": "event" },
{ "day": 21, "amount": 3000, "source": "gift" },
{ "day": 30, "amount": 5000, "source": "event" },
{ "day": 45, "amount": 6000, "source": "event" }
],
"anchors": [
{ "day": 7, "cumTarget": 65000 },
{ "day": 30, "cumTarget": 280000 },
{ "day": 60, "cumTarget": 620000 }
]
}
下面这段是 S 层仿真脚本:它仿真 60 天,产出每日产出、累计曲线,并用最小二乘(不依赖 scipy)把累计曲线拟合成幂律 C(d) ≈ A·d^B,最后画一张「实际曲线 vs 拟合曲线」的 SVG:
# file: sim/ch10_curve_sim.py
# tier: S
# verify: python3 sim/ch10_curve_sim.py --config config/ --out out/
"""产出曲线仿真:把 10.1 的建筑产出、10.2 的离线效率与脉冲型产出源汇总成一条 N 天曲线,
并对累计曲线做幂律拟合 C(d) ≈ A·d^B(最小二乘,无 scipy),报告拟合优度与残差。
产出:out/ch10_curve.json + out/ch10_curve.csv + out/ch10_curve.svg(纯 Python SVG)
"""
from __future__ import annotations
import argparse, csv, json, math, os
import xml.etree.ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
def load(cfg_dir, name):
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def building_rate(buildings, levels):
total = 0
for b in buildings["buildings"]:
lv = levels.get(b["id"], 0)
if lv > 0:
total += (b["outputBase"] * b["outputGrowthNum"] ** (lv - 1)) // b["outputGrowthDen"] ** (lv - 1)
return total
def levels_on_day(cfg, day):
"""每过 upgradeEveryDays 天升一栋建筑(按 upgradeOrder 循环),不超过 maxLevel。"""
lv = dict(cfg["levels"])
order, every, mx = cfg["upgradeOrder"], cfg["upgradeEveryDays"], cfg["maxLevel"]
for i in range(day // every):
b = order[i % len(order)]
if lv.get(b, 0) < mx:
lv[b] = lv.get(b, 0) + 1
return lv
def build_curve(cfg, buildings):
daily, cum, acc = [], [], 0
for d in range(1, cfg["days"] + 1):
base = building_rate(buildings, levels_on_day(cfg, d)) * 24 * cfg["efficiencyPermille"] // 1000
v = base + sum(b["amount"] for b in cfg["burstSchedule"] if b["day"] == d)
acc += v; daily.append(v); cum.append(acc)
return daily, cum
def power_law_fit(cum):
"""最小二乘拟合 C(d)=A·d^B:对 log C = log A + B·log d 做一元线性回归。"""
xs = [math.log(d + 1) for d in range(len(cum))]
ys = [math.log(v) for v in cum]
n = len(xs); mx = sum(xs) / n; my = sum(ys) / n
b = sum((xs[i] - mx) * (ys[i] - my) for i in range(n)) / sum((x - mx) ** 2 for x in xs)
a = math.exp(my - b * mx)
pred = [a * (d + 1) ** b for d in range(n)]
sse = sum((cum[i] - pred[i]) ** 2 for i in range(n))
mean = sum(cum) / n
sst = sum((v - mean) ** 2 for v in cum)
return {"A": a, "B": b, "r2": 1 - sse / sst if sst else 0.0, "rmse": math.sqrt(sse / n), "pred": pred}
def render_svg(path, cum, pred, anchors):
width, height, pl, pr, pt, pb = 680, 400, 56, 24, 28, 44
iw, ih = width - pl - pr, height - pt - pb
n, hi = len(cum), float(max(cum)) or 1.0
def xy(i, v):
return f"{pl + iw * (i / max(n - 1, 1)):.1f},{pt + ih * (1 - v / hi):.1f}"
svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": f"0 0 {width} {height}", "width": str(width), "height": str(height)})
ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(height - pb), "stroke": "#333"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(height - pb), "x2": str(width - pr), "y2": str(height - pb), "stroke": "#333"})
ET.SubElement(svg, "polyline", {"points": " ".join(xy(i, cum[i]) for i in range(n)), "fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
ET.SubElement(svg, "polyline", {"points": " ".join(xy(i, pred[i]) for i in range(n)), "fill": "none", "stroke": "#e67e22", "stroke-width": "1.6", "stroke-dasharray": "5 3"})
for a in anchors:
if 1 <= a["day"] <= n:
x = pl + iw * ((a["day"] - 1) / max(n - 1, 1))
y = pt + ih * (1 - cum[a["day"] - 1] / hi)
ET.SubElement(svg, "circle", {"cx": f"{x:.1f}", "cy": f"{y:.1f}", "r": "3.5", "fill": "#c0392b"})
ET.SubElement(svg, "text", {"x": str(pl), "y": "16", "font-size": "13", "fill": "#111"}).text = "cumulative: actual (blue) vs power-law fit (orange)"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main():
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "ch10_curve")
buildings = load(args.config, "buildings")
daily, cum = build_curve(cfg, buildings)
fit = power_law_fit(cum)
with open(os.path.join(args.out, "ch10_curve.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f); w.writerow(["day", "dailyProduction", "cumulative", "fitCumulative", "residual"])
for i in range(len(cum)):
w.writerow([i + 1, daily[i], cum[i], f"{fit['pred'][i]:.1f}", f"{cum[i] - fit['pred'][i]:.1f}"])
anchors = [{"day": a["day"], "actual": cum[a["day"] - 1], "target": a["cumTarget"],
"residual": cum[a["day"] - 1] - a["cumTarget"],
"residualPermille": (cum[a["day"] - 1] - a["cumTarget"]) * 1000 // a["cumTarget"]}
for a in cfg["anchors"]]
report = {
"version": 1, "resource": cfg["resource"], "days": cfg["days"],
"dailyFirst": daily[0], "dailyLast": daily[-1], "cumulativeFinal": cum[-1],
"fit": {"model": "A*d^B", "A": round(fit["A"], 2), "B": round(fit["B"], 4),
"r2": round(fit["r2"], 6), "rmse": round(fit["rmse"], 2)},
"anchors": anchors,
"burstTotal": sum(b["amount"] for b in cfg["burstSchedule"]),
"upgradeCount": cfg["days"] // cfg["upgradeEveryDays"],
}
with open(os.path.join(args.out, "ch10_curve.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
render_svg(os.path.join(args.out, "ch10_curve.svg"), cum, fit["pred"], cfg["anchors"])
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(Python 3.14.6)得到:
dailyFirst: 8424 dailyLast: 15066 cumulativeFinal: 705272
fit: C(d)=7417.15·d^1.0979 r2=0.995687 rmse=13449.68
upgradeCount: 12 burstTotal: 18500
anchors: D7=62888 D30=305630 D60=705272
几个读法:
- 首末日:第 1 天产 8424,第 60 天产 15066——60 天里日产出涨了约 79%,全部来自 12 次建筑升级。曲线不是平的,也不是纯指数,而是阶梯 + 脉冲。
- 拟合指数 B ≈ 1.098:累计曲线拟合出来略高于线性(
B > 1),说明产出是温和加速的——前期升级快、后期趋缓。这个 B 值是判断「产出膨胀速度」的一个标量指标,第 13 章谈通胀时会用到。 - 拟合优度 R² = 0.9957:幂律模型对这条曲线拟合得相当好,残差主要落在脉冲尖峰附近(脉冲是阶跃,幂律是平滑曲线)。
拟合:给曲线找一个可比的模型
为什么要拟合?因为一条 60 天的原始曲线没法直接拿来比较。换一组升级节奏、换一个效率系数,曲线就变了,两条曲线谁「涨得快」很难肉眼看出来。把它压成 A·d^B 两个参数,B 就成了一个可比的标量:
B ≈ 1:产出基本线性,资源水位稳定上涨。B > 1:产出加速,后期资源供给越来越猛——通胀的温床。B < 1:产出减速,玩家越玩越「喂不饱」——通缩的前兆。
本方案的 B ≈ 1.098 属于温和加速。注意这些是同机相对量级,绝对数值不可移植——换一台机器、换一组配置,A、B、R² 都会变,能比的是「同一配置下不同方案之间」的相对大小。
B 基准:锚点残差
曲线跑出来了,接下来要回答一个产品问题:它落在设计目标的哪个位置?这就是 B 层基准要做的——把曲线在锚点日与设计目标做残差对照,超阈值只软告警、不硬失败(因为目标本身是设计意图,不是硬约束):
# file: sim/ch10_curve_bench.py
# tier: B
# verify: python3 sim/ch10_curve_bench.py --config config/ --out out/
"""B 基准:把仿真产出曲线在锚点日与设计目标做对照,算千分比残差。
软告警不硬失败——残差超阈值只打 WARN,退出码恒为 0。"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch10_curve_sim import build_curve, load # noqa: E402
WARN_THRESHOLD_PERMILLE = 100
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "ch10_curve")
buildings = load(args.config, "buildings")
_, cum = build_curve(cfg, buildings)
checks = []
for a in cfg["anchors"]:
d, tgt = a["day"], a["cumTarget"]
actual = cum[d - 1]
resid = actual - tgt
resid_permille = resid * 1000 // tgt
checks.append({
"day": d, "actual": actual, "target": tgt,
"residual": resid, "residualPermille": resid_permille,
"warn": abs(resid_permille) > WARN_THRESHOLD_PERMILLE,
})
abs_res = [abs(c["residualPermille"]) for c in checks]
rep = {
"version": 1,
"thresholdPermille": WARN_THRESHOLD_PERMILLE,
"checks": checks,
"maxAbsResidualPermille": max(abs_res),
"meanAbsResidualPermille": sum(abs_res) // len(abs_res),
"anyWarn": any(c["warn"] for c in checks),
}
with open(os.path.join(args.out, "ch10_curve_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
for c in checks:
flag = "WARN" if c["warn"] else "OK"
print(f"{flag} D{c['day']} actual={c['actual']} target={c['target']} resid={c['residualPermille']}permille")
print(json.dumps({k: rep[k] for k in ("maxAbsResidualPermille", "meanAbsResidualPermille", "anyWarn")}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑得到:
OK D7 actual=62888 target=65000 resid=-33permille
OK D30 actual=305630 target=280000 resid=91permille
WARN D60 actual=705272 target=620000 resid=137permille
{"maxAbsResidualPermille": 137, "meanAbsResidualPermille": 87, "anyWarn": true}
残差说明了什么
三个锚点里两个落在容差内、一个告警,这本身就是一条有用的信息:
- D7 略低于目标(-33‰):新手期产出比设计预期稍慢,说明升级节奏可以再前移一点。
- D30 略高于目标(+91‰):中期产出开始超过目标,脉冲活动叠加的效果显现。
- D60 明显超出(+137‰,触发 WARN):长期产出比设计目标高了约 14%,且偏离在扩大。
这条趋势才是本节最重要的产出。它说明:在当前的升级节奏与脉冲日程下,产出曲线是「越跑越超」的——产出端没有自我约束机制。如果不配一个同步放大的消耗端(第 11 章的升级花费、第 13 章的回收机制),资源水位会持续走高,最终走向通胀。B 层基准的价值就在这:它不阻止你上线,但会在曲线偏离目标时亮一盏黄灯。
这条曲线还不能直接用
最后强调边界:本节给出的是纯产出曲线,它还没有减掉任何消耗。一条真实的资源水位曲线是「产出 − 消耗」的积分,而消耗端(升级花费、征兵、行军、税率手续费)是第 11 章的内容,产出与消耗一起构成的闭环稳态是第 12 章的核心。本节的意义是把产出侧先做成一个可复现、可拟合、可对账的输入,让后面的闭环仿真有一个可靠的起点。
小结
产出曲线 = 建筑成长的阶梯 × 时间折算 × 脉冲尖峰。把它拟合成 A·d^B,B 是产出膨胀速度的标量指标;把它在锚点日与设计目标对照,残差告诉你产出端有没有失控。本方案跑出 B ≈ 1.098、D60 残差 +137‰,两个数字都指向同一件事:产出在温和加速,需要消耗端接住。
下一章进入消耗端设计,看资源都流向了哪里。
阅读导航:上一节:产出与时间的关系 · 下一节:消耗槽与养成消耗 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。