11.3 消耗曲线仿真

把 11.1 的养成消耗与 11.2 的市场回收汇总成一条 N 天消耗曲线,用 S 层脚本跑出每天各消耗槽的明细与合计,再用 B 层基准对活动强度做蒙特卡洛抖动,量化总消耗的 P10–P90 带宽。闭环(产出与消耗一起建模)留给第 12 章。

11.3 消耗曲线仿真

站内有一篇《游戏经济赛季重置 2026》 ,那篇讲的是通用重置原则:什么时候重置、重置什么、怎么补偿。本节不重复原则,只把「一个赛季里玩家每天消耗多少」这件事算成一条可跑的曲线——重置方案要参考的,正是这条曲线的形状。11.1 盘了槽、11.2 找了税率拐点,这一节把它们汇总成 N 天曲线。

必须先把边界钉死:本节只做消耗侧。产出曲线在第 10 章,产出与消耗一起形成的闭环——资源存量是涨是跌、能不能收敛到稳态——留给第 12 章。把消耗单独画出来,是为了先看清「每天被吸走多少」这条线本身的形状,再谈它和产出线的交点。

把消耗槽汇总成 N 天曲线

汇总的逻辑是:进度驱动的槽按计划表推进,活动驱动的槽随进度增长。

  • 进度驱动(建筑升级、科技研发):按 upgradeEveryDays / techEveryDays 的节奏,每隔若干天升一级,成本用 11.1 算出的指数成本表。它们让曲线出现台阶。
  • 活动驱动(训练、行军、治疗、市场回收):每天的量随玩家进度按整数比增长(activityGrowthNum / activityGrowthDen)。它们让曲线整体上抬。

脚本复用 11.1 的 ch11_sink_slots.py 里的成本函数(cost_at / tech_at / cval),保证同一套口径。下面这段是 S 层仿真脚本:

# file: sim/ch11_consumption_curve.py
# tier: S
# verify: python3 sim/ch11_consumption_curve.py --config config/ --out out/
"""消耗曲线仿真:把 11.1 的养成消耗(建筑升级 / 科技)与活动型消耗(训练 / 行军 / 治疗)
汇总成 N 天曲线,并把 11.2 的市场回收单列。只做消耗侧,闭环(产出与消耗一起)留第 12 章。
产出 out/ch11_consumption.json / ch11_consumption.csv / ch11_consumption.svg"""
import argparse, csv, json, os, xml.etree.ElementTree as ET
from ch11_sink_slots import load, cost_at, tech_at, cval, out_at

COL = {"building": "#1f6feb", "tech": "#9333ea", "training": "#d97706", "healing": "#059669", "march": "#64748b"}
KEYS = ["building", "tech", "training", "healing", "march"]

def act_permille(c, day):
    return 1000 * c["activityGrowthNum"] ** (day - 1) // c["activityGrowthDen"] ** (day - 1)

def build_curve(c, sk, bl, jitter=None):
    val, tech, by_id = sk["resourceValue"], sk["tech"], {b["id"]: b for b in bl["buildings"]}
    levels, tl, order = dict(c["startLevels"]), c["techStartLevel"], c["upgradeOrder"]
    curve = []
    for day in range(1, c["days"] + 1):
        w = {k: 0 for k in KEYS}; by_res = {}
        eff = act_permille(c, day) * (jitter(day) if jitter else 1000) // 1000
        def add(cost, key=None):
            for r, v in cost.items():
                by_res[r] = by_res.get(r, 0) + v
            if key:
                w[key] += cost.get("wood", 0)
        if (day - 1) % c["upgradeEveryDays"] == 0:
            bid = order[((day - 1) // c["upgradeEveryDays"]) % len(order)]
            if levels[bid] < by_id[bid]["maxLevel"]:
                add(cost_at(by_id[bid], levels[bid]), "building"); levels[bid] += 1
        if (day - 1) % c["techEveryDays"] == 0 and tl < tech["maxLevel"]:
            add(tech_at(tech, tl), "tech"); tl += 1
        def scaled(base, count):
            return {r: v * count * eff // 1000 for r, v in base.items()}
        add(scaled(sk["unitCost"], c["trainUnitsPerDay"]), "training")
        add(scaled(sk["marchPerHour"], c["marchHoursPerDay"]), "march")
        add(scaled(sk["healPerWounded"], c["healWoundedPerDay"]), "healing")
        market = sk["referenceDay"]["tradesPerDay"] * eff // 1000 * sk["referenceDay"]["tradeAvgValue"] * c["taxPct"] // 100
        curve.append({"day": day, "wood": w, "totalWood": sum(w.values()),
                      "market": market, "totalValue": cval(by_res, val) + market})
    return curve

def draw(path, curve, res):
    W, H, pl, pr, pt, pb = 720, 360, 72, 24, 28, 52
    iw, ih = W - pl - pr, H - pt - pb
    hi = max(c["totalWood"] for c in curve) or 1; n = max(len(curve) - 1, 1)
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#fff"})
    ET.SubElement(svg, "text", {"x": str(pl), "y": "18", "font-size": "14"}).text = f"{res} 每日消耗曲线({len(curve)} 天,仅消耗侧)"
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(H - pb), "stroke": "#cbd5e1"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(H - pb), "x2": str(W - pr), "y2": str(H - pb), "stroke": "#cbd5e1"})
    series = [("total", [c["totalWood"] for c in curve], "#111", 2.4)] + [(k, [c["wood"][k] for c in curve], COL[k], 1.4) for k in KEYS]
    for i, (name, ys, color, wd) in enumerate(series):
        pts = " ".join(f"{pl + iw * j / n:.1f},{pt + ih * (1 - v / hi):.1f}" for j, v in enumerate(ys))
        ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": color, "stroke-width": str(wd)})
        ET.SubElement(svg, "text", {"x": str(pl + 10 + i * 118), "y": str(H - pb + 22), "font-size": "11", "fill": color}).text = name
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)

def main():
    ap = argparse.ArgumentParser(); ap.add_argument("--config", default="config/"); ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    c, sk, bl = load(a.config, "ch11_consumption"), load(a.config, "ch11_sinks"), load(a.config, "buildings")
    curve = build_curve(c, sk, bl)
    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "ch11_consumption.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["day"] + KEYS + ["totalWood", "marketValue", "totalValue"])
        [w.writerow([x["day"]] + [x["wood"][k] for k in KEYS] + [x["totalWood"], x["market"], x["totalValue"]]) for x in curve]
    total = sum(x["totalWood"] for x in curve); first, last = curve[0]["totalWood"], curve[-1]["totalWood"]
    peak = max(curve, key=lambda x: x["totalWood"])
    summary = {"version": 1, "days": c["days"], "resource": c["resource"], "firstDayWood": first, "lastDayWood": last,
               "totalWood": total, "dayAvgWood": total // c["days"], "peakDay": peak["day"], "peakWood": peak["totalWood"],
               "growthPct": (last - first) * 100 // first if first else 0, "totalMarketValue": sum(x["market"] for x in curve)}
    with open(os.path.join(a.out, "ch11_consumption.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    draw(os.path.join(a.out, "ch11_consumption.svg"), curve, c["resource"])
    print(json.dumps(summary, ensure_ascii=False))

if __name__ == "__main__":
    main()

本机实跑(python3 sim/ch11_consumption_curve.py --config config/ --out out/)的关键数字:30 天里,木材日消耗从第 1 天的 28120 涨到第 30 天的 65025,增长 131%;30 天累计 1318133,日均 43937,峰值出现在第 30 天。同期市场回收(交易税)累计 456555。

曲线的主体是训练(每天数万),它的增长来自活动强度;建筑升级和科技是曲线上那一格格台阶。看 CSV 的头几天能看清结构:

daybuildingtechtraininghealingmarchtotalWoodmarketValue
11204002400036000281209600
2002472037080284289888
30025440381602925610176
28196053304799506149521321
300056544848106502522617

曲线的形状说明了什么

把这条曲线拆开看,能得到三条设计结论。

第一,消耗的主体是活动型,不是进度型。 30 天里,建筑升级和科技加起来占比很小,训练一项就接近一半。这意味着消耗曲线的稳定性主要取决于活动型槽的参数,而不是建筑成本表。调建筑成本增长率,改的是台阶的高度;调训练单价或活动增长,改的是整条曲线的斜率。

第二,进度型槽提供节奏感,不提供体量。 台阶让玩家每隔几天有一次「变强」的实感,但它在总量里的份额很小。所以设计上不必纠结建筑成本是否够大,而要让台阶出现的频率和玩家的在线节奏对齐——这正是第 3 章日/周/赛季三级节奏 讲的东西。

第三,活动增长决定了长线斜率。 activityGrowthNum / activityGrowthDen 取 103/100 时,30 天增长约 2.4 倍,消耗曲线跟着抬升 131%。这个参数是通胀压力的主要来源:活动增长越快,后期每天吸走的资源越多,产出侧必须同步放大,否则闭环会走向供不应求。它的取值不该拍脑袋,而要和产出增长率对照着定——对照的具体做法在第 12 章。

消耗槽30 天木材口径占比特征
训练主体,随活动增长决定曲线斜率
治疗次主体,随活动增长与训练同步抬升
建筑升级台阶状决定节奏感
科技研发台阶状,间隔更长长线目标
行军0(不耗木材)耗粮草,另计

不确定性基准:活动抖动下的总消耗分布

上面的曲线假设每天的活动强度严格按进度增长。真实玩家不会这么整齐:训练量、行军时长、伤兵数每天都有波动。下面这段是 B 层基准脚本,对每日活动强度按 ±25% 做整数抖动,重复 2000 次,得到 30 天总消耗的分布:

# file: sim/ch11_consumption_bench.py
# tier: B
# verify: python3 sim/ch11_consumption_bench.py --config config/ --trials 2000
"""消耗曲线的不确定性基准(B 层,软告警不硬失败)。
确定性曲线假设每天活动强度按固定进度增长;真实玩家每天的训练量、行军时长、伤兵数会抖动。
对每日活动强度按 variancePct 做整数抖动,重复 trials 次,得到 N 天总消耗的分布,与点估计对照。
LCG 固定种子,结果可复现。"""
import argparse, json, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch11_sink_slots import load
from ch11_consumption_curve import build_curve

OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "out")

def lcg(seed):
    st = [seed & 0xFFFFFFFF]
    def nxt():
        st[0] = (st[0] * 1103515245 + 12345) % 2147483648
        return st[0]
    return nxt

def pct(xs, p):
    return xs[(p * (len(xs) - 1)) // 100] if xs else 0

def run(c, sk, bl, trials, seed, var):
    rng = lcg(seed); totals = []
    for _ in range(trials):
        jit = [(1000 - 10 * var) + rng() % (20 * var + 1) for _ in range(c["days"])]
        totals.append(sum(x["totalWood"] for x in build_curve(c, sk, bl, jitter=lambda d, j=jit: j[d - 1])))
    totals.sort(); det = sum(x["totalWood"] for x in build_curve(c, sk, bl)); p50 = pct(totals, 50)
    return {"trials": trials, "seed": seed, "variancePct": var, "days": c["days"], "deterministicTotal": det,
            "p10": pct(totals, 10), "p50": p50, "p90": pct(totals, 90), "p95": pct(totals, 95),
            "meanTotal": sum(totals) // len(totals), "minTotal": totals[0], "maxTotal": totals[-1],
            "spreadPctP10P90": (pct(totals, 90) - pct(totals, 10)) * 100 // (p50 or 1),
            "biasPct": (p50 - det) * 100 // (det or 1)}

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config"))
    ap.add_argument("--trials", type=int, default=2000); ap.add_argument("--seed", type=int, default=20261012)
    ap.add_argument("--variance", type=int, default=25)
    a = ap.parse_args()
    c, sk, bl = load(a.config, "ch11_consumption"), load(a.config, "ch11_sinks"), load(a.config, "buildings")
    rep = run(c, sk, bl, a.trials, a.seed, a.variance)
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ch11_consumption_bench.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(f"trials={rep['trials']} seed={rep['seed']} variance=±{rep['variancePct']}% days={rep['days']}")
    print(f"deterministic={rep['deterministicTotal']}  p10={rep['p10']} p50={rep['p50']} p90={rep['p90']} p95={rep['p95']}")
    print(f"P10–P90 相对带宽={rep['spreadPctP10P90']}%  bias(P50-det)={rep['biasPct']}%")
    if rep["spreadPctP10P90"] > 60:
        print(f"WARN 消耗曲线不确定性偏大:P10–P90 带宽 {rep['spreadPctP10P90']}%,产出侧需留更多冗余")

if __name__ == "__main__":
    main()

本机实跑(python3 sim/ch11_consumption_bench.py --config config/ --trials 2000):确定性总消耗 1318133,P10 是 1271157、P50 是 1317471、P90 是 1363121,P10–P90 相对带宽只有 6%,P50 相对点估计的偏差 -1%。抖动没有让结果发散,原因是训练、治疗这些活动型槽在 30 天里每天都出现,正负抖动相互抵消,大数定律把总量拉回点估计附近。这说明消耗侧的总量是稳的,波动主要发生在单日——这对产出侧的意义是:不必为总消耗留大冗余,但日结算的峰值需要关注。

消耗曲线与闭环的边界

本节到消耗曲线为止,是有意为之。一条单独的消耗曲线只能回答「每天吸走多少」,回答不了「资源会不会爆仓」或「会不会枯竭」——那需要把产出曲线和消耗曲线放在同一个存量-流量方程里,看净流量的符号。这正是第 12 章存量/流量模型 要做的第一件事。

把本节的数字和 10.3 的产出曲线 放在一起,就已经能看出端倪:消耗按 131% 增长,如果产出增长跟不上,后期就会供不应求;反之则囤积。但判断必须留给闭环,本节不做结论,只交出一条可复现的消耗曲线和它的不确定性带宽。

怎么用这条曲线

这条曲线不是给玩家看的,是给三类人用的:

  • 数值策划:拿它对照 10.3 的产出曲线,判断净流量符号,进而决定要不要调整训练单价或活动增长。曲线本身不回答「崩不崩」,但它提供了判断的输入。
  • 运营:拿它预测每个赛季第 N 天的消耗水位,提前排好资源投放活动。消耗曲线抬升最快的时段,往往正是玩家最需要「回血」的时段。
  • 风控:拿它当基线。如果某个服务器的实际消耗长期远低于这条曲线,可能意味着玩家在囤积,或者有资源产出被异常放大——第 15 章的异常检测与打金识别 正是用「实际 vs 基线」的偏离来发现问题的。

一句话:消耗曲线是闭环仿真的半条输入。单独看它,只能确认「消耗在按预期增长」;把它和产出拼起来,才能回答经济稳不稳。

本节小结

这一节把消耗侧收口:

  1. N 天曲线:进度驱动的槽出台阶、活动驱动的槽随进度增长,汇总成一条 30 天木材消耗曲线,从 28120 涨到 65025(+131%)。
  2. B 层基准:对活动强度做 ±25% 抖动 2000 次,总消耗的 P10–P90 带宽仅 6%,说明总量稳定、波动集中在单日。
  3. 边界:本节只做消耗侧,产出与消耗的闭环留给第 12 章。
  4. 用途:这条曲线是闭环仿真的半条输入,单独看只能确认「消耗在按预期增长」。

值得再强调一次:本节所有数字都是同机相对量级,绝对数值不可移植——换一组 config/ 参数,曲线的具体高度会变,但「活动型槽主导、进度型槽出台阶、总量随活动增长」这三条结构性结论不变。

第 11 章到此结束。下一章把产出线和消耗线合到一起,回答「经济会不会崩」这个本卷最核心的问题。

阅读导航:上一节:11.2 税率、手续费与消耗弹性 · 下一节:12.1 存量/流量模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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