16.2 多平台仿真与验收

多平台出海的验收不能只看一张总额表:本节把五个平台的毛收入、分成与预扣放进一份台账(S 层)做全链路对账,再用一份与 Python 逐位等价的 TypeScript 内核交叉核对(P 层),最后用 75 格扰动网格暴露「同一扰动下三种聚合口径给出三种净额」的顺序依赖与一处验收翻转(B 层)。

站内《上线变现与长线运营》 写的是国内上线与长线运营,站内 SaaS 平台创业口径的小游戏专题 (25 篇)讲的是做小游戏平台本身。本卷两者都不重做——卷五只做跨平台发行与出海,本章只做多平台仿真与验收。上一节16.1 出海发行方案装配 把 16 章装订成册;本节把册子里最容易被糊弄的那张账——五个平台的毛收入、分成、预扣与净结算——真跑一遍。

验收要回答的两个问题

  1. 净额怎么从毛收入算出来。平台扣点常分两段:先按分成率抽成,再对分成后金额预扣。两段各自地板除,还是先合成一个费率再一次地板除,结果并不相等——整数金额不满足分配律。
  2. 结论在扰动下还站不站得住。平台政策、汇率、分成谈判都会让输入在 ±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 五卷总收官 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制