hot inventions

This commit is contained in:
Devin Zuczek
2026-08-05 15:45:48 -04:00
parent a73dec7c13
commit bc96a6245b
4 changed files with 99 additions and 35 deletions
+32 -19
View File
@@ -14,10 +14,11 @@
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
*
* Who OWNS an invention is a separate table (`inventory_invention`, written by the
* `econ` worker at purchase time); this module only reads it, to fold bought inventions
* into the caller's own list. See @repo/domain's inventory-invention-db.ts.
* `econ` worker at purchase time); this module only reads it to fold bought inventions
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import { getOwnedInventionIds } from '@repo/domain'
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
@@ -351,31 +352,43 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise<S
return results.map((r) => JSON.parse(r.data) as SavedInvention)
}
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
function topScore(invention: SavedInvention): number {
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
return (
n(invention.NumDownloads) * 3 +
n(invention.CheerCount) * 2 +
n(invention.NumPlayersHaveUsedInRoom)
)
/** Midnight UTC today, as the ISO timestamp `acquired_at` is compared against. */
function startOfUtcDay(): string {
return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`
}
/**
* The "top today" feed — published inventions ranked by engagement. The real feed
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
* Paginated via skip/take; returns a bare array, like the other invention feeds.
* The "top today" feed — the inventions other players picked up TODAY, most first.
*
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
* purchase time, grouped by invention, rather than from the lifetime counters on the
* invention itself: those never reset, so "top today" used to mean "top ever" and the
* shelf only changed when something overtook a total built up over months.
*
* "Today" is the UTC day, matching the timestamps econ writes. The day therefore rolls
* over at 00:00 UTC wherever the player is, and the feed IS EMPTY until the first
* acquisition of that day — nothing stands in for it, the same way the featured feed
* serves nothing while nothing is curated.
*
* An acquired invention that has since been unpublished or hidden drops out: this is a
* public feed, so it is filtered like every other one. Paginated via skip/take AFTER
* that filtering, so a hidden invention doesn't leave a hole in a page.
*/
export async function getTopInventions(
db: D1Database,
skip: number,
take: number
): Promise<SavedInvention[]> {
const inventions = await publicInventions(db)
return inventions
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
const counts = await getInventionAcquisitionCounts(db, startOfUtcDay())
if (counts.length === 0) return []
// getInventionsByIds answers in the order it is asked, so the ranking survives the
// load; ids with no invention row left (deleted) simply drop out.
const ranked = await getInventionsByIds(
db,
counts.map((c) => c.inventionId)
)
return ranked.filter((i) => i.IsPublished && !i.HideFromPlayer).slice(skip, skip + take)
}
/**
+8 -5
View File
@@ -622,17 +622,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The "top today" invention feed — published inventions ranked by engagement
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
// (take defaults to 50, as the client asks for). Bare array.
// The "top today" invention feed — the inventions most acquired since 00:00 UTC,
// counted from the purchase rows the `econ` worker writes. A real day window, so an
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
// to 50, as the client asks for). Bare array.
.get(
'/api/inventions/v1/toptoday',
describeRoute({
tags: ['Inventions'],
summary: 'The “top today” feed',
description:
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
'daily counters, so “today” is a label, not a window.',
'Published inventions ranked by how many players acquired them TODAY (since ' +
'00:00 UTC), counted from the purchase records — free grants included, one per ' +
'player per invention. Genuinely a day window: empty until the days first ' +
'acquisition, and it resets at midnight UTC.',
parameters: pageParams(50),
responses: { 200: json(InventionDto.array(), 'The top inventions') },
}),
+29 -11
View File
@@ -1138,14 +1138,15 @@ describe('public endpoints', () => {
const ids = async (res: Response): Promise<number[]> =>
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
// Nothing is flagged IsFeatured yet, so featured is EMPTY — it does not stand in the
// top feed, which by now has published inventions in it.
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(beforeTop.length).toBeGreaterThan(0)
const beforeFeatured = await ids(
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
// the only inventions acquired so far in this file are an unpublished one and an id
// with no invention row — neither of which a public feed may show.
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
[]
)
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
[]
)
expect(beforeFeatured).toEqual([])
const feedInvention = (
id: number,
@@ -1183,11 +1184,24 @@ describe('public endpoints', () => {
.run()
}
// Top: engagement-ranked, so the biggest download counts lead.
// Today's acquisitions, which is what "top today" now counts: 201 picked up by three
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
// invention can still be owned — and must not surface in a public feed.
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
await grantInvention(env.DB, 7001, 203)
await grantInvention(env.DB, 7001, 204)
await grantInvention(env.DB, 7002, 205)
// 202 was acquired, but not today — the window is the current UTC day, so it is out.
await env.DB.prepare(
'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
)
.bind(7004, 202, '2020-01-01T00:00:00.000Z')
.run()
// Top: most acquisitions today first. Download counts no longer rank anything — 202
// has the biggest of them and is absent entirely.
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
expect(top.slice(0, 3)).toEqual([202, 203, 201])
expect(top).not.toContain(204)
expect(top).not.toContain(205)
expect(top).toEqual([201, 203])
// Featured: only the flagged, visible inventions — newest first. 201 is published but
// unflagged, so it stays out however popular it is.
@@ -1197,6 +1211,10 @@ describe('public endpoints', () => {
// skip/take paginate both feeds.
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
expect(await ids(page)).toEqual([203])
// Pagination happens after the visibility filter, so the hidden/unpublished
// acquisitions don't leave holes in a page.
const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`)
expect(await ids(firstPage)).toEqual([201])
const featuredPage = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
)
@@ -62,6 +62,36 @@ export async function ownsInvention(
return row !== null
}
/**
* How many times each invention was acquired at or after `since`, most-acquired first
* (ties broken by newest invention, so paging is stable). Backs the `api` worker's "top
* today" feed, which passes the start of the current UTC day.
*
* `acquired_at` holds `toISOString()` output, which is fixed-width UTC, so a lexical
* `>=` on the string is a chronological comparison — no date parsing in SQL.
*
* This counts ACQUISITIONS, not spend: a free invention's grant is a row here just like
* a paid one, and one player can only ever contribute a single row per invention (the
* table's primary key), so a popular invention can't be inflated by one buyer. Creators
* are absent by design — they own theirs through `CreatorPlayerId` and never buy it —
* which is what makes this a measure of what other people picked up.
*/
export async function getInventionAcquisitionCounts(
db: D1Database,
since: string
): Promise<Array<{ inventionId: number; count: number }>> {
const { results } = await db
.prepare(
`SELECT invention_id, COUNT(*) AS count FROM inventory_invention
WHERE acquired_at >= ?1
GROUP BY invention_id
ORDER BY count DESC, invention_id DESC`
)
.bind(since)
.all<{ invention_id: number; count: number }>()
return results.map((r) => ({ inventionId: r.invention_id, count: r.count }))
}
/** The ids of every invention a player has bought, oldest purchase first. */
export async function getOwnedInventionIds(db: D1Database, accountId: number): Promise<number[]> {
const { results } = await db