跳到主要内容
游戏周边服务

本页尚未翻译,当前显示英文版本。

Catalog & Commerce

物品、价格、钱包、商店、购买与权益。 在平台允许的地方接入真实商店(Stripe、App Store、Google Play、Steam、Xbox);商店可以按计划上架、面向特定受众;购买流程的每一步都能挂 hook。

何时使用

  • 你要卖东西:通过 Stripe、App Store、Google Play、Steam 或 Xbox 收真实货币,或者收钱包(wallet)里的货币。
  • 商店(storefront)必须按玩家解析:计划、受众和价格都由服务端计算,客户端从不自己判断购买资格。
  • 定价规则集中在一个可测试的 hook 里:折扣、改价和否决都在扣款之前执行。
  • 收据(receipt)必须防重放。退款必须收回权益(entitlement),而且走发放时的同一组事件。
  • 无需使用:如果你从不出售物品(item)。奖励仍然通过商业(commerce)模块唯一的 Grant 发放,来源为 reward(Leaderboards 的周期宝箱就是这样到账的),所以即使游戏没有商店,也只有一本可审计的发放台账。

分工

主体在本页中的职责
player浏览商店、购买、管理钱包、使用兑换码
seller配置商品目录(catalog)、价格和商店的上架计划
backend-service验证收据;通过购买 hook 改价或发放物品

概览

获取已为该玩家解析好的 main 商店,并用钱包货币购买
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
// client — the storefront arrives already resolved for this player
const front = await playserv.commerce.storefront('main');
const order = await playserv.commerce.purchase(front.items[0], { pay: Pay.wallet('gems') });
# client — the storefront arrives already resolved for this player
front = await playserv.commerce.storefront("main")
order = await playserv.commerce.purchase(front.items[0], pay=Pay.wallet("gems"))
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

// client — the storefront arrives already resolved for this player
Client->Commerce->Storefronts->Select().Then(
    TPSOnResult<TPSPage<FPSStorefront>>::CreateWeakLambda(this, [this](const TPSResult<TPSPage<FPSStorefront>>& Result)
    {
        if (!Result.HasValue()) { return; }
        const FPSStorefront& Front = Result.Value().Rows[0];
        Client->Commerce->Orders->Create(FPSIdempotencyKey(CartId), Front.Items[0], FPSPay::Wallet(TEXT("gems")));
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — 在 Unity 中基于生成的类型原样运行
// client — the storefront arrives already resolved for this player
var front = await playserv.Commerce.Storefront("main");
var order = await playserv.Commerce.Purchase(front.Items.First(), pay: Pay.Wallet("gems"));
两个购买 hook:before 为首次购买改价,after 发放物品
[Before(Commerce.Purchase)]                    // veto or reprice
public static Verdict FirstBuyDiscount(PurchaseIntent p) =>
    p.Player.Purchases == 0 ? Hook.Continue(p.WithPrice(p.Price * 0.5m)) : Hook.Continue(p);

[After(Commerce.Purchase)]                     // grant — side effects only
public static Task Grant(Purchase done) =>
    done.Player.Inventory.Grant(done.Item, done.Count);
// veto or reprice
export const firstBuyDiscount = before(Commerce.purchase, (p: PurchaseIntent) =>
  p.player.purchases === 0 ? Hook.continue(p.withPrice(p.price * 0.5)) : Hook.continue(p));

// grant — side effects only
export const grant = after(Commerce.purchase, (done: Purchase) =>
  done.player.inventory.grant(done.item, done.count));
@before(commerce.purchase)                     # veto or reprice
def first_buy_discount(p: PurchaseIntent) -> Verdict:
    return Hook.continue_(p.with_price(p.price * 0.5)) if p.player.purchases == 0 else Hook.continue_(p)

@after(commerce.purchase)                      # grant — side effects only
async def grant(done: Purchase):
    await done.player.inventory.grant(done.item, done.count)
即将推出 —— Go

Go 中基于特性的声明方式尚未确定(O-1),确定写法后会补上示例。

在引擎之外运行

hook 就是云函数(cloud function):它在平台上执行,不在引擎里。用 C#、TypeScript 或 Python 编写,Unreal 订阅“已购买”/“权益已变更”事件。引擎内编写函数的方式已在计划中:Blueprint 图,以及 C++(转译为经平台校验的脚本目标)。两者都像其他声明一样经过分析并推送,执行仍留在平台上。

在引擎之外运行

hook 就是云函数(cloud function):它在平台上执行,不在引擎里。用 C#、TypeScript 或 Python 编写,Unity 订阅“已购买”/“权益已变更”事件。

模型

商品目录中的物品声明什么。

声明项含义
key物品是创作内容,按 key 寻址,所以在代码里改名就是真正的改名
kindconsumable:用掉即消耗;durable:拥有一次即可
prices价格是货币数量:以最小货币单位计的整数加货币代码,绝不用浮点数。一个物品可以带多个价格,游戏货币和真实货币都行
每个提供方的外部标识符每个提供方(provider)一个槽位,需要声明,因为商店用自己的 id 识别物品
指向的对象可选,指向任意已声明类型的实体;购买该物品即获得这个实体的所有权

允许怎样组合。

含义
可购买的对象只能是商品目录中的物品,不能是任意实体,因为购买需要有人授予权利,并为退款负责
两层,没有第三层礼包(bundle)是由物品组成的物品。商店是一组商品(offer),每个商品指向一个物品,并可覆盖它的价格和礼包内容
地区定价由商店表达,而不是写在物品上

商店声明什么。

声明项含义
offers商品集合,每个商品指向一个物品
schedule按挂钟时间,始终为 UTC:窗口何时开启、何时关闭
audience是一个谓词,不是玩家列表,所以受众是一条持续成立的规则,而不是一份快照

玩家不在某个商店的受众范围内,这个商店对他就不存在。

订单(order)的状态。

从到
createdawaiting payment
awaiting paymentpaid · declined · expired
paidgranted
paid 或 grantedrefunded
始终成立含义
价格在订单创建时固定下来,之后改价改变不了已经约定的内容
awaiting payment有声明的截止时间,而且按提供方声明,因为各家不同
发放与支付分开:paid 和 granted 是两个状态。钱到账和东西到手是两件事,混为一谈就看不出是哪一步失败了
权益记录来源:购买、兑换码、奖励或赠送,所以一年后仍能回答“这是从哪来的”
消耗型权益只做累加:通过带幂等键(idempotency key)的增量变化,从不覆盖读到的值
所有权是一个所有者谓词:权益归属玩家的机制,与任何有所有者的行相同
商品目录在代码中声明,默认以 seed 所有权模式进入管理面板:代码创建缺失的内容,设计师的修改在下次推送后依然保留
提供方密钥存放在运营平面(operator plane),绝不写进声明,也绝不进代码仓库
提供方能力需要声明:它到底有没有可用的 API,能做什么。这样商品目录就不会承诺商店做不到的流程

退款随时可能到来

退款是一次外部状态转换:游戏没有发起任何请求,它也会到来。已发放的内容如何处理是声明出来的:共有三种答案,没有默认值。

每个扩展点都指明它交给 hook 的类型:购买前是购买意图(玩家、商品、提供方、价格),购买后是购买本身。hook 从不接收无类型的数据包。

错误

  • 不在受众范围内返回 not found,重试没有意义。不在计划时间内同样返回 not found,但窗口开启后值得重试。
  • 提供方不可用和提供方拒绝付款是刻意区分的两种答复:前者是 unavailable,可以退避重试;后者是冲突,重试也解决不了。
  • 无效收据属于校验拒绝;已经由其他订单或其他玩家消费掉的收据属于冲突,重放因此毫无用处。
  • 读取商店之后、购买之前价格变了属于 precondition failure:重新读取、重新决定,而不是悄悄按新价格扣款。
  • 游戏货币不足是冲突,不是禁止:购买权限是有的,只是余额不够。充值后值得重试。
  • 已持有的永久型权益属于冲突。
  • 地区或年龄不允许此次购买返回 forbidden,重试没有意义。
  • 订单已过截止时间属于冲突:请创建新订单。
  • 消费限额已用完时,视触发的是哪种限额,返回冲突或限流,并说明限额何时重置。

限制

具体数值会随平台限制一章给出;触及每个上限时的行为现在就已确定。

  • 商品目录的大小:再发布物品会以冲突拒绝。
  • 每个项目的商店数量:拒绝创建。
  • 单个商店的商品数量:拒绝新增;系统绝不悄悄截断商店。
  • 待支付订单的有效期:转为 expired,并发出事件。
  • 购买尝试的频率:限流,并给出截止时间。
  • 每个周期的消费限额:返回冲突,并说明限额何时重置。
  • 订单的保留期:超过声明的期限后订单不再可读,而不是无缘无故消失。
  • 每个玩家的权益数量:拒绝发放,已发放的权益绝不移除。
  • 价格精度不是限制,而是类型:以最小货币单位计的整数。