From 3cf4b4029eb28bf93d97912d628f9a1a79cdc5ed Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 22 Oct 2026 11:55:24 -0400 Subject: [PATCH] [econ] fix room owner key purchase --- apps/econ/src/econ.app.ts | 16 +++++++++++----- apps/econ/src/test/integration/api.test.ts | 10 ++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 5e296e1..25fd92c 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -6517,8 +6517,10 @@ const app = new Hono({ strict: false }) 'Looks the key up by `RoomKeyId`, confirms the client’s `RequestedPrice` matches its', 'stored `Price`, debits the buyer that price in RecCenterTokens, pays it to the', 'room’s OWNER (its `CreatorAccountId`), and records the buyer as holding the key', - '(`room_key_player`). A free key, and an owner buying their own room’s key, move no', - 'tokens. A GET because that is how the client sends it.', + '(`room_key_player`). A free key moves no tokens, and nor does a key bought by the', + 'room’s creator or a CO-OWNER: they get their own room’s keys free, and the client', + 'asks them `RequestedPrice=0` (the stored price is accepted too). A GET because that', + 'is how the client sends it.', '', 'Answers the client’s `RoomKeyPurchaseResponseDTO`: `RoomKeyResponse` is the', '`{ Status, RoomKey }` the create answers in, and `BalanceUpdateResponse` carries one', @@ -6588,17 +6590,21 @@ const app = new Hono({ strict: false }) if (key === null || key.PurchaseCurrencyId !== null) { return answer(UpdateResponse.NoItemAvailable) } - if (requestedPrice !== key.Price) return answer(UpdateResponse.RequestedPriceDoesNotMatch) const ownerId = await getRoomOwnerId(c.env.DB, key.RoomId) if (ownerId === null) return answer(UpdateResponse.NoItemAvailable) + // The room's creator and co-owners get their own room's keys free — the client asks + // them `RequestedPrice=0` — and are never charged, whatever price they name. + const canManage = (await canManageRoomById(c.env.DB, key.RoomId, id)) === true + if (requestedPrice !== key.Price && !(canManage && requestedPrice === 0)) { + return answer(UpdateResponse.RequestedPriceDoesNotMatch) + } // Claim the key BEFORE charging: the insert is atomic, so of two concurrent buys only // one gets past here and only one is charged. A buyer who cannot pay gives it back. if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) { return answer(UpdateResponse.AlreadyOwned) } - // The owner paying themselves would be a debit and a credit of the same tokens. - if (key.Price > 0 && ownerId !== id) { + if (key.Price > 0 && !canManage) { if (!(await spendCurrency(c.env.DB, id, currencyType, key.Price, startingTokens))) { await releaseRoomKey(c.env.DB, id, key.RoomKeyId) return answer(UpdateResponse.NotEnoughCredit) diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index d71a6ba..8a30138 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -3054,7 +3054,8 @@ describe('econ endpoints', () => { expect(await (await buy('7301', key.RoomKeyId, 300)).json()).toEqual(refused(3, key, 200)) expect([await tokens(1), await tokens(7301)]).toEqual([1300, 200]) - // A free key moves nothing; nor does the owner buying their own room's key. + // A free key moves nothing; nor does a room manager buying their own room's key — + // the owner at the stored price, or a co-owner at the 0 the client asks them. const gratis = (await (await buy('7302', free.RoomKeyId)).json()) as { RoomKeyResponse: { Status: number } BalanceUpdateResponse: { Balance: number } @@ -3064,7 +3065,12 @@ describe('econ endpoints', () => { const own = (await (await buy('1', key.RoomKeyId, 300)).json()) as typeof gratis expect(own.RoomKeyResponse.Status).toBe(0) expect(own.BalanceUpdateResponse.Balance).toBe(1300) - expect(await holders(key.RoomKeyId)).toEqual([1, 7301]) + await setTokens(2, 50) + const coOwner = (await (await buy('2', key.RoomKeyId, 0)).json()) as typeof gratis + expect(coOwner.RoomKeyResponse.Status).toBe(0) + expect(coOwner.BalanceUpdateResponse.Balance).toBe(50) + expect(await holders(key.RoomKeyId)).toEqual([1, 2, 7301]) + expect([await tokens(1), await tokens(2)]).toEqual([1300, 50]) expect(await drainFrames()).toEqual([]) // A key priced in a room currency is not sold for tokens.