前面十三节我们一直在讲系统和系统之间的资源流动:产出端给多少、消耗端吃多少、赛季重置回收多少。但 SLG 里还有一条主线,是玩家和玩家之间的资源流动——一个玩家多余的粮草,换成另一个玩家手里的铁矿。这条线一旦打开,经济就不再是一个封闭的池子,而是有了一个内部的价格。这一章讲的就是这条线。
动手前先划清边界。站内的经济平衡长文 给了一套通用的经济平衡框架,但它举例用的是放置类:离线收益、线性挂机、指数成长,玩家之间几乎不发生交换。那套框架里没有「价格」这个概念,因为放置类不需要它。SLG 不一样:大地图上有大量同质化的资源,玩家又有差异化的需求,于是交换必然发生,价格必然出现。本章换用 SLG 大地图地块口径,专门处理这个「玩家之间」的问题。
站内的 SLG 技术架构专题 讲的是前后端分层与协议设计,明确不碰玩法数值。市场恰恰是协议与数值咬合最紧的地方——撮合规则既是服务器的一段代码,也是一个会改变经济分配结果的数值决策。这一节先把形态讲清楚,定价与流动性留到 14.2,套利与工作室留到 14.3。
玩家之间为什么要交换
在单机或纯 PVE 的 SLG 里,资源是「打出来的」,玩家和系统之间是单向关系。但 SLG 一旦进入赛季制大地图,玩家之间的资源禀赋就会分化:
- 地缘差异:出生在粮区的人粮草溢出,出生在矿区的人铁矿溢出;
- 阶段差异:早期玩家屯了一堆用不上的高级材料,后期玩家正缺这些材料;
- 玩法差异:种田党产出的资源远多于自己消耗,战斗党消耗远大于产出。
只要有差异,就有交换的动力;只要有交换,就需要一个市场来撮合。而市场长成什么样,直接决定了这个经济体的价格透明度、流动性和被套利的难易程度。
三种形态:拍卖行 / 摆摊 / 交易所
市面上的 SLG 大致用三种市场形态,它们不是「好和坏」,而是三种不同的取舍。
拍卖行是集中撮合:所有挂单进同一个池子,系统按规则统一撮合,成交价对所有玩家可见。它的优点是价格透明——玩家一眼就能看到「现在粮草值多少」,系统也容易拿到一个统一的参考价来指导经济。代价是实现成本高:你需要一个真正的撮合引擎、一个订单簿、一套防刷与防自成交的风控。
摆摊是分散撮合:玩家在自己的地块或摊位上挂出商品,别人走过来才能看到、才能买。它的优点是实现极简——没有中央撮合,交易就是两个玩家之间的一次直接转账,服务器压力小。代价是它天然带位置成本:好位置(主城门口、交通要道)的摊位费天然更高,价格也不透明,同一个东西在不同摊位能卖出完全不同的价。
交易所介于两者之间,本质是挂单撮合:玩家挂出「买/卖」的限价单,进入一个共享的订单簿,系统按价格优先、时间优先撮合,允许部分成交。它比拍卖行更接近真实金融市场,有深度(同一价位可以堆积大量挂单),但也意味着你要处理部分成交、撤单、订单簿膨胀这些工程问题。
三种形态最核心的差别,其实可以浓缩成一个问题:当你手里有一批买单和一批卖单,你按什么规则决定谁和谁成交、以什么价格成交? 下面这份配置就是同一批订单,用来让三种规则跑在同一份输入上。
{
"version": 1,
"note": "同一批买卖订单,用三种撮合规则(先到先得/价格优先/价格时间优先)跑出成交价与成交量。字段均为整数,保证 Python 与 TypeScript 逐位一致。",
"referencePrice": 104,
"orders": [
{ "id": "B1", "side": "buy", "price": 112, "qty": 50, "seq": 6 },
{ "id": "B2", "side": "buy", "price": 112, "qty": 40, "seq": 1 },
{ "id": "B3", "side": "buy", "price": 105, "qty": 80, "seq": 3 },
{ "id": "B4", "side": "buy", "price": 100, "qty": 120, "seq": 8 },
{ "id": "B5", "side": "buy", "price": 110, "qty": 60, "seq": 10 },
{ "id": "S1", "side": "sell", "price": 99, "qty": 40, "seq": 5 },
{ "id": "S2", "side": "sell", "price": 99, "qty": 50, "seq": 2 },
{ "id": "S3", "side": "sell", "price": 106, "qty": 70, "seq": 4 },
{ "id": "S4", "side": "sell", "price": 111, "qty": 40, "seq": 7 },
{ "id": "S5", "side": "sell", "price": 104, "qty": 90, "seq": 9 }
]
}
注意这批订单里故意埋了两处同价竞争:买单 B1 和 B2 都挂 112,卖单 S1 和 S2 都挂 99。这两处会让「价格优先」和「价格时间优先」产生分歧——因为它们的同价拆解规则不同。
同一批订单,三种撮合规则
下面这段是 S 层仿真脚本,读 config/ 里的这批订单,用三种规则各撮合一遍,输出成交明细与汇总。规则定义如下:
- 先到先得(fifo):完全按到达顺序
seq处理,新单与对面最早的挂单撮合,价格不参与排序; - 价格优先(price):两侧先按价格排序(买高卖低),同价按配置数组顺序;
- 价格时间优先(price_time):先按价格排序,同价再按到达顺序
seq。
三种规则下,成交价一律取「先到的一方」(seq 较小者)的挂价,也就是做市/挂单方的价格。
# file: sim/ch14_market_forms.py
# tier: S
# verify: python3 sim/ch14_market_forms.py --config config/ --out out/
"""市场形态:对同一批买卖订单,用三种撮合规则跑出成交价与成交量。
fifo=先到先得(完全按 seq);price=价格优先(同价按配置数组顺序);
price_time=价格时间优先(同价按 seq)。成交价一律取先到(seq 较小)一方挂价。
产出 out/ch14_market.json + out/ch14_market_trades.csv。
"""
from __future__ import annotations
import argparse, csv, json, os
def load(cfg_dir):
with open(os.path.join(cfg_dir, "ch14_market.json"), encoding="utf-8") as f:
cfg = json.load(f)
return cfg["referencePrice"], cfg["orders"]
def tprice(b, s):
return b["price"] if b["seq"] <= s["seq"] else s["price"]
def match_arrival(orders):
"""先到先得:按 seq 处理,新单与对面最早的挂单撮合。"""
buys, sells, trades = [], [], []
for o in sorted(orders, key=lambda x: x["seq"]):
rest = o["qty"]
if o["side"] == "buy":
while rest > 0 and sells and o["price"] >= sells[0]["price"]:
s = sells[0]; q = min(rest, s["qty"])
trades.append({"buyId": o["id"], "sellId": s["id"], "price": tprice(o, s), "qty": q})
rest -= q; s["qty"] -= q
if s["qty"] == 0: sells.pop(0)
if rest > 0: buys.append({**o, "qty": rest})
else:
while rest > 0 and buys and buys[0]["price"] >= o["price"]:
b = buys[0]; q = min(rest, b["qty"])
trades.append({"buyId": b["id"], "sellId": o["id"], "price": tprice(b, o), "qty": q})
rest -= q; b["qty"] -= q
if b["qty"] == 0: buys.pop(0)
if rest > 0: sells.append({**o, "qty": rest})
return trades
def match_book(orders, kb, ks):
"""挂单簿撮合:两侧按给定键排序,反复取最优买/卖对撮合到不过价。"""
buys = sorted(({**o} for o in orders if o["side"] == "buy"), key=kb)
sells = sorted(({**o} for o in orders if o["side"] == "sell"), key=ks)
trades, i, j = [], 0, 0
while i < len(buys) and j < len(sells):
b, s = buys[i], sells[j]
if b["price"] < s["price"]: break
q = min(b["qty"], s["qty"])
trades.append({"buyId": b["id"], "sellId": s["id"], "price": tprice(b, s), "qty": q})
b["qty"] -= q; s["qty"] -= q
if b["qty"] == 0: i += 1
if s["qty"] == 0: j += 1
return trades
def summarize(rule, trades):
vol = sum(t["qty"] for t in trades)
notional = sum(t["price"] * t["qty"] for t in trades)
px = sorted({t["price"] for t in trades})
return {"rule": rule, "tradeCount": len(trades), "volume": vol, "notional": notional,
"vwap": notional // vol if vol else 0, "minPrice": px[0] if px else 0,
"maxPrice": px[-1] if px else 0}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
ref, orders = load(a.config)
idx = {o["id"]: k for k, o in enumerate(orders)}
runs = {
"fifo": match_arrival(orders),
"price": match_book(orders, lambda o: (-o["price"], idx[o["id"]]), lambda o: (o["price"], idx[o["id"]])),
"price_time": match_book(orders, lambda o: (-o["price"], o["seq"]), lambda o: (o["price"], o["seq"])),
}
report = {"version": 1, "referencePrice": ref,
"rules": {k: summarize(k, v) for k, v in runs.items()}, "trades": runs}
os.makedirs(a.out, exist_ok=True)
with open(os.path.join(a.out, "ch14_market.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(a.out, "ch14_market_trades.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f); w.writerow(["rule", "buyId", "sellId", "price", "qty"])
for rule, ts in runs.items():
for t in ts: w.writerow([rule, t["buyId"], t["sellId"], t["price"], t["qty"]])
for k in ("fifo", "price", "price_time"):
r = report["rules"][k]
print(f"{k:11s} trades={r['tradeCount']} volume={r['volume']} vwap={r['vwap']} range=[{r['minPrice']},{r['maxPrice']}]")
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它(同机相对量级,绝对数值不可移植):
fifo trades=6 volume=190 vwap=106 range=[99,112]
price trades=5 volume=180 vwap=104 range=[99,112]
price_time trades=5 volume=180 vwap=104 range=[99,112]
三行结果的差别,正是三种形态的差别。先到先得成交了 190、均价 106,而价格优先和价格时间优先都只成交了 180、均价 104。先到先得多出来那 10 个单位,来自它把一批「价格并不最优」的订单也撮合了——因为它根本不看价格,只看谁先到。这就是摆摊式市场的典型后果:它保证「先来后到」的公平,却牺牲了价格效率。
而价格优先与价格时间优先的总量相同、均价相同,差别只在谁先成交:价格优先让数组里更靠前的 B1 先吃到卖单,价格时间优先让到达更早的 B2 先吃到。换句话说,在同一价格档位上,时间戳决定了谁被满足——这正是「价格时间优先」存在的意义。
P 层:TypeScript 侧再撮合一遍
撮合规则不该只活在 Python 里。下面这段是 P 层原型,用同一份 config/ch14_market.json 重算一遍——如果两边算出的成交量或均价不一致,说明「唯一真源」名不副实。
// file: proto/src/ch14_market.ts
// tier: P
// verify: node --test 'proto/**/*.test.ts'
// 与 sim/ch14_market_forms.py 同构:读同一份 config/ch14_market.json,三种撮合规则逐位一致。
// 全程整数运算,成交价取 seq 较小(先到)一方挂价。
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const HERE = dirname(fileURLToPath(import.meta.url));
const CONFIG_DIR = join(HERE, "..", "..", "config");
export interface Order { id: string; side: "buy" | "sell"; price: number; qty: number; seq: number }
export interface Trade { buyId: string; sellId: string; price: number; qty: number }
export interface MarketConfig { referencePrice: number; orders: Order[] }
export function loadMarketConfig(): MarketConfig {
return JSON.parse(readFileSync(join(CONFIG_DIR, "ch14_market.json"), "utf-8")) as MarketConfig;
}
const tprice = (b: Order, s: Order): number => (b.seq <= s.seq ? b.price : s.price);
export function matchArrival(orders: Order[]): Trade[] {
const buys: Order[] = [], sells: Order[] = [], trades: Trade[] = [];
for (const o of [...orders].sort((x, y) => x.seq - y.seq)) {
if (o.side === "buy") {
let rest = o.qty;
while (rest > 0 && sells.length && o.price >= sells[0].price) {
const s = sells[0];
const q = Math.min(rest, s.qty);
trades.push({ buyId: o.id, sellId: s.id, price: tprice(o, s), qty: q });
rest -= q; s.qty -= q;
if (s.qty === 0) sells.shift();
}
if (rest > 0) buys.push({ ...o, qty: rest });
} else {
let rest = o.qty;
while (rest > 0 && buys.length && buys[0].price >= o.price) {
const b = buys[0];
const q = Math.min(rest, b.qty);
trades.push({ buyId: b.id, sellId: o.id, price: tprice(b, o), qty: q });
rest -= q; b.qty -= q;
if (b.qty === 0) buys.shift();
}
if (rest > 0) sells.push({ ...o, qty: rest });
}
}
return trades;
}
export function matchBook(orders: Order[], kb: (o: Order) => number[], ks: (o: Order) => number[]): Trade[] {
const cmp = (a: number[], b: number[]): number => {
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] - b[i];
return 0;
};
const buys = orders.filter((o) => o.side === "buy").map((o) => ({ ...o })).sort((a, b) => cmp(kb(a), kb(b)));
const sells = orders.filter((o) => o.side === "sell").map((o) => ({ ...o })).sort((a, b) => cmp(ks(a), ks(b)));
const trades: Trade[] = [];
let i = 0, j = 0;
while (i < buys.length && j < sells.length) {
const b = buys[i], s = sells[j];
if (b.price < s.price) break;
const q = Math.min(b.qty, s.qty);
trades.push({ buyId: b.id, sellId: s.id, price: tprice(b, s), qty: q });
b.qty -= q; s.qty -= q;
if (b.qty === 0) i++;
if (s.qty === 0) j++;
}
return trades;
}
export interface RuleSummary {
rule: string; tradeCount: number; volume: number; notional: number; vwap: number; minPrice: number; maxPrice: number;
}
export function summarize(rule: string, trades: Trade[]): RuleSummary {
let volume = 0, notional = 0;
const prices = new Set<number>();
for (const t of trades) { volume += t.qty; notional += t.price * t.qty; prices.add(t.price); }
const sorted = [...prices].sort((a, b) => a - b);
return { rule, tradeCount: trades.length, volume, notional, vwap: volume ? Math.floor(notional / volume) : 0,
minPrice: sorted.length ? sorted[0] : 0, maxPrice: sorted.length ? sorted[sorted.length - 1] : 0 };
}
export function marketReport() {
const cfg = loadMarketConfig();
const arrIdx: Record<string, number> = {};
cfg.orders.forEach((o, k) => { arrIdx[o.id] = k; });
const fifo = matchArrival(cfg.orders);
const price = matchBook(cfg.orders, (o) => [-o.price, arrIdx[o.id]], (o) => [o.price, arrIdx[o.id]]);
const priceTime = matchBook(cfg.orders, (o) => [-o.price, o.seq], (o) => [o.price, o.seq]);
return { referencePrice: cfg.referencePrice,
rules: { fifo: summarize("fifo", fifo), price: summarize("price", price), price_time: summarize("price_time", priceTime) },
trades: { fifo, price, price_time: priceTime } };
}
对应的断言写进 proto/test/ch14.test.ts,跑 node --test proto/test/ch14.test.ts,本节 5 条全过:fifo 成交量 190 / 均价 106,price 与 price_time 均为 180 / 104,且两者「成交量相同但配对顺序不同」。Python 与 TypeScript 对同一份配置算出逐位一致的结果,这就是「一份配置、两处消费」。
形态选择决定经济形态
把三种形态对经济的影响与实现代价并列,一张表就能看清取舍:
| 形态 | 价格透明 | 深度 | 位置成本 | 实现代价 | 对经济的主要影响 |
|---|---|---|---|---|---|
| 拍卖行 | 高(统一撮合价) | 中 | 无 | 高(撮合引擎+风控) | 价格收敛快,易形成全服参考价 |
| 摆摊 | 低(一摊一价) | 无 | 有(摊位费/地价) | 低(点对点转账) | 价格离散,价差大,套利空间天然存在 |
| 交易所 | 高(订单簿公开) | 高 | 无 | 最高(部分成交/撤单/簿维护) | 深度最好,但订单簿膨胀需治理 |
一个常见的错误是「哪个先进上哪个」。真实决策应该反过来问:你希望这个经济体的价格是「收敛」还是「离散」? 想让玩家有稳定的参考价、想让系统能用一个统一价去指导产出与回收,就上拍卖行或交易所;想让价格保持离散、让地头蛇有位置优势、想少写代码,就上摆摊。形态不是技术选型,是经济设计。
本节只回答了「用哪种形态、按什么规则撮合」。但撮合完之后,价格会停在哪个水平、价差会不会收窄、流动性够不够,是下一节的问题。14.2 给定价与流动性,14.3 给套利与风控边界。
阅读导航:上一节:赛季重置与货币回收 · 下一节:定价机制与流动性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。