openvibers/openvibe.games Commits
Games: the in-game boards read OpenVibe.Play's (local board SQL removed) (#77)
Changes
@@@ -251,9 +251,9 @@</section><a class="play" id="play-link" href="/play?game=salvage-rush">▶ Play a round</a><p class="note">-- "This week" starts on Monday, 00:00 UTC. The same boards, and the achievements you have-- unlocked with your OpenVibe account, are on-- <a href="https://play.openvibe.network/">OpenVibe.Play</a>.++ "This week" starts on Monday, 00:00 UTC. These boards are kept by++ <a href="https://play.openvibe.network/">OpenVibe.Play</a>, with the achievements you have++ unlocked with your OpenVibe account; a finished round or haul shows here within a minute.</p></main></body>@@@ -35,8 +35,7 @@ const COPY: Record<> = {'salvage-rush': {title: 'Salvage Rush',-- lede:-- "Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the server counted.",++ lede: "Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the server counted.",score: 'Best score',play: '/play?game=salvage-rush',playLabel: '▶ Play a round',@@@ -47,8 +46,7 @@ const COPY: Record<},scraplandia: {title: 'Scraplandia haul',-- lede:-- 'Total haul points from server-confirmed extractions. Hold a recovery-bird circle to secure valuables and score.',++ lede: 'Total haul points from server-confirmed extractions. Hold a recovery-bird circle to secure valuables and score.',score: 'Haul points',play: '/play',playLabel: '▶ Play Scraplandia',@@@ -60,7 +58,9 @@ const COPY: Record<}let game: Game =-- new URLSearchParams(location.search).get('game') === 'scraplandia' ? 'scraplandia' : 'salvage-rush'++ new URLSearchParams(location.search).get('game') === 'scraplandia'++ ? 'scraplandia'++ : 'salvage-rush'let period: 'all' | 'week' =new URLSearchParams(location.search).get('period') === 'week' ? 'week' : 'all'let page = 1@@@ -88,7 +88,8 @@ function applyGame(): void {}async function load(): Promise<void> {-- for (const tab of periodTabs) tab.setAttribute('aria-pressed', String(tab.dataset.period === period))++ for (const tab of periodTabs)++ tab.setAttribute('aria-pressed', String(tab.dataset.period === period))const copy = COPY[game]status.textContent = 'Loading…'entries.replaceChildren()@@@ -98,7 +99,7 @@ async function load(): Promise<void> {try {const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)if (!response.ok) throw new Error('unavailable')-- const data = (await response.json()) as { entries: Entry[]; pageSize: number }++ const data = (await response.json()) as { entries: Entry[]; pages: number }for (const entry of data.entries) {const row = document.createElement('tr')if (entry.rank <= 3) row.className = `top-${entry.rank}`@@@ -110,7 +111,7 @@ async function load(): Promise<void> {entries.append(row)}status.textContent = data.entries.length ? '' : copy.empty(period)-- next.disabled = data.entries.length < data.pageSize++ next.disabled = page >= data.pages} catch {status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'}@@@ -1,16 +1,30 @@import { describe, expect, it } from 'vitest'import { openTestDb, openTestStore } from '@openvibe/persistence/testing'import { accountKey } from '@openvibe/persistence'++import { sql, type Db } from 'openvibe-sdk/db'import {earnedAchievements,earnedHaulAchievements,-- leaderboard,playerAchievements,recordHaul,recordJoined,recordRound,} from './progression.js'++// The boards are OpenVibe.Play's (platform/playBoards.ts); these read what the local tables recorded for it.++const bestScores = async (db: Db, game: string) =>++ (++ await db.many<{ account_key: string; score: number }>(++ sql`SELECT account_key, score FROM game_best_scores WHERE game_id = ${game} ORDER BY score DESC, account_key`,++ )++ ).map((row) => ({ key: row.account_key, score: Number(row.score) }))++const haulTotals = async (db: Db) =>++ (++ await db.many<{ account_key: string; total: number }>(++ sql`SELECT account_key, SUM(score) AS total FROM game_hauls GROUP BY account_key ORDER BY total DESC`,++ )++ ).map((row) => Number(row.total))++describe('game progression', () => {it('unlocks Scraplandia first visit once from a server join', async () => {const db = await openTestDb()@@@ -39,7 +53,7 @@ describe('game progression', () => {expect(exported.gameAchievements).toHaveLength(3)await store.identity.eraseSubjects([subject])expect((await playerAchievements(store.db, subject))[0]?.achievements[0]?.unlockedAt).toBeNull()-- expect((await leaderboard(store.db, 'salvage-rush', 'all', 1, 300)).entries).toEqual([])++ expect(await bestScores(store.db, 'salvage-rush')).toEqual([])})it('moves guest scores and unlocks to the subject when its character is adopted', async () => {@@@ -72,7 +86,9 @@ describe('game progression', () => {100,)expect((await store.identity.adoptGuestCharacter(guest, subject, 200)).moved).toBe(1)-- expect((await leaderboard(store.db, 'salvage-rush', 'all', 1, 300)).entries[0]?.score).toBe(3)++ expect(await bestScores(store.db, 'salvage-rush')).toEqual([++ { key: accountKey(subject), score: 3 },++ ])expect((await playerAchievements(store.db, subject))[1]?.achievements.filter((a) => a.unlockedAt),).toHaveLength(2)@@@ -117,7 +133,7 @@ describe('game progression', () => {expect(accountKey(subject)).toBe(subject)})-- it('orders personal bests and filters this week', async () => {++ it("keeps each player's all-time best and every round", async () => {const db = await openTestDb()const now = Date.UTC(2026, 9, 11, 12) // Sundayawait recordRound(@@@ -137,13 +153,11 @@ describe('game progression', () => {],now,)-- expect(-- (await leaderboard(db, 'salvage-rush', 'all', 1, now)).entries.map((e) => e.score),-- ).toEqual([10, 5])-- expect(-- (await leaderboard(db, 'salvage-rush', 'week', 1, now)).entries.map((e) => e.score),-- ).toEqual([5, 2])-- expect((await leaderboard(db, 'salvage-rush', 'all', 2, now)).entries).toEqual([])++ expect((await bestScores(db, 'salvage-rush')).map((e) => e.score)).toEqual([10, 5])++ const rounds = await db.many<{ score: number }>(++ sql`SELECT score FROM game_round_results WHERE game_id = 'salvage-rush' ORDER BY score DESC`,++ )++ expect(rounds.map((r) => Number(r.score))).toEqual([10, 5, 2])})it('unlocks Scraplandia haul achievements at their thresholds', () => {@@@ -153,7 +167,7 @@ describe('game progression', () => {expect(earnedHaulAchievements(500, 2000)).toEqual(['first-haul', 'big-haul', 'haul-week'])})-- it('records hauls once per extraction and sums them on the Scraplandia leaderboard', async () => {++ it('records hauls once per extraction, never a zero-value one', async () => {const db = await openTestDb()const now = Date.UTC(2026, 9, 11, 12) // Sundayawait recordHaul(db, 'a', 'e1', 'A', 100, now)@@@ -161,12 +175,7 @@ describe('game progression', () => {await recordHaul(db, 'a', 'e2', 'A', 999, now) // duplicate extraction id, ignoredawait recordHaul(db, 'b', 'e3', 'B', 10, Date.UTC(2026, 9, 4, 23, 59)) // previous weekawait recordHaul(db, 'c', 'e4', 'C', 0, now) // zero-value, not recorded-- expect((await leaderboard(db, 'scraplandia', 'all', 1, now)).entries.map((e) => e.score)).toEqual(-- [150, 10],-- )-- expect((await leaderboard(db, 'scraplandia', 'week', 1, now)).entries.map((e) => e.score)).toEqual(-- [150],-- )++ expect(await haulTotals(db)).toEqual([150, 10])})it('unlocks haul achievements after a recorded haul', async () => {@@@ -213,68 +213,6 @@ export class GameProgressWriter {}}--interface BoardRow {-- account_key: string-- player_name: string-- score: number-- finished_at: number--}--export async function leaderboard(-- db: Db,-- game: GameId,-- period: 'all' | 'week',-- page: number,-- now: number,--) {-- const since = period === 'week' ? weekStartMs(now) : 0-- const limit = 20-- const offset = (page - 1) * limit-- const entries = (rows: BoardRow[]) =>-- rows.map((row, i) => ({-- rank: offset + i + 1,-- name: row.player_name,-- score: Number(row.score),-- finishedAt: Number(row.finished_at),-- }))---- if (game === 'scraplandia') {-- const rows = await db.many<BoardRow>(sql`-- SELECT account_key, player_name, SUM(score) AS score, MAX(finished_at) AS finished_at-- FROM game_hauls-- WHERE finished_at >= ${since}-- GROUP BY account_key, player_name-- ORDER BY score DESC, finished_at ASC, account_key ASC-- LIMIT ${limit} OFFSET ${offset}-- `)-- return { game, period, page, pageSize: limit, entries: entries(rows) }-- }---- const rows =-- period === 'all'-- ? await db.many<BoardRow>(sql`-- SELECT account_key, player_name, score, finished_at FROM game_best_scores-- WHERE game_id = ${game}-- ORDER BY score DESC, finished_at ASC, account_key ASC-- LIMIT ${limit} OFFSET ${offset}-- `)-- : await db.many<BoardRow>(sql`-- SELECT account_key, player_name, score, finished_at FROM (-- SELECT account_key, player_name, score, finished_at,-- ROW_NUMBER() OVER (PARTITION BY account_key ORDER BY score DESC, finished_at ASC) AS own_rank-- FROM game_round_results WHERE game_id = ${game} AND finished_at >= ${since}-- ) best WHERE own_rank = 1-- ORDER BY score DESC, finished_at ASC, account_key ASC-- LIMIT ${limit} OFFSET ${offset}-- `)-- return {-- game,-- period,-- page,-- pageSize: limit,-- entries: entries(rows),-- }--}--export async function playerAchievements(db: Db, token: string) {const key = accountKey(token)const unlocked = await db.many<{@@@ -70,6 +70,7 @@ import { createWsTicketStore } from './net/wsTicket.js'import { GameProgressWriter } from './game/progression.js'import type { RoundResult } from './game/salvageRush.js'import { handleGameRead } from './net/gameRoutes.js'++import { createPlayBoards } from './platform/playBoards.js'import { createCasualRoutes } from './casual/routes.js'import { pruneCasualActions } from './casual/store.js'import { CASUAL_GAMES, validatePack } from '@openvibe/casual'@@@ -190,6 +191,8 @@ async function main(): Promise<void> {warn: (message) => log.warn(message),})playSync.start()++ // The in-game boards are Play's (public reads, no token).++ const playBoards = createPlayBoards({ url: config.platform.playUrl })const platformLog = log.child({ system: 'platform' })const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })let outbox: PgOutbox | null = null@@@ -784,6 +787,7 @@ async function main(): Promise<void> {if (handleGameRead(req, res, {db,++ boards: playBoards,networkAuthUrl: config.networkAuthUrl,limits: actorLimits,address: (request) => resolveClientAddress(request, config.trustProxy),@@@ -1,6 +1,7 @@import { describe, expect, it } from 'vitest'import type { IncomingMessage, ServerResponse } from 'node:http'import { handleGameRead } from './gameRoutes.js'++import { BoardUnavailable } from '../platform/playBoards.js'describe('game read routes', () => {it('returns 404 for an unknown game before touching persistence', () => {@@@ -21,6 +22,7 @@ describe('game read routes', () => {expect(handleGameRead(req, res, {db: null as never,++ boards: null as never,networkAuthUrl: null,limits: null as never,address: () => 'test',@@@ -30,13 +32,10 @@ describe('game read routes', () => {expect(JSON.parse(body)).toEqual({ error: 'unknown_game' })})-- it('answers the Scraplandia haul leaderboard', async () => {++ const call = async (url: string, boards: unknown) => {let status = 0let body = ''-- const req = {-- method: 'GET',-- url: '/api/v1/games/scraplandia/leaderboard?period=week&page=1',-- } as IncomingMessage++ const req = { method: 'GET', url } as IncomingMessageconst res = {writeHead: (code: number) => {status = code@@@ -44,23 +43,61 @@ describe('game read routes', () => {end: (value: string) => {body = value},-- } as ServerResponse-- const db = {-- many: async () => [{ account_key: 'a', player_name: 'A', score: 120, finished_at: 100 }],-- }-- const limits = { publicRead: async () => true }++ headersSent: false,++ } as unknown as ServerResponsehandleGameRead(req, res, {-- db: db as never,++ db: null as never,++ boards: boards as never,networkAuthUrl: null,-- limits: limits as never,-- address: () => 'test',++ limits: { publicRead: async () => true } as never,++ address: () => '203.0.113.9',})await new Promise((resolve) => setTimeout(resolve, 10))++ return { status, body: JSON.parse(body) as Record<string, unknown> }++ }++++ it("answers a leaderboard from Play's board, with the viewer's address", async () => {++ const asked: unknown[] = []++ const boards = async (...args: unknown[]) => {++ asked.push(args)++ return {++ game: 'scraplandia',++ period: 'week',++ page: 1,++ pageSize: 25,++ pages: 1,++ total: 1,++ entries: [{ rank: 1, name: 'A', score: 120, signedIn: true }],++ }++ }++ const { status, body } = await call(++ '/api/v1/games/scraplandia/leaderboard?period=week&page=1',++ boards,++ )expect(status).toBe(200)-- expect(JSON.parse(body)).toMatchObject({++ expect(asked).toEqual([['scraplandia', 'week', 1, '203.0.113.9']])++ expect(body).toMatchObject({game: 'scraplandia',period: 'week',-- entries: [{ rank: 1, name: 'A', score: 120, finishedAt: 100 }],++ entries: [{ rank: 1, name: 'A', score: 120 }],})})++++ it('answers 503 when Play cannot be read', async () => {++ const boards = async () => {++ throw new BoardUnavailable('play 502')++ }++ const { status, body } = await call('/api/v1/games/salvage-rush/leaderboard?period=all', boards)++ expect(status).toBe(503)++ expect(body).toEqual({ error: 'boards_unavailable' })++ })++++ it('refuses a bad period before asking Play', async () => {++ const boards = async () => {++ throw new Error('not asked')++ }++ expect((await call('/api/v1/games/salvage-rush/leaderboard?period=month', boards)).status).toBe(++ 400,++ )++ })})@@@ -4,10 +4,13 @@ import { isGuestToken } from '../platform/accounts.js'import { hashGuestKey } from './guestIdentity.js'import { resolveNetworkUser } from './networkAuth.js'import type { GamesActorLimits } from './actorLimits.js'--import { isGameId, leaderboard, playerAchievements } from '../game/progression.js'++import { isGameId, playerAchievements } from '../game/progression.js'++import { BoardUnavailable, type PlayBoards } from '../platform/playBoards.js'interface Options {db: Db++ /** The boards, read from OpenVibe.Play. */++ boards: PlayBoardsnetworkAuthUrl: string | nulllimits: GamesActorLimitsaddress: (req: IncomingMessage) => string@@@ -45,16 +48,20 @@ export function handleGameRead(send(400, { error: 'invalid_query' })return}-- send(-- 200,-- await leaderboard(-- options.db,-- match[1] as 'scraplandia' | 'salvage-rush',-- period,-- page,-- Date.now(),-- ),-- )++ try {++ send(++ 200,++ await options.boards(++ match[1] as 'scraplandia' | 'salvage-rush',++ period,++ page,++ options.address(req),++ ),++ )++ } catch (err) {++ if (!(err instanceof BoardUnavailable)) throw err++ send(503, { error: 'boards_unavailable' })++ }return}const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]nnew file mode 100644@@@ -0,0 +1,77 @@++import { describe, expect, it } from 'vitest'++import { BoardUnavailable, createPlayBoards } from './playBoards.js'++++const board = (entries: unknown[]) =>++ new Response(JSON.stringify({ page: 1, pages: 2, total: 26, entries }), {++ status: 200,++ headers: { 'content-type': 'application/json' },++ })++++describe("in-game boards from Play's public board read", () => {++ it("asks each game's own board, maps the entries and forwards the viewer's address", async () => {++ const asked: { url: string; headers: Record<string, string> }[] = []++ const read = createPlayBoards({++ url: 'http://127.0.0.1:5040',++ fetch: (async (url: string, init: RequestInit) => {++ asked.push({ url, headers: init.headers as Record<string, string> })++ return board([{ rank: 1, name: 'A', score: 150, signed_in: true }])++ }) as typeof fetch,++ })++ const haul = await read('scraplandia', 'week', 1, '203.0.113.9')++ await read('salvage-rush', 'all', 2, '203.0.113.9')++ expect(asked.map((a) => a.url)).toEqual([++ 'http://127.0.0.1:5040/api/v1/titles/scraplandia/boards/haul?period=week&page=1',++ 'http://127.0.0.1:5040/api/v1/titles/salvage-rush/boards/best?period=all&page=2',++ ])++ expect(asked[0]?.headers['x-forwarded-for']).toBe('203.0.113.9')++ expect(haul).toEqual({++ game: 'scraplandia',++ period: 'week',++ page: 1,++ pageSize: 25,++ pages: 2,++ total: 26,++ entries: [{ rank: 1, name: 'A', score: 150, signedIn: true }],++ })++ })++++ it('keeps a page for 15 seconds', async () => {++ let calls = 0++ let now = 1_000++ const read = createPlayBoards({++ url: 'http://play',++ now: () => now,++ fetch: (async () => {++ calls += 1++ return board([])++ }) as unknown as typeof fetch,++ })++ await read('salvage-rush', 'all', 1, 'a')++ now += 14_000++ await read('salvage-rush', 'all', 1, 'b')++ expect(calls).toBe(1)++ await read('salvage-rush', 'week', 1, 'b')++ expect(calls).toBe(2)++ now += 2_000++ await read('salvage-rush', 'all', 1, 'c')++ expect(calls).toBe(3)++ })++++ it('is unavailable without Play, when Play refuses, and when it cannot be reached', async () => {++ await expect(++ createPlayBoards({ url: null })('salvage-rush', 'all', 1, 'a'),++ ).rejects.toBeInstanceOf(BoardUnavailable)++ const refused = createPlayBoards({++ url: 'http://play',++ fetch: (async () => new Response('{}', { status: 503 })) as unknown as typeof fetch,++ })++ await expect(refused('salvage-rush', 'all', 1, 'a')).rejects.toBeInstanceOf(BoardUnavailable)++ const down = createPlayBoards({++ url: 'http://play',++ fetch: (async () => {++ throw new TypeError('fetch failed')++ }) as unknown as typeof fetch,++ })++ await expect(down('salvage-rush', 'all', 1, 'a')).rejects.toBeInstanceOf(BoardUnavailable)++ })++})nnew file mode 100644@@@ -0,0 +1,91 @@++/**++ * The in-game boards, read from OpenVibe.Play (ADR-0007 decision 10). Play keeps every title's boards; Games posts to++ * them (playSync.ts) and answers `GET /api/v1/games/:game/leaderboard` from Play's public board read:++ *++ * scraplandia → board `haul` (sum of haul points)++ * salvage-rush → board `best` (each player's best round)++ *++ * A page is kept for CACHE_MS, so a busy leaderboard page asks Play at most once per game, period and page in that++ * time. The viewer's address goes along as X-Forwarded-For (Games and Play are both behind the host's nginx), so++ * Play's per-address limits count the viewer and not Games. Play's board shows a name, a score and whether the player was signed in, never a player id. A result reaches++ * the board once the sync has posted it (every 30 s).++ */++import type { GameId } from '../game/progression.js'++import { PLAY_TITLES } from './playSync.js'++++const CACHE_MS = 15_000++const CACHE_MAX = 200++const TIMEOUT_MS = 5_000++/** Play's board page (its PER_PAGE). */++const PLAY_PAGE = 25++++export interface BoardPage {++ game: GameId++ period: 'all' | 'week'++ page: number++ pageSize: number++ pages: number++ total: number++ entries: { rank: number; name: string; score: number; signedIn: boolean }[]++}++++interface PlayBoard {++ page: number++ pages: number++ total: number++ entries: { rank: number; name: string; score: number; signed_in: boolean }[]++}++++export class BoardUnavailable extends Error {}++++export function createPlayBoards(opts: {++ url: string | null++ fetch?: typeof fetch++ now?: () => number++}) {++ const doFetch = opts.fetch ?? fetch++ const now = opts.now ?? Date.now++ const cache = new Map<string, { at: number; value: BoardPage }>()++++ return async function read(++ game: GameId,++ period: 'all' | 'week',++ page: number,++ address: string,++ ): Promise<BoardPage> {++ if (!opts.url) throw new BoardUnavailable('GAMES_PLAY_URL is not set')++ const key = `${game}\u0000${period}\u0000${page}`++ const hit = cache.get(key)++ if (hit && now() - hit.at < CACHE_MS) return hit.value++ const board = PLAY_TITLES[game].boards[0]!.id++ const query = new URLSearchParams({ period, page: String(page) })++ const res = await doFetch(`${opts.url}/api/v1/titles/${game}/boards/${board}?${query}`, {++ headers: { accept: 'application/json', 'x-forwarded-for': address },++ signal: AbortSignal.timeout(TIMEOUT_MS),++ }).catch((err: unknown) => {++ throw new BoardUnavailable(err instanceof Error ? err.message : String(err))++ })++ if (!res.ok) throw new BoardUnavailable(`play ${res.status}`)++ const body = (await res.json()) as PlayBoard++ const value: BoardPage = {++ game,++ period,++ page,++ pageSize: PLAY_PAGE,++ pages: Number(body.pages) || 1,++ total: Number(body.total) || 0,++ entries: (body.entries ?? []).map((e) => ({++ rank: Number(e.rank),++ name: String(e.name),++ score: Number(e.score),++ signedIn: e.signed_in === true,++ })),++ }++ cache.delete(key)++ cache.set(key, { at: now(), value })++ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value as string)++ return value++ }++}++++export type PlayBoards = ReturnType<typeof createPlayBoards>@@@ -30,10 +30,11 @@ extraction event; its leaderboard ranks by the sum of haul points in the periodsingle best. `game_achievements` stores one unlock per game, criterion and player. Local guestkeys are SHA-256 hashes as for characters; Network subjects remain canonical. No score is writtenuntil the server confirms a round or extraction. `GET /api/v1/games/:game/leaderboard?period=all|week&page=1`--returns 20 entries per page, ordered by score, then earliest completion. `week`--starts Monday 00:00 UTC. `GET /api/v1/players/me/achievements` accepts the same++reads the game's board on OpenVibe.Play (below): 25 entries per page (`rank`, `name`, `score`, `signedIn`, with++`pages` and `total`), kept 15 s, the viewer's address forwarded so Play's per-address limits count the viewer; 503++`boards_unavailable` when Play cannot be read. `week` starts Monday 00:00 UTC. `GET /api/v1/players/me/achievements` accepts the samebearer or `X-Guest-Token` used by the ticket endpoint. Public reads have per-address limits.--The read-only `/leaderboard.html` page shows Salvage Rush's best round scores and Scraplandia's summed haul points, switchable in the page, all time or this week (from Monday 00:00 UTC), 20 a page.++The read-only `/leaderboard.html` page shows Salvage Rush's best round scores and Scraplandia's summed haul points, switchable in the page, all time or this week (from Monday 00:00 UTC), 25 a page.Network account exports include results, best scores, unlocks and hauls; account erasure removes them.Guest character adoption and Network subject merges move progression (including hauls) to the surviving subject.@@@ -53,5 +54,6 @@ OpenVibe.Play (play.openvibe.network, contracts 0.172.0) is the network's home fachievements and leaderboards. With `GAMES_PLAY_URL` set, Games registers both titles there (Scraplandia's weeklyhaul board sums extractions; Salvage Rush's board ranks each player's best round) and posts every round, haul, unlockand guest adoption from the local tables (`apps/server/src/platform/playSync.ts`, position in `play_sync`). Every post--is idempotent, so the first run imports the history. The local tables still serve the in-game boards; reading them--from Play and dropping the local copies is the next step.++is idempotent, so the first run imports the history. The in-game boards read Play's (`apps/server/src/platform/playBoards.ts`).++The local tables stay as the record the achievement rules read (a haul's weekly total, a round's unlocks) and the sync's++source; nothing reads them for a board.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/platform/playBoards.test.ts'++// covers: 'apps/server/src/platform'++// Generated by node test/run.js --index; do not edit.
@@ -251,9 +251,9 @@
@@ -251,9 +251,9 @@
</section>
</section>
<a class="play" id="play-link" href="/play?game=salvage-rush">▶ Play a round</a>
<a class="play" id="play-link" href="/play?game=salvage-rush">▶ Play a round</a>
<p class="note">
<p class="note">
- "This week" starts on Monday, 00:00 UTC. The same boards, and the achievements you have
- unlocked with your OpenVibe account, are on
- <a href="https://play.openvibe.network/">OpenVibe.Play</a>.
+ "This week" starts on Monday, 00:00 UTC. These boards are kept by
+ <a href="https://play.openvibe.network/">OpenVibe.Play</a>, with the achievements you have
+ unlocked with your OpenVibe account; a finished round or haul shows here within a minute.
</p>
</p>
</main>
</main>
</body>
</body>
@@ -35,8 +35,7 @@ const COPY: Record<
@@ -35,8 +35,7 @@ const COPY: Record<
> = {
> = {
'salvage-rush': {
'salvage-rush': {
title: 'Salvage Rush',
title: 'Salvage Rush',
- lede:
- "Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the server counted.",
+ lede: "Each player's best round score. Rounds last 90 seconds; every point comes from a pickup the server counted.",
score: 'Best score',
score: 'Best score',
play: '/play?game=salvage-rush',
play: '/play?game=salvage-rush',
playLabel: '▶ Play a round',
playLabel: '▶ Play a round',
@@ -47,8 +46,7 @@ const COPY: Record<
@@ -47,8 +46,7 @@ const COPY: Record<
},
},
scraplandia: {
scraplandia: {
title: 'Scraplandia haul',
title: 'Scraplandia haul',
- lede:
- 'Total haul points from server-confirmed extractions. Hold a recovery-bird circle to secure valuables and score.',
+ lede: 'Total haul points from server-confirmed extractions. Hold a recovery-bird circle to secure valuables and score.',
score: 'Haul points',
score: 'Haul points',
play: '/play',
play: '/play',
playLabel: '▶ Play Scraplandia',
playLabel: '▶ Play Scraplandia',
@@ -60,7 +58,9 @@ const COPY: Record<
@@ -60,7 +58,9 @@ const COPY: Record<
}
}
let game: Game =
let game: Game =
- new URLSearchParams(location.search).get('game') === 'scraplandia' ? 'scraplandia' : 'salvage-rush'
+ new URLSearchParams(location.search).get('game') === 'scraplandia'
+ ? 'scraplandia'
+ : 'salvage-rush'
let period: 'all' | 'week' =
let period: 'all' | 'week' =
new URLSearchParams(location.search).get('period') === 'week' ? 'week' : 'all'
new URLSearchParams(location.search).get('period') === 'week' ? 'week' : 'all'
let page = 1
let page = 1
@@ -88,7 +88,8 @@ function applyGame(): void {
@@ -88,7 +88,8 @@ function applyGame(): void {
}
}
async function load(): Promise<void> {
async function load(): Promise<void> {
- for (const tab of periodTabs) tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
+ for (const tab of periodTabs)
+ tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
const copy = COPY[game]
const copy = COPY[game]
status.textContent = 'Loading…'
status.textContent = 'Loading…'
entries.replaceChildren()
entries.replaceChildren()
@@ -98,7 +99,7 @@ async function load(): Promise<void> {
@@ -98,7 +99,7 @@ async function load(): Promise<void> {
try {
try {
const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)
const response = await fetch(`/api/v1/games/${game}/leaderboard?period=${period}&page=${page}`)
if (!response.ok) throw new Error('unavailable')
if (!response.ok) throw new Error('unavailable')
- const data = (await response.json()) as { entries: Entry[]; pageSize: number }
+ const data = (await response.json()) as { entries: Entry[]; pages: number }
for (const entry of data.entries) {
for (const entry of data.entries) {
const row = document.createElement('tr')
const row = document.createElement('tr')
if (entry.rank <= 3) row.className = `top-${entry.rank}`
if (entry.rank <= 3) row.className = `top-${entry.rank}`
@@ -110,7 +111,7 @@ async function load(): Promise<void> {
@@ -110,7 +111,7 @@ async function load(): Promise<void> {
entries.append(row)
entries.append(row)
}
}
status.textContent = data.entries.length ? '' : copy.empty(period)
status.textContent = data.entries.length ? '' : copy.empty(period)
- next.disabled = data.entries.length < data.pageSize
+ next.disabled = page >= data.pages
} catch {
} catch {
status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'
status.textContent = 'The leaderboard is unavailable right now. Try again in a minute.'
}
}
@@ -1,16 +1,30 @@
@@ -1,16 +1,30 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { openTestDb, openTestStore } from '@openvibe/persistence/testing'
import { openTestDb, openTestStore } from '@openvibe/persistence/testing'
import { accountKey } from '@openvibe/persistence'
import { accountKey } from '@openvibe/persistence'
+import { sql, type Db } from 'openvibe-sdk/db'
import {
import {
earnedAchievements,
earnedAchievements,
earnedHaulAchievements,
earnedHaulAchievements,
- leaderboard,
playerAchievements,
playerAchievements,
recordHaul,
recordHaul,
recordJoined,
recordJoined,
recordRound,
recordRound,
} from './progression.js'
} from './progression.js'
+// The boards are OpenVibe.Play's (platform/playBoards.ts); these read what the local tables recorded for it.
+const bestScores = async (db: Db, game: string) =>
+ (
+ await db.many<{ account_key: string; score: number }>(
+ sql`SELECT account_key, score FROM game_best_scores WHERE game_id = ${game} ORDER BY score DESC, account_key`,
+ )
+ ).map((row) => ({ key: row.account_key, score: Number(row.score) }))
+const haulTotals = async (db: Db) =>
+ (
+ await db.many<{ account_key: string; total: number }>(
+ sql`SELECT account_key, SUM(score) AS total FROM game_hauls GROUP BY account_key ORDER BY total DESC`,
+ )
+ ).map((row) => Number(row.total))
+
describe('game progression', () => {
describe('game progression', () => {
it('unlocks Scraplandia first visit once from a server join', async () => {
it('unlocks Scraplandia first visit once from a server join', async () => {
const db = await openTestDb()
const db = await openTestDb()
@@ -39,7 +53,7 @@ describe('game progression', () => {
@@ -39,7 +53,7 @@ describe('game progression', () => {
expect(exported.gameAchievements).toHaveLength(3)
expect(exported.gameAchievements).toHaveLength(3)
await store.identity.eraseSubjects([subject])
await store.identity.eraseSubjects([subject])
expect((await playerAchievements(store.db, subject))[0]?.achievements[0]?.unlockedAt).toBeNull()
expect((await playerAchievements(store.db, subject))[0]?.achievements[0]?.unlockedAt).toBeNull()
- expect((await leaderboard(store.db, 'salvage-rush', 'all', 1, 300)).entries).toEqual([])
+ expect(await bestScores(store.db, 'salvage-rush')).toEqual([])
})
})
it('moves guest scores and unlocks to the subject when its character is adopted', async () => {
it('moves guest scores and unlocks to the subject when its character is adopted', async () => {
@@ -72,7 +86,9 @@ describe('game progression', () => {
@@ -72,7 +86,9 @@ describe('game progression', () => {
100,
100,
)
)
expect((await store.identity.adoptGuestCharacter(guest, subject, 200)).moved).toBe(1)
expect((await store.identity.adoptGuestCharacter(guest, subject, 200)).moved).toBe(1)
- expect((await leaderboard(store.db, 'salvage-rush', 'all', 1, 300)).entries[0]?.score).toBe(3)
+ expect(await bestScores(store.db, 'salvage-rush')).toEqual([
+ { key: accountKey(subject), score: 3 },
+ ])
expect(
expect(
(await playerAchievements(store.db, subject))[1]?.achievements.filter((a) => a.unlockedAt),
(await playerAchievements(store.db, subject))[1]?.achievements.filter((a) => a.unlockedAt),
).toHaveLength(2)
).toHaveLength(2)
@@ -117,7 +133,7 @@ describe('game progression', () => {
@@ -117,7 +133,7 @@ describe('game progression', () => {
expect(accountKey(subject)).toBe(subject)
expect(accountKey(subject)).toBe(subject)
})
})
- it('orders personal bests and filters this week', async () => {
+ it("keeps each player's all-time best and every round", async () => {
const db = await openTestDb()
const db = await openTestDb()
const now = Date.UTC(2026, 9, 11, 12) // Sunday
const now = Date.UTC(2026, 9, 11, 12) // Sunday
await recordRound(
await recordRound(
@@ -137,13 +153,11 @@ describe('game progression', () => {
@@ -137,13 +153,11 @@ describe('game progression', () => {
],
],
now,
now,
)
)
- expect(
- (await leaderboard(db, 'salvage-rush', 'all', 1, now)).entries.map((e) => e.score),
- ).toEqual([10, 5])
- expect(
- (await leaderboard(db, 'salvage-rush', 'week', 1, now)).entries.map((e) => e.score),
- ).toEqual([5, 2])
- expect((await leaderboard(db, 'salvage-rush', 'all', 2, now)).entries).toEqual([])
+ expect((await bestScores(db, 'salvage-rush')).map((e) => e.score)).toEqual([10, 5])
+ const rounds = await db.many<{ score: number }>(
+ sql`SELECT score FROM game_round_results WHERE game_id = 'salvage-rush' ORDER BY score DESC`,
+ )
+ expect(rounds.map((r) => Number(r.score))).toEqual([10, 5, 2])
})
})
it('unlocks Scraplandia haul achievements at their thresholds', () => {
it('unlocks Scraplandia haul achievements at their thresholds', () => {
@@ -153,7 +167,7 @@ describe('game progression', () => {
@@ -153,7 +167,7 @@ describe('game progression', () => {
expect(earnedHaulAchievements(500, 2000)).toEqual(['first-haul', 'big-haul', 'haul-week'])
expect(earnedHaulAchievements(500, 2000)).toEqual(['first-haul', 'big-haul', 'haul-week'])
})
})
- it('records hauls once per extraction and sums them on the Scraplandia leaderboard', async () => {
+ it('records hauls once per extraction, never a zero-value one', async () => {
const db = await openTestDb()
const db = await openTestDb()
const now = Date.UTC(2026, 9, 11, 12) // Sunday
const now = Date.UTC(2026, 9, 11, 12) // Sunday
await recordHaul(db, 'a', 'e1', 'A', 100, now)
await recordHaul(db, 'a', 'e1', 'A', 100, now)
@@ -161,12 +175,7 @@ describe('game progression', () => {
@@ -161,12 +175,7 @@ describe('game progression', () => {
await recordHaul(db, 'a', 'e2', 'A', 999, now) // duplicate extraction id, ignored
await recordHaul(db, 'a', 'e2', 'A', 999, now) // duplicate extraction id, ignored
await recordHaul(db, 'b', 'e3', 'B', 10, Date.UTC(2026, 9, 4, 23, 59)) // previous week
await recordHaul(db, 'b', 'e3', 'B', 10, Date.UTC(2026, 9, 4, 23, 59)) // previous week
await recordHaul(db, 'c', 'e4', 'C', 0, now) // zero-value, not recorded
await recordHaul(db, 'c', 'e4', 'C', 0, now) // zero-value, not recorded
- expect((await leaderboard(db, 'scraplandia', 'all', 1, now)).entries.map((e) => e.score)).toEqual(
- [150, 10],
- )
- expect((await leaderboard(db, 'scraplandia', 'week', 1, now)).entries.map((e) => e.score)).toEqual(
- [150],
- )
+ expect(await haulTotals(db)).toEqual([150, 10])
})
})
it('unlocks haul achievements after a recorded haul', async () => {
it('unlocks haul achievements after a recorded haul', async () => {
@@ -213,68 +213,6 @@ export class GameProgressWriter {
@@ -213,68 +213,6 @@ export class GameProgressWriter {
}
}
}
}
-interface BoardRow {
- account_key: string
- player_name: string
- score: number
- finished_at: number
-}
-export async function leaderboard(
- db: Db,
- game: GameId,
- period: 'all' | 'week',
- page: number,
- now: number,
-) {
- const since = period === 'week' ? weekStartMs(now) : 0
- const limit = 20
- const offset = (page - 1) * limit
- const entries = (rows: BoardRow[]) =>
- rows.map((row, i) => ({
- rank: offset + i + 1,
- name: row.player_name,
- score: Number(row.score),
- finishedAt: Number(row.finished_at),
- }))
-
- if (game === 'scraplandia') {
- const rows = await db.many<BoardRow>(sql`
- SELECT account_key, player_name, SUM(score) AS score, MAX(finished_at) AS finished_at
- FROM game_hauls
- WHERE finished_at >= ${since}
- GROUP BY account_key, player_name
- ORDER BY score DESC, finished_at ASC, account_key ASC
- LIMIT ${limit} OFFSET ${offset}
- `)
- return { game, period, page, pageSize: limit, entries: entries(rows) }
- }
-
- const rows =
- period === 'all'
- ? await db.many<BoardRow>(sql`
- SELECT account_key, player_name, score, finished_at FROM game_best_scores
- WHERE game_id = ${game}
- ORDER BY score DESC, finished_at ASC, account_key ASC
- LIMIT ${limit} OFFSET ${offset}
- `)
- : await db.many<BoardRow>(sql`
- SELECT account_key, player_name, score, finished_at FROM (
- SELECT account_key, player_name, score, finished_at,
- ROW_NUMBER() OVER (PARTITION BY account_key ORDER BY score DESC, finished_at ASC) AS own_rank
- FROM game_round_results WHERE game_id = ${game} AND finished_at >= ${since}
- ) best WHERE own_rank = 1
- ORDER BY score DESC, finished_at ASC, account_key ASC
- LIMIT ${limit} OFFSET ${offset}
- `)
- return {
- game,
- period,
- page,
- pageSize: limit,
- entries: entries(rows),
- }
-}
-
export async function playerAchievements(db: Db, token: string) {
export async function playerAchievements(db: Db, token: string) {
const key = accountKey(token)
const key = accountKey(token)
const unlocked = await db.many<{
const unlocked = await db.many<{
@@ -70,6 +70,7 @@ import { createWsTicketStore } from './net/wsTicket.js'
@@ -70,6 +70,7 @@ import { createWsTicketStore } from './net/wsTicket.js'
import { GameProgressWriter } from './game/progression.js'
import { GameProgressWriter } from './game/progression.js'
import type { RoundResult } from './game/salvageRush.js'
import type { RoundResult } from './game/salvageRush.js'
import { handleGameRead } from './net/gameRoutes.js'
import { handleGameRead } from './net/gameRoutes.js'
+import { createPlayBoards } from './platform/playBoards.js'
import { createCasualRoutes } from './casual/routes.js'
import { createCasualRoutes } from './casual/routes.js'
import { pruneCasualActions } from './casual/store.js'
import { pruneCasualActions } from './casual/store.js'
import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
import { CASUAL_GAMES, validatePack } from '@openvibe/casual'
@@ -190,6 +191,8 @@ async function main(): Promise<void> {
@@ -190,6 +191,8 @@ async function main(): Promise<void> {
warn: (message) => log.warn(message),
warn: (message) => log.warn(message),
})
})
playSync.start()
playSync.start()
+ // The in-game boards are Play's (public reads, no token).
+ const playBoards = createPlayBoards({ url: config.platform.playUrl })
const platformLog = log.child({ system: 'platform' })
const platformLog = log.child({ system: 'platform' })
const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })
const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })
let outbox: PgOutbox | null = null
let outbox: PgOutbox | null = null
@@ -784,6 +787,7 @@ async function main(): Promise<void> {
@@ -784,6 +787,7 @@ async function main(): Promise<void> {
if (
if (
handleGameRead(req, res, {
handleGameRead(req, res, {
db,
db,
+ boards: playBoards,
networkAuthUrl: config.networkAuthUrl,
networkAuthUrl: config.networkAuthUrl,
limits: actorLimits,
limits: actorLimits,
address: (request) => resolveClientAddress(request, config.trustProxy),
address: (request) => resolveClientAddress(request, config.trustProxy),
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { handleGameRead } from './gameRoutes.js'
import { handleGameRead } from './gameRoutes.js'
+import { BoardUnavailable } from '../platform/playBoards.js'
describe('game read routes', () => {
describe('game read routes', () => {
it('returns 404 for an unknown game before touching persistence', () => {
it('returns 404 for an unknown game before touching persistence', () => {
@@ -21,6 +22,7 @@ describe('game read routes', () => {
@@ -21,6 +22,7 @@ describe('game read routes', () => {
expect(
expect(
handleGameRead(req, res, {
handleGameRead(req, res, {
db: null as never,
db: null as never,
+ boards: null as never,
networkAuthUrl: null,
networkAuthUrl: null,
limits: null as never,
limits: null as never,
address: () => 'test',
address: () => 'test',
@@ -30,13 +32,10 @@ describe('game read routes', () => {
@@ -30,13 +32,10 @@ describe('game read routes', () => {
expect(JSON.parse(body)).toEqual({ error: 'unknown_game' })
expect(JSON.parse(body)).toEqual({ error: 'unknown_game' })
})
})
- it('answers the Scraplandia haul leaderboard', async () => {
+ const call = async (url: string, boards: unknown) => {
let status = 0
let status = 0
let body = ''
let body = ''
- const req = {
- method: 'GET',
- url: '/api/v1/games/scraplandia/leaderboard?period=week&page=1',
- } as IncomingMessage
+ const req = { method: 'GET', url } as IncomingMessage
const res = {
const res = {
writeHead: (code: number) => {
writeHead: (code: number) => {
status = code
status = code
@@ -44,23 +43,61 @@ describe('game read routes', () => {
@@ -44,23 +43,61 @@ describe('game read routes', () => {
end: (value: string) => {
end: (value: string) => {
body = value
body = value
},
},
- } as ServerResponse
- const db = {
- many: async () => [{ account_key: 'a', player_name: 'A', score: 120, finished_at: 100 }],
- }
- const limits = { publicRead: async () => true }
+ headersSent: false,
+ } as unknown as ServerResponse
handleGameRead(req, res, {
handleGameRead(req, res, {
- db: db as never,
+ db: null as never,
+ boards: boards as never,
networkAuthUrl: null,
networkAuthUrl: null,
- limits: limits as never,
- address: () => 'test',
+ limits: { publicRead: async () => true } as never,
+ address: () => '203.0.113.9',
})
})
await new Promise((resolve) => setTimeout(resolve, 10))
await new Promise((resolve) => setTimeout(resolve, 10))
+ return { status, body: JSON.parse(body) as Record<string, unknown> }
+ }
+
+ it("answers a leaderboard from Play's board, with the viewer's address", async () => {
+ const asked: unknown[] = []
+ const boards = async (...args: unknown[]) => {
+ asked.push(args)
+ return {
+ game: 'scraplandia',
+ period: 'week',
+ page: 1,
+ pageSize: 25,
+ pages: 1,
+ total: 1,
+ entries: [{ rank: 1, name: 'A', score: 120, signedIn: true }],
+ }
+ }
+ const { status, body } = await call(
+ '/api/v1/games/scraplandia/leaderboard?period=week&page=1',
+ boards,
+ )
expect(status).toBe(200)
expect(status).toBe(200)
- expect(JSON.parse(body)).toMatchObject({
+ expect(asked).toEqual([['scraplandia', 'week', 1, '203.0.113.9']])
+ expect(body).toMatchObject({
game: 'scraplandia',
game: 'scraplandia',
period: 'week',
period: 'week',
- entries: [{ rank: 1, name: 'A', score: 120, finishedAt: 100 }],
+ entries: [{ rank: 1, name: 'A', score: 120 }],
})
})
})
})
+
+ it('answers 503 when Play cannot be read', async () => {
+ const boards = async () => {
+ throw new BoardUnavailable('play 502')
+ }
+ const { status, body } = await call('/api/v1/games/salvage-rush/leaderboard?period=all', boards)
+ expect(status).toBe(503)
+ expect(body).toEqual({ error: 'boards_unavailable' })
+ })
+
+ it('refuses a bad period before asking Play', async () => {
+ const boards = async () => {
+ throw new Error('not asked')
+ }
+ expect((await call('/api/v1/games/salvage-rush/leaderboard?period=month', boards)).status).toBe(
+ 400,
+ )
+ })
})
})
@@ -4,10 +4,13 @@ import { isGuestToken } from '../platform/accounts.js'
@@ -4,10 +4,13 @@ import { isGuestToken } from '../platform/accounts.js'
import { hashGuestKey } from './guestIdentity.js'
import { hashGuestKey } from './guestIdentity.js'
import { resolveNetworkUser } from './networkAuth.js'
import { resolveNetworkUser } from './networkAuth.js'
import type { GamesActorLimits } from './actorLimits.js'
import type { GamesActorLimits } from './actorLimits.js'
-import { isGameId, leaderboard, playerAchievements } from '../game/progression.js'
+import { isGameId, playerAchievements } from '../game/progression.js'
+import { BoardUnavailable, type PlayBoards } from '../platform/playBoards.js'
interface Options {
interface Options {
db: Db
db: Db
+ /** The boards, read from OpenVibe.Play. */
+ boards: PlayBoards
networkAuthUrl: string | null
networkAuthUrl: string | null
limits: GamesActorLimits
limits: GamesActorLimits
address: (req: IncomingMessage) => string
address: (req: IncomingMessage) => string
@@ -45,16 +48,20 @@ export function handleGameRead(
@@ -45,16 +48,20 @@ export function handleGameRead(
send(400, { error: 'invalid_query' })
send(400, { error: 'invalid_query' })
return
return
}
}
- send(
- 200,
- await leaderboard(
- options.db,
- match[1] as 'scraplandia' | 'salvage-rush',
- period,
- page,
- Date.now(),
- ),
- )
+ try {
+ send(
+ 200,
+ await options.boards(
+ match[1] as 'scraplandia' | 'salvage-rush',
+ period,
+ page,
+ options.address(req),
+ ),
+ )
+ } catch (err) {
+ if (!(err instanceof BoardUnavailable)) throw err
+ send(503, { error: 'boards_unavailable' })
+ }
return
return
}
}
const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest'
+import { BoardUnavailable, createPlayBoards } from './playBoards.js'
+
+const board = (entries: unknown[]) =>
+ new Response(JSON.stringify({ page: 1, pages: 2, total: 26, entries }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+
+describe("in-game boards from Play's public board read", () => {
+ it("asks each game's own board, maps the entries and forwards the viewer's address", async () => {
+ const asked: { url: string; headers: Record<string, string> }[] = []
+ const read = createPlayBoards({
+ url: 'http://127.0.0.1:5040',
+ fetch: (async (url: string, init: RequestInit) => {
+ asked.push({ url, headers: init.headers as Record<string, string> })
+ return board([{ rank: 1, name: 'A', score: 150, signed_in: true }])
+ }) as typeof fetch,
+ })
+ const haul = await read('scraplandia', 'week', 1, '203.0.113.9')
+ await read('salvage-rush', 'all', 2, '203.0.113.9')
+ expect(asked.map((a) => a.url)).toEqual([
+ 'http://127.0.0.1:5040/api/v1/titles/scraplandia/boards/haul?period=week&page=1',
+ 'http://127.0.0.1:5040/api/v1/titles/salvage-rush/boards/best?period=all&page=2',
+ ])
+ expect(asked[0]?.headers['x-forwarded-for']).toBe('203.0.113.9')
+ expect(haul).toEqual({
+ game: 'scraplandia',
+ period: 'week',
+ page: 1,
+ pageSize: 25,
+ pages: 2,
+ total: 26,
+ entries: [{ rank: 1, name: 'A', score: 150, signedIn: true }],
+ })
+ })
+
+ it('keeps a page for 15 seconds', async () => {
+ let calls = 0
+ let now = 1_000
+ const read = createPlayBoards({
+ url: 'http://play',
+ now: () => now,
+ fetch: (async () => {
+ calls += 1
+ return board([])
+ }) as unknown as typeof fetch,
+ })
+ await read('salvage-rush', 'all', 1, 'a')
+ now += 14_000
+ await read('salvage-rush', 'all', 1, 'b')
+ expect(calls).toBe(1)
+ await read('salvage-rush', 'week', 1, 'b')
+ expect(calls).toBe(2)
+ now += 2_000
+ await read('salvage-rush', 'all', 1, 'c')
+ expect(calls).toBe(3)
+ })
+
+ it('is unavailable without Play, when Play refuses, and when it cannot be reached', async () => {
+ await expect(
+ createPlayBoards({ url: null })('salvage-rush', 'all', 1, 'a'),
+ ).rejects.toBeInstanceOf(BoardUnavailable)
+ const refused = createPlayBoards({
+ url: 'http://play',
+ fetch: (async () => new Response('{}', { status: 503 })) as unknown as typeof fetch,
+ })
+ await expect(refused('salvage-rush', 'all', 1, 'a')).rejects.toBeInstanceOf(BoardUnavailable)
+ const down = createPlayBoards({
+ url: 'http://play',
+ fetch: (async () => {
+ throw new TypeError('fetch failed')
+ }) as unknown as typeof fetch,
+ })
+ await expect(down('salvage-rush', 'all', 1, 'a')).rejects.toBeInstanceOf(BoardUnavailable)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+/**
+ * The in-game boards, read from OpenVibe.Play (ADR-0007 decision 10). Play keeps every title's boards; Games posts to
+ * them (playSync.ts) and answers `GET /api/v1/games/:game/leaderboard` from Play's public board read:
+ *
+ * scraplandia → board `haul` (sum of haul points)
+ * salvage-rush → board `best` (each player's best round)
+ *
+ * A page is kept for CACHE_MS, so a busy leaderboard page asks Play at most once per game, period and page in that
+ * time. The viewer's address goes along as X-Forwarded-For (Games and Play are both behind the host's nginx), so
+ * Play's per-address limits count the viewer and not Games. Play's board shows a name, a score and whether the player was signed in, never a player id. A result reaches
+ * the board once the sync has posted it (every 30 s).
+ */
+import type { GameId } from '../game/progression.js'
+import { PLAY_TITLES } from './playSync.js'
+
+const CACHE_MS = 15_000
+const CACHE_MAX = 200
+const TIMEOUT_MS = 5_000
+/** Play's board page (its PER_PAGE). */
+const PLAY_PAGE = 25
+
+export interface BoardPage {
+ game: GameId
+ period: 'all' | 'week'
+ page: number
+ pageSize: number
+ pages: number
+ total: number
+ entries: { rank: number; name: string; score: number; signedIn: boolean }[]
+}
+
+interface PlayBoard {
+ page: number
+ pages: number
+ total: number
+ entries: { rank: number; name: string; score: number; signed_in: boolean }[]
+}
+
+export class BoardUnavailable extends Error {}
+
+export function createPlayBoards(opts: {
+ url: string | null
+ fetch?: typeof fetch
+ now?: () => number
+}) {
+ const doFetch = opts.fetch ?? fetch
+ const now = opts.now ?? Date.now
+ const cache = new Map<string, { at: number; value: BoardPage }>()
+
+ return async function read(
+ game: GameId,
+ period: 'all' | 'week',
+ page: number,
+ address: string,
+ ): Promise<BoardPage> {
+ if (!opts.url) throw new BoardUnavailable('GAMES_PLAY_URL is not set')
+ const key = `${game}\u0000${period}\u0000${page}`
+ const hit = cache.get(key)
+ if (hit && now() - hit.at < CACHE_MS) return hit.value
+ const board = PLAY_TITLES[game].boards[0]!.id
+ const query = new URLSearchParams({ period, page: String(page) })
+ const res = await doFetch(`${opts.url}/api/v1/titles/${game}/boards/${board}?${query}`, {
+ headers: { accept: 'application/json', 'x-forwarded-for': address },
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ }).catch((err: unknown) => {
+ throw new BoardUnavailable(err instanceof Error ? err.message : String(err))
+ })
+ if (!res.ok) throw new BoardUnavailable(`play ${res.status}`)
+ const body = (await res.json()) as PlayBoard
+ const value: BoardPage = {
+ game,
+ period,
+ page,
+ pageSize: PLAY_PAGE,
+ pages: Number(body.pages) || 1,
+ total: Number(body.total) || 0,
+ entries: (body.entries ?? []).map((e) => ({
+ rank: Number(e.rank),
+ name: String(e.name),
+ score: Number(e.score),
+ signedIn: e.signed_in === true,
+ })),
+ }
+ cache.delete(key)
+ cache.set(key, { at: now(), value })
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value as string)
+ return value
+ }
+}
+
+export type PlayBoards = ReturnType<typeof createPlayBoards>
@@ -30,10 +30,11 @@ extraction event; its leaderboard ranks by the sum of haul points in the period
@@ -30,10 +30,11 @@ extraction event; its leaderboard ranks by the sum of haul points in the period
single best. `game_achievements` stores one unlock per game, criterion and player. Local guest
single best. `game_achievements` stores one unlock per game, criterion and player. Local guest
keys are SHA-256 hashes as for characters; Network subjects remain canonical. No score is written
keys are SHA-256 hashes as for characters; Network subjects remain canonical. No score is written
until the server confirms a round or extraction. `GET /api/v1/games/:game/leaderboard?period=all|week&page=1`
until the server confirms a round or extraction. `GET /api/v1/games/:game/leaderboard?period=all|week&page=1`
-returns 20 entries per page, ordered by score, then earliest completion. `week`
-starts Monday 00:00 UTC. `GET /api/v1/players/me/achievements` accepts the same
+reads the game's board on OpenVibe.Play (below): 25 entries per page (`rank`, `name`, `score`, `signedIn`, with
+`pages` and `total`), kept 15 s, the viewer's address forwarded so Play's per-address limits count the viewer; 503
+`boards_unavailable` when Play cannot be read. `week` starts Monday 00:00 UTC. `GET /api/v1/players/me/achievements` accepts the same
bearer or `X-Guest-Token` used by the ticket endpoint. Public reads have per-address limits.
bearer or `X-Guest-Token` used by the ticket endpoint. Public reads have per-address limits.
-The read-only `/leaderboard.html` page shows Salvage Rush's best round scores and Scraplandia's summed haul points, switchable in the page, all time or this week (from Monday 00:00 UTC), 20 a page.
+The read-only `/leaderboard.html` page shows Salvage Rush's best round scores and Scraplandia's summed haul points, switchable in the page, all time or this week (from Monday 00:00 UTC), 25 a page.
Network account exports include results, best scores, unlocks and hauls; account erasure removes them.
Network account exports include results, best scores, unlocks and hauls; account erasure removes them.
Guest character adoption and Network subject merges move progression (including hauls) to the surviving subject.
Guest character adoption and Network subject merges move progression (including hauls) to the surviving subject.
@@ -53,5 +54,6 @@ OpenVibe.Play (play.openvibe.network, contracts 0.172.0) is the network's home f
@@ -53,5 +54,6 @@ OpenVibe.Play (play.openvibe.network, contracts 0.172.0) is the network's home f
achievements and leaderboards. With `GAMES_PLAY_URL` set, Games registers both titles there (Scraplandia's weekly
achievements and leaderboards. With `GAMES_PLAY_URL` set, Games registers both titles there (Scraplandia's weekly
haul board sums extractions; Salvage Rush's board ranks each player's best round) and posts every round, haul, unlock
haul board sums extractions; Salvage Rush's board ranks each player's best round) and posts every round, haul, unlock
and guest adoption from the local tables (`apps/server/src/platform/playSync.ts`, position in `play_sync`). Every post
and guest adoption from the local tables (`apps/server/src/platform/playSync.ts`, position in `play_sync`). Every post
-is idempotent, so the first run imports the history. The local tables still serve the in-game boards; reading them
-from Play and dropping the local copies is the next step.
+is idempotent, so the first run imports the history. The in-game boards read Play's (`apps/server/src/platform/playBoards.ts`).
+The local tables stay as the record the achievement rules read (a haul's weekly total, a round's unlocks) and the sync's
+source; nothing reads them for a board.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/platform/playBoards.test.ts'
+// covers: 'apps/server/src/platform'
+// Generated by node test/run.js --index; do not edit.