openvibers/openvibe.games Commits

Source map names: surf_lagoon everywhere, old forms redirect (#195)

OP OpenVibers committed 16 days ago · dd8e5760bb · 22 files, +637 −40

* Source map names everywhere: surf_lagoon is the place id, URL, Play board and what people read; old forms redirect (301/308), the WebSocket aliases them, migration 0019 moves stored place ids

* test/ pointers for the two new test files

Changes

ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 37fc49c..39fa687 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -92,13 +92,39 @@ async function start(): Promise<void> {
// A creator world (/worlds): its own place, the plain creator pack and the world's map.
const requestedWorld = params.get('world')
const worldId = requestedWorld && CREATOR_WORLD_ID.test(requestedWorld) ? requestedWorld : null
-- // OpenVibe.Surf Ride: ?map=<id> (or ?game=surf&map=…). A map is a place
-- // (`surf-lagoon`), so the existing place URL scheme carries it unchanged.
++ // OpenVibe.Surf Ride: /surf/<id> is canonical, and ?map=<id> / ?game=<id>
++ // are accepted aliases. A map's place id is the map id (`surf_lagoon`).
const requestedMap = params.get('map')
++ const surfPath = /^\/surf\/([^/]+)\/?$/.exec(location.pathname)?.[1]
const surfInfo: SurfMapInfo | null =
++ (surfPath && surfPath !== 'embed' ? surfMap(surfPath) : null) ??
(requestedMap ? surfMap(requestedMap) : null) ??
-- (requestedGame === 'surf' ? surfMap('surf_lagoon') : null) ??
++ (requestedGame ? surfMap(requestedGame) : null) ??
null
++ // The old hyphenated forms are aliases only: put the canonical form in the
++ // address bar before any fetch. The server sends the same 301.
++ if (surfInfo) {
++ if (surfPath && surfPath !== 'embed' && surfPath !== surfInfo.id) {
++ history.replaceState(null, '', `/surf/${surfInfo.id}${location.search}${location.hash}`)
++ } else if (requestedGame && surfMap(requestedGame) && !surfPath) {
++ const rest = new URLSearchParams(params)
++ rest.delete('game')
++ rest.delete('map')
++ history.replaceState(
++ null,
++ '',
++ `/surf/${surfInfo.id}${rest.size ? `?${rest.toString()}` : ''}${location.hash}`,
++ )
++ } else if (requestedMap && requestedMap !== surfInfo.id) {
++ params.set('map', surfInfo.id)
++ const query = params.toString()
++ history.replaceState(
++ null,
++ '',
++ `${location.pathname}${query ? `?${query}` : ''}${location.hash}`,
++ )
++ }
++ }
const selectedPlace = worldId
? creatorPlaceId(worldId)
: surfInfo
ddiff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts
iindex 194e53d..d3b151d 100644
---- a/apps/server/src/config.test.ts
++++ b/apps/server/src/config.test.ts
@@@ -60,6 +60,15 @@ describe('loadConfig migrationsDir', () => {
})
})
++describe('loadConfig surf place', () => {
++ it('names a surf place by its map id, including an old hyphenated env value', () => {
++ expect(loadConfig({ GAMES_PLACE_ID: 'surf_lagoon' }).placeId).toBe('surf_lagoon')
++ expect(loadConfig({ GAMES_PLACE_ID: 'surf-lagoon' }).placeId).toBe('surf_lagoon')
++ // Other games are untouched.
++ expect(loadConfig({ GAMES_PLACE_ID: 'salvage-rush' }).placeId).toBe('salvage-rush')
++ })
++})
++
describe('loadConfig scriptMods', () => {
it('is off unless the place enables it', () => {
expect(loadConfig({}).scriptMods).toBe(false)
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 253f726..307e099 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -1,3 +1,4 @@
++import { surfMap } from '@openvibe/content'
import { MIGRATIONS_DIR } from '@openvibe/persistence'
import { SIM_TICK_RATE, SURF_SNAPSHOT_EVERY, SURF_TICK_RATE } from '@openvibe/shared'
import { parseProxyTrust, type ProxyTrust } from './net/clientAddress.js'
@@@ -204,10 +205,14 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
}
export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
++ const requestedPlace = env.GAMES_PLACE_ID ?? 'scraplandia'
++ // A surf deployment's place id is its map id; the old `surf-lagoon` env value
++ // boots the canonical place rather than starting a second, aliased one.
++ const placeId = surfMap(requestedPlace)?.id ?? requestedPlace
return {
port: intEnv(env, 'PORT', 8000),
host: env.HOST ?? '0.0.0.0',
-- placeId: env.GAMES_PLACE_ID ?? 'scraplandia',
++ placeId,
db: {
url: env.DATABASE_URL ?? null,
directUrl: env.DATABASE_DIRECT_URL ?? null,
@@@ -220,7 +225,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
staticDir: env.STATIC_DIR ?? null,
mapPath:
env.MAP_PATH ??
-- (env.GAMES_PLACE_ID === 'salvage-rush' ? 'data/salvage-rush-map.json' : 'data/map.json'),
++ (placeId === 'salvage-rush' ? 'data/salvage-rush-map.json' : 'data/map.json'),
trustProxy: parseProxyTrust(env.TRUST_PROXY),
/** openvibe.network auth endpoint; when set, editor tokens validate there. */
networkAuthUrl:
ddiff --git a/apps/server/src/game/placeRouter.ts b/apps/server/src/game/placeRouter.ts
iindex 377c8a1..4d33ef5 100644
---- a/apps/server/src/game/placeRouter.ts
++++ b/apps/server/src/game/placeRouter.ts
@@@ -29,6 +29,8 @@ export class PlaceRouter {
private readonly hubs: ReadonlyMap<string, InstanceHub>,
private readonly defaultPlace: string,
private readonly dynamic: DynamicPlaces | null = null,
++ /** A one-release alias for old place ids (e.g. `surf-lagoon` → `surf_lagoon`). */
++ private readonly canonicalPlace: (place: string) => string | null = () => null,
) {
if (!hubs.has(defaultPlace)) throw new Error(`default place ${defaultPlace} is not hosted`)
}
@@@ -49,7 +51,9 @@ export class PlaceRouter {
}
resolve(place: string | undefined): string {
-- return place && this.hubs.has(place) ? place : this.defaultPlace
++ if (!place) return this.defaultPlace
++ if (this.hubs.has(place)) return place
++ return this.canonicalPlace(place) ?? this.defaultPlace
}
onMessage(conn: GameConnection, msg: ClientMessage): void {
ddiff --git a/apps/server/src/game/surfMaps.test.ts b/apps/server/src/game/surfMaps.test.ts
iindex a45a723..a9db4f1 100644
---- a/apps/server/src/game/surfMaps.test.ts
++++ b/apps/server/src/game/surfMaps.test.ts
@@@ -5,6 +5,7 @@ import {
compileMapFileV2,
parseMapFile,
surfMap,
++ surfMapOfPlace,
type MapFileV2,
} from '@openvibe/content'
import {
@@@ -64,7 +65,7 @@ describe.each(SURF_MAPS)('$name', (info) => {
const map = load(info.id)
expect(map.movement).toBe('surf')
expect(map.surf).toMatchObject({
-- title: info.name,
++ title: info.id,
author: 'OpenVibe',
tier: info.tier,
stages: info.stages,
@@@ -152,6 +153,19 @@ describe('surf map catalogue', () => {
expect(SURF_MAPS.map((m) => m.id)).toEqual(['surf_lagoon', 'surf_canyon', 'surf_orbit'])
expect(SURF_MAPS.map((m) => m.tier)).toEqual([1, 2, 4])
expect(SURF_MAPS.map((m) => m.stages)).toEqual([3, 4, 6])
-- for (const info of SURF_MAPS) expect(surfMap(info.placeId)?.id).toBe(info.id)
++ // A Source map is its file name: the visible name, content id and place id
++ // are the same string, and the JSON title agrees.
++ expect(SURF_MAPS.every((m) => m.name === m.id && m.placeId === m.id)).toBe(true)
++ for (const info of SURF_MAPS) {
++ expect(surfMap(info.placeId)?.id).toBe(info.id)
++ expect(load(info.id).surf?.title).toBe(info.id)
++ }
++ })
++
++ it('resolves the old hyphenated id only as a redirect alias', () => {
++ expect(surfMap('surf-lagoon')).toMatchObject({ id: 'surf_lagoon', placeId: 'surf_lagoon' })
++ expect(surfMapOfPlace('surf-canyon')?.id).toBe('surf_canyon')
++ expect(surfMap('surf')?.id).toBe('surf_lagoon')
++ expect(surfMap('surf-missing')).toBeUndefined()
})
})
ddiff --git a/apps/server/src/game/surfRules.test.ts b/apps/server/src/game/surfRules.test.ts
iindex 8ffc515..c91f1c2 100644
---- a/apps/server/src/game/surfRules.test.ts
++++ b/apps/server/src/game/surfRules.test.ts
@@@ -56,7 +56,7 @@ function sessionFor(override: ReturnType<typeof loadSurfOverride>) {
function ctxFor(session: any, respawns: string[], sent: unknown[]) {
return {
clock: { tick: 0 },
-- config: { ...SURF_ROOM_TIMING, placeId: 'surf-lagoon', instanceSlot: 0 },
++ config: { ...SURF_ROOM_TIMING, placeId: 'surf_lagoon', instanceSlot: 0 },
env: {},
regions: { update() {} },
sessions: {
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 2bf6bb1..e7e3d16 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -239,6 +239,13 @@ async function main(): Promise<void> {
createSurfPlay({ url: config.platform.playUrl, tokens: platform?.tokens ?? null }),
(message) => log.warn(message),
)
++ // One-time: copy the local PBs onto the renamed Play boards (lagoon →
++ // surf_lagoon). It is idempotent, so a Play outage simply retries next boot.
++ void surfRecords
++ .migratePlayBoards()
++ .catch((error: unknown) =>
++ log.warn('surf Play board copy failed (will retry next boot)', { error: String(error) }),
++ )
const platformLog = log.child({ system: 'platform' })
const valkey = createValkey({ url: config.db.valkeyUrl ?? '', prefix: config.db.valkeyPrefix })
let outbox: PgOutbox | null = null
@@@ -753,7 +760,12 @@ async function main(): Promise<void> {
maxRunning: config.creatorWorldsMax,
idleMs: config.creatorWorldIdleSeconds * 1000,
})
-- const router = new PlaceRouter(hubs, config.placeId, worldHost)
++ const router = new PlaceRouter(
++ hubs,
++ config.placeId,
++ worldHost,
++ (place) => surfMapOfPlace(place)?.placeId ?? null,
++ )
// The idle sweep runs in the admission chain, so a world never stops under a join that is starting it.
const worldSweepTimer = setInterval(() => {
void router
ddiff --git a/apps/server/src/net/httpServer.test.ts b/apps/server/src/net/httpServer.test.ts
iindex 21ffe34..d838ea3 100644
---- a/apps/server/src/net/httpServer.test.ts
++++ b/apps/server/src/net/httpServer.test.ts
@@@ -186,6 +186,37 @@ describe('real routes on the apex', () => {
expect(r.headers.location).toBe(location)
})
++ it('serves the canonical surf page and the embed route', async () => {
++ for (const path of ['/surf/surf_lagoon', '/surf/embed?map=surf_lagoon']) {
++ const r = await send(path)
++ expect(r.status).toBe(200)
++ expect(r.body).toContain('game page')
++ }
++ const embed = await send('/surf/embed?map=surf_lagoon')
++ expect(embed.headers['content-security-policy']).toBe(
++ "frame-ancestors https://openvibe.surf 'self'",
++ )
++ })
++
++ it.each([
++ ['/surf/surf-lagoon', '/surf/surf_lagoon'],
++ ['/surf/surf-lagoon/', '/surf/surf_lagoon'],
++ ['/surf/embed?map=surf-lagoon&embed=1', '/surf/embed?map=surf_lagoon&embed=1'],
++ ['/play?game=surf-lagoon', '/surf/surf_lagoon'],
++ ['/play?game=surf-lagoon&src=ad', '/surf/surf_lagoon?src=ad'],
++ ['/play?game=surf&map=surf-canyon', '/surf/surf_canyon'],
++ ])('%s redirects to %s', async (path, location) => {
++ const r = await send(path)
++ expect(r.status).toBe(301)
++ expect(r.headers.location).toBe(location)
++ })
++
++ it('answers an unknown surf map with 404, not the game page', async () => {
++ const r = await send('/surf/surf-missing')
++ expect(r.status).toBe(404)
++ expect(r.body).not.toContain('game page')
++ })
++
it('answers HEAD like GET, without a body', async () => {
const r = await send('/', { method: 'HEAD' })
expect(r.status).toBe(200)
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 168d74b..396adbf 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -62,7 +62,54 @@ export function portalPage(filePath: string, parts: HomeParts | null): string {
}
import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import { casualGameOf, casualPageHtml } from '../casual/page.js'
--import type { MapFileV2 } from '@openvibe/content'
++import { surfMap, type MapFileV2 } from '@openvibe/content'
++
++/**
++ * The human surf pages: `/surf/<map>` is canonical, `/surf/embed` is the iframe
++ * route, and the old forms are permanent redirects. A redirect is returned for
++ * the caller to send before any static file is looked up; `page` means the
++ * client bundle's play.html answers it.
++ */
++const SURF_MAP_PATH = /^\/surf\/([^/]+)\/?$/
++
++function surfPageRoute(
++ path: string,
++ query: URLSearchParams,
++): { redirect?: string; page?: boolean } | null {
++ const match = SURF_MAP_PATH.exec(path)
++ if (!match) {
++ // ?game=<surf map> on the game page (or the apex root) is the old entry
++ // point. Every surf game value lands on its canonical /surf/<id> page.
++ if (path !== '/play' && path !== '/' && path !== '/play/') return null
++ const game = query.get('game')
++ if (!game) return null
++ const info = surfMap(game)
++ if (!info) return null
++ const requestedMap = query.get('map')
++ const target = (requestedMap ? surfMap(requestedMap) : null) ?? info
++ const rest = new URLSearchParams(query)
++ rest.delete('game')
++ rest.delete('map')
++ return { redirect: `/surf/${target.id}${rest.size ? `?${rest.toString()}` : ''}` }
++ }
++ const requested = match[1]!
++ if (requested === 'embed') {
++ const map = query.get('map')
++ if (!map) return { page: true }
++ const info = surfMap(map)
++ if (!info) return { page: true } // the client reports the unknown map
++ if (info.id === map) return { page: true }
++ const rest = new URLSearchParams(query)
++ rest.set('map', info.id)
++ return { redirect: `/surf/embed?${rest.toString()}` }
++ }
++ const info = surfMap(requested)
++ if (!info) return null
++ if (requested !== info.id || path.endsWith('/')) {
++ return { redirect: `/surf/${info.id}${query.size ? `?${query.toString()}` : ''}` }
++ }
++ return { page: true }
++}
/**
* Minimal HTTP layer: health/metrics endpoints and (in production) the
@@@ -751,6 +798,12 @@ export function createHttpServer(
notFound()
return
}
++ const surfRoute = surfPageRoute(url, new URL(req.url ?? '/', 'http://games.local').searchParams)
++ if (surfRoute?.redirect) {
++ res.writeHead(301, { location: surfRoute.redirect, 'cache-control': 'no-store' })
++ res.end()
++ return
++ }
if (!root) {
notFound()
return
@@@ -813,7 +866,7 @@ export function createHttpServer(
? 'editor.html'
: url === '/worlds'
? 'worlds.html'
-- : url === '/surf/embed'
++ : url === '/surf/embed' || surfRoute?.page
? 'play.html'
: null
ddiff --git a/apps/server/src/net/surfRoutes.test.ts b/apps/server/src/net/surfRoutes.test.ts
nnew file mode 100644
iindex 0000000..1c96eb3
---- /dev/null
++++ b/apps/server/src/net/surfRoutes.test.ts
@@@ -0,0 +1,76 @@
++import { createServer, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { afterEach, describe, expect, it } from 'vitest'
++import { openTestDb } from '@openvibe/persistence/testing'
++import { handleSurfRead } from './surfRoutes.js'
++
++/**
++ * The public surf reads: the map id is the Source map name (`surf_lagoon`), the
++ * old hyphenated id is a 308 alias (with its ghost and query string), and the
++ * catalogue only ever names the canonical form.
++ */
++
++let server: Server | null = null
++
++afterEach(async () => {
++ if (!server) return
++ await new Promise<void>((resolve) => server!.close(() => resolve()))
++ server = null
++})
++
++async function serve(): Promise<string> {
++ const db = await openTestDb()
++ server = createServer((req, res) => {
++ if (!handleSurfRead(req, res, { db, networkAuthUrl: null })) {
++ res.writeHead(404, { 'content-type': 'application/json' })
++ res.end('{"error":"not_found"}')
++ }
++ })
++ await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve()))
++ return `http://127.0.0.1:${(server.address() as AddressInfo).port}`
++}
++
++describe('surf map reads', () => {
++ it('lists the canonical map ids and names', async () => {
++ const base = await serve()
++ const res = await fetch(`${base}/api/surf/maps`)
++ expect(res.status).toBe(200)
++ const body = (await res.json()) as { maps: { id: string; name: string }[] }
++ expect(body.maps.map((m) => m.id)).toEqual(['surf_lagoon', 'surf_canyon', 'surf_orbit'])
++ expect(body.maps.every((m) => m.name === m.id)).toBe(true)
++ })
++
++ it('answers the canonical map id', async () => {
++ const base = await serve()
++ const res = await fetch(`${base}/api/surf/maps/surf_lagoon`)
++ expect(res.status).toBe(200)
++ const body = (await res.json()) as { map: { id: string; name: string } }
++ expect(body.map).toMatchObject({ id: 'surf_lagoon', name: 'surf_lagoon' })
++ })
++
++ it.each([
++ ['/api/surf/maps/surf-lagoon', '/api/surf/maps/surf_lagoon'],
++ ['/api/surf/maps/surf-lagoon/ghost', '/api/surf/maps/surf_lagoon/ghost'],
++ ])('%s is a 308 to %s', async (path, location) => {
++ const base = await serve()
++ const res = await fetch(`${base}${path}`, { redirect: 'manual' })
++ expect(res.status).toBe(308)
++ expect(res.headers.get('location')).toBe(location)
++ })
++
++ it('keeps the query string through the API redirect', async () => {
++ const base = await serve()
++ const res = await fetch(`${base}/api/surf/maps/surf-lagoon/ghost?of=wr`, {
++ redirect: 'manual',
++ })
++ expect(res.status).toBe(308)
++ expect(res.headers.get('location')).toBe('/api/surf/maps/surf_lagoon/ghost?of=wr')
++ })
++
++ it('answers an unknown map with 404, never the old id', async () => {
++ const base = await serve()
++ const res = await fetch(`${base}/api/surf/maps/surf-missing`)
++ expect(res.status).toBe(404)
++ expect(await res.json()).toEqual({ error: 'unknown_map' })
++ })
++})
ddiff --git a/apps/server/src/net/surfRoutes.ts b/apps/server/src/net/surfRoutes.ts
iindex 5402ae3..77a46ef 100644
---- a/apps/server/src/net/surfRoutes.ts
++++ b/apps/server/src/net/surfRoutes.ts
@@@ -1,5 +1,5 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
--import { SURF_MAPS, type SurfMapInfo } from '@openvibe/content'
++import { SURF_MAPS, surfMap, type SurfMapInfo } from '@openvibe/content'
import type { Db } from 'openvibe-sdk/db'
import { isGuestToken } from '../platform/accounts.js'
import { PgSurfRecords } from '../platform/surfRuns.js'
@@@ -43,6 +43,12 @@ function noStore(res: ServerResponse, status: number, body: unknown): void {
send(res, status, body, 'no-store')
}
++/** A permanent redirect that keeps the query string (the canonical API id). */
++function movedPermanently(res: ServerResponse, path: string, query: string): void {
++ res.writeHead(308, { location: `${path}${query}`, 'cache-control': 'no-store' })
++ res.end()
++}
++
function mapInfo(id: string): SurfMapInfo | undefined {
return SURF_MAPS.find((m) => m.id === id)
}
@@@ -90,6 +96,17 @@ export function handleSurfRead(
const info = mapInfo(id)
if (!info) {
++ // The old `surf-lagoon` API names are aliases only: a permanent redirect to
++ // the Source-form id, ghost suffix and all.
++ const legacy = surfMap(id)
++ if (legacy) {
++ movedPermanently(
++ res,
++ url.pathname.replace(/^(\/api\/surf\/maps\/)[^/]+/, `$1${legacy.id}`),
++ url.search,
++ )
++ return true
++ }
noStore(res, 404, { error: 'unknown_map' })
return true
}
ddiff --git a/apps/server/src/observability/metrics.test.ts b/apps/server/src/observability/metrics.test.ts
iindex d8b3602..763448b 100644
---- a/apps/server/src/observability/metrics.test.ts
++++ b/apps/server/src/observability/metrics.test.ts
@@@ -41,7 +41,7 @@ describe('ServerMetrics room gauges', () => {
it("answers one place's gauges, not the other places' (the e2e reads its own place through /metrics?place=)", () => {
const m = new ServerMetrics()
m.sampleRoom('scraplandia:0', gauges({ mapStatics: 0, entities: 3 }), 1_000)
-- m.sampleRoom('surf-lagoon:0', gauges({ mapStatics: 47, entities: 1 }), 1_000)
++ m.sampleRoom('surf_lagoon:0', gauges({ mapStatics: 47, entities: 1 }), 1_000)
expect(m.mapStatics).toBe(47)
const one = m.placeSnapshot('scraplandia')
expect(one['mapStatics']).toBe(0)
ddiff --git a/apps/server/src/platform/surfRuns.test.ts b/apps/server/src/platform/surfRuns.test.ts
iindex 70b4dcc..6211b6e 100644
---- a/apps/server/src/platform/surfRuns.test.ts
++++ b/apps/server/src/platform/surfRuns.test.ts
@@@ -105,8 +105,24 @@ describe('surf runs persistence', () => {
const outcome = await records.record(run({ id: 'run-1', timeMs: 9_876 }))
expect(outcome.pb).toBe(true)
expect(play.defined).toEqual(['surf'])
-- expect(play.posted).toEqual([{ board: 'lagoon', time: 9_876, ref: 'run-1' }])
-- expect(surfPlayKey('surf_lagoon')).toBe('surf:lagoon')
++ expect(play.posted).toEqual([{ board: 'surf_lagoon', time: 9_876, ref: 'run-1' }])
++ expect(surfPlayKey('surf_lagoon')).toBe('surf:surf_lagoon')
++ expect(surfBoardId('surf-lagoon')).toBe('surf_lagoon')
++ })
++
++ it('copies each player’s local PB onto the renamed Play board once', async () => {
++ const db = await openTestDb()
++ const play = fakePlay()
++ const records = new PgSurfRecords(db, play.poster)
++ await records.record(run({ id: 'run-slow', timeMs: 9_000, createdAt: 1 }))
++ await records.record(run({ id: 'run-fast', timeMs: 8_000, createdAt: 2 }))
++ play.posted.length = 0
++
++ expect(await records.migratePlayBoards()).toBe(1)
++ expect(play.posted).toEqual([{ board: 'surf_lagoon', time: 8_000, ref: 'run-fast' }])
++ // The marker makes it one-shot; a second boot posts nothing.
++ expect(await records.migratePlayBoards()).toBe(0)
++ expect(play.posted).toHaveLength(1)
})
it('an unknown-kind Play answer leaves the run saved and reports no rank', async () => {
@@@ -154,15 +170,15 @@ describe('surf runs persistence', () => {
expect(calls[0]!.path).toBe('/api/v1/titles/surf')
expect(calls[0]!.body.boards).toEqual(
expect.arrayContaining([
-- expect.objectContaining({ id: 'lagoon', kind: 'time' }),
-- expect.objectContaining({ id: 'canyon', kind: 'time' }),
-- expect.objectContaining({ id: 'orbit', kind: 'time' }),
++ expect.objectContaining({ id: 'surf_lagoon', name: 'surf_lagoon — time', kind: 'time' }),
++ expect.objectContaining({ id: 'surf_canyon', name: 'surf_canyon — time', kind: 'time' }),
++ expect.objectContaining({ id: 'surf_orbit', name: 'surf_orbit — time', kind: 'time' }),
]),
)
const result = await poster.post(run({ id: 'run-9', timeMs: 4_321 }))
expect(calls[1]!.path).toBe('/api/v1/titles/surf/scores')
expect(calls[1]!.body).toMatchObject({
-- board: 'lagoon',
++ board: 'surf_lagoon',
ref: 'run-9',
entries: [{ score: 4_321 }],
})
ddiff --git a/apps/server/src/platform/surfRuns.ts b/apps/server/src/platform/surfRuns.ts
iindex ade9ab5..a9e8ce8 100644
---- a/apps/server/src/platform/surfRuns.ts
++++ b/apps/server/src/platform/surfRuns.ts
@@@ -34,16 +34,29 @@ export interface SurfRunRow {
isPb: boolean
}
++/** What Play needs to record a run: the local PB row, without its ghost. */
++export interface SurfPlayRun {
++ id: string
++ mapId: string
++ accountKey: string
++ playerName: string
++ timeMs: number
++ createdAt: number
++}
++
export interface SurfPlayPoster {
/** Register the surf title/boards; safe to call repeatedly. */
define(): Promise<void>
/** Post one run's ms. Returns the rank when Play reports one. */
-- post(run: SurfRunRecord): Promise<{ rank: number | null }>
++ post(run: SurfPlayRun): Promise<{ rank: number | null }>
}
--/** The Play board id for a map: `surf` title, `<slug>` board (`surf:lagoon`). */
++/** The one-time local-PB copy onto the renamed Play boards (see migratePlayBoards). */
++const SURF_PLAY_MIGRATION = 'surf_board_ids'
++
++/** The Play board id for a map: the full map id (`surf_lagoon`), so the URL is `/titles/surf/boards/surf_lagoon`. */
export function surfBoardId(mapId: string): string {
-- return mapId.replace(/^surf_/, '').replace(/[^a-z0-9_-]/g, '')
++ return mapId.replace(/-/g, '_').replace(/[^a-z0-9_]/g, '')
}
export function surfPlayKey(mapId: string): string {
@@@ -188,6 +201,59 @@ export class PgSurfRecords implements SurfRecords {
return { pb, wr, rank, pbMs, wrMs }
}
++ /**
++ * Copy each player's local PB onto the renamed Play boards once.
++ *
++ * Renaming `lagoon` to `surf_lagoon` creates a new Play board; Play retires
++ * the old one and keeps its scores, but the new board starts empty. Games'
++ * authoritative local runs still hold every player's best (one PB per map per
++ * account), so posting those under their original refs is an idempotent copy:
++ * no player's score disappears from the public all-time board. A marker in
++ * `play_sync` makes it one-shot; a partial failure retries on the next boot
++ * and Play's (board, ref, player) key makes the retry harmless.
++ */
++ async migratePlayBoards(): Promise<number> {
++ if (!this.play) return 0
++ const done = await this.db.maybe<{ stream: string }>(sql`
++ SELECT stream FROM play_sync WHERE stream = ${SURF_PLAY_MIGRATION}
++ `)
++ if (done) return 0
++ await this.play.define()
++ const rows = await this.db.many<{
++ id: string
++ map_id: string
++ account_key: string
++ player_name: string
++ time_ms: number | string
++ created_at: number | string
++ }>(sql`
++ SELECT DISTINCT ON (map_id, account_key)
++ id, map_id, account_key, player_name, time_ms, created_at
++ FROM surf_runs
++ WHERE is_pb
++ ORDER BY map_id, account_key, created_at DESC
++ `)
++ let copied = 0
++ for (const row of rows) {
++ if (!toPlayer(row.account_key)) continue
++ await this.play.post({
++ id: row.id,
++ mapId: row.map_id,
++ accountKey: row.account_key,
++ playerName: row.player_name,
++ timeMs: Number(row.time_ms),
++ createdAt: Number(row.created_at),
++ })
++ copied++
++ }
++ await this.db.exec(sql`
++ INSERT INTO play_sync (stream, at, key) VALUES
++ (${SURF_PLAY_MIGRATION}, ${Date.now()}, 'surf_lagoon')
++ ON CONFLICT (stream) DO UPDATE SET at = excluded.at, key = excluded.key
++ `)
++ return copied
++ }
++
/** Every map with its record count and derived world record. */
async maps(): Promise<SurfMapSummary[]> {
const rows = await this.db.many<{
ddiff --git a/content/surf/surf_canyon.json b/content/surf/surf_canyon.json
iindex 9790040..13267a1 100644
---- a/content/surf/surf_canyon.json
++++ b/content/surf/surf_canyon.json
@@@ -2,7 +2,7 @@
"v": 2,
"movement": "surf",
"surf": {
-- "title": "Surf Canyon",
++ "title": "surf_canyon",
"author": "OpenVibe",
"tier": 2,
"stages": 4,
ddiff --git a/content/surf/surf_lagoon.json b/content/surf/surf_lagoon.json
iindex 664d433..1e383db 100644
---- a/content/surf/surf_lagoon.json
++++ b/content/surf/surf_lagoon.json
@@@ -2,7 +2,7 @@
"v": 2,
"movement": "surf",
"surf": {
-- "title": "Surf Lagoon",
++ "title": "surf_lagoon",
"author": "OpenVibe",
"tier": 1,
"stages": 3,
ddiff --git a/content/surf/surf_orbit.json b/content/surf/surf_orbit.json
iindex 9adaf52..917d777 100644
---- a/content/surf/surf_orbit.json
++++ b/content/surf/surf_orbit.json
@@@ -2,7 +2,7 @@
"v": 2,
"movement": "surf",
"surf": {
-- "title": "Surf Orbit",
++ "title": "surf_orbit",
"author": "OpenVibe",
"tier": 4,
"stages": 6,
ddiff --git a/packages/content/src/packs/surf.ts b/packages/content/src/packs/surf.ts
iindex 6790f06..cb1c65e 100644
---- a/packages/content/src/packs/surf.ts
++++ b/packages/content/src/packs/surf.ts
@@@ -7,15 +7,17 @@ import type { MovementStyle } from '../schema/world.js'
* A surf map is an ordinary `MapFileV2` document (`content/surf/<id>.json`) with
* the `surf` block, surf zones and `movement: "surf"`. These records are the
* catalogue the client's `?map=` URL and the server's place router share; a
-- * place id is `surf-<slug>` so the existing `/play?game=…` scheme carries it
-- * unchanged. None of the maps, names or layouts are CS maps: they are authored
-- * for this engine and this brief.
++ * place id is the map id (`surf_lagoon`), so `/surf/surf_lagoon` and
++ * `?game=surf_lagoon` name the same place. The old `surf-<slug>` place ids are
++ * accepted only as redirect aliases. None of the maps, names or layouts are CS
++ * maps: they are authored for this engine and this brief.
*/
export interface SurfMapInfo {
/** Stable map id, also the content world id (`surf_lagoon`). */
id: `surf_${'lagoon' | 'canyon' | 'orbit'}`
-- /** Place id in the existing URL scheme (`surf-lagoon`). */
++ /** Place id, the same canonical form as the map id (`surf_lagoon`). */
placeId: string
++ /** Visible name: the map id, as a Source map name is (`surf_lagoon`). */
name: string
/** Difficulty: 1 beginner, 2 intermediate, 4 advanced. */
tier: number
@@@ -31,8 +33,8 @@ export const SURF_AUTHOR = 'OpenVibe' as const
export const SURF_MAPS: readonly SurfMapInfo[] = [
{
id: 'surf_lagoon',
-- placeId: 'surf-lagoon',
-- name: 'Surf Lagoon',
++ placeId: 'surf_lagoon',
++ name: 'surf_lagoon',
tier: 1,
stages: 3,
author: SURF_AUTHOR,
@@@ -40,8 +42,8 @@ export const SURF_MAPS: readonly SurfMapInfo[] = [
},
{
id: 'surf_canyon',
-- placeId: 'surf-canyon',
-- name: 'Surf Canyon',
++ placeId: 'surf_canyon',
++ name: 'surf_canyon',
tier: 2,
stages: 4,
author: SURF_AUTHOR,
@@@ -49,8 +51,8 @@ export const SURF_MAPS: readonly SurfMapInfo[] = [
},
{
id: 'surf_orbit',
-- placeId: 'surf-orbit',
-- name: 'Surf Orbit',
++ placeId: 'surf_orbit',
++ name: 'surf_orbit',
tier: 4,
stages: 6,
author: SURF_AUTHOR,
@@@ -64,9 +66,14 @@ export function isSurfMapId(value: string): value is SurfMapId {
return SURF_MAPS.some((m) => m.id === value)
}
--/** Resolve by map id or place id (`surf_lagoon` / `surf-lagoon`). */
++/**
++ * Resolve a map id, its old hyphenated alias (`surf_lagoon` / `surf-lagoon`), or
++ * the bare game selector `surf` (which has always meant the first map, Lagoon).
++ */
export function surfMap(value: string): SurfMapInfo | undefined {
-- return SURF_MAPS.find((m) => m.id === value || m.placeId === value)
++ if (value === 'surf') return SURF_MAPS[0]
++ const canonical = value.replace(/-/g, '_')
++ return SURF_MAPS.find((m) => m.id === value || m.placeId === value || m.id === canonical)
}
export function surfPlaceId(mapId: SurfMapId): string {
@@@ -78,9 +85,9 @@ export function surfPlaceOf(value: string | undefined): string | null {
return value ? (surfMap(value)?.placeId ?? null) : null
}
--/** The map id a place id maps to, or null. */
++/** The map a place id names, including the old hyphenated alias; null when it is not surf. */
export function surfMapOfPlace(placeId: string): SurfMapInfo | null {
-- return SURF_MAPS.find((m) => m.placeId === placeId) ?? null
++ return SURF_MAPS.find((m) => m.placeId === placeId) ?? surfMap(placeId) ?? null
}
export function isSurfPlace(placeId: string): boolean {
ddiff --git a/packages/persistence/migrations/0019_surf_place_ids.sql b/packages/persistence/migrations/0019_surf_place_ids.sql
nnew file mode 100644
iindex 0000000..c5f50b8
---- /dev/null
++++ b/packages/persistence/migrations/0019_surf_place_ids.sql
@@@ -0,0 +1,145 @@
++-- phase: expand
++-- A surf place is named by its map id (`surf_lagoon`), the Source map-name form,
++-- not the old hyphenated slug (`surf-lagoon`). The old rows move once, in
++-- dependency order. There is at most one instance per place per slot; old and
++-- canonical rows can both exist during a rolling deploy, so the canonical row
++-- wins and the old one is dropped after its positions have moved.
++INSERT INTO places (id, name) VALUES
++ ('surf_lagoon', 'surf_lagoon'),
++ ('surf_canyon', 'surf_canyon'),
++ ('surf_orbit', 'surf_orbit')
++ON CONFLICT (id) DO UPDATE SET name = excluded.name;
++
++-- Keep the live lease of an old instance by copying it to the canonical place
++-- id. The primary key is (place_id, slot); the id is `<place_id>:<slot>`.
++INSERT INTO game_instances
++ (place_id, slot, id, capacity, state, holder, lease_version, heartbeat_at, lease_until)
++SELECT n.id, o.slot, n.id || ':' || o.slot, o.capacity, o.state, o.holder,
++ o.lease_version, o.heartbeat_at, o.lease_until
++FROM game_instances o
++JOIN (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id) ON o.place_id = n.old_id
++ON CONFLICT (place_id, slot) DO NOTHING;
++
++-- A character that already has the canonical position keeps it; drop the
++-- duplicate old row before moving the rest (the primary key is character+instance).
++DELETE FROM character_instance_positions c
++USING game_instances o, (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id), game_instances ni
++WHERE c.instance_id = o.id
++ AND o.place_id = n.old_id
++ AND ni.place_id = n.id
++ AND ni.slot = o.slot
++ AND EXISTS (
++ SELECT 1 FROM character_instance_positions x
++ WHERE x.character_id = c.character_id AND x.instance_id = ni.id
++ );
++
++UPDATE character_instance_positions c
++SET instance_id = ni.id
++FROM game_instances o, (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id), game_instances ni
++WHERE c.instance_id = o.id
++ AND o.place_id = n.old_id
++ AND ni.place_id = n.id
++ AND ni.slot = o.slot;
++
++DELETE FROM game_instances o
++USING (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE o.place_id = n.old_id;
++
++-- The character's last place is a plain label, not a foreign key.
++UPDATE characters SET place_id = n.id
++FROM (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE characters.place_id = n.old_id;
++
++-- Per-place world state follows too. A key that already exists under the
++-- canonical place wins; the old duplicate is dropped.
++UPDATE world_meta m SET place_id = n.id
++FROM (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE m.place_id = n.old_id
++ AND NOT EXISTS (SELECT 1 FROM world_meta x WHERE x.place_id = n.id AND x.key = m.key);
++DELETE FROM world_meta m USING (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE m.place_id = n.old_id;
++
++UPDATE world_entities e SET place_id = n.id
++FROM (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE e.place_id = n.old_id
++ AND NOT EXISTS (SELECT 1 FROM world_entities x WHERE x.place_id = n.id AND x.id = e.id);
++DELETE FROM world_entities e USING (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE e.place_id = n.old_id;
++
++UPDATE world_constraints c SET place_id = n.id
++FROM (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE c.place_id = n.old_id
++ AND NOT EXISTS (SELECT 1 FROM world_constraints x WHERE x.place_id = n.id AND x.id = c.id);
++DELETE FROM world_constraints c USING (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE c.place_id = n.old_id;
++
++UPDATE mod_storage s SET place_id = n.id
++FROM (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE s.place_id = n.old_id
++ AND NOT EXISTS (
++ SELECT 1 FROM mod_storage x
++ WHERE x.place_id = n.id AND x.mod_id = s.mod_id AND x.key = s.key
++ );
++DELETE FROM mod_storage s USING (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE s.place_id = n.old_id;
++
++-- Last: the old place rows themselves. Every reference above has moved.
++DELETE FROM places p
++USING (VALUES
++ ('surf-lagoon', 'surf_lagoon'),
++ ('surf-canyon', 'surf_canyon'),
++ ('surf-orbit', 'surf_orbit')
++) AS n(old_id, id)
++WHERE p.id = n.old_id;
ddiff --git a/packages/persistence/src/pg/surfPlaceMigration.test.ts b/packages/persistence/src/pg/surfPlaceMigration.test.ts
nnew file mode 100644
iindex 0000000..86e4e0e
---- /dev/null
++++ b/packages/persistence/src/pg/surfPlaceMigration.test.ts
@@@ -0,0 +1,110 @@
++import { readFileSync } from 'node:fs'
++import { join } from 'node:path'
++import { describe, expect, it } from 'vitest'
++import { openTestDb } from '../testing.js'
++import { MIGRATIONS_DIR } from '../migrations.js'
++
++/**
++ * The surf place rename (migration 0019) moves the old `surf-lagoon` rows to
++ * `surf_lagoon` without losing a character's saved room position, and keeps an
++ * already-canonical row when both exist during a rolling deploy.
++ */
++const MIGRATION = readFileSync(join(MIGRATIONS_DIR, '0019_surf_place_ids.sql'), 'utf8')
++
++const OLD = 'surf-lagoon'
++const NEW = 'surf_lagoon'
++
++async function seedCharacter(
++ db: Awaited<ReturnType<typeof openTestDb>>,
++ id: string,
++ placeId: string,
++): Promise<void> {
++ await db.query(
++ `INSERT INTO characters
++ (id, account_key, slot, name, place_id, pos_x, pos_y, pos_z, yaw, inventory, updated_at)
++ VALUES ($1, $2, 0, 'Surfer', $3, 1, 2, 3, 0, '[]'::jsonb, 100)`,
++ [id, `usr_${id}`, placeId],
++ )
++}
++
++describe('surf place id migration', () => {
++ it('moves places, instances, positions, characters and world state to the map id', async () => {
++ const db = await openTestDb()
++ await db.query(`INSERT INTO places (id, name) VALUES ($1, 'Surf Lagoon')`, [OLD])
++ await db.query(
++ `INSERT INTO game_instances
++ (place_id, slot, id, capacity, state, holder, lease_version, heartbeat_at, lease_until)
++ VALUES ($1, 0, $2, 8, 'running', 'old-holder', 3, 100, 200)`,
++ [OLD, `${OLD}:0`],
++ )
++ await seedCharacter(db, 'char-old', OLD)
++ await db.query(
++ `INSERT INTO character_instance_positions
++ (character_id, instance_id, pos_x, pos_y, pos_z, yaw, updated_at)
++ VALUES ('char-old', $1, 4, 5, 6, 1, 100)`,
++ [`${OLD}:0`],
++ )
++ await db.query(
++ `INSERT INTO world_meta (place_id, key, value) VALUES ($1, 'world_id', '"x"'::jsonb)`,
++ [OLD],
++ )
++ await db.query(
++ `INSERT INTO mod_storage (place_id, mod_id, key, value) VALUES ($1, 'mod', 'k', '"v"'::jsonb)`,
++ [OLD],
++ )
++
++ await db.query(MIGRATION)
++
++ expect(
++ await db.many(`SELECT id, name FROM places WHERE id IN ($1, $2) ORDER BY id`, [OLD, NEW]),
++ ).toEqual([{ id: NEW, name: NEW }])
++ expect(await db.many(`SELECT id FROM game_instances WHERE place_id = $1`, [NEW])).toEqual([
++ { id: `${NEW}:0` },
++ ])
++ expect(await db.many(`SELECT instance_id FROM character_instance_positions`)).toEqual([
++ { instance_id: `${NEW}:0` },
++ ])
++ expect(await db.many(`SELECT place_id FROM characters WHERE id = 'char-old'`)).toEqual([
++ { place_id: NEW },
++ ])
++ expect(await db.many(`SELECT place_id FROM world_meta`)).toEqual([{ place_id: NEW }])
++ expect(await db.many(`SELECT place_id FROM mod_storage`)).toEqual([{ place_id: NEW }])
++ })
++
++ it('keeps the canonical instance and position when both rows already exist', async () => {
++ const db = await openTestDb()
++ await db.query(`INSERT INTO places (id, name) VALUES ($1, 'old'), ($2, $2)`, [OLD, NEW])
++ for (const [place, holder, lease] of [
++ [OLD, 'old-holder', 3],
++ [NEW, 'new-holder', 7],
++ ] as const) {
++ await db.query(
++ `INSERT INTO game_instances
++ (place_id, slot, id, capacity, state, holder, lease_version, heartbeat_at, lease_until)
++ VALUES ($1, 0, $2, 8, 'running', $3, $4, 100, 200)`,
++ [place, `${place}:0`, holder, lease],
++ )
++ }
++ await seedCharacter(db, 'char-both', OLD)
++ for (const place of [OLD, NEW]) {
++ await db.query(
++ `INSERT INTO character_instance_positions
++ (character_id, instance_id, pos_x, pos_y, pos_z, yaw, updated_at)
++ VALUES ('char-both', $1, 4, 5, 6, 1, 100)`,
++ [`${place}:0`],
++ )
++ }
++
++ await db.query(MIGRATION)
++
++ expect(
++ await db.many(`SELECT id, holder, lease_version FROM game_instances WHERE place_id = $1`, [
++ NEW,
++ ]),
++ ).toEqual([{ id: `${NEW}:0`, holder: 'new-holder', lease_version: 7 }])
++ expect(await db.many(`SELECT instance_id FROM character_instance_positions`)).toEqual([
++ { instance_id: `${NEW}:0` },
++ ])
++ expect(await db.many(`SELECT id FROM places WHERE id = $1`, [OLD])).toEqual([])
++ })
++})
ddiff --git a/test/apps__server__src__net__surfRoutes.test.js b/test/apps__server__src__net__surfRoutes.test.js
nnew file mode 100644
iindex 0000000..5139c84
---- /dev/null
++++ b/test/apps__server__src__net__surfRoutes.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/net/surfRoutes.test.ts'
++// covers: 'apps/server/src/net'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/packages__persistence__src__pg__surfPlaceMigration.test.js b/test/packages__persistence__src__pg__surfPlaceMigration.test.js
nnew file mode 100644
iindex 0000000..6d54150
---- /dev/null
++++ b/test/packages__persistence__src__pg__surfPlaceMigration.test.js
@@@ -0,0 +1,3 @@
++// file: 'packages/persistence/src/pg/surfPlaceMigration.test.ts'
++// covers: 'packages/persistence/src/pg'
++// Generated by node test/run.js --index; do not edit.