10.1 产出源清单与产出速率

产出源决定了资源从哪来。本节把 SLG 的产出端拆成连续型与脉冲型两类,盘点地块、建筑、任务活动、掠夺、赠送、付费七种来源的速率特征与可控性,并用一段 S 层脚本从 config/buildings.json 算出四栋建筑十级的每小时产出表并输出 CSV,再用 TypeScript 原型断言同一张表。

上一章给资源与货币分了层,用账本给「资源不会凭空产生」立了守恒约束。这一节走到等式的另一侧:资源从哪来。站内那篇通用的经济平衡框架 用放置类举例,讲的是「产出—消耗—成长」的一般结构,落点是怎么让三条曲线互相咬合;本节换成 SLG 的大地图口径,先只做产出侧——把产出端拆成一组可测量的速率,而不是一张凭感觉列的清单。产出与消耗合起来的闭环留到第 12 章。

产出端要回答的三个问题

设计产出端时,最容易犯的错是把它做成一张「资源清单」:木材来自伐木场,石头来自采石场,粮草来自农田……清单列完就以为设计完了。但清单只回答了「谁产」,没回答「产多快、谁说了算、波动多大」。真正决定经济能不能跑稳的,是后面三件事:

  • 速率(rate):单位时间内产多少。这是唯一能被脚本精确计算的量,也是后面所有仿真的输入。
  • 可控性(control):产出量由谁决定。玩家点升级就能改的(建筑),和玩家完全控制不了的(对手掠夺),在数值上的处理方式完全不同。
  • 方差(variance):同样一段时间,不同玩家拿到的产出差多少。零方差的稳定流和方差 80% 的脉冲流,对经济水位的影响是两回事。

这三个维度合起来,才是一份能拿去做平衡的「产出源清单」。

七种产出源与它们的可控性

《王朝纪元》把产出源收敛成七种。前两种是连续型(只要在线或满足条件就持续产出),后五种是脉冲型(在某个时点一次性发放):

源类型谁决定产出量设计可控性方差载体
地块产出连续玩家(占地的数量与等级)高0四类基础资源
建筑产出连续玩家(升级节奏)高0四类基础资源
任务与成就脉冲设计师(固定奖励)高0混合
活动奖励脉冲运营(活动排期)高20%混合
掠夺脉冲对手(战力与在线)低80%混合
系统赠送脉冲运营(补偿/福利)高10%混合
付费获取脉冲商业化(礼包设计)高0混合

看这张表,会发现一个反直觉的结论:七种源里,六种都高度可控,只有掠夺一种不可控,但它偏偏是方差最大的那一个。这就是为什么第 14、15 章要专门处理市场与打金——产出端唯一的「野变量」全在玩家对抗上。

连续型与脉冲型的分野,直接决定了它们在数值表里怎么存:连续型存成每小时速率(一个小数点都不该有,见下节),脉冲型存成一次性数量 + 触发条件。

连续型产出:建筑与地块

建筑产出是唯一可以用一条公式精确描述的源。config/buildings.json 给每栋建筑定义了基准产出与增长率,都用整数比表达:

output(L) = floor(outputBase × outputGrowthNum^(L-1) / outputGrowthDen^(L-1))

伐木场的 outputBase 是 60、outputGrowthNum/Den 是 122/100,所以 1 级每小时 60 木材,2 级是 floor(60 × 122 / 100) = 73,一路到 10 级 359。采石场、冶铁厂、农田同理,只是基准与增长率不同(农田 80 起、增长率 120/100;冶铁厂 18 起、增长率 124/100)。

地块产出在结构上与建筑产出同源——占领一块地,相当于获得一份固定速率——所以本节的脚本把两者合并统计,只在配置里分开标注 driver(谁是产出量的决定方)。

用 S 层脚本把速率表算出来

下面这份是 D 层配置,把七种产出源的特征写成可读的数据。它只描述「是什么」,不含任何计算:

{
  "version": 1,
  "note": "产出源盘点:连续型(地块/建筑)与脉冲型(任务/活动/掠夺/赠送/付费)。速率一律整数,百分比一律整数百分比。",
  "baseLevels": { "lumberyard": 5, "quarry": 5, "ironworks": 3, "farm": 6 },
  "sources": [
    { "id": "tile", "name": "地块产出", "kind": "continuous", "driver": "player", "control": "high", "variancePct": 0, "carrier": "wood/stone/iron/grain" },
    { "id": "building", "name": "建筑产出", "kind": "continuous", "driver": "player", "control": "high", "variancePct": 0, "carrier": "wood/stone/iron/grain" },
    { "id": "quest", "name": "任务与成就", "kind": "burst", "driver": "designer", "control": "high", "variancePct": 0, "carrier": "mixed" },
    { "id": "event", "name": "活动奖励", "kind": "burst", "driver": "ops", "control": "high", "variancePct": 20, "carrier": "mixed" },
    { "id": "plunder", "name": "掠夺", "kind": "burst", "driver": "opponent", "control": "low", "variancePct": 80, "carrier": "mixed" },
    { "id": "gift", "name": "系统赠送", "kind": "burst", "driver": "ops", "control": "high", "variancePct": 10, "carrier": "mixed" },
    { "id": "pay", "name": "付费获取", "kind": "burst", "driver": "monetization", "control": "high", "variancePct": 0, "carrier": "mixed" }
  ]
}

下面这段是 S 层仿真脚本:它只读 config/buildings.json 与这份配置,把四栋建筑十级的小时产出算成一张表,并汇总产出源清单。它不修改任何既有配置。

# file: sim/ch10_source_inventory.py
# tier: S
# verify: python3 sim/ch10_source_inventory.py --config config/ --out out/
"""产出源盘点:从 config/buildings.json 算出各建筑各等级的小时产出表,汇总产出源清单。

只读 buildings.json / resources.json(主控拥有,不修改);ch10_sources.json 描述地块、
任务、活动、掠夺、赠送、付费这些非建筑源的特征。
产出:out/ch10_building_output.csv + out/ch10_source_inventory.csv + out/ch10_source_inventory.json
"""

from __future__ import annotations
import argparse, csv, json, os


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 output_at_level(b, level):
    """每小时产出 floor(base × Num^(L-1) / Den^(L-1)):整数幂保证跨语言逐位一致。"""
    n = b["outputGrowthNum"] ** (level - 1)
    d = b["outputGrowthDen"] ** (level - 1)
    return (b["outputBase"] * n) // d


def building_table(buildings):
    return [{"building": b["id"], "resource": b["resource"], "level": lv,
             "outputPerHour": output_at_level(b, lv)}
            for b in buildings["buildings"] for lv in range(1, b["maxLevel"] + 1)]


def continuous_rate(buildings, levels):
    out = {}
    for b in buildings["buildings"]:
        lv = levels.get(b["id"], 0)
        if lv > 0:
            out[b["resource"]] = out.get(b["resource"], 0) + output_at_level(b, lv)
    return out


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)

    buildings = load(args.config, "buildings")
    resources = load(args.config, "resources")
    sources = load(args.config, "ch10_sources")
    rows = building_table(buildings)

    with open(os.path.join(args.out, "ch10_building_output.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["building", "resource", "level", "outputPerHour"])
        for r in rows:
            w.writerow([r["building"], r["resource"], r["level"], r["outputPerHour"]])

    keys = ("id", "name", "kind", "driver", "control", "variancePct")
    src = [{k: s[k] for k in keys} for s in sources["sources"]]
    with open(os.path.join(args.out, "ch10_source_inventory.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(list(keys))
        for s in src:
            w.writerow([s[k] for k in keys])

    control_hist = {}
    for s in src:
        control_hist[s["control"]] = control_hist.get(s["control"], 0) + 1
    report = {
        "version": 1,
        "resourceCount": len(resources["resources"]),
        "buildingCount": len(buildings["buildings"]),
        "levelRows": len(rows),
        "sourceCount": len(src),
        "continuousCount": sum(1 for s in src if s["kind"] == "continuous"),
        "burstCount": sum(1 for s in src if s["kind"] == "burst"),
        "controlHist": control_hist,
        "baseLevels": sources["baseLevels"],
        "continuousRatePerHour": continuous_rate(buildings, sources["baseLevels"]),
        "maxOutputPerHour": max(r["outputPerHour"] for r in rows),
        "minOutputPerHour": min(r["outputPerHour"] for r in rows),
    }
    with open(os.path.join(args.out, "ch10_source_inventory.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 0


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

在本机实跑(Python 3.14.6),得到:

== 40 行等级表;7 个产出源(连续 2 / 脉冲 5)
continuousRatePerHour: wood=132  stone=110  iron=27  grain=199
maxOutputPerHour: 412(农田 10 级)  minOutputPerHour: 18(冶铁厂 1 级)
controlHist: {"high": 6, "low": 1}

这份输出就是本节的核心产物。几点读法:

  • 连续速率是一张四元组:基础等级(伐木 5、采石 5、冶铁 3、农田 6)下,每小时产木材 132、石料 110、铁矿 27、粮草 199。它是后面所有资源曲线的起点。
  • 等级表的上下界:最小的产出是冶铁厂 1 级的 18/小时,最大是农田 10 级的 412/小时——铁矿作为高级资源,基准速率被刻意压低,这是第 9 章「资源分层」在产出端的体现。
  • 可控性直方图 {"high": 6, "low": 1}:一眼看出产出端的风险敞口——七种源里只有掠夺不可控。

脉冲型产出:任务、活动、掠夺、赠送、付费

脉冲型产出没有「每小时速率」,它的量纲是一次给多少。设计上要额外标注两件事:触发条件(什么时候给)和上限(一天最多给几次)。这三者的区别很大:

  • 任务与成就:量固定、一次性、跟着成长走,是新手期的主要补给。
  • 活动奖励:量可调、跟着版本排期走,是运营手里的调节阀。
  • 掠夺:量随对手波动、方差 80%,是唯一会「被别人拿走」的源——它既是产出也是对手的消耗,双向计入经济。
  • 系统赠送与付费获取:量由运营与商业化决定,前者是补偿手段,后者是变现入口(付费点的建模在卷二)。

把脉冲源直接换算成「平均每日贡献」是常见的偷懒做法,但它会抹掉方差。本节只登记它们的量纲与可控性,把方差留给第 12 章的闭环仿真去消化。

速率之外:上限、结算与口径

速率表算出来,还不能直接当资源曲线用,中间隔着两道口径:

  1. 上限截断。resources.json 给每种资源设了 cap(木材/石料/粮草 20 万、铁矿 12 万)。产出结算时是 stock = min(cap, stock + rate × hours)——一旦触顶,超出部分直接蒸发。所以「速率 × 时间」只是上限内的乐观估计。
  2. 结算粒度。速率是每小时量,结算是按 tick 累加。连续型源按小时结算,脉冲型源在触发时点一次性入账——两者不能混在同一个循环里,否则会出现「脉冲被当成速率重复发」的经典 bug。

这两条也是第 9 章账本模型要求「每笔产出都要有来源凭证」的原因:速率表负责「应该产多少」,账本负责「实际入账多少」,两者对不上就是配置或代码有 bug。

原型层:同一张表的两种消费

同一份 config/,仿真层用来算平衡,原型层用来跑可玩逻辑。下面这段是 P 层 TypeScript 原型里产出表的关键片段(完整文件是 proto/src/ch10_production.ts),它复用 core.ts 的 outputAtLevel,保证与 Python 侧走同一个整数幂公式:

// snippet: proto/src/ch10_production.ts(产出表节选,完整文件见工作区)
// 与 sim/ch10_source_inventory.py 同构:读同一份 config/buildings.json 产出等级表。
import { outputAtLevel, loadConfig, type BuildingDef } from "./core.ts";

export function productionTable() {
  const rows: { building: string; resource: string; level: number; outputPerHour: number }[] = [];
  for (const b of loadConfig<{ buildings: BuildingDef[] }>("buildings").buildings) {
    for (let lv = 1; lv <= b.maxLevel; lv++) {
      rows.push({ building: b.id, resource: b.resource, level: lv, outputPerHour: outputAtLevel(b, lv) });
    }
  }
  return rows;
}

断言同一张表在两种语言里逐位一致(完整断言见 proto/test/ch10.test.ts):

✔ 10.1 建筑小时产出表:4 建筑 × 10 级 = 40 行,关键点对齐
✔ 10.1 产出源汇总:7 源(2 连续 / 5 脉冲),基础等级连续速率

小结

产出源清单不是一张名词表,而是一组速率 + 可控性 + 方差的三元组。把七种源按这两类分完,产出端的设计空间就收敛了:连续型决定经济的水位,脉冲型决定经济的节拍,而唯一的低可控源(掠夺)留给风控章节专门处理。

下一节把「时间」这个变量引进来——同一份速率表,在线玩家和离线玩家拿到的资源并不一样,回归玩家又要不要补产出。

阅读导航:上一节:账本模型与守恒约束 · 下一节:产出与时间的关系 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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