openvibers/openvibe.games Commits

Games: the in-game boards read OpenVibe.Play's (local board SQL removed) (#77)

OP OpenVibers committed 29 days ago · 77392eb98f · 11 files, +291 −122

The in-game boards now come from OpenVibe.Play, as plan T8 asked ("the in-game boards read from Play"). Games already posts every round, haul, unlock and guest adoption there.

- `GET /api/v1/games/:game/leaderboard` reads Play's public board for the game: Scraplandia's `haul` (sum) and Salvage Rush's `best`.
  - The new `apps/server/src/platform/playBoards.ts` reads Play's public board API.
  - Each page is cached for 15 s.
  - The viewer's address is forwarded, so Play's per-address limits count the viewer, not Games' loopback.
  - The response is 25 per page (Play's page size), with `pages` and `total`.
  - It answers 503 `boards_unavailable` when Play can't be read.
- The local board SQL (`leaderboard()` in progression.ts) is deleted.
  - The local tables stay: the achievement rules read them (a haul's weekly total), and the sync posts from them. Nothing reads them for a board any more.
- The leaderboard page pages by Play's `pages`, and its note says the boards are kept by OpenVibe.Play. A finished round shows within a minute: the sync runs every 30 s, plus the cache.
- The production boards were empty on both sides, so nothing is lost in the move.

Tests:
- `playBoards.test.ts`: board ids and URLs, entry mapping, the forwarded address, the 15 s cache, and unavailable with no URL, a refusal or a network failure.
- `gameRoutes.test.ts`: answers from Play's board, 503, and a bad period refused before Play is asked.
- `progression.test.ts`: now reads the tables it records into.

Changes

ddiff --git a/apps/client/leaderboard.html b/apps/client/leaderboard.html
iindex ef850d9..69d5829 100644
---- a/apps/client/leaderboard.html
++++ b/apps/client/leaderboard.html
@@@ -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>
ddiff --git a/apps/client/src/pages/leaderboard.ts b/apps/client/src/pages/leaderboard.ts
iindex 37b9131..6d18253 100644
---- a/apps/client/src/pages/leaderboard.ts
++++ b/apps/client/src/pages/leaderboard.ts
@@@ -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.'
}
ddiff --git a/apps/server/src/game/progression.test.ts b/apps/server/src/game/progression.test.ts
iindex 3916c92..ec19db8 100644
---- a/apps/server/src/game/progression.test.ts
++++ b/apps/server/src/game/progression.test.ts
@@@ -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) // Sunday
await 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) // Sunday
await 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, ignored
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
-- 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 () => {
ddiff --git a/apps/server/src/game/progression.ts b/apps/server/src/game/progression.ts
iindex 9632a15..3dd99b7 100644
---- a/apps/server/src/game/progression.ts
++++ b/apps/server/src/game/progression.ts
@@@ -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<{
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 4f63b15..fe5d9c3 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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),
ddiff --git a/apps/server/src/net/gameRoutes.test.ts b/apps/server/src/net/gameRoutes.test.ts
iindex a173991..e232ad2 100644
---- a/apps/server/src/net/gameRoutes.test.ts
++++ b/apps/server/src/net/gameRoutes.test.ts
@@@ -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 = 0
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 = {
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 ServerResponse
handleGameRead(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,
++ )
++ })
})
ddiff --git a/apps/server/src/net/gameRoutes.ts b/apps/server/src/net/gameRoutes.ts
iindex 581bdf5..bbd4620 100644
---- a/apps/server/src/net/gameRoutes.ts
++++ b/apps/server/src/net/gameRoutes.ts
@@@ -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: PlayBoards
networkAuthUrl: string | null
limits: GamesActorLimits
address: (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]
ddiff --git a/apps/server/src/platform/playBoards.test.ts b/apps/server/src/platform/playBoards.test.ts
nnew file mode 100644
iindex 0000000..892f0ee
---- /dev/null
++++ b/apps/server/src/platform/playBoards.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/platform/playBoards.ts b/apps/server/src/platform/playBoards.ts
nnew file mode 100644
iindex 0000000..41d7687
---- /dev/null
++++ b/apps/server/src/platform/playBoards.ts
@@@ -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>
ddiff --git a/docs/games.md b/docs/games.md
iindex 53632ff..94d4200 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -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
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`
--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.
--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 f
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
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.
ddiff --git a/test/apps__server__src__platform__playBoards.test.js b/test/apps__server__src__platform__playBoards.test.js
nnew file mode 100644
iindex 0000000..4b07272
---- /dev/null
++++ b/test/apps__server__src__platform__playBoards.test.js
@@@ -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.