站内《上线变现与长线运营》 写的是国内上线与长线运营,站内 SaaS 平台创业口径的小游戏专题 (25 篇)讲的是做小游戏平台本身。本卷两者都不重做——卷五只做跨平台发行与出海,本章只做多平台仿真与验收。上一节16.1 出海发行方案装配 把 16 章装订成册;本节把册子里最容易被糊弄的那张账——五个平台的毛收入、分成、预扣与净结算——真跑一遍。
验收要回答的两个问题
- 净额怎么从毛收入算出来。平台扣点常分两段:先按分成率抽成,再对分成后金额预扣。两段各自地板除,还是先合成一个费率再一次地板除,结果并不相等——整数金额不满足分配律。
- 结论在扰动下还站不站得住。平台政策、汇率、分成谈判都会让输入在 ±15% 内浮动;若验收线附近的结论一扰动就翻,那它不是结论,是巧合。
S 层:多平台全链路台账仿真
下面这段是 S 层仿真脚本,读 config/g16_fullchain.json(五个平台的毛收入 / 分成 / 预扣 / 到账周期)与共享 config/economy.json(留存曲线与满级产出),逐平台按「先分成后预扣、各自整数地板除」算净额并累加,再对八个科目同时给「直接路径」与「反算路径」两组数、比残差与容差。全整数、无浮点。
#!/usr/bin/env python3
# file: sim/g16_fullchain.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g16_fullchain.py --config config/ --out out/
"""多平台出海全链路仿真与对账(S 层)。读 config/g16_fullchain.json 与共享 config/economy.json,
逐平台「先分成后预扣」整数地板除算净额并累加;给三种聚合口径顺序暴露整数金额的顺序敏感性;
同科目给直接 / 反算双路径残差与容差。产出 JSON + SVG。全整数无浮点。真实平台收入 /
分成 / 预扣需真实业务环境,命令行不可复现(X)。"""
from __future__ import annotations
import argparse, json, os
import xml.etree.ElementTree as ET
def load(d, name):
with open(os.path.join(d, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def net_seq(g, s, w):
"""先分成后预扣,各自整数地板除。"""
return (g * (1000 - s) // 1000) * (1000 - w) // 1000
def comb(s, w):
"""合成费率:share + withhold - share*withhold/1000。"""
return s + w - s * w // 1000
def build(cfg, econ):
ps = cfg["platforms"]
gross = sum(p["grossFen"] for p in ps)
nets = [net_seq(p["grossFen"], p["sharePermille"], p["withholdPermille"]) for p in ps]
net = sum(nets)
take = (gross - net) * 1000 // gross
md, dau = cfg["monthDays"], cfg["dau"]
arpdau = net * 10 // (dau * md)
a_share = sum(p["grossFen"] * p["sharePermille"] for p in ps) // gross
a_wh = sum(p["grossFen"] * p["withholdPermille"] for p in ps) // gross
orders = {"orderA": net,
"orderB": sum(p["grossFen"] * (1000 - comb(p["sharePermille"], p["withholdPermille"])) // 1000 for p in ps),
"orderC": gross * (1000 - comb(a_share, a_wh)) // 1000}
decay, hor = econ["retention"]["decayPctPerDay"], econ["retention"]["horizonDays"]
curve = [1000 * decay ** d // 100 ** d for d in range(hor)]
cum = sum(curve)
e = econ["economy"]
maxo = e["baseOutputPerMinute"] * e["levelBonusNum"] ** (e["maxLevel"] - 1) // e["levelBonusDen"] ** (e["maxLevel"] - 1)
ncyc = sum(n * p["payoutCycleDays"] for n, p in zip(nets, ps)) // net
gcyc = sum(p["grossFen"] * p["payoutCycleDays"] for p in ps) // gross
ltv_a = arpdau * cum // 1000
ltv_b = sum(arpdau * curve[d] // 1000 for d in range(hor))
accts = [
("monthlyGross", "组合月毛收入(分)", gross, sum(p["grossFen"] for p in ps), 0),
("monthlyNet", "组合月净结算(分)", net, gross * (1000 - take) // 1000, gross // 1000),
("takePermille", "综合扣点(‰)", take, 1000 - net * 1000 // gross, 1),
("netArpdau", "单位净 ARPDAU(0.1分)", arpdau, sum(n * 10 // (dau * md) for n in nets), len(ps)),
("effShare", "加权分成(‰)", a_share, 1000 - sum(p["grossFen"] * (1000 - p["sharePermille"]) for p in ps) // gross, 1),
("effWithhold", "加权预扣(‰)", a_wh, 1000 - sum(p["grossFen"] * (1000 - p["withholdPermille"]) for p in ps) // gross, 1),
("ltv30d", "组合 LTV(0.1分)", ltv_a, ltv_b, hor),
("payoutCycle", "加权到账周期(天)", ncyc, gcyc, 3),
]
rows = [dict(id=i, name=n, direct=dd, reverse=rv, tolerance=t, residual=dd - rv, ok=abs(dd - rv) <= t)
for i, n, dd, rv, t in accts]
return dict(rows=rows, gross=gross, net=net, take=take, arpdau=arpdau, cum=cum, maxOut=maxo,
orders=orders, distinct=len(set(orders.values())),
platforms=[dict(p, netFen=n) for p, n in zip(ps, nets)], ok=all(r["ok"] for r in rows))
def svg(path, res):
o = res["orders"]
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": "640", "height": "60"})
ET.SubElement(root, "text", {"x": "12", "y": "34", "font-size": "13", "fill": "#111827"}).text = \
f'组合净额三口径 A/B/C = {o["orderA"]}/{o["orderB"]}/{o["orderC"]}(不同结果 {res["distinct"]} 种)'
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg, econ = load(a.config, "g16_fullchain"), load(a.config, "economy")
res = build(cfg, econ)
accts = ",".join(f'{r["id"]}:{r["direct"]}:{r["reverse"]}:{r["residual"]}' for r in res["rows"])
digest = (f'gross={res["gross"]},net={res["net"]},take={res["take"]},arpdau={res["arpdau"]},'
f'cum={res["cum"]},maxOut={res["maxOut"]};orders='
f'{res["orders"]["orderA"]},{res["orders"]["orderB"]},{res["orders"]["orderC"]},'
f'distinct={res["distinct"]};accts={accts};ok={str(res["ok"]).lower()}')
os.makedirs(a.out, exist_ok=True)
svgb = svg(os.path.join(a.out, "g16_fullchain.svg"), res)
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"], "monthDays": cfg["monthDays"], "dau": cfg["dau"],
"netArpdauThresholdDeciFen": cfg["netArpdauThresholdDeciFen"], "platforms": res["platforms"], "grossFen": res["gross"],
"netFen": res["net"], "takePermille": res["take"], "netArpdauDeciFen": res["arpdau"], "accept": res["arpdau"] >= cfg["netArpdauThresholdDeciFen"],
"cumActivePermille": res["cum"], "maxLevelOutput": res["maxOut"], "aggregationOrders": res["orders"], "distinctOrderResults": res["distinct"],
"accounts": res["rows"], "failed": [r["id"] for r in res["rows"] if not r["ok"]], "ok": res["ok"], "digest": digest, "svgBytes": svgb}
with open(os.path.join(a.out, "g16_fullchain.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "threshold": cfg["netArpdauThresholdDeciFen"], "accept": res["arpdau"] >= cfg["netArpdauThresholdDeciFen"],
"distinctOrderResults": res["distinct"], "failed": rep["failed"], "ok": res["ok"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
真实运行(/opt/homebrew/bin/python3 sim/g16_fullchain.py --config config/ --out out/):
{
"digest": "gross=18900000,net=13175750,take=302,arpdau=439,cum=11463,maxOut=170;orders=13175750,13174500,13173300,distinct=3;accts=monthlyGross:18900000:18900000:0,monthlyNet:13175750:13192200:-16450,takePermille:302:303:-1,netArpdau:439:438:1,effShare:298:299:-1,effWithhold:6:7:-1,ltv30d:5032:5021:11,payoutCycle:36:36:0;ok=true",
"threshold": 400,
"accept": true,
"distinctOrderResults": 3,
"failed": [],
"ok": true
}
P 层:跨语言对账内核
下面这段是 P 层 TS 内核,是上面 Python 脚本的逐位等价对偶实现:同一份 config/g16_fullchain.json、同一个共享 config/economy.json,全整数、地板除、幂用 BigInt、无浮点、无 Math.random。它导出 digest(),把整条台账压成一枚字符串,与 Python 的 digest 逐字符对拍。
// file: core/src/g16/fullchain.ts
// tier: P
// verify: node --test 'core/test/g16_*.test.ts'
// 多平台出海全链路对账内核:与 sim/g16_fullchain.py 逐位等价的 TS 对偶实现。读同一份 config/g16_fullchain.json
// 与共享 config/economy.json;全整数、地板除、幂用 BigInt、无浮点、无 Math.random。只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface Platform { readonly id: string; readonly label: string; readonly grossFen: number; readonly sharePermille: number; readonly withholdPermille: number; readonly payoutCycleDays: number; }
export interface FullchainConfig { readonly version: number; readonly game: string; readonly monthDays: number; readonly dau: number; readonly netArpdauThresholdDeciFen: number; readonly platforms: readonly Platform[]; }
export interface Economy { readonly economy: { readonly baseOutputPerMinute: number; readonly levelBonusNum: number; readonly levelBonusDen: number; readonly maxLevel: number }; readonly retention: { readonly decayPctPerDay: number; readonly horizonDays: number }; }
export interface Account { readonly id: string; readonly name: string; readonly direct: number; readonly reverse: number; readonly tolerance: number; readonly residual: number; readonly ok: boolean; }
export interface Ledger { readonly gross: number; readonly net: number; readonly takePermille: number; readonly netArpdau: number; readonly threshold: number; readonly cumActive: number; readonly maxLevelOutput: number; readonly orders: { readonly a: number; readonly b: number; readonly c: number }; readonly distinct: number; readonly accounts: readonly Account[]; readonly ok: boolean; }
/** 先分成后预扣,各自整数地板除。 */
export function netSequential(g: number, s: number, w: number): number {
return Math.floor((Math.floor((g * (1000 - s)) / 1000) * (1000 - w)) / 1000);
}
/** 合成费率:share + withhold - share*withhold/1000(整数地板除)。 */
export function combinedRate(s: number, w: number): number {
return s + w - Math.floor((s * w) / 1000);
}
/** 满级产出:base*(num/den)^(maxLevel-1),幂用 BigInt、地板除。 */
export function maxLevelOutput(base: number, num: number, den: number, maxLevel: number): number {
return Number((BigInt(base) * BigInt(num) ** BigInt(maxLevel - 1)) / BigInt(den) ** BigInt(maxLevel - 1));
}
/** 留存曲线:r(d) = 1000*decay^d // 100^d。 */
export function retentionCurve(decayPct: number, days: number): number[] {
const out: number[] = [];
for (let d = 0; d < days; d += 1) out.push(Math.floor((1000 * decayPct ** d) / 100 ** d));
return out;
}
export function buildLedger(cfg: FullchainConfig, econ: Economy): Ledger {
const ps = cfg.platforms, md = cfg.monthDays, dau = cfg.dau;
const gross = ps.reduce((s, p) => s + p.grossFen, 0);
const nets = ps.map((p) => netSequential(p.grossFen, p.sharePermille, p.withholdPermille));
const net = nets.reduce((s, n) => s + n, 0);
const take = Math.floor(((gross - net) * 1000) / gross);
const arpdau = Math.floor((net * 10) / (dau * md));
const aShare = Math.floor(ps.reduce((s, p) => s + p.grossFen * p.sharePermille, 0) / gross);
const aWh = Math.floor(ps.reduce((s, p) => s + p.grossFen * p.withholdPermille, 0) / gross);
const orders = {
a: net,
b: ps.reduce((s, p) => s + Math.floor((p.grossFen * (1000 - combinedRate(p.sharePermille, p.withholdPermille))) / 1000), 0),
c: Math.floor((gross * (1000 - combinedRate(aShare, aWh))) / 1000),
};
const distinct = new Set([orders.a, orders.b, orders.c]).size;
const curve = retentionCurve(econ.retention.decayPctPerDay, econ.retention.horizonDays);
const cumActive = curve.reduce((s, r) => s + r, 0);
const e = econ.economy;
const ncyc = Math.floor(ps.reduce((s, p, i) => s + nets[i] * p.payoutCycleDays, 0) / net);
const gcyc = Math.floor(ps.reduce((s, p) => s + p.grossFen * p.payoutCycleDays, 0) / gross);
const ltvA = Math.floor((arpdau * cumActive) / 1000);
const ltvB = curve.reduce((s, r) => s + Math.floor((arpdau * r) / 1000), 0);
const raw = [
{ id: "monthlyGross", name: "组合月毛收入(分)", direct: gross, reverse: ps.reduce((s, p) => s + p.grossFen, 0), tolerance: 0 },
{ id: "monthlyNet", name: "组合月净结算(分)", direct: net, reverse: Math.floor((gross * (1000 - take)) / 1000), tolerance: Math.floor(gross / 1000) },
{ id: "takePermille", name: "综合扣点(‰)", direct: take, reverse: 1000 - Math.floor((net * 1000) / gross), tolerance: 1 },
{ id: "netArpdau", name: "单位净 ARPDAU(0.1分)", direct: arpdau, reverse: nets.reduce((s, n) => s + Math.floor((n * 10) / (dau * md)), 0), tolerance: ps.length },
{ id: "effShare", name: "加权分成(‰)", direct: aShare, reverse: 1000 - Math.floor(ps.reduce((s, p) => s + p.grossFen * (1000 - p.sharePermille), 0) / gross), tolerance: 1 },
{ id: "effWithhold", name: "加权预扣(‰)", direct: aWh, reverse: 1000 - Math.floor(ps.reduce((s, p) => s + p.grossFen * (1000 - p.withholdPermille), 0) / gross), tolerance: 1 },
{ id: "ltv30d", name: "组合 LTV(0.1分)", direct: ltvA, reverse: ltvB, tolerance: curve.length },
{ id: "payoutCycle", name: "加权到账周期(天)", direct: ncyc, reverse: gcyc, tolerance: 3 },
];
const accounts: Account[] = raw.map((a) => {
const residual = a.direct - a.reverse;
return { ...a, residual, ok: Math.abs(residual) <= a.tolerance };
});
return { gross, net, takePermille: take, netArpdau: arpdau, threshold: cfg.netArpdauThresholdDeciFen,
cumActive, maxLevelOutput: maxLevelOutput(e.baseOutputPerMinute, e.levelBonusNum, e.levelBonusDen, e.maxLevel),
orders, distinct, accounts, ok: accounts.every((a) => a.ok) };
}
/** 跨语言对拍摘要:与 sim/g16_fullchain.py 的 digest 逐字符一致。 */
export function digest(l: Ledger): string {
const accts = l.accounts.map((r) => `${r.id}:${r.direct}:${r.reverse}:${r.residual}`).join(",");
return `gross=${l.gross},net=${l.net},take=${l.takePermille},arpdau=${l.netArpdau},`
+ `cum=${l.cumActive},maxOut=${l.maxLevelOutput};orders=${l.orders.a},${l.orders.b},${l.orders.c},`
+ `distinct=${l.distinct};accts=${accts};ok=${l.ok}`;
}
真实运行(node --test 'core/test/g16_*.test.ts',前缀必须写全 g16_,不能写裸 g*,否则会串到别的卷):
✔ 跨语言一致:TS 全链路摘要 == Python 金标准 (0.508292ms)
✔ 共享 economy 派生:留存曲线单调不增、满级产出用 BigInt (0.065625ms)
✔ 整数金额的顺序敏感性:三种聚合口径给出 3 种结果 (0.070625ms)
... (其余 8 条 ✔ 略)
ℹ tests 11 ℹ pass 11 ℹ fail 0
B 层:扰动下的验收稳健性
下面这段是 B 层基准脚本,把台账放到「单平台毛收入 / 分成 / 预扣」三旋钮 × 5 档扰动(pct ∈ {70, 85, 100, 115, 130})的网格上重跑(5 平台 × 3 参数 × 5 档 = 75 格),软告警、不硬失败:如实暴露「同一扰动下经不同聚合口径得到 N 个不同净额」与「净 ARPDAU 在 ±15% 带内跌破验收线」。
#!/usr/bin/env python3
# file: sim/g16_fullchain_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/g16_fullchain_bench.py --config config/ --out out/
"""多平台出海验收的稳健性基准(B 层,软告警不硬失败):把 sim/g16_fullchain.py 的台账放到
「单平台毛收入 / 分成 / 预扣」三旋钮 × 5 档扰动网格上重跑(5 平台 × 3 参数 × pct∈{70,85,100,115,130})。
软告警如实暴露:① 整数金额的顺序依赖——同一扰动下经不同聚合口径得到 N 个不同净额;② 净 ARPDAU
在 ±15% 带内跌破验收线(结论翻转)。产出 out/g16_fullchain_bench.json,退出码恒为 0。全整数无浮点。"""
from __future__ import annotations
import argparse, copy, json, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from g16_fullchain import build
PCTS, WARN_PCTS = [70, 85, 100, 115, 130], {85, 115}
PARAMS, CAP = ["grossFen", "sharePermille", "withholdPermille"], 950
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
base = json.load(open(os.path.join(a.config, "g16_fullchain.json"), encoding="utf-8"))
econ = json.load(open(os.path.join(a.config, "economy.json"), encoding="utf-8"))
br = build(base, econ)
threshold = base["netArpdauThresholdDeciFen"]
base_accept = br["arpdau"] >= threshold
cells, warns = [], []
for idx, p in enumerate(base["platforms"]):
for field in PARAMS:
for pct in PCTS:
cfg = copy.deepcopy(base)
raw = p[field] * pct // 100
cfg["platforms"][idx][field] = min(raw, CAP) if field in ("sharePermille", "withholdPermille") else raw
r = build(cfg, econ)
accept = r["arpdau"] >= threshold
cells.append({"platform": p["id"], "field": field, "pct": pct, "netFen": r["net"],
"arpdau": r["arpdau"], "orders": r["orders"], "distinct": r["distinct"], "accept": accept})
if pct in WARN_PCTS and r["distinct"] > 1:
warns.append(f'{p["id"]}.{field} ±{abs(pct - 100)}% 同一扰动下出现 {r["distinct"]} 种结果')
if pct in WARN_PCTS and accept != base_accept:
warns.append(f'{p["id"]}.{field} ±{abs(pct - 100)}% 验收判定翻转(accept={accept})')
flips = [c for c in cells if c["accept"] != base_accept]
min_arpdau = min(c["arpdau"] for c in cells)
max_distinct = max(c["distinct"] for c in cells)
uniq = sorted(set(warns))
verdict = "PASS(soft)" if not flips else f"WARN(soft) {len(flips)} 格验收翻转"
digest = (f'cells={len(cells)},baseDistinct={br["distinct"]},maxDistinct={max_distinct},minArpdau={min_arpdau},'
f'baseAccept={str(base_accept).lower()},flips={len(flips)},warns={len(uniq)}')
os.makedirs(a.out, exist_ok=True)
rep = {"version": base["version"], "game": base["game"], "observedAt": base["observedAt"], "perturbPcts": PCTS, "warnPcts": sorted(WARN_PCTS),
"params": PARAMS, "baseNetFen": br["net"], "baseArpdau": br["arpdau"], "baseThreshold": threshold, "baseAccept": base_accept, "baseOrders": br["orders"],
"baseDistinct": br["distinct"], "maxDistinct": max_distinct, "minArpdau": min_arpdau, "flipCount": len(flips), "warnings": uniq, "verdict": verdict,
"cells": cells, "digest": digest}
with open(os.path.join(a.out, "g16_fullchain_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
for line in uniq:
print(f"WARN {line}")
print(json.dumps({"baseDistinct": br["distinct"], "maxDistinct": max_distinct, "minArpdau": min_arpdau,
"baseAccept": base_accept, "flipCount": len(flips), "warnCount": len(uniq),
"verdict": verdict, "digest": digest}, ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
真实运行(/opt/homebrew/bin/python3 sim/g16_fullchain_bench.py --config config/ --out out/):
WARN armorgames.grossFen ±15% 同一扰动下出现 3 种结果
... (其余 14 行同格式,均为 ±15% 下出现 3 种结果,略)
{"baseDistinct":3,"maxDistinct":3,"minArpdau":380,"baseAccept":true,"flipCount":1,"warnCount":15,"verdict":"WARN(soft) 1 格验收翻转","digest":"cells=75,baseDistinct=3,maxDistinct=3,minArpdau=380,baseAccept=true,flips=1,warns=15"}
这里必须诚实报告坏消息:15 格出现「同一扰动下 3 种结果」,且有 1 格验收翻转——最大平台 poki 的毛收入被压到 70% 时,组合净 ARPDAU 掉到 380,跌破 400 的验收线,accept 由 true 变 false。这不是脚本出错,而是真实暴露了整数金额的顺序依赖:验收结论确实对输入敏感,读者必须知道它敏感在哪一格。
把五个平台合并成「组合净额」时至少有三种口径,本节台账三种都算了:A 逐平台连续下取整(每平台各自 floor(floor(g·(1−s))·(1−w)) 再求和)= 13175750 分;B 每平台合并费率(每平台先合成费率 s+w−s·w/1000 再一次 floor 再求和)= 13174500 分;C 组合加权费率(先按毛收入加权出组合费率,再一次 floor)= 13173300 分。三者最大相差 2450 分(约 0.019%),单看很小,却说明一个工程事实:「净额」不是输入的函数,而是「输入 + 聚合口径」的函数——对外报账、对账、写进合同的数,必须钉死口径。
自检表
| 检查项 | 结果 | 依据 |
|---|---|---|
| 净 ARPDAU 是否过线 | 439 ≥ 400,过 | netArpdau |
| 跨语言摘要一致 | 是 | P 测试首条 ✔ |
| 扰动下验收是否稳健 | 否,1 格翻转 | B 层 flipCount=1 |
一句话结论:账目本身对得上(八科目残差全过),但验收结论不稳健(最大平台一扰动就翻)。这正是 B 层存在的意义——它不替 S 层说好话,只把「结论依赖哪个输入」指出来。本节所有仿真数字均为本机相对量级,绝对数值不可移植。
阅读导航:上一节:16.1 出海发行方案装配 · 下一节:16.3 五卷总收官 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。