mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 16:08:58 -07:00
[econ] fix room consumable consume
This commit is contained in:
@@ -106,6 +106,8 @@ import {
|
|||||||
ConsumeConsumableRequest,
|
ConsumeConsumableRequest,
|
||||||
ConsumeEnvelope,
|
ConsumeEnvelope,
|
||||||
ConsumeGiftRequest,
|
ConsumeGiftRequest,
|
||||||
|
ConsumeRoomConsumableRequest,
|
||||||
|
ConsumeRoomConsumableResponse,
|
||||||
CreatePurchaseOfferRequest,
|
CreatePurchaseOfferRequest,
|
||||||
CreateRoomCurrencyRequest,
|
CreateRoomCurrencyRequest,
|
||||||
CreateRoomKeyRequest,
|
CreateRoomKeyRequest,
|
||||||
@@ -177,6 +179,7 @@ import {
|
|||||||
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
||||||
import {
|
import {
|
||||||
awardRoomConsumable,
|
awardRoomConsumable,
|
||||||
|
consumeRoomConsumable,
|
||||||
getRoomConsumable,
|
getRoomConsumable,
|
||||||
getRoomConsumables,
|
getRoomConsumables,
|
||||||
getRoomInventory,
|
getRoomInventory,
|
||||||
@@ -4220,6 +4223,65 @@ const app = new Hono<App>({ strict: false })
|
|||||||
return c.json(await getRoomInventory(c.env.DB, roomId, id))
|
return c.json(await getRoomInventory(c.env.DB, roomId, id))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
// Use up one of a room consumable the CALLER holds — drinking the Health Potion. Auth-gated
|
||||||
|
// (401); a refusal is a 200 in the same shape, as the purchase's is.
|
||||||
|
.put(
|
||||||
|
'/api/roomconsumables/v1/roomConsumable/:id/consume',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Econ'],
|
||||||
|
summary: 'Consume a room consumable',
|
||||||
|
description: [
|
||||||
|
'Takes ONE of the consumable named in the path from what the caller holds. The body',
|
||||||
|
'is only `{ CurrentConcurrencyCode, NewConcurrencyCode }`, at the top level; there is',
|
||||||
|
'no quantity. `NewConcurrencyCode` is stored and served back as the item’s',
|
||||||
|
'`ConcurrencyCode`; `CurrentConcurrencyCode` is accepted and NOT checked.',
|
||||||
|
'',
|
||||||
|
'Answers `{ Status, InventoryItem }` — the purchase’s `OperationResult` — with the',
|
||||||
|
'`RoomConsumableInventoryItem` the caller now holds: `Count` the RESULTING total, 0',
|
||||||
|
'when they used their last (after which `…/room/{roomId}/me` omits it).',
|
||||||
|
'',
|
||||||
|
'A consumable that does not exist, or one the caller holds none of, is `Status` 1',
|
||||||
|
'with a null `InventoryItem`, and nothing is written. The refusal form is an',
|
||||||
|
'ASSUMPTION — only success has been observed.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
description: 'The consumable to use — a `room_consumable` id',
|
||||||
|
schema: { type: 'string' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requestBody: jsonBody(ConsumeRoomConsumableRequest, 'The concurrency pair'),
|
||||||
|
responses: {
|
||||||
|
200: json(ConsumeRoomConsumableResponse, 'What the caller now holds, or a refusal'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const body = await c.req.json<{ NewConcurrencyCode?: unknown }>().catch(() => null)
|
||||||
|
const newCode =
|
||||||
|
body !== null && typeof body === 'object' && typeof body.NewConcurrencyCode === 'string'
|
||||||
|
? body.NewConcurrencyCode
|
||||||
|
: null
|
||||||
|
// Every key is always sent: an omitted `Status` reads as its enum's 0, success.
|
||||||
|
const refused = () => c.json({ Status: ROOM_KEY_STATUS_FAILED, InventoryItem: null })
|
||||||
|
|
||||||
|
const consumable = await getRoomConsumable(c.env.DB, c.req.param('id'))
|
||||||
|
if (!consumable) return refused()
|
||||||
|
const held = await consumeRoomConsumable(c.env.DB, id, consumable.RoomConsumableId, newCode)
|
||||||
|
if (!held) return refused()
|
||||||
|
return c.json({
|
||||||
|
Status: ROOM_KEY_STATUS_OK,
|
||||||
|
InventoryItem: toInventoryItem(id, held, consumable),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
// The custom currencies a room has minted. Public, like the room itself: a room's
|
// The custom currencies a room has minted. Public, like the room itself: a room's
|
||||||
// currencies are shown to every player who walks into it, so the list is not a secret
|
// currencies are shown to every player who walks into it, so the list is not a secret
|
||||||
// the way its ban list is. A room with none — or an unknown room — is an empty list.
|
// the way its ban list is. A room with none — or an unknown room — is an empty list.
|
||||||
|
|||||||
@@ -1159,6 +1159,27 @@ export const PurchaseRoomConsumableResponse = z.object({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` — just the concurrency pair, at the
|
||||||
|
* TOP level (not nested under `ConcurrencyCodes` as the purchase sends it). Uses up one.
|
||||||
|
*/
|
||||||
|
export const ConsumeRoomConsumableRequest = z.object({
|
||||||
|
CurrentConcurrencyCode: z.string().nullable().optional().describe('Accepted and NOT checked'),
|
||||||
|
NewConcurrencyCode: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.describe('Stored and echoed back as the item’s `ConcurrencyCode`'),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The consume's answer: the purchase's `OperationResult`, alone. */
|
||||||
|
export const ConsumeRoomConsumableResponse = z.object({
|
||||||
|
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
|
||||||
|
InventoryItem: RoomConsumableInventoryItemDto.nullable().describe(
|
||||||
|
'What the caller holds afterwards (`Count` the resulting total, 0 included); null on a refusal'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The envelope the consumable write answers in — the same
|
* The envelope the consumable write answers in — the same
|
||||||
* `{ Value, Success, Error, error_id }` the room-currency writes use.
|
* `{ Value, Success, Error, error_id }` the room-currency writes use.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
|
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
|
||||||
* - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency`
|
* - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency`
|
||||||
* sell one, in tokens or the listing's room currency
|
* sell one, in tokens or the listing's room currency
|
||||||
|
* - `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` uses up one the caller holds
|
||||||
*
|
*
|
||||||
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
|
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
|
||||||
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and
|
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and
|
||||||
@@ -344,6 +345,42 @@ export async function awardRoomConsumable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use up ONE of a consumable a player holds, returning what they hold afterwards — or null
|
||||||
|
* when they hold none, and nothing was written. `concurrencyCode` is the request's
|
||||||
|
* `NewConcurrencyCode`, stored to be served back; null leaves the stored one.
|
||||||
|
*
|
||||||
|
* A single conditional UPDATE: the `quantity >= 1` guard is what refuses, so two consumes
|
||||||
|
* racing over the last one cannot both succeed. Here an empty `RETURNING` is exactly the
|
||||||
|
* answer wanted — "you had none" — unlike the award, where it would be ambiguous.
|
||||||
|
*/
|
||||||
|
export async function consumeRoomConsumable(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
consumableId: string,
|
||||||
|
concurrencyCode: string | null = null
|
||||||
|
): Promise<RoomInventoryHolding | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE room_inventory SET
|
||||||
|
quantity = quantity - 1,
|
||||||
|
concurrency_code = COALESCE(?3, concurrency_code),
|
||||||
|
modified_at = ?4
|
||||||
|
WHERE player_id = ?1 AND consumable_id = ?2 AND quantity >= 1
|
||||||
|
RETURNING quantity, concurrency_code, modified_at`
|
||||||
|
)
|
||||||
|
.bind(playerId, consumableId, concurrencyCode, new Date().toISOString())
|
||||||
|
.first<{ quantity: number; concurrency_code: string | null; modified_at: string | null }>()
|
||||||
|
|
||||||
|
return row
|
||||||
|
? {
|
||||||
|
quantity: row.quantity,
|
||||||
|
concurrencyCode: row.concurrency_code,
|
||||||
|
modifiedAt: row.modified_at,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything a player owns of one room's consumables, in the shop's order. A listing they own
|
* Everything a player owns of one room's consumables, in the shop's order. A listing they own
|
||||||
* none of is absent — including one they held and spent down to zero.
|
* none of is absent — including one they held and spent down to zero.
|
||||||
|
|||||||
@@ -1773,6 +1773,77 @@ describe('econ endpoints', () => {
|
|||||||
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run()
|
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => {
|
||||||
|
const potion = (
|
||||||
|
await envOf(await save({ ...body, Name: 'Drinkable potion' }, await bearer('1')))
|
||||||
|
).Value!
|
||||||
|
await env.DB.prepare(
|
||||||
|
'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)'
|
||||||
|
)
|
||||||
|
.bind(potion.RoomConsumableId)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const consume = async (sub: string | null, consumableId: string, code: string) =>
|
||||||
|
exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/${consumableId}/consume`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(sub === null ? {} : await bearer(sub)),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb',
|
||||||
|
NewConcurrencyCode: code,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const held = async () =>
|
||||||
|
(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT quantity FROM room_inventory WHERE player_id = 7801 AND consumable_id = ?1'
|
||||||
|
)
|
||||||
|
.bind(potion.RoomConsumableId)
|
||||||
|
.first<{ quantity: number }>()
|
||||||
|
)?.quantity
|
||||||
|
|
||||||
|
expect((await consume(null, potion.RoomConsumableId, 'a')).status).toBe(401)
|
||||||
|
|
||||||
|
const res = await consume('7801', potion.RoomConsumableId, 'code-1')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
Status: 0,
|
||||||
|
InventoryItem: {
|
||||||
|
RoomConsumableId: potion.RoomConsumableId,
|
||||||
|
AccountId: 7801,
|
||||||
|
Count: 1,
|
||||||
|
ConcurrencyCode: 'code-1',
|
||||||
|
ModifiedAt: expect.any(String),
|
||||||
|
Consumable: { ...potion, ImageName: '', IsGiftable: false, Visibility: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The last one answers Count 0; after that there is nothing to use.
|
||||||
|
const last = (await (await consume('7801', potion.RoomConsumableId, 'code-2')).json()) as {
|
||||||
|
Status: number
|
||||||
|
InventoryItem: { Count: number; ConcurrencyCode: string } | null
|
||||||
|
}
|
||||||
|
expect(last).toMatchObject({
|
||||||
|
Status: 0,
|
||||||
|
InventoryItem: { Count: 0, ConcurrencyCode: 'code-2' },
|
||||||
|
})
|
||||||
|
const refusal = { Status: 1, InventoryItem: null }
|
||||||
|
expect(await (await consume('7801', potion.RoomConsumableId, 'code-3')).json()).toEqual(
|
||||||
|
refusal
|
||||||
|
)
|
||||||
|
expect(await held()).toBe(0)
|
||||||
|
// Someone who never had one, and an unknown consumable, are refused too.
|
||||||
|
expect(await (await consume('7802', potion.RoomConsumableId, 'x')).json()).toEqual(refusal)
|
||||||
|
expect(await (await consume('7801', 'nope', 'x')).json()).toEqual(refusal)
|
||||||
|
|
||||||
|
await env.DB.prepare('DELETE FROM room_inventory').run()
|
||||||
|
})
|
||||||
|
|
||||||
test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => {
|
test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => {
|
||||||
expect(await shopOf(99999)).toEqual([])
|
expect(await shopOf(99999)).toEqual([])
|
||||||
const res = await exports.default.fetch(
|
const res = await exports.default.fetch(
|
||||||
@@ -8047,6 +8118,7 @@ describe('econ endpoints', () => {
|
|||||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||||
'PUT /api/equipment/v1/update',
|
'PUT /api/equipment/v1/update',
|
||||||
'PUT /api/roomconsumables/v1/roomConsumable',
|
'PUT /api/roomconsumables/v1/roomConsumable',
|
||||||
|
'PUT /api/roomconsumables/v1/roomConsumable/{id}/consume',
|
||||||
'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}',
|
'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}',
|
||||||
'PUT /api/roomkeys/v1/updateAll',
|
'PUT /api/roomkeys/v1/updateAll',
|
||||||
])
|
])
|
||||||
|
|||||||
Reference in New Issue
Block a user