mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
[econ] gift purchase
This commit is contained in:
+173
-81
@@ -6,6 +6,7 @@ import {
|
|||||||
addXp,
|
addXp,
|
||||||
consumeGift,
|
consumeGift,
|
||||||
createGift,
|
createGift,
|
||||||
|
getAccount,
|
||||||
getGift,
|
getGift,
|
||||||
getOutfits,
|
getOutfits,
|
||||||
getPendingGifts,
|
getPendingGifts,
|
||||||
@@ -506,47 +507,76 @@ interface StoreItem {
|
|||||||
Prices: StorePrice[]
|
Prices: StorePrice[]
|
||||||
/**
|
/**
|
||||||
* The subscriber price list, where the catalog has one (sf300's item 2263 lists 95 tokens
|
* The subscriber price list, where the catalog has one (sf300's item 2263 lists 95 tokens
|
||||||
* in `Prices` and 85 in here). The client renders — and posts as `RequestedPrice` — the
|
* in `Prices` and 85 in here). A subscriber's client renders and posts this as
|
||||||
* subscriber price to a subscriber, so the price check has to read the same list or a
|
* `RequestedPrice`, so checking their buy against `Prices` alone 409s it as "Price has
|
||||||
* subscriber's every buy 409s as "Price has changed".
|
* changed". Treated as a FLOOR rather than the price to expect, because the client also
|
||||||
|
* posts the FULL price for items whose two lists agree (sf3's 2208, 150/150) — see
|
||||||
|
* {@link priceCheck}.
|
||||||
*/
|
*/
|
||||||
SubscriberPrices?: StorePrice[] | null
|
SubscriberPrices?: StorePrice[] | null
|
||||||
PurchasableItemId: number
|
PurchasableItemId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Rec Room Plus discount, in percent off the regular price, floored to whole tokens.
|
* The most Rec Room Plus can take off an item, in percent of the regular price.
|
||||||
*
|
*
|
||||||
* The client applies this ITSELF: a subscriber's client posts `floor(Price * 0.9)` as
|
* The client applies the discount ITSELF and posts the result as `RequestedPrice`, but it
|
||||||
* `RequestedPrice` (95 → 85, 75 → 67, 30 → 27) whatever the catalog says — the captured
|
* does NOT apply it to everything: sf3's item 2208 is 150 tokens in both catalog lists and a
|
||||||
* catalogs carry a `SubscriberPrices` list, but for 1238 of 1382 items it is a verbatim copy
|
* subscriber's client posts 150, while sf300's 2263 is 95/85 and posts 85. Only 144 of the
|
||||||
* of `Prices` (they were captured through a non-subscriber's view), and every entry that does
|
* 1382 captured items carry a discounted `SubscriberPrices` at all, and whether the rest are
|
||||||
* differ is exactly this formula. So the catalog list can't be the source of truth for the
|
* genuinely full price for a subscriber or were merely captured through a non-subscriber's
|
||||||
* check; the formula is, and the list is only honoured where it agrees with it (i.e. is lower).
|
* view isn't answerable from here. So the server doesn't predict the number: it accepts
|
||||||
|
* anything from the regular price down to this much off (see {@link priceCheck}) and charges
|
||||||
|
* what the client asked to pay. Deriving one exact subscriber price instead 409'd every buy
|
||||||
|
* the client priced the other way.
|
||||||
*/
|
*/
|
||||||
const SUBSCRIBER_DISCOUNT_PERCENT = 10
|
const SUBSCRIBER_DISCOUNT_PERCENT = 10
|
||||||
|
|
||||||
|
/** The lowest a subscriber's client can render an item whose regular price is `regular`. */
|
||||||
|
function subscriberFloor(regular: number): number {
|
||||||
|
return Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The price of an item in one currency for one buyer. A non-subscriber pays the `Prices`
|
* The outcome of confirming a client's `RequestedPrice` against the catalog: the price to
|
||||||
* entry. A subscriber pays the `SubscriberPrices` entry when the catalog has a genuinely
|
* actually charge, or why the line can't be sold.
|
||||||
* discounted one for that currency, else {@link SUBSCRIBER_DISCOUNT_PERCENT} off the regular
|
|
||||||
* price — what their client rendered and posted. `undefined` when the item isn't sold in
|
|
||||||
* that currency at all.
|
|
||||||
*/
|
*/
|
||||||
function priceFor(
|
type PriceCheck =
|
||||||
|
| { charge: number }
|
||||||
|
/** The item isn't sold in the requested currency at all. */
|
||||||
|
| 'no-currency'
|
||||||
|
/** The catalog moved under a stale client, or the price was made up. */
|
||||||
|
| 'mismatch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms what the buyer's client rendered, and answers what to charge them.
|
||||||
|
*
|
||||||
|
* A non-subscriber pays the `Prices` entry, exactly. A subscriber pays whatever they asked to
|
||||||
|
* pay within a BAND: the regular price at the top (their client posts it for items it doesn't
|
||||||
|
* discount) down to the catalog's `SubscriberPrices` entry or
|
||||||
|
* {@link SUBSCRIBER_DISCOUNT_PERCENT} off, whichever is lower.
|
||||||
|
*
|
||||||
|
* Charging `RequestedPrice` rather than a server-picked end of the band keeps the debit equal
|
||||||
|
* to the price the buyer was shown. The floor is what bounds the discount: a modified client
|
||||||
|
* can shave at most {@link SUBSCRIBER_DISCOUNT_PERCENT} off, and only while subscribed.
|
||||||
|
*/
|
||||||
|
function priceCheck(
|
||||||
item: StoreItem,
|
item: StoreItem,
|
||||||
currencyType: number,
|
currencyType: number,
|
||||||
subscriber: boolean
|
subscriber: boolean,
|
||||||
): StorePrice | undefined {
|
requestedPrice: unknown
|
||||||
|
): PriceCheck {
|
||||||
const regular = item.Prices.find((p) => p.CurrencyType === currencyType)
|
const regular = item.Prices.find((p) => p.CurrencyType === currencyType)
|
||||||
if (regular === undefined || !subscriber) return regular
|
if (regular === undefined) return 'no-currency'
|
||||||
|
if (!Number.isInteger(requestedPrice)) return 'mismatch'
|
||||||
|
const requested = requestedPrice as number
|
||||||
|
if (requested === regular.Price) return { charge: requested }
|
||||||
|
if (!subscriber) return 'mismatch'
|
||||||
const listed = item.SubscriberPrices?.find((p) => p.CurrencyType === currencyType)
|
const listed = item.SubscriberPrices?.find((p) => p.CurrencyType === currencyType)
|
||||||
if (listed !== undefined && listed.Price < regular.Price) return listed
|
const floor = Math.min(subscriberFloor(regular.Price), listed?.Price ?? regular.Price)
|
||||||
return {
|
return requested >= floor && requested < regular.Price ? { charge: requested } : 'mismatch'
|
||||||
CurrencyType: currencyType,
|
|
||||||
Price: Math.floor((regular.Price * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Storefront {
|
interface Storefront {
|
||||||
StoreItems: StoreItem[]
|
StoreItems: StoreItem[]
|
||||||
}
|
}
|
||||||
@@ -619,15 +649,26 @@ const CONSUMABLE_GRANT_COUNT = 1
|
|||||||
/** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */
|
/** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */
|
||||||
const COACH_ACCOUNT_ID = 1
|
const COACH_ACCOUNT_ID = 1
|
||||||
|
|
||||||
/** Build the stored gift-box content (the client's rendered "gift box") from a gift-drop. */
|
/**
|
||||||
|
* Build the stored gift-box content (the client's rendered "gift box") from a gift-drop.
|
||||||
|
*
|
||||||
|
* `fromPlayerId` and `giftContext` are stamped on because the box outlives the request that
|
||||||
|
* made it: a gift's receiver may well be offline and meets it in `GET /api/avatar/v2/gifts`,
|
||||||
|
* with nothing but the row to say who sent it or why. They default to Coach and the drop's
|
||||||
|
* own context — a box the server handed over on nobody's behalf.
|
||||||
|
*/
|
||||||
function toGiftContent(
|
function toGiftContent(
|
||||||
giftDrop: StoreGiftDrop,
|
giftDrop: StoreGiftDrop,
|
||||||
message: string,
|
message: string,
|
||||||
consumableCount: number,
|
consumableCount: number,
|
||||||
consumableMappingId = 0,
|
consumableMappingId = 0,
|
||||||
consumablePreExistingCount = 0
|
consumablePreExistingCount = 0,
|
||||||
|
fromPlayerId = COACH_ACCOUNT_ID,
|
||||||
|
giftContext: number | null = null
|
||||||
): GiftContent {
|
): GiftContent {
|
||||||
return {
|
return {
|
||||||
|
FromPlayerId: fromPlayerId,
|
||||||
|
GiftContext: giftContext ?? giftDrop.Context,
|
||||||
ConsumableItemDesc: giftDrop.ConsumableItemDesc,
|
ConsumableItemDesc: giftDrop.ConsumableItemDesc,
|
||||||
ConsumableCount: consumableCount,
|
ConsumableCount: consumableCount,
|
||||||
ConsumableMappingId: consumableMappingId,
|
ConsumableMappingId: consumableMappingId,
|
||||||
@@ -671,7 +712,8 @@ async function pushGiftReceived(
|
|||||||
accountId: number,
|
accountId: number,
|
||||||
gift: GrantedGift,
|
gift: GrantedGift,
|
||||||
message: string,
|
message: string,
|
||||||
fromPlayerId: number
|
fromPlayerId: number,
|
||||||
|
giftContext: number | null = null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
@@ -693,7 +735,7 @@ async function pushGiftReceived(
|
|||||||
Platform: -1,
|
Platform: -1,
|
||||||
PlatformsToSpawnOn: -1,
|
PlatformsToSpawnOn: -1,
|
||||||
BalanceType: ALL_PLATFORMS,
|
BalanceType: ALL_PLATFORMS,
|
||||||
GiftContext: gift.drop.Context,
|
GiftContext: giftContext ?? gift.drop.Context,
|
||||||
GiftRarity: gift.drop.Rarity,
|
GiftRarity: gift.drop.Rarity,
|
||||||
Message: message,
|
Message: message,
|
||||||
}
|
}
|
||||||
@@ -903,6 +945,13 @@ interface GrantOptions extends RollOptions {
|
|||||||
* it is saying its own UI announces the items.
|
* it is saying its own UI announces the items.
|
||||||
*/
|
*/
|
||||||
skipGiftBox?: boolean
|
skipGiftBox?: boolean
|
||||||
|
/**
|
||||||
|
* Who the box says it is from, and why it exists — a purchase gifted to another player
|
||||||
|
* carries the buyer (or Coach, when they sent it anonymously) and the `Gift` block's
|
||||||
|
* `GiftContext`. Default: Coach and the drop's own context, i.e. a box from the server.
|
||||||
|
*/
|
||||||
|
fromPlayerId?: number
|
||||||
|
giftContext?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -989,7 +1038,15 @@ async function grantGiftDrop(
|
|||||||
const { id } = await createGift(
|
const { id } = await createGift(
|
||||||
db,
|
db,
|
||||||
accountId,
|
accountId,
|
||||||
toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
|
toGiftContent(
|
||||||
|
giftDrop,
|
||||||
|
message,
|
||||||
|
consumableCount,
|
||||||
|
consumableMappingId,
|
||||||
|
consumablePreExisting,
|
||||||
|
options.fromPlayerId,
|
||||||
|
options.giftContext
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return { id, drop: giftDrop }
|
return { id, drop: giftDrop }
|
||||||
}
|
}
|
||||||
@@ -1184,9 +1241,9 @@ function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined):
|
|||||||
*
|
*
|
||||||
* Pure — the catalog and the buyer's subscriber status are passed in — so the whole bag
|
* Pure — the catalog and the buyer's subscriber status are passed in — so the whole bag
|
||||||
* resolves from ONE storefront read and ONE token read. The price check is buyItem's, per
|
* resolves from ONE storefront read and ONE token read. The price check is buyItem's, per
|
||||||
* line: `RequestedPrice` is the UNIT price the client rendered (the subscriber price, for a
|
* line: `RequestedPrice` is the UNIT price the client rendered (for a subscriber, anywhere in
|
||||||
* subscriber — see {@link priceFor}), and a mismatch means the catalog moved under a stale
|
* the discount band — see {@link priceCheck}), and a mismatch means the catalog moved under a
|
||||||
* client rather than that the player agreed to today's price.
|
* stale client rather than that the player agreed to today's price.
|
||||||
*/
|
*/
|
||||||
function resolveBulkLine(
|
function resolveBulkLine(
|
||||||
line: PurchaseItemRequest,
|
line: PurchaseItemRequest,
|
||||||
@@ -1241,30 +1298,25 @@ function resolveBulkLine(
|
|||||||
error: 'This item can only be bought once per line',
|
error: 'This item can only be bought once per line',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const price = priceFor(item, currencyType, subscriber)
|
const checked = priceCheck(item, currencyType, subscriber, line.RequestedPrice)
|
||||||
if (price === undefined) {
|
if (checked === 'no-currency') {
|
||||||
return {
|
return {
|
||||||
method,
|
method,
|
||||||
code: UpdateResponse.NoItemAvailable,
|
code: UpdateResponse.NoItemAvailable,
|
||||||
error: 'Currency type not available for this item',
|
error: 'Currency type not available for this item',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(line.RequestedPrice)) {
|
if (checked === 'mismatch') {
|
||||||
return {
|
return {
|
||||||
method,
|
method,
|
||||||
code: UpdateResponse.RequestedPriceDoesNotMatch,
|
code: UpdateResponse.RequestedPriceDoesNotMatch,
|
||||||
error: 'RequestedPrice is required',
|
error: !Number.isInteger(line.RequestedPrice)
|
||||||
}
|
? 'RequestedPrice is required'
|
||||||
}
|
: 'Price has changed',
|
||||||
if (line.RequestedPrice !== price.Price) {
|
|
||||||
return {
|
|
||||||
method,
|
|
||||||
code: UpdateResponse.RequestedPriceDoesNotMatch,
|
|
||||||
error: 'Price has changed',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
|
const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
|
||||||
return { method, item, price: price.Price, count, gift }
|
return { method, item, price: checked.charge, count, gift }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a resolved line is buyable or is already a failure. */
|
/** Whether a resolved line is buyable or is already a failure. */
|
||||||
@@ -2485,10 +2537,14 @@ const app = new Hono<App>({ strict: false })
|
|||||||
summary: 'Buy a storefront item',
|
summary: 'Buy a storefront item',
|
||||||
description: [
|
description: [
|
||||||
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
'Looks the item up in its storefront catalog, confirms the client’s `RequestedPrice`',
|
||||||
'still matches (the `SubscriberPrices` entry for a Rec Room Plus subscriber — the same',
|
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same check as',
|
||||||
'check as `UpdateAndGetSubscription` — else the `Prices` one), debits the buyer atomically, grants the item (into the inventory or',
|
'`UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
|
||||||
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
|
'client applies the discount itself and not to every item — debits the buyer atomically,',
|
||||||
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
|
'grants the item (into the inventory or',
|
||||||
|
'consumable table), and returns a gift box. A `Gift` block routes the item — and its',
|
||||||
|
'box — to the player it names, who is handed it over the hub as',
|
||||||
|
'`GiftPackageReceivedImmediate`; the caller always pays, and `Anonymous` hides them',
|
||||||
|
'from the box rather than withholding it. `Balance` in the response is the CHANGE (negated',
|
||||||
'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the',
|
'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the',
|
||||||
'buyer’s account-wide bucket to the RESULTING total, so the frame, this body and a',
|
'buyer’s account-wide bucket to the RESULTING total, so the frame, this body and a',
|
||||||
'`GET /balance` re-fetch all agree (`Delta` there is display-only).',
|
'`GET /balance` re-fetch all agree (`Delta` there is display-only).',
|
||||||
@@ -2499,7 +2555,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'),
|
200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'),
|
||||||
400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'),
|
400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
404: json(ErrorResponse, 'No such item'),
|
404: json(ErrorResponse, 'No such item, or a `Gift` naming a player that does not exist'),
|
||||||
409: json(ErrorResponse, 'The price has changed since the client rendered it'),
|
409: json(ErrorResponse, 'The price has changed since the client rendered it'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -2533,15 +2589,21 @@ const app = new Hono<App>({ strict: false })
|
|||||||
const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
|
const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
|
||||||
if (item === null) return c.json({ error: 'Item not found' }, 404)
|
if (item === null) return c.json({ error: 'Item not found' }, 404)
|
||||||
|
|
||||||
// A subscriber is shown, and posts, the item's `SubscriberPrices` entry; checking the
|
// A subscriber's client prices the item itself and posts the result, so the check is a
|
||||||
// regular price against it 409'd every subscriber buy of a discounted item.
|
// band rather than one number — see `priceCheck`. `charge` is what they asked to pay.
|
||||||
const price = priceFor(item, currencyType as number, await isSubscriber(c))
|
const checked = priceCheck(
|
||||||
if (price === undefined) {
|
item,
|
||||||
|
currencyType as number,
|
||||||
|
await isSubscriber(c),
|
||||||
|
requestedPrice
|
||||||
|
)
|
||||||
|
if (checked === 'no-currency') {
|
||||||
return c.json({ error: 'Currency type not available for this item' }, 400)
|
return c.json({ error: 'Currency type not available for this item' }, 400)
|
||||||
}
|
}
|
||||||
if (price.Price !== requestedPrice) {
|
if (checked === 'mismatch') {
|
||||||
return c.json({ error: 'Price has changed' }, 409)
|
return c.json({ error: 'Price has changed' }, 409)
|
||||||
}
|
}
|
||||||
|
const price = checked.charge
|
||||||
// The item's currency must be an account balance we can debit (RecCenterTokens et al),
|
// The item's currency must be an account balance we can debit (RecCenterTokens et al),
|
||||||
// not a room-scoped or non-spendable currency.
|
// not a room-scoped or non-spendable currency.
|
||||||
if (!isSpendable(currencyType as number)) {
|
if (!isSpendable(currencyType as number)) {
|
||||||
@@ -2556,17 +2618,19 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
|
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
|
||||||
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
||||||
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
|
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
|
||||||
|
const giftContext = Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
|
||||||
|
// A gift is paid for here and granted THERE, so an id that names nobody would take the
|
||||||
|
// buyer's tokens and strand the box on an account that will never read it. The client
|
||||||
|
// only offers players it just looked up, so this is a tampered or stale id — refuse it
|
||||||
|
// before charging rather than after.
|
||||||
|
if (receiverId !== id && (await getAccount(c.env.DB, receiverId)) === null) {
|
||||||
|
return c.json({ error: 'No such player to gift to' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||||
// Debit the buyer atomically; a false return means they couldn't afford it and
|
// Debit the buyer atomically; a false return means they couldn't afford it and
|
||||||
// nothing changed, so no item is granted.
|
// nothing changed, so no item is granted.
|
||||||
const paid = await spendCurrency(
|
const paid = await spendCurrency(c.env.DB, id, currencyType as number, price, startingTokens)
|
||||||
c.env.DB,
|
|
||||||
id,
|
|
||||||
currencyType as number,
|
|
||||||
price.Price,
|
|
||||||
startingTokens
|
|
||||||
)
|
|
||||||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
||||||
|
|
||||||
// Grant the item to the recipient, with the gift box that renders it. A box (an
|
// Grant the item to the recipient, with the gift box that renders it. A box (an
|
||||||
@@ -2574,7 +2638,18 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// `granted.drop` is what the roll landed on — the response has to describe THAT, not
|
// `granted.drop` is what the roll landed on — the response has to describe THAT, not
|
||||||
// the box, or a query purchase answers with every item field empty and the client
|
// the box, or a query purchase answers with every item field empty and the client
|
||||||
// draws an empty box.
|
// draws an empty box.
|
||||||
const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message)
|
const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message, {
|
||||||
|
fromPlayerId,
|
||||||
|
giftContext,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The buyer reads their own box out of the response below, but a gift's receiver has
|
||||||
|
// no response to read — they may not even be online. Hand them the box the way every
|
||||||
|
// other server-handed box arrives, so it pops in front of them instead of waiting for
|
||||||
|
// their client's next `GET /api/avatar/v2/gifts`.
|
||||||
|
if (receiverId !== id) {
|
||||||
|
await pushGiftReceived(c, receiverId, granted, message, fromPlayerId, giftContext)
|
||||||
|
}
|
||||||
|
|
||||||
// Push the spend to the buyer (`id` — the caller is who was charged) so their client
|
// Push the spend to the buyer (`id` — the caller is who was charged) so their client
|
||||||
// updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
|
// updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
|
||||||
@@ -2582,7 +2657,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// with both the response body below and any re-fetch instead of compounding with them
|
// with both the response body below and any re-fetch instead of compounding with them
|
||||||
// — see the frame rule above pushBalanceUpdate. Best-effort.
|
// — see the frame rule above pushBalanceUpdate. Best-effort.
|
||||||
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
|
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
|
||||||
await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance)
|
await pushBalancePurchase(c, id, currencyType as number, -price, newBalance)
|
||||||
|
|
||||||
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
|
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
|
||||||
// negated price), not the resulting balance (the client reads its new total from
|
// negated price), not the resulting balance (the client reads its new total from
|
||||||
@@ -2591,17 +2666,10 @@ const app = new Hono<App>({ strict: false })
|
|||||||
BalanceUpdates: [
|
BalanceUpdates: [
|
||||||
{
|
{
|
||||||
UpdateResponse: 0,
|
UpdateResponse: 0,
|
||||||
Data: [
|
Data: [toBalanceUpdateData(granted, fromPlayerId, message, giftContext)],
|
||||||
toBalanceUpdateData(
|
|
||||||
granted,
|
|
||||||
fromPlayerId,
|
|
||||||
message,
|
|
||||||
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
|
|
||||||
),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
Balance: -price.Price,
|
Balance: -price,
|
||||||
CurrencyType: currencyType,
|
CurrencyType: currencyType,
|
||||||
BalanceType: ALL_PLATFORMS,
|
BalanceType: ALL_PLATFORMS,
|
||||||
})
|
})
|
||||||
@@ -2646,6 +2714,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
200: json(BulkPurchaseResponse, 'The bag’s result, or `Success: false` if nothing sold'),
|
200: json(BulkPurchaseResponse, 'The bag’s result, or `Success: false` if nothing sold'),
|
||||||
400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
|
400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
404: json(BulkPurchaseResponse, 'A line gifts to a player that does not exist'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -2656,7 +2725,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// this shape never has to special-case one. A null `Value` is legal here (the client's
|
// this shape never has to special-case one. A null `Value` is legal here (the client's
|
||||||
// validator only cascades into a non-null one), and it is the honest answer: nothing
|
// validator only cascades into a non-null one), and it is the honest answer: nothing
|
||||||
// was bought, so there is no balance to report and nothing to render.
|
// was bought, so there is no balance to report and nothing to render.
|
||||||
const refuse = (error: string, status: 200 | 400 = 200) =>
|
const refuse = (error: string, status: 200 | 400 | 404 = 200) =>
|
||||||
c.json({ Success: false, Error: error, error_id: null, Value: null }, status)
|
c.json({ Success: false, Error: error, error_id: null, Value: null }, status)
|
||||||
|
|
||||||
const body = (await c.req.json().catch(() => null)) as {
|
const body = (await c.req.json().catch(() => null)) as {
|
||||||
@@ -2703,6 +2772,20 @@ const app = new Hono<App>({ strict: false })
|
|||||||
const firstFailure = resolved.find((line): line is BulkLineFailure => !isBulkLine(line))
|
const firstFailure = resolved.find((line): line is BulkLineFailure => !isBulkLine(line))
|
||||||
if (!allowPartial && firstFailure !== undefined) return refuse(firstFailure.error)
|
if (!allowPartial && firstFailure !== undefined) return refuse(firstFailure.error)
|
||||||
|
|
||||||
|
// Same as buyItem: a line gifting to an id that names nobody would charge the buyer and
|
||||||
|
// strand the box. One lookup per DISTINCT recipient, and the whole bag refuses — a bad
|
||||||
|
// recipient is a malformed request, not a line that merely didn't fit.
|
||||||
|
const recipients = new Set<number>()
|
||||||
|
for (const line of buyable) {
|
||||||
|
const to = line.gift?.ToPlayerId
|
||||||
|
if (Number.isInteger(to) && to !== id) recipients.add(to as number)
|
||||||
|
}
|
||||||
|
for (const to of recipients) {
|
||||||
|
if ((await getAccount(c.env.DB, to)) === null) {
|
||||||
|
return refuse('No such player to gift to', 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Decide what the balance covers BEFORE spending: lines are taken in request order
|
// Decide what the balance covers BEFORE spending: lines are taken in request order
|
||||||
// while they fit, so a bag that overruns still buys the items the player put in first.
|
// while they fit, so a bag that overruns still buys the items the player put in first.
|
||||||
// The read is only for choosing; the single spend below is what actually settles, and
|
// The read is only for choosing; the single spend below is what actually settles, and
|
||||||
@@ -2752,9 +2835,16 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// player while the caller pays, a named gift shows the sender, and a self-buy or an
|
// player while the caller pays, a named gift shows the sender, and a self-buy or an
|
||||||
// anonymous gift is attributed to the "Coach" system account.
|
// anonymous gift is attributed to the "Coach" system account.
|
||||||
const gift = line.gift
|
const gift = line.gift
|
||||||
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
|
// Annotated: without it the inference of this handler's own type runs through the
|
||||||
|
// hub call below and back, and tsc gives up on the initializer (TS7022).
|
||||||
|
const receiverId: number = Number.isInteger(gift?.ToPlayerId)
|
||||||
|
? (gift?.ToPlayerId as number)
|
||||||
|
: id
|
||||||
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
|
||||||
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
|
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
|
||||||
|
const giftContext = Number.isInteger(gift?.GiftContext)
|
||||||
|
? (gift?.GiftContext as number)
|
||||||
|
: null
|
||||||
// One box per requested item, holding all `count` copies — the wire has one
|
// One box per requested item, holding all `count` copies — the wire has one
|
||||||
// `GiftPackage` per entry, and only a consumable can be asked for more than once
|
// `GiftPackage` per entry, and only a consumable can be asked for more than once
|
||||||
// (`resolveBulkLine` refuses a bigger count on anything owned once).
|
// (`resolveBulkLine` refuses a bigger count on anything owned once).
|
||||||
@@ -2762,20 +2852,22 @@ const app = new Hono<App>({ strict: false })
|
|||||||
rollCatalog,
|
rollCatalog,
|
||||||
skipGiftBox,
|
skipGiftBox,
|
||||||
copies: line.count,
|
copies: line.count,
|
||||||
|
fromPlayerId,
|
||||||
|
giftContext,
|
||||||
})
|
})
|
||||||
|
// The bag's own response carries only the buyer's boxes, so a gifted line is
|
||||||
|
// announced to its receiver the same way buyItem's is. `BypassGiftPackages` skipped
|
||||||
|
// the box entirely, and there is nothing to announce.
|
||||||
|
if (receiverId !== id && !skipGiftBox) {
|
||||||
|
await pushGiftReceived(c, receiverId, granted, message, fromPlayerId, giftContext)
|
||||||
|
}
|
||||||
packages.set(
|
packages.set(
|
||||||
line,
|
line,
|
||||||
// Null under `BypassGiftPackages`, which is the flag asking for exactly that —
|
// Null under `BypassGiftPackages`, which is the flag asking for exactly that —
|
||||||
// the item is granted either way.
|
// the item is granted either way.
|
||||||
skipGiftBox
|
skipGiftBox
|
||||||
? null
|
? null
|
||||||
: toGiftPackage(
|
: toGiftPackage(granted, receiverId, fromPlayerId, message, giftContext)
|
||||||
granted,
|
|
||||||
receiverId,
|
|
||||||
fromPlayerId,
|
|
||||||
message,
|
|
||||||
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1266,7 +1266,8 @@ describe('econ endpoints', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// sf300's item 2263 is 95 tokens in `Prices` and 85 in `SubscriberPrices`. A subscriber's
|
// sf300's item 2263 is 95 tokens in `Prices` and 85 in `SubscriberPrices`. A subscriber's
|
||||||
// client renders and posts the 85, so the check has to read the list the buyer sees.
|
// client applies the Plus discount itself, but not to every item, so the server takes any
|
||||||
|
// price in the band [85, 95] from a subscriber and charges what they asked to pay.
|
||||||
const buy2263 = async (headers: Record<string, string>, RequestedPrice: number) =>
|
const buy2263 = async (headers: Record<string, string>, RequestedPrice: number) =>
|
||||||
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1292,9 +1293,9 @@ describe('econ endpoints', () => {
|
|||||||
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 85 }])
|
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 85 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem derives the subscriber price when the catalog lists none', async () => {
|
test('POST /api/storefronts/v2/buyItem takes 10% off from a subscriber when the catalog lists no discount', async () => {
|
||||||
// sf3's item 2184 is 95 in BOTH lists (captured through a non-subscriber's view), but a
|
// sf3's item 2184 is 95 in BOTH lists, but a subscriber's client may still post
|
||||||
// subscriber's client still posts floor(95 * 0.9) = 85.
|
// floor(95 * 0.9) = 85.
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1314,9 +1315,38 @@ describe('econ endpoints', () => {
|
|||||||
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem 409s a subscriber posting the regular price', async () => {
|
test('POST /api/storefronts/v2/buyItem charges a subscriber the full price when their client sends it', async () => {
|
||||||
const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 95)
|
// sf3's item 2208 is 150 in both lists and the live client posts 150 for a subscriber:
|
||||||
|
// not every item is discounted, so the full price has to stay buyable by a subscriber.
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer('326', ['gameClient', 'developer'])),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
StorefrontType: 3,
|
||||||
|
PurchasableItemId: 2208,
|
||||||
|
CurrencyType: 2,
|
||||||
|
RequestedPrice: 150,
|
||||||
|
CouponConsumablePlayerMappingId: null,
|
||||||
|
Gift: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(((await res.json()) as { Balance: number }).Balance).toBe(-150)
|
||||||
|
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||||
|
headers: await bearer('326'),
|
||||||
|
})
|
||||||
|
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 150 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => {
|
||||||
|
const res = await buy2263(await bearer('323', ['gameClient', 'developer']), 84)
|
||||||
expect(res.status).toBe(409)
|
expect(res.status).toBe(409)
|
||||||
|
// …and above it: a made-up price is a mismatch in either direction.
|
||||||
|
const over = await buy2263(await bearer('323', ['gameClient', 'developer']), 96)
|
||||||
|
expect(over.status).toBe(409)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem 409s a non-subscriber posting the subscriber price', async () => {
|
test('POST /api/storefronts/v2/buyItem 409s a non-subscriber posting the subscriber price', async () => {
|
||||||
@@ -1326,6 +1356,141 @@ describe('econ endpoints', () => {
|
|||||||
expect(ok.status).toBe(200)
|
expect(ok.status).toBe(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Seed a real account row, so a gift naming this player has somewhere to land. */
|
||||||
|
const seedAccount = async (accountId: number, username: string) => {
|
||||||
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(JSON.stringify({ accountId, username, displayName: username }))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buy sf3's item 2107 (Backpack Skin (Camo), 3500) with a `Gift` block, as the client posts it. */
|
||||||
|
const giftBackpack = async (sub: string, gift: Record<string, unknown> | null) =>
|
||||||
|
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
StorefrontType: 3,
|
||||||
|
PurchasableItemId: 2107,
|
||||||
|
CurrencyType: 2,
|
||||||
|
RequestedPrice: 3500,
|
||||||
|
CouponConsumablePlayerMappingId: null,
|
||||||
|
Gift: gift,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingGifts = async (sub: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
|
||||||
|
headers: await bearer(sub),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
return (await res.json()) as Array<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipment = async (sub: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
|
||||||
|
headers: await bearer(sub),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
return (await res.json()) as Array<{ ModificationGuid: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v2/buyItem charges the buyer and hands the item to the gift’s receiver', async () => {
|
||||||
|
await seedAccount(205, 'GiftReceiver')
|
||||||
|
await drainFrames()
|
||||||
|
const res = await giftBackpack('330', {
|
||||||
|
ToPlayerId: 205,
|
||||||
|
Message: 'hello this is a message',
|
||||||
|
Anonymous: false,
|
||||||
|
GiftContext: 500,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
// The buyer pays — `Balance` is their change — even though nothing lands on them.
|
||||||
|
expect(((await res.json()) as { Balance: number }).Balance).toBe(-3500)
|
||||||
|
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||||
|
headers: await bearer('330'),
|
||||||
|
})
|
||||||
|
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 3500 }])
|
||||||
|
|
||||||
|
// The item and its box are the RECEIVER's; the buyer keeps neither.
|
||||||
|
expect(await equipment('330')).toEqual([])
|
||||||
|
expect(await pendingGifts('330')).toEqual([])
|
||||||
|
const [box, ...rest] = await equipment('205')
|
||||||
|
expect(rest).toEqual([])
|
||||||
|
expect(box?.ModificationGuid).toBe('523e3615-4633-41a3-9b2d-17d3207a684b')
|
||||||
|
const [gift, ...others] = await pendingGifts('205')
|
||||||
|
expect(others).toEqual([])
|
||||||
|
// The box outlives the request, so it carries who sent it and why — the receiver may
|
||||||
|
// only ever meet it in this list.
|
||||||
|
expect(gift?.FromPlayerId).toBe(330)
|
||||||
|
expect(gift?.GiftContext).toBe(500)
|
||||||
|
expect(gift?.Message).toBe('hello this is a message')
|
||||||
|
|
||||||
|
// The receiver has no response to read, so the box is pushed to them.
|
||||||
|
const frames = await drainFrames()
|
||||||
|
const received = frames.find(
|
||||||
|
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
|
||||||
|
)
|
||||||
|
expect(received?.accountId).toBe(205)
|
||||||
|
expect(received?.payload).toMatchObject({
|
||||||
|
Id: gift?.Id,
|
||||||
|
FromPlayerId: 330,
|
||||||
|
GiftContext: 500,
|
||||||
|
Message: 'hello this is a message',
|
||||||
|
EquipmentModificationGuid: '523e3615-4633-41a3-9b2d-17d3207a684b',
|
||||||
|
})
|
||||||
|
// …and the spend frame still goes to the BUYER, who is the one who paid.
|
||||||
|
const spend = frames.find(
|
||||||
|
(f) => f.notificationType === NotificationType.StorefrontBalancePurchase
|
||||||
|
)
|
||||||
|
expect(spend?.accountId).toBe(330)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v2/buyItem attributes an anonymous gift to Coach', async () => {
|
||||||
|
await seedAccount(206, 'AnonReceiver')
|
||||||
|
await drainFrames()
|
||||||
|
const res = await giftBackpack('331', {
|
||||||
|
ToPlayerId: 206,
|
||||||
|
Message: 'guess who',
|
||||||
|
Anonymous: true,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
// Anonymous hides the sender from the box, it does not withhold the gift: id 1 is Coach.
|
||||||
|
const [gift] = await pendingGifts('206')
|
||||||
|
expect(gift?.FromPlayerId).toBe(1)
|
||||||
|
expect(gift?.Message).toBe('guess who')
|
||||||
|
const received = (await drainFrames()).find(
|
||||||
|
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
|
||||||
|
)
|
||||||
|
expect(received?.payload).toMatchObject({ FromPlayerId: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v2/buyItem 404s a gift to a player that does not exist', async () => {
|
||||||
|
await drainFrames()
|
||||||
|
const res = await giftBackpack('332', { ToPlayerId: 999999, Message: 'hi', Anonymous: false })
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
expect(await res.json()).toEqual({ error: 'No such player to gift to' })
|
||||||
|
// Refused before the debit: the buyer still has every token, and nothing was pushed.
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 332, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(10000)
|
||||||
|
expect(await drainFrames()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v2/buyItem gifting to yourself is just a purchase', async () => {
|
||||||
|
await seedAccount(333, 'SelfGifter')
|
||||||
|
await drainFrames()
|
||||||
|
const res = await giftBackpack('333', { ToPlayerId: 333, Message: 'treat', Anonymous: false })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const [gift] = await pendingGifts('333')
|
||||||
|
expect(gift?.FromPlayerId).toBe(333)
|
||||||
|
// No hub gift frame: the buyer read the box out of the response.
|
||||||
|
expect(
|
||||||
|
(await drainFrames()).filter(
|
||||||
|
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
|
||||||
|
)
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem 404s for an unknown item', async () => {
|
test('POST /api/storefronts/v2/buyItem 404s for an unknown item', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1642,6 +1807,58 @@ describe('econ endpoints', () => {
|
|||||||
expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)')
|
expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /api/items/bulkpurchase routes a gifted line to its receiver', async () => {
|
||||||
|
await seedAccount(207, 'BagReceiver')
|
||||||
|
await drainFrames()
|
||||||
|
const res = await bulkPurchase('960', {
|
||||||
|
PurchaseItemRequests: [
|
||||||
|
line(10, 200),
|
||||||
|
line(2182, 100, {
|
||||||
|
Gift: { ToPlayerId: 207, Message: 'from the bag', Anonymous: false, GiftContext: 500 },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const body = (await res.json()) as BulkBody
|
||||||
|
expect(body.Success).toBe(true)
|
||||||
|
// The buyer pays for both lines; only the first one lands on them.
|
||||||
|
expect(body.Value!.Balance).toBe(10000 - 300)
|
||||||
|
const [own, gifted] = body.Value!.BalanceUpdates
|
||||||
|
expect(own?.Data.GiftPackage).toMatchObject({ PlayerId: 960, FromPlayerId: 1 })
|
||||||
|
expect(gifted?.Data.GiftPackage).toMatchObject({
|
||||||
|
PlayerId: 207,
|
||||||
|
FromPlayerId: 960,
|
||||||
|
GiftContext: 500,
|
||||||
|
})
|
||||||
|
expect(await pendingGifts('960')).toHaveLength(1)
|
||||||
|
const [box] = await pendingGifts('207')
|
||||||
|
expect(box?.FromPlayerId).toBe(960)
|
||||||
|
expect(box?.GiftContext).toBe(500)
|
||||||
|
expect(box?.Message).toBe('from the bag')
|
||||||
|
// The bag's response is the buyer's; the receiver is told over the hub instead.
|
||||||
|
const received = (await drainFrames()).find(
|
||||||
|
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
|
||||||
|
)
|
||||||
|
expect(received?.accountId).toBe(207)
|
||||||
|
expect(received?.payload).toMatchObject({ Id: box?.Id, FromPlayerId: 960, GiftContext: 500 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/items/bulkpurchase 404s a bag gifting to a player that does not exist', async () => {
|
||||||
|
const res = await bulkPurchase('970', {
|
||||||
|
PurchaseItemRequests: [
|
||||||
|
line(10, 200),
|
||||||
|
line(2182, 100, { Gift: { ToPlayerId: 999998, Message: 'hi', Anonymous: false } }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
const body = (await res.json()) as BulkBody
|
||||||
|
expect(body.Success).toBe(false)
|
||||||
|
expect(body.Error).toBe('No such player to gift to')
|
||||||
|
// The whole bag is refused before the debit, the good line included.
|
||||||
|
expect(
|
||||||
|
await getBalance(env.DB, 970, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||||
|
).toBe(10000)
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/items/bulkpurchase reports per line what it cannot sell', async () => {
|
test('POST /api/items/bulkpurchase reports per line what it cannot sell', async () => {
|
||||||
await drainFrames()
|
await drainFrames()
|
||||||
const res = await bulkPurchase('96', {
|
const res = await bulkPurchase('96', {
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ export const RECEIVED_GIFT_SCHEMA_DDL: string[] = [
|
|||||||
* `CreatedAt` are NOT part of this — they come from the row (see {@link StoredGift}).
|
* `CreatedAt` are NOT part of this — they come from the row (see {@link StoredGift}).
|
||||||
*/
|
*/
|
||||||
export interface GiftContent extends Record<string, unknown> {
|
export interface GiftContent extends Record<string, unknown> {
|
||||||
|
/**
|
||||||
|
* Who the box is from — the buyer for a named gift, the "Coach" system account (1) for a
|
||||||
|
* self-purchase or an anonymous one. The client draws the sender from the box itself, so
|
||||||
|
* a gift that doesn't carry it reads as being from nobody once the receiving player has
|
||||||
|
* to come back for it. Boxes written before this existed carry neither it nor
|
||||||
|
* {@link GiftContent.GiftContext}.
|
||||||
|
*/
|
||||||
|
FromPlayerId?: number
|
||||||
|
/** Why the box exists (the buying `Gift` block's `GiftContext`, else the drop's own). */
|
||||||
|
GiftContext?: number
|
||||||
ConsumableItemDesc: string
|
ConsumableItemDesc: string
|
||||||
ConsumableCount: number
|
ConsumableCount: number
|
||||||
// When the gift carries a consumable, the id of the `consumable` row granted at
|
// When the gift carries a consumable, the id of the `consumable` row granted at
|
||||||
|
|||||||
Reference in New Issue
Block a user