本节目标:把促销的三个杠杆——折扣深度、限时、稀缺——算成可复算的收入,找出「收入峰值折扣」。
上一节把档位和价格锚点定下来了。档位是静态的:一档 3000 分摆在那里不动。真实运营里价格是动态的:节日打折、限时秒杀、限量发售。本节和广告变现那篇《游戏广告变现设计 2026》 是分工关系:那篇列的是广告变现清单,本节做的是可跑的折扣与稀缺模型。三个杠杆各管一件事:折扣改单价、换需求,限时改紧迫感、抬窗口内需求,稀缺改供给上限、封成交量。
4.2 折扣、限时与稀缺
4.2.1 折扣不是越深越好
「打折就是降价换量」这句直觉只对一半。收入是单价 × 数量:单价线性下降,数量却不会线性上升——需求对价格的响应越来越钝。两者相乘先升后降,存在一个收入峰值折扣。本节要找的就是它落在哪里,以及限时、稀缺叠加后会怎么挪。
4.2.2 D 层:需求档位表与促销参数
下面这份是 D 层配置:原价与基线单位数、一张「折扣 → 需求百分比」的档位表,以及限时窗口与稀缺封顶参数。demandPct 是相对原价基线(=100)的需求百分比——把「降价换多少量」写成离散表,每个点都能独立核对。promo 里 urgencyPct(180%)是限时开窗瞬间的需求倍数,decayHours 是它衰减到 floorPct(120%)的时长,scarcityCap 是库存上限。
{
"version": 1,
"game": "merge-island",
"note": "折扣 / 限时 / 稀缺口径(D 层)。需求档位与限时参数均为示例假设;真实折扣弹性与转化需真实业务环境,命令行不可复现(X)。金额一律以「分」记账,比例一律整数百分比,无浮点。demandPct 是相对原价基线(=100)的需求百分比。",
"currency": "fen",
"base": {
"listPriceFen": 3000,
"baseUnits": 1000
},
"discountLadder": [
{"discountPct": 0, "demandPct": 100},
{"discountPct": 10, "demandPct": 135},
{"discountPct": 20, "demandPct": 175},
{"discountPct": 30, "demandPct": 215},
{"discountPct": 40, "demandPct": 245},
{"discountPct": 50, "demandPct": 262},
{"discountPct": 60, "demandPct": 272},
{"discountPct": 70, "demandPct": 276},
{"discountPct": 80, "demandPct": 278}
],
"promo": {
"windowHours": 72,
"urgencyPct": 180,
"decayHours": 24,
"floorPct": 120,
"scarcityCap": 2400
}
}
4.2.3 S 层:折扣扫描 + 限时衰减 + 稀缺封顶
下面这段是 S 层仿真脚本:按需求表扫折扣找收入峰值、把限时窗口折算成平均需求倍数、对窗口需求做稀缺封顶并算售罄时长。全程整数,金额以分记账。
#!/usr/bin/env python3
# file: sim/l04_discount.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l04_discount.py --config config/ --out out/
"""折扣 / 限时 / 稀缺:把促销三件套算成可复算的收入。
读 config/l04_discount.json:原价 + 需求档位表 + 限时/稀缺参数。
1) 折扣扫描:revenue(d) = listPrice*(100-d)//100 × baseUnits*demandPct//100,找收入峰值折扣;
2) 限时:窗口需求按「先冲高再衰减」折算成平均倍数 avgUrgencyPct,算窗口需求量;
3) 稀缺:对窗口需求量封顶(stock cap),算封顶后收入与「售罄时长」;
4) 对照:促销窗口收入 vs 同窗口稳态收入;稀缺封顶花掉多少确定性收入。
全程整数(分 / 小时)。产出 out/l04_discount.json + out/l04_discount.csv。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
def load(cfg_dir, name):
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def unit_price_fen(list_price_fen, discount_pct):
"""折后单价(分):原价 ×(100-折扣)/100,整数地板除。"""
return list_price_fen * (100 - discount_pct) // 100
def demand_units(base_units, demand_pct):
return base_units * demand_pct // 100
def revenue_fen(list_price_fen, base_units, discount_pct, demand_pct):
return unit_price_fen(list_price_fen, discount_pct) * demand_units(base_units, demand_pct)
def avg_urgency_pct(promo):
"""窗口平均需求倍数:前 decayHours 从 urgency 线性降到 floor,其余停在 floor。"""
w = promo["windowHours"]
d = min(promo["decayHours"], w)
peak = (promo["urgencyPct"] + promo["floorPct"]) // 2
return (peak * d + promo["floorPct"] * (w - d)) // w
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = load(args.config, "l04_discount")
lp, bu, promo = cfg["base"]["listPriceFen"], cfg["base"]["baseUnits"], cfg["promo"]
os.makedirs(args.out, exist_ok=True)
rows = [{"discountPct": s["discountPct"], "demandPct": s["demandPct"],
"unitPriceFen": unit_price_fen(lp, s["discountPct"]),
"units": demand_units(bu, s["demandPct"]),
"revenueFen": revenue_fen(lp, bu, s["discountPct"], s["demandPct"])}
for s in cfg["discountLadder"]]
best = max(rows, key=lambda r: r["revenueFen"])
avg_u = avg_urgency_pct(promo)
window_units = best["units"] * avg_u // 100
capped = min(window_units, promo["scarcityCap"])
window_rev, uncapped_rev = best["unitPriceFen"] * capped, best["unitPriceFen"] * window_units
steady_rev = best["unitPriceFen"] * best["units"]
sold_out = (promo["windowHours"] * promo["scarcityCap"] // window_units
if window_units > promo["scarcityCap"] else promo["windowHours"])
with open(os.path.join(args.out, "l04_discount.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["discountPct", "demandPct", "unitPriceFen", "units", "revenueFen"])
for r in rows:
w.writerow([r["discountPct"], r["demandPct"], r["unitPriceFen"], r["units"],
r["revenueFen"]])
report = {"source": "sim/l04_discount.py", "tier": "S", "game": cfg["game"],
"note": cfg["note"], "base": cfg["base"], "rows": rows,
"bestDiscountPct": best["discountPct"], "bestRevenueFen": best["revenueFen"],
"promo": {"windowHours": promo["windowHours"], "avgUrgencyPct": avg_u,
"windowUnits": window_units, "scarcityCap": promo["scarcityCap"],
"cappedUnits": capped, "windowRevenueFen": window_rev,
"uncappedRevenueFen": uncapped_rev, "steadyRevenueFen": steady_rev,
"promoUpliftPermille": (window_rev - steady_rev) * 1000 // max(1, steady_rev),
"scarcityCostFen": uncapped_rev - window_rev, "soldOutHours": sold_out,
"bound": window_units > promo["scarcityCap"]},
"csv": os.path.join(args.out, "l04_discount.csv")}
with open(os.path.join(args.out, "l04_discount.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
for r in rows:
print(f"discount={r['discountPct']:>2}% price={r['unitPriceFen']:>4}分 "
f"units={r['units']:>5} revenue={r['revenueFen']:>8}分")
print(f"best discount={best['discountPct']}% revenue={best['revenueFen']}分")
print(f"promo avgUrgency={avg_u}% windowUnits={window_units} cap={promo['scarcityCap']} "
f"capped={capped} bound={window_units > promo['scarcityCap']}")
print(f"windowRevenue={window_rev}分 uncapped={uncapped_rev}分 steady={steady_rev}分 "
f"uplift={(window_rev - steady_rev) * 1000 // max(1, steady_rev)}permille "
f"scarcityCost={uncapped_rev - window_rev}分 soldOut={sold_out}h")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑(金额单位「分」):折扣 0→30% 收入一路涨(3000000 → 3645000 → 4200000 → 4515000),峰值 4515000 分;30% 之后掉头(40% 4410000、50% 3930000、80% 只剩 1668000)。促销侧:限时平均倍数 130%,窗口需求 2795 单被封顶到 2400 单,售罄在第 61 小时,窗口收入 5040000 分、稀缺成本 829500 分,比同窗口稳态 4515000 分净高 116‰。
4.2.4 逐条读输出
- 收入峰值折扣是 30%(4515000 分,约 4.5 万元)。从 0% 打到 30% 收入一路涨;过了 30% 掉头往下,打到 80% 只剩 1668000 分,不到峰值的三分之一。「打折就是降价换量」的直觉在这里被数字否掉。
- 限时把窗口需求抬到平均 130%。注意它没用满 180%——
urgencyPct=180只在开窗瞬间成立,之后衰减到 120%,按「先冲高再衰减」折算,整个 72 小时窗口的平均倍数是 130%。 - 稀缺封顶绑定了:峰值折扣下窗口需求 2795 单,库存上限 2400,于是被砍到 2400 单,售罄时间提前到第 61 小时。它花掉 829500 分确定性收入(未封顶 5869500 减封顶后 5040000)。稀缺的价值在感知层(制造紧迫、保护原价),本模型只算它在收入公式里花掉多少,换来的感知不可复现,要诚实标注。
- 即便扣掉稀缺成本,促销窗口收入 5040000 分仍比同窗口稳态 4515000 分高 116‰——促销净赚,但没有「毛收入峰值」看起来那么多。这些数字是同机相对量级,绝对数值不可移植。
4.2.5 W 层:可交互的折扣原型
把内核放进一个单文件 HTML 就能在浏览器里点着看。下面这段是 W 层构建脚本:把 core/src/l04/discount.ts 擦除类型后内联进 HTML,产出 web/dist/l04.html,复用 web/pack.mjs 的打包器。
#!/usr/bin/env node
// file: web/build_l04_discount.mjs
// tier: W
// verify: node web/build_l04_discount.mjs
// 把 core/src/l04/discount.ts 的折扣/限时/稀缺内核打成单文件 HTML 原型,产物 web/dist/l04.html。
// 复用 web/pack.mjs 的擦除逻辑与页面外壳。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "l04_discount.json"), "utf8"));
const js = bundle(ROOT, ["core/src/l04/discount.ts"]);
const script = `const CFG = ${inlineConfig(cfg)};
${js}
const best = bestDiscount(CFG.base.listPriceFen, CFG.base.baseUnits, CFG.discountLadder);
const promo = CFG.promo, el = (id) => document.getElementById(id);
let discount = 0;
const rowFor = (d) => CFG.discountLadder.find((r) => r.discountPct === d);
function render() {
const r = rowFor(discount);
const price = unitPriceFen(CFG.base.listPriceFen, discount);
const units = demandUnits(CFG.base.baseUnits, r.demandPct);
const rev = revenueFen(CFG.base.listPriceFen, CFG.base.baseUnits, discount, r.demandPct);
const capped = applyScarcity(Math.floor((units * avgUrgencyPct(promo)) / 100), promo.scarcityCap);
el("price").textContent = String(price); el("units").textContent = String(units);
el("rev").textContent = String(rev); el("wrev").textContent = String(price * capped);
el("best").textContent = best.discountPct + "%";
el("bar").style.width = Math.max(2, Math.min(100, Math.floor((rev * 100) / best.revenueFen))) + "%";
el("bar").style.background = discount === best.discountPct ? "#dc2626" : "#38bdf8";
globalThis.__pebble = { discount, price, units, revenue: rev, windowRevenue: price * capped,
bestDiscountPct: best.discountPct, avgUrgencyPct: avgUrgencyPct(promo), canvasDrawn: true, api };
}
const api = {
setDiscount(d) { discount = d; render(); const r = rowFor(d);
return { discount: d, price: unitPriceFen(CFG.base.listPriceFen, d), units: demandUnits(CFG.base.baseUnits, r.demandPct),
revenue: revenueFen(CFG.base.listPriceFen, CFG.base.baseUnits, d, r.demandPct) }; },
best() { return { discountPct: best.discountPct, revenueFen: best.revenueFen }; },
promo() { const r = rowFor(best.discountPct), u = demandUnits(CFG.base.baseUnits, r.demandPct);
const w = Math.floor((u * avgUrgencyPct(promo)) / 100), c = applyScarcity(w, promo.scarcityCap);
return { avgUrgencyPct: avgUrgencyPct(promo), windowUnits: w, capped: c, bound: c < w,
windowRevenueFen: best.unitPriceFen * c, scarcityCostFen: best.unitPriceFen * (w - c) }; },
};
for (const r of CFG.discountLadder) { const b = document.createElement("button");
b.textContent = r.discountPct + "%"; b.addEventListener("click", () => { discount = r.discountPct; render(); });
el("btns").appendChild(b); }
render();
`;
const body = ` <h1>《合成小岛》· 折扣原型</h1>
<div class="sub">折后单价 / 需求量 / 收入来自 core/src/l04/discount.ts:全整数,无 Math.random</div>
<div class="card"><div class="k">折扣档位</div><div id="btns" class="row"></div></div>
<div class="card row"><div><div class="k">折后单价 (分)</div><div class="big" id="price">3000</div></div>
<div><div class="k">需求量 (单)</div><div class="big" id="units">1000</div></div>
<div><div class="k">收入 (分)</div><div class="big" id="rev">0</div></div>
<div><div class="k">限时+稀缺窗口收入 (分)</div><div class="big" id="wrev">0</div></div></div>
<div class="card"><div class="k">相对峰值收入</div>
<div style="height:16px;background:#0f172a;border-radius:8px;overflow:hidden;margin-top:8px">
<div id="bar" style="height:100%;width:10%;background:#38bdf8"></div></div>
<div class="sub" style="margin-top:8px">峰值折扣 <span id="best">30%</span>(超过它收入开始下滑)</div></div>`;
const bytes = writePage(join(ROOT, "web/dist/l04.html"), page({ title: "《合成小岛》折扣原型 · l04", body, script }));
console.log(JSON.stringify({ html: "web/dist/l04.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
下面这段是 W 层验收脚本:Playwright 无头打开 HTML,逐个折扣档位采样收入,断言「先升后降、峰值在 30%」,并核对限时 + 稀缺的窗口收入。本机跑通后末尾打印 SMOKE-W04-DISCOUNT: PASS,9 档收入曲线与 S 层一致。
#!/usr/bin/env node
// file: web/verify_l04_discount.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_l04_discount.mjs web/dist/l04.html
// W 层验收:Playwright 无头打开单文件 HTML,逐个折扣档位采样收入,
// 断言「先升后降、峰值在 30%」,并核对限时+稀缺封顶的窗口收入,最后截图存档。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/l04.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const page = await browser.newPage({ viewport: { width: 800, height: 900 } });
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
// 逐个折扣档位采样:一次把「整条收入曲线 + 峰值 + 促销窗口」取回来。
const sweep = await page.evaluate(() => {
const api = globalThis.__pebble.api;
const discounts = [0, 10, 20, 30, 40, 50, 60, 70, 80];
const revenues = discounts.map((d) => api.setDiscount(d).revenue);
const prices = discounts.map((d) => api.setDiscount(d).price);
api.setDiscount(30);
return { discounts, revenues, prices, best: api.best(), promo: api.promo() };
});
await page.screenshot({ path: join(ROOT, "out/shots/l04_discount.png"), fullPage: true });
await page.close();
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(sweep.best.discountPct, 30, `峰值折扣应为 30%,实测 ${sweep.best.discountPct}`);
assert.equal(sweep.best.revenueFen, 4515000);
const peak = sweep.discounts.indexOf(30);
for (let i = 1; i <= peak; i += 1) assert.ok(sweep.revenues[i] > sweep.revenues[i - 1], "峰值前收入应递增");
for (let i = peak + 1; i < sweep.revenues.length; i += 1) assert.ok(sweep.revenues[i] < sweep.revenues[i - 1], "峰值后收入应递减");
assert.equal(sweep.prices[0], 3000, "0% 折单价应为原价 3000 分");
assert.equal(sweep.prices[3], 2100, "30% 折单价应为 2100 分");
assert.equal(sweep.promo.avgUrgencyPct, 130, `限时平均倍数应为 130%,实测 ${sweep.promo.avgUrgencyPct}`);
assert.equal(sweep.promo.capped, 2400, "稀缺封顶后单位应为 2400");
assert.equal(sweep.promo.bound, true, "库存上限应绑定");
assert.equal(sweep.promo.windowRevenueFen, 5040000);
assert.equal(sweep.promo.scarcityCostFen, 829500);
console.log(JSON.stringify({
page: "web/dist/l04.html", discounts: sweep.discounts, revenues: sweep.revenues,
prices: sweep.prices, best: sweep.best, promo: sweep.promo,
screenshot: "out/shots/l04_discount.png",
}, null, 2));
console.log("SMOKE-W04-DISCOUNT: PASS");
4.2.6 三条可复用判断
- 找峰值,别凭手感定折扣。折扣扫描只要一张需求表就能跑,峰值往往在 20%~40% 之间;超过峰值再打折,收入是净损失。
- 限时抬的是窗口需求,不是长期需求。倍数要按「冲高 + 衰减」折算平均,别把开窗瞬间的峰值当常态。
- 稀缺在收入公式里只有减项。封顶只砍成交量,它的价值在感知层、算不进收入——要么接受这笔成本,要么别用稀缺。
小结
- 折扣不是越深越好:收入 = 单价 × 数量,先升后降,存在峰值折扣。
- D 层配置把需求档位表、限时与稀缺参数写成唯一真源;S 层扫折扣、折算限时、封顶稀缺。
- 本机实跑:峰值折扣 30%(收入 4515000 分);限时平均倍数 130%;稀缺封顶使售罄提前到第 61 小时、花掉 829500 分;促销净提升 116‰。
- W 层把同一内核打成单文件 HTML,Playwright 无头验收与 S 层数字一致;稀缺的感知价值不可复现。
折扣为什么会「先升后降」、副作用到底吃掉多少——这是价格弹性要回答的。下一节把弹性算出来,并把折扣的三项副作用(需求前置、参考价侵蚀、鲸鱼流失)从毛收入里扣掉,看净收入最优落在哪里。
阅读导航:上一节:定价锚点与档位设计 · 下一节:价格弹性与副作用 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。