openvibers/openvibe.games Commits
Source map names: surf_lagoon everywhere, old forms redirect (#195)
Changes
@@@ -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@@@ -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)@@@ -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 ?? requestedPlacereturn {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:@@@ -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 {@@@ -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()})})@@@ -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: {@@@ -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@@@ -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)@@@ -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': nullnnew file mode 100644@@@ -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' })++ })++})@@@ -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}@@@ -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)@@@ -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 }],})@@@ -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<{@@@ -2,7 +2,7 @@"v": 2,"movement": "surf","surf": {-- "title": "Surf Canyon",++ "title": "surf_canyon","author": "OpenVibe","tier": 2,"stages": 4,@@@ -2,7 +2,7 @@"v": 2,"movement": "surf","surf": {-- "title": "Surf Lagoon",++ "title": "surf_lagoon","author": "OpenVibe","tier": 1,"stages": 3,@@@ -2,7 +2,7 @@"v": 2,"movement": "surf","surf": {-- "title": "Surf Orbit",++ "title": "surf_orbit","author": "OpenVibe","tier": 4,"stages": 6,@@@ -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 constexport 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 {nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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([])++ })++})nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.
@@ -92,13 +92,39 @@ async function start(): Promise<void> {
@@ -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.
// A creator world (/worlds): its own place, the plain creator pack and the world's map.
const requestedWorld = params.get('world')
const requestedWorld = params.get('world')
const worldId = requestedWorld && CREATOR_WORLD_ID.test(requestedWorld) ? requestedWorld : null
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 requestedMap = params.get('map')
+ const surfPath = /^\/surf\/([^/]+)\/?$/.exec(location.pathname)?.[1]
const surfInfo: SurfMapInfo | null =
const surfInfo: SurfMapInfo | null =
+ (surfPath && surfPath !== 'embed' ? surfMap(surfPath) : null) ??
(requestedMap ? surfMap(requestedMap) : null) ??
(requestedMap ? surfMap(requestedMap) : null) ??
- (requestedGame === 'surf' ? surfMap('surf_lagoon') : null) ??
+ (requestedGame ? surfMap(requestedGame) : null) ??
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
const selectedPlace = worldId
? creatorPlaceId(worldId)
? creatorPlaceId(worldId)
: surfInfo
: surfInfo
@@ -60,6 +60,15 @@ describe('loadConfig migrationsDir', () => {
@@ -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', () => {
describe('loadConfig scriptMods', () => {
it('is off unless the place enables it', () => {
it('is off unless the place enables it', () => {
expect(loadConfig({}).scriptMods).toBe(false)
expect(loadConfig({}).scriptMods).toBe(false)
@@ -1,3 +1,4 @@
@@ -1,3 +1,4 @@
+import { surfMap } from '@openvibe/content'
import { MIGRATIONS_DIR } from '@openvibe/persistence'
import { MIGRATIONS_DIR } from '@openvibe/persistence'
import { SIM_TICK_RATE, SURF_SNAPSHOT_EVERY, SURF_TICK_RATE } from '@openvibe/shared'
import { SIM_TICK_RATE, SURF_SNAPSHOT_EVERY, SURF_TICK_RATE } from '@openvibe/shared'
import { parseProxyTrust, type ProxyTrust } from './net/clientAddress.js'
import { parseProxyTrust, type ProxyTrust } from './net/clientAddress.js'
@@ -204,10 +205,14 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
@@ -204,10 +205,14 @@ export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
}
}
export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
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 {
return {
port: intEnv(env, 'PORT', 8000),
port: intEnv(env, 'PORT', 8000),
host: env.HOST ?? '0.0.0.0',
host: env.HOST ?? '0.0.0.0',
- placeId: env.GAMES_PLACE_ID ?? 'scraplandia',
+ placeId,
db: {
db: {
url: env.DATABASE_URL ?? null,
url: env.DATABASE_URL ?? null,
directUrl: env.DATABASE_DIRECT_URL ?? null,
directUrl: env.DATABASE_DIRECT_URL ?? null,
@@ -220,7 +225,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -220,7 +225,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
staticDir: env.STATIC_DIR ?? null,
staticDir: env.STATIC_DIR ?? null,
mapPath:
mapPath:
env.MAP_PATH ??
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),
trustProxy: parseProxyTrust(env.TRUST_PROXY),
/** openvibe.network auth endpoint; when set, editor tokens validate there. */
/** openvibe.network auth endpoint; when set, editor tokens validate there. */
networkAuthUrl:
networkAuthUrl:
@@ -29,6 +29,8 @@ export class PlaceRouter {
@@ -29,6 +29,8 @@ export class PlaceRouter {
private readonly hubs: ReadonlyMap<string, InstanceHub>,
private readonly hubs: ReadonlyMap<string, InstanceHub>,
private readonly defaultPlace: string,
private readonly defaultPlace: string,
private readonly dynamic: DynamicPlaces | null = null,
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`)
if (!hubs.has(defaultPlace)) throw new Error(`default place ${defaultPlace} is not hosted`)
}
}
@@ -49,7 +51,9 @@ export class PlaceRouter {
@@ -49,7 +51,9 @@ export class PlaceRouter {
}
}
resolve(place: string | undefined): string {
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 {
onMessage(conn: GameConnection, msg: ClientMessage): void {
@@ -5,6 +5,7 @@ import {
@@ -5,6 +5,7 @@ import {
compileMapFileV2,
compileMapFileV2,
parseMapFile,
parseMapFile,
surfMap,
surfMap,
+ surfMapOfPlace,
type MapFileV2,
type MapFileV2,
} from '@openvibe/content'
} from '@openvibe/content'
import {
import {
@@ -64,7 +65,7 @@ describe.each(SURF_MAPS)('$name', (info) => {
@@ -64,7 +65,7 @@ describe.each(SURF_MAPS)('$name', (info) => {
const map = load(info.id)
const map = load(info.id)
expect(map.movement).toBe('surf')
expect(map.movement).toBe('surf')
expect(map.surf).toMatchObject({
expect(map.surf).toMatchObject({
- title: info.name,
+ title: info.id,
author: 'OpenVibe',
author: 'OpenVibe',
tier: info.tier,
tier: info.tier,
stages: info.stages,
stages: info.stages,
@@ -152,6 +153,19 @@ describe('surf map catalogue', () => {
@@ -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.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.tier)).toEqual([1, 2, 4])
expect(SURF_MAPS.map((m) => m.stages)).toEqual([3, 4, 6])
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()
})
})
})
})
@@ -56,7 +56,7 @@ function sessionFor(override: ReturnType<typeof loadSurfOverride>) {
@@ -56,7 +56,7 @@ function sessionFor(override: ReturnType<typeof loadSurfOverride>) {
function ctxFor(session: any, respawns: string[], sent: unknown[]) {
function ctxFor(session: any, respawns: string[], sent: unknown[]) {
return {
return {
clock: { tick: 0 },
clock: { tick: 0 },
- config: { ...SURF_ROOM_TIMING, placeId: 'surf-lagoon', instanceSlot: 0 },
+ config: { ...SURF_ROOM_TIMING, placeId: 'surf_lagoon', instanceSlot: 0 },
env: {},
env: {},
regions: { update() {} },
regions: { update() {} },
sessions: {
sessions: {
@@ -239,6 +239,13 @@ async function main(): Promise<void> {
@@ -239,6 +239,13 @@ async function main(): Promise<void> {
createSurfPlay({ url: config.platform.playUrl, tokens: platform?.tokens ?? null }),
createSurfPlay({ url: config.platform.playUrl, tokens: platform?.tokens ?? null }),
(message) => log.warn(message),
(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 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
@@ -753,7 +760,12 @@ async function main(): Promise<void> {
@@ -753,7 +760,12 @@ async function main(): Promise<void> {
maxRunning: config.creatorWorldsMax,
maxRunning: config.creatorWorldsMax,
idleMs: config.creatorWorldIdleSeconds * 1000,
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.
// The idle sweep runs in the admission chain, so a world never stops under a join that is starting it.
const worldSweepTimer = setInterval(() => {
const worldSweepTimer = setInterval(() => {
void router
void router
@@ -186,6 +186,37 @@ describe('real routes on the apex', () => {
@@ -186,6 +186,37 @@ describe('real routes on the apex', () => {
expect(r.headers.location).toBe(location)
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 () => {
it('answers HEAD like GET, without a body', async () => {
const r = await send('/', { method: 'HEAD' })
const r = await send('/', { method: 'HEAD' })
expect(r.status).toBe(200)
expect(r.status).toBe(200)
@@ -62,7 +62,54 @@ export function portalPage(filePath: string, parts: HomeParts | null): string {
@@ -62,7 +62,54 @@ export function portalPage(filePath: string, parts: HomeParts | null): string {
}
}
import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import { casualGameOf, casualPageHtml } from '../casual/page.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
* Minimal HTTP layer: health/metrics endpoints and (in production) the
@@ -751,6 +798,12 @@ export function createHttpServer(
@@ -751,6 +798,12 @@ export function createHttpServer(
notFound()
notFound()
return
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) {
if (!root) {
notFound()
notFound()
return
return
@@ -813,7 +866,7 @@ export function createHttpServer(
@@ -813,7 +866,7 @@ export function createHttpServer(
? 'editor.html'
? 'editor.html'
: url === '/worlds'
: url === '/worlds'
? 'worlds.html'
? 'worlds.html'
- : url === '/surf/embed'
+ : url === '/surf/embed' || surfRoute?.page
? 'play.html'
? 'play.html'
: null
: null
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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' })
+ })
+})
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
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 type { Db } from 'openvibe-sdk/db'
import { isGuestToken } from '../platform/accounts.js'
import { isGuestToken } from '../platform/accounts.js'
import { PgSurfRecords } from '../platform/surfRuns.js'
import { PgSurfRecords } from '../platform/surfRuns.js'
@@ -43,6 +43,12 @@ function noStore(res: ServerResponse, status: number, body: unknown): void {
@@ -43,6 +43,12 @@ function noStore(res: ServerResponse, status: number, body: unknown): void {
send(res, status, body, 'no-store')
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 {
function mapInfo(id: string): SurfMapInfo | undefined {
return SURF_MAPS.find((m) => m.id === id)
return SURF_MAPS.find((m) => m.id === id)
}
}
@@ -90,6 +96,17 @@ export function handleSurfRead(
@@ -90,6 +96,17 @@ export function handleSurfRead(
const info = mapInfo(id)
const info = mapInfo(id)
if (!info) {
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' })
noStore(res, 404, { error: 'unknown_map' })
return true
return true
}
}
@@ -41,7 +41,7 @@ describe('ServerMetrics room gauges', () => {
@@ -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=)", () => {
it("answers one place's gauges, not the other places' (the e2e reads its own place through /metrics?place=)", () => {
const m = new ServerMetrics()
const m = new ServerMetrics()
m.sampleRoom('scraplandia:0', gauges({ mapStatics: 0, entities: 3 }), 1_000)
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)
expect(m.mapStatics).toBe(47)
const one = m.placeSnapshot('scraplandia')
const one = m.placeSnapshot('scraplandia')
expect(one['mapStatics']).toBe(0)
expect(one['mapStatics']).toBe(0)
@@ -105,8 +105,24 @@ describe('surf runs persistence', () => {
@@ -105,8 +105,24 @@ describe('surf runs persistence', () => {
const outcome = await records.record(run({ id: 'run-1', timeMs: 9_876 }))
const outcome = await records.record(run({ id: 'run-1', timeMs: 9_876 }))
expect(outcome.pb).toBe(true)
expect(outcome.pb).toBe(true)
expect(play.defined).toEqual(['surf'])
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 () => {
it('an unknown-kind Play answer leaves the run saved and reports no rank', async () => {
@@ -154,15 +170,15 @@ describe('surf runs persistence', () => {
@@ -154,15 +170,15 @@ describe('surf runs persistence', () => {
expect(calls[0]!.path).toBe('/api/v1/titles/surf')
expect(calls[0]!.path).toBe('/api/v1/titles/surf')
expect(calls[0]!.body.boards).toEqual(
expect(calls[0]!.body.boards).toEqual(
expect.arrayContaining([
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 }))
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]!.path).toBe('/api/v1/titles/surf/scores')
expect(calls[1]!.body).toMatchObject({
expect(calls[1]!.body).toMatchObject({
- board: 'lagoon',
+ board: 'surf_lagoon',
ref: 'run-9',
ref: 'run-9',
entries: [{ score: 4_321 }],
entries: [{ score: 4_321 }],
})
})
@@ -34,16 +34,29 @@ export interface SurfRunRow {
@@ -34,16 +34,29 @@ export interface SurfRunRow {
isPb: boolean
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 {
export interface SurfPlayPoster {
/** Register the surf title/boards; safe to call repeatedly. */
/** Register the surf title/boards; safe to call repeatedly. */
define(): Promise<void>
define(): Promise<void>
/** Post one run's ms. Returns the rank when Play reports one. */
/** 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 {
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 {
export function surfPlayKey(mapId: string): string {
@@ -188,6 +201,59 @@ export class PgSurfRecords implements SurfRecords {
@@ -188,6 +201,59 @@ export class PgSurfRecords implements SurfRecords {
return { pb, wr, rank, pbMs, wrMs }
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. */
/** Every map with its record count and derived world record. */
async maps(): Promise<SurfMapSummary[]> {
async maps(): Promise<SurfMapSummary[]> {
const rows = await this.db.many<{
const rows = await this.db.many<{
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"v": 2,
"v": 2,
"movement": "surf",
"movement": "surf",
"surf": {
"surf": {
- "title": "Surf Canyon",
+ "title": "surf_canyon",
"author": "OpenVibe",
"author": "OpenVibe",
"tier": 2,
"tier": 2,
"stages": 4,
"stages": 4,
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"v": 2,
"v": 2,
"movement": "surf",
"movement": "surf",
"surf": {
"surf": {
- "title": "Surf Lagoon",
+ "title": "surf_lagoon",
"author": "OpenVibe",
"author": "OpenVibe",
"tier": 1,
"tier": 1,
"stages": 3,
"stages": 3,
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"v": 2,
"v": 2,
"movement": "surf",
"movement": "surf",
"surf": {
"surf": {
- "title": "Surf Orbit",
+ "title": "surf_orbit",
"author": "OpenVibe",
"author": "OpenVibe",
"tier": 4,
"tier": 4,
"stages": 6,
"stages": 6,
@@ -7,15 +7,17 @@ import type { MovementStyle } from '../schema/world.js'
@@ -7,15 +7,17 @@ import type { MovementStyle } from '../schema/world.js'
* A surf map is an ordinary `MapFileV2` document (`content/surf/<id>.json`) with
* 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
* 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
* 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 {
export interface SurfMapInfo {
/** Stable map id, also the content world id (`surf_lagoon`). */
/** Stable map id, also the content world id (`surf_lagoon`). */
id: `surf_${'lagoon' | 'canyon' | 'orbit'}`
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
placeId: string
+ /** Visible name: the map id, as a Source map name is (`surf_lagoon`). */
name: string
name: string
/** Difficulty: 1 beginner, 2 intermediate, 4 advanced. */
/** Difficulty: 1 beginner, 2 intermediate, 4 advanced. */
tier: number
tier: number
@@ -31,8 +33,8 @@ export const SURF_AUTHOR = 'OpenVibe' as const
@@ -31,8 +33,8 @@ export const SURF_AUTHOR = 'OpenVibe' as const
export const SURF_MAPS: readonly SurfMapInfo[] = [
export const SURF_MAPS: readonly SurfMapInfo[] = [
{
{
id: 'surf_lagoon',
id: 'surf_lagoon',
- placeId: 'surf-lagoon',
- name: 'Surf Lagoon',
+ placeId: 'surf_lagoon',
+ name: 'surf_lagoon',
tier: 1,
tier: 1,
stages: 3,
stages: 3,
author: SURF_AUTHOR,
author: SURF_AUTHOR,
@@ -40,8 +42,8 @@ export const SURF_MAPS: readonly SurfMapInfo[] = [
@@ -40,8 +42,8 @@ export const SURF_MAPS: readonly SurfMapInfo[] = [
},
},
{
{
id: 'surf_canyon',
id: 'surf_canyon',
- placeId: 'surf-canyon',
- name: 'Surf Canyon',
+ placeId: 'surf_canyon',
+ name: 'surf_canyon',
tier: 2,
tier: 2,
stages: 4,
stages: 4,
author: SURF_AUTHOR,
author: SURF_AUTHOR,
@@ -49,8 +51,8 @@ export const SURF_MAPS: readonly SurfMapInfo[] = [
@@ -49,8 +51,8 @@ export const SURF_MAPS: readonly SurfMapInfo[] = [
},
},
{
{
id: 'surf_orbit',
id: 'surf_orbit',
- placeId: 'surf-orbit',
- name: 'Surf Orbit',
+ placeId: 'surf_orbit',
+ name: 'surf_orbit',
tier: 4,
tier: 4,
stages: 6,
stages: 6,
author: SURF_AUTHOR,
author: SURF_AUTHOR,
@@ -64,9 +66,14 @@ export function isSurfMapId(value: string): value is SurfMapId {
@@ -64,9 +66,14 @@ export function isSurfMapId(value: string): value is SurfMapId {
return SURF_MAPS.some((m) => m.id === value)
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 {
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 {
export function surfPlaceId(mapId: SurfMapId): string {
@@ -78,9 +85,9 @@ export function surfPlaceOf(value: string | undefined): string | null {
@@ -78,9 +85,9 @@ export function surfPlaceOf(value: string | undefined): string | null {
return value ? (surfMap(value)?.placeId ?? null) : 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 {
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 {
export function isSurfPlace(placeId: string): boolean {
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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([])
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.