mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] some invention stats
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
-- An invention's `NumDownloads` is the number of players who have it: the rows in
|
||||||
|
-- `inventory_invention` that name it. `grantInvention` now re-derives it on every grant
|
||||||
|
-- (packages/domain/src/inventory-invention-db.ts); every purchase before that left it at
|
||||||
|
-- the 0 a save mints, so this backfills the inventions already bought.
|
||||||
|
--
|
||||||
|
-- The `invention` table is the `api` worker's (same database). `ModifiedAt` is left alone:
|
||||||
|
-- being bought is not an edit of the invention.
|
||||||
|
|
||||||
|
-- Backs that per-invention count; the primary key leads with account_id.
|
||||||
|
-- Generated from INVENTORY_INVENTION_SCHEMA_DDL — keep in sync.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_invention_invention
|
||||||
|
ON inventory_invention (invention_id);
|
||||||
|
|
||||||
|
UPDATE invention
|
||||||
|
SET data = json_set(
|
||||||
|
data,
|
||||||
|
'$.NumDownloads',
|
||||||
|
(SELECT COUNT(*) FROM inventory_invention WHERE invention_id = invention.id)
|
||||||
|
)
|
||||||
|
WHERE id IN (SELECT invention_id FROM inventory_invention);
|
||||||
@@ -2675,9 +2675,8 @@ interface SettledInventionPurchase {
|
|||||||
*
|
*
|
||||||
* Ownership is recorded in `inventory_invention`; the creator is not sold their own
|
* Ownership is recorded in `inventory_invention`; the creator is not sold their own
|
||||||
* invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
|
* invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
|
||||||
* than a second row. The invention’s `NumDownloads` counter is deliberately NOT
|
* than a second row. The grant also resyncs the invention’s `NumDownloads` — the count
|
||||||
* bumped: that column lives on the `invention` table the `api` worker owns, and this
|
* of players who have it — and the invention returned carries the new number.
|
||||||
* worker only reads it.
|
|
||||||
*/
|
*/
|
||||||
async function settleInventionPurchase(
|
async function settleInventionPurchase(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
@@ -2723,7 +2722,7 @@ async function settleInventionPurchase(
|
|||||||
// around them, so order them by what a failure costs. A buyer who paid and got the
|
// around them, so order them by what a failure costs. A buyer who paid and got the
|
||||||
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
|
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
|
||||||
// is not.
|
// is not.
|
||||||
await grantInvention(c.env.DB, id, inventionId)
|
const downloads = await grantInvention(c.env.DB, id, inventionId)
|
||||||
|
|
||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
|
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
|
||||||
@@ -2760,7 +2759,7 @@ async function settleInventionPurchase(
|
|||||||
if (price > 0) {
|
if (price > 0) {
|
||||||
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
|
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
|
||||||
}
|
}
|
||||||
return { invention, balance }
|
return { invention: { ...invention, NumDownloads: downloads }, balance }
|
||||||
}
|
}
|
||||||
|
|
||||||
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
|
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
|
||||||
|
|||||||
@@ -5054,7 +5054,7 @@ describe('econ endpoints', () => {
|
|||||||
}
|
}
|
||||||
InventionResponse: {
|
InventionResponse: {
|
||||||
Status: number
|
Status: number
|
||||||
Invention: { InventionId: number; Name: string }
|
Invention: { InventionId: number; Name: string; NumDownloads: number }
|
||||||
InventionVersion: { InventionId: number; VersionNumber: number }
|
InventionVersion: { InventionId: number; VersionNumber: number }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5070,9 +5070,22 @@ describe('econ endpoints', () => {
|
|||||||
|
|
||||||
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
||||||
|
|
||||||
// Owning an invention is boolean: buying it again is a conflict, not a second row.
|
// `NumDownloads` is the number of players who have it, re-derived on the grant:
|
||||||
|
// the response carries it and so does the stored record.
|
||||||
|
const storedDownloads = async (): Promise<number> =>
|
||||||
|
(
|
||||||
|
await env.DB.prepare(
|
||||||
|
"SELECT json_extract(data, '$.NumDownloads') AS n FROM invention WHERE id = 8"
|
||||||
|
).first<{ n: number }>()
|
||||||
|
)?.n ?? -1
|
||||||
|
expect(body.InventionResponse.Invention.NumDownloads).toBe(1)
|
||||||
|
expect(await storedDownloads()).toBe(1)
|
||||||
|
|
||||||
|
// Owning an invention is boolean: buying it again is a conflict, not a second row —
|
||||||
|
// and not a second download.
|
||||||
expect((await buyInvention('50', 8)).status).toBe(409)
|
expect((await buyInvention('50', 8)).status).toBe(409)
|
||||||
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
||||||
|
expect(await storedDownloads()).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/storefronts/v2/buyInvention pays the creator the buyer’s tokens', async () => {
|
test('GET /api/storefronts/v2/buyInvention pays the creator the buyer’s tokens', async () => {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
* creator is not listed here either — they own their invention through its
|
* creator is not listed here either — they own their invention through its
|
||||||
* `CreatorPlayerId`, and the buy path refuses to sell an invention to its own creator.
|
* `CreatorPlayerId`, and the buy path refuses to sell an invention to its own creator.
|
||||||
*
|
*
|
||||||
|
* The one thing a grant does write on the invention record is its `NumDownloads`: the
|
||||||
|
* number of rows here that name it, re-derived on every grant (see `grantInvention`), the
|
||||||
|
* way `CheerCount` is re-derived from `invention_interaction` on every cheer.
|
||||||
|
*
|
||||||
* The `econ` worker owns the schema/migration (apps/econ/migrations/
|
* The `econ` worker owns the schema/migration (apps/econ/migrations/
|
||||||
* 0008_inventory_invention.sql) and is the only writer; `api` only reads, to fold bought
|
* 0008_inventory_invention.sql) and is the only writer; `api` only reads, to fold bought
|
||||||
* inventions into `GET /api/inventions/v2/mine`. Both import these helpers so the table
|
* inventions into `GET /api/inventions/v2/mine`. Both import these helpers so the table
|
||||||
@@ -23,24 +27,46 @@ export const INVENTORY_INVENTION_SCHEMA_DDL: string[] = [
|
|||||||
acquired_at TEXT NOT NULL,
|
acquired_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (account_id, invention_id)
|
PRIMARY KEY (account_id, invention_id)
|
||||||
)`,
|
)`,
|
||||||
|
// Backs the per-invention count `grantInvention` re-derives (migrations/0026).
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_inventory_invention_invention
|
||||||
|
ON inventory_invention (invention_id)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grant an invention to a player. INSERT OR IGNORE on the (account, invention) primary
|
* Grant an invention to a player. INSERT OR IGNORE on the (account, invention) primary
|
||||||
* key: owning an invention is boolean, so a second grant keeps the original
|
* key: owning an invention is boolean, so a second grant keeps the original
|
||||||
* `acquired_at` rather than back-dating the purchase to now.
|
* `acquired_at` rather than back-dating the purchase to now.
|
||||||
|
*
|
||||||
|
* Then resyncs the invention's denormalized `NumDownloads` — the number of players who
|
||||||
|
* have it — and returns it. The count is DERIVED from this table rather than incremented,
|
||||||
|
* so a repeated grant can't inflate it and a drifted number heals on the next one. It is
|
||||||
|
* a `json_set` on the one key, like the cheer count, so it doesn't touch `ModifiedAt` or
|
||||||
|
* race a full-record rewrite into losing anything but this number.
|
||||||
*/
|
*/
|
||||||
export async function grantInvention(
|
export async function grantInvention(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
inventionId: number
|
inventionId: number
|
||||||
): Promise<void> {
|
): Promise<number> {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT OR IGNORE INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
|
'INSERT OR IGNORE INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
|
||||||
)
|
)
|
||||||
.bind(accountId, inventionId, new Date().toISOString())
|
.bind(accountId, inventionId, new Date().toISOString())
|
||||||
.run()
|
.run()
|
||||||
|
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT COUNT(*) AS n FROM inventory_invention WHERE invention_id = ?1')
|
||||||
|
.bind(inventionId)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
const downloads = row?.n ?? 0
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE invention SET data = json_set(data, '$.NumDownloads', CAST(?2 AS INTEGER)) WHERE id = ?1"
|
||||||
|
)
|
||||||
|
.bind(inventionId, downloads)
|
||||||
|
.run()
|
||||||
|
return downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user