11.2 税率、手续费与消耗弹性

摩擦成本怎么设计:税率太高玩家绕过市场、回收不足,太低又回收不够。本节用 S 层仿真把税率从 0% 扫到 20%,画出回收量、交易量、玩家净收益三条曲线,找到拉弗拐点,并用 TypeScript 原型与断言验证同一份配置两侧逐位一致,最后给出保守取值的建议。

11.2 税率、手续费与消耗弹性

站内有一篇《游戏经济系统运营 2026》 ,那篇是一份产业 checklist:列清楚要盯哪些指标、设哪些告警。本节不重复清单,只做它清单里的一条——摩擦成本——并把它变成可跑、可扫、可找拐点的模型。11.1 盘完消耗槽后,交易税被单列出来,因为它是六类槽里唯一的百分比型、可回收槽:它不创造成长,只在玩家与市场之间抽一道水。

摩擦成本的两难很直接。收得太高,玩家会绕开市场:私下赠送、走第三方、干脆不交易,系统一分钱抽不到,还把交易从官方市场逼走,让经济更不可观测。收得太低,市场热闹但系统回收不足,流通中的资源越积越多,通胀压不住。所以问题不是「收不收」,而是「收多少」——这需要一个能找出最优点的模型。

从交易量到回收量:拉弗曲线

把税率设为变量 t(0% 到 20%),建一个最小模型:

  • 交易量 volume(t):税率每升 1%,一部分交易绕开市场。设每 1% 税率让交易量衰减 6%,则 volume(t) = base × (100 - 6t) / 100,低于 0 时截断为 0。
  • 回收量 recovery(t) = volume(t) × 均价 × t / 100:这是系统真正抽走的量。
  • 玩家净收益 netBenefit(t) = volume(t) × 均价 × (100 - t) / 100:玩家成交后留在手里的价值。

关键在于 recovery(t) 是交易量(随税率下降)与税率(随税率上升)的乘积,因此它不是单调的——这就是拉弗曲线的形状:税率从 0 往上加,回收量先升后降,中间有一个拐点。拐点左侧加税回收增加,拐点右侧加税回收反而减少。

下面这段是 S 层仿真脚本,读 config/ch11_tax.json,把税率从 0% 扫到 20%,输出三条曲线的 CSV、拐点汇总 JSON 和一张三子图 SVG(回收量 / 交易量 / 玩家净收益各占一格,各自独立纵轴):

# file: sim/ch11_tax_elasticity.py
# tier: S
# verify: python3 sim/ch11_tax_elasticity.py --config config/ --out out/
"""交易税/手续费的消耗弹性:税率从 0% 扫到 20%,模拟交易量、回收量、玩家净收益三条曲线。
税率越高,一部分交易绕开市场(走赠送/私聊),交易量线性衰减;回收量 = 交易量 × 均价 × 税率,
呈拉弗曲线,存在拐点。拐点右侧再加税,回收反而下降。
产出 out/ch11_tax_elasticity.json / ch11_tax_curve.csv / ch11_tax_elasticity.svg"""
import argparse, csv, json, os, xml.etree.ElementTree as ET

PANELS = [("回收量(税收)", "recovery", "#1f6feb"), ("交易量", "volume", "#d97706"), ("玩家净收益", "netBenefit", "#059669")]

def load(d, n):
    with open(os.path.join(d, f"{n}.json"), encoding="utf-8") as f:
        return json.load(f)

def sweep(c):
    rows, t = [], c["taxPctLo"]
    while t <= c["taxPctHi"]:
        vol = max(0, c["baseTradesPerDay"] * (100 - c["avoidPctPerTaxPct"] * t) // 100)
        gross = vol * c["avgTradeValue"]
        rows.append({"taxPct": t, "volume": vol, "grossValue": gross,
                     "recovery": gross * t // 100, "netBenefit": gross * (100 - t) // 100,
                     "bypassPct": min(100, c["avoidPctPerTaxPct"] * t)})
        t += c["taxPctStep"]
    return rows

def draw(path, rows):
    W, H, pl, pr, pt, gap, ph = 720, 480, 72, 24, 24, 24, 120
    iw = W - pl - pr; n = max(len(rows) - 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"})
    for pi, (title, key, color) in enumerate(PANELS):
        top = pt + pi * (ph + gap); ys = [r[key] for r in rows]; lo, hi = min(ys), max(ys)
        ET.SubElement(svg, "text", {"x": str(pl), "y": str(top + 12), "font-size": "13"}).text = title
        ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(top), "x2": str(pl), "y2": str(top + ph), "stroke": "#cbd5e1"})
        ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(top + ph), "x2": str(W - pr), "y2": str(top + ph), "stroke": "#cbd5e1"})
        ET.SubElement(svg, "text", {"x": str(pl - 8), "y": str(top + 12), "font-size": "10", "text-anchor": "end", "fill": "#475569"}).text = str(hi)
        pts = " ".join(f"{pl + iw * j / n:.1f},{top + ph * (1 - (v - lo) / ((hi - lo) or 1)):.1f}" for j, v in enumerate(ys))
        ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": color, "stroke-width": "2"})
        k = max(range(len(ys)), key=lambda i: ys[i]); px = pl + iw * k / n
        ET.SubElement(svg, "text", {"x": f"{px:.0f}", "y": str(top - 2), "font-size": "10", "text-anchor": "middle", "fill": color}).text = f"{rows[k]['taxPct']}%"
    ET.SubElement(svg, "text", {"x": str(W - pr), "y": str(H - 6), "font-size": "11", "text-anchor": "end", "fill": "#475569"}).text = "横轴:税率 0% → 20%"
    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(); cfg = load(a.config, "ch11_tax"); rows = sweep(cfg)
    os.makedirs(a.out, exist_ok=True)
    with open(os.path.join(a.out, "ch11_tax_curve.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["taxPct", "volume", "grossValue", "recovery", "netBenefit", "bypassPct"])
        [w.writerow([r[k] for k in ("taxPct", "volume", "grossValue", "recovery", "netBenefit", "bypassPct")]) for r in rows]
    peak = max(rows, key=lambda r: r["recovery"]); ch = next(r for r in rows if r["taxPct"] == cfg["chosenTaxPct"])
    summary = {"version": 1, "taxRange": [cfg["taxPctLo"], cfg["taxPctHi"]], "kneeTaxPct": peak["taxPct"],
               "kneeRecovery": peak["recovery"], "chosenTaxPct": ch["taxPct"], "chosenRecovery": ch["recovery"],
               "chosenNetBenefit": ch["netBenefit"], "baselineGross": rows[0]["grossValue"], "chosenVolume": ch["volume"],
               "chosenBypassPct": ch["bypassPct"], "recoveryAtMaxTax": rows[-1]["recovery"],
               "recoveryDropFromKneePct": (peak["recovery"] - rows[-1]["recovery"]) * 100 // peak["recovery"]}
    with open(os.path.join(a.out, "ch11_tax_elasticity.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    draw(os.path.join(a.out, "ch11_tax_elasticity.svg"), rows)
    print(json.dumps(summary, ensure_ascii=False))

if __name__ == "__main__":
    main()

本机实跑(python3 sim/ch11_tax_elasticity.py --config config/ --out out/)得到的关键数字:拐点在 8%,此时回收量峰值 4992,交易量从基准 1000 降到 520,玩家净收益 57408,已有 48% 的交易额绕开了市场。取几个税率点看形状:

taxPctvolumegrossValuerecoverynetBenefitbypassPct
0%100012000001200000%
4%7609120036488755224%
8%5206240049925740848%
12%2803360040322956872%
17%0000100%

拐点右侧,税率从 8% 加到 12%,回收量从 4992 掉到 4032——加税反而少收,这是拉弗曲线的直接后果。到 17%,交易量归零,回收量也是 0,系统把一个本可以收税的活跃市场彻底逼没了。这就是 11.1 说的「摩擦成本设计的两难」的量化版本。

同一份配置的两处消费与断言

税率模型同样做成 P 层原型,与 Python 侧读同一份 config/ch11_tax.json。下面这段是 P 层脚本:

// file: proto/src/ch11_tax.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch11_tax_elasticity.py 同构:读同一份 ch11_tax.json,扫税率得到
// 交易量 / 回收量 / 玩家净收益三条曲线,整数口径,结果与 Python 侧逐位一致。

import { loadConfig } from "./core.ts";

export interface TaxConfig {
  taxPctLo: number; taxPctHi: number; taxPctStep: number;
  baseTradesPerDay: number; avgTradeValue: number;
  avoidPctPerTaxPct: number; chosenTaxPct: number;
}

export interface TaxPoint {
  taxPct: number; volume: number; grossValue: number;
  recovery: number; netBenefit: number; bypassPct: number;
}

export function loadTaxConfig(): TaxConfig {
  return loadConfig<TaxConfig>("ch11_tax");
}

export function sweep(cfg: TaxConfig = loadTaxConfig()): TaxPoint[] {
  const rows: TaxPoint[] = [];
  for (let t = cfg.taxPctLo; t <= cfg.taxPctHi; t += cfg.taxPctStep) {
    const volume = Math.max(0, Math.floor((cfg.baseTradesPerDay * (100 - cfg.avoidPctPerTaxPct * t)) / 100));
    const grossValue = volume * cfg.avgTradeValue;
    rows.push({
      taxPct: t, volume, grossValue,
      recovery: Math.floor((grossValue * t) / 100),
      netBenefit: Math.floor((grossValue * (100 - t)) / 100),
      bypassPct: Math.min(100, cfg.avoidPctPerTaxPct * t),
    });
  }
  return rows;
}

export function knee(rows: TaxPoint[] = sweep()): TaxPoint {
  return rows.reduce((best, r) => (r.recovery > best.recovery ? r : best), rows[0]);
}

配套断言在 proto/test/ch11.test.ts,把 11.1 的回本时间与 11.2 的拐点一起钉死:

// file: proto/test/ch11.test.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 第 11 章断言:养成回本时间与消耗槽清单(P 侧)、税率弹性曲线的拐点。
// 数值与 sim/ch11_sink_slots.py、sim/ch11_tax_elasticity.py 逐位一致。

import { test } from "node:test";
import assert from "node:assert/strict";
import { paybackRows, sinkInventory } from "../src/ch11_sinks.ts";
import { sweep, knee } from "../src/ch11_tax.ts";

test("11.1 伐木场回本时间随等级上升:15h → 22h", () => {
  const rows = paybackRows().filter((r) => r.building === "lumberyard");
  const first = rows[0], last = rows[rows.length - 1];
  assert.equal(first.costValue, 200);
  assert.equal(first.outputPerHour, 73);
  assert.equal(first.marginalPaybackHours, 15);
  assert.equal(first.cumulativePaybackHours, 2);
  assert.equal(last.costValue, 1440);
  assert.equal(last.marginalPaybackHours, 22);
  assert.equal(last.cumulativeCostValue, 5865);
  assert.equal(last.cumulativePaybackHours, 16);
});

test("11.1 冶铁厂因产出资源价值更高,回本时间更长", () => {
  const last = paybackRows().filter((r) => r.building === "ironworks").pop()!;
  assert.equal(last.costValue, 2764);
  assert.equal(last.marginalPaybackHours, 38);
  assert.equal(last.cumulativePaybackHours, 28);
});

test("11.1 回本时间整体随等级上升(整数地板除允许 ±2 抖动)", () => {
  for (const b of ["lumberyard", "quarry", "ironworks", "farm"]) {
    const vals = paybackRows().filter((r) => r.building === b).map((r) => r.marginalPaybackHours);
    assert.ok(vals[vals.length - 1] > vals[0], `${b} 末级回本时间未高于首级`);
    for (let i = 1; i < vals.length; i++) assert.ok(vals[i] >= vals[i - 1] - 2, `${b} 第 ${i} 级回落超过 2`);
  }
});

test("11.1 消耗槽清单:训练是大头,合计单日价值 91828", () => {
  const inv = sinkInventory();
  assert.equal(inv["building-upgrade"], 2100);
  assert.equal(inv["tech-research"], 4648);
  assert.equal(inv["troop-training"], 69000);
  assert.equal(inv["march-supply"], 480);
  assert.equal(inv["healing"], 6000);
  assert.equal(inv["trade-tax"], 9600);
  assert.equal(Object.values(inv).reduce((s, x) => s + x, 0), 91828);
});

test("11.2 税率扫 0%–20% 共 21 个点,0% 时零回收", () => {
  const rows = sweep();
  assert.equal(rows.length, 21);
  assert.equal(rows[0].taxPct, 0);
  assert.equal(rows[0].recovery, 0);
  assert.equal(rows[0].volume, 1000);
});

test("11.2 拉弗拐点在 8%:回收量峰值 4992", () => {
  const k = knee();
  assert.equal(k.taxPct, 8);
  assert.equal(k.recovery, 4992);
  assert.equal(k.volume, 520);
  assert.equal(k.netBenefit, 57408);
  assert.equal(k.bypassPct, 48);
});

test("11.2 拐点右侧再加税回收反降;17% 起全部绕过市场", () => {
  const rows = sweep();
  assert.ok(rows.find((r) => r.taxPct === 12)!.recovery < rows.find((r) => r.taxPct === 8)!.recovery);
  const at17 = rows.find((r) => r.taxPct === 17)!;
  assert.equal(at17.volume, 0);
  assert.equal(at17.recovery, 0);
  assert.equal(at17.bypassPct, 100);
});

本机实跑 node --test proto/test/ch11.test.ts:7 条断言全通过。这条门禁的价值在于:如果哪天有人把税率模型的整数口径改成浮点,P 侧与 S 侧的数字会立刻对不上,断言就会红——「一份配置两处消费」不是口号,是一条会失败的测试。

设计建议:把税率设在拐点左侧

拐点给出了一个清晰的操作建议:税率不应超过拐点,且要留一段安全边际。原因是模型的参数(avoidPctPerTaxPct,每 1% 税率逼走多少交易)是估计值,真实玩家对税率的敏感度会随服务器阶段变化。把税率设在拐点右侧,一旦实际敏感度比估计更高,回收量会加速下滑;设在拐点左侧,即便估计偏乐观,也还有缓冲。

《王朝纪元》里,交易税的默认值取 8%,恰好是模型的拐点。这是一个刻意的保守选择:宁可少收一点,也不把交易逼出市场。如果运营发现交易活跃度远高于预期,可以小幅试探到 9%–10% 并观察回收量是否仍上升;一旦出现「税率涨、回收跌」,就说明越过了拐点,应立即回退。这套「扫一遍、找拐点、留边际」的做法,和 14.3 讲的套利、工作室与风控边界 是同一个思路:任何百分比型的规则都要先跑出曲线再定值,而不是拍脑袋。

本节小结

这一节把摩擦成本讲清了:

  1. 两难:税率太高玩家绕过市场、回收不足;太低则回收不够、通胀压不住。
  2. 拉弗曲线:回收量是交易量与税率的乘积,因此存在拐点;本机模型拐点在 8%。
  3. 保守取值:默认税率设在拐点,并留安全边际;出现「税率涨、回收跌」就回退。

下一节把 11.1 的养成消耗与 11.2 的市场回收汇总成一条 N 天消耗曲线,并给一个不确定性基准,量化「活动抖动」对总消耗的影响。

阅读导航:上一节:11.1 消耗槽与养成消耗 · 下一节:11.3 消耗曲线仿真 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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