10.1 建的是一张原价档位表:每元玉璧数 10000 到 12000,梯度 50 到 43‰。折扣要做的,是把这张表里的某个价格临时改掉。看起来只是「打 7 折」,但它同时动了三样东西:深度(打几折)、窗口(打多久)、稀缺(能买几次)。本节把这三样放进同一个可跑的仿真里,看它们分别如何改变收入,再用一个 B 基准量化折扣最被忽视的代价——它会把低档的单位价值抬到超过满价顶档,击穿 10.1 那张表的秩序。
折扣在运营里几乎是最常用的工具,也是最容易被滥用的工具。原因很简单:降价一定能带来「更多」的某种东西——更多购买、更多曝光、更多留存——但「更多」不等于「更赚」。本节的目标,是把「打折到底赚不赚」拆成能算的数。
折扣的三个变量
一个折扣活动由三个变量决定:
- 深度(depth):折扣百分比。本套的折扣档写在
config/pricing.json里(只读):d10是 9 折、d30是 7 折、d50是 5 折。 - 窗口(window):折扣只在一个限时区间内生效。窗口越短,稀缺感越强,但也越依赖玩家的即时冲动。
- 稀缺(scarcity):限购次数。本套的
config/n10_pricing.json里scarcity.limitPerSeasonByTier给每个折扣档配了每赛季限购次数:d10不限购(0),d30/d50各限 1 次。
这三个变量合起来,才是「一次折扣活动」。只调深度不调窗口和限购,等于把折扣当成常态价——那就不是折扣了。
折扣深度 vs 需求响应
折扣的收益逻辑是:价格降了,买的人多了。这里的「多」就是需求响应。本套把它设成一个设计假设(不是实测数据):每降 1 个百分点,需求提升 liftPermillePerOffPct = 30 千分比。于是:
需求倍率 = 1000‰ + 折扣率 × 30‰
折后价 = 原价 × (100 − 折扣率) / 100
d10 的需求倍率是 1000 + 10 × 30 = 1300‰(多 30%),d30 是 1900‰,d50 是 2500‰。需求倍率随折扣线性增长,但收入是「折后价 × 需求」,折后价随折扣线性下降——两者相乘,收入未必随折扣单调变化。这正是要跑仿真的原因。
仿真:折扣在两种情形下的收入
下面这段是 S 层仿真脚本,读 config/pricing.json 的折扣档与 config/n10_pricing.json 的窗口、稀缺参数,以 t2 档(3000 分)为参照商品、玩家池 1000 人,算每个折扣档在「无限购」与「限购」两种情形下的可售量与收入:
# file: sim/n10_discount_scarcity.py
# tier: S
# verify: python3 sim/n10_discount_scarcity.py --config config/ --out out/
"""折扣与稀缺仿真:读 config/pricing.json 的折扣档与 config/n10_pricing.json 的
折扣窗口(限时天数、玩家池、需求响应)与稀缺限购,算每个折扣档在「无限购」与
「限购」两种情形下的可售量与收入,并与原价基准对照。
产出 out/n10_discount.json + out/n10_discount.csv + out/n10_discount.svg。"""
from __future__ import annotations
import argparse
import csv
import json
import os
from svg_line import render
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as fh:
return json.load(fh)
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
pricing = load(args.config, "pricing")
n10 = load(args.config, "n10_pricing")
win = n10["discountWindow"]
limits = n10["scarcity"]["limitPerSeasonByTier"]
ref = next(t for t in pricing["tiers"] if t["id"] == "t2") # 参照商品 = t2
eff_jade = ref["jade"] * (100 + ref["bonusPct"]) // 100
base_units = win["playerPool"]
base_revenue = ref["priceCents"] * base_units
rows = []
for d in pricing["discount"]["tiers"]:
off = d["offPct"]
eff_price = ref["priceCents"] * (100 - off) // 100
mult = 1000 + off * win["liftPermillePerOffPct"]
raw_units = base_units * mult // 1000
limit = limits[d["id"]]
cap = 0 if limit == 0 else win["playerPool"] * limit
sold = raw_units if limit == 0 else min(raw_units, cap)
rev_unlimited = eff_price * raw_units
rev = eff_price * sold
rows.append({
"id": d["id"], "offPct": off, "effPriceCents": eff_price,
"demandMultPermille": mult, "limitPerSeason": limit,
"rawUnits": raw_units, "soldUnits": sold, "limitBinding": limit != 0 and raw_units > cap,
"revenueUnlimitedCents": rev_unlimited, "revenueCents": rev,
"deltaUnlimitedPermille": rev_unlimited * 1000 // base_revenue - 1000,
"deltaPermille": rev * 1000 // base_revenue - 1000,
"unitValueMilli": eff_jade * 100 * 1000 // eff_price,
})
with open(os.path.join(args.out, "n10_discount.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["id", "offPct", "effPriceCents", "demandMultPermille", "limitPerSeason",
"rawUnits", "soldUnits", "limitBinding", "revenueUnlimitedCents", "revenueCents",
"deltaUnlimitedPermille", "deltaPermille", "unitValueMilli"])
for r in rows:
w.writerow([r["id"], r["offPct"], r["effPriceCents"], r["demandMultPermille"], r["limitPerSeason"],
r["rawUnits"], r["soldUnits"], int(r["limitBinding"]), r["revenueUnlimitedCents"],
r["revenueCents"], r["deltaUnlimitedPermille"], r["deltaPermille"], r["unitValueMilli"]])
render([r["revenueUnlimitedCents"] for r in rows], os.path.join(args.out, "n10_discount.svg"),
"revenue by discount (unlimited)")
report = {
"version": 1, "referenceTier": ref["id"], "referencePriceCents": ref["priceCents"],
"effectiveJade": eff_jade, "baseUnits": base_units, "baseRevenueCents": base_revenue,
"window": win, "rows": rows,
"bestUnlimited": max(rows, key=lambda r: r["revenueUnlimitedCents"])["id"],
}
with open(os.path.join(args.out, "n10_discount.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑产出的收入对照表 out/n10_discount.csv(原价基准收入 = 3000 × 1000 = 3,000,000 分):
| 折扣 | 折后价(分) | 需求倍率 | 限购 | 可售量 | 实售量 | 无限购收入 | 限购收入 | 无限购变化 | 限购变化 |
|---|---|---|---|---|---|---|---|---|---|
| d10 | 2700 | 1300‰ | 0 | 1300 | 1300 | 3,510,000 | 3,510,000 | +170‰ | +170‰ |
| d30 | 2100 | 1900‰ | 1 | 1900 | 1000 | 3,990,000 | 2,100,000 | +330‰ | −300‰ |
| d50 | 1500 | 2500‰ | 1 | 2500 | 1000 | 3,750,000 | 1,500,000 | +250‰ | −500‰ |
这张表里有两组对照,每一组都在讲一件事:
- 无限购时,d30 收入最高(+330‰)。折扣从 10% 加深到 30%,收入反而涨得更快;但从 30% 再加深到 50%,收入回落到 +250‰。原因是折后价线性下降、需求倍率线性上升,两者相乘是一条先升后降的曲线——最深的折扣不一定是收入最高的折扣。这个峰的准确位置取决于需求响应假设,10.3 会用弹性把它定出来。
- 限购把 d30 / d50 的增收空间砍掉了。限购 1 次/赛季意味着每个玩家最多买 1 份,1000 人的池子最多卖出 1000 份;而 d30 的需求驱动量是 1900 份,被硬生生压到 1000 份。收入从「无限购的 +330‰」变成「限购的 −300‰」。限购不是增收工具,它是稀缺工具——它的价值不在收入表上,而在下一节的锚点保护上。
限购的代价与价值
把 limitBinding 一列读出来:d30 和 d50 都是 true,说明限购是约束项(需求超过供给上限)。限购一上,可售量从需求驱动量降到 玩家池 × 限购次数。这带来两个后果:
- 收入受损:
d30从 3,990,000 掉到 2,100,000,d50从 3,750,000 掉到 1,500,000。限购越严,收入损失越大。 - 稀缺成立:正因为「买不到那么多」,折扣才有稀缺感。不限购的折扣不是稀缺,是降价——它只是把价格永久下移,玩家不会觉得「抢到了」。
所以限购的正确读法不是「损失了多少收入」,而是「用多少收入换来了多少锚点保护」。这个「锚点保护」值多少,下一节用 B 基准量化。
基准:折扣前后单位价值对照
折扣最被忽视的代价,是它改变了 10.1 那张表的相对秩序。原价下,每元玉璧数从 t1 的 10000 单调升到 t5 的 12000;一旦某个档打折,它的单位价值就会跳升——如果跳得够高,低档折后就会比满价顶档还划算,10.1 辛苦建的梯度就被击穿了。
下面这段是 B 层基准脚本(软告警,不硬失败),对每个档位施加每个折扣档,算折后每元玉璧数,与满价顶档(t5 = 12000)对照:
# file: sim/n10_discount_scarcity_bench.py
# tier: B
# verify: python3 sim/n10_discount_scarcity_bench.py --config config/ --out out/
"""折扣前后单位价值对照(B 层,软告警不硬失败):对每个档位施加每个折扣档,
算折扣后的每元玉璧数,与「满价顶档」(t5)对照,标出哪些折扣把低档的单位价值
抬到超过满价顶档(价格锚点被击穿)。产出 out/n10_discount_bench.json。"""
from __future__ import annotations
import argparse
import json
import os
from n10_discount_scarcity import load
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
pricing = load(args.config, "pricing")
top = pricing["tiers"][-1]
top_value = top["jade"] * (100 + top["bonusPct"]) // 100 * 100 * 1000 // top["priceCents"]
rows = []
for t in pricing["tiers"]:
eff_jade = t["jade"] * (100 + t["bonusPct"]) // 100
for d in pricing["discount"]["tiers"]:
off = d["offPct"]
eff_price = t["priceCents"] * (100 - off) // 100
value = eff_jade * 100 * 1000 // eff_price
rows.append({"tier": t["id"], "discount": d["id"], "offPct": off,
"discountedValueMilli": value, "breached": value > top_value})
breached = [r for r in rows if r["breached"]]
report = {
"version": 1, "kind": "baseline", "topTierId": top["id"], "topValueMilli": top_value,
"rows": rows, "breachCount": len(breached),
"breaches": [f"{r['tier']}@{r['discount']}" for r in breached],
"pass": len(breached) == 0,
}
with open(os.path.join(args.out, "n10_discount_bench.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑的折后单位价值对照表(每元玉璧数,顶档满价基准 = 12000,加粗 = 击穿):
| 档位 | 满价 | d10 | d30 | d50 |
|---|---|---|---|---|
| t1 | 10000 | 11111 | 14285 | 20000 |
| t2 | 10500 | 11666 | 15000 | 21000 |
| t3 | 11000 | 12222 | 15714 | 22000 |
| t4 | 11500 | 12777 | 16428 | 23000 |
| t5 | 12000 | 13333 | 17142 | 24000 |
脚本输出 breachCount = 13(15 个组合里 13 个击穿),pass = false。读法:
- d10 就已经击穿 3 个档:
t3打 9 折后每元玉璧数 12222,超过了满价顶档的 12000。仅仅 10% 的折扣,就让一个中档的单位价值反超了顶档。 - d30 / d50 击穿全部 5 档:5 折的
t5每元玉璧数 24000,是满价顶档的整整两倍。这意味着打折期间,玩家没有任何理由买原价玉璧——包括顶档。 - 击穿的不是「划算」,是「秩序」:10.1 的梯度表靠「越往上越划算」成立;折扣一上,这个秩序就乱了。玩家会记住「等打折」,而不是「买大的」。
这就是折扣的真实代价:它用一次性的收入提升,换走了价格锚点的长期可信度。限购(上一节)能部分缓解——把折扣量控住,击穿的影响面就小;但深度折扣(d30 以上)只要生效,击穿就不可避免。折扣深度和锚点保护是一对矛盾,运营要在这对矛盾里做取舍,而不是默认「越深越好」。
折扣不能只看收入
把两节的数合起来看,一次折扣活动至少要算三个数:
- 收入变化(S 层):
d30无限购 +330‰,限购 −300‰。这是最直接、也最容易被当成的唯一指标。 - 单位价值击穿(B 层):
d30击穿全部 5 档。这是收入的隐性成本——今天的收入,透支的是明天的原价购买。 - 稀缺是否成立:
limitBinding = true说明限购在起作用,折扣是「抢」而不是「降」。
只盯第一个数,就会得出「d30 无限购最赚,那就别限购」的结论;把三个数一起看,才会发现「不限购的 d30 虽然收入最高,但击穿了全部锚点,等于把价格永久打下来」。折扣决策从来不是选一个最赚的数,而是在收入、锚点、稀缺三者之间找一个能持续的平衡点。
最后划清可复现边界:liftPermillePerOffPct = 30 是设计假设,不是实测。真实的需求响应必须靠真实投放数据回归(X 层——需真实业务环境,命令行不可复现)。本节能做的是:给定需求响应假设,算出各折扣档的收入与击穿情况;做不了的是:告诉你需求响应到底是 30 还是 50。后者的估计方法,是下一节的主题。
小结
折扣有三个变量:深度、窗口、稀缺。本节用 S 层脚本跑出「无限购时 d30 收入最高(+330‰)、限购把它压到 −300‰」,用 B 基准量化出「d10 就击穿 3 个档、d30/d50 击穿全部 5 档」。折扣最深的代价不是少赚了钱,而是把价格锚点的秩序打乱了。而「需求到底响应多少」,正是价格弹性的问题——下一节把它从假设变成可以估计的量。
阅读导航:上一节:10.1 定价锚点与档位设计 · 下一节:10.3 价格弹性与副作用 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。