openvibers/openvibe.games Commits
Apply Prettier formatting to drifted sources, tests and CI workflow
Changes
@@@ -8,10 +8,10 @@ jobs:test:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@66b76f868ea8228aad7811309eafef3726b036f3with:-- package-manager: pnpm # version from package.json "packageManager"++ package-manager: pnpm # version from package.json "packageManager"install: pnpm install --frozen-lockfiletest: pnpm typecheck && pnpm lint && pnpm test-- syntax-dirs: '' # TypeScript: typecheck covers it++ syntax-dirs: '' # TypeScript: typecheck covers itextra: cd apps/server && npx openvibe-contracts-check --service games --src src# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo.security:@@@ -4,7 +4,12 @@ import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'import type { Scene } from '@babylonjs/core/scene.js'import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'import { ZoneIndex, type ZoneRules } from '@openvibe/gameplay'--import { getMapOverride, terrainHeight, type ContentRegistry, type WorldShape } from '@openvibe/content'++import {++ getMapOverride,++ terrainHeight,++ type ContentRegistry,++ type WorldShape,++} from '@openvibe/content'import { v3addScaled, vec3 } from '@openvibe/shared'import { meshForShape } from '../render/sceneSetup.js'@@@ -431,7 +431,15 @@ async function start(): Promise<void> {hud.setStatus('connecting…')try {-- await connection.connect(gameSocketUrl(), identity.token, name, appearance, content.digest, slot, sso)++ await connection.connect(++ gameSocketUrl(),++ identity.token,++ name,++ appearance,++ content.digest,++ slot,++ sso,++ )} catch {// Refused or timed out (a page opened during a restart): wait for the server, then reload.hud.setStatus('could not reach the server — retrying automatically…')@@@ -14,14 +14,18 @@ describe('client content handshake', () => {it('sends the local definition digest and protocol version in hello', () => {expect(helloForContent('Player', defaultAppearance(), 0, digest)).toMatchObject({-- t: 'hello', v: PROTOCOL_VERSION, contentDigest: digest,++ t: 'hello',++ v: PROTOCOL_VERSION,++ contentDigest: digest,})})it('turns a mismatched welcome into a typed reject before state applies', () => {expect(welcomeContentMismatch({ ...welcome, contentDigest: 'v2-00000000' }, digest)).toEqual({-- t: 'reject', reason: 'content_mismatch',-- clientDigest: digest, serverDigest: 'v2-00000000',++ t: 'reject',++ reason: 'content_mismatch',++ clientDigest: digest,++ serverDigest: 'v2-00000000',})})})@@@ -19,6 +19,8 @@ describe('page icon', () => {it('public/favicon.svg exists and is an SVG', () => {expect(existsSync(client('public/favicon.svg'))).toBe(true)-- expect(readFileSync(client('public/favicon.svg'), 'utf8')).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)++ expect(readFileSync(client('public/favicon.svg'), 'utf8')).toMatch(++ /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/,++ )})})@@@ -77,7 +77,7 @@ describe('architecture audit: the server is Babylon-free (ADR-0007)', () => {})it('imports no @babylonjs/* anywhere in the server', () => {-- expect(report(activeHits(/@babylonjs\//))) .toBe('')++ expect(report(activeHits(/@babylonjs\//))).toBe('')})it('drives Rapier (not Havok) and has no Havok loader', () => {@@@ -25,7 +25,15 @@ import {} from '@openvibe/gameplay'import type { ItemDef } from '@openvibe/content'import { CollisionLayer } from '@openvibe/physics'--import { asEntityId, qfromYaw, qrotateVecInv, quat, v3dist, vec3, type Vec3 } from '@openvibe/shared'++import {++ asEntityId,++ qfromYaw,++ qrotateVecInv,++ quat,++ v3dist,++ vec3,++ type Vec3,++} from '@openvibe/shared'import { viewDirection } from './playerSession.js'import type { ConstraintRecord, GameWorld } from './gameWorld.js'import { eyePosition, type PlayerSession } from './playerSession.js'@@@ -211,13 +211,19 @@ describe('Games on platform identity, events and mods (real server, restart)', (const stale = connect(a.game)stale.conn.identity = { guestKeyHash: hashGuestKey(`stale-${offered ?? 'missing'}`) }a.game.onMessage(stale.conn, {-- t: 'hello', v: PROTOCOL_VERSION, slot: 0, name: 'Stale',-- appearance: defaultAppearance(), ...(offered ? { contentDigest: offered } : {}),++ t: 'hello',++ v: PROTOCOL_VERSION,++ slot: 0,++ name: 'Stale',++ appearance: defaultAppearance(),++ ...(offered ? { contentDigest: offered } : {}),})await new Promise((r) => setTimeout(r, 0))expect(stale.inbox).toContainEqual({-- t: 'reject', reason: 'content_mismatch',-- clientDigest: offered ?? null, serverDigest: a.world.content.digest,++ t: 'reject',++ reason: 'content_mismatch',++ clientDigest: offered ?? null,++ serverDigest: a.world.content.digest,})expect(stale.inbox.some((m) => m.t === 'welcome')).toBe(false)}@@@ -226,7 +232,11 @@ describe('Games on platform identity, events and mods (real server, restart)', (const oldBundle = connect(a.game)oldBundle.conn.identity = { guestKeyHash: hashGuestKey('old-bundle') }a.game.onMessage(oldBundle.conn, {-- t: 'hello', v: PROTOCOL_VERSION - 1, slot: 0, name: 'Old', appearance: defaultAppearance(),++ t: 'hello',++ v: PROTOCOL_VERSION - 1,++ slot: 0,++ name: 'Old',++ appearance: defaultAppearance(),})await new Promise((r) => setTimeout(r, 0))expect(oldBundle.inbox).toContainEqual({ t: 'reject', reason: 'protocol_mismatch' })@@@ -94,10 +94,14 @@ export class SessionsSystem implements System {return}if (msg.contentDigest !== serverDigest) {-- conn.send(encodeServerMessage({-- t: 'reject', reason: 'content_mismatch',-- clientDigest: msg.contentDigest ?? null, serverDigest,-- }))++ conn.send(++ encodeServerMessage({++ t: 'reject',++ reason: 'content_mismatch',++ clientDigest: msg.contentDigest ?? null,++ serverDigest,++ }),++ )conn.close(4013, 'content_mismatch')return}@@@ -46,7 +46,9 @@ describe('games-content@2 definitions', () => {expect(capabilitiesUsedBy(mapPack)).toEqual([CAP_DEFINE])const result = validateContentPack(mapPack, createContent())expect(result.ok).toBe(false)-- expect(!result.ok && result.errors).toContain('map is defined by the built-in pack and cannot be replaced by a mod')++ expect(!result.ok && result.errors).toContain(++ 'map is defined by the built-in pack and cannot be replaced by a mod',++ )expect(validateContentPack({ map: { id: 'broken' } }, createContent()).ok).toBe(false)})it('needs games.def.define for any non-empty defs section', () => {@@@ -121,7 +121,8 @@ const validateSchema = ajv.compile(CONTENT_PACK_SCHEMA)/** Which capability each pack section needs. */export function capabilitiesUsedBy(pack: ContentPack): string[] {const used: string[] = []-- if (pack.map || Object.values(pack.defs ?? {}).some((list) => (list ?? []).length > 0)) used.push(CAP_DEFINE)++ if (pack.map || Object.values(pack.defs ?? {}).some((list) => (list ?? []).length > 0))++ used.push(CAP_DEFINE)if ((pack.announcements ?? []).length > 0) used.push(CAP_ANNOUNCE)if ((pack.props ?? []).length > 0) used.push(CAP_PLACE_PROP)return used@@@ -195,7 +196,8 @@ export async function loadDefinitionPacks(for (const mod of await mods.list()) {if (mod.status !== 'enabled') continueconst pack = mod.pack as unknown as ContentPackV2-- if (!pack.map && Object.values(pack.defs ?? {}).every((list) => (list ?? []).length === 0)) continue++ if (!pack.map && Object.values(pack.defs ?? {}).every((list) => (list ?? []).length === 0))++ continueconst granted = (await mods.grants(mod.id)).some((g) => g.capability === CAP_DEFINE && g.revokedAt === null,)@@@ -12,7 +12,8 @@ import { canEditMap, rankOf, resolveNetworkUser } from './networkAuth.js'*/const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString('base64url')--const token = (claims: Record<string, unknown>): string => `${b64({ alg: 'RS256' })}.${b64(claims)}.sig`++const token = (claims: Record<string, unknown>): string =>++ `${b64({ alg: 'RS256' })}.${b64(claims)}.sig`let server: Serverlet url: string@@@ -35,18 +36,38 @@ describe('rank from the staff map', () => {expect(rankOf({ role: 'admin' })).toBe('admin')expect(rankOf({ role: 'admin', is_owner: true })).toBe('owner')expect(rankOf({ role: 'user', is_owner: true })).toBe(null)-- expect(rankOf({ role: 'global_mod', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' })).toBe('admin')-- expect(rankOf({ role: 'admin', staff_caps: ['staff.moderation.chat'], staff_map: '1.1.0' })).toBe('moderator')-- expect(canEditMap('owner') && canEditMap('admin') && !canEditMap('moderator') && !canEditMap(null)).toBe(true)++ expect(++ rankOf({ role: 'global_mod', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' }),++ ).toBe('admin')++ expect(++ rankOf({ role: 'admin', staff_caps: ['staff.moderation.chat'], staff_map: '1.1.0' }),++ ).toBe('moderator')++ expect(++ canEditMap('owner') && canEditMap('admin') && !canEditMap('moderator') && !canEditMap(null),++ ).toBe(true)})it('reads the accepted token and prefers the account role after a change', async () => {-- account = { id: 7, username: 'goosely', role: 'admin', subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' }-- expect((await resolveNetworkUser(url, token({ role: 'admin', is_owner: true })))?.rank).toBe('owner')++ account = {++ id: 7,++ username: 'goosely',++ role: 'admin',++ subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3',++ }++ expect((await resolveNetworkUser(url, token({ role: 'admin', is_owner: true })))?.rank).toBe(++ 'owner',++ )// a username is not the owner claimexpect((await resolveNetworkUser(url, token({ role: 'admin' })))?.rank).toBe('admin')account = { id: 8, username: 'demoted', role: 'user' }-- expect((await resolveNetworkUser(url, token({ role: 'admin', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' })))?.rank).toBe(null)++ expect(++ (++ await resolveNetworkUser(++ url,++ token({ role: 'admin', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' }),++ )++ )?.rank,++ ).toBe(null)account = { id: 9, username: 'promoted', role: 'admin' }expect((await resolveNetworkUser(url, token({ role: 'user' })))?.rank).toBe('admin')})@@@ -57,11 +78,17 @@ describe('rank from the staff map', () => {const walk = (dir: string): void => {for (const name of readdirSync(dir)) {const f = join(dir, name)-- if (statSync(f).isDirectory()) { walk(f); continue }++ if (statSync(f).isDirectory()) {++ walk(f)++ continue++ }if (!/\.ts$/.test(name) || name.endsWith('.test.ts')) continue-- readFileSync(f, 'utf8').split('\n').forEach((line, i) => {-- if (/OWNER_USERNAME|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) offenders.push(`${relative(root, f)}:${i + 1}`)-- })++ readFileSync(f, 'utf8')++ .split('\n')++ .forEach((line, i) => {++ if (/OWNER_USERNAME|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line))++ offenders.push(`${relative(root, f)}:${i + 1}`)++ })}}walk(root)@@@ -41,7 +41,12 @@ describe('/shared (D42)', () => {})it('serves only the browser files openvibe-shared lists', async () => {-- for (const p of ['/shared/serve.js', '/shared/../package.json', '/shared/', '/other/navbar.js']) {++ for (const p of [++ '/shared/serve.js',++ '/shared/../package.json',++ '/shared/',++ '/other/navbar.js',++ ]) {expect((await fetch(base + p)).status).toBe(404)}expect((await fetch(`${base}/shared/navbar.js`, { method: 'POST' })).status).toBe(404)@@@ -3,7 +3,14 @@ import { tmpdir } from 'node:os'import { join } from 'node:path'import type { IncomingMessage, ServerResponse } from 'node:http'import { describe, expect, it } from 'vitest'--import { buildRelease, gitCommit, isDirectLoopback, prometheusText, releaseHandler, wantsPrometheus } from './release.js'++import {++ buildRelease,++ gitCommit,++ isDirectLoopback,++ prometheusText,++ releaseHandler,++ wantsPrometheus,++} from './release.js'const SHA = 'a'.repeat(40)function checkout(kind: 'loose' | 'packed' | 'detached'): string {@@@ -13,7 +20,8 @@ function checkout(kind: 'loose' | 'packed' | 'detached'): string {if (kind === 'detached') writeFileSync(join(root, '.git', 'HEAD'), `${SHA}\n`)else writeFileSync(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n')if (kind === 'loose') writeFileSync(join(root, '.git', 'refs', 'heads', 'main'), `${SHA}\n`)-- if (kind === 'packed') writeFileSync(join(root, '.git', 'packed-refs'), `# pack-refs\n${SHA} refs/heads/main\n`)++ if (kind === 'packed')++ writeFileSync(join(root, '.git', 'packed-refs'), `# pack-refs\n${SHA} refs/heads/main\n`)return root}@@@ -28,18 +36,32 @@ describe('release manifest (D43)', () => {})it('builds the shared manifest shape; RELEASE_SHA wins', () => {-- const m = buildRelease(checkout('loose'), { env: {}, now: () => new Date('2026-09-24T00:00:00Z') })++ const m = buildRelease(checkout('loose'), {++ env: {},++ now: () => new Date('2026-09-24T00:00:00Z'),++ })expect(m.service).toBe('games')expect(m.release).toBe(SHA.slice(0, 12))expect(m.components.server).toEqual({ kind: 'server', version: SHA.slice(0, 12) })expect(m.booted_at).toBe('2026-09-24T00:00:00.000Z')-- expect(buildRelease('/nowhere', { env: { RELEASE_SHA: 'b'.repeat(40) } }).release).toBe('b'.repeat(12))++ expect(buildRelease('/nowhere', { env: { RELEASE_SHA: 'b'.repeat(40) } }).release).toBe(++ 'b'.repeat(12),++ )})it('answers GET /release.json only', () => {-- const handle = releaseHandler(buildRelease('/nowhere', { env: { RELEASE_SHA: 'c'.repeat(40) } }))++ const handle = releaseHandler(++ buildRelease('/nowhere', { env: { RELEASE_SHA: 'c'.repeat(40) } }),++ )const out: { status?: number; body?: string | undefined } = {}-- const res = { writeHead: (s: number) => { out.status = s }, end: (b?: string) => { out.body = b } } as unknown as ServerResponse++ const res = {++ writeHead: (s: number) => {++ out.status = s++ },++ end: (b?: string) => {++ out.body = b++ },++ } as unknown as ServerResponseexpect(handle({ url: '/api/x', method: 'GET' } as IncomingMessage, res)).toBe(false)expect(handle({ url: '/release.json?x=1', method: 'GET' } as IncomingMessage, res)).toBe(true)expect(out.status).toBe(200)@@@ -47,7 +69,8 @@ describe('release manifest (D43)', () => {})it('metrics are for direct loopback callers only', () => {-- const req = (addr: string, headers: Record<string, string> = {}) => ({ socket: { remoteAddress: addr }, headers }) as unknown as IncomingMessage++ const req = (addr: string, headers: Record<string, string> = {}) =>++ ({ socket: { remoteAddress: addr }, headers }) as unknown as IncomingMessageexpect(isDirectLoopback(req('127.0.0.1'))).toBe(true)expect(isDirectLoopback(req('::1'))).toBe(true)expect(isDirectLoopback(req('127.0.0.1', { 'x-forwarded-for': '1.2.3.4' }))).toBe(false)@@@ -56,9 +79,19 @@ describe('release manifest (D43)', () => {})it('serves the metrics snapshot as Prometheus text when a scraper asks', () => {-- const text = prometheusText({ tick: 42, tickDurationMs: 0.5, sessions: 3, name: 'x', nested: { a: 1 }, bad: Number.NaN })-- expect(text).toBe('# TYPE games_tick gauge\ngames_tick 42\n# TYPE games_tick_duration_ms gauge\ngames_tick_duration_ms 0.5\n# TYPE games_sessions gauge\ngames_sessions 3\n')-- const req = (accept: string, url = '/metrics') => ({ headers: { accept }, url }) as unknown as IncomingMessage++ const text = prometheusText({++ tick: 42,++ tickDurationMs: 0.5,++ sessions: 3,++ name: 'x',++ nested: { a: 1 },++ bad: Number.NaN,++ })++ expect(text).toBe(++ '# TYPE games_tick gauge\ngames_tick 42\n# TYPE games_tick_duration_ms gauge\ngames_tick_duration_ms 0.5\n# TYPE games_sessions gauge\ngames_sessions 3\n',++ )++ const req = (accept: string, url = '/metrics') =>++ ({ headers: { accept }, url }) as unknown as IncomingMessageexpect(wantsPrometheus(req('text/plain;version=0.0.4;q=0.5,*/*;q=0.1'))).toBe(true)expect(wantsPrometheus(req('application/openmetrics-text;version=1.0.0'))).toBe(true)expect(wantsPrometheus(req('', '/metrics?format=prometheus'))).toBe(true)@@@ -61,7 +61,10 @@ export function buildRelease(root: string,{ env = process.env, now = () => new Date() }: { env?: NodeJS.ProcessEnv; now?: () => Date } = {},): ReleaseManifest {-- const sha = (env.RELEASE_SHA && /^[0-9a-f]{7,40}$/.test(env.RELEASE_SHA) ? env.RELEASE_SHA : gitCommit(root)) ?? 'unknown'++ const sha =++ (env.RELEASE_SHA && /^[0-9a-f]{7,40}$/.test(env.RELEASE_SHA)++ ? env.RELEASE_SHA++ : gitCommit(root)) ?? 'unknown'const release = sha.slice(0, 12)const req = createRequire(import.meta.url)const packages: Record<string, string> = {}@@@ -78,7 +81,10 @@ export function buildRelease(packages,min_client_release: env.MIN_CLIENT_RELEASE ?? null,mixed_version_window_hours: 24,-- components: { client: { kind: 'script', version: release }, server: { kind: 'server', version: release } },++ components: {++ client: { kind: 'script', version: release },++ server: { kind: 'server', version: release },++ },schema_generation: null,schema_compatible_from: null,contract_ranges: {},@@@ -89,8 +95,16 @@ export function buildRelease(export function releaseHandler(manifest: ReleaseManifest) {const body = JSON.stringify(manifest)return (req: IncomingMessage, res: ServerResponse): boolean => {-- if ((req.url ?? '').split('?')[0] !== '/release.json' || (req.method !== 'GET' && req.method !== 'HEAD')) return false-- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-cache', 'access-control-allow-origin': '*' })++ if (++ (req.url ?? '').split('?')[0] !== '/release.json' ||++ (req.method !== 'GET' && req.method !== 'HEAD')++ )++ return false++ res.writeHead(200, {++ 'content-type': 'application/json',++ 'cache-control': 'no-cache',++ 'access-control-allow-origin': '*',++ })res.end(req.method === 'HEAD' ? undefined : body)return true}@@@ -112,7 +126,10 @@ export function prometheusText(snapshot: Record<string, unknown>): string {const lines: string[] = []for (const [k, v] of Object.entries(snapshot)) {if (typeof v !== 'number' || !Number.isFinite(v)) continue-- const name = `games_${k.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9_]/g, '_').toLowerCase()}`++ const name = `games_${k++ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')++ .replace(/[^A-Za-z0-9_]/g, '_')++ .toLowerCase()}`lines.push(`# TYPE ${name} gauge`, `${name} ${v}`)}return `${lines.join('\n')}\n`@@@ -53,7 +53,18 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {factions: FACTIONS,markets: MARKETS,jobs: JOBS,-- world: { id: 'base', name: 'Base', groundHalfExtent: 80, spawnPoint: [0, 1.2, 4], spawnYaw: 0, flatTerrain: true, statics: [], resourceNodes: [], initialProps: [], zones: [] },++ world: {++ id: 'base',++ name: 'Base',++ groundHalfExtent: 80,++ spawnPoint: [0, 1.2, 4],++ spawnYaw: 0,++ flatTerrain: true,++ statics: [],++ resourceNodes: [],++ initialProps: [],++ zones: [],++ },},[SCRAPLANDIA_PACK, ...packs],)@@@ -188,7 +188,10 @@ describe('contentDigest', () => {})it('changes when the pack map changes', () => {-- const changed = { ...base, world: { ...base.world, spawnPoint: [1, 1.2, 4] as [number, number, number] } }++ const changed = {++ ...base,++ world: { ...base.world, spawnPoint: [1, 1.2, 4] as [number, number, number] },++ }expect(contentDigest(changed)).not.toBe(contentDigest(base))})})@@@ -198,11 +201,24 @@ describe('Scraplandia default pack', () => {// Recorded from main 545f6c1, packages/content/src/defs/scrapcity.ts.// The released city is an editor-built blank slate: no seeded entity ids.const expected = {-- id: 'openvibeville_v2', name: 'Scrap City', flatTerrain: true,-- groundHalfExtent: 80, spawnPoint: [0, 1.2, 4], spawnYaw: 0,-- statics: [], resourceNodes: [], initialProps: [],-- zones: [{ id: 'city', name: 'Scrap City', min: [-20.5, -1, -20.5],-- max: [20.5, 8, 20.5], rules: { pvp: false, build: false, physgun: true } }],++ id: 'openvibeville_v2',++ name: 'Scrap City',++ flatTerrain: true,++ groundHalfExtent: 80,++ spawnPoint: [0, 1.2, 4],++ spawnYaw: 0,++ statics: [],++ resourceNodes: [],++ initialProps: [],++ zones: [++ {++ id: 'city',++ name: 'Scrap City',++ min: [-20.5, -1, -20.5],++ max: [20.5, 8, 20.5],++ rules: { pvp: false, build: false, physgun: true },++ },++ ],}expect(SCRAPLANDIA_PACK.map).toEqual(expected)expect(createContent().world).toEqual(expected)@@@ -565,7 +565,8 @@ const corpusTerrain = (): BodyDesc => ({const corpusIsland = (): BodyDesc[] => {const out: BodyDesc[] = []for (let x = -1; x <= 1; x++)-- for (let z = -1; z <= 1; z++) out.push(corpusBox([0.8, 0.8, 0.8], vec3(x * 0.9, 0.4, 6 + z * 0.9)))++ for (let z = -1; z <= 1; z++)++ out.push(corpusBox([0.8, 0.8, 0.8], vec3(x * 0.9, 0.4, 6 + z * 0.9)))return out}@@@ -591,7 +592,12 @@ interface CorpusScene {}const CORPUS: CorpusScene[] = [-- { name: 'flat ground', bodies: [corpusFloor()], script: corpusTicks(90, corpusWalk), mockExpressible: true },++ {++ name: 'flat ground',++ bodies: [corpusFloor()],++ script: corpusTicks(90, corpusWalk),++ mockExpressible: true,++ },{name: 'stairs',bodies: [corpusFloor(), ...corpusStairs()],@@@ -1356,30 +1356,27 @@ function conformance(a: Adapter): void {close()})-- it.skipIf(maybe('step advances'))(-- 'step advances exactly the dt it is given',-- () => {-- const probe = (dt: number, ticks: number) => {-- const w = open()-- const box = w.addBody(dynamicBoxDesc(vec3(0, 900, 0), 0.5, 10))-- stepFor(w, ticks, dt)-- const v = pos()-- w.getLinearVelocity(box, v)-- close()-- return v.y-- }-- // Rapier honours the dt it is given: 30 steps of 1/30 s and 60 steps-- // of 1/60 s are the same simulated second (the damping is applied per-- // step, so they agree to within a few mm/s, not bit-exactly).-- expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 60, 60), 1)-- // An extra simulated second is very nearly twice the velocity.-- expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeGreaterThan(1.9)-- expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeLessThan(2.1)-- // One simulated second of this gravity: about -16.5 m/s.-- expect(probe(1 / 30, 30)).toBeLessThan(-15)-- expect(probe(1 / 30, 30)).toBeGreaterThan(-17)-- },-- )++ it.skipIf(maybe('step advances'))('step advances exactly the dt it is given', () => {++ const probe = (dt: number, ticks: number) => {++ const w = open()++ const box = w.addBody(dynamicBoxDesc(vec3(0, 900, 0), 0.5, 10))++ stepFor(w, ticks, dt)++ const v = pos()++ w.getLinearVelocity(box, v)++ close()++ return v.y++ }++ // Rapier honours the dt it is given: 30 steps of 1/30 s and 60 steps++ // of 1/60 s are the same simulated second (the damping is applied per++ // step, so they agree to within a few mm/s, not bit-exactly).++ expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 60, 60), 1)++ // An extra simulated second is very nearly twice the velocity.++ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeGreaterThan(1.9)++ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeLessThan(2.1)++ // One simulated second of this gravity: about -16.5 m/s.++ expect(probe(1 / 30, 30)).toBeLessThan(-15)++ expect(probe(1 / 30, 30)).toBeGreaterThan(-17)++ })it('a resting dynamic body on the ground settles', () => {const w = open()@@@ -9,7 +9,14 @@ describe('protocol codec', () => {const messages: ClientMessage[] = [// hello carries no identity field: authentication happens at the// WebSocket upgrade (ADR-0007 decision 8).-- { t: 'hello', v: PROTOCOL_VERSION, contentDigest: 'v2-1234abcd', slot: 0, name: 'Tester', appearance: defaultAppearance() },++ {++ t: 'hello',++ v: PROTOCOL_VERSION,++ contentDigest: 'v2-1234abcd',++ slot: 0,++ name: 'Tester',++ appearance: defaultAppearance(),++ },{ t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },{ t: 'use', target: 'abc123' },{ t: 'craft', recipe: 'craft_wooden_crate' },@@@ -44,16 +51,29 @@ describe('protocol codec', () => {})it('decodes an older hello without a digest for a typed join rejection', () => {-- const old = decodeClientMessage(JSON.stringify({-- t: 'hello', v: PROTOCOL_VERSION - 1, slot: 0, name: 'Old',-- appearance: defaultAppearance(),-- }))++ const old = decodeClientMessage(++ JSON.stringify({++ t: 'hello',++ v: PROTOCOL_VERSION - 1,++ slot: 0,++ name: 'Old',++ appearance: defaultAppearance(),++ }),++ )expect(old).toMatchObject({ t: 'hello' })expect(old).not.toHaveProperty('contentDigest')-- expect(decodeClientMessage(JSON.stringify({-- t: 'hello', v: PROTOCOL_VERSION, contentDigest: 'garbage',-- slot: 0, name: 'Bad', appearance: defaultAppearance(),-- }))).toBeNull()++ expect(++ decodeClientMessage(++ JSON.stringify({++ t: 'hello',++ v: PROTOCOL_VERSION,++ contentDigest: 'garbage',++ slot: 0,++ name: 'Bad',++ appearance: defaultAppearance(),++ }),++ ),++ ).toBeNull()})it('refuses unknown intent bits and out-of-range quantised angles', () => {@@@ -12,7 +12,10 @@ export const ClientHelloSchema = z.object({t: z.literal('hello'),v: z.number().int(),/** Digest of the client's loaded content definitions. Omitted by old clients. */-- contentDigest: z.string().regex(/^v[0-9]+-[0-9a-f]{8}$/).optional(),++ contentDigest: z++ .string()++ .regex(/^v[0-9]+-[0-9a-f]{8}$/)++ .optional(),/** Character slot under this account (MMO-style, 3 max). */slot: z.number().int().min(0).max(2).default(0),name: z.string().min(1).max(24),@@@ -193,6 +193,8 @@ for (const f of chosen) {}for (const tail of tails) console.log(`\n${tail}`)if (!bad && r.status !== 0) {-- console.log(`\n── vitest exited ${r.status ?? r.signal} although every file passed ──\n${vitestOut.split('\n').slice(-30).join('\n')}`)++ console.log(++ `\n── vitest exited ${r.status ?? r.signal} although every file passed ──\n${vitestOut.split('\n').slice(-30).join('\n')}`,++ )}process.exit(bad || r.status !== 0 ? 1 : 0)
@@ -8,10 +8,10 @@ jobs:
@@ -8,10 +8,10 @@ jobs:
test:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@66b76f868ea8228aad7811309eafef3726b036f3
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@66b76f868ea8228aad7811309eafef3726b036f3
with:
with:
- package-manager: pnpm # version from package.json "packageManager"
+ package-manager: pnpm # version from package.json "packageManager"
install: pnpm install --frozen-lockfile
install: pnpm install --frozen-lockfile
test: pnpm typecheck && pnpm lint && pnpm test
test: pnpm typecheck && pnpm lint && pnpm test
- syntax-dirs: '' # TypeScript: typecheck covers it
+ syntax-dirs: '' # TypeScript: typecheck covers it
extra: cd apps/server && npx openvibe-contracts-check --service games --src src
extra: cd apps/server && npx openvibe-contracts-check --service games --src src
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo.
# Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo.
security:
security:
@@ -4,7 +4,12 @@ import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
@@ -4,7 +4,12 @@ import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Scene } from '@babylonjs/core/scene.js'
import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
import { CollisionLayer, type PhysicsWorld } from '@openvibe/physics'
import { ZoneIndex, type ZoneRules } from '@openvibe/gameplay'
import { ZoneIndex, type ZoneRules } from '@openvibe/gameplay'
-import { getMapOverride, terrainHeight, type ContentRegistry, type WorldShape } from '@openvibe/content'
+import {
+ getMapOverride,
+ terrainHeight,
+ type ContentRegistry,
+ type WorldShape,
+} from '@openvibe/content'
import { v3addScaled, vec3 } from '@openvibe/shared'
import { v3addScaled, vec3 } from '@openvibe/shared'
import { meshForShape } from '../render/sceneSetup.js'
import { meshForShape } from '../render/sceneSetup.js'
@@ -431,7 +431,15 @@ async function start(): Promise<void> {
@@ -431,7 +431,15 @@ async function start(): Promise<void> {
hud.setStatus('connecting…')
hud.setStatus('connecting…')
try {
try {
- await connection.connect(gameSocketUrl(), identity.token, name, appearance, content.digest, slot, sso)
+ await connection.connect(
+ gameSocketUrl(),
+ identity.token,
+ name,
+ appearance,
+ content.digest,
+ slot,
+ sso,
+ )
} catch {
} catch {
// Refused or timed out (a page opened during a restart): wait for the server, then reload.
// Refused or timed out (a page opened during a restart): wait for the server, then reload.
hud.setStatus('could not reach the server — retrying automatically…')
hud.setStatus('could not reach the server — retrying automatically…')
@@ -14,14 +14,18 @@ describe('client content handshake', () => {
@@ -14,14 +14,18 @@ describe('client content handshake', () => {
it('sends the local definition digest and protocol version in hello', () => {
it('sends the local definition digest and protocol version in hello', () => {
expect(helloForContent('Player', defaultAppearance(), 0, digest)).toMatchObject({
expect(helloForContent('Player', defaultAppearance(), 0, digest)).toMatchObject({
- t: 'hello', v: PROTOCOL_VERSION, contentDigest: digest,
+ t: 'hello',
+ v: PROTOCOL_VERSION,
+ contentDigest: digest,
})
})
})
})
it('turns a mismatched welcome into a typed reject before state applies', () => {
it('turns a mismatched welcome into a typed reject before state applies', () => {
expect(welcomeContentMismatch({ ...welcome, contentDigest: 'v2-00000000' }, digest)).toEqual({
expect(welcomeContentMismatch({ ...welcome, contentDigest: 'v2-00000000' }, digest)).toEqual({
- t: 'reject', reason: 'content_mismatch',
- clientDigest: digest, serverDigest: 'v2-00000000',
+ t: 'reject',
+ reason: 'content_mismatch',
+ clientDigest: digest,
+ serverDigest: 'v2-00000000',
})
})
})
})
})
})
@@ -19,6 +19,8 @@ describe('page icon', () => {
@@ -19,6 +19,8 @@ describe('page icon', () => {
it('public/favicon.svg exists and is an SVG', () => {
it('public/favicon.svg exists and is an SVG', () => {
expect(existsSync(client('public/favicon.svg'))).toBe(true)
expect(existsSync(client('public/favicon.svg'))).toBe(true)
- expect(readFileSync(client('public/favicon.svg'), 'utf8')).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)
+ expect(readFileSync(client('public/favicon.svg'), 'utf8')).toMatch(
+ /^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/,
+ )
})
})
})
})
@@ -77,7 +77,7 @@ describe('architecture audit: the server is Babylon-free (ADR-0007)', () => {
@@ -77,7 +77,7 @@ describe('architecture audit: the server is Babylon-free (ADR-0007)', () => {
})
})
it('imports no @babylonjs/* anywhere in the server', () => {
it('imports no @babylonjs/* anywhere in the server', () => {
- expect(report(activeHits(/@babylonjs\//))) .toBe('')
+ expect(report(activeHits(/@babylonjs\//))).toBe('')
})
})
it('drives Rapier (not Havok) and has no Havok loader', () => {
it('drives Rapier (not Havok) and has no Havok loader', () => {
@@ -25,7 +25,15 @@ import {
@@ -25,7 +25,15 @@ import {
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
import type { ItemDef } from '@openvibe/content'
import type { ItemDef } from '@openvibe/content'
import { CollisionLayer } from '@openvibe/physics'
import { CollisionLayer } from '@openvibe/physics'
-import { asEntityId, qfromYaw, qrotateVecInv, quat, v3dist, vec3, type Vec3 } from '@openvibe/shared'
+import {
+ asEntityId,
+ qfromYaw,
+ qrotateVecInv,
+ quat,
+ v3dist,
+ vec3,
+ type Vec3,
+} from '@openvibe/shared'
import { viewDirection } from './playerSession.js'
import { viewDirection } from './playerSession.js'
import type { ConstraintRecord, GameWorld } from './gameWorld.js'
import type { ConstraintRecord, GameWorld } from './gameWorld.js'
import { eyePosition, type PlayerSession } from './playerSession.js'
import { eyePosition, type PlayerSession } from './playerSession.js'
@@ -211,13 +211,19 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -211,13 +211,19 @@ describe('Games on platform identity, events and mods (real server, restart)', (
const stale = connect(a.game)
const stale = connect(a.game)
stale.conn.identity = { guestKeyHash: hashGuestKey(`stale-${offered ?? 'missing'}`) }
stale.conn.identity = { guestKeyHash: hashGuestKey(`stale-${offered ?? 'missing'}`) }
a.game.onMessage(stale.conn, {
a.game.onMessage(stale.conn, {
- t: 'hello', v: PROTOCOL_VERSION, slot: 0, name: 'Stale',
- appearance: defaultAppearance(), ...(offered ? { contentDigest: offered } : {}),
+ t: 'hello',
+ v: PROTOCOL_VERSION,
+ slot: 0,
+ name: 'Stale',
+ appearance: defaultAppearance(),
+ ...(offered ? { contentDigest: offered } : {}),
})
})
await new Promise((r) => setTimeout(r, 0))
await new Promise((r) => setTimeout(r, 0))
expect(stale.inbox).toContainEqual({
expect(stale.inbox).toContainEqual({
- t: 'reject', reason: 'content_mismatch',
- clientDigest: offered ?? null, serverDigest: a.world.content.digest,
+ t: 'reject',
+ reason: 'content_mismatch',
+ clientDigest: offered ?? null,
+ serverDigest: a.world.content.digest,
})
})
expect(stale.inbox.some((m) => m.t === 'welcome')).toBe(false)
expect(stale.inbox.some((m) => m.t === 'welcome')).toBe(false)
}
}
@@ -226,7 +232,11 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -226,7 +232,11 @@ describe('Games on platform identity, events and mods (real server, restart)', (
const oldBundle = connect(a.game)
const oldBundle = connect(a.game)
oldBundle.conn.identity = { guestKeyHash: hashGuestKey('old-bundle') }
oldBundle.conn.identity = { guestKeyHash: hashGuestKey('old-bundle') }
a.game.onMessage(oldBundle.conn, {
a.game.onMessage(oldBundle.conn, {
- t: 'hello', v: PROTOCOL_VERSION - 1, slot: 0, name: 'Old', appearance: defaultAppearance(),
+ t: 'hello',
+ v: PROTOCOL_VERSION - 1,
+ slot: 0,
+ name: 'Old',
+ appearance: defaultAppearance(),
})
})
await new Promise((r) => setTimeout(r, 0))
await new Promise((r) => setTimeout(r, 0))
expect(oldBundle.inbox).toContainEqual({ t: 'reject', reason: 'protocol_mismatch' })
expect(oldBundle.inbox).toContainEqual({ t: 'reject', reason: 'protocol_mismatch' })
@@ -94,10 +94,14 @@ export class SessionsSystem implements System {
@@ -94,10 +94,14 @@ export class SessionsSystem implements System {
return
return
}
}
if (msg.contentDigest !== serverDigest) {
if (msg.contentDigest !== serverDigest) {
- conn.send(encodeServerMessage({
- t: 'reject', reason: 'content_mismatch',
- clientDigest: msg.contentDigest ?? null, serverDigest,
- }))
+ conn.send(
+ encodeServerMessage({
+ t: 'reject',
+ reason: 'content_mismatch',
+ clientDigest: msg.contentDigest ?? null,
+ serverDigest,
+ }),
+ )
conn.close(4013, 'content_mismatch')
conn.close(4013, 'content_mismatch')
return
return
}
}
@@ -46,7 +46,9 @@ describe('games-content@2 definitions', () => {
@@ -46,7 +46,9 @@ describe('games-content@2 definitions', () => {
expect(capabilitiesUsedBy(mapPack)).toEqual([CAP_DEFINE])
expect(capabilitiesUsedBy(mapPack)).toEqual([CAP_DEFINE])
const result = validateContentPack(mapPack, createContent())
const result = validateContentPack(mapPack, createContent())
expect(result.ok).toBe(false)
expect(result.ok).toBe(false)
- expect(!result.ok && result.errors).toContain('map is defined by the built-in pack and cannot be replaced by a mod')
+ expect(!result.ok && result.errors).toContain(
+ 'map is defined by the built-in pack and cannot be replaced by a mod',
+ )
expect(validateContentPack({ map: { id: 'broken' } }, createContent()).ok).toBe(false)
expect(validateContentPack({ map: { id: 'broken' } }, createContent()).ok).toBe(false)
})
})
it('needs games.def.define for any non-empty defs section', () => {
it('needs games.def.define for any non-empty defs section', () => {
@@ -121,7 +121,8 @@ const validateSchema = ajv.compile(CONTENT_PACK_SCHEMA)
@@ -121,7 +121,8 @@ const validateSchema = ajv.compile(CONTENT_PACK_SCHEMA)
/** Which capability each pack section needs. */
/** Which capability each pack section needs. */
export function capabilitiesUsedBy(pack: ContentPack): string[] {
export function capabilitiesUsedBy(pack: ContentPack): string[] {
const used: string[] = []
const used: string[] = []
- if (pack.map || Object.values(pack.defs ?? {}).some((list) => (list ?? []).length > 0)) used.push(CAP_DEFINE)
+ if (pack.map || Object.values(pack.defs ?? {}).some((list) => (list ?? []).length > 0))
+ used.push(CAP_DEFINE)
if ((pack.announcements ?? []).length > 0) used.push(CAP_ANNOUNCE)
if ((pack.announcements ?? []).length > 0) used.push(CAP_ANNOUNCE)
if ((pack.props ?? []).length > 0) used.push(CAP_PLACE_PROP)
if ((pack.props ?? []).length > 0) used.push(CAP_PLACE_PROP)
return used
return used
@@ -195,7 +196,8 @@ export async function loadDefinitionPacks(
@@ -195,7 +196,8 @@ export async function loadDefinitionPacks(
for (const mod of await mods.list()) {
for (const mod of await mods.list()) {
if (mod.status !== 'enabled') continue
if (mod.status !== 'enabled') continue
const pack = mod.pack as unknown as ContentPackV2
const pack = mod.pack as unknown as ContentPackV2
- if (!pack.map && Object.values(pack.defs ?? {}).every((list) => (list ?? []).length === 0)) continue
+ if (!pack.map && Object.values(pack.defs ?? {}).every((list) => (list ?? []).length === 0))
+ continue
const granted = (await mods.grants(mod.id)).some(
const granted = (await mods.grants(mod.id)).some(
(g) => g.capability === CAP_DEFINE && g.revokedAt === null,
(g) => g.capability === CAP_DEFINE && g.revokedAt === null,
)
)
@@ -12,7 +12,8 @@ import { canEditMap, rankOf, resolveNetworkUser } from './networkAuth.js'
@@ -12,7 +12,8 @@ import { canEditMap, rankOf, resolveNetworkUser } from './networkAuth.js'
*/
*/
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString('base64url')
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString('base64url')
-const token = (claims: Record<string, unknown>): string => `${b64({ alg: 'RS256' })}.${b64(claims)}.sig`
+const token = (claims: Record<string, unknown>): string =>
+ `${b64({ alg: 'RS256' })}.${b64(claims)}.sig`
let server: Server
let server: Server
let url: string
let url: string
@@ -35,18 +36,38 @@ describe('rank from the staff map', () => {
@@ -35,18 +36,38 @@ describe('rank from the staff map', () => {
expect(rankOf({ role: 'admin' })).toBe('admin')
expect(rankOf({ role: 'admin' })).toBe('admin')
expect(rankOf({ role: 'admin', is_owner: true })).toBe('owner')
expect(rankOf({ role: 'admin', is_owner: true })).toBe('owner')
expect(rankOf({ role: 'user', is_owner: true })).toBe(null)
expect(rankOf({ role: 'user', is_owner: true })).toBe(null)
- expect(rankOf({ role: 'global_mod', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' })).toBe('admin')
- expect(rankOf({ role: 'admin', staff_caps: ['staff.moderation.chat'], staff_map: '1.1.0' })).toBe('moderator')
- expect(canEditMap('owner') && canEditMap('admin') && !canEditMap('moderator') && !canEditMap(null)).toBe(true)
+ expect(
+ rankOf({ role: 'global_mod', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' }),
+ ).toBe('admin')
+ expect(
+ rankOf({ role: 'admin', staff_caps: ['staff.moderation.chat'], staff_map: '1.1.0' }),
+ ).toBe('moderator')
+ expect(
+ canEditMap('owner') && canEditMap('admin') && !canEditMap('moderator') && !canEditMap(null),
+ ).toBe(true)
})
})
it('reads the accepted token and prefers the account role after a change', async () => {
it('reads the accepted token and prefers the account role after a change', async () => {
- account = { id: 7, username: 'goosely', role: 'admin', subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' }
- expect((await resolveNetworkUser(url, token({ role: 'admin', is_owner: true })))?.rank).toBe('owner')
+ account = {
+ id: 7,
+ username: 'goosely',
+ role: 'admin',
+ subject_id: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3',
+ }
+ expect((await resolveNetworkUser(url, token({ role: 'admin', is_owner: true })))?.rank).toBe(
+ 'owner',
+ )
// a username is not the owner claim
// a username is not the owner claim
expect((await resolveNetworkUser(url, token({ role: 'admin' })))?.rank).toBe('admin')
expect((await resolveNetworkUser(url, token({ role: 'admin' })))?.rank).toBe('admin')
account = { id: 8, username: 'demoted', role: 'user' }
account = { id: 8, username: 'demoted', role: 'user' }
- expect((await resolveNetworkUser(url, token({ role: 'admin', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' })))?.rank).toBe(null)
+ expect(
+ (
+ await resolveNetworkUser(
+ url,
+ token({ role: 'admin', staff_caps: ['staff.games.manage'], staff_map: '1.1.0' }),
+ )
+ )?.rank,
+ ).toBe(null)
account = { id: 9, username: 'promoted', role: 'admin' }
account = { id: 9, username: 'promoted', role: 'admin' }
expect((await resolveNetworkUser(url, token({ role: 'user' })))?.rank).toBe('admin')
expect((await resolveNetworkUser(url, token({ role: 'user' })))?.rank).toBe('admin')
})
})
@@ -57,11 +78,17 @@ describe('rank from the staff map', () => {
@@ -57,11 +78,17 @@ describe('rank from the staff map', () => {
const walk = (dir: string): void => {
const walk = (dir: string): void => {
for (const name of readdirSync(dir)) {
for (const name of readdirSync(dir)) {
const f = join(dir, name)
const f = join(dir, name)
- if (statSync(f).isDirectory()) { walk(f); continue }
+ if (statSync(f).isDirectory()) {
+ walk(f)
+ continue
+ }
if (!/\.ts$/.test(name) || name.endsWith('.test.ts')) continue
if (!/\.ts$/.test(name) || name.endsWith('.test.ts')) continue
- readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
- if (/OWNER_USERNAME|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) offenders.push(`${relative(root, f)}:${i + 1}`)
- })
+ readFileSync(f, 'utf8')
+ .split('\n')
+ .forEach((line, i) => {
+ if (/OWNER_USERNAME|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line))
+ offenders.push(`${relative(root, f)}:${i + 1}`)
+ })
}
}
}
}
walk(root)
walk(root)
@@ -41,7 +41,12 @@ describe('/shared (D42)', () => {
@@ -41,7 +41,12 @@ describe('/shared (D42)', () => {
})
})
it('serves only the browser files openvibe-shared lists', async () => {
it('serves only the browser files openvibe-shared lists', async () => {
- for (const p of ['/shared/serve.js', '/shared/../package.json', '/shared/', '/other/navbar.js']) {
+ for (const p of [
+ '/shared/serve.js',
+ '/shared/../package.json',
+ '/shared/',
+ '/other/navbar.js',
+ ]) {
expect((await fetch(base + p)).status).toBe(404)
expect((await fetch(base + p)).status).toBe(404)
}
}
expect((await fetch(`${base}/shared/navbar.js`, { method: 'POST' })).status).toBe(404)
expect((await fetch(`${base}/shared/navbar.js`, { method: 'POST' })).status).toBe(404)
@@ -3,7 +3,14 @@ import { tmpdir } from 'node:os'
@@ -3,7 +3,14 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join } from 'node:path'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
-import { buildRelease, gitCommit, isDirectLoopback, prometheusText, releaseHandler, wantsPrometheus } from './release.js'
+import {
+ buildRelease,
+ gitCommit,
+ isDirectLoopback,
+ prometheusText,
+ releaseHandler,
+ wantsPrometheus,
+} from './release.js'
const SHA = 'a'.repeat(40)
const SHA = 'a'.repeat(40)
function checkout(kind: 'loose' | 'packed' | 'detached'): string {
function checkout(kind: 'loose' | 'packed' | 'detached'): string {
@@ -13,7 +20,8 @@ function checkout(kind: 'loose' | 'packed' | 'detached'): string {
@@ -13,7 +20,8 @@ function checkout(kind: 'loose' | 'packed' | 'detached'): string {
if (kind === 'detached') writeFileSync(join(root, '.git', 'HEAD'), `${SHA}\n`)
if (kind === 'detached') writeFileSync(join(root, '.git', 'HEAD'), `${SHA}\n`)
else writeFileSync(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n')
else writeFileSync(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n')
if (kind === 'loose') writeFileSync(join(root, '.git', 'refs', 'heads', 'main'), `${SHA}\n`)
if (kind === 'loose') writeFileSync(join(root, '.git', 'refs', 'heads', 'main'), `${SHA}\n`)
- if (kind === 'packed') writeFileSync(join(root, '.git', 'packed-refs'), `# pack-refs\n${SHA} refs/heads/main\n`)
+ if (kind === 'packed')
+ writeFileSync(join(root, '.git', 'packed-refs'), `# pack-refs\n${SHA} refs/heads/main\n`)
return root
return root
}
}
@@ -28,18 +36,32 @@ describe('release manifest (D43)', () => {
@@ -28,18 +36,32 @@ describe('release manifest (D43)', () => {
})
})
it('builds the shared manifest shape; RELEASE_SHA wins', () => {
it('builds the shared manifest shape; RELEASE_SHA wins', () => {
- const m = buildRelease(checkout('loose'), { env: {}, now: () => new Date('2026-09-24T00:00:00Z') })
+ const m = buildRelease(checkout('loose'), {
+ env: {},
+ now: () => new Date('2026-09-24T00:00:00Z'),
+ })
expect(m.service).toBe('games')
expect(m.service).toBe('games')
expect(m.release).toBe(SHA.slice(0, 12))
expect(m.release).toBe(SHA.slice(0, 12))
expect(m.components.server).toEqual({ kind: 'server', version: SHA.slice(0, 12) })
expect(m.components.server).toEqual({ kind: 'server', version: SHA.slice(0, 12) })
expect(m.booted_at).toBe('2026-09-24T00:00:00.000Z')
expect(m.booted_at).toBe('2026-09-24T00:00:00.000Z')
- expect(buildRelease('/nowhere', { env: { RELEASE_SHA: 'b'.repeat(40) } }).release).toBe('b'.repeat(12))
+ expect(buildRelease('/nowhere', { env: { RELEASE_SHA: 'b'.repeat(40) } }).release).toBe(
+ 'b'.repeat(12),
+ )
})
})
it('answers GET /release.json only', () => {
it('answers GET /release.json only', () => {
- const handle = releaseHandler(buildRelease('/nowhere', { env: { RELEASE_SHA: 'c'.repeat(40) } }))
+ const handle = releaseHandler(
+ buildRelease('/nowhere', { env: { RELEASE_SHA: 'c'.repeat(40) } }),
+ )
const out: { status?: number; body?: string | undefined } = {}
const out: { status?: number; body?: string | undefined } = {}
- const res = { writeHead: (s: number) => { out.status = s }, end: (b?: string) => { out.body = b } } as unknown as ServerResponse
+ const res = {
+ writeHead: (s: number) => {
+ out.status = s
+ },
+ end: (b?: string) => {
+ out.body = b
+ },
+ } as unknown as ServerResponse
expect(handle({ url: '/api/x', method: 'GET' } as IncomingMessage, res)).toBe(false)
expect(handle({ url: '/api/x', method: 'GET' } as IncomingMessage, res)).toBe(false)
expect(handle({ url: '/release.json?x=1', method: 'GET' } as IncomingMessage, res)).toBe(true)
expect(handle({ url: '/release.json?x=1', method: 'GET' } as IncomingMessage, res)).toBe(true)
expect(out.status).toBe(200)
expect(out.status).toBe(200)
@@ -47,7 +69,8 @@ describe('release manifest (D43)', () => {
@@ -47,7 +69,8 @@ describe('release manifest (D43)', () => {
})
})
it('metrics are for direct loopback callers only', () => {
it('metrics are for direct loopback callers only', () => {
- const req = (addr: string, headers: Record<string, string> = {}) => ({ socket: { remoteAddress: addr }, headers }) as unknown as IncomingMessage
+ const req = (addr: string, headers: Record<string, string> = {}) =>
+ ({ socket: { remoteAddress: addr }, headers }) as unknown as IncomingMessage
expect(isDirectLoopback(req('127.0.0.1'))).toBe(true)
expect(isDirectLoopback(req('127.0.0.1'))).toBe(true)
expect(isDirectLoopback(req('::1'))).toBe(true)
expect(isDirectLoopback(req('::1'))).toBe(true)
expect(isDirectLoopback(req('127.0.0.1', { 'x-forwarded-for': '1.2.3.4' }))).toBe(false)
expect(isDirectLoopback(req('127.0.0.1', { 'x-forwarded-for': '1.2.3.4' }))).toBe(false)
@@ -56,9 +79,19 @@ describe('release manifest (D43)', () => {
@@ -56,9 +79,19 @@ describe('release manifest (D43)', () => {
})
})
it('serves the metrics snapshot as Prometheus text when a scraper asks', () => {
it('serves the metrics snapshot as Prometheus text when a scraper asks', () => {
- const text = prometheusText({ tick: 42, tickDurationMs: 0.5, sessions: 3, name: 'x', nested: { a: 1 }, bad: Number.NaN })
- expect(text).toBe('# TYPE games_tick gauge\ngames_tick 42\n# TYPE games_tick_duration_ms gauge\ngames_tick_duration_ms 0.5\n# TYPE games_sessions gauge\ngames_sessions 3\n')
- const req = (accept: string, url = '/metrics') => ({ headers: { accept }, url }) as unknown as IncomingMessage
+ const text = prometheusText({
+ tick: 42,
+ tickDurationMs: 0.5,
+ sessions: 3,
+ name: 'x',
+ nested: { a: 1 },
+ bad: Number.NaN,
+ })
+ expect(text).toBe(
+ '# TYPE games_tick gauge\ngames_tick 42\n# TYPE games_tick_duration_ms gauge\ngames_tick_duration_ms 0.5\n# TYPE games_sessions gauge\ngames_sessions 3\n',
+ )
+ const req = (accept: string, url = '/metrics') =>
+ ({ headers: { accept }, url }) as unknown as IncomingMessage
expect(wantsPrometheus(req('text/plain;version=0.0.4;q=0.5,*/*;q=0.1'))).toBe(true)
expect(wantsPrometheus(req('text/plain;version=0.0.4;q=0.5,*/*;q=0.1'))).toBe(true)
expect(wantsPrometheus(req('application/openmetrics-text;version=1.0.0'))).toBe(true)
expect(wantsPrometheus(req('application/openmetrics-text;version=1.0.0'))).toBe(true)
expect(wantsPrometheus(req('', '/metrics?format=prometheus'))).toBe(true)
expect(wantsPrometheus(req('', '/metrics?format=prometheus'))).toBe(true)
@@ -61,7 +61,10 @@ export function buildRelease(
@@ -61,7 +61,10 @@ export function buildRelease(
root: string,
root: string,
{ env = process.env, now = () => new Date() }: { env?: NodeJS.ProcessEnv; now?: () => Date } = {},
{ env = process.env, now = () => new Date() }: { env?: NodeJS.ProcessEnv; now?: () => Date } = {},
): ReleaseManifest {
): ReleaseManifest {
- const sha = (env.RELEASE_SHA && /^[0-9a-f]{7,40}$/.test(env.RELEASE_SHA) ? env.RELEASE_SHA : gitCommit(root)) ?? 'unknown'
+ const sha =
+ (env.RELEASE_SHA && /^[0-9a-f]{7,40}$/.test(env.RELEASE_SHA)
+ ? env.RELEASE_SHA
+ : gitCommit(root)) ?? 'unknown'
const release = sha.slice(0, 12)
const release = sha.slice(0, 12)
const req = createRequire(import.meta.url)
const req = createRequire(import.meta.url)
const packages: Record<string, string> = {}
const packages: Record<string, string> = {}
@@ -78,7 +81,10 @@ export function buildRelease(
@@ -78,7 +81,10 @@ export function buildRelease(
packages,
packages,
min_client_release: env.MIN_CLIENT_RELEASE ?? null,
min_client_release: env.MIN_CLIENT_RELEASE ?? null,
mixed_version_window_hours: 24,
mixed_version_window_hours: 24,
- components: { client: { kind: 'script', version: release }, server: { kind: 'server', version: release } },
+ components: {
+ client: { kind: 'script', version: release },
+ server: { kind: 'server', version: release },
+ },
schema_generation: null,
schema_generation: null,
schema_compatible_from: null,
schema_compatible_from: null,
contract_ranges: {},
contract_ranges: {},
@@ -89,8 +95,16 @@ export function buildRelease(
@@ -89,8 +95,16 @@ export function buildRelease(
export function releaseHandler(manifest: ReleaseManifest) {
export function releaseHandler(manifest: ReleaseManifest) {
const body = JSON.stringify(manifest)
const body = JSON.stringify(manifest)
return (req: IncomingMessage, res: ServerResponse): boolean => {
return (req: IncomingMessage, res: ServerResponse): boolean => {
- if ((req.url ?? '').split('?')[0] !== '/release.json' || (req.method !== 'GET' && req.method !== 'HEAD')) return false
- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-cache', 'access-control-allow-origin': '*' })
+ if (
+ (req.url ?? '').split('?')[0] !== '/release.json' ||
+ (req.method !== 'GET' && req.method !== 'HEAD')
+ )
+ return false
+ res.writeHead(200, {
+ 'content-type': 'application/json',
+ 'cache-control': 'no-cache',
+ 'access-control-allow-origin': '*',
+ })
res.end(req.method === 'HEAD' ? undefined : body)
res.end(req.method === 'HEAD' ? undefined : body)
return true
return true
}
}
@@ -112,7 +126,10 @@ export function prometheusText(snapshot: Record<string, unknown>): string {
@@ -112,7 +126,10 @@ export function prometheusText(snapshot: Record<string, unknown>): string {
const lines: string[] = []
const lines: string[] = []
for (const [k, v] of Object.entries(snapshot)) {
for (const [k, v] of Object.entries(snapshot)) {
if (typeof v !== 'number' || !Number.isFinite(v)) continue
if (typeof v !== 'number' || !Number.isFinite(v)) continue
- const name = `games_${k.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^A-Za-z0-9_]/g, '_').toLowerCase()}`
+ const name = `games_${k
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
+ .replace(/[^A-Za-z0-9_]/g, '_')
+ .toLowerCase()}`
lines.push(`# TYPE ${name} gauge`, `${name} ${v}`)
lines.push(`# TYPE ${name} gauge`, `${name} ${v}`)
}
}
return `${lines.join('\n')}\n`
return `${lines.join('\n')}\n`
@@ -53,7 +53,18 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
@@ -53,7 +53,18 @@ export function createContent(packs: ContentPackV2[] = []): ContentRegistry {
factions: FACTIONS,
factions: FACTIONS,
markets: MARKETS,
markets: MARKETS,
jobs: JOBS,
jobs: JOBS,
- world: { id: 'base', name: 'Base', groundHalfExtent: 80, spawnPoint: [0, 1.2, 4], spawnYaw: 0, flatTerrain: true, statics: [], resourceNodes: [], initialProps: [], zones: [] },
+ world: {
+ id: 'base',
+ name: 'Base',
+ groundHalfExtent: 80,
+ spawnPoint: [0, 1.2, 4],
+ spawnYaw: 0,
+ flatTerrain: true,
+ statics: [],
+ resourceNodes: [],
+ initialProps: [],
+ zones: [],
+ },
},
},
[SCRAPLANDIA_PACK, ...packs],
[SCRAPLANDIA_PACK, ...packs],
)
)
@@ -188,7 +188,10 @@ describe('contentDigest', () => {
@@ -188,7 +188,10 @@ describe('contentDigest', () => {
})
})
it('changes when the pack map changes', () => {
it('changes when the pack map changes', () => {
- const changed = { ...base, world: { ...base.world, spawnPoint: [1, 1.2, 4] as [number, number, number] } }
+ const changed = {
+ ...base,
+ world: { ...base.world, spawnPoint: [1, 1.2, 4] as [number, number, number] },
+ }
expect(contentDigest(changed)).not.toBe(contentDigest(base))
expect(contentDigest(changed)).not.toBe(contentDigest(base))
})
})
})
})
@@ -198,11 +201,24 @@ describe('Scraplandia default pack', () => {
@@ -198,11 +201,24 @@ describe('Scraplandia default pack', () => {
// Recorded from main 545f6c1, packages/content/src/defs/scrapcity.ts.
// Recorded from main 545f6c1, packages/content/src/defs/scrapcity.ts.
// The released city is an editor-built blank slate: no seeded entity ids.
// The released city is an editor-built blank slate: no seeded entity ids.
const expected = {
const expected = {
- id: 'openvibeville_v2', name: 'Scrap City', flatTerrain: true,
- groundHalfExtent: 80, spawnPoint: [0, 1.2, 4], spawnYaw: 0,
- statics: [], resourceNodes: [], initialProps: [],
- zones: [{ id: 'city', name: 'Scrap City', min: [-20.5, -1, -20.5],
- max: [20.5, 8, 20.5], rules: { pvp: false, build: false, physgun: true } }],
+ id: 'openvibeville_v2',
+ name: 'Scrap City',
+ flatTerrain: true,
+ groundHalfExtent: 80,
+ spawnPoint: [0, 1.2, 4],
+ spawnYaw: 0,
+ statics: [],
+ resourceNodes: [],
+ initialProps: [],
+ zones: [
+ {
+ id: 'city',
+ name: 'Scrap City',
+ min: [-20.5, -1, -20.5],
+ max: [20.5, 8, 20.5],
+ rules: { pvp: false, build: false, physgun: true },
+ },
+ ],
}
}
expect(SCRAPLANDIA_PACK.map).toEqual(expected)
expect(SCRAPLANDIA_PACK.map).toEqual(expected)
expect(createContent().world).toEqual(expected)
expect(createContent().world).toEqual(expected)
@@ -565,7 +565,8 @@ const corpusTerrain = (): BodyDesc => ({
@@ -565,7 +565,8 @@ const corpusTerrain = (): BodyDesc => ({
const corpusIsland = (): BodyDesc[] => {
const corpusIsland = (): BodyDesc[] => {
const out: BodyDesc[] = []
const out: BodyDesc[] = []
for (let x = -1; x <= 1; x++)
for (let x = -1; x <= 1; x++)
- for (let z = -1; z <= 1; z++) out.push(corpusBox([0.8, 0.8, 0.8], vec3(x * 0.9, 0.4, 6 + z * 0.9)))
+ for (let z = -1; z <= 1; z++)
+ out.push(corpusBox([0.8, 0.8, 0.8], vec3(x * 0.9, 0.4, 6 + z * 0.9)))
return out
return out
}
}
@@ -591,7 +592,12 @@ interface CorpusScene {
@@ -591,7 +592,12 @@ interface CorpusScene {
}
}
const CORPUS: CorpusScene[] = [
const CORPUS: CorpusScene[] = [
- { name: 'flat ground', bodies: [corpusFloor()], script: corpusTicks(90, corpusWalk), mockExpressible: true },
+ {
+ name: 'flat ground',
+ bodies: [corpusFloor()],
+ script: corpusTicks(90, corpusWalk),
+ mockExpressible: true,
+ },
{
{
name: 'stairs',
name: 'stairs',
bodies: [corpusFloor(), ...corpusStairs()],
bodies: [corpusFloor(), ...corpusStairs()],
@@ -1356,30 +1356,27 @@ function conformance(a: Adapter): void {
@@ -1356,30 +1356,27 @@ function conformance(a: Adapter): void {
close()
close()
})
})
- it.skipIf(maybe('step advances'))(
- 'step advances exactly the dt it is given',
- () => {
- const probe = (dt: number, ticks: number) => {
- const w = open()
- const box = w.addBody(dynamicBoxDesc(vec3(0, 900, 0), 0.5, 10))
- stepFor(w, ticks, dt)
- const v = pos()
- w.getLinearVelocity(box, v)
- close()
- return v.y
- }
- // Rapier honours the dt it is given: 30 steps of 1/30 s and 60 steps
- // of 1/60 s are the same simulated second (the damping is applied per
- // step, so they agree to within a few mm/s, not bit-exactly).
- expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 60, 60), 1)
- // An extra simulated second is very nearly twice the velocity.
- expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeGreaterThan(1.9)
- expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeLessThan(2.1)
- // One simulated second of this gravity: about -16.5 m/s.
- expect(probe(1 / 30, 30)).toBeLessThan(-15)
- expect(probe(1 / 30, 30)).toBeGreaterThan(-17)
- },
- )
+ it.skipIf(maybe('step advances'))('step advances exactly the dt it is given', () => {
+ const probe = (dt: number, ticks: number) => {
+ const w = open()
+ const box = w.addBody(dynamicBoxDesc(vec3(0, 900, 0), 0.5, 10))
+ stepFor(w, ticks, dt)
+ const v = pos()
+ w.getLinearVelocity(box, v)
+ close()
+ return v.y
+ }
+ // Rapier honours the dt it is given: 30 steps of 1/30 s and 60 steps
+ // of 1/60 s are the same simulated second (the damping is applied per
+ // step, so they agree to within a few mm/s, not bit-exactly).
+ expect(probe(1 / 30, 30)).toBeCloseTo(probe(1 / 60, 60), 1)
+ // An extra simulated second is very nearly twice the velocity.
+ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeGreaterThan(1.9)
+ expect(probe(1 / 30, 60) / probe(1 / 30, 30)).toBeLessThan(2.1)
+ // One simulated second of this gravity: about -16.5 m/s.
+ expect(probe(1 / 30, 30)).toBeLessThan(-15)
+ expect(probe(1 / 30, 30)).toBeGreaterThan(-17)
+ })
it('a resting dynamic body on the ground settles', () => {
it('a resting dynamic body on the ground settles', () => {
const w = open()
const w = open()
@@ -9,7 +9,14 @@ describe('protocol codec', () => {
@@ -9,7 +9,14 @@ describe('protocol codec', () => {
const messages: ClientMessage[] = [
const messages: ClientMessage[] = [
// hello carries no identity field: authentication happens at the
// hello carries no identity field: authentication happens at the
// WebSocket upgrade (ADR-0007 decision 8).
// WebSocket upgrade (ADR-0007 decision 8).
- { t: 'hello', v: PROTOCOL_VERSION, contentDigest: 'v2-1234abcd', slot: 0, name: 'Tester', appearance: defaultAppearance() },
+ {
+ t: 'hello',
+ v: PROTOCOL_VERSION,
+ contentDigest: 'v2-1234abcd',
+ slot: 0,
+ name: 'Tester',
+ appearance: defaultAppearance(),
+ },
{ t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },
{ t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },
{ t: 'use', target: 'abc123' },
{ t: 'use', target: 'abc123' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
@@ -44,16 +51,29 @@ describe('protocol codec', () => {
@@ -44,16 +51,29 @@ describe('protocol codec', () => {
})
})
it('decodes an older hello without a digest for a typed join rejection', () => {
it('decodes an older hello without a digest for a typed join rejection', () => {
- const old = decodeClientMessage(JSON.stringify({
- t: 'hello', v: PROTOCOL_VERSION - 1, slot: 0, name: 'Old',
- appearance: defaultAppearance(),
- }))
+ const old = decodeClientMessage(
+ JSON.stringify({
+ t: 'hello',
+ v: PROTOCOL_VERSION - 1,
+ slot: 0,
+ name: 'Old',
+ appearance: defaultAppearance(),
+ }),
+ )
expect(old).toMatchObject({ t: 'hello' })
expect(old).toMatchObject({ t: 'hello' })
expect(old).not.toHaveProperty('contentDigest')
expect(old).not.toHaveProperty('contentDigest')
- expect(decodeClientMessage(JSON.stringify({
- t: 'hello', v: PROTOCOL_VERSION, contentDigest: 'garbage',
- slot: 0, name: 'Bad', appearance: defaultAppearance(),
- }))).toBeNull()
+ expect(
+ decodeClientMessage(
+ JSON.stringify({
+ t: 'hello',
+ v: PROTOCOL_VERSION,
+ contentDigest: 'garbage',
+ slot: 0,
+ name: 'Bad',
+ appearance: defaultAppearance(),
+ }),
+ ),
+ ).toBeNull()
})
})
it('refuses unknown intent bits and out-of-range quantised angles', () => {
it('refuses unknown intent bits and out-of-range quantised angles', () => {
@@ -12,7 +12,10 @@ export const ClientHelloSchema = z.object({
@@ -12,7 +12,10 @@ export const ClientHelloSchema = z.object({
t: z.literal('hello'),
t: z.literal('hello'),
v: z.number().int(),
v: z.number().int(),
/** Digest of the client's loaded content definitions. Omitted by old clients. */
/** Digest of the client's loaded content definitions. Omitted by old clients. */
- contentDigest: z.string().regex(/^v[0-9]+-[0-9a-f]{8}$/).optional(),
+ contentDigest: z
+ .string()
+ .regex(/^v[0-9]+-[0-9a-f]{8}$/)
+ .optional(),
/** Character slot under this account (MMO-style, 3 max). */
/** Character slot under this account (MMO-style, 3 max). */
slot: z.number().int().min(0).max(2).default(0),
slot: z.number().int().min(0).max(2).default(0),
name: z.string().min(1).max(24),
name: z.string().min(1).max(24),
@@ -193,6 +193,8 @@ for (const f of chosen) {
@@ -193,6 +193,8 @@ for (const f of chosen) {
}
}
for (const tail of tails) console.log(`\n${tail}`)
for (const tail of tails) console.log(`\n${tail}`)
if (!bad && r.status !== 0) {
if (!bad && r.status !== 0) {
- console.log(`\n── vitest exited ${r.status ?? r.signal} although every file passed ──\n${vitestOut.split('\n').slice(-30).join('\n')}`)
+ console.log(
+ `\n── vitest exited ${r.status ?? r.signal} although every file passed ──\n${vitestOut.split('\n').slice(-30).join('\n')}`,
+ )
}
}
process.exit(bad || r.status !== 0 ? 1 : 0)
process.exit(bad || r.status !== 0 ? 1 : 0)