站内《小游戏开发实战》卷三目录 把收入、LTV 与增长预算分配算得很细,本章 14.1 算了单用户账、14.2 算了月度账。但账算出来只是原料,操盘手每个周期真正要做的是一个组合决策:手里几条渠道,哪条该停、哪条该留、哪条该加钱,加多少。本节不重做卷三 3 章的收入模型,也不重做 2.3 的单条计划内关停规则(那条讲「一条计划的累计 ROAS 连续低于止损线就关」),而是站在组合层:把 14.1 的单位账与 14.2 的现金约束合起来,判每条渠道的动作,并给出加投的最优倍数。分工是:2.3 管一条计划什么时候关,本节管一整个投放组合的钱往哪放。这也正是本章的收口——前面所有的账,最后都要落到「停、留、加」三个字上。
一个组合,三种动作
对每条渠道,先算一个数:roasPermille = ltvFen30 × 1000 // cacFen,也就是单用户 D30 收入与获取成本的比(1000‰ = 回本线)。然后按两条线分三种动作:
| 动作 | 条件 | 含义 |
|---|---|---|
| STOP(止损) | ROAS < 止损线 | 这条渠道在失血,停掉它省下的就是少亏的 |
| HOLD(维持) | 止损线 ≤ ROAS < 加投线 | 能赚但不够多,维持现状观察 |
| SCALE(加投) | ROAS ≥ 加投线 | 值得加钱,但要算清加到哪一点 |
难点在 SCALE。加投不是线性放大:放量会推高边际 CAC(好的人群先被买完,后面越来越贵)。所以「加多少」有一个最优点——加到边际 ROAS 落到回本线为止。再往后每多投一块钱,收回来的还不够花出去的。
决策公式
把决策写成整数公式(金额用「分」,比例用千分比):
roasPermille(c) = ltvFen30(c) × 1000 // cacFen(c)
判定:roas < stopFloor → STOP;roas ≥ scaleFloor → SCALE;否则 HOLD
# SCALE 渠道求「边际 ROAS 落到 1000」的放量倍数:
scaleStar(c) = min(maxScale, 1000 + (roas − 1000) × 1000 // scaleDriftPermille)
cacEnd(c) = cacFen × (1000 + scaleDrift × (scaleStar − 1000) // 1000) // 1000
avgCac(c) = (cacFen + cacEnd) // 2 # 线性成本曲线增量段均值
extraSpend(c) = baseSpendFen × (scaleStar − 1000) // 1000
extraInstalls(c) = extraSpend // avgCac
extraProfit(c) = extraInstalls × ltvFen30 − extraSpend # 加投的增量利润
savedLoss(c) = baseSpendFen × (1000 − roas) // 1000 # STOP 渠道省下的亏损
scaleDriftPermille 是放量的边际成本斜率——每多投一倍基数,CAC 上升的千分比。它越大,最优点越早到,说明这条渠道「不经放」。avgCac 取增量段两端均值,是因为在「CAC 随放量线性上升」的假设下,增量段的平均成本恰好是两端平均(梯形积分),这样 extraProfit 才不是用端点高估成本、低估利润。汇总的 netGain = ΣextraProfit + ΣsavedLoss 就是这一轮「停、加」合计带来的金额改善。
S 层仿真:把组合决策算出来
下面这段是 S 层仿真脚本 sim/o14_decision.py。它读 config/o14_decision.json 的四条渠道,逐条判动作、算加投倍数与增量利润、算止损省下的亏损,汇总净改善,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o14_decision.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o14_decision.py --config config/ --out out/
"""止损与再投入决策:把每条渠道判成「止损 / 维持 / 加投」,再算出加投的最优倍数与增量利润。
读 config/o14_decision.json 的四条渠道。每条:
roasPermille(c) = ltvFen30(c) * 1000 // cacFen(c) # D30 ROAS(1000 = 回本线)
判定:roas < stopFloor → STOP(止损)
roas >= scaleFloor → SCALE(加投)
其余 → HOLD(维持)
# SCALE 渠道求「边际 ROAS 落到 1000 的那一点」作为最优放量倍数:
scaleStar(c) = min(maxScale, 1000 + (roas - 1000) * 1000 // scaleDriftPermille)
cacEnd(c) = cacFen * (1000 + scaleDrift * (scaleStar - 1000) // 1000) // 1000
avgCac(c) = (cacFen + cacEnd) // 2 # 线性成本曲线增量段均值
extraSpend(c) = baseSpendFen * (scaleStar - 1000) // 1000
extraInstalls(c)= extraSpend // avgCac
extraProfit(c) = extraInstalls * ltvFen30 - extraSpend # 加投的增量利润(分)
# STOP 渠道:停掉即不再亏:
savedLoss(c) = baseSpendFen * (1000 - roas) // 1000
汇总:totalExtraProfit(加投增量利润)、totalSavedLoss(止损省下的亏损)、netGain = 两者之和。
全程整数、地板除,无浮点。产出 out/o14_decision.json + .csv + .svg。
真实出价与真实边际成本曲线需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def evaluate(c: dict, stop_floor: int, scale_floor: int, max_scale: int) -> dict:
cac, ltv = c["cacFen"], c["ltvFen30"]
roas = ltv * 1000 // cac
if roas < stop_floor:
verdict = "STOP"
elif roas >= scale_floor:
verdict = "SCALE"
else:
verdict = "HOLD"
scale_star = 1000
extra_profit = 0
extra_spend = 0
if verdict == "SCALE":
drift = c["scaleDriftPermille"]
scale_star = min(max_scale, 1000 + (roas - 1000) * 1000 // drift)
cac_end = cac * (1000 + drift * (scale_star - 1000) // 1000) // 1000
avg_cac = (cac + cac_end) // 2
extra_spend = c["baseSpendFen"] * (scale_star - 1000) // 1000
extra_installs = extra_spend // avg_cac
extra_profit = extra_installs * ltv - extra_spend
saved_loss = c["baseSpendFen"] * (1000 - roas) // 1000 if verdict == "STOP" else 0
return dict(c, roasPermille=roas, verdict=verdict, scaleStarPermille=scale_star,
extraSpendFen=extra_spend, extraProfitFen=extra_profit, savedLossFen=saved_loss)
def draw_svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:每渠道一根横条——决策带来的金额改善(加投增量利润 / 止损省下的亏损)。"""
W, H, PAD, ROW = 760, 70 + 56 * len(rows), 200, 56
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}
).text = "《合成小岛》止损与加投的金额改善(分):绿=加投增量利润,蓝=止损省下的亏损"
span = W - PAD - 24
hi = max([max(r["extraProfitFen"], r["savedLossFen"]) for r in rows] + [1])
color = {"SCALE": "#16a34a", "STOP": "#2563eb", "HOLD": "#cbd5e1"}
for i, r in enumerate(rows):
y = 60 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 24), "font-size": "12",
"fill": "#374151"}).text = f'{r["name"]} · {r["verdict"]}'
amt = r["extraProfitFen"] if r["verdict"] == "SCALE" else r["savedLossFen"]
w = span * amt // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 8), "width": str(w), "height": "20",
"rx": "3", "fill": color[r["verdict"]]})
ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 23), "font-size": "11",
"fill": "#6b7280"}).text = f'ROAS {r["roasPermille"]}‰ · {amt}分'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "o14_decision.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = [evaluate(c, cfg["stopFloorPermille"], cfg["scaleFloorPermille"], cfg["maxScalePermille"])
for c in cfg["channels"]]
total_extra = sum(r["extraProfitFen"] for r in rows)
total_saved = sum(r["savedLossFen"] for r in rows)
net_gain = total_extra + total_saved
digest = ",".join(f'{r["id"]}:{r["verdict"]}:{r["roasPermille"]}:{r["scaleStarPermille"]}'
f':{r["extraProfitFen"]}' for r in rows) \
+ f'|T:{total_extra}:{total_saved}:{net_gain}'
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o14_decision.svg"), rows)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"stopFloorPermille": cfg["stopFloorPermille"], "scaleFloorPermille": cfg["scaleFloorPermille"],
"maxScalePermille": cfg["maxScalePermille"], "channels": rows,
"totalExtraProfitFen": total_extra, "totalSavedLossFen": total_saved,
"netGainFen": net_gain, "digest": digest,
"counts": {v: sum(1 for r in rows if r["verdict"] == v) for v in ("STOP", "HOLD", "SCALE")},
"svg": os.path.join(args.out, "o14_decision.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "o14_decision.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o14_decision.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "baseSpendFen", "cacFen", "ltvFen30", "roasPermille", "verdict",
"scaleStarPermille", "extraSpendFen", "extraProfitFen", "savedLossFen")
w.writerow(cols)
for r in rows:
w.writerow([r[k] for k in cols])
print(json.dumps({"channels": [{"id": r["id"], "verdict": r["verdict"],
"roasPermille": r["roasPermille"],
"scaleStarPermille": r["scaleStarPermille"],
"extraProfitFen": r["extraProfitFen"],
"savedLossFen": r["savedLossFen"]} for r in rows],
"totalExtraProfitFen": total_extra, "totalSavedLossFen": total_saved,
"netGainFen": net_gain, "digest": digest,
"counts": report["counts"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/o14_decision.json。四条渠道覆盖了三种动作,scaleDriftPermille 是各自的边际成本斜率:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"stopFloorPermille": 1000,
"scaleFloorPermille": 1300,
"maxScalePermille": 3000,
"note": "止损与再投入决策参数,口径是「公开可观察机制」的建模假设,不是任何投放后台实测。baseSpendFen=该渠道当前月投放额(分);cacFen=当前单用户获取成本(分);ltvFen30=单用户 D30 收入(分);scaleDriftPermille=每多投 1000‰(即 1 倍基数)CAC 上升的千分比(放量边际成本递增)。stopFloorPermille=止损线(ROAS 千分比,低于即停);scaleFloorPermille=加投线(高于即加);maxScalePermille=单渠道放量倍数上限。真实出价与真实边际成本曲线需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"channels": [
{ "id": "feed", "name": "信息流", "baseSpendFen": 4200000, "cacFen": 381, "ltvFen30": 333, "scaleDriftPermille": 120 },
{ "id": "kol", "name": "达人", "baseSpendFen": 3600000, "cacFen": 553, "ltvFen30": 688, "scaleDriftPermille": 90 },
{ "id": "rewarded", "name": "激励", "baseSpendFen": 2100000, "cacFen": 228, "ltvFen30": 209, "scaleDriftPermille": 60 },
{ "id": "brand", "name": "品牌专区", "baseSpendFen": 1500000, "cacFen": 300, "ltvFen30": 420, "scaleDriftPermille": 400 }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o14_decision.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):
| 渠道 | ROAS‰ | 动作 | 最优倍数 | 金额改善(分) |
|---|---|---|---|---|
| 信息流 | 874 | STOP | — | +529200 |
| 达人 | 1244 | HOLD | — | 0 |
| 激励 | 916 | STOP | — | +176400 |
| 品牌专区 | 1400 | SCALE | 2.0× | +249720 |
| 合计 | 2 停 / 1 留 / 1 加 | +955320 |
读这张表要读出三件事。第一,两条 ROAS 不到 1000‰ 的渠道直接止损:信息流(874‰)停掉省下 529200 分,激励(916‰)停掉省下 176400 分,合计 705600 分——这些是「不动作就会继续亏掉」的钱。第二,达人 1244‰ 落在止损线与加投线之间,判 HOLD:它能赚(14.1 里贡献毛利 +110 分),但 ROAS 没到加投线 1300‰,先维持观察,不急着加钱。第三,只有品牌专区够格加投:ROAS 1400‰,scaleStar = 1000 + (1400−1000)×1000//400 = 2000,即加到 2.0 倍——不是无限加,而是加到边际 ROAS 落到 1000‰ 那一点;这一步增量投入 1500000 分,增量利润 +249720 分。三个动作合起来,这一轮净改善 955320 分(约 9553 元)。注意 maxScalePermille=3000 是硬上限:哪怕某渠道 ROAS 高到算出 5 倍,也只能加到 3 倍,避免单一渠道过度集中。
P 层内核:跨语言对拍
下面这段是 P 层 TypeScript 内核 core/src/o14/decision.ts——与 sim/o14_decision.py 逐位等价的对偶实现:同样的判定边界、同样的最优倍数求解、同样的增量利润口径。它读同一份 config/o14_decision.json,产出逐字符一致的摘要。
// file: core/src/o14/decision.ts
// tier: P
// verify: node --test 'core/test/o14_*.test.ts'
//
// 止损与再投入决策内核:与 sim/o14_decision.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o14_decision.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。
export interface DecisionChannel {
readonly id: string; readonly name: string;
readonly baseSpendFen: number; readonly cacFen: number; readonly ltvFen30: number;
readonly scaleDriftPermille: number;
}
export interface DecisionConfig {
readonly version: number; readonly game: string;
readonly stopFloorPermille: number; readonly scaleFloorPermille: number;
readonly maxScalePermille: number; readonly channels: readonly DecisionChannel[];
}
export type Verdict = "STOP" | "HOLD" | "SCALE";
export interface DecisionResult {
readonly id: string; readonly verdict: Verdict;
readonly roasPermille: number; readonly scaleStarPermille: number;
readonly extraSpendFen: number; readonly extraProfitFen: number; readonly savedLossFen: number;
}
export interface DecisionTotals {
readonly totalExtraProfitFen: number; readonly totalSavedLossFen: number; readonly netGainFen: number;
}
export function evaluate(c: DecisionChannel, stopFloor: number, scaleFloor: number, maxScale: number): DecisionResult {
const cac = c.cacFen, ltv = c.ltvFen30;
const roasPermille = Math.floor((ltv * 1000) / cac);
const verdict: Verdict = roasPermille < stopFloor ? "STOP"
: roasPermille >= scaleFloor ? "SCALE" : "HOLD";
let scaleStarPermille = 1000;
let extraSpendFen = 0;
let extraProfitFen = 0;
if (verdict === "SCALE") {
const drift = c.scaleDriftPermille;
scaleStarPermille = Math.min(maxScale, 1000 + Math.floor(((roasPermille - 1000) * 1000) / drift));
const cacEnd = Math.floor((cac * (1000 + Math.floor((drift * (scaleStarPermille - 1000)) / 1000))) / 1000);
const avgCac = Math.floor((cac + cacEnd) / 2);
extraSpendFen = Math.floor((c.baseSpendFen * (scaleStarPermille - 1000)) / 1000);
const extraInstalls = Math.floor(extraSpendFen / avgCac);
extraProfitFen = extraInstalls * ltv - extraSpendFen;
}
const savedLossFen = verdict === "STOP" ? Math.floor((c.baseSpendFen * (1000 - roasPermille)) / 1000) : 0;
return { id: c.id, verdict, roasPermille, scaleStarPermille, extraSpendFen, extraProfitFen, savedLossFen };
}
export function evaluateAll(cfg: DecisionConfig): DecisionResult[] {
return cfg.channels.map((c) => evaluate(c, cfg.stopFloorPermille, cfg.scaleFloorPermille, cfg.maxScalePermille));
}
export function totals(rows: readonly DecisionResult[]): DecisionTotals {
const totalExtraProfitFen = rows.reduce((s, r) => s + r.extraProfitFen, 0);
const totalSavedLossFen = rows.reduce((s, r) => s + r.savedLossFen, 0);
return { totalExtraProfitFen, totalSavedLossFen, netGainFen: totalExtraProfitFen + totalSavedLossFen };
}
/** 跨语言对拍摘要:与 sim/o14_decision.py 的 digest 逐字符一致。 */
export function digest(rows: readonly DecisionResult[], t: DecisionTotals): string {
const per = rows.map((r) => `${r.id}:${r.verdict}:${r.roasPermille}:${r.scaleStarPermille}:${r.extraProfitFen}`)
.join(",");
return `${per}|T:${t.totalExtraProfitFen}:${t.totalSavedLossFen}:${t.netGainFen}`;
}
配套测试 core/test/o14_decision.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算逐字符相等,并验「STOP 省下的亏损按缺口计」「最优倍数落在边际 ROAS=1000 处」两条结论。跑 node --test 'core/test/o14_*.test.ts'(本节 5 个用例,与 14.1 合计 11 个全过),两套独立实现给出同一个摘要:
digest = feed:STOP:874:1000:0,kol:HOLD:1244:1000:0,rewarded:STOP:916:1000:0,brand:SCALE:1400:2000:249720|T:249720:705600:955320
只要有一边的判定边界、倍数求解、均值口径写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真与 TS 内核里必须长得一模一样。
X 层:真实边际成本曲线不可复现
上面算的是模型侧。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的边际成本曲线:
scaleDriftPermille的线性假设是简化,真实放量的 CAC 可能前缓后陡,拐点位置只有投了才知道; - 真实的止损与加投线(
stopFloor/scaleFloor)应随品类与现金流压力调整,不是固定常数; - 真实的放量执行约束:平台单账户预算上限、审核与学习期都会限制「一次性加到 2 倍」的可行性。
正确做法是把这张表当决策底稿:模型给出「停哪些、加哪个、加到几倍」的初判,真实投放时用小步爬坡(见 2.2)逐档验证边际 ROAS 是否真如模型所料。这也是 14.1 / 14.2 的账必须持续被线上数据反标定的原因——决策的输入错了,输出再自洽也是错的。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三动作齐全 | 止损 / 维持 / 加投都有明确判据 |
| 边际成本 | 放量推高 CAC 显式建模,不按线性外推 |
| 最优点 | 加投倍数解到「边际 ROAS=回本线」,不凭感觉 |
| 上下限 | 有 maxScale 硬上限,防单渠道过度集中 |
| 金额可汇总 | 止损省下的亏损与加投增量利润都能加总成净改善 |
| 边界清晰 | 真实边际成本曲线标 X,不写成「实测」 |
到这里,第 14 章三节连成了一条完整的账:14.1 算一个用户赚不赚,14.2 算一个月盈不盈、现金够不够,14.3 把两者合成停 / 留 / 加的组合决策。下一章进入案例与失败模式——把《合成小岛》的投放与变现复盘一遍,看看这些账在真实操盘里长什么样。
阅读导航:上一节:14.2 月度经营账与现金流 · 下一节:15.1 《合成小岛》投放与变现复盘 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。