From ef3c5100fee8594771a0350bb9ac900d933ad1fc Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 28 Sep 2026 00:03:50 -0400 Subject: [PATCH] [econ] fighting inflation --- apps/api/env.d.ts | 8 ++++ apps/api/src/openapi.ts | 8 +++- apps/api/src/test/integration/api.test.ts | 26 ++++++++--- apps/econ/src/catalog-load.ts | 2 +- apps/econ/src/test/integration/api.test.ts | 11 +++-- apps/econ/static/storefronts/sf3-2025.json | 52 +++++++++++----------- packages/tools/src/cmd/storefront.cmd.ts | 2 +- 7 files changed, 69 insertions(+), 40 deletions(-) diff --git a/apps/api/env.d.ts b/apps/api/env.d.ts index 4ce8dd1..6297231 100644 --- a/apps/api/env.d.ts +++ b/apps/api/env.d.ts @@ -10,3 +10,11 @@ declare namespace Cloudflare { mainModule: MainModule } } + +// Vite serves a `?raw` import as the file's text. Used by the tests to read a data +// migration (0032, the custom-item price cap) as a STRING and run its UPDATE against the +// schema they build from SCHEMA_DDL, which the migration files never touch. +declare module '*.sql?raw' { + const content: string + export default content +} diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index f9a49f1..8e0db40 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -361,7 +361,11 @@ export const CheerPlayerResponse = z.object({ export const CreateCustomAvatarItemMetadata = z.object({ Name: z.string(), Description: z.string().optional(), - Price: z.number().int().min(0).max(1000).optional().describe('Tokens, 0–1000; 0 when left out'), + Price: z + .number() + .int() + .optional() + .describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE); 0 when left out'), BaseAvatarItemId: z.number().int(), BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'), Accessibility: z.number().int().optional(), @@ -434,7 +438,7 @@ export const CustomAvatarItemDto = z.object({ export const UpdateCustomAvatarItemRequest = z.object({ Name: z.string().nullable().optional(), Description: z.string().nullable().optional(), - Price: z.number().int().min(0).max(1000).nullable().optional().describe('Tokens, 0–1000'), + Price: z.number().int().nullable().optional().describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE)'), Accessibility: z.number().int().nullable().optional(), }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 933ffd4..03963dc 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -38,6 +38,7 @@ import { SYSTEM_SENDER_ID, THREAD_SCHEMA_DDL, } from '../../../../chat/src/thread-db' +import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw' import { banEvasionMatch, resolveBan } from '../../bans-db' import { createCustomAvatarItem, @@ -47,7 +48,6 @@ import { MAX_PLAYER_ITEM_PRICE, } from '../../custom-avatar-items-db' import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load' -import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw' import { countGoing, SCHEMA_DDL as EVENTS_SCHEMA_DDL, @@ -4639,7 +4639,9 @@ describe('custom avatar items', () => { expect((await create(10.5)).status).toBe(400) // Nothing was written for the refused ones. expect( - await env.DB.prepare(`SELECT count(*) AS n FROM custom_avatar_item WHERE name_lower = 'pricey'`) + await env.DB.prepare( + `SELECT count(*) AS n FROM custom_avatar_item WHERE name_lower = 'pricey'` + ) .first<{ n: number }>() .then((r) => r!.n) ).toBe(0) @@ -4647,9 +4649,13 @@ describe('custom avatar items', () => { // The cap itself is allowed, inclusive; a missing price is still 0. const atCap = await create(1000) expect(atCap.status).toBe(200) - const { Value } = (await atCap.json()) as { Value: { CustomAvatarItemId: string; Price: number } } + const { Value } = (await atCap.json()) as { + Value: { CustomAvatarItemId: string; Price: number } + } expect(Value.Price).toBe(1000) - expect(((await (await create(undefined)).json()) as { Value: { Price: number } }).Value.Price).toBe(0) + expect( + ((await (await create(undefined)).json()) as { Value: { Price: number } }).Value.Price + ).toBe(0) // The edit is bound the same way, and a refused edit leaves the row as it was. const url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}` @@ -4702,7 +4708,7 @@ describe('custom avatar items', () => { RankedEntityId: firstPartyId, CreatorAccountId: 1, Name: 'Studio Wings', - Description: null, + Description: '', Price: 6000, Accessibility: 1, OutfitType: 100, @@ -4736,8 +4742,14 @@ describe('custom avatar items', () => { expect((await price(dear.CustomAvatarItemId)).Price).toBe(1000) // The lowered row is marked modified, as an edit would mark it; the untouched ones aren't. expect((await price(dear.CustomAvatarItemId)).ModifiedAt).not.toBe(dear.ModifiedAt) - expect(await price(fair.CustomAvatarItemId)).toMatchObject({ Price: 1000, ModifiedAt: fair.ModifiedAt }) - expect(await price(cheap.CustomAvatarItemId)).toMatchObject({ Price: 50, ModifiedAt: cheap.ModifiedAt }) + expect(await price(fair.CustomAvatarItemId)).toMatchObject({ + Price: 1000, + ModifiedAt: fair.ModifiedAt, + }) + expect(await price(cheap.CustomAvatarItemId)).toMatchObject({ + Price: 50, + ModifiedAt: cheap.ModifiedAt, + }) expect((await price(firstPartyId)).Price).toBe(6000) }) diff --git a/apps/econ/src/catalog-load.ts b/apps/econ/src/catalog-load.ts index 519ac85..9696f6e 100644 --- a/apps/econ/src/catalog-load.ts +++ b/apps/econ/src/catalog-load.ts @@ -186,7 +186,7 @@ export const UNLISTED_SKIN_RARITY = 50 * What a GOLD skin costs — any skin whose name carries `(Gold)`, thirteen of them, all from the * capture. A prestige price, and the same for a subscriber: no discount, no sale. */ -export const GOLD_SKIN_PRICE = 1_000_000 +export const GOLD_SKIN_PRICE = 5_000_000 /** Whether a listing is a gold skin — a skin whose name says `(Gold)`. */ export const isGoldSkin = (listing: StoreListing): boolean => diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 4d5e661..3784c9b 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -2816,12 +2816,17 @@ describe('econ endpoints', () => { listing.PurchasableItemId ) // A skin's or a consumable's thumbnail is blanked — the dump names a PNG per item that - // this server does not have — and nothing else about a drop changes. + // this server does not have — and a drop with Context -1 (the hair dyes and the dice + // skins) is served with Context 0. Nothing else about a drop changes. const blanked = listing.GiftDrop.EquipmentModificationGuid !== '' || listing.GiftDrop.ConsumableItemDesc !== '' + const expectedDrop = { + ...listing.GiftDrop, + Context: listing.GiftDrop.Context === -1 ? 0 : listing.GiftDrop.Context, + } expect(served.GiftDrop).toEqual( - blanked ? { ...listing.GiftDrop, ThumbnailImageName: '' } : listing.GiftDrop + blanked ? { ...expectedDrop, ThumbnailImageName: '' } : expectedDrop ) // The other edit to a listing: the dump was taken mid-sale (80% off nearly everything, // no end date), and the client posts the sale price it computes — which this server, @@ -2865,7 +2870,7 @@ describe('econ endpoints', () => { expect(skinsJson.every((sk) => soldGuids.includes(sk.ModificationGuid))).toBe(true) expect(soldSkins.every((i) => i.GiftDrop.ThumbnailImageName === '')).toBe(true) expect(soldSkins.length).toBeGreaterThan(unlisted.length) - // A "(Gold)" skin — thirteen, all from the capture — is a prestige item: 1,000,000 tokens, + // A "(Gold)" skin — thirteen, all from the capture — is a prestige item: 5,000,000 tokens, // the same for a subscriber, no sale. A "(Gold)" AVATAR item is priced as the dump has it. const goldSkins = soldSkins.filter((i) => i.GiftDrop.FriendlyName.includes('(Gold)')) expect(goldSkins.length).toBeGreaterThan(10) diff --git a/apps/econ/static/storefronts/sf3-2025.json b/apps/econ/static/storefronts/sf3-2025.json index 89d7f3a..d0092da 100644 --- a/apps/econ/static/storefronts/sf3-2025.json +++ b/apps/econ/static/storefronts/sf3-2025.json @@ -193541,14 +193541,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -193594,14 +193594,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -193647,14 +193647,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -193912,14 +193912,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -195078,14 +195078,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -198205,14 +198205,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -198629,14 +198629,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -199583,14 +199583,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -200272,14 +200272,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -200855,14 +200855,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -201650,14 +201650,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -201756,14 +201756,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], @@ -202074,14 +202074,14 @@ "Prices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], "SubscriberPrices": [ { "CurrencyType": 2, - "Price": 1000000, + "Price": 5000000, "StorefrontSaleData": null } ], diff --git a/packages/tools/src/cmd/storefront.cmd.ts b/packages/tools/src/cmd/storefront.cmd.ts index 064ef4e..4151a63 100644 --- a/packages/tools/src/cmd/storefront.cmd.ts +++ b/packages/tools/src/cmd/storefront.cmd.ts @@ -57,7 +57,7 @@ import type { StoreListing } from '../../../../apps/econ/src/catalog-load' * thumbnails every skin listing — the game's own 99 and the appended ones alike — and every * consumable listing has its `ThumbnailImageName` set to `""`. The dump names a * PNG per item that this server does not have. - * gold a skin whose name carries `(Gold)` sells at `GOLD_SKIN_PRICE`, 1,000,000 tokens, + * gold a skin whose name carries `(Gold)` sells at `GOLD_SKIN_PRICE`, 5,000,000 tokens, * subscriber price the same: no discount, no sale. * context a drop whose `Context` is `NO_CONTEXT` (-1) gets 0. The dump carries -1 on * exactly 46 listings — the 22 permanent hair dyes and the 24 dice skins, the