mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
hot inventions
This commit is contained in:
@@ -14,10 +14,11 @@
|
|||||||
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
|
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
|
||||||
*
|
*
|
||||||
* Who OWNS an invention is a separate table (`inventory_invention`, written by the
|
* 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
|
* `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.
|
* 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 +
|
* 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)
|
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
|
/** Midnight UTC today, as the ISO timestamp `acquired_at` is compared against. */
|
||||||
function topScore(invention: SavedInvention): number {
|
function startOfUtcDay(): string {
|
||||||
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
return `${new Date().toISOString().slice(0, 10)}T00:00:00.000Z`
|
||||||
return (
|
|
||||||
n(invention.NumDownloads) * 3 +
|
|
||||||
n(invention.CheerCount) * 2 +
|
|
||||||
n(invention.NumPlayersHaveUsedInRoom)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "top today" feed — published inventions ranked by engagement. The real feed
|
* The "top today" feed — the inventions other players picked up TODAY, most first.
|
||||||
* 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.
|
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
|
||||||
* Paginated via skip/take; returns a bare array, like the other invention feeds.
|
* 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(
|
export async function getTopInventions(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number
|
||||||
): Promise<SavedInvention[]> {
|
): Promise<SavedInvention[]> {
|
||||||
const inventions = await publicInventions(db)
|
const counts = await getInventionAcquisitionCounts(db, startOfUtcDay())
|
||||||
return inventions
|
if (counts.length === 0) return []
|
||||||
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
|
|
||||||
.slice(skip, skip + take)
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -622,17 +622,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// The "top today" invention feed — published inventions ranked by engagement
|
// The "top today" invention feed — the inventions most acquired since 00:00 UTC,
|
||||||
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
|
// counted from the purchase rows the `econ` worker writes. A real day window, so an
|
||||||
// (take defaults to 50, as the client asks for). Bare array.
|
// 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(
|
.get(
|
||||||
'/api/inventions/v1/toptoday',
|
'/api/inventions/v1/toptoday',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'The “top today” feed',
|
summary: 'The “top today” feed',
|
||||||
description:
|
description:
|
||||||
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
|
'Published inventions ranked by how many players acquired them TODAY (since ' +
|
||||||
'daily counters, so “today” is a label, not a window.',
|
'00:00 UTC), counted from the purchase records — free grants included, one per ' +
|
||||||
|
'player per invention. Genuinely a day window: empty until the day’s first ' +
|
||||||
|
'acquisition, and it resets at midnight UTC.',
|
||||||
parameters: pageParams(50),
|
parameters: pageParams(50),
|
||||||
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1138,14 +1138,15 @@ describe('public endpoints', () => {
|
|||||||
const ids = async (res: Response): Promise<number[]> =>
|
const ids = async (res: Response): Promise<number[]> =>
|
||||||
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||||
|
|
||||||
// Nothing is flagged IsFeatured yet, so featured is EMPTY — it does not stand in the
|
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
|
||||||
// top feed, which by now has published inventions in it.
|
// the only inventions acquired so far in this file are an unpublished one and an id
|
||||||
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
// with no invention row — neither of which a public feed may show.
|
||||||
expect(beforeTop.length).toBeGreaterThan(0)
|
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
|
||||||
const beforeFeatured = await ids(
|
[]
|
||||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
|
)
|
||||||
|
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
|
||||||
|
[]
|
||||||
)
|
)
|
||||||
expect(beforeFeatured).toEqual([])
|
|
||||||
|
|
||||||
const feedInvention = (
|
const feedInvention = (
|
||||||
id: number,
|
id: number,
|
||||||
@@ -1183,11 +1184,24 @@ describe('public endpoints', () => {
|
|||||||
.run()
|
.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`))
|
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||||
expect(top.slice(0, 3)).toEqual([202, 203, 201])
|
expect(top).toEqual([201, 203])
|
||||||
expect(top).not.toContain(204)
|
|
||||||
expect(top).not.toContain(205)
|
|
||||||
|
|
||||||
// Featured: only the flagged, visible inventions — newest first. 201 is published but
|
// Featured: only the flagged, visible inventions — newest first. 201 is published but
|
||||||
// unflagged, so it stays out however popular it is.
|
// unflagged, so it stays out however popular it is.
|
||||||
@@ -1197,6 +1211,10 @@ describe('public endpoints', () => {
|
|||||||
// skip/take paginate both feeds.
|
// skip/take paginate both feeds.
|
||||||
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
|
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
|
||||||
expect(await ids(page)).toEqual([203])
|
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(
|
const featuredPage = await exports.default.fetch(
|
||||||
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
|
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,36 @@ export async function ownsInvention(
|
|||||||
return row !== null
|
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. */
|
/** The ids of every invention a player has bought, oldest purchase first. */
|
||||||
export async function getOwnedInventionIds(db: D1Database, accountId: number): Promise<number[]> {
|
export async function getOwnedInventionIds(db: D1Database, accountId: number): Promise<number[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
|
|||||||
Reference in New Issue
Block a user