10.2 折扣、限时与稀缺

折扣不只是「便宜」。本节用 config/pricing.json 的折扣档与 config/n10_pricing.json 的限时窗口、稀缺限购,仿真每个折扣档在无限购与限购两种情形下的收入,再用 B 基准量化「折扣击穿价格锚点」:低档折后的单位价值超过了满价顶档。

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 分):

折扣折后价(分)需求倍率限购可售量实售量无限购收入限购收入无限购变化限购变化
d1027001300‰0130013003,510,0003,510,000+170‰+170‰
d3021001900‰1190010003,990,0002,100,000+330‰−300‰
d5015002500‰1250010003,750,0001,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,加粗 = 击穿):

档位满价d10d30d50
t110000111111428520000
t210500116661500021000
t311000122221571422000
t411500127771642823000
t512000133331714224000

脚本输出 breachCount = 13(15 个组合里 13 个击穿),pass = false。读法:

  • d10 就已经击穿 3 个档:t3 打 9 折后每元玉璧数 12222,超过了满价顶档的 12000。仅仅 10% 的折扣,就让一个中档的单位价值反超了顶档。
  • d30 / d50 击穿全部 5 档:5 折的 t5 每元玉璧数 24000,是满价顶档的整整两倍。这意味着打折期间,玩家没有任何理由买原价玉璧——包括顶档。
  • 击穿的不是「划算」,是「秩序」:10.1 的梯度表靠「越往上越划算」成立;折扣一上,这个秩序就乱了。玩家会记住「等打折」,而不是「买大的」。

这就是折扣的真实代价:它用一次性的收入提升,换走了价格锚点的长期可信度。限购(上一节)能部分缓解——把折扣量控住,击穿的影响面就小;但深度折扣(d30 以上)只要生效,击穿就不可避免。折扣深度和锚点保护是一对矛盾,运营要在这对矛盾里做取舍,而不是默认「越深越好」。

折扣不能只看收入

把两节的数合起来看,一次折扣活动至少要算三个数:

  1. 收入变化(S 层):d30 无限购 +330‰,限购 −300‰。这是最直接、也最容易被当成的唯一指标。
  2. 单位价值击穿(B 层):d30 击穿全部 5 档。这是收入的隐性成本——今天的收入,透支的是明天的原价购买。
  3. 稀缺是否成立:limitBinding = true 说明限购在起作用,折扣是「抢」而不是「降」。

只盯第一个数,就会得出「d30 无限购最赚,那就别限购」的结论;把三个数一起看,才会发现「不限购的 d30 虽然收入最高,但击穿了全部锚点,等于把价格永久打下来」。折扣决策从来不是选一个最赚的数,而是在收入、锚点、稀缺三者之间找一个能持续的平衡点。

最后划清可复现边界:liftPermillePerOffPct = 30 是设计假设,不是实测。真实的需求响应必须靠真实投放数据回归(X 层——需真实业务环境,命令行不可复现)。本节能做的是:给定需求响应假设,算出各折扣档的收入与击穿情况;做不了的是:告诉你需求响应到底是 30 还是 50。后者的估计方法,是下一节的主题。

小结

折扣有三个变量:深度、窗口、稀缺。本节用 S 层脚本跑出「无限购时 d30 收入最高(+330‰)、限购把它压到 −300‰」,用 B 基准量化出「d10 就击穿 3 个档、d30/d50 击穿全部 5 档」。折扣最深的代价不是少赚了钱,而是把价格锚点的秩序打乱了。而「需求到底响应多少」,正是价格弹性的问题——下一节把它从假设变成可以估计的量。

阅读导航:上一节:10.1 定价锚点与档位设计 · 下一节:10.3 价格弹性与副作用 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引