openvibers/openvibe.games Commits
The Source developer console: ~, cvars, commands, completions (#197)
Changes
nnew file mode 100644@@@ -0,0 +1,182 @@++/**++ * Console smoke (job 4): a real browser opens ~, types a Source query and++ * closes with Escape on the surf page.++ *++ * Run after `pnpm --filter @openvibe/client build`:++ * pnpm test:console++ *++ * The game server hosts every surf place itself, so `/surf/embed?map=surf_lagoon`++ * is served without extra fixtures. The assertions are behavioural: the DOM++ * overlay, the input capture that keeps typing out of movement, the++ * authoritative `sv_gravity` value from welcome, and an autoexec bind.++ */++import { spawn } from 'node:child_process'++import { mkdtempSync, rmSync } from 'node:fs'++import { tmpdir } from 'node:os'++import { join } from 'node:path'++import { chromium, type Page } from 'playwright'++++const PORT = 18196++const dir = mkdtempSync(join(tmpdir(), 'openvibe-console-'))++++const waitForServer = (server: ReturnType<typeof spawn>): Promise<void> =>++ new Promise((resolve, reject) => {++ const timer = setTimeout(() => reject(new Error('server did not start')), 90_000)++ server.stdout?.on('data', (chunk: Buffer) => {++ if (chunk.toString().includes('listening')) {++ clearTimeout(timer)++ resolve()++ }++ })++ server.stderr?.on('data', (chunk: Buffer) => process.stderr.write(chunk))++ })++++/** Poll in the page with page.evaluate; rAF-driven waitForFunction is unreliable headless. */++async function poll(++ page: Page,++ check: () => boolean,++ timeoutMs: number,++ label: string,++): Promise<void> {++ const end = Date.now() + timeoutMs++ while (Date.now() < end) {++ if (await page.evaluate(check)) return++ await page.waitForTimeout(50)++ }++ throw new Error(`timed out waiting for ${label}`)++}++++async function main(): Promise<void> {++ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {++ env: {++ ...process.env,++ PORT: String(PORT),++ NODE_ENV: 'test',++ DATABASE_DIR: dir,++ DATABASE_URL: '',++ DATABASE_DIRECT_URL: '',++ STATIC_DIR: 'apps/client/dist',++ },++ stdio: ['ignore', 'pipe', 'pipe'],++ })++ try {++ await waitForServer(server)++ const browser = await chromium.launch({++ args: ['--use-angle=swiftshader', '--enable-webgl', '--disable-gpu-sandbox'],++ })++ try {++ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })++ const errors: string[] = []++ page.on('pageerror', (err) => errors.push(String(err)))++ const url = `http://127.0.0.1:${PORT}/surf/embed?map=surf_lagoon`++ // autoexec.cfg is a virtual file, applied once welcome has landed. Set++ // it with the page's real origin and reload, rather than racing an init++ // script against the app's boot.++ await page.goto(url, { waitUntil: 'domcontentloaded' })++ await page.evaluate(() => {++ localStorage.setItem('openvibe.cfg.autoexec.cfg', 'bind q +duck; echo AUTOEXEC_RAN')++ })++ await page.reload({ waitUntil: 'domcontentloaded' })++++ await poll(++ page,++ () =>++ Boolean(++ (window as unknown as { __openvibe?: { state?: { myEntityId?: string } } }).__openvibe++ ?.state?.myEntityId,++ ),++ 90_000,++ 'welcome',++ )++ await poll(++ page,++ () =>++ document++ .querySelector('.ovg-console .console-log')++ ?.textContent?.includes('AUTOEXEC_RAN') ?? false,++ 10_000,++ 'autoexec.cfg',++ )++ const bound = await page.evaluate(() =>++ (++ window as unknown as {++ __openvibe: { input: { bindings: { commandForCode(code: string): string | null } } }++ }++ ).__openvibe.input.bindings.commandForCode('KeyQ'),++ )++ if (bound !== '+duck') throw new Error(`autoexec bind missing: ${String(bound)}`)++++ await page.bringToFront()++ const consoleOpen = (): Promise<boolean> =>++ page.evaluate(() => {++ const el = document.querySelector('.ovg-console')++ return el instanceof HTMLElement && !el.hidden++ })++ // A fresh tab can lose the first synthetic key to focus; retry the real++ // key, not a synthetic DOM event, so this still proves ~ opens it.++ for (let attempt = 0; attempt < 3 && !(await consoleOpen()); attempt++) {++ await page.keyboard.press('`')++ await page.waitForTimeout(200)++ }++ if (!(await consoleOpen())) throw new Error('~ did not open the console')++ // The overlay focuses its input in a microtask; focus explicitly so the++ // smoke is about the console behaviour, not a one-frame focus race.++ await page.locator('.console-input').focus()++ const captured = await page.evaluate(++ () =>++ (window as unknown as { __openvibe: { input: { uiCapture: boolean } } }).__openvibe.input++ .uiCapture,++ )++ if (!captured) throw new Error('opening the console did not capture input')++++ await page.keyboard.type('sv_gravity')++ await page.keyboard.press('Enter')++ await poll(++ page,++ () =>++ document++ .querySelector('.ovg-console .console-log')++ ?.textContent?.includes('sv_gravity = 800') ?? false,++ 5_000,++ 'sv_gravity reply',++ )++ await page.keyboard.type('help sv_airaccelerate')++ await page.keyboard.press('Enter')++ await poll(++ page,++ () =>++ document++ .querySelector('.ovg-console .console-log')++ ?.textContent?.includes('sv_airaccelerate = 150') ?? false,++ 5_000,++ 'sv_airaccelerate reply',++ )++++ await page.keyboard.press('Escape')++ await poll(++ page,++ () => document.querySelector('.ovg-console')?.hasAttribute('hidden') ?? false,++ 5_000,++ 'console close',++ )++ const released = await page.evaluate(++ () =>++ (window as unknown as { __openvibe: { input: { uiCapture: boolean } } }).__openvibe.input++ .uiCapture,++ )++ if (released) throw new Error('closing the console did not release input')++ if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`)++ console.log('console smoke passed: open, type sv_gravity/help, Escape close')++ } finally {++ await browser.close()++ }++ } finally {++ server.kill()++ rmSync(dir, { recursive: true, force: true })++ }++}++++void main().catch((err: unknown) => {++ console.error(err)++ process.exitCode = 1++})@@@ -14,6 +14,8 @@ import {import { ApiError, type ActionAnswer, type CasualApi, type Player } from './api.js'import { icon } from './art.js'import { button, duration, h, num, sheet, tick, toast } from './dom.js'++import { CvarRegistry, CLIENT_CVARS } from '@openvibe/shared'++import { GameConsole } from '../ui/console.js'import { forgetSession } from '../net/identity.js'export type Tab = 'home' | 'heroes' | 'summon' | 'quests' | 'shop'@@@ -35,6 +37,8 @@ export class App {private view = h('section', { class: 'cg-view', attrs: { 'aria-live': 'off' } })private tabs = h('nav', { class: 'cg-tabs', attrs: { 'aria-label': 'Game sections' } })private menu: (app: App) => void = () => {}++ /** Same ~ overlay as the 3D client, with read-only commands only. */++ readonly console: GameConsoleconstructor(readonly root: HTMLElement,@@@ -46,6 +50,15 @@ export class App {private views: Record<Tab, View>,) {root.replaceChildren(this.top, this.view, this.tabs)++ this.console = new GameConsole({++ parent: document.body,++ cvars: new CvarRegistry(CLIENT_CVARS),++ readOnly: true,++ })++ this.console.setStatusProvider(() => [++ `${this.pack.name} · ${this.player.name}`,++ `tab ${this.tab} · version ${this.version} · read-only console`,++ ])}onMenu(fn: (app: App) => void): void {@@@ -76,7 +76,7 @@ export class LocalPlayer {private readonly state: ClientState,spawn: { x: number; y: number; z: number },/** The place's movement preset, resolved on both sides (surf vs default). */-- readonly params: MovementParams = DEFAULT_MOVEMENT,++ public params: MovementParams = DEFAULT_MOVEMENT,) {this.move = createMoveState(spawn)this.eyeSmooth = eyeOffsetFor(Stance.Stand, params)@@@ -108,6 +108,11 @@ export class LocalPlayer {this.currPos.set(spawn.x, spawn.y, spawn.z)}++ /** Adopt an authoritative cvar change without resetting the predicted pose. */++ setMovementParams(params: MovementParams): void {++ this.params = params++ }++/** One fixed simulation tick: capture, send, predict. */fixedUpdate(): void {const sample = this.input.sample()@@@ -1,5 +1,5 @@import { describe, expect, it } from 'vitest'--import { Intents } from '@openvibe/shared'++import { Bindings, Intents } from '@openvibe/shared'import {KeyboardInputSource,SwitchableInputSource,@@@ -143,6 +143,26 @@ describe('input sources', () => {expect(new Set(fromKeyboard).size).toBeGreaterThan(4)})++ it('reads intents from Source binds, and console + commands set the same bits', () => {++ const keyboard = new FakeKeyboard()++ const binds = new Bindings({ storage: null, defaults: { space: '+jump', ctrl: '+duck' } })++ const source = new KeyboardInputSource(keyboard, binds)++ keyboard.keys.add('Space')++ expect(source.sample().intents).toBe(Intents.Jump)++ keyboard.keys.add('ControlLeft')++ expect(source.sample().intents).toBe(Intents.Jump | Intents.Crouch)++ // Rebinding space mid-session takes effect on the next sample.++ binds.bind('space', '+duck')++ expect(source.sample().intents).toBe(Intents.Crouch)++ // A console-typed +jump feeds the same intent bit.++ source.setManualIntent('Jump', true)++ expect(source.sample().intents).toBe(Intents.Crouch | Intents.Jump)++ source.setManualIntent('Jump', false)++ expect(source.sample().intents).toBe(Intents.Crouch)++ binds.unbind('ctrl')++ keyboard.keys.delete('ControlLeft')++ })++it('the switchable source samples whichever device is current', () => {const keyboard = new FakeKeyboard()keyboard.keys.add('Space')@@@ -1,4 +1,10 @@--import { Intents } from '@openvibe/shared'++import {++ Intents,++ intentCommand,++ keyCodeFor,++ type Bindings,++ type IntentName,++} from '@openvibe/shared'/** One simulation tick of intent input, independent of the device behind it. */export interface InputSample {@@@ -35,9 +41,23 @@ export interface KeyboardState {readonly pitch: number}--/** Keyboard + mouse: today's desktop bindings, mapped onto named intents. */++/** Keyboard + mouse: Source key bindings, mapped onto named intents. */export class KeyboardInputSource implements InputSource {-- constructor(private readonly keys: KeyboardState) {}++ /** Console-typed `+jump` / `-jump`, independent of the physical keys. */++ private manualIntents = 0++++ constructor(++ private readonly keys: KeyboardState,++ /** When present, the player's binds replace the stock intent keys. */++ private readonly bindings: Bindings | null = null,++ ) {}++++ /** A `+`/`-` command typed in the console feeds the same intent bits as a key. */++ setManualIntent(name: string, down: boolean): void {++ const bit = Intents[name as IntentName]++ if (typeof bit !== 'number') return++ this.manualIntents = down ? this.manualIntents | bit : this.manualIntents & ~bit++ }get yaw(): number {return this.keys.yaw@@@ -49,11 +69,21 @@ export class KeyboardInputSource implements InputSource {sample(): InputSample {const keys = this.keys-- let intents = 0-- if (keys.keyDown('Space')) intents |= Intents.Jump-- if (keys.keyDown('ShiftLeft')) intents |= Intents.Sprint-- if (keys.keyDown('ControlLeft') || keys.keyDown('KeyC')) intents |= Intents.Crouch-- if (keys.keyDown('KeyZ')) intents |= Intents.Prone++ let intents = this.manualIntents++ if (this.bindings) {++ for (const binding of this.bindings.entries()) {++ const code = keyCodeFor(binding.key)++ if (!code || !keys.keyDown(code)) continue++ const intent = intentCommand(binding.command)++ if (intent?.down) intents |= Intents[intent.name]++ }++ } else {++ // No console mounted (unit tests, embeds): the stock controls remain.++ if (keys.keyDown('Space')) intents |= Intents.Jump++ if (keys.keyDown('ShiftLeft')) intents |= Intents.Sprint++ if (keys.keyDown('ControlLeft') || keys.keyDown('KeyC')) intents |= Intents.Crouch++ if (keys.keyDown('KeyZ')) intents |= Intents.Prone++ }const moveX = (keys.keyDown('KeyD') ? 1 : 0) - (keys.keyDown('KeyA') ? 1 : 0)const moveZ = (keys.keyDown('KeyW') ? 1 : 0) - (keys.keyDown('KeyS') ? 1 : 0)return { moveX, moveZ, yaw: keys.yaw, pitch: keys.pitch, intents }@@@ -1,5 +1,6 @@// @vitest-environment jsdomimport { describe, expect, it } from 'vitest'++import { Bindings } from '@openvibe/shared'import { InputTracker } from './inputTracker.js'function setup(pointerLock: boolean) {@@@ -19,7 +20,7 @@ function setup(pointerLock: boolean) {target.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, buttons: 0 }))const scroll = (target: Element) =>target.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 }))-- return { canvas, hud, actions, wheel, press, release, scroll }++ return { canvas, hud, input, actions, wheel, press, release, scroll }}describe('input tracker presses', () => {@@@ -46,4 +47,45 @@ describe('input tracker presses', () => {expect(actions).toEqual([])expect(wheel).toEqual([])})++++ it('routes a bound key to onBind instead of the stock action table', () => {++ const { input, actions } = setup(false)++ input.bindings = new Bindings({ storage: null, defaults: { space: '+jump', r: '+restart' } })++ const bound: [string, boolean][] = []++ input.onBind = (command, down) => bound.push([command, down])++ const press = (code: string) =>++ document.body.dispatchEvent(++ new KeyboardEvent('keydown', { code, key: code, bubbles: true, cancelable: true }),++ )++ const release = (code: string) =>++ document.body.dispatchEvent(++ new KeyboardEvent('keyup', { code, key: code, bubbles: true, cancelable: true }),++ )++ press('Space')++ expect(bound).toEqual([['+jump', true]])++ expect(input.keyDown('Space')).toBe(true)++ release('Space')++ expect(bound).toEqual([++ ['+jump', true],++ ['+jump', false],++ ])++ // R would normally reload; the bind wins, and no stock action fires.++ press('KeyR')++ expect(bound.at(-1)).toEqual(['+restart', true])++ expect(actions).toEqual([])++ })++++ it('ignores keys aimed at a text input, even with no UI capture', () => {++ const { input, actions } = setup(false)++ const field = document.createElement('input')++ document.body.append(field)++ field.dispatchEvent(++ new KeyboardEvent('keydown', { code: 'Space', key: ' ', bubbles: true, cancelable: true }),++ )++ expect(input.keyDown('Space')).toBe(false)++ field.dispatchEvent(++ new KeyboardEvent('keydown', { code: 'KeyE', key: 'e', bubbles: true, cancelable: true }),++ )++ expect(actions).toEqual([])++ })})@@@ -7,6 +7,8 @@* pointer is locked some browsers retarget events inconsistently, and a* document listener sees them regardless.*/++import type { Bindings } from '@openvibe/shared'++export class InputTracker {yaw = 0pitch = 0@@@ -37,6 +39,10 @@ export class InputTracker {private lookDy = 0/** Last seen mouse `buttons` bitmask (chorded press/release detection). */private buttonsState = 0++ /** Source-style key bindings; bound keys bypass the stock action table. */++ bindings: Bindings | null = null++ /** `+command` on press, `-command` on release. Intents are read by the input source. */++ onBind: ((command: string, down: boolean) => void) | null = null/** Emits press/release actions from `buttons` bitmask transitions. */private diffButtons(e: MouseEvent): void {@@@ -100,7 +106,20 @@ export class InputTracker {})document.addEventListener('keydown', (e) => {if (e.repeat) return++ // A focused input (the console, a name field) owns its keystrokes:++ // typing must never add a held game key. The console also sets++ // `uiCapture`, but this is true even before the callback runs.++ if (isTypingTarget(e.target)) returnthis.keys.add(e.code)++ // A bound key means exactly what the player bound it to. `+jump` and++ // its siblings still reach the input source through `keys`; one-shot++ // commands (`toggle_ghost`, `+restart`) go to onBind.++ const bound = this.bindings?.commandForCode(e.code) ?? null++ if (bound && !this.uiCapture) {++ e.preventDefault()++ this.onBind?.(bound, true)++ return++ }if (e.code === 'KeyE' && !this.uiCapture) {e.preventDefault()this.onAction?.({ kind: 'use_down' })@@@ -117,7 +136,14 @@ export class InputTracker {if (e.code === 'Tab') e.preventDefault()})document.addEventListener('keyup', (e) => {-- this.keys.delete(e.code)++ const wasDown = this.keys.delete(e.code)++ if (isTypingTarget(e.target)) return++ const bound = this.bindings?.commandForCode(e.code) ?? null++ if (bound && wasDown && !this.uiCapture) {++ e.preventDefault()++ this.onBind?.(bound, false)++ return++ }if (e.code === 'KeyE' && !this.uiCapture) {this.onAction?.({ kind: 'use_up' })}@@@ -192,6 +218,16 @@ export class InputTracker {}}++/** A key event aimed at an editable control is text, not a game input. */++function isTypingTarget(target: EventTarget | null): boolean {++ if (!(target instanceof HTMLElement)) return false++ return (++ target instanceof HTMLInputElement ||++ target instanceof HTMLTextAreaElement ||++ target.isContentEditable++ )++}++export type InputAction =| { kind: 'use_down' }| { kind: 'use_up' }@@@ -18,7 +18,16 @@ import {} from '@openvibe/content'import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'--import { FixedTimestep } from '@openvibe/shared'++import {++ CLIENT_CVARS,++ CvarRegistry,++ DEFAULT_BINDS,++ Bindings,++ ConfigStore,++ FixedTimestep,++ SOURCE_MOVEMENT_CVARS,++ isIntentCommand,++} from '@openvibe/shared'import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'import { defaultAppearance, type Appearance } from '@openvibe/protocol'import { Color3 } from '@babylonjs/core/Maths/math.color.js'@@@ -27,7 +36,9 @@ import { WATER_LEVEL, getMapOverride, terrainHeight } from '@openvibe/content'import {DEFAULT_MOVEMENT,decodeSurfTrack,++ movementCvarValues,movementParamsFor,++ movementParamsWithCvars,movementStyleFor,} from '@openvibe/gameplay'import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'@@@ -68,6 +79,7 @@ import { ClientState } from './state/clientState.js'import { characterSelect, type CharacterInfo } from './ui/characterSelect.js'import { customizeScreen } from './ui/customizeScreen.js'import { Hud } from './ui/hud.js'++import { GameConsole } from './ui/console.js'import { IconFactory } from './ui/iconFactory.js'import { registerPhysgunModule } from './weapons/physgunModule.js'import { registerRiggingModule } from './weapons/riggingModule.js'@@@ -166,14 +178,13 @@ async function start(): Promise<void> {}if (Array.isArray(data.packs)) {const contentSurf = data.game ? surfMapOfPlace(data.game) : null-- const builtIn =-- contentSurf-- ? surfPack(contentSurf.id)-- : data.game === 'salvage-rush'-- ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK-- : data.game === 'creator' && worldId-- ? creatorWorldPack(worldId)-- : undefined++ const builtIn = contentSurf++ ? surfPack(contentSurf.id)++ : data.game === 'salvage-rush'++ ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK++ : data.game === 'creator' && worldId++ ? creatorWorldPack(worldId)++ : undefinedconst active = createContent(data.packs, builtIn)if (active.digest === data.digest) content = active}@@@ -315,9 +326,36 @@ async function start(): Promise<void> {guest: identity.token,...(sso ? { bearer: sso } : {}),})++ // The ~ console: Source vocabulary over this place's movement preset. The++ // server sends the authoritative values in welcome/cvar_state; the console++ // only ever requests a server cvar change.++ const baseMovement = movementParamsFor(movementStyleFor(content.world))++ const cvarRegistry = new CvarRegistry([...SOURCE_MOVEMENT_CVARS, ...CLIENT_CVARS])++ cvarRegistry.apply(movementCvarValues(baseMovement))++ const bindings = new Bindings({++ defaults: {++ ...DEFAULT_BINDS,++ // Scraplandia keeps its existing R/G verbs; surf binds them to the run.++ ...(surfInfo ? {} : { r: '+reload', g: '+drop' }),++ },++ })++ const gameConsole = new GameConsole({++ parent: uiRoot,++ cvars: cvarRegistry,++ bindings,++ configs: new ConfigStore(),++ })++ gameConsole.setStatusProvider(() => [++ `${name} · tick ${state.serverTick} · ${state.entities.size} entities`,++ `entity ${state.myEntityId || '—'} · rank ${state.myRank ?? 'player'} · ${connection.open ? 'connected' : 'offline'}`,++ ])++ gameConsole.onCvarRequest = (cvar, value, requestId) => {++ connection.send({ t: 'cvar_set', name: cvar, value, requestId })++ }++// Source's chat keys: Y talks to the room, U to the team. Capture-phase so// the game never reads them as movement/tool keys; the HUD owns the input-- // and its capture handoff.++ // and its capture handoff. The console's own textarea stops these keys too.document.addEventListener('keydown',(e) => {@@@ -362,7 +400,8 @@ async function start(): Promise<void> {// Gathering: point at whichever pile gives what is still missing.const hasWood = state.countOf('wood_log') > 0 || state.countOf('wood_plank') > 0const hasStone = state.countOf('stone') > 0-- const prefer = step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []++ const prefer =++ step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []const sightings: NodeSighting[] = []// Props the step names (the campfire, the workbench, the trading post) count as sightings too.const props = new Set(toward.props ?? [])@@@ -377,11 +416,21 @@ async function start(): Promise<void> {remaining: e.remaining ?? 1,})} else if (e.kind === 'prop' && props.has(e.def)) {-- sightings.push({ def: e.def, name: content.item(e.def)?.name ?? e.def, x: e.pos[0], z: e.pos[2], remaining: 1 })++ sightings.push({++ def: e.def,++ name: content.item(e.def)?.name ?? e.def,++ x: e.pos[0],++ z: e.pos[2],++ remaining: 1,++ })}}const zones = getMapOverride(content.world)?.zones ?? []-- const wanted = { nodes: [...(toward.nodes ?? []), ...props], ...(toward.zone ? { zone: toward.zone } : {}), prefer }++ const wanted = {++ nodes: [...(toward.nodes ?? []), ...props],++ ...(toward.zone ? { zone: toward.zone } : {}),++ prefer,++ }waypoint.update(from, player.viewYaw, pickTarget(from, wanted, sightings, zones))}}@@@ -403,7 +452,8 @@ async function start(): Promise<void> {// Touch on a coarse pointer, keyboard otherwise; a device with both// switches to whichever was used last, carrying the view angle across.-- const keyboardSource = new KeyboardInputSource(input)++ const keyboardSource = new KeyboardInputSource(input, bindings)++ gameConsole.onIntentCommand = (intent, down) => keyboardSource.setManualIntent(intent, down)const touchPad = new TouchPad()const touchSource = new TouchInputSource(touchPad)const touchControls = new TouchControls(uiRoot, canvas, touchPad)@@@ -473,11 +523,16 @@ async function start(): Promise<void> {y: placeSpawn.pos[1],z: placeSpawn.pos[2],},-- movementParamsFor(movementStyleFor(content.world)),++ baseMovement,)scene.activeCamera = player.camerareleaseCamera()environment.attachCamera(player.camera)++ // The server's authoritative cvar values are the only source for movement++ // parameter changes; prediction and simulation share the same conversion.++ gameConsole.onServerCvars = (values) => {++ player.setMovementParams(movementParamsWithCvars(baseMovement, values))++ }const interact = new InteractionController(physics,player,@@@ -496,6 +551,7 @@ async function start(): Promise<void> {connection,player,icons,++ console: gameConsole,}const fpBody = new FirstPersonBody(scene, content, appearance, player, state)const viewmodel = new Viewmodel(scene, content, player.camera, appearance)@@@ -555,27 +611,49 @@ async function start(): Promise<void> {// Your PB is the default ghost; the WR is the fallback before your first run.void loadSurfGhost('pb').then((ok) => (ok ? true : loadSurfGhost('wr')))}-- // Ride's keys. Capture-phase: InputTracker would otherwise read R as-- // reload and G as drop, neither of which exists in a surf place.-- document.addEventListener(-- 'keydown',-- (e) => {-- if (!surfInfo) return-- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return-- if (input.uiCapture) return-- if (e.code !== 'KeyR' && e.code !== 'KeyG') return-- e.preventDefault()-- e.stopImmediatePropagation()-- if (e.code === 'KeyR') {-- surfUi?.hideFinish()-- connection.send({ t: 'surf_restart' })-- } else {-- const on = surfGhost?.toggle() ?? false-- hud.toast(on ? 'Ghost on' : 'Ghost off', false)-- }-- },-- true,-- )++ const restartSurf = (): boolean => {++ if (!surfInfo) return false++ surfUi?.hideFinish()++ connection.send({ t: 'surf_restart' })++ return true++ }++ const toggleGhost = (): boolean => {++ if (!surfGhost) return false++ const on = surfGhost.toggle()++ hud.toast(on ? 'Ghost on' : 'Ghost off', false)++ return true++ }++ // Source binds, not key codes: the defaults are `r +restart` and++ // `g toggle_ghost` in a surf place, `r +reload`/`g +drop` elsewhere, and a++ // player's `bind` replaces them. Intent commands are read by the keyboard++ // source; this is only for one-shot verbs.++ const runBoundCommand = (command: string): boolean => {++ switch (command) {++ case '+restart':++ return restartSurf()++ case 'toggle_ghost':++ return toggleGhost()++ case 'toggleconsole':++ gameConsole.toggle()++ return true++ case '+reload':++ interact.handle({ kind: 'reload' })++ return true++ case '+drop':++ interact.handle({ kind: 'drop' })++ return true++ default:++ return false++ }++ }++ // Bindings switch on with their handler. Enabling them before boot has++ // reached this point would preventDefault on ~ with no one to toggle.++ input.bindings = bindings++ input.onBind = (command, down) => {++ if (!down || isIntentCommand(command)) return++ runBoundCommand(command)++ }++ gameConsole.onBoundCommand = runBoundCommandinteract.placementPose = () => (ghost.valid ? ghost.pose : null)interact.onPlacementRotate = (delta) => ghost.rotate(delta)@@@ -608,6 +686,14 @@ async function start(): Promise<void> {window.addEventListener('keydown', unlockAudio)hud.volume = () => sfx.getVolume()hud.onVolumeChange = (v) => sfx.setVolume(v)++ gameConsole.onLocalCvar = (cvar, value) => {++ if (cvar === 'volume') sfx.setVolume(Number(value))++ else if (cvar === 'crosshair') hud.setCrosshair(Number(value) !== 0)++ }++ const storedVolume = cvarRegistry.get('volume')++ if (typeof storedVolume === 'number') sfx.setVolume(storedVolume)++ const storedCrosshair = cvarRegistry.get('crosshair')++ if (typeof storedCrosshair === 'number') hud.setCrosshair(storedCrosshair !== 0)interact.onMelee = (target) => {sfx.swing()if (!target) return@@@ -641,13 +727,15 @@ async function start(): Promise<void> {let wasGrounded = truelet fallSpeed = 0-- hud.onUiCaptureChange = (captured) => {++ const applyUiCapture = (captured: boolean): void => {input.uiCapture = capturedtouchControls.setUiCapture(captured)if (captured) input.exitLock()-- // Straight back into the game when the menu closes; touch never locks.++ // Straight back into the game when every overlay closes; touch never locks.else if (switchable.current === keyboardSource) input.requestLock()}++ hud.onUiCaptureChange = (captured) => applyUiCapture(captured || gameConsole.isOpen)++ gameConsole.onOpenChange = (open) => applyUiCapture(open || hud.interfaceOpen)input.onAction = (action) => {switch (action.kind) {case 'toggle_menu':@@@ -685,7 +773,8 @@ async function start(): Promise<void> {state.events.on('actionResult', (r) => {// A drink the server accepted (eating answers the same action, so only right after this client asked to drink).-- if (r.action === 'consume' && r.ok && Date.now() - interact.lastDrinkAt < 3000) firstStepsRef?.onDrink()++ if (r.action === 'consume' && r.ok && Date.now() - interact.lastDrinkAt < 3000)++ firstStepsRef?.onDrink()if (r.action === 'use' && r.ok && interact.lastTargetWasPlayer) {interact.lastTargetWasPlayer = falsehud.flashHitmarker()@@@ -704,6 +793,17 @@ async function start(): Promise<void> {player.onTransfer()}state.apply(msg)++ if (msg.t === 'welcome') {++ // The authoritative values arrive with welcome; autoexec runs only++ // after they are applied, so a cfg that queries sv_gravity sees 800.++ gameConsole.applyServerCvars(msg.cvars ?? {}, msg.cvarsWritable ?? [])++ void gameConsole.runAutoexec()++ } else if (msg.t === 'cvar_state') {++ gameConsole.applyServerCvars(msg.values, msg.writable, {++ ...(msg.error !== undefined ? { error: msg.error } : {}),++ ...(msg.requestId !== undefined ? { requestId: msg.requestId } : {}),++ })++ }if (msg.t === 'transfer_result') {hud.toast(msg.accepted@@@ -796,7 +896,9 @@ async function start(): Promise<void> {hud.setStatus('could not join — try signing in again from the home page')}} else if (msg.reason === 'guest_one_character') {-- hud.setStatus('this server keeps one character per guest — sign in with OpenVibe to keep more')++ hud.setStatus(++ 'this server keeps one character per guest — sign in with OpenVibe to keep more',++ )} else if (msg.reason === 'auth_failed' || msg.reason === 'signed_out') {localStorage.removeItem('ovg_sso')hud.setStatus(@@@ -15,6 +15,7 @@ import { TypedEmitter } from '@openvibe/shared'export interface ClientStateEvents {welcome: { entityId: string; tickRate: number }++ cvars: { values: Record<string, number | boolean>; writable: string[]; error?: string }entityAdded: WireEntityentityRemoved: stringentityUpdated: WireEntity@@@ -62,6 +63,10 @@ export class ClientState {myEntityId = ''/** OpenVibe rank from welcome (owner/admin/moderator, null = player). */myRank: 'owner' | 'admin' | 'moderator' | null = null++ /** Server-authoritative cvars in Source units (welcome + cvar_state). */++ cvars: Record<string, number | boolean> = {}++ /** Server cvars this rank may change. */++ cvarsWritable: string[] = []myPlayerId = ''instanceId: string | null = nullinstanceCount = 1@@@ -120,7 +125,19 @@ export class ClientState {this.tickRate = msg.tickRatethis.snapshotRate = msg.snapshotRatethis.serverTick = msg.tick++ if (msg.cvars) this.cvars = msg.cvars++ if (msg.cvarsWritable) this.cvarsWritable = msg.cvarsWritablethis.events.emit('welcome', { entityId: msg.entityId, tickRate: msg.tickRate })++ this.events.emit('cvars', { values: this.cvars, writable: this.cvarsWritable })++ break++ case 'cvar_state':++ this.cvars = msg.values++ this.cvarsWritable = msg.writable++ this.events.emit('cvars', {++ values: msg.values,++ writable: msg.writable,++ ...(msg.error !== undefined ? { error: msg.error } : {}),++ })breakcase 'transfer_result':breaknnew file mode 100644@@@ -0,0 +1,137 @@++/* The ~ console, as Source draws it: a dark panel that drops from the top over the game, the log above, the input++ below, and completions as a list under the input. */++.ovg-console {++ position: fixed;++ z-index: 200;++ left: 0;++ right: 0;++ top: 0;++ display: flex;++ flex-direction: column;++ max-height: min(55vh, 34rem);++ padding: calc(0.55rem + env(safe-area-inset-top)) 0.75rem 0.65rem;++ border-bottom: 1px solid var(--border, #2a3340);++ background: color-mix(in srgb, var(--bg-secondary, #0a0e12) 94%, transparent);++ color: var(--text-primary, #e6e6e6);++ font:++ 400 0.82rem/1.45 ui-monospace,++ SFMono-Regular,++ Menlo,++ Consolas,++ monospace;++ backdrop-filter: blur(8px);++ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.45);++ animation: console-drop 0.14s ease-out;++}++@keyframes console-drop {++ from {++ transform: translateY(-12px);++ opacity: 0;++ }++}++@media (prefers-reduced-motion: reduce) {++ .ovg-console {++ animation: none;++ }++}++.ovg-console[hidden] {++ display: none;++}++.console-head {++ display: flex;++ align-items: baseline;++ justify-content: space-between;++ gap: 0.75rem;++ padding-bottom: 0.35rem;++ border-bottom: 1px solid color-mix(in srgb, var(--border, #2a3340) 70%, transparent);++}++.console-title {++ font-weight: 800;++ letter-spacing: 0.14em;++ text-transform: uppercase;++ color: var(--g-accent-text, #f0b040);++}++.console-keyhint {++ color: var(--text-secondary, #b0b0b8);++ font-size: 0.72rem;++}++.console-log {++ flex: 1 1 auto;++ min-height: 4rem;++ overflow-y: auto;++ padding: 0.4rem 0.1rem;++ overscroll-behavior: contain;++}++.console-line {++ white-space: pre-wrap;++ overflow-wrap: anywhere;++}++.console-line.is-error {++ color: #ff8d8d;++}++.console-line.is-echo {++ color: var(--text-secondary, #b0b0b8);++}++.console-form {++ display: flex;++ align-items: center;++ gap: 0.45rem;++ padding-top: 0.4rem;++ border-top: 1px solid color-mix(in srgb, var(--border, #2a3340) 70%, transparent);++}++.console-prompt {++ color: var(--g-accent-text, #f0b040);++ font-weight: 700;++}++.console-input {++ flex: 1 1 auto;++ min-width: 0;++ padding: 0.18rem 0;++ border: 0;++ outline: 0;++ background: transparent;++ color: inherit;++ font: inherit;++ caret-color: var(--g-accent-text, #f0b040);++}++.console-suggestion {++ color: var(--text-secondary, #b0b0b8);++ font-size: 0.7rem;++}++.console-completions {++ display: flex;++ flex-direction: column;++ max-height: 9.5rem;++ overflow-y: auto;++ margin: 0.35rem 0 0;++ padding: 0.2rem 0;++ list-style: none;++ border: 1px solid var(--border, #2a3340);++ border-radius: 0.3rem;++ background: color-mix(in srgb, var(--bg-primary, #05080b) 92%, transparent);++}++.console-completions[hidden] {++ display: none;++}++.console-completion {++ padding: 0.1rem 0.5rem;++ color: var(--text-secondary, #b0b0b8);++ cursor: pointer;++}++.console-completion.is-selected {++ background: color-mix(in srgb, var(--g-accent, #f0b040) 18%, transparent);++ color: var(--text-primary, #e6e6e6);++}++.console-completion:hover {++ color: var(--text-primary, #e6e6e6);++}++++@media (max-width: 640px) {++ .ovg-console {++ max-height: 62vh;++ font-size: 0.78rem;++ }++ .console-keyhint {++ display: none;++ }++}nnew file mode 100644@@@ -0,0 +1,206 @@++// @vitest-environment jsdom++import { afterEach, describe, expect, it } from 'vitest'++import {++ AUTOEXEC_CONFIG,++ Bindings,++ ConfigStore,++ CvarRegistry,++ CLIENT_CVARS,++ SOURCE_MOVEMENT_CVARS,++ type KeyValueStorage,++} from '@openvibe/shared'++import { movementCvarValues, SURF_MOVEMENT } from '@openvibe/gameplay'++import { GameConsole } from './console.js'++++class MemoryStorage implements KeyValueStorage {++ private readonly data = new Map<string, string>()++ get length(): number {++ return this.data.size++ }++ getItem(key: string): string | null {++ return this.data.get(key) ?? null++ }++ setItem(key: string, value: string): void {++ this.data.set(key, value)++ }++ removeItem(key: string): void {++ this.data.delete(key)++ }++ key(index: number): string | null {++ return [...this.data.keys()][index] ?? null++ }++}++++const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))++++function makeConsole(options: { readOnly?: boolean } = {}) {++ const storage = new MemoryStorage()++ const cvars = new CvarRegistry([...SOURCE_MOVEMENT_CVARS, ...CLIENT_CVARS])++ cvars.apply(movementCvarValues(SURF_MOVEMENT))++ const bindings = new Bindings({ storage })++ const configs = new ConfigStore({ storage })++ const console = new GameConsole({++ parent: document.body,++ cvars,++ bindings,++ configs,++ storage,++ ...(options.readOnly ? { readOnly: true } : {}),++ })++ const input = console.root.querySelector('.console-input') as HTMLInputElement++ const log = console.root.querySelector('.console-log') as HTMLElement++ const key = (target: EventTarget, code: string, value = '') =>++ target.dispatchEvent(++ new KeyboardEvent('keydown', {++ code,++ key: value || code,++ bubbles: true,++ cancelable: true,++ }),++ )++ const run = async (line: string) => {++ input.value = line++ key(input, 'Enter', 'Enter')++ await flush()++ }++ return { console, storage, cvars, bindings, configs, input, log, key, run }++}++++let mounted: GameConsole | null = null++afterEach(() => {++ mounted?.destroy()++ mounted = null++ document.body.innerHTML = ''++})++++describe('developer console overlay', () => {++ it('opens with ~, accepts typed commands and closes with Escape', async () => {++ const { console, root } = (() => {++ const made = makeConsole()++ mounted = made.console++ return { console: made.console, root: made.console.root }++ })()++ document.body.dispatchEvent(++ new KeyboardEvent('keydown', {++ code: 'Backquote',++ key: '`',++ bubbles: true,++ cancelable: true,++ }),++ )++ expect(root.hidden).toBe(false)++ expect(console.isOpen).toBe(true)++ await flush()++ const input = root.querySelector('.console-input') as HTMLInputElement++ expect(document.activeElement).toBe(input)++++ input.value = 'help sv_gravity'++ input.dispatchEvent(++ new KeyboardEvent('keydown', {++ code: 'Enter',++ key: 'Enter',++ bubbles: true,++ cancelable: true,++ }),++ )++ await flush()++ expect(root.querySelector('.console-log')?.textContent).toContain('sv_gravity = 800')++++ input.dispatchEvent(++ new KeyboardEvent('keydown', {++ code: 'Escape',++ key: 'Escape',++ bubbles: true,++ cancelable: true,++ }),++ )++ expect(root.hidden).toBe(true)++ expect(console.isOpen).toBe(false)++ })++++ it('keeps history and completes names with Tab', async () => {++ const { console, root, input, key, run } = (() => {++ const made = makeConsole()++ mounted = made.console++ return { ...made, root: made.console.root }++ })()++ console.open()++ await run('help')++ await run('sv_gravity')++ key(input, 'ArrowUp', 'ArrowUp')++ expect(input.value).toBe('sv_gravity')++ key(input, 'ArrowUp', 'ArrowUp')++ expect(input.value).toBe('help')++ key(input, 'ArrowDown', 'ArrowDown')++ expect(input.value).toBe('sv_gravity')++++ input.value = 'sv_grav'++ input.setSelectionRange(input.value.length, input.value.length)++ key(input, 'Tab', 'Tab')++ expect(input.value).toBe('sv_gravity')++ expect(root.querySelector('.console-completions')?.childElementCount).toBeGreaterThan(0)++ })++++ it('never changes a server cvar without permission or an authoritative reply', async () => {++ const { console, cvars } = (() => {++ const made = makeConsole()++ mounted = made.console++ return made++ })()++ const requests: { name: string; value: number | boolean; requestId: number }[] = []++ console.onCvarRequest = (name, value, requestId) => requests.push({ name, value, requestId })++ const values = movementCvarValues(SURF_MOVEMENT)++ console.applyServerCvars(values, [])++++ await console.executeLine('sv_gravity 500')++ expect(requests).toEqual([])++ expect(cvars.get('sv_gravity')).toBe(800)++ expect(console.root.querySelector('.console-log')?.textContent).toContain('server-controlled')++++ console.applyServerCvars(values, ['sv_gravity'])++ await console.executeLine('sv_gravity 500')++ expect(requests).toHaveLength(1)++ expect(requests[0]).toMatchObject({ name: 'sv_gravity', value: 500 })++ // Still 800: a request is not a value.++ expect(cvars.get('sv_gravity')).toBe(800)++++ console.applyServerCvars({ ...values, sv_gravity: 500 }, ['sv_gravity'], {++ requestId: requests[0]!.requestId,++ })++ expect(cvars.get('sv_gravity')).toBe(500)++ })++++ it('applies exec practice.cfg and autoexec after welcome', async () => {++ const { console, bindings, configs } = (() => {++ const made = makeConsole()++ mounted = made.console++ return made++ })()++ expect(configs.write('practice', 'bind space +duck').ok).toBe(true)++ await console.executeLine('exec practice.cfg')++ expect(bindings.commandForCode('Space')).toBe('+duck')++ expect(console.root.querySelector('.console-log')?.textContent).toContain(++ 'Executing practice.cfg',++ )++++ expect(configs.write(AUTOEXEC_CONFIG, 'bind space +jump').ok).toBe(true)++ await console.runAutoexec()++ expect(bindings.commandForCode('Space')).toBe('+jump')++ })++++ it('is read-only in the casual mount', async () => {++ const { console, cvars, bindings } = (() => {++ const made = makeConsole({ readOnly: true })++ mounted = made.console++ return made++ })()++ console.applyServerCvars(movementCvarValues(SURF_MOVEMENT), ['sv_gravity'])++ await console.executeLine('sv_gravity 500')++ expect(cvars.get('sv_gravity')).toBe(800)++ expect(console.root.querySelector('.console-log')?.textContent).toContain('read-only')++ await console.executeLine('bind space +duck')++ expect(bindings.commandForCode('Space')).toBe('+jump')++ await console.executeLine('cvarlist sv_gravity')++ expect(console.root.querySelector('.console-log')?.textContent).toContain('sv_gravity = 800')++ })++})nnew file mode 100644@@@ -0,0 +1,729 @@++/**++ * The Source-style developer console (~).++ *++ * A DOM overlay with a log, an input line, history and Tab completion. It is++ * deliberately independent of the game: the main client and the casual page++ * mount the same component, and the command table decides what each can do.++ * Server cvars are only ever requested here; the authoritative value arrives++ * in welcome/cvar_state and is applied by the caller.++ */++import {++ AUTOEXEC_CONFIG,++ Bindings,++ CommandRegistry,++ ConfigStore,++ CVARS_STORAGE_KEY,++ HISTORY_STORAGE_KEY,++ browserStorage,++ intentCommand,++ parseCommandLine,++ type CommandDef,++ type CommandResult,++ type CvarRegistry,++ type CvarSpec,++ type CvarValue,++ type KeyValueStorage,++} from '@openvibe/shared'++import './console.css'++++export interface ConsoleContext {++ raw: string++ line: number++ echo(text: string | readonly string[]): void++ error(text: string): void++}++++export interface ConsoleOptions {++ parent?: HTMLElement++ cvars: CvarRegistry++ bindings?: Bindings++ configs?: ConfigStore++ storage?: KeyValueStorage | null++ /** The casual page: only commands marked `readOnly` are registered. */++ readOnly?: boolean++ commands?: readonly CommandDef<ConsoleContext>[]++ /** Status lines for `status`. */++ status?: () => string | readonly string[]++}++++const MAX_LOG_LINES = 600++const MAX_HISTORY = 100++++type PrintKind = 'normal' | 'error' | 'echo'++++/** Extract the lines/error/close intent from any command result shape. */++function resultOf(result: CommandResult): { lines: string[]; error?: string; close?: boolean } {++ if (result === undefined) return { lines: [] }++ if (typeof result === 'string') return { lines: [result] }++ if (Array.isArray(result)) return { lines: [...(result as readonly string[])] }++ const object = result as { lines?: readonly string[]; error?: string; close?: boolean }++ return {++ lines: [...(object.lines ?? [])],++ ...(object.error !== undefined ? { error: object.error } : {}),++ ...(object.close ? { close: true } : {}),++ }++}++++export class GameConsole {++ readonly root: HTMLElement++ readonly bindings: Bindings++ readonly configs: ConfigStore++ readonly cvars: CvarRegistry++ readonly commands: CommandRegistry<ConsoleContext>++++ private readonly logEl: HTMLElement++ private readonly inputEl: HTMLInputElement++ private readonly completionsEl: HTMLElement++ private readonly hintEl: HTMLElement++ private readonly storage: KeyValueStorage | null++ private readonly readOnly: boolean++ private readonly history: string[]++ private historyIndex: number | null = null++ private completionIndex = 0++ private completionSource = ''++ private openState = false++ private autoexecRan = false++ private requestSeq = 1++ private readonly pending = new Map<number, { name: string; value: number | boolean }>()++ private readonly writable = new Set<string>()++++ /** Send a server cvar request; the caller owns the connection. */++ onCvarRequest: ((name: string, value: number | boolean, requestId: number) => void) | null = null++ /** Authoritative cvar values arrived (welcome or cvar_state). */++ onServerCvars: ((values: Record<string, number | boolean>) => void) | null = null++ /** A `+jump` / `-jump` typed in the console feeds the intent source. */++ onIntentCommand: ((name: string, down: boolean) => void) | null = null++ /** One-shot bound commands such as `toggle_ghost` and `+restart`. */++ onBoundCommand: ((command: string) => boolean) | null = null++ /** The overlay opened or closed: the caller releases/restores pointer lock. */++ onOpenChange: ((open: boolean) => void) | null = null++ /** A client cvar changed locally (volume, crosshair, fps cap). */++ onLocalCvar: ((name: string, value: CvarValue) => void) | null = null++++ constructor(options: ConsoleOptions) {++ this.cvars = options.cvars++ this.bindings = options.bindings ?? new Bindings()++ this.configs = options.configs ?? new ConfigStore()++ this.storage = options.storage === undefined ? browserStorage() : options.storage++ this.readOnly = options.readOnly ?? false++ this.history = this.loadHistory()++++ this.root = document.createElement('section')++ this.root.className = 'ovg-console'++ this.root.hidden = true++ this.root.setAttribute('role', 'dialog')++ this.root.setAttribute('aria-label', 'Developer console')++++ const head = document.createElement('header')++ head.className = 'console-head'++ const title = document.createElement('span')++ title.className = 'console-title'++ title.textContent = 'Console'++ const hint = document.createElement('span')++ hint.className = 'console-keyhint'++ hint.textContent = '~ close · Tab complete · ↑↓ history'++ head.append(title, hint)++++ this.logEl = document.createElement('div')++ this.logEl.className = 'console-log'++ this.logEl.setAttribute('role', 'log')++ this.logEl.setAttribute('aria-live', 'polite')++++ const form = document.createElement('form')++ form.className = 'console-form'++ const prompt = document.createElement('span')++ prompt.className = 'console-prompt'++ prompt.textContent = ']'++ prompt.setAttribute('aria-hidden', 'true')++ this.inputEl = document.createElement('input')++ this.inputEl.className = 'console-input'++ this.inputEl.type = 'text'++ this.inputEl.autocomplete = 'off'++ this.inputEl.spellcheck = false++ this.inputEl.setAttribute('aria-label', 'Console command')++ this.inputEl.setAttribute('autocapitalize', 'off')++ this.hintEl = document.createElement('output')++ this.hintEl.className = 'console-suggestion'++ form.append(prompt, this.inputEl, this.hintEl)++++ this.completionsEl = document.createElement('ul')++ this.completionsEl.className = 'console-completions'++ this.completionsEl.hidden = true++ this.completionsEl.setAttribute('aria-label', 'Completions')++++ this.root.append(head, this.logEl, form, this.completionsEl)++ const parent = options.parent ?? document.body++ parent.append(this.root)++++ this.commands = new CommandRegistry<ConsoleContext>()++ this.registerCoreCommands()++ if (!this.readOnly) this.registerEditCommands()++ for (const command of options.commands ?? []) {++ if (this.readOnly && !command.readOnly) continue++ this.commands.register(command)++ }++ if (options.status) this.status = options.status++++ this.loadLocalCvars()++ this.inputEl.addEventListener('keydown', (e) => this.onInputKeyDown(e))++ this.inputEl.addEventListener('input', () => this.resetCompletion())++ form.addEventListener('submit', (e) => {++ e.preventDefault()++ void this.submit()++ })++ // A press inside the console is UI, not a game click.++ this.root.addEventListener('pointerdown', (e) => e.stopPropagation())++ this.completionsEl.addEventListener('pointerdown', (e) => e.stopPropagation())++ window.addEventListener('keydown', this.onWindowKeyDown)++ window.addEventListener('keyup', this.onWindowKeyUp)++ }++++ private status: () => string | readonly string[] = () => []++++ get isOpen(): boolean {++ return this.openState++ }++++ open(): void {++ if (this.openState) return++ this.openState = true++ this.root.hidden = false++ this.root.classList.add('is-open')++ this.onOpenChange?.(true)++ // After the current key event, so the same ~ does not race focus.++ queueMicrotask(() => this.inputEl.focus())++ }++++ close(): void {++ if (!this.openState) return++ this.openState = false++ this.root.hidden = true++ this.root.classList.remove('is-open')++ this.completionsEl.hidden = true++ this.inputEl.blur()++ this.onOpenChange?.(false)++ }++++ toggle(): void {++ if (this.openState) this.close()++ else this.open()++ }++++ destroy(): void {++ window.removeEventListener('keydown', this.onWindowKeyDown)++ window.removeEventListener('keyup', this.onWindowKeyUp)++ this.root.remove()++ }++++ registerCommand(command: CommandDef<ConsoleContext>): void {++ if (this.readOnly && !command.readOnly) return++ this.commands.register(command)++ }++++ /** Set the `status` command's text provider after the game state exists. */++ setStatusProvider(provider: () => string | readonly string[]): void {++ this.status = provider++ }++++ /** Print a line (or lines) into the log; errors are red, echoes dim. */++ print(lines: string | readonly string[], kind: PrintKind = 'normal'): void {++ const list = typeof lines === 'string' ? [lines] : lines++ for (const text of list) {++ const line = document.createElement('div')++ line.className = `console-line${kind === 'error' ? ' is-error' : kind === 'echo' ? ' is-echo' : ''}`++ line.textContent = text++ this.logEl.append(line)++ }++ while (this.logEl.childElementCount > MAX_LOG_LINES) this.logEl.firstElementChild?.remove()++ this.logEl.scrollTop = this.logEl.scrollHeight++ }++++ /** Apply the server's authoritative values; called after welcome and on cvar_state. */++ applyServerCvars(++ values: Record<string, number | boolean>,++ writable: readonly string[],++ options: { error?: string; requestId?: number } = {},++ ): void {++ this.writable.clear()++ for (const name of writable) this.writable.add(name)++ this.cvars.apply(values)++ this.onServerCvars?.(values)++ const pending =++ options.requestId !== undefined ? this.pending.get(options.requestId) : undefined++ if (options.requestId !== undefined) this.pending.delete(options.requestId)++ if (options.error) {++ this.print(options.error, 'error')++ return++ }++ if (pending) this.print(`${pending.name} = ${this.cvars.formatValue(pending.name)}`)++ }++++ /** Run `autoexec.cfg` once, after the server's welcome has been applied. */++ async runAutoexec(): Promise<void> {++ if (this.autoexecRan || this.readOnly) return++ this.autoexecRan = true++ const text = this.configs.read(AUTOEXEC_CONFIG)++ if (!text) return++ this.print(`Executing ${AUTOEXEC_CONFIG}…`, 'echo')++ await this.runConfig(text, AUTOEXEC_CONFIG, 0)++ }++++ /** Execute one line as if typed, including `;`-separated commands. */++ async executeLine(line: string): Promise<void> {++ const commands = parseCommandLine(line)++ for (const command of commands) await this.execute(command)++ }++++ private async submit(): Promise<void> {++ const line = this.inputEl.value.trim()++ if (!line) {++ this.inputEl.value = ''++ this.resetCompletion()++ return++ }++ this.print(`] ${line}`, 'echo')++ this.remember(line)++ this.inputEl.value = ''++ this.historyIndex = null++ this.resetCompletion()++ await this.executeLine(line)++ this.inputEl.focus()++ }++++ private async execute(command: ReturnType<typeof parseCommandLine>[number]): Promise<void> {++ const context: ConsoleContext = {++ raw: command.raw,++ line: command.line,++ echo: (text) => this.print(text),++ error: (text) => this.print(text, 'error'),++ }++ // `+jump`/`-jump` are intents, not commands: they feed the same bits a++ // bound key does. This also works for `exec` scripts.++ const intent = intentCommand(command.name)++ if (intent) {++ if (this.readOnly) {++ context.error('read-only console: intents are unavailable')++ return++ }++ this.onIntentCommand?.(intent.name, intent.down)++ this.print(`${intent.down ? '+' : '-'}${intent.name.toLowerCase()}`, 'echo')++ return++ }++ const def = this.commands.get(command.name)++ if (def) {++ try {++ const result = resultOf(await def.run(command.args, context))++ if (result.error) this.print(result.error, 'error')++ if (result.lines.length) this.print(result.lines)++ if (result.close) this.close()++ } catch (err) {++ this.print(err instanceof Error ? err.message : 'command failed', 'error')++ }++ return++ }++ const spec = this.cvars.spec(command.name)++ if (spec) {++ this.runCvar(spec, command.args)++ return++ }++ if (this.onBoundCommand?.(command.raw)) return++ this.print(`Unknown command "${command.name}". Try help or find ${command.name}.`, 'error')++ }++++ private runCvar(spec: CvarSpec, args: string[]): void {++ if (!args.length) {++ this.print(++ `${spec.name} = ${this.cvars.formatValue(spec.name)}${spec.unit ? ` ${spec.unit}` : ''}`,++ )++ return++ }++ if (this.readOnly) {++ this.print('read-only console: cvars cannot be changed here', 'error')++ return++ }++ const raw = args.join(' ')++ if (spec.flags.includes('client')) {++ const result = this.cvars.set(spec.name, raw)++ if (!result.ok) {++ this.print(result.error ?? `invalid ${spec.name}`, 'error')++ return++ }++ this.persistLocalCvars()++ this.onLocalCvar?.(spec.name, result.value!)++ this.print(`${spec.name} = ${this.cvars.formatValue(spec.name)}`)++ return++ }++ // Server cvars are requests. Never change the local copy before the++ // authoritative cvar_state arrives: that would let a client predict a++ // value the server refused.++ if (!this.writable.has(spec.name)) {++ this.print(`${spec.name} is server-controlled; you do not have permission`, 'error')++ return++ }++ const checked = this.cvars.check(spec.name, raw)++ if (!checked.ok || (typeof checked.value !== 'number' && typeof checked.value !== 'boolean')) {++ this.print(checked.error ?? `${spec.name} expects a number`, 'error')++ return++ }++ const requestId = this.requestSeq++++ this.pending.set(requestId, { name: spec.name, value: checked.value })++ if (!this.onCvarRequest) {++ this.pending.delete(requestId)++ this.print('not connected: server cvars are unavailable', 'error')++ return++ }++ this.onCvarRequest(spec.name, checked.value, requestId)++ this.print(`Requesting ${spec.name} = ${checked.value} (server)`, 'echo')++ }++++ private async runConfig(text: string, name: string, depth: number): Promise<boolean> {++ if (depth > 8) {++ this.print(`exec ${name}: nested configs are too deep`, 'error')++ return false++ }++ let ok = true++ for (const command of parseCommandLine(text)) {++ if (command.name.toLowerCase() === 'exec' && depth >= 8) {++ this.print('exec: nested configs are too deep', 'error')++ return false++ }++ // Re-dispatch through the regular path so binds/cvars/commands behave++ // exactly as if typed.++ try {++ await this.execute(command)++ } catch (err) {++ ok = false++ this.print(++ `${name}:${command.line}: ${err instanceof Error ? err.message : 'failed'}`,++ 'error',++ )++ }++ }++ return ok++ }++++ private loadHistory(): string[] {++ try {++ const raw = this.storage?.getItem(HISTORY_STORAGE_KEY) ?? null++ if (!raw) return []++ const parsed = JSON.parse(raw) as unknown++ return Array.isArray(parsed)++ ? parsed.filter((line): line is string => typeof line === 'string').slice(-MAX_HISTORY)++ : []++ } catch {++ return []++ }++ }++++ private remember(line: string): void {++ if (this.history[this.history.length - 1] !== line) this.history.push(line)++ while (this.history.length > MAX_HISTORY) this.history.shift()++ try {++ this.storage?.setItem(HISTORY_STORAGE_KEY, JSON.stringify(this.history))++ } catch {++ // History is a convenience; a refused localStorage must not break the console.++ }++ }++++ private loadLocalCvars(): void {++ try {++ const raw = this.storage?.getItem(CVARS_STORAGE_KEY) ?? null++ if (!raw) return++ const parsed = JSON.parse(raw) as Record<string, unknown>++ for (const [name, value] of Object.entries(parsed)) {++ const spec = this.cvars.spec(name)++ if (!spec?.flags.includes('client')) continue++ if (typeof value !== 'number' && typeof value !== 'boolean' && typeof value !== 'string')++ continue++ this.cvars.set(spec.name, value)++ }++ } catch {++ // Corrupt settings fall back to the defaults.++ }++ }++++ private persistLocalCvars(): void {++ if (!this.storage) return++ const values = this.cvars.snapshot((spec) => spec.flags.includes('client'))++ try {++ this.storage.setItem(CVARS_STORAGE_KEY, JSON.stringify(values))++ } catch {++ // Private mode/quota: the value still applies this session.++ }++ }++++ private onWindowKeyDown = (e: KeyboardEvent): void => {++ // A bound `~ toggleconsole` already prevented the default and toggled++ // through InputTracker.onBind; do not toggle twice.++ if (e.defaultPrevented) return++ if (this.openState) {++ if (e.code === 'Escape' || e.code === 'Backquote') {++ e.preventDefault()++ this.close()++ }++ return++ }++ if (e.code === 'Backquote') {++ e.preventDefault()++ this.open()++ }++ }++++ private onWindowKeyUp = (e: KeyboardEvent): void => {++ if (!this.openState) return++ if (e.code === 'Backquote' || e.code === 'Escape') e.preventDefault()++ }++++ private onInputKeyDown(e: KeyboardEvent): void {++ if (e.key === 'Enter') {++ e.preventDefault()++ void this.submit()++ return++ }++ if (e.key === 'Tab') {++ e.preventDefault()++ this.complete()++ return++ }++ if (e.key === 'ArrowUp') {++ e.preventDefault()++ this.historyStep(-1)++ return++ }++ if (e.key === 'ArrowDown') {++ e.preventDefault()++ this.historyStep(1)++ return++ }++ if (e.key === 'Escape') {++ e.preventDefault()++ this.close()++ }++ }++++ private historyStep(direction: -1 | 1): void {++ if (!this.history.length) return++ if (this.historyIndex === null) {++ if (direction === 1) return++ this.historyIndex = this.history.length - 1++ } else {++ const next = this.historyIndex + direction++ if (next < 0) {++ this.historyIndex = 0++ } else if (next >= this.history.length) {++ this.historyIndex = null++ this.inputEl.value = ''++ this.resetCompletion()++ return++ } else {++ this.historyIndex = next++ }++ }++ this.inputEl.value = this.history[this.historyIndex] ?? ''++ this.resetCompletion()++ this.inputEl.setSelectionRange(this.inputEl.value.length, this.inputEl.value.length)++ }++++ private complete(): void {++ const value = this.inputEl.value++ const start = value.lastIndexOf(' ', this.inputEl.selectionStart ?? value.length) + 1++ const prefix = value.slice(start)++ const source = `${start}:${prefix}`++ if (source !== this.completionSource) {++ this.completionSource = source++ this.completionIndex = -1++ }++ const candidates = this.completionCandidates(prefix)++ if (!candidates.length) {++ this.completionsEl.hidden = true++ return++ }++ this.completionIndex = (this.completionIndex + 1) % candidates.length++ const candidate = candidates[this.completionIndex]!++ this.inputEl.value = value.slice(0, start) + candidate + value.slice(start + prefix.length)++ this.inputEl.setSelectionRange(start + candidate.length, start + candidate.length)++ this.renderCompletions(candidates.slice(0, 10), candidate)++ }++++ private completionCandidates(prefix: string): string[] {++ const names = new Set<string>()++ for (const name of this.commands.matching(prefix)) names.add(name)++ for (const name of this.cvars.matching(prefix)) names.add(name)++ return [...names].sort()++ }++++ private renderCompletions(candidates: readonly string[], selected: string): void {++ this.completionsEl.replaceChildren(++ ...candidates.map((name) => {++ const item = document.createElement('li')++ item.className = `console-completion${name === selected ? ' is-selected' : ''}`++ item.textContent = name++ item.addEventListener('pointerdown', (e) => {++ e.preventDefault()++ const value = this.inputEl.value++ const start = value.lastIndexOf(' ', this.inputEl.selectionStart ?? value.length) + 1++ const end = this.inputEl.selectionStart ?? value.length++ this.inputEl.value = value.slice(0, start) + name + value.slice(end)++ this.inputEl.setSelectionRange(start + name.length, start + name.length)++ this.completionsEl.hidden = true++ this.inputEl.focus()++ })++ return item++ }),++ )++ this.completionsEl.hidden = false++ }++++ private resetCompletion(): void {++ this.completionIndex = 0++ this.completionSource = ''++ this.completionsEl.hidden = true++ }++++ private registerCoreCommands(): void {++ this.commands.register({++ name: 'help',++ aliases: ['?'],++ readOnly: true,++ usage: 'help [command|cvar|text]',++ help: 'List commands and cvars, or explain one name. With text, same as find.',++ run: (args, _context) => {++ const query = args[0]++ if (!query) {++ const commands = this.commands.all().map((command) => command.name)++ return [++ `Commands: ${commands.join(', ')}`,++ `Cvars: ${this.cvars.all().length} (cvarlist) · search with find <text>`,++ ]++ }++ const def = this.commands.get(query)++ if (def) return this.commands.help(def)++ const spec = this.cvars.spec(query)++ if (spec) return this.cvars.help(spec)++ return this.findLines(args.join(' '))++ },++ })++ this.commands.register({++ name: 'find',++ readOnly: true,++ usage: 'find <text>',++ help: 'Search command names, cvar names and help text.',++ run: (args, _context) =>++ args.length ? this.findLines(args.join(' ')) : 'usage: find <text>',++ })++ this.commands.register({++ name: 'cvarlist',++ readOnly: true,++ usage: 'cvarlist [prefix]',++ help: 'List server and client cvars with their current values.',++ run: (args, _context) => {++ const prefix = (args[0] ?? '').toLowerCase()++ const specs = this.cvars.all().filter((spec) => spec.name.startsWith(prefix))++ if (!specs.length) return `no cvars match "${prefix}"`++ return specs.map(++ (spec) =>++ `${spec.name} = ${this.cvars.formatValue(spec.name)}${spec.unit ? ` ${spec.unit}` : ''} [${spec.flags.join(' ')}]`,++ )++ },++ })++ this.commands.register({++ name: 'clear',++ aliases: ['cls'],++ readOnly: true,++ help: 'Clear the console log.',++ run: () => {++ this.logEl.replaceChildren()++ },++ })++ this.commands.register({++ name: 'echo',++ readOnly: true,++ usage: 'echo <text>',++ help: 'Print text to the console.',++ run: (args) => args.join(' '),++ })++ this.commands.register({++ name: 'status',++ readOnly: true,++ help: 'Show connection and world status supplied by the page.',++ run: () => this.status(),++ })++ this.commands.register({++ name: 'toggleconsole',++ readOnly: true,++ help: 'Close the console. Bound to ~ by default.',++ run: () => ({ close: true }),++ })++ }++++ private registerEditCommands(): void {++ this.commands.register({++ name: 'bind',++ usage: 'bind [key] [command]',++ help: 'Show, set or list key bindings. Persisted in this browser.',++ run: (args) => {++ if (!args.length) {++ return this.bindings.entries().map(({ key, command }) => `${key} = ${command}`)++ }++ const key = args[0]!++ if (args.length === 1) {++ const bound = this.bindings.get(key)++ return bound ? `${key} = ${bound}` : `${key} is not bound`++ }++ const command = args.slice(1).join(' ')++ const result = this.bindings.bind(key, command)++ return result.ok ? `${result.key} = ${command}` : { error: result.error }++ },++ })++ this.commands.register({++ name: 'unbind',++ usage: 'unbind <key>',++ help: 'Remove a key binding.',++ run: (args) => {++ if (!args.length) return 'usage: unbind <key>'++ const result = this.bindings.unbind(args[0]!)++ return result.ok ? `${result.key} is unbound` : { error: result.error }++ },++ })++ this.commands.register({++ name: 'writecfg',++ aliases: ['savecfg'],++ usage: 'writecfg <name.cfg> "<commands>"',++ help: 'Save commands to a virtual cfg in this browser (quotes keep ;-separated commands).',++ run: (args) => {++ if (args.length < 2) return 'usage: writecfg <name.cfg> "<commands>"'++ const result = this.configs.write(args[0]!, args.slice(1).join(' '))++ return result.ok ? `wrote ${result.name}` : { error: result.error }++ },++ })++ this.commands.register({++ name: 'exec',++ usage: 'exec <name.cfg>',++ help: 'Run a saved cfg from this browser (exec practice.cfg).',++ run: async (args, context) => {++ if (!args.length) return 'usage: exec <name.cfg>'++ const name = this.configs.normalize(args[0]!)++ if (!name) return { error: `invalid config name "${args[0]}"` }++ const text = this.configs.read(name)++ if (text === null) return { error: `couldn't exec ${name}` }++ this.print(`Executing ${name}…`, 'echo')++ await this.runConfig(text, name, 1)++ context.echo(`exec ${name}: done`)++ },++ })++ }++++ private findLines(text: string): string[] {++ const needle = text.trim()++ if (!needle) return ['usage: find <text>']++ const commands = this.commands.find(needle)++ const specs = this.cvars.find(needle)++ if (!commands.length && !specs.length) return [`nothing found for "${needle}"`]++ return [++ ...commands.map((command) => `[cmd] ${command.name} — ${command.help}`),++ ...specs.map(++ (spec) => `[cvar] ${spec.name} = ${this.cvars.formatValue(spec.name)} — ${spec.help}`,++ ),++ ]++ }++}@@@ -46,6 +46,11 @@ export class Hud {/** Source-style chat (Y/U); the HUD owns its mount and capture handoff. */readonly chat: ChatmenuOpen = false++++ /** Any HUD panel that owns input right now (the menu, a shop, a container). */++ get interfaceOpen(): boolean {++ return this.menuOpen || this.shopOpen || this.openContainerId !== null++ }private activeTab: MenuTab = 'inventory'private dragFrom: number | null = nullonUiCaptureChange: ((captured: boolean) => void) | null = null@@@ -24,6 +24,7 @@ import {ProductionSystem,ReplicationSystem,SessionsSystem,++ SettingsSystem,SocialSystem,SurfSystem,VehicleSystem,@@@ -83,6 +84,7 @@ export class GameServer {)const ctx = this.ctxconst sessions = new SessionsSystem(ctx)++ const settings = new SettingsSystem(ctx)const chat = new ChatSystem(ctx)const movement = new MovementSystem(ctx)// Runs see this tick's authoritative positions: construct after movement,@@@ -109,6 +111,7 @@ export class GameServer {this.systems = {sessions,++ settings,chat,movement,surf,@@@ -134,6 +137,7 @@ export class GameServer {ctx.systems = this.systemsthis.ordered = [sessions,++ settings,chat,movement,surf,@@@ -17,7 +17,13 @@ import { SurfSystem } from './systems/surf.js'*/function surfWorld() {const override = loadSurfOverride('surf_lagoon')-- const world = { id: 'surf_lagoon', movement: 'surf', zones: [], spawnPoint: [0, 2, 0], spawnYaw: 0 }++ const world = {++ id: 'surf_lagoon',++ movement: 'surf',++ zones: [],++ spawnPoint: [0, 2, 0],++ spawnYaw: 0,++ }setMapOverride(override, world.id)return { world, override }}@@@ -72,6 +78,7 @@ function ctxFor(session: any, respawns: string[], sent: unknown[]) {bodyOf: () => undefined,},systems: {++ settings: { movementParams: (base: unknown) => base },vehicles: { driveVehicle() {} },combat: { respawn: (_s: unknown, died: boolean) => respawns.push(died ? 'died' : 'void') },},@@@ -28,6 +28,7 @@ import type { PhysicsSystem } from './physics.js'import type { ProductionSystem } from './production.js'import type { ReplicationSystem } from './replication.js'import type { SessionsSystem } from './sessions.js'++import type { SettingsSystem } from './settings.js'import type { SurfSystem } from './surf.js'import type { SurfRecords } from '../surfRecords.js'import type { SocialSystem } from './social.js'@@@ -192,6 +193,7 @@ export class SessionRegistry {*/export interface ServerSystems {sessions: SessionsSystem++ settings: SettingsSystemchat: ChatSystemmovement: MovementSystemsurf: SurfSystem@@@ -16,6 +16,7 @@ export { buildHandlerTable } from './system.js'export type { HandlerMap, MessageHandler, System } from './system.js'export { SessionsSystem } from './sessions.js'++export { SettingsSystem, canChangeServerCvars, SERVER_CVAR_ALLOWLIST } from './settings.js'export { ChatSystem } from '../chatCommands.js'export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'export { SurfSystem } from './surf.js'@@@ -144,7 +144,7 @@ export class MovementSystem implements System {// Resolve the place's movement once per tick, not once per player: the// style is room-wide, and instanceHub's per-tick budget is tight.const style = movementStyleFor(ctx.world.content.world)-- const params = movementParamsFor(style)++ const params = ctx.systems.settings.movementParams(movementParamsFor(style))const surfPlace = style === 'surf'for (const session of ctx.sessions.values()) {this.stepSession(ctx, session, dt, params, surfPlace)@@@ -1,5 +1,11 @@import { STARTER_ITEMS, getMapOverride, worldSpawn } from '@openvibe/content'--import { Inventory, SkillSet, movementParamsFor, movementStyleFor, type GameEntity } from '@openvibe/gameplay'++import {++ Inventory,++ SkillSet,++ movementParamsFor,++ movementStyleFor,++ type GameEntity,++} from '@openvibe/gameplay'import { CollisionLayer } from '@openvibe/physics'import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'@@@ -188,12 +194,11 @@ export class SessionsSystem implements System {// position: resuming half-way would skip the clock (brief decision 4).const override = getMapOverride(world)const surfSpawn = override?.surf ? override.spawn : undefined-- const spawn =-- surfSpawn-- ? vec3(surfSpawn[0], surfSpawn[1], surfSpawn[2])-- : existing && existing.positionInPlace !== false-- ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])-- : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])++ const spawn = surfSpawn++ ? vec3(surfSpawn[0], surfSpawn[1], surfSpawn[2])++ : existing && existing.positionInPlace !== false++ ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])++ : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])const inventory = existing? Inventory.fromDto(existing.inventory, ctx.world.content): new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, ctx.world.content)@@@ -262,9 +267,13 @@ export class SessionsSystem implements System {// Kinematic capsule so props collide with players. Shorter than the// movement hull and lifted off the feet: standing ON a prop must not// press it down (that caused sink/jitter loops when prop-surfing).-- const movement = movementParamsFor(movementStyleFor(world))++ const movement = ctx.systems.settings.movementParams(movementParamsFor(movementStyleFor(world)))const bodyId = ctx.world.physics.addBody({-- shape: { type: 'capsule', radius: movement.capsuleRadius, height: movement.capsuleHeight - 0.3 },++ shape: {++ type: 'capsule',++ radius: movement.capsuleRadius,++ height: movement.capsuleHeight - 0.3,++ },motion: 'kinematic',pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),layer: CollisionLayer.Player,@@@ -272,6 +281,7 @@ export class SessionsSystem implements System {})ctx.sessions.setBody(playerId, bodyId)++ const cvarState = ctx.systems.settings.wireFor(rank)// From here the client is in the game: chat is allowed only after this// flag, so a `say` racing the join (or sent before `hello`) is dropped.session.welcomed = true@@@ -285,6 +295,8 @@ export class SessionsSystem implements System {tick: ctx.clock.tick,tickRate: ctx.config.tickRate,snapshotRate: ctx.config.tickRate / ctx.config.snapshotEvery,++ cvars: cvarState.values,++ cvarsWritable: cvarState.writable,...(ctx.config.instancesPerPlace > 1? {instanceId: `${ctx.config.placeId}:${ctx.config.instanceSlot ?? 0}`,nnew file mode 100644@@@ -0,0 +1,91 @@++import { describe, expect, it } from 'vitest'++import type { ClientCvarSet, ServerMessage } from '@openvibe/protocol'++import type { PlayerSession } from '../playerSession.js'++import type { GameConnection, ServerContext } from './context.js'++import { canChangeServerCvars, SettingsSystem } from './settings.js'++++const guest = { rank: null, playerId: 'guest' } as unknown as PlayerSession++const moderator = { rank: 'moderator', playerId: 'mod' } as unknown as PlayerSession++const admin = { rank: 'admin', playerId: 'admin' } as unknown as PlayerSession++++function setup() {++ const sent: { to: string; msg: ServerMessage }[] = []++ const sessions = [guest, moderator, admin]++ const ctx = {++ // Surf values: gravity 800 u/s², airaccelerate 150.++ content: { world: { id: 'surf_test', movement: 'surf' } },++ sessions: {++ values: () => sessions[Symbol.iterator](),++ },++ net: {++ send: (session: PlayerSession, msg: ServerMessage) =>++ sent.push({ to: session.playerId, msg }),++ },++ log: { info: () => {}, warn: () => {}, error: () => {} },++ } as unknown as ServerContext++ const settings = new SettingsSystem(ctx)++ const call = (session: PlayerSession, msg: ClientCvarSet): void =>++ (++ settings.handlers.cvar_set as unknown as (++ s: PlayerSession,++ m: ClientCvarSet,++ c: GameConnection,++ ) => void++ )(session, msg, {} as GameConnection)++ return { settings, sent, call }++}++++describe('server-authoritative console cvars', () => {++ it('shows the surf preset in Source units', () => {++ const { settings } = setup()++ expect(settings.values().sv_gravity).toBe(800)++ expect(settings.values().sv_airaccelerate).toBe(150)++ expect(settings.movementParams().gravity).toBeCloseTo(20.32, 9)++ })++++ it('refuses a guest without changing a single value', () => {++ const { settings, sent, call } = setup()++ call(guest, { t: 'cvar_set', name: 'sv_gravity', value: 900, requestId: 7 })++ expect(sent).toHaveLength(1)++ const state = sent[0]!.msg++ expect(state.t).toBe('cvar_state')++ if (state.t !== 'cvar_state') return++ expect(state.error).toContain('permission')++ expect(state.values.sv_gravity).toBe(800)++ expect(settings.movementParams().gravity).toBeCloseTo(20.32, 9)++ expect(canChangeServerCvars(guest.rank)).toBe(false)++ expect(canChangeServerCvars(moderator.rank)).toBe(false)++ })++++ it('applies an admin change to the room and broadcasts the authoritative values', () => {++ const { settings, sent, call } = setup()++ call(admin, { t: 'cvar_set', name: 'sv_gravity', value: 900, requestId: 9 })++ expect(settings.values().sv_gravity).toBe(900)++ expect(settings.movementParams().gravity).toBeCloseTo(22.86, 9)++ expect(sent.map((s) => s.to).sort()).toEqual(['admin', 'guest', 'mod'])++ const answer = sent.find((s) => s.to === 'admin')!.msg++ if (answer.t !== 'cvar_state') throw new Error('expected cvar_state')++ expect(answer.requestId).toBe(9)++ expect(answer.error).toBeUndefined()++ expect(answer.values.sv_gravity).toBe(900)++ // Another player's copy has the value but not this request's id.++ const peer = sent.find((s) => s.to === 'guest')!.msg++ if (peer.t !== 'cvar_state') throw new Error('expected cvar_state')++ expect(peer.requestId).toBeUndefined()++ expect(peer.writable).toEqual([])++ })++++ it('refuses names outside the allowlist and values outside the range', () => {++ const { settings, sent, call } = setup()++ call(admin, { t: 'cvar_set', name: 'cl_interp', value: 0.2, requestId: 1 })++ const badName = sent.at(-1)!.msg++ if (badName.t !== 'cvar_state') throw new Error('expected cvar_state')++ expect(badName.error).toContain('not a server cvar')++++ call(admin, { t: 'cvar_set', name: 'sv_gravity', value: 9_999, requestId: 2 })++ const badRange = sent.at(-1)!.msg++ if (badRange.t !== 'cvar_state') throw new Error('expected cvar_state')++ expect(badRange.error).toContain('at most')++ expect(settings.values().sv_gravity).toBe(800)++ })++})nnew file mode 100644@@@ -0,0 +1,142 @@++/**++ * Server-authoritative console cvars (job 4).++ *++ * The client console may ask, but this system is the only place a `sv_*`++ * value changes. It owns the allowlist: movement cvars from the Source layer++ * in packages/shared, validated by the same registry the client displays, with++ * a rank gate (owner/admin, the same staff permission as map editing).++ *++ * Values are stored in Source units, so `sv_gravity 800` means 800 u/s² on the++ * wire and 20.32 m/s² in the simulation. The movement system asks for the++ * effective params every tick; the client applies the same conversion from the++ * welcome/cvar_state payload.++ */++import {++ movementCvarValues,++ movementParamsFor,++ movementParamsWithCvars,++ movementStyleFor,++ type MovementParams,++} from '@openvibe/gameplay'++import type { ClientCvarSet, ServerCvarState } from '@openvibe/protocol'++import { CvarRegistry, SOURCE_MOVEMENT_CVARS, type CvarValue } from '@openvibe/shared'++import { canEditMap } from '../../net/networkAuth.js'++import type { PlayerSession } from '../playerSession.js'++import type { GameConnection, ServerContext } from './context.js'++import type { HandlerMap, System } from './system.js'++++/** The server may be asked to change only these cvars. */++export const SERVER_CVAR_ALLOWLIST: readonly string[] = SOURCE_MOVEMENT_CVARS.map(++ (spec) => spec.name,++)++const ALLOWED = new Set(SERVER_CVAR_ALLOWLIST)++const SPECS = new Map(SOURCE_MOVEMENT_CVARS.map((spec) => [spec.name, spec]))++++/** The same staff gate as map editing: owners and admins, not moderators. */++export function canChangeServerCvars(rank: PlayerSession['rank']): boolean {++ return canEditMap(rank)++}++++export class SettingsSystem implements System {++ readonly name = 'settings'++ private readonly overrides = new Map<string, number | boolean>()++ /** Validation only; the authoritative values live in `overrides`. */++ private readonly validator = new CvarRegistry(SOURCE_MOVEMENT_CVARS)++++ readonly handlers: HandlerMap = {++ cvar_set: (session, msg: ClientCvarSet, _conn: GameConnection) => this.set(session, msg),++ }++++ constructor(private readonly ctx: ServerContext) {}++++ private baseParams(): MovementParams {++ return movementParamsFor(movementStyleFor(this.ctx.content.world))++ }++++ /** Current effective Source-unit values, including the map preset's defaults. */++ values(base: MovementParams = this.baseParams()): Record<string, number | boolean> {++ const values = movementCvarValues(base)++ for (const [name, value] of this.overrides) values[name] = value++ // Every movement spec is numeric or boolean; movementCvarValues only++ // emits those types because the specs have no string cvars.++ return values as Record<string, number | boolean>++ }++++ /** Current simulation params: the preset plus any server cvar overrides. */++ movementParams(base: MovementParams = this.baseParams()): MovementParams {++ return movementParamsWithCvars(base, this.values(base))++ }++++ /** The welcome/`cvar_state` payload for one session. */++ writable(rank: PlayerSession['rank']): string[] {++ return canChangeServerCvars(rank) ? [...SERVER_CVAR_ALLOWLIST] : []++ }++++ wireFor(rank: PlayerSession['rank']): {++ values: Record<string, number | boolean>++ writable: string[]++ } {++ return { values: this.values(), writable: this.writable(rank) }++ }++++ private stateFor(++ session: PlayerSession,++ extra: { requestId?: number; error?: string } = {},++ ): ServerCvarState {++ return {++ t: 'cvar_state',++ values: this.values(),++ writable: this.writable(session.rank),++ ...(extra.requestId !== undefined ? { requestId: extra.requestId } : {}),++ ...(extra.error !== undefined ? { error: extra.error } : {}),++ }++ }++++ private set(session: PlayerSession, msg: ClientCvarSet): void {++ const requestId = msg.requestId++ const fail = (error: string): void => {++ this.ctx.net.send(++ session,++ this.stateFor(session, {++ ...(requestId !== undefined ? { requestId } : {}),++ error,++ }),++ )++ }++ if (!canChangeServerCvars(session.rank)) {++ fail('server cvars need staff permission')++ return++ }++ const name = msg.name.trim().toLowerCase()++ if (!ALLOWED.has(name)) {++ fail(`${name} is not a server cvar; it stays local`)++ return++ }++ const parsed = this.validator.set(name, msg.value as CvarValue)++ if (!parsed.ok) {++ fail(parsed.error ?? `invalid value for ${name}`)++ return++ }++ const spec = SPECS.get(name)++ if (!spec || (typeof parsed.value !== 'number' && typeof parsed.value !== 'boolean')) {++ fail(`${name} is not a server cvar`)++ return++ }++ this.overrides.set(name, parsed.value)++ const values = this.values()++ // A room-wide change: everyone's prediction needs the new numbers.++ for (const peer of this.ctx.sessions.values()) {++ this.ctx.net.send(peer, {++ t: 'cvar_state',++ values,++ writable: this.writable(peer.rank),++ ...(peer === session && requestId !== undefined ? { requestId } : {}),++ })++ }++ this.ctx.log.info('cvar changed', {++ cvar: name,++ value: parsed.value,++ by: session.playerId,++ })++ }++}@@@ -2,7 +2,7 @@"name": "openvibe-games","private": true,"version": "0.1.0",-- "description": "Scraplandia — persistent multiplayer physics sandbox (browser)",++ "description": "Scraplandia \u2014 persistent multiplayer physics sandbox (browser)","type": "module","engines": {"node": ">=22"@@@ -21,7 +21,8 @@"typecheck": "tsc --build tsconfig.solution.json","lint": "eslint .","format": "prettier --write .",-- "format:check": "prettier --check ."++ "format:check": "prettier --check .",++ "test:console": "node --import tsx apps/client/e2e/consoleCheck.ts"},"devDependencies": {"@eslint/js": "^9.23.0",nnew file mode 100644@@@ -0,0 +1,33 @@++import { describe, expect, it } from 'vitest'++import { movementCvarValues, movementParamsWithCvars, SURF_MOVEMENT } from './styles.js'++++describe('Source cvars over movement params', () => {++ it('reads the surf preset back in Source units without float noise', () => {++ const values = movementCvarValues(SURF_MOVEMENT)++ expect(values.sv_gravity).toBe(800)++ expect(values.sv_airaccelerate).toBe(150)++ expect(values.sv_friction).toBe(4)++ expect(values.sv_jump_impulse).toBeCloseTo(301.992, 3)++ expect(values.sv_air_max_wishspeed).toBe(30)++ })++++ it('applies only the supplied cvars and converts units exactly like Source', () => {++ const params = movementParamsWithCvars(SURF_MOVEMENT, {++ sv_gravity: 900,++ sv_airaccelerate: 100,++ sv_maxspeed: 500,++ })++ expect(params.gravity).toBeCloseTo(22.86, 9)++ expect(params.airAccel).toBe(100)++ expect(params.maxGroundSpeed).toBeCloseTo(12.7, 9)++ // CS has no sprint: raising the run cap raises the sprint cap too.++ expect(params.maxSprintSpeed).toBeCloseTo(12.7, 9)++ expect(params.friction).toBe(SURF_MOVEMENT.friction)++ })++++ it('round-trips every movement cvar', () => {++ const values = movementCvarValues(SURF_MOVEMENT)++ const params = movementParamsWithCvars(SURF_MOVEMENT, values)++ expect(movementCvarValues(params)).toEqual(values)++ })++})@@@ -1,4 +1,5 @@import { getMapOverride, type MovementStyle, type WorldDef } from '@openvibe/content'++import { SOURCE_MOVEMENT_CVARS, type CvarValue } from '@openvibe/shared'import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'/**@@@ -93,3 +94,54 @@ export function movementParamsFor(style: MovementStyle): MovementParams {export function movementStyleFor(world: WorldDef): MovementStyle {return getMapOverride(world)?.movement ?? world.movement ?? 'default'}++++/**++ * The Source-unit values of a movement preset: what the console shows for++ * `sv_gravity`, `sv_airaccelerate` and friends. Integers are rounded so the++ * default 20.32 m/s² reads as 800, not 800.0000000000001.++ */++export function movementCvarValues(params: MovementParams): Record<string, number | boolean> {++ const out: Record<string, number | boolean> = {}++ for (const spec of SOURCE_MOVEMENT_CVARS) {++ if (!spec.target) continue++ const value = params[spec.target as keyof MovementParams]++ if (typeof value === 'boolean') {++ out[spec.name] = value++ } else if (typeof value === 'number') {++ const converted = value / (spec.scale ?? 1)++ out[spec.name] =++ spec.type === 'integer' ? Math.round(converted) : Number(converted.toFixed(6))++ }++ }++ return out++}++++/**++ * Overlay authoritative Source-unit cvars onto a movement preset. Both the++ * server simulation and the client's prediction call this with the same++ * values, so a cvar change cannot desynchronise them.++ */++export function movementParamsWithCvars(++ base: MovementParams,++ values: Readonly<Record<string, CvarValue>>,++): MovementParams {++ const next: { -readonly [K in keyof MovementParams]: MovementParams[K] } = { ...base }++ for (const spec of SOURCE_MOVEMENT_CVARS) {++ if (!spec.target) continue++ const value = values[spec.name]++ if (value === undefined) continue++ const target = spec.target as keyof MovementParams++ if (typeof value === 'boolean') {++ next[target] = value as never++ continue++ }++ const numeric = typeof value === 'number' ? value : Number(value)++ if (!Number.isFinite(numeric)) continue++ const converted = numeric * (spec.scale ?? 1)++ next[target] = converted as never++ // CS has no sprint key: sv_maxspeed is the one run speed, so Shift must++ // not clamp a raised limit back down on surf maps.++ if (spec.target === 'maxGroundSpeed') next.maxSprintSpeed = converted++ }++ return next++}@@@ -27,6 +27,7 @@ describe('protocol codec', () => {{ t: 'physgun', a: 'grab' },{ t: 'physgun', a: 'rotate', dyaw: 0.1, dpitch: -0.1, snap: true },{ t: 'physgun', a: 'grid', on: true },++ { t: 'cvar_set', name: 'sv_gravity', value: 800, requestId: 3 },{ t: 'say', text: 'hello room' },{ t: 'say_team', text: 'team line' },]@@@ -46,6 +47,8 @@ describe('protocol codec', () => {'{"t":"input","seq":1,"moveX":1e999,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',),).toBeNull()++ expect(decodeClientMessage('{"t":"cvar_set","name":"../x","value":1}')).toBeNull()++ expect(decodeClientMessage('{"t":"cvar_set","name":"sv_gravity","value":1e999}')).toBeNull()// out-of-range movement axes (speedhack attempt)expect(decodeClientMessage(@@@ -30,7 +30,12 @@ export const ClientHelloSchema = z.object({/** Requested place. Older clients omit it and join the configured default. */place: z.string().min(1).max(64).optional(),/** Which of this account's characters (0 … MAX_CHARACTERS - 1); a game, not the platform, decides how many it uses. */-- slot: z.number().int().min(0).max(MAX_CHARACTERS - 1).default(0),++ slot: z++ .number()++ .int()++ .min(0)++ .max(MAX_CHARACTERS - 1)++ .default(0),name: z.string().min(1).max(24),appearance: AppearanceSchema,})@@@ -303,6 +308,23 @@ export const ClientContainerSortSchema = z.object({target: z.string().max(32),})++/**++ * Change one server-authoritative cvar. The server owns the allowlist, the++ * rank check and the range check; this message is only a request. `requestId`++ * is echoed in `cvar_state` so the console can tell its answer from another++ * player's change.++ */++export const ClientCvarSetSchema = z.object({++ t: z.literal('cvar_set'),++ name: z++ .string()++ .min(1)++ .max(64)++ .regex(/^[a-z0-9_]+$/),++ value: z.union([z.number().finite(), z.boolean()]),++ requestId: z.number().int().nonnegative().max(1_000_000_000).optional(),++})++/*** Source's `say`: a public chat line. The server rate-limits it and treats the* text as plain text throughout — it is never interpreted as markup.@@@ -350,6 +372,7 @@ export const ClientMessageSchema = z.union([ClientSaySchema,ClientSayTeamSchema,ClientSurfRestartSchema,++ ClientCvarSetSchema,])export type ClientHello = z.infer<typeof ClientHelloSchema>@@@ -380,6 +403,7 @@ export type ClientJobTurnIn = z.infer<typeof ClientJobTurnInSchema>export type ClientContainerOpen = z.infer<typeof ClientContainerOpenSchema>export type ClientContainerMove = z.infer<typeof ClientContainerMoveSchema>export type ClientContainerSort = z.infer<typeof ClientContainerSortSchema>++export type ClientCvarSet = z.infer<typeof ClientCvarSetSchema>export type ClientSay = z.infer<typeof ClientSaySchema>export type ClientSayTeam = z.infer<typeof ClientSayTeamSchema>export type { ClientSurfRestart } from './surf.js'@@@ -29,6 +29,23 @@ export interface ServerWelcome {/** Present when multi-instance mode is enabled. */instanceId?: stringinstanceCount?: number++ /** Current server-authoritative cvars in Source units (see cvar_state). */++ cvars?: Record<string, number | boolean>++ /** The cvar names this player's rank may change. */++ cvarsWritable?: string[]++}++++/** The room's authoritative cvar values, sent after a change and in reply to `cvar_set`. */++export interface ServerCvarState {++ t: 'cvar_state'++ /** Source-unit values, keyed by canonical Source name. */++ values: Record<string, number | boolean>++ /** Names this session's rank may change (the same subset as welcome). */++ writable: string[]++ /** Echo of the request this answers, when it answers one. */++ requestId?: number++ /** Why a requested change was refused; values are still the authoritative ones. */++ error?: string}export interface ServerTransferResult {@@@ -298,6 +315,7 @@ export type ServerMessage =scores: { name: string; score: number; rank: number }[]}| ServerWelcome++ | ServerCvarState| ServerTransferResult| ServerReject| ServerSpawnnnew file mode 100644@@@ -0,0 +1,153 @@++// @vitest-environment jsdom++import { describe, expect, it } from 'vitest'++import {++ AUTOEXEC_CONFIG,++ Bindings,++ ConfigStore,++ CommandRegistry,++ DEFAULT_BINDS,++ intentCommand,++ keyCodeFor,++ keyNameFromCode,++ normalizeKeyName,++ parseConfig,++ parseCommandLine,++ tokenizeCommand,++ type KeyValueStorage,++} from './commands.js'++++/** A localStorage-shaped store, with the enumeration ConfigStore.list uses. */++class MemoryStorage implements KeyValueStorage {++ private readonly data = new Map<string, string>()++ get length(): number {++ return this.data.size++ }++ getItem(key: string): string | null {++ return this.data.get(key) ?? null++ }++ setItem(key: string, value: string): void {++ this.data.set(key, value)++ }++ removeItem(key: string): void {++ this.data.delete(key)++ }++ key(index: number): string | null {++ return [...this.data.keys()][index] ?? null++ }++}++++describe('console config parser', () => {++ it('splits quoted arguments, comments and semicolon-separated commands', () => {++ const text = [++ '// a comment, not a command',++ 'bind space +jump',++ 'bind "a" "say hello world"; sv_gravity 800 // trailing',++ '',++ 'exec practice.cfg',++ ].join('\n')++ expect(parseConfig(text)).toEqual([++ { name: 'bind', args: ['space', '+jump'], raw: 'bind space +jump', line: 2 },++ { name: 'bind', args: ['a', 'say hello world'], raw: 'bind a say hello world', line: 3 },++ { name: 'sv_gravity', args: ['800'], raw: 'sv_gravity 800', line: 3 },++ { name: 'exec', args: ['practice.cfg'], raw: 'exec practice.cfg', line: 5 },++ ])++ })++++ it('tokenises one line and keeps empty quoted arguments', () => {++ expect(tokenizeCommand('say "hi there" x')).toEqual(['say', 'hi there', 'x'])++ expect(tokenizeCommand('bind "a" ""')).toEqual(['bind', 'a', ''])++ expect(parseCommandLine('+jump; -jump')).toEqual([++ { name: '+jump', args: [], raw: '+jump', line: 1 },++ { name: '-jump', args: [], raw: '-jump', line: 1 },++ ])++ })++})++++describe('Source key names and bindings', () => {++ it('maps Source key names to stable DOM codes', () => {++ expect(keyCodeFor('space')).toBe('Space')++ expect(keyCodeFor('ctrl')).toBe('ControlLeft')++ expect(keyCodeFor('a')).toBe('KeyA')++ expect(keyCodeFor('1')).toBe('Digit1')++ expect(normalizeKeyName('grave')).toBe('~')++ expect(normalizeKeyName('KeyA')).toBe('a')++ expect(keyNameFromCode('Space')).toBe('space')++ expect(normalizeKeyName('not-a-key')).toBeNull()++ })++++ it('starts from Source defaults and persists bind changes', () => {++ const storage = new MemoryStorage()++ const first = new Bindings({ storage })++ expect(first.commandForCode('Space')).toBe('+jump')++ expect(first.commandForCode('ControlLeft')).toBe('+duck')++ first.bind('space', '+duck')++ first.bind('q', 'toggle_ghost')++ expect(first.commandForCode('Space')).toBe('+duck')++++ // A new browser session reads the same binds back.++ const second = new Bindings({ storage })++ expect(second.commandForCode('Space')).toBe('+duck')++ expect(second.commandForCode('KeyQ')).toBe('toggle_ghost')++ expect(second.unbind('q').ok).toBe(true)++ expect(new Bindings({ storage }).commandForCode('KeyQ')).toBeNull()++ })++++ it('unbinds an intent and keeps the other defaults', () => {++ const storage = new MemoryStorage()++ const bindings = new Bindings({ storage })++ bindings.unbind('space')++ expect(bindings.commandForCode('Space')).toBeNull()++ expect(bindings.commandForCode('KeyR')).toBe(DEFAULT_BINDS.r)++ expect(intentCommand('+jump')).toEqual({ name: 'Jump', down: true })++ expect(intentCommand('-jump')).toEqual({ name: 'Jump', down: false })++ expect(intentCommand('+not-an-intent')).toBeNull()++ })++})++++describe('virtual cfg files', () => {++ it('normalises names and persists through a fresh store', () => {++ const storage = new MemoryStorage()++ const configs = new ConfigStore({ storage })++ expect(configs.normalize('practice')).toBe('practice.cfg')++ expect(configs.normalize('CFG/AUTOEXEC.CFG')).toBe(AUTOEXEC_CONFIG)++ expect(configs.normalize('../evil')).toBeNull()++ expect(configs.normalize('')).toBeNull()++ expect(configs.write('practice', 'bind space +jump').ok).toBe(true)++ expect(new ConfigStore({ storage }).read('practice.cfg')).toBe('bind space +jump')++ expect(configs.list()).toEqual(['practice.cfg'])++ expect(configs.remove('practice.cfg')).toBe(true)++ expect(configs.read('practice.cfg')).toBeNull()++ })++++ it('returns no list without a storage implementation', () => {++ const configs = new ConfigStore({ storage: null })++ expect(configs.write('autoexec.cfg', 'bind space +jump')).toEqual({++ ok: false,++ error: 'browser storage is unavailable',++ })++ expect(configs.list()).toEqual([])++ })++})++++describe('command registry', () => {++ it('resolves aliases, searches help text and lists canonical commands once', () => {++ const registry = new CommandRegistry<{ hits: string[] }>([++ {++ name: 'help',++ aliases: ['?'],++ help: 'Show help for a command.',++ run: (args, context) => {++ context.hits.push(args.join(' '))++ return 'ok'++ },++ },++ { name: 'find', help: 'Search for a name.', run: () => [] },++ ])++ expect(registry.get('?')?.name).toBe('help')++ expect(registry.all().map((c) => c.name)).toEqual(['find', 'help'])++ expect(registry.find('search').map((c) => c.name)).toEqual(['find'])++ const context = { hits: [] as string[] }++ void registry.get('help')!.run(['gravity'], context)++ expect(context.hits).toEqual(['gravity'])++ })++})nnew file mode 100644@@@ -0,0 +1,583 @@++/**++ * Source-style console input primitives shared by the client's two pages.++ *++ * Nothing here touches the DOM: the parser, the key-binding table, the tiny++ * virtual config filesystem and the command registry are pure data and can be++ * tested on their own. The console overlay in apps/client/src/ui/console.ts is++ * the presentation layer.++ */++import type { IntentName } from './intents.js'++++/** The small storage surface the browser and tests both provide. */++export interface KeyValueStorage {++ getItem(key: string): string | null++ setItem(key: string, value: string): void++ removeItem(key: string): void++ /** Present on localStorage; tests may omit it. */++ readonly length?: number++ key?(index: number): string | null++}++++/** localStorage, or null where the browser refuses it (private mode, blocked site data). */++export function browserStorage(): KeyValueStorage | null {++ try {++ return globalThis.localStorage ?? null++ } catch {++ return null++ }++}++++/** One command after tokenising: `bind space +jump` -> name `bind`, args `['space', '+jump']`. */++export interface ParsedCommand {++ name: string++ args: string[]++ /** The command as it reads after tokenising (quotes removed). */++ raw: string++ /** 1-based line in the config it came from. */++ line: number++}++++/** Split one line into arguments, honouring quotes and `//` comments. */++export function tokenizeCommand(line: string): string[] {++ const tokens: string[] = []++ let token = ''++ let inToken = false++ let quoted = false++ let escaped = false++ for (let i = 0; i < line.length; i++) {++ const c = line[i]!++ if (escaped) {++ token += c++ inToken = true++ escaped = false++ continue++ }++ if (quoted && c === '\\') {++ escaped = true++ continue++ }++ if (c === '"') {++ quoted = !quoted++ inToken = true++ continue++ }++ if (!quoted && c === '/' && line[i + 1] === '/') break++ if (!quoted && /\s/.test(c)) {++ if (inToken) {++ tokens.push(token)++ token = ''++ inToken = false++ }++ continue++ }++ token += c++ inToken = true++ }++ if (escaped) token += '\\'++ if (inToken) tokens.push(token)++ return tokens++}++++/**++ * Parse a Source cfg / console script. Newlines and semicolons separate++ * commands; `//` comments and quoted arguments are honoured. A command split++ * over several lines fails rather than guessing, matching Source's++ * one-command-per-line configs.++ */++export function parseConfig(text: string): ParsedCommand[] {++ const commands: ParsedCommand[] = []++ let tokens: string[] = []++ let token = ''++ let inToken = false++ let quoted = false++ let escaped = false++ let line = 1++ let commandLine = 1++++ const flushToken = (): void => {++ if (!inToken) return++ tokens.push(token)++ token = ''++ inToken = false++ }++ const flushCommand = (): void => {++ flushToken()++ if (!tokens.length) {++ commandLine = line++ return++ }++ const name = tokens[0]!++ commands.push({++ name,++ args: tokens.slice(1),++ raw: tokens.join(' '),++ line: commandLine,++ })++ tokens = []++ commandLine = line++ }++++ for (let i = 0; i < text.length; i++) {++ const c = text[i]!++ if (escaped) {++ token += c++ inToken = true++ escaped = false++ continue++ }++ if (quoted && c === '\\') {++ escaped = true++ continue++ }++ if (c === '"') {++ quoted = !quoted++ inToken = true++ continue++ }++ if (!quoted && c === '/' && text[i + 1] === '/') {++ while (i + 1 < text.length && text[i + 1] !== '\n') i++++ continue++ }++ if (!quoted && (c === '\n' || c === ';')) {++ flushCommand()++ if (c === '\n') line++++ commandLine = line++ continue++ }++ if (!quoted && /\s/.test(c)) {++ flushToken()++ continue++ }++ token += c++ inToken = true++ }++ if (escaped) token += '\\'++ flushCommand()++ return commands++}++++/** Parse one console line into the commands it carries. */++export function parseCommandLine(line: string): ParsedCommand[] {++ return parseConfig(line)++}++++/**++ * Source key names, normalised. The browser's `KeyboardEvent.code` is the++ * stable identity (layout-independent); a player's `bind a +jump` means the++ * physical A key, exactly as Source reads it.++ */++const KEY_TO_CODE: Readonly<Record<string, string>> = {++ '~': 'Backquote',++ '`': 'Backquote',++ backquote: 'Backquote',++ grave: 'Backquote',++ space: 'Space',++ spacebar: 'Space',++ tab: 'Tab',++ enter: 'Enter',++ return: 'Enter',++ escape: 'Escape',++ esc: 'Escape',++ ctrl: 'ControlLeft',++ control: 'ControlLeft',++ shift: 'ShiftLeft',++ alt: 'AltLeft',++ option: 'AltLeft',++ capslock: 'CapsLock',++ backspace: 'Backspace',++ delete: 'Delete',++ del: 'Delete',++ insert: 'Insert',++ ins: 'Insert',++ home: 'Home',++ end: 'End',++ pageup: 'PageUp',++ pagedown: 'PageDown',++ up: 'ArrowUp',++ down: 'ArrowDown',++ left: 'ArrowLeft',++ right: 'ArrowRight',++ minus: 'Minus',++ equal: 'Equal',++ equals: 'Equal',++ comma: 'Comma',++ period: 'Period',++ slash: 'Slash',++ semicolon: 'Semicolon',++ apostrophe: 'Quote',++ quote: 'Quote',++ bracketleft: 'BracketLeft',++ bracketright: 'BracketRight',++ backslash: 'Backslash',++}++++/** Preferred spelling for each physical key: one name per DOM code. */++const CODE_TO_KEY: Readonly<Record<string, string>> = {++ Backquote: '~',++ Space: 'space',++ Tab: 'tab',++ Enter: 'enter',++ Escape: 'escape',++ ControlLeft: 'ctrl',++ ShiftLeft: 'shift',++ AltLeft: 'alt',++ CapsLock: 'capslock',++ Backspace: 'backspace',++ Delete: 'delete',++ Insert: 'insert',++ Home: 'home',++ End: 'end',++ PageUp: 'pageup',++ PageDown: 'pagedown',++ ArrowUp: 'up',++ ArrowDown: 'down',++ ArrowLeft: 'left',++ ArrowRight: 'right',++ Minus: 'minus',++ Equal: 'equal',++ Comma: 'comma',++ Period: 'period',++ Slash: 'slash',++ Semicolon: 'semicolon',++ Quote: 'apostrophe',++ BracketLeft: 'bracketleft',++ BracketRight: 'bracketright',++ Backslash: 'backslash',++}++++/** Normalise a Source key name to one canonical spelling; null when it is not a known key. */++export function normalizeKeyName(name: string): string | null {++ const code = keyCodeFor(name)++ return code ? keyNameFromCode(code) : null++}++++/** The DOM code a Source key name means, or null when it is not a keyboard key. */++export function keyCodeFor(name: string): string | null {++ const raw = name.trim().toLowerCase()++ const mapped = KEY_TO_CODE[raw]++ if (mapped) return mapped++ if (/^[a-z]$/.test(raw)) return `Key${raw.toUpperCase()}`++ if (/^[0-9]$/.test(raw)) return `Digit${raw}`++ if (/^f([1-9]|1[0-2])$/.test(raw)) return `F${raw.slice(1)}`++ // A DOM code supplied directly (case-sensitive spellings).++ if (++ /^(Key[A-Z]|Digit[0-9]|F([1-9]|1[0-2])|Arrow(Up|Down|Left|Right)|Control(Left|Right)|Shift(Left|Right)|Alt(Left|Right)|[A-Z][A-Za-z]+)$/.test(++ name.trim(),++ )++ )++ return name.trim()++ return null++}++++/** The Source key name for a DOM code (the inverse of keyCodeFor where it is unambiguous). */++export function keyNameFromCode(code: string): string | null {++ const direct = CODE_TO_KEY[code]++ if (direct) return direct++ if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase()++ if (/^Digit[0-9]$/.test(code)) return code.slice(5)++ if (/^F([1-9]|1[0-2])$/.test(code)) return code.toLowerCase()++ return null++}++++/** Config cvars and binds are per-browser, as Source's cfg files are per-install. */++export const BINDS_STORAGE_KEY = 'openvibe.console.binds'++export const CVARS_STORAGE_KEY = 'openvibe.console.cvars'++export const HISTORY_STORAGE_KEY = 'openvibe.console.history'++export const CONFIG_STORAGE_PREFIX = 'openvibe.cfg.'++export const AUTOEXEC_CONFIG = 'autoexec.cfg'++++/**++ * The keys a fresh install starts with. The default is Source-vocabulary:++ * `+jump` and friends are intent commands; the console reads the same table.++ */++export const DEFAULT_BINDS: Readonly<Record<string, string>> = Object.freeze({++ space: '+jump',++ ctrl: '+duck',++ c: '+duck',++ shift: '+sprint',++ z: '+prone',++ r: '+restart',++ g: 'toggle_ghost',++ '~': 'toggleconsole',++})++++export interface BindingsOptions {++ storage?: KeyValueStorage | null++ storageKey?: string++ defaults?: Readonly<Record<string, string>>++}++++/** Key -> command bindings, persisted as a single localStorage value. */++export class Bindings {++ private readonly values = new Map<string, string>()++ private readonly storage: KeyValueStorage | null++ private readonly storageKey: string++ private readonly defaults: Readonly<Record<string, string>>++++ constructor(options: BindingsOptions = {}) {++ this.storage = options.storage === undefined ? browserStorage() : options.storage++ this.storageKey = options.storageKey ?? BINDS_STORAGE_KEY++ this.defaults = options.defaults ?? DEFAULT_BINDS++ for (const [key, command] of Object.entries(this.defaults)) {++ const normalized = normalizeKeyName(key)++ if (normalized) this.values.set(normalized, command)++ }++ this.load()++ }++++ private load(): void {++ let raw: string | null = null++ try {++ raw = this.storage?.getItem(this.storageKey) ?? null++ } catch {++ return++ }++ if (!raw) return++ try {++ const parsed = JSON.parse(raw) as { v?: number; binds?: Record<string, unknown> }++ if (parsed.v !== 1 || !parsed.binds || typeof parsed.binds !== 'object') return++ for (const [key, command] of Object.entries(parsed.binds)) {++ const normalized = normalizeKeyName(key)++ if (normalized && typeof command === 'string' && command.trim()) {++ this.values.set(normalized, command.trim())++ }++ }++ } catch {++ // Corrupt storage: defaults still work.++ }++ }++++ private save(): void {++ if (!this.storage) return++ try {++ this.storage.setItem(this.storageKey, JSON.stringify({ v: 1, binds: this.asRecord() }))++ } catch {++ // Private mode/quota: the bindings still work until reload.++ }++ }++++ /** Bind a key; an empty command unbinds it. Returns the canonical key. */++ bind(key: string, command: string): { ok: true; key: string } | { ok: false; error: string } {++ const normalized = normalizeKeyName(key)++ if (!normalized) return { ok: false, error: `unknown key "${key}"` }++ if (!command.trim()) {++ this.values.delete(normalized)++ } else {++ this.values.set(normalized, command.trim())++ }++ this.save()++ return { ok: true, key: normalized }++ }++++ unbind(key: string): { ok: true; key: string } | { ok: false; error: string } {++ const normalized = normalizeKeyName(key)++ if (!normalized) return { ok: false, error: `unknown key "${key}"` }++ this.values.delete(normalized)++ this.save()++ return { ok: true, key: normalized }++ }++++ get(key: string): string | null {++ const normalized = normalizeKeyName(key)++ return normalized ? (this.values.get(normalized) ?? null) : null++ }++++ /** The command a DOM key code expresses, if any. */++ commandForCode(code: string): string | null {++ const name = keyNameFromCode(code)++ return name ? (this.values.get(name) ?? null) : null++ }++++ /** Every binding, sorted by key name. */++ entries(): { key: string; command: string }[] {++ return [...this.values.entries()]++ .sort(([a], [b]) => a.localeCompare(b))++ .map(([key, command]) => ({ key, command }))++ }++++ asRecord(): Record<string, string> {++ return Object.fromEntries(this.values)++ }++}++++/** What a `+command` means for the intent bitfield. */++const INTENT_COMMANDS: Readonly<Record<string, IntentName>> = {++ jump: 'Jump',++ duck: 'Crouch',++ crouch: 'Crouch',++ sprint: 'Sprint',++ use: 'Use',++ attack: 'Attack',++ prone: 'Prone',++}++++/** `+jump` -> { name: 'Jump', down: true }; `-jump` -> down false. */++export function intentCommand(command: string): { name: IntentName; down: boolean } | null {++ const raw = command.trim().toLowerCase()++ if (raw.length < 2) return null++ const sign = raw[0]++ if (sign !== '+' && sign !== '-') return null++ const name = INTENT_COMMANDS[raw.slice(1)]++ return name ? { name, down: sign === '+' } : null++}++++/** Is this a `+`/`-` intent command (so a bind should feed intents, not a one-shot action)? */++export function isIntentCommand(command: string): boolean {++ return intentCommand(command) !== null++}++++export interface ConfigStoreOptions {++ storage?: KeyValueStorage | null++ prefix?: string++}++++/** A virtual `.cfg` folder in localStorage: `exec practice.cfg`, `autoexec.cfg`, etc. */++export class ConfigStore {++ private readonly storage: KeyValueStorage | null++ private readonly prefix: string++++ constructor(options: ConfigStoreOptions = {}) {++ this.storage = options.storage === undefined ? browserStorage() : options.storage++ this.prefix = options.prefix ?? CONFIG_STORAGE_PREFIX++ }++++ /** `practice`, `practice.cfg` and `PRACTICE.CFG` all name `practice.cfg`. */++ normalize(name: string): string | null {++ let value = name.trim().toLowerCase()++ if (value.startsWith('cfg/')) value = value.slice(4)++ if (!value) return null++ if (!value.endsWith('.cfg')) value += '.cfg'++ if (!/^[a-z0-9][a-z0-9_.-]*\.cfg$/.test(value)) return null++ if (value.includes('..')) return null++ return value++ }++++ read(name: string): string | null {++ const key = this.normalize(name)++ if (!key || !this.storage) return null++ try {++ return this.storage.getItem(this.prefix + key)++ } catch {++ return null++ }++ }++++ has(name: string): boolean {++ return this.read(name) !== null++ }++++ write(name: string, text: string): { ok: true; name: string } | { ok: false; error: string } {++ const key = this.normalize(name)++ if (!key) return { ok: false, error: `invalid config name "${name}"` }++ if (!this.storage) return { ok: false, error: 'browser storage is unavailable' }++ try {++ this.storage.setItem(this.prefix + key, text)++ return { ok: true, name: key }++ } catch {++ return { ok: false, error: 'browser storage refused the config' }++ }++ }++++ remove(name: string): boolean {++ const key = this.normalize(name)++ if (!key || !this.storage) return false++ try {++ this.storage.removeItem(this.prefix + key)++ return true++ } catch {++ return false++ }++ }++++ list(): string[] {++ if (!this.storage) return []++ const prefix = this.prefix++ const names: string[] = []++ try {++ const length = this.storage.length ?? 0++ for (let i = 0; i < length; i++) {++ const key = this.storage.key?.(i)++ if (key?.startsWith(prefix)) names.push(key.slice(prefix.length))++ }++ } catch {++ return []++ }++ return names.sort()++ }++}++++/** The result a command reports back to the console. */++export type CommandResult =++ string | readonly string[] | { lines?: readonly string[]; error?: string; close?: boolean } | void++++/** One console command. `run` may be async (exec and server round-trips are). */++export interface CommandDef<C = unknown> {++ name: string++ aliases?: readonly string[]++ usage?: string++ help: string++ /** Allowed in the casual page's read-only console. */++ readOnly?: boolean++ run(args: string[], context: C): CommandResult | Promise<CommandResult>++}++++/** A case-insensitive command table with alias lookup and help search. */++export class CommandRegistry<C = unknown> {++ private readonly canonical = new Map<string, CommandDef<C>>()++ private readonly byName = new Map<string, CommandDef<C>>()++++ constructor(defs: readonly CommandDef<C>[] = []) {++ for (const def of defs) this.register(def)++ }++++ register(def: CommandDef<C>): void {++ const name = def.name.trim().toLowerCase()++ if (!name) throw new Error('command name must not be empty')++ if (this.byName.has(name)) throw new Error(`duplicate command "${name}"`)++ this.canonical.set(name, def)++ this.byName.set(name, def)++ for (const alias of def.aliases ?? []) {++ const key = alias.trim().toLowerCase()++ if (!key) continue++ if (this.byName.has(key)) throw new Error(`duplicate command alias "${key}"`)++ this.byName.set(key, def)++ }++ }++++ get(name: string): CommandDef<C> | null {++ return this.byName.get(name.trim().toLowerCase()) ?? null++ }++++ /** Registered commands in name order (aliases are not repeated). */++ all(): CommandDef<C>[] {++ return [...this.canonical.values()].sort((a, b) => a.name.localeCompare(b.name))++ }++++ /** Command and alias names starting with `prefix`. */++ matching(prefix: string): string[] {++ const p = prefix.trim().toLowerCase()++ return [...this.byName.keys()].filter((name) => name.startsWith(p)).sort()++ }++++ /** Commands whose name, alias or help contains `text`. */++ find(text: string): CommandDef<C>[] {++ const needle = text.trim().toLowerCase()++ if (!needle) return this.all()++ const out = new Set<CommandDef<C>>()++ for (const def of this.canonical.values()) {++ if (++ def.name.toLowerCase().includes(needle) ||++ def.help.toLowerCase().includes(needle) ||++ (def.aliases ?? []).some((a) => a.toLowerCase().includes(needle))++ )++ out.add(def)++ }++ return [...out].sort((a, b) => a.name.localeCompare(b.name))++ }++++ help(def: CommandDef<C>): string[] {++ const lines = [def.usage ? `${def.name} — ${def.usage}` : def.name, def.help]++ if (def.aliases?.length) lines.push(`aliases: ${def.aliases.join(', ')}`)++ return lines++ }++}nnew file mode 100644@@@ -0,0 +1,64 @@++// @vitest-environment jsdom++import { describe, expect, it } from 'vitest'++import { CLIENT_CVARS, CvarRegistry, SOURCE_MOVEMENT_CVARS, type CvarSpec } from './registry.js'++++const registry = (): CvarRegistry => new CvarRegistry([...SOURCE_MOVEMENT_CVARS, ...CLIENT_CVARS])++++describe('cvar registry', () => {++ it('shows Source values and resolves our names as aliases', () => {++ const cvars = registry()++ expect(cvars.formatValue('sv_gravity')).toBe('800')++ expect(cvars.get('gravity')).toBe(800)++ expect(cvars.canonical('airaccelerate')).toBe('sv_airaccelerate')++ expect(cvars.formatValue('sv_airaccelerate')).toBe('150')++ expect(cvars.formatValue('sv_autobunnyhopping')).toBe('0')++ })++++ it('validates types, ranges and readonly flags', () => {++ const cvars = registry()++ expect(cvars.set('sv_airaccelerate', '150').ok).toBe(true)++ expect(cvars.get('sv_airaccelerate')).toBe(150)++ expect(cvars.set('sv_gravity', '-1').ok).toBe(false)++ expect(cvars.set('sv_gravity', 'lots').ok).toBe(false)++ expect(cvars.set('sv_autobunnyhopping', 'on').ok).toBe(true)++ expect(cvars.get('sv_autobunnyhopping')).toBe(true)++ expect(cvars.set('nope', '1').ok).toBe(false)++++ const readonly: CvarSpec = {++ name: 'sv_locked',++ type: 'float',++ defaultValue: 1,++ flags: ['server', 'readonly'],++ help: 'locked',++ }++ const locked = new CvarRegistry([readonly])++ expect(locked.set('sv_locked', '2')).toEqual({ ok: false, error: 'sv_locked is read-only' })++ // The server can still replicate it.++ expect(locked.forceSet('sv_locked', 2).ok).toBe(true)++ })++++ it('only persists client flags and lets an authoritative map be applied', () => {++ const cvars = registry()++ cvars.set('volume', '0.5')++ cvars.set('cl_interp', '0.05')++ expect(cvars.snapshot((spec) => spec.flags.includes('client'))).toMatchObject({++ volume: 0.5,++ cl_interp: 0.05,++ })++ // A server value overrides the local default but not a client-only setting.++ const changed = cvars.apply({ sv_gravity: 900, volume: 0.2, unknown: 1 })++ expect(changed).toContain('sv_gravity')++ expect(cvars.get('sv_gravity')).toBe(900)++ expect(cvars.get('volume')).toBe(0.5)++ })++++ it('searches names, aliases and help, and formats help with units', () => {++ const cvars = registry()++ expect(cvars.find('gravity').map((s) => s.name)).toContain('sv_gravity')++ expect(cvars.find('surf').map((s) => s.name)).toContain('sv_airaccelerate')++ expect(cvars.matching('sv_air')).toContain('sv_airaccelerate')++ const help = cvars.help(cvars.spec('sv_gravity')!)++ expect(help[0]).toBe('sv_gravity = 800 u/s² [float]')++ expect(help.join('\n')).toContain('aliases: gravity')++ })++})nnew file mode 100644@@@ -0,0 +1,441 @@++/**++ * The console's cvar layer.++ *++ * Source names and Source units are the player-facing interface; the engine++ * keeps metres and seconds. A definition may therefore name an internal++ * `target` and a `scale` (units × scale = engine value), but the registry++ * itself stores the value the player typed in Source units. The gameplay++ * package owns the actual conversion into `MovementParams`.++ */++++/** What a cvar value can be on the wire. */++export type CvarValue = number | boolean | string++export type CvarType = 'float' | 'integer' | 'boolean' | 'string'++++/**++ * `server` — authoritative; only the server may change it.++ * `replicated` — sent in welcome/cvar_state (server values).++ * `client` — local to this browser, safe to change from the console.++ * `archive` — persisted in localStorage.++ * `cheat` — exists, but a normal player cannot change it.++ */++export type CvarFlag = 'server' | 'client' | 'replicated' | 'archive' | 'cheat' | 'readonly'++++export interface CvarSpec {++ name: string++ type: CvarType++ defaultValue: CvarValue++ flags: readonly CvarFlag[]++ help: string++ aliases?: readonly string[]++ /** Engine-side field this cvar maps onto (gameplay interprets it). */++ target?: string++ /** Source value × scale = engine value. */++ scale?: number++ unit?: string++ min?: number++ max?: number++}++++export interface CvarSetResult {++ ok: boolean++ value?: CvarValue++ error?: string++}++++const numberFor = (input: CvarValue, type: 'float' | 'integer'): number | null => {++ const value =++ typeof input === 'number'++ ? input++ : typeof input === 'string' && input.trim() !== ''++ ? Number(input)++ : Number.NaN++ if (!Number.isFinite(value)) return null++ if (type === 'integer' && !Number.isInteger(value)) return null++ return value++}++++const boolFor = (input: CvarValue): boolean | null => {++ if (typeof input === 'boolean') return input++ const value = typeof input === 'number' ? String(input) : input.trim().toLowerCase()++ if (['1', 'true', 'on', 'yes'].includes(value)) return true++ if (['0', 'false', 'off', 'no'].includes(value)) return false++ return null++}++++/** An in-memory cvar table with Source-style aliases, flags and validation. */++export class CvarRegistry {++ private readonly byName = new Map<string, CvarSpec>()++ private readonly byAlias = new Map<string, CvarSpec>()++ private readonly values = new Map<string, CvarValue>()++++ constructor(specs: readonly CvarSpec[] = []) {++ for (const spec of specs) this.register(spec)++ }++++ register(spec: CvarSpec): void {++ const name = spec.name.trim().toLowerCase()++ if (!name) throw new Error('cvar name must not be empty')++ if (this.byName.has(name)) throw new Error(`duplicate cvar "${name}"`)++ this.byName.set(name, spec)++ this.byAlias.set(name, spec)++ for (const alias of spec.aliases ?? []) {++ const key = alias.trim().toLowerCase()++ if (!key) continue++ if (this.byAlias.has(key)) throw new Error(`duplicate cvar alias "${key}"`)++ this.byAlias.set(key, spec)++ }++ this.values.set(name, spec.defaultValue)++ }++++ /** Resolve an alias to its canonical definition. */++ spec(name: string): CvarSpec | null {++ return this.byAlias.get(name.trim().toLowerCase()) ?? null++ }++++ has(name: string): boolean {++ return this.spec(name) !== null++ }++++ canonical(name: string): string | null {++ return this.spec(name)?.name ?? null++ }++++ get(name: string): CvarValue | null {++ const spec = this.spec(name)++ return spec ? (this.values.get(spec.name) ?? spec.defaultValue) : null++ }++++ /** All canonical definitions in name order. */++ all(): CvarSpec[] {++ return [...this.byName.values()].sort((a, b) => a.name.localeCompare(b.name))++ }++++ /** Canonical names and aliases that start with `prefix`. */++ matching(prefix: string): string[] {++ const p = prefix.trim().toLowerCase()++ return [...this.byAlias.keys()].filter((name) => name.startsWith(p)).sort()++ }++++ /** Definitions whose name, alias or help contains `text`. */++ find(text: string): CvarSpec[] {++ const needle = text.trim().toLowerCase()++ const out = new Set<CvarSpec>()++ for (const spec of this.byName.values()) {++ if (++ spec.name.includes(needle) ||++ spec.help.toLowerCase().includes(needle) ||++ (spec.aliases ?? []).some((a) => a.toLowerCase().includes(needle))++ )++ out.add(spec)++ }++ return [...out].sort((a, b) => a.name.localeCompare(b.name))++ }++++ /** Validate and store a player-entered value. */++ set(name: string, input: CvarValue): CvarSetResult {++ const spec = this.spec(name)++ if (!spec) return { ok: false, error: `unknown cvar "${name}"` }++ if (spec.flags.includes('readonly')) return { ok: false, error: `${spec.name} is read-only` }++ const result = this.parse(spec, input)++ if (!result.ok) return result++ this.values.set(spec.name, result.value!)++ return { ok: true, value: result.value! }++ }++++ /** Validate a value without storing it (the client does this before asking the server). */++ check(name: string, input: CvarValue): CvarSetResult {++ const spec = this.spec(name)++ if (!spec) return { ok: false, error: `unknown cvar "${name}"` }++ if (spec.flags.includes('readonly')) return { ok: false, error: `${spec.name} is read-only` }++ return this.parse(spec, input)++ }++++ /** Apply a value received from the server, bypassing the local read-only/cheat guard. */++ forceSet(name: string, input: CvarValue): CvarSetResult {++ const spec = this.spec(name)++ if (!spec) return { ok: false, error: `unknown cvar "${name}"` }++ const result = this.parse(spec, input)++ if (!result.ok) return result++ this.values.set(spec.name, result.value!)++ return { ok: true, value: result.value! }++ }++++ /** Apply an authoritative map; unknown names are ignored (newer-server tolerance). */++ apply(values: Readonly<Record<string, CvarValue>>): string[] {++ const changed: string[] = []++ for (const [name, value] of Object.entries(values)) {++ const spec = this.spec(name)++ if (!spec) continue++ // A server payload can never change a client-only setting.++ if (!spec.flags.includes('server') && !spec.flags.includes('replicated')) continue++ const result = this.forceSet(spec.name, value)++ if (result.ok) changed.push(spec.name)++ }++ return changed++ }++++ /** Canonical `{name: value}` snapshot, optionally filtered. */++ snapshot(filter?: (spec: CvarSpec) => boolean): Record<string, CvarValue> {++ const out: Record<string, CvarValue> = {}++ for (const spec of this.byName.values()) {++ if (filter && !filter(spec)) continue++ out[spec.name] = this.values.get(spec.name) ?? spec.defaultValue++ }++ return out++ }++++ /** `800`, `0.1`, `1`/`0` for booleans — what the console prints. */++ formatValue(name: string, value: CvarValue | null = this.get(name)): string {++ const spec = this.spec(name)++ if (value === null) return ''++ if (typeof value === 'boolean') return value ? '1' : '0'++ if (typeof value === 'number') {++ if (spec?.type === 'integer') return String(Math.round(value))++ return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)))++ }++ return value++ }++++ /** `sv_gravity = 800` with the unit only when asked (help shows it separately). */++ formatLine(name: string): string {++ const spec = this.spec(name)++ if (!spec) return name++ return `${spec.name} = ${this.formatValue(name)}`++ }++++ help(spec: CvarSpec): string[] {++ const lines = [++ `${spec.name} = ${this.formatValue(spec.name)}${spec.unit ? ` ${spec.unit}` : ''} [${spec.type}]`,++ spec.help,++ `flags: ${spec.flags.join(' ') || 'none'} · default: ${this.formatValue(spec.name, spec.defaultValue)}`,++ ]++ if (spec.aliases?.length) lines.push(`aliases: ${spec.aliases.join(', ')}`)++ return lines++ }++++ private parse(spec: CvarSpec, input: CvarValue): CvarSetResult {++ if (spec.type === 'string') {++ if (typeof input === 'number' || typeof input === 'boolean')++ return { ok: true, value: String(input) }++ return { ok: true, value: input }++ }++ if (spec.type === 'boolean') {++ const value = boolFor(input)++ return value === null++ ? { ok: false, error: `${spec.name} expects 0 or 1` }++ : { ok: true, value }++ }++ const value = numberFor(input, spec.type)++ if (value === null)++ return {++ ok: false,++ error: `${spec.name} expects ${spec.type === 'integer' ? 'a whole number' : 'a number'}`,++ }++ if (spec.min !== undefined && value < spec.min)++ return { ok: false, error: `${spec.name} must be at least ${spec.min}` }++ if (spec.max !== undefined && value > spec.max)++ return { ok: false, error: `${spec.name} must be at most ${spec.max}` }++ return { ok: true, value }++ }++}++++/** Source's gameplay scale: one Hammer unit is one inch. */++export const METRES_PER_UNIT = 0.0254++++/**++ * The movement cvars of §4.2, in Source units. `target`/`scale` let both the++ * client's prediction and the server's simulation apply the exact same map.++ */++export const SOURCE_MOVEMENT_CVARS: readonly CvarSpec[] = [++ {++ name: 'sv_gravity',++ aliases: ['gravity'],++ type: 'float',++ defaultValue: 800,++ min: 0,++ max: 5000,++ unit: 'u/s²',++ target: 'gravity',++ scale: METRES_PER_UNIT,++ flags: ['server', 'replicated'],++ help: "World gravity in Hammer units per second squared. 800 is Source's Earth-like default.",++ },++ {++ name: 'sv_accelerate',++ aliases: ['accelerate', 'groundaccel'],++ type: 'float',++ defaultValue: 10,++ min: 0,++ max: 100,++ target: 'groundAccel',++ scale: 1,++ flags: ['server', 'replicated'],++ help: 'Ground acceleration factor while walking.',++ },++ {++ name: 'sv_airaccelerate',++ aliases: ['airaccelerate', 'airaccel'],++ type: 'float',++ defaultValue: 150,++ min: 0,++ max: 1000,++ target: 'airAccel',++ scale: 1,++ flags: ['server', 'replicated'],++ help: 'Air acceleration factor. Surf servers use 150; CS:S default is 10.',++ },++ {++ name: 'sv_friction',++ aliases: ['friction'],++ type: 'float',++ defaultValue: 4,++ min: 0,++ max: 100,++ target: 'friction',++ scale: 1,++ flags: ['server', 'replicated'],++ help: 'Ground friction. CS:S uses 4.',++ },++ {++ name: 'sv_stopspeed',++ aliases: ['stopspeed'],++ type: 'float',++ defaultValue: 2.032 / METRES_PER_UNIT,++ min: 0,++ max: 1000,++ unit: 'u/s',++ target: 'stopSpeed',++ scale: METRES_PER_UNIT,++ flags: ['server', 'replicated'],++ help: 'Speed below which friction stops the player crisply (Source: 80 u/s).',++ },++ {++ name: 'sv_maxspeed',++ aliases: ['maxspeed', 'maxgroundspeed'],++ type: 'float',++ defaultValue: 250,++ min: 0,++ max: 3500,++ unit: 'u/s',++ target: 'maxGroundSpeed',++ scale: METRES_PER_UNIT,++ flags: ['server', 'replicated'],++ help: 'Maximum ground speed. CS:S run speed is 250 u/s.',++ },++ {++ name: 'sv_maxvelocity',++ aliases: ['maxvelocity', 'maxhorizontalspeed'],++ type: 'float',++ defaultValue: 3500,++ min: 0,++ max: 100000,++ unit: 'u/s',++ target: 'maxHorizontalSpeed',++ scale: METRES_PER_UNIT,++ flags: ['server', 'replicated'],++ help: 'Horizontal speed ceiling. Source caps velocity at 3500 u/s.',++ },++ {++ name: 'sv_stepsize',++ aliases: ['stepsize', 'stepheight'],++ type: 'float',++ defaultValue: 18,++ min: 0,++ max: 72,++ unit: 'u',++ target: 'stepHeight',++ scale: METRES_PER_UNIT,++ flags: ['server', 'replicated'],++ help: 'Maximum obstacle height stepped over. Source uses 18 units.',++ },++ {++ name: 'sv_air_max_wishspeed',++ aliases: ['air_max_wishspeed', 'airspeedcap'],++ type: 'float',++ defaultValue: 30,++ min: 0,++ max: 1000,++ unit: 'u/s',++ target: 'airSpeedCap',++ scale: METRES_PER_UNIT,++ flags: ['server', 'replicated'],++ help: 'Per-tick air wish-speed cap in Hammer units. Surf uses 30 u/s (0.762 m/s per tick at 100 Hz).',++ },++ {++ name: 'sv_jump_impulse',++ aliases: ['jump_impulse', 'jumpspeed'],++ type: 'float',++ defaultValue: 301.993,++ min: 0,++ max: 1000,++ unit: 'u/s',++ target: 'jumpSpeed',++ scale: METRES_PER_UNIT,++ flags: ['server', 'replicated'],++ help: "Vertical jump impulse. Source's 301.993 u/s clears a 52-unit block.",++ },++ {++ name: 'sv_autobunnyhopping',++ aliases: ['autobunnyhopping', 'autobhop'],++ type: 'boolean',++ defaultValue: false,++ target: 'autoBhop',++ scale: 1,++ flags: ['server', 'replicated'],++ help: 'Hold jump to bunnyhop automatically. Source servers opt in; surf defaults off.',++ },++ {++ name: 'sv_standable_normal',++ aliases: ['standable_normal', 'groundnormaly'],++ type: 'float',++ defaultValue: 0.7,++ min: 0,++ max: 1,++ target: 'groundNormalY',++ scale: 1,++ flags: ['server', 'replicated'],++ help: 'Lowest surface normal Y treated as walkable ground. 0.7 ≈ 45.6°.',++ },++]++++/** Client-only settings; the server never sees these. */++export const CLIENT_CVARS: readonly CvarSpec[] = [++ {++ name: 'cl_interp',++ type: 'float',++ defaultValue: 0.1,++ min: 0,++ max: 1,++ unit: 's',++ flags: ['client', 'archive'],++ help: 'Interpolation delay used for remote entities. Source default is 0.1 s.',++ },++ {++ name: 'fps_max',++ type: 'integer',++ defaultValue: 0,++ min: 0,++ max: 1000,++ flags: ['client', 'archive'],++ help: 'Frame-rate cap; 0 means the browser decides.',++ },++ {++ name: 'volume',++ type: 'float',++ defaultValue: 1,++ min: 0,++ max: 1,++ flags: ['client', 'archive'],++ help: 'Game audio volume, 0 to 1.',++ },++ {++ name: 'crosshair',++ type: 'integer',++ defaultValue: 1,++ min: 0,++ max: 1,++ flags: ['client', 'archive'],++ help: 'Draw the crosshair: 1 on, 0 off.',++ },++]@@@ -8,3 +8,5 @@ export * from './result.js'export * from './random.js'export * from './log.js'export * from './tick.js'++export * from './commands.js'++export * from './console/registry.js'nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/ui/console.test.ts'++// covers: 'apps/client/src/ui'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/game/systems/settings.test.ts'++// covers: 'apps/server/src/game/systems'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/gameplay/src/movement/cvars.test.ts'++// covers: 'packages/gameplay/src/movement'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/shared/src/commands.test.ts'++// covers: 'packages/shared/src'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'packages/shared/src/console/registry.test.ts'++// covers: 'packages/shared/src/console'++// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
+/**
+ * Console smoke (job 4): a real browser opens ~, types a Source query and
+ * closes with Escape on the surf page.
+ *
+ * Run after `pnpm --filter @openvibe/client build`:
+ * pnpm test:console
+ *
+ * The game server hosts every surf place itself, so `/surf/embed?map=surf_lagoon`
+ * is served without extra fixtures. The assertions are behavioural: the DOM
+ * overlay, the input capture that keeps typing out of movement, the
+ * authoritative `sv_gravity` value from welcome, and an autoexec bind.
+ */
+import { spawn } from 'node:child_process'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { chromium, type Page } from 'playwright'
+
+const PORT = 18196
+const dir = mkdtempSync(join(tmpdir(), 'openvibe-console-'))
+
+const waitForServer = (server: ReturnType<typeof spawn>): Promise<void> =>
+ new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('server did not start')), 90_000)
+ server.stdout?.on('data', (chunk: Buffer) => {
+ if (chunk.toString().includes('listening')) {
+ clearTimeout(timer)
+ resolve()
+ }
+ })
+ server.stderr?.on('data', (chunk: Buffer) => process.stderr.write(chunk))
+ })
+
+/** Poll in the page with page.evaluate; rAF-driven waitForFunction is unreliable headless. */
+async function poll(
+ page: Page,
+ check: () => boolean,
+ timeoutMs: number,
+ label: string,
+): Promise<void> {
+ const end = Date.now() + timeoutMs
+ while (Date.now() < end) {
+ if (await page.evaluate(check)) return
+ await page.waitForTimeout(50)
+ }
+ throw new Error(`timed out waiting for ${label}`)
+}
+
+async function main(): Promise<void> {
+ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {
+ env: {
+ ...process.env,
+ PORT: String(PORT),
+ NODE_ENV: 'test',
+ DATABASE_DIR: dir,
+ DATABASE_URL: '',
+ DATABASE_DIRECT_URL: '',
+ STATIC_DIR: 'apps/client/dist',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ try {
+ await waitForServer(server)
+ const browser = await chromium.launch({
+ args: ['--use-angle=swiftshader', '--enable-webgl', '--disable-gpu-sandbox'],
+ })
+ try {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
+ const errors: string[] = []
+ page.on('pageerror', (err) => errors.push(String(err)))
+ const url = `http://127.0.0.1:${PORT}/surf/embed?map=surf_lagoon`
+ // autoexec.cfg is a virtual file, applied once welcome has landed. Set
+ // it with the page's real origin and reload, rather than racing an init
+ // script against the app's boot.
+ await page.goto(url, { waitUntil: 'domcontentloaded' })
+ await page.evaluate(() => {
+ localStorage.setItem('openvibe.cfg.autoexec.cfg', 'bind q +duck; echo AUTOEXEC_RAN')
+ })
+ await page.reload({ waitUntil: 'domcontentloaded' })
+
+ await poll(
+ page,
+ () =>
+ Boolean(
+ (window as unknown as { __openvibe?: { state?: { myEntityId?: string } } }).__openvibe
+ ?.state?.myEntityId,
+ ),
+ 90_000,
+ 'welcome',
+ )
+ await poll(
+ page,
+ () =>
+ document
+ .querySelector('.ovg-console .console-log')
+ ?.textContent?.includes('AUTOEXEC_RAN') ?? false,
+ 10_000,
+ 'autoexec.cfg',
+ )
+ const bound = await page.evaluate(() =>
+ (
+ window as unknown as {
+ __openvibe: { input: { bindings: { commandForCode(code: string): string | null } } }
+ }
+ ).__openvibe.input.bindings.commandForCode('KeyQ'),
+ )
+ if (bound !== '+duck') throw new Error(`autoexec bind missing: ${String(bound)}`)
+
+ await page.bringToFront()
+ const consoleOpen = (): Promise<boolean> =>
+ page.evaluate(() => {
+ const el = document.querySelector('.ovg-console')
+ return el instanceof HTMLElement && !el.hidden
+ })
+ // A fresh tab can lose the first synthetic key to focus; retry the real
+ // key, not a synthetic DOM event, so this still proves ~ opens it.
+ for (let attempt = 0; attempt < 3 && !(await consoleOpen()); attempt++) {
+ await page.keyboard.press('`')
+ await page.waitForTimeout(200)
+ }
+ if (!(await consoleOpen())) throw new Error('~ did not open the console')
+ // The overlay focuses its input in a microtask; focus explicitly so the
+ // smoke is about the console behaviour, not a one-frame focus race.
+ await page.locator('.console-input').focus()
+ const captured = await page.evaluate(
+ () =>
+ (window as unknown as { __openvibe: { input: { uiCapture: boolean } } }).__openvibe.input
+ .uiCapture,
+ )
+ if (!captured) throw new Error('opening the console did not capture input')
+
+ await page.keyboard.type('sv_gravity')
+ await page.keyboard.press('Enter')
+ await poll(
+ page,
+ () =>
+ document
+ .querySelector('.ovg-console .console-log')
+ ?.textContent?.includes('sv_gravity = 800') ?? false,
+ 5_000,
+ 'sv_gravity reply',
+ )
+ await page.keyboard.type('help sv_airaccelerate')
+ await page.keyboard.press('Enter')
+ await poll(
+ page,
+ () =>
+ document
+ .querySelector('.ovg-console .console-log')
+ ?.textContent?.includes('sv_airaccelerate = 150') ?? false,
+ 5_000,
+ 'sv_airaccelerate reply',
+ )
+
+ await page.keyboard.press('Escape')
+ await poll(
+ page,
+ () => document.querySelector('.ovg-console')?.hasAttribute('hidden') ?? false,
+ 5_000,
+ 'console close',
+ )
+ const released = await page.evaluate(
+ () =>
+ (window as unknown as { __openvibe: { input: { uiCapture: boolean } } }).__openvibe.input
+ .uiCapture,
+ )
+ if (released) throw new Error('closing the console did not release input')
+ if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`)
+ console.log('console smoke passed: open, type sv_gravity/help, Escape close')
+ } finally {
+ await browser.close()
+ }
+ } finally {
+ server.kill()
+ rmSync(dir, { recursive: true, force: true })
+ }
+}
+
+void main().catch((err: unknown) => {
+ console.error(err)
+ process.exitCode = 1
+})
@@ -14,6 +14,8 @@ import {
@@ -14,6 +14,8 @@ import {
import { ApiError, type ActionAnswer, type CasualApi, type Player } from './api.js'
import { ApiError, type ActionAnswer, type CasualApi, type Player } from './api.js'
import { icon } from './art.js'
import { icon } from './art.js'
import { button, duration, h, num, sheet, tick, toast } from './dom.js'
import { button, duration, h, num, sheet, tick, toast } from './dom.js'
+import { CvarRegistry, CLIENT_CVARS } from '@openvibe/shared'
+import { GameConsole } from '../ui/console.js'
import { forgetSession } from '../net/identity.js'
import { forgetSession } from '../net/identity.js'
export type Tab = 'home' | 'heroes' | 'summon' | 'quests' | 'shop'
export type Tab = 'home' | 'heroes' | 'summon' | 'quests' | 'shop'
@@ -35,6 +37,8 @@ export class App {
@@ -35,6 +37,8 @@ export class App {
private view = h('section', { class: 'cg-view', attrs: { 'aria-live': 'off' } })
private view = h('section', { class: 'cg-view', attrs: { 'aria-live': 'off' } })
private tabs = h('nav', { class: 'cg-tabs', attrs: { 'aria-label': 'Game sections' } })
private tabs = h('nav', { class: 'cg-tabs', attrs: { 'aria-label': 'Game sections' } })
private menu: (app: App) => void = () => {}
private menu: (app: App) => void = () => {}
+ /** Same ~ overlay as the 3D client, with read-only commands only. */
+ readonly console: GameConsole
constructor(
constructor(
readonly root: HTMLElement,
readonly root: HTMLElement,
@@ -46,6 +50,15 @@ export class App {
@@ -46,6 +50,15 @@ export class App {
private views: Record<Tab, View>,
private views: Record<Tab, View>,
) {
) {
root.replaceChildren(this.top, this.view, this.tabs)
root.replaceChildren(this.top, this.view, this.tabs)
+ this.console = new GameConsole({
+ parent: document.body,
+ cvars: new CvarRegistry(CLIENT_CVARS),
+ readOnly: true,
+ })
+ this.console.setStatusProvider(() => [
+ `${this.pack.name} · ${this.player.name}`,
+ `tab ${this.tab} · version ${this.version} · read-only console`,
+ ])
}
}
onMenu(fn: (app: App) => void): void {
onMenu(fn: (app: App) => void): void {
@@ -76,7 +76,7 @@ export class LocalPlayer {
@@ -76,7 +76,7 @@ export class LocalPlayer {
private readonly state: ClientState,
private readonly state: ClientState,
spawn: { x: number; y: number; z: number },
spawn: { x: number; y: number; z: number },
/** The place's movement preset, resolved on both sides (surf vs default). */
/** The place's movement preset, resolved on both sides (surf vs default). */
- readonly params: MovementParams = DEFAULT_MOVEMENT,
+ public params: MovementParams = DEFAULT_MOVEMENT,
) {
) {
this.move = createMoveState(spawn)
this.move = createMoveState(spawn)
this.eyeSmooth = eyeOffsetFor(Stance.Stand, params)
this.eyeSmooth = eyeOffsetFor(Stance.Stand, params)
@@ -108,6 +108,11 @@ export class LocalPlayer {
@@ -108,6 +108,11 @@ export class LocalPlayer {
this.currPos.set(spawn.x, spawn.y, spawn.z)
this.currPos.set(spawn.x, spawn.y, spawn.z)
}
}
+ /** Adopt an authoritative cvar change without resetting the predicted pose. */
+ setMovementParams(params: MovementParams): void {
+ this.params = params
+ }
+
/** One fixed simulation tick: capture, send, predict. */
/** One fixed simulation tick: capture, send, predict. */
fixedUpdate(): void {
fixedUpdate(): void {
const sample = this.input.sample()
const sample = this.input.sample()
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
-import { Intents } from '@openvibe/shared'
+import { Bindings, Intents } from '@openvibe/shared'
import {
import {
KeyboardInputSource,
KeyboardInputSource,
SwitchableInputSource,
SwitchableInputSource,
@@ -143,6 +143,26 @@ describe('input sources', () => {
@@ -143,6 +143,26 @@ describe('input sources', () => {
expect(new Set(fromKeyboard).size).toBeGreaterThan(4)
expect(new Set(fromKeyboard).size).toBeGreaterThan(4)
})
})
+ it('reads intents from Source binds, and console + commands set the same bits', () => {
+ const keyboard = new FakeKeyboard()
+ const binds = new Bindings({ storage: null, defaults: { space: '+jump', ctrl: '+duck' } })
+ const source = new KeyboardInputSource(keyboard, binds)
+ keyboard.keys.add('Space')
+ expect(source.sample().intents).toBe(Intents.Jump)
+ keyboard.keys.add('ControlLeft')
+ expect(source.sample().intents).toBe(Intents.Jump | Intents.Crouch)
+ // Rebinding space mid-session takes effect on the next sample.
+ binds.bind('space', '+duck')
+ expect(source.sample().intents).toBe(Intents.Crouch)
+ // A console-typed +jump feeds the same intent bit.
+ source.setManualIntent('Jump', true)
+ expect(source.sample().intents).toBe(Intents.Crouch | Intents.Jump)
+ source.setManualIntent('Jump', false)
+ expect(source.sample().intents).toBe(Intents.Crouch)
+ binds.unbind('ctrl')
+ keyboard.keys.delete('ControlLeft')
+ })
+
it('the switchable source samples whichever device is current', () => {
it('the switchable source samples whichever device is current', () => {
const keyboard = new FakeKeyboard()
const keyboard = new FakeKeyboard()
keyboard.keys.add('Space')
keyboard.keys.add('Space')
@@ -1,4 +1,10 @@
@@ -1,4 +1,10 @@
-import { Intents } from '@openvibe/shared'
+import {
+ Intents,
+ intentCommand,
+ keyCodeFor,
+ type Bindings,
+ type IntentName,
+} from '@openvibe/shared'
/** One simulation tick of intent input, independent of the device behind it. */
/** One simulation tick of intent input, independent of the device behind it. */
export interface InputSample {
export interface InputSample {
@@ -35,9 +41,23 @@ export interface KeyboardState {
@@ -35,9 +41,23 @@ export interface KeyboardState {
readonly pitch: number
readonly pitch: number
}
}
-/** Keyboard + mouse: today's desktop bindings, mapped onto named intents. */
+/** Keyboard + mouse: Source key bindings, mapped onto named intents. */
export class KeyboardInputSource implements InputSource {
export class KeyboardInputSource implements InputSource {
- constructor(private readonly keys: KeyboardState) {}
+ /** Console-typed `+jump` / `-jump`, independent of the physical keys. */
+ private manualIntents = 0
+
+ constructor(
+ private readonly keys: KeyboardState,
+ /** When present, the player's binds replace the stock intent keys. */
+ private readonly bindings: Bindings | null = null,
+ ) {}
+
+ /** A `+`/`-` command typed in the console feeds the same intent bits as a key. */
+ setManualIntent(name: string, down: boolean): void {
+ const bit = Intents[name as IntentName]
+ if (typeof bit !== 'number') return
+ this.manualIntents = down ? this.manualIntents | bit : this.manualIntents & ~bit
+ }
get yaw(): number {
get yaw(): number {
return this.keys.yaw
return this.keys.yaw
@@ -49,11 +69,21 @@ export class KeyboardInputSource implements InputSource {
@@ -49,11 +69,21 @@ export class KeyboardInputSource implements InputSource {
sample(): InputSample {
sample(): InputSample {
const keys = this.keys
const keys = this.keys
- let intents = 0
- if (keys.keyDown('Space')) intents |= Intents.Jump
- if (keys.keyDown('ShiftLeft')) intents |= Intents.Sprint
- if (keys.keyDown('ControlLeft') || keys.keyDown('KeyC')) intents |= Intents.Crouch
- if (keys.keyDown('KeyZ')) intents |= Intents.Prone
+ let intents = this.manualIntents
+ if (this.bindings) {
+ for (const binding of this.bindings.entries()) {
+ const code = keyCodeFor(binding.key)
+ if (!code || !keys.keyDown(code)) continue
+ const intent = intentCommand(binding.command)
+ if (intent?.down) intents |= Intents[intent.name]
+ }
+ } else {
+ // No console mounted (unit tests, embeds): the stock controls remain.
+ if (keys.keyDown('Space')) intents |= Intents.Jump
+ if (keys.keyDown('ShiftLeft')) intents |= Intents.Sprint
+ if (keys.keyDown('ControlLeft') || keys.keyDown('KeyC')) intents |= Intents.Crouch
+ if (keys.keyDown('KeyZ')) intents |= Intents.Prone
+ }
const moveX = (keys.keyDown('KeyD') ? 1 : 0) - (keys.keyDown('KeyA') ? 1 : 0)
const moveX = (keys.keyDown('KeyD') ? 1 : 0) - (keys.keyDown('KeyA') ? 1 : 0)
const moveZ = (keys.keyDown('KeyW') ? 1 : 0) - (keys.keyDown('KeyS') ? 1 : 0)
const moveZ = (keys.keyDown('KeyW') ? 1 : 0) - (keys.keyDown('KeyS') ? 1 : 0)
return { moveX, moveZ, yaw: keys.yaw, pitch: keys.pitch, intents }
return { moveX, moveZ, yaw: keys.yaw, pitch: keys.pitch, intents }
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
+import { Bindings } from '@openvibe/shared'
import { InputTracker } from './inputTracker.js'
import { InputTracker } from './inputTracker.js'
function setup(pointerLock: boolean) {
function setup(pointerLock: boolean) {
@@ -19,7 +20,7 @@ function setup(pointerLock: boolean) {
@@ -19,7 +20,7 @@ function setup(pointerLock: boolean) {
target.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, buttons: 0 }))
target.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, buttons: 0 }))
const scroll = (target: Element) =>
const scroll = (target: Element) =>
target.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 }))
target.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 }))
- return { canvas, hud, actions, wheel, press, release, scroll }
+ return { canvas, hud, input, actions, wheel, press, release, scroll }
}
}
describe('input tracker presses', () => {
describe('input tracker presses', () => {
@@ -46,4 +47,45 @@ describe('input tracker presses', () => {
@@ -46,4 +47,45 @@ describe('input tracker presses', () => {
expect(actions).toEqual([])
expect(actions).toEqual([])
expect(wheel).toEqual([])
expect(wheel).toEqual([])
})
})
+
+ it('routes a bound key to onBind instead of the stock action table', () => {
+ const { input, actions } = setup(false)
+ input.bindings = new Bindings({ storage: null, defaults: { space: '+jump', r: '+restart' } })
+ const bound: [string, boolean][] = []
+ input.onBind = (command, down) => bound.push([command, down])
+ const press = (code: string) =>
+ document.body.dispatchEvent(
+ new KeyboardEvent('keydown', { code, key: code, bubbles: true, cancelable: true }),
+ )
+ const release = (code: string) =>
+ document.body.dispatchEvent(
+ new KeyboardEvent('keyup', { code, key: code, bubbles: true, cancelable: true }),
+ )
+ press('Space')
+ expect(bound).toEqual([['+jump', true]])
+ expect(input.keyDown('Space')).toBe(true)
+ release('Space')
+ expect(bound).toEqual([
+ ['+jump', true],
+ ['+jump', false],
+ ])
+ // R would normally reload; the bind wins, and no stock action fires.
+ press('KeyR')
+ expect(bound.at(-1)).toEqual(['+restart', true])
+ expect(actions).toEqual([])
+ })
+
+ it('ignores keys aimed at a text input, even with no UI capture', () => {
+ const { input, actions } = setup(false)
+ const field = document.createElement('input')
+ document.body.append(field)
+ field.dispatchEvent(
+ new KeyboardEvent('keydown', { code: 'Space', key: ' ', bubbles: true, cancelable: true }),
+ )
+ expect(input.keyDown('Space')).toBe(false)
+ field.dispatchEvent(
+ new KeyboardEvent('keydown', { code: 'KeyE', key: 'e', bubbles: true, cancelable: true }),
+ )
+ expect(actions).toEqual([])
+ })
})
})
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
* pointer is locked some browsers retarget events inconsistently, and a
* pointer is locked some browsers retarget events inconsistently, and a
* document listener sees them regardless.
* document listener sees them regardless.
*/
*/
+import type { Bindings } from '@openvibe/shared'
+
export class InputTracker {
export class InputTracker {
yaw = 0
yaw = 0
pitch = 0
pitch = 0
@@ -37,6 +39,10 @@ export class InputTracker {
@@ -37,6 +39,10 @@ export class InputTracker {
private lookDy = 0
private lookDy = 0
/** Last seen mouse `buttons` bitmask (chorded press/release detection). */
/** Last seen mouse `buttons` bitmask (chorded press/release detection). */
private buttonsState = 0
private buttonsState = 0
+ /** Source-style key bindings; bound keys bypass the stock action table. */
+ bindings: Bindings | null = null
+ /** `+command` on press, `-command` on release. Intents are read by the input source. */
+ onBind: ((command: string, down: boolean) => void) | null = null
/** Emits press/release actions from `buttons` bitmask transitions. */
/** Emits press/release actions from `buttons` bitmask transitions. */
private diffButtons(e: MouseEvent): void {
private diffButtons(e: MouseEvent): void {
@@ -100,7 +106,20 @@ export class InputTracker {
@@ -100,7 +106,20 @@ export class InputTracker {
})
})
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (e.repeat) return
if (e.repeat) return
+ // A focused input (the console, a name field) owns its keystrokes:
+ // typing must never add a held game key. The console also sets
+ // `uiCapture`, but this is true even before the callback runs.
+ if (isTypingTarget(e.target)) return
this.keys.add(e.code)
this.keys.add(e.code)
+ // A bound key means exactly what the player bound it to. `+jump` and
+ // its siblings still reach the input source through `keys`; one-shot
+ // commands (`toggle_ghost`, `+restart`) go to onBind.
+ const bound = this.bindings?.commandForCode(e.code) ?? null
+ if (bound && !this.uiCapture) {
+ e.preventDefault()
+ this.onBind?.(bound, true)
+ return
+ }
if (e.code === 'KeyE' && !this.uiCapture) {
if (e.code === 'KeyE' && !this.uiCapture) {
e.preventDefault()
e.preventDefault()
this.onAction?.({ kind: 'use_down' })
this.onAction?.({ kind: 'use_down' })
@@ -117,7 +136,14 @@ export class InputTracker {
@@ -117,7 +136,14 @@ export class InputTracker {
if (e.code === 'Tab') e.preventDefault()
if (e.code === 'Tab') e.preventDefault()
})
})
document.addEventListener('keyup', (e) => {
document.addEventListener('keyup', (e) => {
- this.keys.delete(e.code)
+ const wasDown = this.keys.delete(e.code)
+ if (isTypingTarget(e.target)) return
+ const bound = this.bindings?.commandForCode(e.code) ?? null
+ if (bound && wasDown && !this.uiCapture) {
+ e.preventDefault()
+ this.onBind?.(bound, false)
+ return
+ }
if (e.code === 'KeyE' && !this.uiCapture) {
if (e.code === 'KeyE' && !this.uiCapture) {
this.onAction?.({ kind: 'use_up' })
this.onAction?.({ kind: 'use_up' })
}
}
@@ -192,6 +218,16 @@ export class InputTracker {
@@ -192,6 +218,16 @@ export class InputTracker {
}
}
}
}
+/** A key event aimed at an editable control is text, not a game input. */
+function isTypingTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false
+ return (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target.isContentEditable
+ )
+}
+
export type InputAction =
export type InputAction =
| { kind: 'use_down' }
| { kind: 'use_down' }
| { kind: 'use_up' }
| { kind: 'use_up' }
@@ -18,7 +18,16 @@ import {
@@ -18,7 +18,16 @@ import {
} from '@openvibe/content'
} from '@openvibe/content'
import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'
import { MapLightLayer, mapDiff, staticWork, terrainWork } from './render/mapRuntimeLayers.js'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
-import { FixedTimestep } from '@openvibe/shared'
+import {
+ CLIENT_CVARS,
+ CvarRegistry,
+ DEFAULT_BINDS,
+ Bindings,
+ ConfigStore,
+ FixedTimestep,
+ SOURCE_MOVEMENT_CVARS,
+ isIntentCommand,
+} from '@openvibe/shared'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import { defaultAppearance, type Appearance } from '@openvibe/protocol'
import { defaultAppearance, type Appearance } from '@openvibe/protocol'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
@@ -27,7 +36,9 @@ import { WATER_LEVEL, getMapOverride, terrainHeight } from '@openvibe/content'
@@ -27,7 +36,9 @@ import { WATER_LEVEL, getMapOverride, terrainHeight } from '@openvibe/content'
import {
import {
DEFAULT_MOVEMENT,
DEFAULT_MOVEMENT,
decodeSurfTrack,
decodeSurfTrack,
+ movementCvarValues,
movementParamsFor,
movementParamsFor,
+ movementParamsWithCvars,
movementStyleFor,
movementStyleFor,
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
import { Sfx, footstepSurface, materialForVisual } from './audio/sfx.js'
@@ -68,6 +79,7 @@ import { ClientState } from './state/clientState.js'
@@ -68,6 +79,7 @@ import { ClientState } from './state/clientState.js'
import { characterSelect, type CharacterInfo } from './ui/characterSelect.js'
import { characterSelect, type CharacterInfo } from './ui/characterSelect.js'
import { customizeScreen } from './ui/customizeScreen.js'
import { customizeScreen } from './ui/customizeScreen.js'
import { Hud } from './ui/hud.js'
import { Hud } from './ui/hud.js'
+import { GameConsole } from './ui/console.js'
import { IconFactory } from './ui/iconFactory.js'
import { IconFactory } from './ui/iconFactory.js'
import { registerPhysgunModule } from './weapons/physgunModule.js'
import { registerPhysgunModule } from './weapons/physgunModule.js'
import { registerRiggingModule } from './weapons/riggingModule.js'
import { registerRiggingModule } from './weapons/riggingModule.js'
@@ -166,14 +178,13 @@ async function start(): Promise<void> {
@@ -166,14 +178,13 @@ async function start(): Promise<void> {
}
}
if (Array.isArray(data.packs)) {
if (Array.isArray(data.packs)) {
const contentSurf = data.game ? surfMapOfPlace(data.game) : null
const contentSurf = data.game ? surfMapOfPlace(data.game) : null
- const builtIn =
- contentSurf
- ? surfPack(contentSurf.id)
- : data.game === 'salvage-rush'
- ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK
- : data.game === 'creator' && worldId
- ? creatorWorldPack(worldId)
- : undefined
+ const builtIn = contentSurf
+ ? surfPack(contentSurf.id)
+ : data.game === 'salvage-rush'
+ ? (await import('@openvibe/content/salvage-rush')).SALVAGE_RUSH_PACK
+ : data.game === 'creator' && worldId
+ ? creatorWorldPack(worldId)
+ : undefined
const active = createContent(data.packs, builtIn)
const active = createContent(data.packs, builtIn)
if (active.digest === data.digest) content = active
if (active.digest === data.digest) content = active
}
}
@@ -315,9 +326,36 @@ async function start(): Promise<void> {
@@ -315,9 +326,36 @@ async function start(): Promise<void> {
guest: identity.token,
guest: identity.token,
...(sso ? { bearer: sso } : {}),
...(sso ? { bearer: sso } : {}),
})
})
+ // The ~ console: Source vocabulary over this place's movement preset. The
+ // server sends the authoritative values in welcome/cvar_state; the console
+ // only ever requests a server cvar change.
+ const baseMovement = movementParamsFor(movementStyleFor(content.world))
+ const cvarRegistry = new CvarRegistry([...SOURCE_MOVEMENT_CVARS, ...CLIENT_CVARS])
+ cvarRegistry.apply(movementCvarValues(baseMovement))
+ const bindings = new Bindings({
+ defaults: {
+ ...DEFAULT_BINDS,
+ // Scraplandia keeps its existing R/G verbs; surf binds them to the run.
+ ...(surfInfo ? {} : { r: '+reload', g: '+drop' }),
+ },
+ })
+ const gameConsole = new GameConsole({
+ parent: uiRoot,
+ cvars: cvarRegistry,
+ bindings,
+ configs: new ConfigStore(),
+ })
+ gameConsole.setStatusProvider(() => [
+ `${name} · tick ${state.serverTick} · ${state.entities.size} entities`,
+ `entity ${state.myEntityId || '—'} · rank ${state.myRank ?? 'player'} · ${connection.open ? 'connected' : 'offline'}`,
+ ])
+ gameConsole.onCvarRequest = (cvar, value, requestId) => {
+ connection.send({ t: 'cvar_set', name: cvar, value, requestId })
+ }
+
// Source's chat keys: Y talks to the room, U to the team. Capture-phase so
// Source's chat keys: Y talks to the room, U to the team. Capture-phase so
// the game never reads them as movement/tool keys; the HUD owns the input
// the game never reads them as movement/tool keys; the HUD owns the input
- // and its capture handoff.
+ // and its capture handoff. The console's own textarea stops these keys too.
document.addEventListener(
document.addEventListener(
'keydown',
'keydown',
(e) => {
(e) => {
@@ -362,7 +400,8 @@ async function start(): Promise<void> {
@@ -362,7 +400,8 @@ async function start(): Promise<void> {
// Gathering: point at whichever pile gives what is still missing.
// Gathering: point at whichever pile gives what is still missing.
const hasWood = state.countOf('wood_log') > 0 || state.countOf('wood_plank') > 0
const hasWood = state.countOf('wood_log') > 0 || state.countOf('wood_plank') > 0
const hasStone = state.countOf('stone') > 0
const hasStone = state.countOf('stone') > 0
- const prefer = step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []
+ const prefer =
+ step.id === 'gather' ? (hasStone ? ['branch_pile'] : hasWood ? ['loose_stones'] : []) : []
const sightings: NodeSighting[] = []
const sightings: NodeSighting[] = []
// Props the step names (the campfire, the workbench, the trading post) count as sightings too.
// Props the step names (the campfire, the workbench, the trading post) count as sightings too.
const props = new Set(toward.props ?? [])
const props = new Set(toward.props ?? [])
@@ -377,11 +416,21 @@ async function start(): Promise<void> {
@@ -377,11 +416,21 @@ async function start(): Promise<void> {
remaining: e.remaining ?? 1,
remaining: e.remaining ?? 1,
})
})
} else if (e.kind === 'prop' && props.has(e.def)) {
} else if (e.kind === 'prop' && props.has(e.def)) {
- sightings.push({ def: e.def, name: content.item(e.def)?.name ?? e.def, x: e.pos[0], z: e.pos[2], remaining: 1 })
+ sightings.push({
+ def: e.def,
+ name: content.item(e.def)?.name ?? e.def,
+ x: e.pos[0],
+ z: e.pos[2],
+ remaining: 1,
+ })
}
}
}
}
const zones = getMapOverride(content.world)?.zones ?? []
const zones = getMapOverride(content.world)?.zones ?? []
- const wanted = { nodes: [...(toward.nodes ?? []), ...props], ...(toward.zone ? { zone: toward.zone } : {}), prefer }
+ const wanted = {
+ nodes: [...(toward.nodes ?? []), ...props],
+ ...(toward.zone ? { zone: toward.zone } : {}),
+ prefer,
+ }
waypoint.update(from, player.viewYaw, pickTarget(from, wanted, sightings, zones))
waypoint.update(from, player.viewYaw, pickTarget(from, wanted, sightings, zones))
}
}
}
}
@@ -403,7 +452,8 @@ async function start(): Promise<void> {
@@ -403,7 +452,8 @@ async function start(): Promise<void> {
// Touch on a coarse pointer, keyboard otherwise; a device with both
// Touch on a coarse pointer, keyboard otherwise; a device with both
// switches to whichever was used last, carrying the view angle across.
// switches to whichever was used last, carrying the view angle across.
- const keyboardSource = new KeyboardInputSource(input)
+ const keyboardSource = new KeyboardInputSource(input, bindings)
+ gameConsole.onIntentCommand = (intent, down) => keyboardSource.setManualIntent(intent, down)
const touchPad = new TouchPad()
const touchPad = new TouchPad()
const touchSource = new TouchInputSource(touchPad)
const touchSource = new TouchInputSource(touchPad)
const touchControls = new TouchControls(uiRoot, canvas, touchPad)
const touchControls = new TouchControls(uiRoot, canvas, touchPad)
@@ -473,11 +523,16 @@ async function start(): Promise<void> {
@@ -473,11 +523,16 @@ async function start(): Promise<void> {
y: placeSpawn.pos[1],
y: placeSpawn.pos[1],
z: placeSpawn.pos[2],
z: placeSpawn.pos[2],
},
},
- movementParamsFor(movementStyleFor(content.world)),
+ baseMovement,
)
)
scene.activeCamera = player.camera
scene.activeCamera = player.camera
releaseCamera()
releaseCamera()
environment.attachCamera(player.camera)
environment.attachCamera(player.camera)
+ // The server's authoritative cvar values are the only source for movement
+ // parameter changes; prediction and simulation share the same conversion.
+ gameConsole.onServerCvars = (values) => {
+ player.setMovementParams(movementParamsWithCvars(baseMovement, values))
+ }
const interact = new InteractionController(
const interact = new InteractionController(
physics,
physics,
player,
player,
@@ -496,6 +551,7 @@ async function start(): Promise<void> {
@@ -496,6 +551,7 @@ async function start(): Promise<void> {
connection,
connection,
player,
player,
icons,
icons,
+ console: gameConsole,
}
}
const fpBody = new FirstPersonBody(scene, content, appearance, player, state)
const fpBody = new FirstPersonBody(scene, content, appearance, player, state)
const viewmodel = new Viewmodel(scene, content, player.camera, appearance)
const viewmodel = new Viewmodel(scene, content, player.camera, appearance)
@@ -555,27 +611,49 @@ async function start(): Promise<void> {
@@ -555,27 +611,49 @@ async function start(): Promise<void> {
// Your PB is the default ghost; the WR is the fallback before your first run.
// Your PB is the default ghost; the WR is the fallback before your first run.
void loadSurfGhost('pb').then((ok) => (ok ? true : loadSurfGhost('wr')))
void loadSurfGhost('pb').then((ok) => (ok ? true : loadSurfGhost('wr')))
}
}
- // Ride's keys. Capture-phase: InputTracker would otherwise read R as
- // reload and G as drop, neither of which exists in a surf place.
- document.addEventListener(
- 'keydown',
- (e) => {
- if (!surfInfo) return
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
- if (input.uiCapture) return
- if (e.code !== 'KeyR' && e.code !== 'KeyG') return
- e.preventDefault()
- e.stopImmediatePropagation()
- if (e.code === 'KeyR') {
- surfUi?.hideFinish()
- connection.send({ t: 'surf_restart' })
- } else {
- const on = surfGhost?.toggle() ?? false
- hud.toast(on ? 'Ghost on' : 'Ghost off', false)
- }
- },
- true,
- )
+ const restartSurf = (): boolean => {
+ if (!surfInfo) return false
+ surfUi?.hideFinish()
+ connection.send({ t: 'surf_restart' })
+ return true
+ }
+ const toggleGhost = (): boolean => {
+ if (!surfGhost) return false
+ const on = surfGhost.toggle()
+ hud.toast(on ? 'Ghost on' : 'Ghost off', false)
+ return true
+ }
+ // Source binds, not key codes: the defaults are `r +restart` and
+ // `g toggle_ghost` in a surf place, `r +reload`/`g +drop` elsewhere, and a
+ // player's `bind` replaces them. Intent commands are read by the keyboard
+ // source; this is only for one-shot verbs.
+ const runBoundCommand = (command: string): boolean => {
+ switch (command) {
+ case '+restart':
+ return restartSurf()
+ case 'toggle_ghost':
+ return toggleGhost()
+ case 'toggleconsole':
+ gameConsole.toggle()
+ return true
+ case '+reload':
+ interact.handle({ kind: 'reload' })
+ return true
+ case '+drop':
+ interact.handle({ kind: 'drop' })
+ return true
+ default:
+ return false
+ }
+ }
+ // Bindings switch on with their handler. Enabling them before boot has
+ // reached this point would preventDefault on ~ with no one to toggle.
+ input.bindings = bindings
+ input.onBind = (command, down) => {
+ if (!down || isIntentCommand(command)) return
+ runBoundCommand(command)
+ }
+ gameConsole.onBoundCommand = runBoundCommand
interact.placementPose = () => (ghost.valid ? ghost.pose : null)
interact.placementPose = () => (ghost.valid ? ghost.pose : null)
interact.onPlacementRotate = (delta) => ghost.rotate(delta)
interact.onPlacementRotate = (delta) => ghost.rotate(delta)
@@ -608,6 +686,14 @@ async function start(): Promise<void> {
@@ -608,6 +686,14 @@ async function start(): Promise<void> {
window.addEventListener('keydown', unlockAudio)
window.addEventListener('keydown', unlockAudio)
hud.volume = () => sfx.getVolume()
hud.volume = () => sfx.getVolume()
hud.onVolumeChange = (v) => sfx.setVolume(v)
hud.onVolumeChange = (v) => sfx.setVolume(v)
+ gameConsole.onLocalCvar = (cvar, value) => {
+ if (cvar === 'volume') sfx.setVolume(Number(value))
+ else if (cvar === 'crosshair') hud.setCrosshair(Number(value) !== 0)
+ }
+ const storedVolume = cvarRegistry.get('volume')
+ if (typeof storedVolume === 'number') sfx.setVolume(storedVolume)
+ const storedCrosshair = cvarRegistry.get('crosshair')
+ if (typeof storedCrosshair === 'number') hud.setCrosshair(storedCrosshair !== 0)
interact.onMelee = (target) => {
interact.onMelee = (target) => {
sfx.swing()
sfx.swing()
if (!target) return
if (!target) return
@@ -641,13 +727,15 @@ async function start(): Promise<void> {
@@ -641,13 +727,15 @@ async function start(): Promise<void> {
let wasGrounded = true
let wasGrounded = true
let fallSpeed = 0
let fallSpeed = 0
- hud.onUiCaptureChange = (captured) => {
+ const applyUiCapture = (captured: boolean): void => {
input.uiCapture = captured
input.uiCapture = captured
touchControls.setUiCapture(captured)
touchControls.setUiCapture(captured)
if (captured) input.exitLock()
if (captured) input.exitLock()
- // Straight back into the game when the menu closes; touch never locks.
+ // Straight back into the game when every overlay closes; touch never locks.
else if (switchable.current === keyboardSource) input.requestLock()
else if (switchable.current === keyboardSource) input.requestLock()
}
}
+ hud.onUiCaptureChange = (captured) => applyUiCapture(captured || gameConsole.isOpen)
+ gameConsole.onOpenChange = (open) => applyUiCapture(open || hud.interfaceOpen)
input.onAction = (action) => {
input.onAction = (action) => {
switch (action.kind) {
switch (action.kind) {
case 'toggle_menu':
case 'toggle_menu':
@@ -685,7 +773,8 @@ async function start(): Promise<void> {
@@ -685,7 +773,8 @@ async function start(): Promise<void> {
state.events.on('actionResult', (r) => {
state.events.on('actionResult', (r) => {
// A drink the server accepted (eating answers the same action, so only right after this client asked to drink).
// A drink the server accepted (eating answers the same action, so only right after this client asked to drink).
- if (r.action === 'consume' && r.ok && Date.now() - interact.lastDrinkAt < 3000) firstStepsRef?.onDrink()
+ if (r.action === 'consume' && r.ok && Date.now() - interact.lastDrinkAt < 3000)
+ firstStepsRef?.onDrink()
if (r.action === 'use' && r.ok && interact.lastTargetWasPlayer) {
if (r.action === 'use' && r.ok && interact.lastTargetWasPlayer) {
interact.lastTargetWasPlayer = false
interact.lastTargetWasPlayer = false
hud.flashHitmarker()
hud.flashHitmarker()
@@ -704,6 +793,17 @@ async function start(): Promise<void> {
@@ -704,6 +793,17 @@ async function start(): Promise<void> {
player.onTransfer()
player.onTransfer()
}
}
state.apply(msg)
state.apply(msg)
+ if (msg.t === 'welcome') {
+ // The authoritative values arrive with welcome; autoexec runs only
+ // after they are applied, so a cfg that queries sv_gravity sees 800.
+ gameConsole.applyServerCvars(msg.cvars ?? {}, msg.cvarsWritable ?? [])
+ void gameConsole.runAutoexec()
+ } else if (msg.t === 'cvar_state') {
+ gameConsole.applyServerCvars(msg.values, msg.writable, {
+ ...(msg.error !== undefined ? { error: msg.error } : {}),
+ ...(msg.requestId !== undefined ? { requestId: msg.requestId } : {}),
+ })
+ }
if (msg.t === 'transfer_result') {
if (msg.t === 'transfer_result') {
hud.toast(
hud.toast(
msg.accepted
msg.accepted
@@ -796,7 +896,9 @@ async function start(): Promise<void> {
@@ -796,7 +896,9 @@ async function start(): Promise<void> {
hud.setStatus('could not join — try signing in again from the home page')
hud.setStatus('could not join — try signing in again from the home page')
}
}
} else if (msg.reason === 'guest_one_character') {
} else if (msg.reason === 'guest_one_character') {
- hud.setStatus('this server keeps one character per guest — sign in with OpenVibe to keep more')
+ hud.setStatus(
+ 'this server keeps one character per guest — sign in with OpenVibe to keep more',
+ )
} else if (msg.reason === 'auth_failed' || msg.reason === 'signed_out') {
} else if (msg.reason === 'auth_failed' || msg.reason === 'signed_out') {
localStorage.removeItem('ovg_sso')
localStorage.removeItem('ovg_sso')
hud.setStatus(
hud.setStatus(
@@ -15,6 +15,7 @@ import { TypedEmitter } from '@openvibe/shared'
@@ -15,6 +15,7 @@ import { TypedEmitter } from '@openvibe/shared'
export interface ClientStateEvents {
export interface ClientStateEvents {
welcome: { entityId: string; tickRate: number }
welcome: { entityId: string; tickRate: number }
+ cvars: { values: Record<string, number | boolean>; writable: string[]; error?: string }
entityAdded: WireEntity
entityAdded: WireEntity
entityRemoved: string
entityRemoved: string
entityUpdated: WireEntity
entityUpdated: WireEntity
@@ -62,6 +63,10 @@ export class ClientState {
@@ -62,6 +63,10 @@ export class ClientState {
myEntityId = ''
myEntityId = ''
/** OpenVibe rank from welcome (owner/admin/moderator, null = player). */
/** OpenVibe rank from welcome (owner/admin/moderator, null = player). */
myRank: 'owner' | 'admin' | 'moderator' | null = null
myRank: 'owner' | 'admin' | 'moderator' | null = null
+ /** Server-authoritative cvars in Source units (welcome + cvar_state). */
+ cvars: Record<string, number | boolean> = {}
+ /** Server cvars this rank may change. */
+ cvarsWritable: string[] = []
myPlayerId = ''
myPlayerId = ''
instanceId: string | null = null
instanceId: string | null = null
instanceCount = 1
instanceCount = 1
@@ -120,7 +125,19 @@ export class ClientState {
@@ -120,7 +125,19 @@ export class ClientState {
this.tickRate = msg.tickRate
this.tickRate = msg.tickRate
this.snapshotRate = msg.snapshotRate
this.snapshotRate = msg.snapshotRate
this.serverTick = msg.tick
this.serverTick = msg.tick
+ if (msg.cvars) this.cvars = msg.cvars
+ if (msg.cvarsWritable) this.cvarsWritable = msg.cvarsWritable
this.events.emit('welcome', { entityId: msg.entityId, tickRate: msg.tickRate })
this.events.emit('welcome', { entityId: msg.entityId, tickRate: msg.tickRate })
+ this.events.emit('cvars', { values: this.cvars, writable: this.cvarsWritable })
+ break
+ case 'cvar_state':
+ this.cvars = msg.values
+ this.cvarsWritable = msg.writable
+ this.events.emit('cvars', {
+ values: msg.values,
+ writable: msg.writable,
+ ...(msg.error !== undefined ? { error: msg.error } : {}),
+ })
break
break
case 'transfer_result':
case 'transfer_result':
break
break
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+/* The ~ console, as Source draws it: a dark panel that drops from the top over the game, the log above, the input
+ below, and completions as a list under the input. */
+.ovg-console {
+ position: fixed;
+ z-index: 200;
+ left: 0;
+ right: 0;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ max-height: min(55vh, 34rem);
+ padding: calc(0.55rem + env(safe-area-inset-top)) 0.75rem 0.65rem;
+ border-bottom: 1px solid var(--border, #2a3340);
+ background: color-mix(in srgb, var(--bg-secondary, #0a0e12) 94%, transparent);
+ color: var(--text-primary, #e6e6e6);
+ font:
+ 400 0.82rem/1.45 ui-monospace,
+ SFMono-Regular,
+ Menlo,
+ Consolas,
+ monospace;
+ backdrop-filter: blur(8px);
+ box-shadow: 0 12px 34px rgba(0, 0, 0, 0.45);
+ animation: console-drop 0.14s ease-out;
+}
+@keyframes console-drop {
+ from {
+ transform: translateY(-12px);
+ opacity: 0;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .ovg-console {
+ animation: none;
+ }
+}
+.ovg-console[hidden] {
+ display: none;
+}
+.console-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding-bottom: 0.35rem;
+ border-bottom: 1px solid color-mix(in srgb, var(--border, #2a3340) 70%, transparent);
+}
+.console-title {
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--g-accent-text, #f0b040);
+}
+.console-keyhint {
+ color: var(--text-secondary, #b0b0b8);
+ font-size: 0.72rem;
+}
+.console-log {
+ flex: 1 1 auto;
+ min-height: 4rem;
+ overflow-y: auto;
+ padding: 0.4rem 0.1rem;
+ overscroll-behavior: contain;
+}
+.console-line {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.console-line.is-error {
+ color: #ff8d8d;
+}
+.console-line.is-echo {
+ color: var(--text-secondary, #b0b0b8);
+}
+.console-form {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ padding-top: 0.4rem;
+ border-top: 1px solid color-mix(in srgb, var(--border, #2a3340) 70%, transparent);
+}
+.console-prompt {
+ color: var(--g-accent-text, #f0b040);
+ font-weight: 700;
+}
+.console-input {
+ flex: 1 1 auto;
+ min-width: 0;
+ padding: 0.18rem 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ caret-color: var(--g-accent-text, #f0b040);
+}
+.console-suggestion {
+ color: var(--text-secondary, #b0b0b8);
+ font-size: 0.7rem;
+}
+.console-completions {
+ display: flex;
+ flex-direction: column;
+ max-height: 9.5rem;
+ overflow-y: auto;
+ margin: 0.35rem 0 0;
+ padding: 0.2rem 0;
+ list-style: none;
+ border: 1px solid var(--border, #2a3340);
+ border-radius: 0.3rem;
+ background: color-mix(in srgb, var(--bg-primary, #05080b) 92%, transparent);
+}
+.console-completions[hidden] {
+ display: none;
+}
+.console-completion {
+ padding: 0.1rem 0.5rem;
+ color: var(--text-secondary, #b0b0b8);
+ cursor: pointer;
+}
+.console-completion.is-selected {
+ background: color-mix(in srgb, var(--g-accent, #f0b040) 18%, transparent);
+ color: var(--text-primary, #e6e6e6);
+}
+.console-completion:hover {
+ color: var(--text-primary, #e6e6e6);
+}
+
+@media (max-width: 640px) {
+ .ovg-console {
+ max-height: 62vh;
+ font-size: 0.78rem;
+ }
+ .console-keyhint {
+ display: none;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ AUTOEXEC_CONFIG,
+ Bindings,
+ ConfigStore,
+ CvarRegistry,
+ CLIENT_CVARS,
+ SOURCE_MOVEMENT_CVARS,
+ type KeyValueStorage,
+} from '@openvibe/shared'
+import { movementCvarValues, SURF_MOVEMENT } from '@openvibe/gameplay'
+import { GameConsole } from './console.js'
+
+class MemoryStorage implements KeyValueStorage {
+ private readonly data = new Map<string, string>()
+ get length(): number {
+ return this.data.size
+ }
+ getItem(key: string): string | null {
+ return this.data.get(key) ?? null
+ }
+ setItem(key: string, value: string): void {
+ this.data.set(key, value)
+ }
+ removeItem(key: string): void {
+ this.data.delete(key)
+ }
+ key(index: number): string | null {
+ return [...this.data.keys()][index] ?? null
+ }
+}
+
+const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0))
+
+function makeConsole(options: { readOnly?: boolean } = {}) {
+ const storage = new MemoryStorage()
+ const cvars = new CvarRegistry([...SOURCE_MOVEMENT_CVARS, ...CLIENT_CVARS])
+ cvars.apply(movementCvarValues(SURF_MOVEMENT))
+ const bindings = new Bindings({ storage })
+ const configs = new ConfigStore({ storage })
+ const console = new GameConsole({
+ parent: document.body,
+ cvars,
+ bindings,
+ configs,
+ storage,
+ ...(options.readOnly ? { readOnly: true } : {}),
+ })
+ const input = console.root.querySelector('.console-input') as HTMLInputElement
+ const log = console.root.querySelector('.console-log') as HTMLElement
+ const key = (target: EventTarget, code: string, value = '') =>
+ target.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ code,
+ key: value || code,
+ bubbles: true,
+ cancelable: true,
+ }),
+ )
+ const run = async (line: string) => {
+ input.value = line
+ key(input, 'Enter', 'Enter')
+ await flush()
+ }
+ return { console, storage, cvars, bindings, configs, input, log, key, run }
+}
+
+let mounted: GameConsole | null = null
+afterEach(() => {
+ mounted?.destroy()
+ mounted = null
+ document.body.innerHTML = ''
+})
+
+describe('developer console overlay', () => {
+ it('opens with ~, accepts typed commands and closes with Escape', async () => {
+ const { console, root } = (() => {
+ const made = makeConsole()
+ mounted = made.console
+ return { console: made.console, root: made.console.root }
+ })()
+ document.body.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ code: 'Backquote',
+ key: '`',
+ bubbles: true,
+ cancelable: true,
+ }),
+ )
+ expect(root.hidden).toBe(false)
+ expect(console.isOpen).toBe(true)
+ await flush()
+ const input = root.querySelector('.console-input') as HTMLInputElement
+ expect(document.activeElement).toBe(input)
+
+ input.value = 'help sv_gravity'
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ code: 'Enter',
+ key: 'Enter',
+ bubbles: true,
+ cancelable: true,
+ }),
+ )
+ await flush()
+ expect(root.querySelector('.console-log')?.textContent).toContain('sv_gravity = 800')
+
+ input.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ code: 'Escape',
+ key: 'Escape',
+ bubbles: true,
+ cancelable: true,
+ }),
+ )
+ expect(root.hidden).toBe(true)
+ expect(console.isOpen).toBe(false)
+ })
+
+ it('keeps history and completes names with Tab', async () => {
+ const { console, root, input, key, run } = (() => {
+ const made = makeConsole()
+ mounted = made.console
+ return { ...made, root: made.console.root }
+ })()
+ console.open()
+ await run('help')
+ await run('sv_gravity')
+ key(input, 'ArrowUp', 'ArrowUp')
+ expect(input.value).toBe('sv_gravity')
+ key(input, 'ArrowUp', 'ArrowUp')
+ expect(input.value).toBe('help')
+ key(input, 'ArrowDown', 'ArrowDown')
+ expect(input.value).toBe('sv_gravity')
+
+ input.value = 'sv_grav'
+ input.setSelectionRange(input.value.length, input.value.length)
+ key(input, 'Tab', 'Tab')
+ expect(input.value).toBe('sv_gravity')
+ expect(root.querySelector('.console-completions')?.childElementCount).toBeGreaterThan(0)
+ })
+
+ it('never changes a server cvar without permission or an authoritative reply', async () => {
+ const { console, cvars } = (() => {
+ const made = makeConsole()
+ mounted = made.console
+ return made
+ })()
+ const requests: { name: string; value: number | boolean; requestId: number }[] = []
+ console.onCvarRequest = (name, value, requestId) => requests.push({ name, value, requestId })
+ const values = movementCvarValues(SURF_MOVEMENT)
+ console.applyServerCvars(values, [])
+
+ await console.executeLine('sv_gravity 500')
+ expect(requests).toEqual([])
+ expect(cvars.get('sv_gravity')).toBe(800)
+ expect(console.root.querySelector('.console-log')?.textContent).toContain('server-controlled')
+
+ console.applyServerCvars(values, ['sv_gravity'])
+ await console.executeLine('sv_gravity 500')
+ expect(requests).toHaveLength(1)
+ expect(requests[0]).toMatchObject({ name: 'sv_gravity', value: 500 })
+ // Still 800: a request is not a value.
+ expect(cvars.get('sv_gravity')).toBe(800)
+
+ console.applyServerCvars({ ...values, sv_gravity: 500 }, ['sv_gravity'], {
+ requestId: requests[0]!.requestId,
+ })
+ expect(cvars.get('sv_gravity')).toBe(500)
+ })
+
+ it('applies exec practice.cfg and autoexec after welcome', async () => {
+ const { console, bindings, configs } = (() => {
+ const made = makeConsole()
+ mounted = made.console
+ return made
+ })()
+ expect(configs.write('practice', 'bind space +duck').ok).toBe(true)
+ await console.executeLine('exec practice.cfg')
+ expect(bindings.commandForCode('Space')).toBe('+duck')
+ expect(console.root.querySelector('.console-log')?.textContent).toContain(
+ 'Executing practice.cfg',
+ )
+
+ expect(configs.write(AUTOEXEC_CONFIG, 'bind space +jump').ok).toBe(true)
+ await console.runAutoexec()
+ expect(bindings.commandForCode('Space')).toBe('+jump')
+ })
+
+ it('is read-only in the casual mount', async () => {
+ const { console, cvars, bindings } = (() => {
+ const made = makeConsole({ readOnly: true })
+ mounted = made.console
+ return made
+ })()
+ console.applyServerCvars(movementCvarValues(SURF_MOVEMENT), ['sv_gravity'])
+ await console.executeLine('sv_gravity 500')
+ expect(cvars.get('sv_gravity')).toBe(800)
+ expect(console.root.querySelector('.console-log')?.textContent).toContain('read-only')
+ await console.executeLine('bind space +duck')
+ expect(bindings.commandForCode('Space')).toBe('+jump')
+ await console.executeLine('cvarlist sv_gravity')
+ expect(console.root.querySelector('.console-log')?.textContent).toContain('sv_gravity = 800')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,729 @@
@@ -0,0 +1,729 @@
+/**
+ * The Source-style developer console (~).
+ *
+ * A DOM overlay with a log, an input line, history and Tab completion. It is
+ * deliberately independent of the game: the main client and the casual page
+ * mount the same component, and the command table decides what each can do.
+ * Server cvars are only ever requested here; the authoritative value arrives
+ * in welcome/cvar_state and is applied by the caller.
+ */
+import {
+ AUTOEXEC_CONFIG,
+ Bindings,
+ CommandRegistry,
+ ConfigStore,
+ CVARS_STORAGE_KEY,
+ HISTORY_STORAGE_KEY,
+ browserStorage,
+ intentCommand,
+ parseCommandLine,
+ type CommandDef,
+ type CommandResult,
+ type CvarRegistry,
+ type CvarSpec,
+ type CvarValue,
+ type KeyValueStorage,
+} from '@openvibe/shared'
+import './console.css'
+
+export interface ConsoleContext {
+ raw: string
+ line: number
+ echo(text: string | readonly string[]): void
+ error(text: string): void
+}
+
+export interface ConsoleOptions {
+ parent?: HTMLElement
+ cvars: CvarRegistry
+ bindings?: Bindings
+ configs?: ConfigStore
+ storage?: KeyValueStorage | null
+ /** The casual page: only commands marked `readOnly` are registered. */
+ readOnly?: boolean
+ commands?: readonly CommandDef<ConsoleContext>[]
+ /** Status lines for `status`. */
+ status?: () => string | readonly string[]
+}
+
+const MAX_LOG_LINES = 600
+const MAX_HISTORY = 100
+
+type PrintKind = 'normal' | 'error' | 'echo'
+
+/** Extract the lines/error/close intent from any command result shape. */
+function resultOf(result: CommandResult): { lines: string[]; error?: string; close?: boolean } {
+ if (result === undefined) return { lines: [] }
+ if (typeof result === 'string') return { lines: [result] }
+ if (Array.isArray(result)) return { lines: [...(result as readonly string[])] }
+ const object = result as { lines?: readonly string[]; error?: string; close?: boolean }
+ return {
+ lines: [...(object.lines ?? [])],
+ ...(object.error !== undefined ? { error: object.error } : {}),
+ ...(object.close ? { close: true } : {}),
+ }
+}
+
+export class GameConsole {
+ readonly root: HTMLElement
+ readonly bindings: Bindings
+ readonly configs: ConfigStore
+ readonly cvars: CvarRegistry
+ readonly commands: CommandRegistry<ConsoleContext>
+
+ private readonly logEl: HTMLElement
+ private readonly inputEl: HTMLInputElement
+ private readonly completionsEl: HTMLElement
+ private readonly hintEl: HTMLElement
+ private readonly storage: KeyValueStorage | null
+ private readonly readOnly: boolean
+ private readonly history: string[]
+ private historyIndex: number | null = null
+ private completionIndex = 0
+ private completionSource = ''
+ private openState = false
+ private autoexecRan = false
+ private requestSeq = 1
+ private readonly pending = new Map<number, { name: string; value: number | boolean }>()
+ private readonly writable = new Set<string>()
+
+ /** Send a server cvar request; the caller owns the connection. */
+ onCvarRequest: ((name: string, value: number | boolean, requestId: number) => void) | null = null
+ /** Authoritative cvar values arrived (welcome or cvar_state). */
+ onServerCvars: ((values: Record<string, number | boolean>) => void) | null = null
+ /** A `+jump` / `-jump` typed in the console feeds the intent source. */
+ onIntentCommand: ((name: string, down: boolean) => void) | null = null
+ /** One-shot bound commands such as `toggle_ghost` and `+restart`. */
+ onBoundCommand: ((command: string) => boolean) | null = null
+ /** The overlay opened or closed: the caller releases/restores pointer lock. */
+ onOpenChange: ((open: boolean) => void) | null = null
+ /** A client cvar changed locally (volume, crosshair, fps cap). */
+ onLocalCvar: ((name: string, value: CvarValue) => void) | null = null
+
+ constructor(options: ConsoleOptions) {
+ this.cvars = options.cvars
+ this.bindings = options.bindings ?? new Bindings()
+ this.configs = options.configs ?? new ConfigStore()
+ this.storage = options.storage === undefined ? browserStorage() : options.storage
+ this.readOnly = options.readOnly ?? false
+ this.history = this.loadHistory()
+
+ this.root = document.createElement('section')
+ this.root.className = 'ovg-console'
+ this.root.hidden = true
+ this.root.setAttribute('role', 'dialog')
+ this.root.setAttribute('aria-label', 'Developer console')
+
+ const head = document.createElement('header')
+ head.className = 'console-head'
+ const title = document.createElement('span')
+ title.className = 'console-title'
+ title.textContent = 'Console'
+ const hint = document.createElement('span')
+ hint.className = 'console-keyhint'
+ hint.textContent = '~ close · Tab complete · ↑↓ history'
+ head.append(title, hint)
+
+ this.logEl = document.createElement('div')
+ this.logEl.className = 'console-log'
+ this.logEl.setAttribute('role', 'log')
+ this.logEl.setAttribute('aria-live', 'polite')
+
+ const form = document.createElement('form')
+ form.className = 'console-form'
+ const prompt = document.createElement('span')
+ prompt.className = 'console-prompt'
+ prompt.textContent = ']'
+ prompt.setAttribute('aria-hidden', 'true')
+ this.inputEl = document.createElement('input')
+ this.inputEl.className = 'console-input'
+ this.inputEl.type = 'text'
+ this.inputEl.autocomplete = 'off'
+ this.inputEl.spellcheck = false
+ this.inputEl.setAttribute('aria-label', 'Console command')
+ this.inputEl.setAttribute('autocapitalize', 'off')
+ this.hintEl = document.createElement('output')
+ this.hintEl.className = 'console-suggestion'
+ form.append(prompt, this.inputEl, this.hintEl)
+
+ this.completionsEl = document.createElement('ul')
+ this.completionsEl.className = 'console-completions'
+ this.completionsEl.hidden = true
+ this.completionsEl.setAttribute('aria-label', 'Completions')
+
+ this.root.append(head, this.logEl, form, this.completionsEl)
+ const parent = options.parent ?? document.body
+ parent.append(this.root)
+
+ this.commands = new CommandRegistry<ConsoleContext>()
+ this.registerCoreCommands()
+ if (!this.readOnly) this.registerEditCommands()
+ for (const command of options.commands ?? []) {
+ if (this.readOnly && !command.readOnly) continue
+ this.commands.register(command)
+ }
+ if (options.status) this.status = options.status
+
+ this.loadLocalCvars()
+ this.inputEl.addEventListener('keydown', (e) => this.onInputKeyDown(e))
+ this.inputEl.addEventListener('input', () => this.resetCompletion())
+ form.addEventListener('submit', (e) => {
+ e.preventDefault()
+ void this.submit()
+ })
+ // A press inside the console is UI, not a game click.
+ this.root.addEventListener('pointerdown', (e) => e.stopPropagation())
+ this.completionsEl.addEventListener('pointerdown', (e) => e.stopPropagation())
+ window.addEventListener('keydown', this.onWindowKeyDown)
+ window.addEventListener('keyup', this.onWindowKeyUp)
+ }
+
+ private status: () => string | readonly string[] = () => []
+
+ get isOpen(): boolean {
+ return this.openState
+ }
+
+ open(): void {
+ if (this.openState) return
+ this.openState = true
+ this.root.hidden = false
+ this.root.classList.add('is-open')
+ this.onOpenChange?.(true)
+ // After the current key event, so the same ~ does not race focus.
+ queueMicrotask(() => this.inputEl.focus())
+ }
+
+ close(): void {
+ if (!this.openState) return
+ this.openState = false
+ this.root.hidden = true
+ this.root.classList.remove('is-open')
+ this.completionsEl.hidden = true
+ this.inputEl.blur()
+ this.onOpenChange?.(false)
+ }
+
+ toggle(): void {
+ if (this.openState) this.close()
+ else this.open()
+ }
+
+ destroy(): void {
+ window.removeEventListener('keydown', this.onWindowKeyDown)
+ window.removeEventListener('keyup', this.onWindowKeyUp)
+ this.root.remove()
+ }
+
+ registerCommand(command: CommandDef<ConsoleContext>): void {
+ if (this.readOnly && !command.readOnly) return
+ this.commands.register(command)
+ }
+
+ /** Set the `status` command's text provider after the game state exists. */
+ setStatusProvider(provider: () => string | readonly string[]): void {
+ this.status = provider
+ }
+
+ /** Print a line (or lines) into the log; errors are red, echoes dim. */
+ print(lines: string | readonly string[], kind: PrintKind = 'normal'): void {
+ const list = typeof lines === 'string' ? [lines] : lines
+ for (const text of list) {
+ const line = document.createElement('div')
+ line.className = `console-line${kind === 'error' ? ' is-error' : kind === 'echo' ? ' is-echo' : ''}`
+ line.textContent = text
+ this.logEl.append(line)
+ }
+ while (this.logEl.childElementCount > MAX_LOG_LINES) this.logEl.firstElementChild?.remove()
+ this.logEl.scrollTop = this.logEl.scrollHeight
+ }
+
+ /** Apply the server's authoritative values; called after welcome and on cvar_state. */
+ applyServerCvars(
+ values: Record<string, number | boolean>,
+ writable: readonly string[],
+ options: { error?: string; requestId?: number } = {},
+ ): void {
+ this.writable.clear()
+ for (const name of writable) this.writable.add(name)
+ this.cvars.apply(values)
+ this.onServerCvars?.(values)
+ const pending =
+ options.requestId !== undefined ? this.pending.get(options.requestId) : undefined
+ if (options.requestId !== undefined) this.pending.delete(options.requestId)
+ if (options.error) {
+ this.print(options.error, 'error')
+ return
+ }
+ if (pending) this.print(`${pending.name} = ${this.cvars.formatValue(pending.name)}`)
+ }
+
+ /** Run `autoexec.cfg` once, after the server's welcome has been applied. */
+ async runAutoexec(): Promise<void> {
+ if (this.autoexecRan || this.readOnly) return
+ this.autoexecRan = true
+ const text = this.configs.read(AUTOEXEC_CONFIG)
+ if (!text) return
+ this.print(`Executing ${AUTOEXEC_CONFIG}…`, 'echo')
+ await this.runConfig(text, AUTOEXEC_CONFIG, 0)
+ }
+
+ /** Execute one line as if typed, including `;`-separated commands. */
+ async executeLine(line: string): Promise<void> {
+ const commands = parseCommandLine(line)
+ for (const command of commands) await this.execute(command)
+ }
+
+ private async submit(): Promise<void> {
+ const line = this.inputEl.value.trim()
+ if (!line) {
+ this.inputEl.value = ''
+ this.resetCompletion()
+ return
+ }
+ this.print(`] ${line}`, 'echo')
+ this.remember(line)
+ this.inputEl.value = ''
+ this.historyIndex = null
+ this.resetCompletion()
+ await this.executeLine(line)
+ this.inputEl.focus()
+ }
+
+ private async execute(command: ReturnType<typeof parseCommandLine>[number]): Promise<void> {
+ const context: ConsoleContext = {
+ raw: command.raw,
+ line: command.line,
+ echo: (text) => this.print(text),
+ error: (text) => this.print(text, 'error'),
+ }
+ // `+jump`/`-jump` are intents, not commands: they feed the same bits a
+ // bound key does. This also works for `exec` scripts.
+ const intent = intentCommand(command.name)
+ if (intent) {
+ if (this.readOnly) {
+ context.error('read-only console: intents are unavailable')
+ return
+ }
+ this.onIntentCommand?.(intent.name, intent.down)
+ this.print(`${intent.down ? '+' : '-'}${intent.name.toLowerCase()}`, 'echo')
+ return
+ }
+ const def = this.commands.get(command.name)
+ if (def) {
+ try {
+ const result = resultOf(await def.run(command.args, context))
+ if (result.error) this.print(result.error, 'error')
+ if (result.lines.length) this.print(result.lines)
+ if (result.close) this.close()
+ } catch (err) {
+ this.print(err instanceof Error ? err.message : 'command failed', 'error')
+ }
+ return
+ }
+ const spec = this.cvars.spec(command.name)
+ if (spec) {
+ this.runCvar(spec, command.args)
+ return
+ }
+ if (this.onBoundCommand?.(command.raw)) return
+ this.print(`Unknown command "${command.name}". Try help or find ${command.name}.`, 'error')
+ }
+
+ private runCvar(spec: CvarSpec, args: string[]): void {
+ if (!args.length) {
+ this.print(
+ `${spec.name} = ${this.cvars.formatValue(spec.name)}${spec.unit ? ` ${spec.unit}` : ''}`,
+ )
+ return
+ }
+ if (this.readOnly) {
+ this.print('read-only console: cvars cannot be changed here', 'error')
+ return
+ }
+ const raw = args.join(' ')
+ if (spec.flags.includes('client')) {
+ const result = this.cvars.set(spec.name, raw)
+ if (!result.ok) {
+ this.print(result.error ?? `invalid ${spec.name}`, 'error')
+ return
+ }
+ this.persistLocalCvars()
+ this.onLocalCvar?.(spec.name, result.value!)
+ this.print(`${spec.name} = ${this.cvars.formatValue(spec.name)}`)
+ return
+ }
+ // Server cvars are requests. Never change the local copy before the
+ // authoritative cvar_state arrives: that would let a client predict a
+ // value the server refused.
+ if (!this.writable.has(spec.name)) {
+ this.print(`${spec.name} is server-controlled; you do not have permission`, 'error')
+ return
+ }
+ const checked = this.cvars.check(spec.name, raw)
+ if (!checked.ok || (typeof checked.value !== 'number' && typeof checked.value !== 'boolean')) {
+ this.print(checked.error ?? `${spec.name} expects a number`, 'error')
+ return
+ }
+ const requestId = this.requestSeq++
+ this.pending.set(requestId, { name: spec.name, value: checked.value })
+ if (!this.onCvarRequest) {
+ this.pending.delete(requestId)
+ this.print('not connected: server cvars are unavailable', 'error')
+ return
+ }
+ this.onCvarRequest(spec.name, checked.value, requestId)
+ this.print(`Requesting ${spec.name} = ${checked.value} (server)`, 'echo')
+ }
+
+ private async runConfig(text: string, name: string, depth: number): Promise<boolean> {
+ if (depth > 8) {
+ this.print(`exec ${name}: nested configs are too deep`, 'error')
+ return false
+ }
+ let ok = true
+ for (const command of parseCommandLine(text)) {
+ if (command.name.toLowerCase() === 'exec' && depth >= 8) {
+ this.print('exec: nested configs are too deep', 'error')
+ return false
+ }
+ // Re-dispatch through the regular path so binds/cvars/commands behave
+ // exactly as if typed.
+ try {
+ await this.execute(command)
+ } catch (err) {
+ ok = false
+ this.print(
+ `${name}:${command.line}: ${err instanceof Error ? err.message : 'failed'}`,
+ 'error',
+ )
+ }
+ }
+ return ok
+ }
+
+ private loadHistory(): string[] {
+ try {
+ const raw = this.storage?.getItem(HISTORY_STORAGE_KEY) ?? null
+ if (!raw) return []
+ const parsed = JSON.parse(raw) as unknown
+ return Array.isArray(parsed)
+ ? parsed.filter((line): line is string => typeof line === 'string').slice(-MAX_HISTORY)
+ : []
+ } catch {
+ return []
+ }
+ }
+
+ private remember(line: string): void {
+ if (this.history[this.history.length - 1] !== line) this.history.push(line)
+ while (this.history.length > MAX_HISTORY) this.history.shift()
+ try {
+ this.storage?.setItem(HISTORY_STORAGE_KEY, JSON.stringify(this.history))
+ } catch {
+ // History is a convenience; a refused localStorage must not break the console.
+ }
+ }
+
+ private loadLocalCvars(): void {
+ try {
+ const raw = this.storage?.getItem(CVARS_STORAGE_KEY) ?? null
+ if (!raw) return
+ const parsed = JSON.parse(raw) as Record<string, unknown>
+ for (const [name, value] of Object.entries(parsed)) {
+ const spec = this.cvars.spec(name)
+ if (!spec?.flags.includes('client')) continue
+ if (typeof value !== 'number' && typeof value !== 'boolean' && typeof value !== 'string')
+ continue
+ this.cvars.set(spec.name, value)
+ }
+ } catch {
+ // Corrupt settings fall back to the defaults.
+ }
+ }
+
+ private persistLocalCvars(): void {
+ if (!this.storage) return
+ const values = this.cvars.snapshot((spec) => spec.flags.includes('client'))
+ try {
+ this.storage.setItem(CVARS_STORAGE_KEY, JSON.stringify(values))
+ } catch {
+ // Private mode/quota: the value still applies this session.
+ }
+ }
+
+ private onWindowKeyDown = (e: KeyboardEvent): void => {
+ // A bound `~ toggleconsole` already prevented the default and toggled
+ // through InputTracker.onBind; do not toggle twice.
+ if (e.defaultPrevented) return
+ if (this.openState) {
+ if (e.code === 'Escape' || e.code === 'Backquote') {
+ e.preventDefault()
+ this.close()
+ }
+ return
+ }
+ if (e.code === 'Backquote') {
+ e.preventDefault()
+ this.open()
+ }
+ }
+
+ private onWindowKeyUp = (e: KeyboardEvent): void => {
+ if (!this.openState) return
+ if (e.code === 'Backquote' || e.code === 'Escape') e.preventDefault()
+ }
+
+ private onInputKeyDown(e: KeyboardEvent): void {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ void this.submit()
+ return
+ }
+ if (e.key === 'Tab') {
+ e.preventDefault()
+ this.complete()
+ return
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ this.historyStep(-1)
+ return
+ }
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ this.historyStep(1)
+ return
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ this.close()
+ }
+ }
+
+ private historyStep(direction: -1 | 1): void {
+ if (!this.history.length) return
+ if (this.historyIndex === null) {
+ if (direction === 1) return
+ this.historyIndex = this.history.length - 1
+ } else {
+ const next = this.historyIndex + direction
+ if (next < 0) {
+ this.historyIndex = 0
+ } else if (next >= this.history.length) {
+ this.historyIndex = null
+ this.inputEl.value = ''
+ this.resetCompletion()
+ return
+ } else {
+ this.historyIndex = next
+ }
+ }
+ this.inputEl.value = this.history[this.historyIndex] ?? ''
+ this.resetCompletion()
+ this.inputEl.setSelectionRange(this.inputEl.value.length, this.inputEl.value.length)
+ }
+
+ private complete(): void {
+ const value = this.inputEl.value
+ const start = value.lastIndexOf(' ', this.inputEl.selectionStart ?? value.length) + 1
+ const prefix = value.slice(start)
+ const source = `${start}:${prefix}`
+ if (source !== this.completionSource) {
+ this.completionSource = source
+ this.completionIndex = -1
+ }
+ const candidates = this.completionCandidates(prefix)
+ if (!candidates.length) {
+ this.completionsEl.hidden = true
+ return
+ }
+ this.completionIndex = (this.completionIndex + 1) % candidates.length
+ const candidate = candidates[this.completionIndex]!
+ this.inputEl.value = value.slice(0, start) + candidate + value.slice(start + prefix.length)
+ this.inputEl.setSelectionRange(start + candidate.length, start + candidate.length)
+ this.renderCompletions(candidates.slice(0, 10), candidate)
+ }
+
+ private completionCandidates(prefix: string): string[] {
+ const names = new Set<string>()
+ for (const name of this.commands.matching(prefix)) names.add(name)
+ for (const name of this.cvars.matching(prefix)) names.add(name)
+ return [...names].sort()
+ }
+
+ private renderCompletions(candidates: readonly string[], selected: string): void {
+ this.completionsEl.replaceChildren(
+ ...candidates.map((name) => {
+ const item = document.createElement('li')
+ item.className = `console-completion${name === selected ? ' is-selected' : ''}`
+ item.textContent = name
+ item.addEventListener('pointerdown', (e) => {
+ e.preventDefault()
+ const value = this.inputEl.value
+ const start = value.lastIndexOf(' ', this.inputEl.selectionStart ?? value.length) + 1
+ const end = this.inputEl.selectionStart ?? value.length
+ this.inputEl.value = value.slice(0, start) + name + value.slice(end)
+ this.inputEl.setSelectionRange(start + name.length, start + name.length)
+ this.completionsEl.hidden = true
+ this.inputEl.focus()
+ })
+ return item
+ }),
+ )
+ this.completionsEl.hidden = false
+ }
+
+ private resetCompletion(): void {
+ this.completionIndex = 0
+ this.completionSource = ''
+ this.completionsEl.hidden = true
+ }
+
+ private registerCoreCommands(): void {
+ this.commands.register({
+ name: 'help',
+ aliases: ['?'],
+ readOnly: true,
+ usage: 'help [command|cvar|text]',
+ help: 'List commands and cvars, or explain one name. With text, same as find.',
+ run: (args, _context) => {
+ const query = args[0]
+ if (!query) {
+ const commands = this.commands.all().map((command) => command.name)
+ return [
+ `Commands: ${commands.join(', ')}`,
+ `Cvars: ${this.cvars.all().length} (cvarlist) · search with find <text>`,
+ ]
+ }
+ const def = this.commands.get(query)
+ if (def) return this.commands.help(def)
+ const spec = this.cvars.spec(query)
+ if (spec) return this.cvars.help(spec)
+ return this.findLines(args.join(' '))
+ },
+ })
+ this.commands.register({
+ name: 'find',
+ readOnly: true,
+ usage: 'find <text>',
+ help: 'Search command names, cvar names and help text.',
+ run: (args, _context) =>
+ args.length ? this.findLines(args.join(' ')) : 'usage: find <text>',
+ })
+ this.commands.register({
+ name: 'cvarlist',
+ readOnly: true,
+ usage: 'cvarlist [prefix]',
+ help: 'List server and client cvars with their current values.',
+ run: (args, _context) => {
+ const prefix = (args[0] ?? '').toLowerCase()
+ const specs = this.cvars.all().filter((spec) => spec.name.startsWith(prefix))
+ if (!specs.length) return `no cvars match "${prefix}"`
+ return specs.map(
+ (spec) =>
+ `${spec.name} = ${this.cvars.formatValue(spec.name)}${spec.unit ? ` ${spec.unit}` : ''} [${spec.flags.join(' ')}]`,
+ )
+ },
+ })
+ this.commands.register({
+ name: 'clear',
+ aliases: ['cls'],
+ readOnly: true,
+ help: 'Clear the console log.',
+ run: () => {
+ this.logEl.replaceChildren()
+ },
+ })
+ this.commands.register({
+ name: 'echo',
+ readOnly: true,
+ usage: 'echo <text>',
+ help: 'Print text to the console.',
+ run: (args) => args.join(' '),
+ })
+ this.commands.register({
+ name: 'status',
+ readOnly: true,
+ help: 'Show connection and world status supplied by the page.',
+ run: () => this.status(),
+ })
+ this.commands.register({
+ name: 'toggleconsole',
+ readOnly: true,
+ help: 'Close the console. Bound to ~ by default.',
+ run: () => ({ close: true }),
+ })
+ }
+
+ private registerEditCommands(): void {
+ this.commands.register({
+ name: 'bind',
+ usage: 'bind [key] [command]',
+ help: 'Show, set or list key bindings. Persisted in this browser.',
+ run: (args) => {
+ if (!args.length) {
+ return this.bindings.entries().map(({ key, command }) => `${key} = ${command}`)
+ }
+ const key = args[0]!
+ if (args.length === 1) {
+ const bound = this.bindings.get(key)
+ return bound ? `${key} = ${bound}` : `${key} is not bound`
+ }
+ const command = args.slice(1).join(' ')
+ const result = this.bindings.bind(key, command)
+ return result.ok ? `${result.key} = ${command}` : { error: result.error }
+ },
+ })
+ this.commands.register({
+ name: 'unbind',
+ usage: 'unbind <key>',
+ help: 'Remove a key binding.',
+ run: (args) => {
+ if (!args.length) return 'usage: unbind <key>'
+ const result = this.bindings.unbind(args[0]!)
+ return result.ok ? `${result.key} is unbound` : { error: result.error }
+ },
+ })
+ this.commands.register({
+ name: 'writecfg',
+ aliases: ['savecfg'],
+ usage: 'writecfg <name.cfg> "<commands>"',
+ help: 'Save commands to a virtual cfg in this browser (quotes keep ;-separated commands).',
+ run: (args) => {
+ if (args.length < 2) return 'usage: writecfg <name.cfg> "<commands>"'
+ const result = this.configs.write(args[0]!, args.slice(1).join(' '))
+ return result.ok ? `wrote ${result.name}` : { error: result.error }
+ },
+ })
+ this.commands.register({
+ name: 'exec',
+ usage: 'exec <name.cfg>',
+ help: 'Run a saved cfg from this browser (exec practice.cfg).',
+ run: async (args, context) => {
+ if (!args.length) return 'usage: exec <name.cfg>'
+ const name = this.configs.normalize(args[0]!)
+ if (!name) return { error: `invalid config name "${args[0]}"` }
+ const text = this.configs.read(name)
+ if (text === null) return { error: `couldn't exec ${name}` }
+ this.print(`Executing ${name}…`, 'echo')
+ await this.runConfig(text, name, 1)
+ context.echo(`exec ${name}: done`)
+ },
+ })
+ }
+
+ private findLines(text: string): string[] {
+ const needle = text.trim()
+ if (!needle) return ['usage: find <text>']
+ const commands = this.commands.find(needle)
+ const specs = this.cvars.find(needle)
+ if (!commands.length && !specs.length) return [`nothing found for "${needle}"`]
+ return [
+ ...commands.map((command) => `[cmd] ${command.name} — ${command.help}`),
+ ...specs.map(
+ (spec) => `[cvar] ${spec.name} = ${this.cvars.formatValue(spec.name)} — ${spec.help}`,
+ ),
+ ]
+ }
+}
@@ -46,6 +46,11 @@ export class Hud {
@@ -46,6 +46,11 @@ export class Hud {
/** Source-style chat (Y/U); the HUD owns its mount and capture handoff. */
/** Source-style chat (Y/U); the HUD owns its mount and capture handoff. */
readonly chat: Chat
readonly chat: Chat
menuOpen = false
menuOpen = false
+
+ /** Any HUD panel that owns input right now (the menu, a shop, a container). */
+ get interfaceOpen(): boolean {
+ return this.menuOpen || this.shopOpen || this.openContainerId !== null
+ }
private activeTab: MenuTab = 'inventory'
private activeTab: MenuTab = 'inventory'
private dragFrom: number | null = null
private dragFrom: number | null = null
onUiCaptureChange: ((captured: boolean) => void) | null = null
onUiCaptureChange: ((captured: boolean) => void) | null = null
@@ -24,6 +24,7 @@ import {
@@ -24,6 +24,7 @@ import {
ProductionSystem,
ProductionSystem,
ReplicationSystem,
ReplicationSystem,
SessionsSystem,
SessionsSystem,
+ SettingsSystem,
SocialSystem,
SocialSystem,
SurfSystem,
SurfSystem,
VehicleSystem,
VehicleSystem,
@@ -83,6 +84,7 @@ export class GameServer {
@@ -83,6 +84,7 @@ export class GameServer {
)
)
const ctx = this.ctx
const ctx = this.ctx
const sessions = new SessionsSystem(ctx)
const sessions = new SessionsSystem(ctx)
+ const settings = new SettingsSystem(ctx)
const chat = new ChatSystem(ctx)
const chat = new ChatSystem(ctx)
const movement = new MovementSystem(ctx)
const movement = new MovementSystem(ctx)
// Runs see this tick's authoritative positions: construct after movement,
// Runs see this tick's authoritative positions: construct after movement,
@@ -109,6 +111,7 @@ export class GameServer {
@@ -109,6 +111,7 @@ export class GameServer {
this.systems = {
this.systems = {
sessions,
sessions,
+ settings,
chat,
chat,
movement,
movement,
surf,
surf,
@@ -134,6 +137,7 @@ export class GameServer {
@@ -134,6 +137,7 @@ export class GameServer {
ctx.systems = this.systems
ctx.systems = this.systems
this.ordered = [
this.ordered = [
sessions,
sessions,
+ settings,
chat,
chat,
movement,
movement,
surf,
surf,
@@ -17,7 +17,13 @@ import { SurfSystem } from './systems/surf.js'
@@ -17,7 +17,13 @@ import { SurfSystem } from './systems/surf.js'
*/
*/
function surfWorld() {
function surfWorld() {
const override = loadSurfOverride('surf_lagoon')
const override = loadSurfOverride('surf_lagoon')
- const world = { id: 'surf_lagoon', movement: 'surf', zones: [], spawnPoint: [0, 2, 0], spawnYaw: 0 }
+ const world = {
+ id: 'surf_lagoon',
+ movement: 'surf',
+ zones: [],
+ spawnPoint: [0, 2, 0],
+ spawnYaw: 0,
+ }
setMapOverride(override, world.id)
setMapOverride(override, world.id)
return { world, override }
return { world, override }
}
}
@@ -72,6 +78,7 @@ function ctxFor(session: any, respawns: string[], sent: unknown[]) {
@@ -72,6 +78,7 @@ function ctxFor(session: any, respawns: string[], sent: unknown[]) {
bodyOf: () => undefined,
bodyOf: () => undefined,
},
},
systems: {
systems: {
+ settings: { movementParams: (base: unknown) => base },
vehicles: { driveVehicle() {} },
vehicles: { driveVehicle() {} },
combat: { respawn: (_s: unknown, died: boolean) => respawns.push(died ? 'died' : 'void') },
combat: { respawn: (_s: unknown, died: boolean) => respawns.push(died ? 'died' : 'void') },
},
},
@@ -28,6 +28,7 @@ import type { PhysicsSystem } from './physics.js'
@@ -28,6 +28,7 @@ import type { PhysicsSystem } from './physics.js'
import type { ProductionSystem } from './production.js'
import type { ProductionSystem } from './production.js'
import type { ReplicationSystem } from './replication.js'
import type { ReplicationSystem } from './replication.js'
import type { SessionsSystem } from './sessions.js'
import type { SessionsSystem } from './sessions.js'
+import type { SettingsSystem } from './settings.js'
import type { SurfSystem } from './surf.js'
import type { SurfSystem } from './surf.js'
import type { SurfRecords } from '../surfRecords.js'
import type { SurfRecords } from '../surfRecords.js'
import type { SocialSystem } from './social.js'
import type { SocialSystem } from './social.js'
@@ -192,6 +193,7 @@ export class SessionRegistry {
@@ -192,6 +193,7 @@ export class SessionRegistry {
*/
*/
export interface ServerSystems {
export interface ServerSystems {
sessions: SessionsSystem
sessions: SessionsSystem
+ settings: SettingsSystem
chat: ChatSystem
chat: ChatSystem
movement: MovementSystem
movement: MovementSystem
surf: SurfSystem
surf: SurfSystem
@@ -16,6 +16,7 @@ export { buildHandlerTable } from './system.js'
@@ -16,6 +16,7 @@ export { buildHandlerTable } from './system.js'
export type { HandlerMap, MessageHandler, System } from './system.js'
export type { HandlerMap, MessageHandler, System } from './system.js'
export { SessionsSystem } from './sessions.js'
export { SessionsSystem } from './sessions.js'
+export { SettingsSystem, canChangeServerCvars, SERVER_CVAR_ALLOWLIST } from './settings.js'
export { ChatSystem } from '../chatCommands.js'
export { ChatSystem } from '../chatCommands.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { SurfSystem } from './surf.js'
export { SurfSystem } from './surf.js'
@@ -144,7 +144,7 @@ export class MovementSystem implements System {
@@ -144,7 +144,7 @@ export class MovementSystem implements System {
// Resolve the place's movement once per tick, not once per player: the
// Resolve the place's movement once per tick, not once per player: the
// style is room-wide, and instanceHub's per-tick budget is tight.
// style is room-wide, and instanceHub's per-tick budget is tight.
const style = movementStyleFor(ctx.world.content.world)
const style = movementStyleFor(ctx.world.content.world)
- const params = movementParamsFor(style)
+ const params = ctx.systems.settings.movementParams(movementParamsFor(style))
const surfPlace = style === 'surf'
const surfPlace = style === 'surf'
for (const session of ctx.sessions.values()) {
for (const session of ctx.sessions.values()) {
this.stepSession(ctx, session, dt, params, surfPlace)
this.stepSession(ctx, session, dt, params, surfPlace)
@@ -1,5 +1,11 @@
@@ -1,5 +1,11 @@
import { STARTER_ITEMS, getMapOverride, worldSpawn } from '@openvibe/content'
import { STARTER_ITEMS, getMapOverride, worldSpawn } from '@openvibe/content'
-import { Inventory, SkillSet, movementParamsFor, movementStyleFor, type GameEntity } from '@openvibe/gameplay'
+import {
+ Inventory,
+ SkillSet,
+ movementParamsFor,
+ movementStyleFor,
+ type GameEntity,
+} from '@openvibe/gameplay'
import { CollisionLayer } from '@openvibe/physics'
import { CollisionLayer } from '@openvibe/physics'
import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'
import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'
import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'
import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'
@@ -188,12 +194,11 @@ export class SessionsSystem implements System {
@@ -188,12 +194,11 @@ export class SessionsSystem implements System {
// position: resuming half-way would skip the clock (brief decision 4).
// position: resuming half-way would skip the clock (brief decision 4).
const override = getMapOverride(world)
const override = getMapOverride(world)
const surfSpawn = override?.surf ? override.spawn : undefined
const surfSpawn = override?.surf ? override.spawn : undefined
- const spawn =
- surfSpawn
- ? vec3(surfSpawn[0], surfSpawn[1], surfSpawn[2])
- : existing && existing.positionInPlace !== false
- ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])
- : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])
+ const spawn = surfSpawn
+ ? vec3(surfSpawn[0], surfSpawn[1], surfSpawn[2])
+ : existing && existing.positionInPlace !== false
+ ? vec3(existing.pos[0], existing.pos[1], existing.pos[2])
+ : vec3(mapSpawn.pos[0], mapSpawn.pos[1], mapSpawn.pos[2])
const inventory = existing
const inventory = existing
? Inventory.fromDto(existing.inventory, ctx.world.content)
? Inventory.fromDto(existing.inventory, ctx.world.content)
: new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, ctx.world.content)
: new Inventory(INVENTORY_SIZE, HOTBAR_SIZE, ctx.world.content)
@@ -262,9 +267,13 @@ export class SessionsSystem implements System {
@@ -262,9 +267,13 @@ export class SessionsSystem implements System {
// Kinematic capsule so props collide with players. Shorter than the
// Kinematic capsule so props collide with players. Shorter than the
// movement hull and lifted off the feet: standing ON a prop must not
// movement hull and lifted off the feet: standing ON a prop must not
// press it down (that caused sink/jitter loops when prop-surfing).
// press it down (that caused sink/jitter loops when prop-surfing).
- const movement = movementParamsFor(movementStyleFor(world))
+ const movement = ctx.systems.settings.movementParams(movementParamsFor(movementStyleFor(world)))
const bodyId = ctx.world.physics.addBody({
const bodyId = ctx.world.physics.addBody({
- shape: { type: 'capsule', radius: movement.capsuleRadius, height: movement.capsuleHeight - 0.3 },
+ shape: {
+ type: 'capsule',
+ radius: movement.capsuleRadius,
+ height: movement.capsuleHeight - 0.3,
+ },
motion: 'kinematic',
motion: 'kinematic',
pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),
pos: vec3(session.move.pos.x, session.move.pos.y + 0.15, session.move.pos.z),
layer: CollisionLayer.Player,
layer: CollisionLayer.Player,
@@ -272,6 +281,7 @@ export class SessionsSystem implements System {
@@ -272,6 +281,7 @@ export class SessionsSystem implements System {
})
})
ctx.sessions.setBody(playerId, bodyId)
ctx.sessions.setBody(playerId, bodyId)
+ const cvarState = ctx.systems.settings.wireFor(rank)
// From here the client is in the game: chat is allowed only after this
// From here the client is in the game: chat is allowed only after this
// flag, so a `say` racing the join (or sent before `hello`) is dropped.
// flag, so a `say` racing the join (or sent before `hello`) is dropped.
session.welcomed = true
session.welcomed = true
@@ -285,6 +295,8 @@ export class SessionsSystem implements System {
@@ -285,6 +295,8 @@ export class SessionsSystem implements System {
tick: ctx.clock.tick,
tick: ctx.clock.tick,
tickRate: ctx.config.tickRate,
tickRate: ctx.config.tickRate,
snapshotRate: ctx.config.tickRate / ctx.config.snapshotEvery,
snapshotRate: ctx.config.tickRate / ctx.config.snapshotEvery,
+ cvars: cvarState.values,
+ cvarsWritable: cvarState.writable,
...(ctx.config.instancesPerPlace > 1
...(ctx.config.instancesPerPlace > 1
? {
? {
instanceId: `${ctx.config.placeId}:${ctx.config.instanceSlot ?? 0}`,
instanceId: `${ctx.config.placeId}:${ctx.config.instanceSlot ?? 0}`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest'
+import type { ClientCvarSet, ServerMessage } from '@openvibe/protocol'
+import type { PlayerSession } from '../playerSession.js'
+import type { GameConnection, ServerContext } from './context.js'
+import { canChangeServerCvars, SettingsSystem } from './settings.js'
+
+const guest = { rank: null, playerId: 'guest' } as unknown as PlayerSession
+const moderator = { rank: 'moderator', playerId: 'mod' } as unknown as PlayerSession
+const admin = { rank: 'admin', playerId: 'admin' } as unknown as PlayerSession
+
+function setup() {
+ const sent: { to: string; msg: ServerMessage }[] = []
+ const sessions = [guest, moderator, admin]
+ const ctx = {
+ // Surf values: gravity 800 u/s², airaccelerate 150.
+ content: { world: { id: 'surf_test', movement: 'surf' } },
+ sessions: {
+ values: () => sessions[Symbol.iterator](),
+ },
+ net: {
+ send: (session: PlayerSession, msg: ServerMessage) =>
+ sent.push({ to: session.playerId, msg }),
+ },
+ log: { info: () => {}, warn: () => {}, error: () => {} },
+ } as unknown as ServerContext
+ const settings = new SettingsSystem(ctx)
+ const call = (session: PlayerSession, msg: ClientCvarSet): void =>
+ (
+ settings.handlers.cvar_set as unknown as (
+ s: PlayerSession,
+ m: ClientCvarSet,
+ c: GameConnection,
+ ) => void
+ )(session, msg, {} as GameConnection)
+ return { settings, sent, call }
+}
+
+describe('server-authoritative console cvars', () => {
+ it('shows the surf preset in Source units', () => {
+ const { settings } = setup()
+ expect(settings.values().sv_gravity).toBe(800)
+ expect(settings.values().sv_airaccelerate).toBe(150)
+ expect(settings.movementParams().gravity).toBeCloseTo(20.32, 9)
+ })
+
+ it('refuses a guest without changing a single value', () => {
+ const { settings, sent, call } = setup()
+ call(guest, { t: 'cvar_set', name: 'sv_gravity', value: 900, requestId: 7 })
+ expect(sent).toHaveLength(1)
+ const state = sent[0]!.msg
+ expect(state.t).toBe('cvar_state')
+ if (state.t !== 'cvar_state') return
+ expect(state.error).toContain('permission')
+ expect(state.values.sv_gravity).toBe(800)
+ expect(settings.movementParams().gravity).toBeCloseTo(20.32, 9)
+ expect(canChangeServerCvars(guest.rank)).toBe(false)
+ expect(canChangeServerCvars(moderator.rank)).toBe(false)
+ })
+
+ it('applies an admin change to the room and broadcasts the authoritative values', () => {
+ const { settings, sent, call } = setup()
+ call(admin, { t: 'cvar_set', name: 'sv_gravity', value: 900, requestId: 9 })
+ expect(settings.values().sv_gravity).toBe(900)
+ expect(settings.movementParams().gravity).toBeCloseTo(22.86, 9)
+ expect(sent.map((s) => s.to).sort()).toEqual(['admin', 'guest', 'mod'])
+ const answer = sent.find((s) => s.to === 'admin')!.msg
+ if (answer.t !== 'cvar_state') throw new Error('expected cvar_state')
+ expect(answer.requestId).toBe(9)
+ expect(answer.error).toBeUndefined()
+ expect(answer.values.sv_gravity).toBe(900)
+ // Another player's copy has the value but not this request's id.
+ const peer = sent.find((s) => s.to === 'guest')!.msg
+ if (peer.t !== 'cvar_state') throw new Error('expected cvar_state')
+ expect(peer.requestId).toBeUndefined()
+ expect(peer.writable).toEqual([])
+ })
+
+ it('refuses names outside the allowlist and values outside the range', () => {
+ const { settings, sent, call } = setup()
+ call(admin, { t: 'cvar_set', name: 'cl_interp', value: 0.2, requestId: 1 })
+ const badName = sent.at(-1)!.msg
+ if (badName.t !== 'cvar_state') throw new Error('expected cvar_state')
+ expect(badName.error).toContain('not a server cvar')
+
+ call(admin, { t: 'cvar_set', name: 'sv_gravity', value: 9_999, requestId: 2 })
+ const badRange = sent.at(-1)!.msg
+ if (badRange.t !== 'cvar_state') throw new Error('expected cvar_state')
+ expect(badRange.error).toContain('at most')
+ expect(settings.values().sv_gravity).toBe(800)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+/**
+ * Server-authoritative console cvars (job 4).
+ *
+ * The client console may ask, but this system is the only place a `sv_*`
+ * value changes. It owns the allowlist: movement cvars from the Source layer
+ * in packages/shared, validated by the same registry the client displays, with
+ * a rank gate (owner/admin, the same staff permission as map editing).
+ *
+ * Values are stored in Source units, so `sv_gravity 800` means 800 u/s² on the
+ * wire and 20.32 m/s² in the simulation. The movement system asks for the
+ * effective params every tick; the client applies the same conversion from the
+ * welcome/cvar_state payload.
+ */
+import {
+ movementCvarValues,
+ movementParamsFor,
+ movementParamsWithCvars,
+ movementStyleFor,
+ type MovementParams,
+} from '@openvibe/gameplay'
+import type { ClientCvarSet, ServerCvarState } from '@openvibe/protocol'
+import { CvarRegistry, SOURCE_MOVEMENT_CVARS, type CvarValue } from '@openvibe/shared'
+import { canEditMap } from '../../net/networkAuth.js'
+import type { PlayerSession } from '../playerSession.js'
+import type { GameConnection, ServerContext } from './context.js'
+import type { HandlerMap, System } from './system.js'
+
+/** The server may be asked to change only these cvars. */
+export const SERVER_CVAR_ALLOWLIST: readonly string[] = SOURCE_MOVEMENT_CVARS.map(
+ (spec) => spec.name,
+)
+const ALLOWED = new Set(SERVER_CVAR_ALLOWLIST)
+const SPECS = new Map(SOURCE_MOVEMENT_CVARS.map((spec) => [spec.name, spec]))
+
+/** The same staff gate as map editing: owners and admins, not moderators. */
+export function canChangeServerCvars(rank: PlayerSession['rank']): boolean {
+ return canEditMap(rank)
+}
+
+export class SettingsSystem implements System {
+ readonly name = 'settings'
+ private readonly overrides = new Map<string, number | boolean>()
+ /** Validation only; the authoritative values live in `overrides`. */
+ private readonly validator = new CvarRegistry(SOURCE_MOVEMENT_CVARS)
+
+ readonly handlers: HandlerMap = {
+ cvar_set: (session, msg: ClientCvarSet, _conn: GameConnection) => this.set(session, msg),
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ private baseParams(): MovementParams {
+ return movementParamsFor(movementStyleFor(this.ctx.content.world))
+ }
+
+ /** Current effective Source-unit values, including the map preset's defaults. */
+ values(base: MovementParams = this.baseParams()): Record<string, number | boolean> {
+ const values = movementCvarValues(base)
+ for (const [name, value] of this.overrides) values[name] = value
+ // Every movement spec is numeric or boolean; movementCvarValues only
+ // emits those types because the specs have no string cvars.
+ return values as Record<string, number | boolean>
+ }
+
+ /** Current simulation params: the preset plus any server cvar overrides. */
+ movementParams(base: MovementParams = this.baseParams()): MovementParams {
+ return movementParamsWithCvars(base, this.values(base))
+ }
+
+ /** The welcome/`cvar_state` payload for one session. */
+ writable(rank: PlayerSession['rank']): string[] {
+ return canChangeServerCvars(rank) ? [...SERVER_CVAR_ALLOWLIST] : []
+ }
+
+ wireFor(rank: PlayerSession['rank']): {
+ values: Record<string, number | boolean>
+ writable: string[]
+ } {
+ return { values: this.values(), writable: this.writable(rank) }
+ }
+
+ private stateFor(
+ session: PlayerSession,
+ extra: { requestId?: number; error?: string } = {},
+ ): ServerCvarState {
+ return {
+ t: 'cvar_state',
+ values: this.values(),
+ writable: this.writable(session.rank),
+ ...(extra.requestId !== undefined ? { requestId: extra.requestId } : {}),
+ ...(extra.error !== undefined ? { error: extra.error } : {}),
+ }
+ }
+
+ private set(session: PlayerSession, msg: ClientCvarSet): void {
+ const requestId = msg.requestId
+ const fail = (error: string): void => {
+ this.ctx.net.send(
+ session,
+ this.stateFor(session, {
+ ...(requestId !== undefined ? { requestId } : {}),
+ error,
+ }),
+ )
+ }
+ if (!canChangeServerCvars(session.rank)) {
+ fail('server cvars need staff permission')
+ return
+ }
+ const name = msg.name.trim().toLowerCase()
+ if (!ALLOWED.has(name)) {
+ fail(`${name} is not a server cvar; it stays local`)
+ return
+ }
+ const parsed = this.validator.set(name, msg.value as CvarValue)
+ if (!parsed.ok) {
+ fail(parsed.error ?? `invalid value for ${name}`)
+ return
+ }
+ const spec = SPECS.get(name)
+ if (!spec || (typeof parsed.value !== 'number' && typeof parsed.value !== 'boolean')) {
+ fail(`${name} is not a server cvar`)
+ return
+ }
+ this.overrides.set(name, parsed.value)
+ const values = this.values()
+ // A room-wide change: everyone's prediction needs the new numbers.
+ for (const peer of this.ctx.sessions.values()) {
+ this.ctx.net.send(peer, {
+ t: 'cvar_state',
+ values,
+ writable: this.writable(peer.rank),
+ ...(peer === session && requestId !== undefined ? { requestId } : {}),
+ })
+ }
+ this.ctx.log.info('cvar changed', {
+ cvar: name,
+ value: parsed.value,
+ by: session.playerId,
+ })
+ }
+}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-games",
"name": "openvibe-games",
"private": true,
"private": true,
"version": "0.1.0",
"version": "0.1.0",
- "description": "Scraplandia — persistent multiplayer physics sandbox (browser)",
+ "description": "Scraplandia \u2014 persistent multiplayer physics sandbox (browser)",
"type": "module",
"type": "module",
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -21,7 +21,8 @@
@@ -21,7 +21,8 @@
"typecheck": "tsc --build tsconfig.solution.json",
"typecheck": "tsc --build tsconfig.solution.json",
"lint": "eslint .",
"lint": "eslint .",
"format": "prettier --write .",
"format": "prettier --write .",
- "format:check": "prettier --check ."
+ "format:check": "prettier --check .",
+ "test:console": "node --import tsx apps/client/e2e/consoleCheck.ts"
},
},
"devDependencies": {
"devDependencies": {
"@eslint/js": "^9.23.0",
"@eslint/js": "^9.23.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import { movementCvarValues, movementParamsWithCvars, SURF_MOVEMENT } from './styles.js'
+
+describe('Source cvars over movement params', () => {
+ it('reads the surf preset back in Source units without float noise', () => {
+ const values = movementCvarValues(SURF_MOVEMENT)
+ expect(values.sv_gravity).toBe(800)
+ expect(values.sv_airaccelerate).toBe(150)
+ expect(values.sv_friction).toBe(4)
+ expect(values.sv_jump_impulse).toBeCloseTo(301.992, 3)
+ expect(values.sv_air_max_wishspeed).toBe(30)
+ })
+
+ it('applies only the supplied cvars and converts units exactly like Source', () => {
+ const params = movementParamsWithCvars(SURF_MOVEMENT, {
+ sv_gravity: 900,
+ sv_airaccelerate: 100,
+ sv_maxspeed: 500,
+ })
+ expect(params.gravity).toBeCloseTo(22.86, 9)
+ expect(params.airAccel).toBe(100)
+ expect(params.maxGroundSpeed).toBeCloseTo(12.7, 9)
+ // CS has no sprint: raising the run cap raises the sprint cap too.
+ expect(params.maxSprintSpeed).toBeCloseTo(12.7, 9)
+ expect(params.friction).toBe(SURF_MOVEMENT.friction)
+ })
+
+ it('round-trips every movement cvar', () => {
+ const values = movementCvarValues(SURF_MOVEMENT)
+ const params = movementParamsWithCvars(SURF_MOVEMENT, values)
+ expect(movementCvarValues(params)).toEqual(values)
+ })
+})
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
import { getMapOverride, type MovementStyle, type WorldDef } from '@openvibe/content'
import { getMapOverride, type MovementStyle, type WorldDef } from '@openvibe/content'
+import { SOURCE_MOVEMENT_CVARS, type CvarValue } from '@openvibe/shared'
import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'
import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'
/**
/**
@@ -93,3 +94,54 @@ export function movementParamsFor(style: MovementStyle): MovementParams {
@@ -93,3 +94,54 @@ export function movementParamsFor(style: MovementStyle): MovementParams {
export function movementStyleFor(world: WorldDef): MovementStyle {
export function movementStyleFor(world: WorldDef): MovementStyle {
return getMapOverride(world)?.movement ?? world.movement ?? 'default'
return getMapOverride(world)?.movement ?? world.movement ?? 'default'
}
}
+
+/**
+ * The Source-unit values of a movement preset: what the console shows for
+ * `sv_gravity`, `sv_airaccelerate` and friends. Integers are rounded so the
+ * default 20.32 m/s² reads as 800, not 800.0000000000001.
+ */
+export function movementCvarValues(params: MovementParams): Record<string, number | boolean> {
+ const out: Record<string, number | boolean> = {}
+ for (const spec of SOURCE_MOVEMENT_CVARS) {
+ if (!spec.target) continue
+ const value = params[spec.target as keyof MovementParams]
+ if (typeof value === 'boolean') {
+ out[spec.name] = value
+ } else if (typeof value === 'number') {
+ const converted = value / (spec.scale ?? 1)
+ out[spec.name] =
+ spec.type === 'integer' ? Math.round(converted) : Number(converted.toFixed(6))
+ }
+ }
+ return out
+}
+
+/**
+ * Overlay authoritative Source-unit cvars onto a movement preset. Both the
+ * server simulation and the client's prediction call this with the same
+ * values, so a cvar change cannot desynchronise them.
+ */
+export function movementParamsWithCvars(
+ base: MovementParams,
+ values: Readonly<Record<string, CvarValue>>,
+): MovementParams {
+ const next: { -readonly [K in keyof MovementParams]: MovementParams[K] } = { ...base }
+ for (const spec of SOURCE_MOVEMENT_CVARS) {
+ if (!spec.target) continue
+ const value = values[spec.name]
+ if (value === undefined) continue
+ const target = spec.target as keyof MovementParams
+ if (typeof value === 'boolean') {
+ next[target] = value as never
+ continue
+ }
+ const numeric = typeof value === 'number' ? value : Number(value)
+ if (!Number.isFinite(numeric)) continue
+ const converted = numeric * (spec.scale ?? 1)
+ next[target] = converted as never
+ // CS has no sprint key: sv_maxspeed is the one run speed, so Shift must
+ // not clamp a raised limit back down on surf maps.
+ if (spec.target === 'maxGroundSpeed') next.maxSprintSpeed = converted
+ }
+ return next
+}
@@ -27,6 +27,7 @@ describe('protocol codec', () => {
@@ -27,6 +27,7 @@ describe('protocol codec', () => {
{ t: 'physgun', a: 'grab' },
{ t: 'physgun', a: 'grab' },
{ t: 'physgun', a: 'rotate', dyaw: 0.1, dpitch: -0.1, snap: true },
{ t: 'physgun', a: 'rotate', dyaw: 0.1, dpitch: -0.1, snap: true },
{ t: 'physgun', a: 'grid', on: true },
{ t: 'physgun', a: 'grid', on: true },
+ { t: 'cvar_set', name: 'sv_gravity', value: 800, requestId: 3 },
{ t: 'say', text: 'hello room' },
{ t: 'say', text: 'hello room' },
{ t: 'say_team', text: 'team line' },
{ t: 'say_team', text: 'team line' },
]
]
@@ -46,6 +47,8 @@ describe('protocol codec', () => {
@@ -46,6 +47,8 @@ describe('protocol codec', () => {
'{"t":"input","seq":1,"moveX":1e999,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',
'{"t":"input","seq":1,"moveX":1e999,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',
),
),
).toBeNull()
).toBeNull()
+ expect(decodeClientMessage('{"t":"cvar_set","name":"../x","value":1}')).toBeNull()
+ expect(decodeClientMessage('{"t":"cvar_set","name":"sv_gravity","value":1e999}')).toBeNull()
// out-of-range movement axes (speedhack attempt)
// out-of-range movement axes (speedhack attempt)
expect(
expect(
decodeClientMessage(
decodeClientMessage(
@@ -30,7 +30,12 @@ export const ClientHelloSchema = z.object({
@@ -30,7 +30,12 @@ export const ClientHelloSchema = z.object({
/** Requested place. Older clients omit it and join the configured default. */
/** Requested place. Older clients omit it and join the configured default. */
place: z.string().min(1).max(64).optional(),
place: z.string().min(1).max(64).optional(),
/** Which of this account's characters (0 … MAX_CHARACTERS - 1); a game, not the platform, decides how many it uses. */
/** Which of this account's characters (0 … MAX_CHARACTERS - 1); a game, not the platform, decides how many it uses. */
- slot: z.number().int().min(0).max(MAX_CHARACTERS - 1).default(0),
+ slot: z
+ .number()
+ .int()
+ .min(0)
+ .max(MAX_CHARACTERS - 1)
+ .default(0),
name: z.string().min(1).max(24),
name: z.string().min(1).max(24),
appearance: AppearanceSchema,
appearance: AppearanceSchema,
})
})
@@ -303,6 +308,23 @@ export const ClientContainerSortSchema = z.object({
@@ -303,6 +308,23 @@ export const ClientContainerSortSchema = z.object({
target: z.string().max(32),
target: z.string().max(32),
})
})
+/**
+ * Change one server-authoritative cvar. The server owns the allowlist, the
+ * rank check and the range check; this message is only a request. `requestId`
+ * is echoed in `cvar_state` so the console can tell its answer from another
+ * player's change.
+ */
+export const ClientCvarSetSchema = z.object({
+ t: z.literal('cvar_set'),
+ name: z
+ .string()
+ .min(1)
+ .max(64)
+ .regex(/^[a-z0-9_]+$/),
+ value: z.union([z.number().finite(), z.boolean()]),
+ requestId: z.number().int().nonnegative().max(1_000_000_000).optional(),
+})
+
/**
/**
* Source's `say`: a public chat line. The server rate-limits it and treats the
* Source's `say`: a public chat line. The server rate-limits it and treats the
* text as plain text throughout — it is never interpreted as markup.
* text as plain text throughout — it is never interpreted as markup.
@@ -350,6 +372,7 @@ export const ClientMessageSchema = z.union([
@@ -350,6 +372,7 @@ export const ClientMessageSchema = z.union([
ClientSaySchema,
ClientSaySchema,
ClientSayTeamSchema,
ClientSayTeamSchema,
ClientSurfRestartSchema,
ClientSurfRestartSchema,
+ ClientCvarSetSchema,
])
])
export type ClientHello = z.infer<typeof ClientHelloSchema>
export type ClientHello = z.infer<typeof ClientHelloSchema>
@@ -380,6 +403,7 @@ export type ClientJobTurnIn = z.infer<typeof ClientJobTurnInSchema>
@@ -380,6 +403,7 @@ export type ClientJobTurnIn = z.infer<typeof ClientJobTurnInSchema>
export type ClientContainerOpen = z.infer<typeof ClientContainerOpenSchema>
export type ClientContainerOpen = z.infer<typeof ClientContainerOpenSchema>
export type ClientContainerMove = z.infer<typeof ClientContainerMoveSchema>
export type ClientContainerMove = z.infer<typeof ClientContainerMoveSchema>
export type ClientContainerSort = z.infer<typeof ClientContainerSortSchema>
export type ClientContainerSort = z.infer<typeof ClientContainerSortSchema>
+export type ClientCvarSet = z.infer<typeof ClientCvarSetSchema>
export type ClientSay = z.infer<typeof ClientSaySchema>
export type ClientSay = z.infer<typeof ClientSaySchema>
export type ClientSayTeam = z.infer<typeof ClientSayTeamSchema>
export type ClientSayTeam = z.infer<typeof ClientSayTeamSchema>
export type { ClientSurfRestart } from './surf.js'
export type { ClientSurfRestart } from './surf.js'
@@ -29,6 +29,23 @@ export interface ServerWelcome {
@@ -29,6 +29,23 @@ export interface ServerWelcome {
/** Present when multi-instance mode is enabled. */
/** Present when multi-instance mode is enabled. */
instanceId?: string
instanceId?: string
instanceCount?: number
instanceCount?: number
+ /** Current server-authoritative cvars in Source units (see cvar_state). */
+ cvars?: Record<string, number | boolean>
+ /** The cvar names this player's rank may change. */
+ cvarsWritable?: string[]
+}
+
+/** The room's authoritative cvar values, sent after a change and in reply to `cvar_set`. */
+export interface ServerCvarState {
+ t: 'cvar_state'
+ /** Source-unit values, keyed by canonical Source name. */
+ values: Record<string, number | boolean>
+ /** Names this session's rank may change (the same subset as welcome). */
+ writable: string[]
+ /** Echo of the request this answers, when it answers one. */
+ requestId?: number
+ /** Why a requested change was refused; values are still the authoritative ones. */
+ error?: string
}
}
export interface ServerTransferResult {
export interface ServerTransferResult {
@@ -298,6 +315,7 @@ export type ServerMessage =
@@ -298,6 +315,7 @@ export type ServerMessage =
scores: { name: string; score: number; rank: number }[]
scores: { name: string; score: number; rank: number }[]
}
}
| ServerWelcome
| ServerWelcome
+ | ServerCvarState
| ServerTransferResult
| ServerTransferResult
| ServerReject
| ServerReject
| ServerSpawn
| ServerSpawn
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+import {
+ AUTOEXEC_CONFIG,
+ Bindings,
+ ConfigStore,
+ CommandRegistry,
+ DEFAULT_BINDS,
+ intentCommand,
+ keyCodeFor,
+ keyNameFromCode,
+ normalizeKeyName,
+ parseConfig,
+ parseCommandLine,
+ tokenizeCommand,
+ type KeyValueStorage,
+} from './commands.js'
+
+/** A localStorage-shaped store, with the enumeration ConfigStore.list uses. */
+class MemoryStorage implements KeyValueStorage {
+ private readonly data = new Map<string, string>()
+ get length(): number {
+ return this.data.size
+ }
+ getItem(key: string): string | null {
+ return this.data.get(key) ?? null
+ }
+ setItem(key: string, value: string): void {
+ this.data.set(key, value)
+ }
+ removeItem(key: string): void {
+ this.data.delete(key)
+ }
+ key(index: number): string | null {
+ return [...this.data.keys()][index] ?? null
+ }
+}
+
+describe('console config parser', () => {
+ it('splits quoted arguments, comments and semicolon-separated commands', () => {
+ const text = [
+ '// a comment, not a command',
+ 'bind space +jump',
+ 'bind "a" "say hello world"; sv_gravity 800 // trailing',
+ '',
+ 'exec practice.cfg',
+ ].join('\n')
+ expect(parseConfig(text)).toEqual([
+ { name: 'bind', args: ['space', '+jump'], raw: 'bind space +jump', line: 2 },
+ { name: 'bind', args: ['a', 'say hello world'], raw: 'bind a say hello world', line: 3 },
+ { name: 'sv_gravity', args: ['800'], raw: 'sv_gravity 800', line: 3 },
+ { name: 'exec', args: ['practice.cfg'], raw: 'exec practice.cfg', line: 5 },
+ ])
+ })
+
+ it('tokenises one line and keeps empty quoted arguments', () => {
+ expect(tokenizeCommand('say "hi there" x')).toEqual(['say', 'hi there', 'x'])
+ expect(tokenizeCommand('bind "a" ""')).toEqual(['bind', 'a', ''])
+ expect(parseCommandLine('+jump; -jump')).toEqual([
+ { name: '+jump', args: [], raw: '+jump', line: 1 },
+ { name: '-jump', args: [], raw: '-jump', line: 1 },
+ ])
+ })
+})
+
+describe('Source key names and bindings', () => {
+ it('maps Source key names to stable DOM codes', () => {
+ expect(keyCodeFor('space')).toBe('Space')
+ expect(keyCodeFor('ctrl')).toBe('ControlLeft')
+ expect(keyCodeFor('a')).toBe('KeyA')
+ expect(keyCodeFor('1')).toBe('Digit1')
+ expect(normalizeKeyName('grave')).toBe('~')
+ expect(normalizeKeyName('KeyA')).toBe('a')
+ expect(keyNameFromCode('Space')).toBe('space')
+ expect(normalizeKeyName('not-a-key')).toBeNull()
+ })
+
+ it('starts from Source defaults and persists bind changes', () => {
+ const storage = new MemoryStorage()
+ const first = new Bindings({ storage })
+ expect(first.commandForCode('Space')).toBe('+jump')
+ expect(first.commandForCode('ControlLeft')).toBe('+duck')
+ first.bind('space', '+duck')
+ first.bind('q', 'toggle_ghost')
+ expect(first.commandForCode('Space')).toBe('+duck')
+
+ // A new browser session reads the same binds back.
+ const second = new Bindings({ storage })
+ expect(second.commandForCode('Space')).toBe('+duck')
+ expect(second.commandForCode('KeyQ')).toBe('toggle_ghost')
+ expect(second.unbind('q').ok).toBe(true)
+ expect(new Bindings({ storage }).commandForCode('KeyQ')).toBeNull()
+ })
+
+ it('unbinds an intent and keeps the other defaults', () => {
+ const storage = new MemoryStorage()
+ const bindings = new Bindings({ storage })
+ bindings.unbind('space')
+ expect(bindings.commandForCode('Space')).toBeNull()
+ expect(bindings.commandForCode('KeyR')).toBe(DEFAULT_BINDS.r)
+ expect(intentCommand('+jump')).toEqual({ name: 'Jump', down: true })
+ expect(intentCommand('-jump')).toEqual({ name: 'Jump', down: false })
+ expect(intentCommand('+not-an-intent')).toBeNull()
+ })
+})
+
+describe('virtual cfg files', () => {
+ it('normalises names and persists through a fresh store', () => {
+ const storage = new MemoryStorage()
+ const configs = new ConfigStore({ storage })
+ expect(configs.normalize('practice')).toBe('practice.cfg')
+ expect(configs.normalize('CFG/AUTOEXEC.CFG')).toBe(AUTOEXEC_CONFIG)
+ expect(configs.normalize('../evil')).toBeNull()
+ expect(configs.normalize('')).toBeNull()
+ expect(configs.write('practice', 'bind space +jump').ok).toBe(true)
+ expect(new ConfigStore({ storage }).read('practice.cfg')).toBe('bind space +jump')
+ expect(configs.list()).toEqual(['practice.cfg'])
+ expect(configs.remove('practice.cfg')).toBe(true)
+ expect(configs.read('practice.cfg')).toBeNull()
+ })
+
+ it('returns no list without a storage implementation', () => {
+ const configs = new ConfigStore({ storage: null })
+ expect(configs.write('autoexec.cfg', 'bind space +jump')).toEqual({
+ ok: false,
+ error: 'browser storage is unavailable',
+ })
+ expect(configs.list()).toEqual([])
+ })
+})
+
+describe('command registry', () => {
+ it('resolves aliases, searches help text and lists canonical commands once', () => {
+ const registry = new CommandRegistry<{ hits: string[] }>([
+ {
+ name: 'help',
+ aliases: ['?'],
+ help: 'Show help for a command.',
+ run: (args, context) => {
+ context.hits.push(args.join(' '))
+ return 'ok'
+ },
+ },
+ { name: 'find', help: 'Search for a name.', run: () => [] },
+ ])
+ expect(registry.get('?')?.name).toBe('help')
+ expect(registry.all().map((c) => c.name)).toEqual(['find', 'help'])
+ expect(registry.find('search').map((c) => c.name)).toEqual(['find'])
+ const context = { hits: [] as string[] }
+ void registry.get('help')!.run(['gravity'], context)
+ expect(context.hits).toEqual(['gravity'])
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,583 @@
@@ -0,0 +1,583 @@
+/**
+ * Source-style console input primitives shared by the client's two pages.
+ *
+ * Nothing here touches the DOM: the parser, the key-binding table, the tiny
+ * virtual config filesystem and the command registry are pure data and can be
+ * tested on their own. The console overlay in apps/client/src/ui/console.ts is
+ * the presentation layer.
+ */
+import type { IntentName } from './intents.js'
+
+/** The small storage surface the browser and tests both provide. */
+export interface KeyValueStorage {
+ getItem(key: string): string | null
+ setItem(key: string, value: string): void
+ removeItem(key: string): void
+ /** Present on localStorage; tests may omit it. */
+ readonly length?: number
+ key?(index: number): string | null
+}
+
+/** localStorage, or null where the browser refuses it (private mode, blocked site data). */
+export function browserStorage(): KeyValueStorage | null {
+ try {
+ return globalThis.localStorage ?? null
+ } catch {
+ return null
+ }
+}
+
+/** One command after tokenising: `bind space +jump` -> name `bind`, args `['space', '+jump']`. */
+export interface ParsedCommand {
+ name: string
+ args: string[]
+ /** The command as it reads after tokenising (quotes removed). */
+ raw: string
+ /** 1-based line in the config it came from. */
+ line: number
+}
+
+/** Split one line into arguments, honouring quotes and `//` comments. */
+export function tokenizeCommand(line: string): string[] {
+ const tokens: string[] = []
+ let token = ''
+ let inToken = false
+ let quoted = false
+ let escaped = false
+ for (let i = 0; i < line.length; i++) {
+ const c = line[i]!
+ if (escaped) {
+ token += c
+ inToken = true
+ escaped = false
+ continue
+ }
+ if (quoted && c === '\\') {
+ escaped = true
+ continue
+ }
+ if (c === '"') {
+ quoted = !quoted
+ inToken = true
+ continue
+ }
+ if (!quoted && c === '/' && line[i + 1] === '/') break
+ if (!quoted && /\s/.test(c)) {
+ if (inToken) {
+ tokens.push(token)
+ token = ''
+ inToken = false
+ }
+ continue
+ }
+ token += c
+ inToken = true
+ }
+ if (escaped) token += '\\'
+ if (inToken) tokens.push(token)
+ return tokens
+}
+
+/**
+ * Parse a Source cfg / console script. Newlines and semicolons separate
+ * commands; `//` comments and quoted arguments are honoured. A command split
+ * over several lines fails rather than guessing, matching Source's
+ * one-command-per-line configs.
+ */
+export function parseConfig(text: string): ParsedCommand[] {
+ const commands: ParsedCommand[] = []
+ let tokens: string[] = []
+ let token = ''
+ let inToken = false
+ let quoted = false
+ let escaped = false
+ let line = 1
+ let commandLine = 1
+
+ const flushToken = (): void => {
+ if (!inToken) return
+ tokens.push(token)
+ token = ''
+ inToken = false
+ }
+ const flushCommand = (): void => {
+ flushToken()
+ if (!tokens.length) {
+ commandLine = line
+ return
+ }
+ const name = tokens[0]!
+ commands.push({
+ name,
+ args: tokens.slice(1),
+ raw: tokens.join(' '),
+ line: commandLine,
+ })
+ tokens = []
+ commandLine = line
+ }
+
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i]!
+ if (escaped) {
+ token += c
+ inToken = true
+ escaped = false
+ continue
+ }
+ if (quoted && c === '\\') {
+ escaped = true
+ continue
+ }
+ if (c === '"') {
+ quoted = !quoted
+ inToken = true
+ continue
+ }
+ if (!quoted && c === '/' && text[i + 1] === '/') {
+ while (i + 1 < text.length && text[i + 1] !== '\n') i++
+ continue
+ }
+ if (!quoted && (c === '\n' || c === ';')) {
+ flushCommand()
+ if (c === '\n') line++
+ commandLine = line
+ continue
+ }
+ if (!quoted && /\s/.test(c)) {
+ flushToken()
+ continue
+ }
+ token += c
+ inToken = true
+ }
+ if (escaped) token += '\\'
+ flushCommand()
+ return commands
+}
+
+/** Parse one console line into the commands it carries. */
+export function parseCommandLine(line: string): ParsedCommand[] {
+ return parseConfig(line)
+}
+
+/**
+ * Source key names, normalised. The browser's `KeyboardEvent.code` is the
+ * stable identity (layout-independent); a player's `bind a +jump` means the
+ * physical A key, exactly as Source reads it.
+ */
+const KEY_TO_CODE: Readonly<Record<string, string>> = {
+ '~': 'Backquote',
+ '`': 'Backquote',
+ backquote: 'Backquote',
+ grave: 'Backquote',
+ space: 'Space',
+ spacebar: 'Space',
+ tab: 'Tab',
+ enter: 'Enter',
+ return: 'Enter',
+ escape: 'Escape',
+ esc: 'Escape',
+ ctrl: 'ControlLeft',
+ control: 'ControlLeft',
+ shift: 'ShiftLeft',
+ alt: 'AltLeft',
+ option: 'AltLeft',
+ capslock: 'CapsLock',
+ backspace: 'Backspace',
+ delete: 'Delete',
+ del: 'Delete',
+ insert: 'Insert',
+ ins: 'Insert',
+ home: 'Home',
+ end: 'End',
+ pageup: 'PageUp',
+ pagedown: 'PageDown',
+ up: 'ArrowUp',
+ down: 'ArrowDown',
+ left: 'ArrowLeft',
+ right: 'ArrowRight',
+ minus: 'Minus',
+ equal: 'Equal',
+ equals: 'Equal',
+ comma: 'Comma',
+ period: 'Period',
+ slash: 'Slash',
+ semicolon: 'Semicolon',
+ apostrophe: 'Quote',
+ quote: 'Quote',
+ bracketleft: 'BracketLeft',
+ bracketright: 'BracketRight',
+ backslash: 'Backslash',
+}
+
+/** Preferred spelling for each physical key: one name per DOM code. */
+const CODE_TO_KEY: Readonly<Record<string, string>> = {
+ Backquote: '~',
+ Space: 'space',
+ Tab: 'tab',
+ Enter: 'enter',
+ Escape: 'escape',
+ ControlLeft: 'ctrl',
+ ShiftLeft: 'shift',
+ AltLeft: 'alt',
+ CapsLock: 'capslock',
+ Backspace: 'backspace',
+ Delete: 'delete',
+ Insert: 'insert',
+ Home: 'home',
+ End: 'end',
+ PageUp: 'pageup',
+ PageDown: 'pagedown',
+ ArrowUp: 'up',
+ ArrowDown: 'down',
+ ArrowLeft: 'left',
+ ArrowRight: 'right',
+ Minus: 'minus',
+ Equal: 'equal',
+ Comma: 'comma',
+ Period: 'period',
+ Slash: 'slash',
+ Semicolon: 'semicolon',
+ Quote: 'apostrophe',
+ BracketLeft: 'bracketleft',
+ BracketRight: 'bracketright',
+ Backslash: 'backslash',
+}
+
+/** Normalise a Source key name to one canonical spelling; null when it is not a known key. */
+export function normalizeKeyName(name: string): string | null {
+ const code = keyCodeFor(name)
+ return code ? keyNameFromCode(code) : null
+}
+
+/** The DOM code a Source key name means, or null when it is not a keyboard key. */
+export function keyCodeFor(name: string): string | null {
+ const raw = name.trim().toLowerCase()
+ const mapped = KEY_TO_CODE[raw]
+ if (mapped) return mapped
+ if (/^[a-z]$/.test(raw)) return `Key${raw.toUpperCase()}`
+ if (/^[0-9]$/.test(raw)) return `Digit${raw}`
+ if (/^f([1-9]|1[0-2])$/.test(raw)) return `F${raw.slice(1)}`
+ // A DOM code supplied directly (case-sensitive spellings).
+ if (
+ /^(Key[A-Z]|Digit[0-9]|F([1-9]|1[0-2])|Arrow(Up|Down|Left|Right)|Control(Left|Right)|Shift(Left|Right)|Alt(Left|Right)|[A-Z][A-Za-z]+)$/.test(
+ name.trim(),
+ )
+ )
+ return name.trim()
+ return null
+}
+
+/** The Source key name for a DOM code (the inverse of keyCodeFor where it is unambiguous). */
+export function keyNameFromCode(code: string): string | null {
+ const direct = CODE_TO_KEY[code]
+ if (direct) return direct
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase()
+ if (/^Digit[0-9]$/.test(code)) return code.slice(5)
+ if (/^F([1-9]|1[0-2])$/.test(code)) return code.toLowerCase()
+ return null
+}
+
+/** Config cvars and binds are per-browser, as Source's cfg files are per-install. */
+export const BINDS_STORAGE_KEY = 'openvibe.console.binds'
+export const CVARS_STORAGE_KEY = 'openvibe.console.cvars'
+export const HISTORY_STORAGE_KEY = 'openvibe.console.history'
+export const CONFIG_STORAGE_PREFIX = 'openvibe.cfg.'
+export const AUTOEXEC_CONFIG = 'autoexec.cfg'
+
+/**
+ * The keys a fresh install starts with. The default is Source-vocabulary:
+ * `+jump` and friends are intent commands; the console reads the same table.
+ */
+export const DEFAULT_BINDS: Readonly<Record<string, string>> = Object.freeze({
+ space: '+jump',
+ ctrl: '+duck',
+ c: '+duck',
+ shift: '+sprint',
+ z: '+prone',
+ r: '+restart',
+ g: 'toggle_ghost',
+ '~': 'toggleconsole',
+})
+
+export interface BindingsOptions {
+ storage?: KeyValueStorage | null
+ storageKey?: string
+ defaults?: Readonly<Record<string, string>>
+}
+
+/** Key -> command bindings, persisted as a single localStorage value. */
+export class Bindings {
+ private readonly values = new Map<string, string>()
+ private readonly storage: KeyValueStorage | null
+ private readonly storageKey: string
+ private readonly defaults: Readonly<Record<string, string>>
+
+ constructor(options: BindingsOptions = {}) {
+ this.storage = options.storage === undefined ? browserStorage() : options.storage
+ this.storageKey = options.storageKey ?? BINDS_STORAGE_KEY
+ this.defaults = options.defaults ?? DEFAULT_BINDS
+ for (const [key, command] of Object.entries(this.defaults)) {
+ const normalized = normalizeKeyName(key)
+ if (normalized) this.values.set(normalized, command)
+ }
+ this.load()
+ }
+
+ private load(): void {
+ let raw: string | null = null
+ try {
+ raw = this.storage?.getItem(this.storageKey) ?? null
+ } catch {
+ return
+ }
+ if (!raw) return
+ try {
+ const parsed = JSON.parse(raw) as { v?: number; binds?: Record<string, unknown> }
+ if (parsed.v !== 1 || !parsed.binds || typeof parsed.binds !== 'object') return
+ for (const [key, command] of Object.entries(parsed.binds)) {
+ const normalized = normalizeKeyName(key)
+ if (normalized && typeof command === 'string' && command.trim()) {
+ this.values.set(normalized, command.trim())
+ }
+ }
+ } catch {
+ // Corrupt storage: defaults still work.
+ }
+ }
+
+ private save(): void {
+ if (!this.storage) return
+ try {
+ this.storage.setItem(this.storageKey, JSON.stringify({ v: 1, binds: this.asRecord() }))
+ } catch {
+ // Private mode/quota: the bindings still work until reload.
+ }
+ }
+
+ /** Bind a key; an empty command unbinds it. Returns the canonical key. */
+ bind(key: string, command: string): { ok: true; key: string } | { ok: false; error: string } {
+ const normalized = normalizeKeyName(key)
+ if (!normalized) return { ok: false, error: `unknown key "${key}"` }
+ if (!command.trim()) {
+ this.values.delete(normalized)
+ } else {
+ this.values.set(normalized, command.trim())
+ }
+ this.save()
+ return { ok: true, key: normalized }
+ }
+
+ unbind(key: string): { ok: true; key: string } | { ok: false; error: string } {
+ const normalized = normalizeKeyName(key)
+ if (!normalized) return { ok: false, error: `unknown key "${key}"` }
+ this.values.delete(normalized)
+ this.save()
+ return { ok: true, key: normalized }
+ }
+
+ get(key: string): string | null {
+ const normalized = normalizeKeyName(key)
+ return normalized ? (this.values.get(normalized) ?? null) : null
+ }
+
+ /** The command a DOM key code expresses, if any. */
+ commandForCode(code: string): string | null {
+ const name = keyNameFromCode(code)
+ return name ? (this.values.get(name) ?? null) : null
+ }
+
+ /** Every binding, sorted by key name. */
+ entries(): { key: string; command: string }[] {
+ return [...this.values.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, command]) => ({ key, command }))
+ }
+
+ asRecord(): Record<string, string> {
+ return Object.fromEntries(this.values)
+ }
+}
+
+/** What a `+command` means for the intent bitfield. */
+const INTENT_COMMANDS: Readonly<Record<string, IntentName>> = {
+ jump: 'Jump',
+ duck: 'Crouch',
+ crouch: 'Crouch',
+ sprint: 'Sprint',
+ use: 'Use',
+ attack: 'Attack',
+ prone: 'Prone',
+}
+
+/** `+jump` -> { name: 'Jump', down: true }; `-jump` -> down false. */
+export function intentCommand(command: string): { name: IntentName; down: boolean } | null {
+ const raw = command.trim().toLowerCase()
+ if (raw.length < 2) return null
+ const sign = raw[0]
+ if (sign !== '+' && sign !== '-') return null
+ const name = INTENT_COMMANDS[raw.slice(1)]
+ return name ? { name, down: sign === '+' } : null
+}
+
+/** Is this a `+`/`-` intent command (so a bind should feed intents, not a one-shot action)? */
+export function isIntentCommand(command: string): boolean {
+ return intentCommand(command) !== null
+}
+
+export interface ConfigStoreOptions {
+ storage?: KeyValueStorage | null
+ prefix?: string
+}
+
+/** A virtual `.cfg` folder in localStorage: `exec practice.cfg`, `autoexec.cfg`, etc. */
+export class ConfigStore {
+ private readonly storage: KeyValueStorage | null
+ private readonly prefix: string
+
+ constructor(options: ConfigStoreOptions = {}) {
+ this.storage = options.storage === undefined ? browserStorage() : options.storage
+ this.prefix = options.prefix ?? CONFIG_STORAGE_PREFIX
+ }
+
+ /** `practice`, `practice.cfg` and `PRACTICE.CFG` all name `practice.cfg`. */
+ normalize(name: string): string | null {
+ let value = name.trim().toLowerCase()
+ if (value.startsWith('cfg/')) value = value.slice(4)
+ if (!value) return null
+ if (!value.endsWith('.cfg')) value += '.cfg'
+ if (!/^[a-z0-9][a-z0-9_.-]*\.cfg$/.test(value)) return null
+ if (value.includes('..')) return null
+ return value
+ }
+
+ read(name: string): string | null {
+ const key = this.normalize(name)
+ if (!key || !this.storage) return null
+ try {
+ return this.storage.getItem(this.prefix + key)
+ } catch {
+ return null
+ }
+ }
+
+ has(name: string): boolean {
+ return this.read(name) !== null
+ }
+
+ write(name: string, text: string): { ok: true; name: string } | { ok: false; error: string } {
+ const key = this.normalize(name)
+ if (!key) return { ok: false, error: `invalid config name "${name}"` }
+ if (!this.storage) return { ok: false, error: 'browser storage is unavailable' }
+ try {
+ this.storage.setItem(this.prefix + key, text)
+ return { ok: true, name: key }
+ } catch {
+ return { ok: false, error: 'browser storage refused the config' }
+ }
+ }
+
+ remove(name: string): boolean {
+ const key = this.normalize(name)
+ if (!key || !this.storage) return false
+ try {
+ this.storage.removeItem(this.prefix + key)
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ list(): string[] {
+ if (!this.storage) return []
+ const prefix = this.prefix
+ const names: string[] = []
+ try {
+ const length = this.storage.length ?? 0
+ for (let i = 0; i < length; i++) {
+ const key = this.storage.key?.(i)
+ if (key?.startsWith(prefix)) names.push(key.slice(prefix.length))
+ }
+ } catch {
+ return []
+ }
+ return names.sort()
+ }
+}
+
+/** The result a command reports back to the console. */
+export type CommandResult =
+ string | readonly string[] | { lines?: readonly string[]; error?: string; close?: boolean } | void
+
+/** One console command. `run` may be async (exec and server round-trips are). */
+export interface CommandDef<C = unknown> {
+ name: string
+ aliases?: readonly string[]
+ usage?: string
+ help: string
+ /** Allowed in the casual page's read-only console. */
+ readOnly?: boolean
+ run(args: string[], context: C): CommandResult | Promise<CommandResult>
+}
+
+/** A case-insensitive command table with alias lookup and help search. */
+export class CommandRegistry<C = unknown> {
+ private readonly canonical = new Map<string, CommandDef<C>>()
+ private readonly byName = new Map<string, CommandDef<C>>()
+
+ constructor(defs: readonly CommandDef<C>[] = []) {
+ for (const def of defs) this.register(def)
+ }
+
+ register(def: CommandDef<C>): void {
+ const name = def.name.trim().toLowerCase()
+ if (!name) throw new Error('command name must not be empty')
+ if (this.byName.has(name)) throw new Error(`duplicate command "${name}"`)
+ this.canonical.set(name, def)
+ this.byName.set(name, def)
+ for (const alias of def.aliases ?? []) {
+ const key = alias.trim().toLowerCase()
+ if (!key) continue
+ if (this.byName.has(key)) throw new Error(`duplicate command alias "${key}"`)
+ this.byName.set(key, def)
+ }
+ }
+
+ get(name: string): CommandDef<C> | null {
+ return this.byName.get(name.trim().toLowerCase()) ?? null
+ }
+
+ /** Registered commands in name order (aliases are not repeated). */
+ all(): CommandDef<C>[] {
+ return [...this.canonical.values()].sort((a, b) => a.name.localeCompare(b.name))
+ }
+
+ /** Command and alias names starting with `prefix`. */
+ matching(prefix: string): string[] {
+ const p = prefix.trim().toLowerCase()
+ return [...this.byName.keys()].filter((name) => name.startsWith(p)).sort()
+ }
+
+ /** Commands whose name, alias or help contains `text`. */
+ find(text: string): CommandDef<C>[] {
+ const needle = text.trim().toLowerCase()
+ if (!needle) return this.all()
+ const out = new Set<CommandDef<C>>()
+ for (const def of this.canonical.values()) {
+ if (
+ def.name.toLowerCase().includes(needle) ||
+ def.help.toLowerCase().includes(needle) ||
+ (def.aliases ?? []).some((a) => a.toLowerCase().includes(needle))
+ )
+ out.add(def)
+ }
+ return [...out].sort((a, b) => a.name.localeCompare(b.name))
+ }
+
+ help(def: CommandDef<C>): string[] {
+ const lines = [def.usage ? `${def.name} — ${def.usage}` : def.name, def.help]
+ if (def.aliases?.length) lines.push(`aliases: ${def.aliases.join(', ')}`)
+ return lines
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+import { CLIENT_CVARS, CvarRegistry, SOURCE_MOVEMENT_CVARS, type CvarSpec } from './registry.js'
+
+const registry = (): CvarRegistry => new CvarRegistry([...SOURCE_MOVEMENT_CVARS, ...CLIENT_CVARS])
+
+describe('cvar registry', () => {
+ it('shows Source values and resolves our names as aliases', () => {
+ const cvars = registry()
+ expect(cvars.formatValue('sv_gravity')).toBe('800')
+ expect(cvars.get('gravity')).toBe(800)
+ expect(cvars.canonical('airaccelerate')).toBe('sv_airaccelerate')
+ expect(cvars.formatValue('sv_airaccelerate')).toBe('150')
+ expect(cvars.formatValue('sv_autobunnyhopping')).toBe('0')
+ })
+
+ it('validates types, ranges and readonly flags', () => {
+ const cvars = registry()
+ expect(cvars.set('sv_airaccelerate', '150').ok).toBe(true)
+ expect(cvars.get('sv_airaccelerate')).toBe(150)
+ expect(cvars.set('sv_gravity', '-1').ok).toBe(false)
+ expect(cvars.set('sv_gravity', 'lots').ok).toBe(false)
+ expect(cvars.set('sv_autobunnyhopping', 'on').ok).toBe(true)
+ expect(cvars.get('sv_autobunnyhopping')).toBe(true)
+ expect(cvars.set('nope', '1').ok).toBe(false)
+
+ const readonly: CvarSpec = {
+ name: 'sv_locked',
+ type: 'float',
+ defaultValue: 1,
+ flags: ['server', 'readonly'],
+ help: 'locked',
+ }
+ const locked = new CvarRegistry([readonly])
+ expect(locked.set('sv_locked', '2')).toEqual({ ok: false, error: 'sv_locked is read-only' })
+ // The server can still replicate it.
+ expect(locked.forceSet('sv_locked', 2).ok).toBe(true)
+ })
+
+ it('only persists client flags and lets an authoritative map be applied', () => {
+ const cvars = registry()
+ cvars.set('volume', '0.5')
+ cvars.set('cl_interp', '0.05')
+ expect(cvars.snapshot((spec) => spec.flags.includes('client'))).toMatchObject({
+ volume: 0.5,
+ cl_interp: 0.05,
+ })
+ // A server value overrides the local default but not a client-only setting.
+ const changed = cvars.apply({ sv_gravity: 900, volume: 0.2, unknown: 1 })
+ expect(changed).toContain('sv_gravity')
+ expect(cvars.get('sv_gravity')).toBe(900)
+ expect(cvars.get('volume')).toBe(0.5)
+ })
+
+ it('searches names, aliases and help, and formats help with units', () => {
+ const cvars = registry()
+ expect(cvars.find('gravity').map((s) => s.name)).toContain('sv_gravity')
+ expect(cvars.find('surf').map((s) => s.name)).toContain('sv_airaccelerate')
+ expect(cvars.matching('sv_air')).toContain('sv_airaccelerate')
+ const help = cvars.help(cvars.spec('sv_gravity')!)
+ expect(help[0]).toBe('sv_gravity = 800 u/s² [float]')
+ expect(help.join('\n')).toContain('aliases: gravity')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,441 @@
@@ -0,0 +1,441 @@
+/**
+ * The console's cvar layer.
+ *
+ * Source names and Source units are the player-facing interface; the engine
+ * keeps metres and seconds. A definition may therefore name an internal
+ * `target` and a `scale` (units × scale = engine value), but the registry
+ * itself stores the value the player typed in Source units. The gameplay
+ * package owns the actual conversion into `MovementParams`.
+ */
+
+/** What a cvar value can be on the wire. */
+export type CvarValue = number | boolean | string
+export type CvarType = 'float' | 'integer' | 'boolean' | 'string'
+
+/**
+ * `server` — authoritative; only the server may change it.
+ * `replicated` — sent in welcome/cvar_state (server values).
+ * `client` — local to this browser, safe to change from the console.
+ * `archive` — persisted in localStorage.
+ * `cheat` — exists, but a normal player cannot change it.
+ */
+export type CvarFlag = 'server' | 'client' | 'replicated' | 'archive' | 'cheat' | 'readonly'
+
+export interface CvarSpec {
+ name: string
+ type: CvarType
+ defaultValue: CvarValue
+ flags: readonly CvarFlag[]
+ help: string
+ aliases?: readonly string[]
+ /** Engine-side field this cvar maps onto (gameplay interprets it). */
+ target?: string
+ /** Source value × scale = engine value. */
+ scale?: number
+ unit?: string
+ min?: number
+ max?: number
+}
+
+export interface CvarSetResult {
+ ok: boolean
+ value?: CvarValue
+ error?: string
+}
+
+const numberFor = (input: CvarValue, type: 'float' | 'integer'): number | null => {
+ const value =
+ typeof input === 'number'
+ ? input
+ : typeof input === 'string' && input.trim() !== ''
+ ? Number(input)
+ : Number.NaN
+ if (!Number.isFinite(value)) return null
+ if (type === 'integer' && !Number.isInteger(value)) return null
+ return value
+}
+
+const boolFor = (input: CvarValue): boolean | null => {
+ if (typeof input === 'boolean') return input
+ const value = typeof input === 'number' ? String(input) : input.trim().toLowerCase()
+ if (['1', 'true', 'on', 'yes'].includes(value)) return true
+ if (['0', 'false', 'off', 'no'].includes(value)) return false
+ return null
+}
+
+/** An in-memory cvar table with Source-style aliases, flags and validation. */
+export class CvarRegistry {
+ private readonly byName = new Map<string, CvarSpec>()
+ private readonly byAlias = new Map<string, CvarSpec>()
+ private readonly values = new Map<string, CvarValue>()
+
+ constructor(specs: readonly CvarSpec[] = []) {
+ for (const spec of specs) this.register(spec)
+ }
+
+ register(spec: CvarSpec): void {
+ const name = spec.name.trim().toLowerCase()
+ if (!name) throw new Error('cvar name must not be empty')
+ if (this.byName.has(name)) throw new Error(`duplicate cvar "${name}"`)
+ this.byName.set(name, spec)
+ this.byAlias.set(name, spec)
+ for (const alias of spec.aliases ?? []) {
+ const key = alias.trim().toLowerCase()
+ if (!key) continue
+ if (this.byAlias.has(key)) throw new Error(`duplicate cvar alias "${key}"`)
+ this.byAlias.set(key, spec)
+ }
+ this.values.set(name, spec.defaultValue)
+ }
+
+ /** Resolve an alias to its canonical definition. */
+ spec(name: string): CvarSpec | null {
+ return this.byAlias.get(name.trim().toLowerCase()) ?? null
+ }
+
+ has(name: string): boolean {
+ return this.spec(name) !== null
+ }
+
+ canonical(name: string): string | null {
+ return this.spec(name)?.name ?? null
+ }
+
+ get(name: string): CvarValue | null {
+ const spec = this.spec(name)
+ return spec ? (this.values.get(spec.name) ?? spec.defaultValue) : null
+ }
+
+ /** All canonical definitions in name order. */
+ all(): CvarSpec[] {
+ return [...this.byName.values()].sort((a, b) => a.name.localeCompare(b.name))
+ }
+
+ /** Canonical names and aliases that start with `prefix`. */
+ matching(prefix: string): string[] {
+ const p = prefix.trim().toLowerCase()
+ return [...this.byAlias.keys()].filter((name) => name.startsWith(p)).sort()
+ }
+
+ /** Definitions whose name, alias or help contains `text`. */
+ find(text: string): CvarSpec[] {
+ const needle = text.trim().toLowerCase()
+ const out = new Set<CvarSpec>()
+ for (const spec of this.byName.values()) {
+ if (
+ spec.name.includes(needle) ||
+ spec.help.toLowerCase().includes(needle) ||
+ (spec.aliases ?? []).some((a) => a.toLowerCase().includes(needle))
+ )
+ out.add(spec)
+ }
+ return [...out].sort((a, b) => a.name.localeCompare(b.name))
+ }
+
+ /** Validate and store a player-entered value. */
+ set(name: string, input: CvarValue): CvarSetResult {
+ const spec = this.spec(name)
+ if (!spec) return { ok: false, error: `unknown cvar "${name}"` }
+ if (spec.flags.includes('readonly')) return { ok: false, error: `${spec.name} is read-only` }
+ const result = this.parse(spec, input)
+ if (!result.ok) return result
+ this.values.set(spec.name, result.value!)
+ return { ok: true, value: result.value! }
+ }
+
+ /** Validate a value without storing it (the client does this before asking the server). */
+ check(name: string, input: CvarValue): CvarSetResult {
+ const spec = this.spec(name)
+ if (!spec) return { ok: false, error: `unknown cvar "${name}"` }
+ if (spec.flags.includes('readonly')) return { ok: false, error: `${spec.name} is read-only` }
+ return this.parse(spec, input)
+ }
+
+ /** Apply a value received from the server, bypassing the local read-only/cheat guard. */
+ forceSet(name: string, input: CvarValue): CvarSetResult {
+ const spec = this.spec(name)
+ if (!spec) return { ok: false, error: `unknown cvar "${name}"` }
+ const result = this.parse(spec, input)
+ if (!result.ok) return result
+ this.values.set(spec.name, result.value!)
+ return { ok: true, value: result.value! }
+ }
+
+ /** Apply an authoritative map; unknown names are ignored (newer-server tolerance). */
+ apply(values: Readonly<Record<string, CvarValue>>): string[] {
+ const changed: string[] = []
+ for (const [name, value] of Object.entries(values)) {
+ const spec = this.spec(name)
+ if (!spec) continue
+ // A server payload can never change a client-only setting.
+ if (!spec.flags.includes('server') && !spec.flags.includes('replicated')) continue
+ const result = this.forceSet(spec.name, value)
+ if (result.ok) changed.push(spec.name)
+ }
+ return changed
+ }
+
+ /** Canonical `{name: value}` snapshot, optionally filtered. */
+ snapshot(filter?: (spec: CvarSpec) => boolean): Record<string, CvarValue> {
+ const out: Record<string, CvarValue> = {}
+ for (const spec of this.byName.values()) {
+ if (filter && !filter(spec)) continue
+ out[spec.name] = this.values.get(spec.name) ?? spec.defaultValue
+ }
+ return out
+ }
+
+ /** `800`, `0.1`, `1`/`0` for booleans — what the console prints. */
+ formatValue(name: string, value: CvarValue | null = this.get(name)): string {
+ const spec = this.spec(name)
+ if (value === null) return ''
+ if (typeof value === 'boolean') return value ? '1' : '0'
+ if (typeof value === 'number') {
+ if (spec?.type === 'integer') return String(Math.round(value))
+ return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(4)))
+ }
+ return value
+ }
+
+ /** `sv_gravity = 800` with the unit only when asked (help shows it separately). */
+ formatLine(name: string): string {
+ const spec = this.spec(name)
+ if (!spec) return name
+ return `${spec.name} = ${this.formatValue(name)}`
+ }
+
+ help(spec: CvarSpec): string[] {
+ const lines = [
+ `${spec.name} = ${this.formatValue(spec.name)}${spec.unit ? ` ${spec.unit}` : ''} [${spec.type}]`,
+ spec.help,
+ `flags: ${spec.flags.join(' ') || 'none'} · default: ${this.formatValue(spec.name, spec.defaultValue)}`,
+ ]
+ if (spec.aliases?.length) lines.push(`aliases: ${spec.aliases.join(', ')}`)
+ return lines
+ }
+
+ private parse(spec: CvarSpec, input: CvarValue): CvarSetResult {
+ if (spec.type === 'string') {
+ if (typeof input === 'number' || typeof input === 'boolean')
+ return { ok: true, value: String(input) }
+ return { ok: true, value: input }
+ }
+ if (spec.type === 'boolean') {
+ const value = boolFor(input)
+ return value === null
+ ? { ok: false, error: `${spec.name} expects 0 or 1` }
+ : { ok: true, value }
+ }
+ const value = numberFor(input, spec.type)
+ if (value === null)
+ return {
+ ok: false,
+ error: `${spec.name} expects ${spec.type === 'integer' ? 'a whole number' : 'a number'}`,
+ }
+ if (spec.min !== undefined && value < spec.min)
+ return { ok: false, error: `${spec.name} must be at least ${spec.min}` }
+ if (spec.max !== undefined && value > spec.max)
+ return { ok: false, error: `${spec.name} must be at most ${spec.max}` }
+ return { ok: true, value }
+ }
+}
+
+/** Source's gameplay scale: one Hammer unit is one inch. */
+export const METRES_PER_UNIT = 0.0254
+
+/**
+ * The movement cvars of §4.2, in Source units. `target`/`scale` let both the
+ * client's prediction and the server's simulation apply the exact same map.
+ */
+export const SOURCE_MOVEMENT_CVARS: readonly CvarSpec[] = [
+ {
+ name: 'sv_gravity',
+ aliases: ['gravity'],
+ type: 'float',
+ defaultValue: 800,
+ min: 0,
+ max: 5000,
+ unit: 'u/s²',
+ target: 'gravity',
+ scale: METRES_PER_UNIT,
+ flags: ['server', 'replicated'],
+ help: "World gravity in Hammer units per second squared. 800 is Source's Earth-like default.",
+ },
+ {
+ name: 'sv_accelerate',
+ aliases: ['accelerate', 'groundaccel'],
+ type: 'float',
+ defaultValue: 10,
+ min: 0,
+ max: 100,
+ target: 'groundAccel',
+ scale: 1,
+ flags: ['server', 'replicated'],
+ help: 'Ground acceleration factor while walking.',
+ },
+ {
+ name: 'sv_airaccelerate',
+ aliases: ['airaccelerate', 'airaccel'],
+ type: 'float',
+ defaultValue: 150,
+ min: 0,
+ max: 1000,
+ target: 'airAccel',
+ scale: 1,
+ flags: ['server', 'replicated'],
+ help: 'Air acceleration factor. Surf servers use 150; CS:S default is 10.',
+ },
+ {
+ name: 'sv_friction',
+ aliases: ['friction'],
+ type: 'float',
+ defaultValue: 4,
+ min: 0,
+ max: 100,
+ target: 'friction',
+ scale: 1,
+ flags: ['server', 'replicated'],
+ help: 'Ground friction. CS:S uses 4.',
+ },
+ {
+ name: 'sv_stopspeed',
+ aliases: ['stopspeed'],
+ type: 'float',
+ defaultValue: 2.032 / METRES_PER_UNIT,
+ min: 0,
+ max: 1000,
+ unit: 'u/s',
+ target: 'stopSpeed',
+ scale: METRES_PER_UNIT,
+ flags: ['server', 'replicated'],
+ help: 'Speed below which friction stops the player crisply (Source: 80 u/s).',
+ },
+ {
+ name: 'sv_maxspeed',
+ aliases: ['maxspeed', 'maxgroundspeed'],
+ type: 'float',
+ defaultValue: 250,
+ min: 0,
+ max: 3500,
+ unit: 'u/s',
+ target: 'maxGroundSpeed',
+ scale: METRES_PER_UNIT,
+ flags: ['server', 'replicated'],
+ help: 'Maximum ground speed. CS:S run speed is 250 u/s.',
+ },
+ {
+ name: 'sv_maxvelocity',
+ aliases: ['maxvelocity', 'maxhorizontalspeed'],
+ type: 'float',
+ defaultValue: 3500,
+ min: 0,
+ max: 100000,
+ unit: 'u/s',
+ target: 'maxHorizontalSpeed',
+ scale: METRES_PER_UNIT,
+ flags: ['server', 'replicated'],
+ help: 'Horizontal speed ceiling. Source caps velocity at 3500 u/s.',
+ },
+ {
+ name: 'sv_stepsize',
+ aliases: ['stepsize', 'stepheight'],
+ type: 'float',
+ defaultValue: 18,
+ min: 0,
+ max: 72,
+ unit: 'u',
+ target: 'stepHeight',
+ scale: METRES_PER_UNIT,
+ flags: ['server', 'replicated'],
+ help: 'Maximum obstacle height stepped over. Source uses 18 units.',
+ },
+ {
+ name: 'sv_air_max_wishspeed',
+ aliases: ['air_max_wishspeed', 'airspeedcap'],
+ type: 'float',
+ defaultValue: 30,
+ min: 0,
+ max: 1000,
+ unit: 'u/s',
+ target: 'airSpeedCap',
+ scale: METRES_PER_UNIT,
+ flags: ['server', 'replicated'],
+ help: 'Per-tick air wish-speed cap in Hammer units. Surf uses 30 u/s (0.762 m/s per tick at 100 Hz).',
+ },
+ {
+ name: 'sv_jump_impulse',
+ aliases: ['jump_impulse', 'jumpspeed'],
+ type: 'float',
+ defaultValue: 301.993,
+ min: 0,
+ max: 1000,
+ unit: 'u/s',
+ target: 'jumpSpeed',
+ scale: METRES_PER_UNIT,
+ flags: ['server', 'replicated'],
+ help: "Vertical jump impulse. Source's 301.993 u/s clears a 52-unit block.",
+ },
+ {
+ name: 'sv_autobunnyhopping',
+ aliases: ['autobunnyhopping', 'autobhop'],
+ type: 'boolean',
+ defaultValue: false,
+ target: 'autoBhop',
+ scale: 1,
+ flags: ['server', 'replicated'],
+ help: 'Hold jump to bunnyhop automatically. Source servers opt in; surf defaults off.',
+ },
+ {
+ name: 'sv_standable_normal',
+ aliases: ['standable_normal', 'groundnormaly'],
+ type: 'float',
+ defaultValue: 0.7,
+ min: 0,
+ max: 1,
+ target: 'groundNormalY',
+ scale: 1,
+ flags: ['server', 'replicated'],
+ help: 'Lowest surface normal Y treated as walkable ground. 0.7 ≈ 45.6°.',
+ },
+]
+
+/** Client-only settings; the server never sees these. */
+export const CLIENT_CVARS: readonly CvarSpec[] = [
+ {
+ name: 'cl_interp',
+ type: 'float',
+ defaultValue: 0.1,
+ min: 0,
+ max: 1,
+ unit: 's',
+ flags: ['client', 'archive'],
+ help: 'Interpolation delay used for remote entities. Source default is 0.1 s.',
+ },
+ {
+ name: 'fps_max',
+ type: 'integer',
+ defaultValue: 0,
+ min: 0,
+ max: 1000,
+ flags: ['client', 'archive'],
+ help: 'Frame-rate cap; 0 means the browser decides.',
+ },
+ {
+ name: 'volume',
+ type: 'float',
+ defaultValue: 1,
+ min: 0,
+ max: 1,
+ flags: ['client', 'archive'],
+ help: 'Game audio volume, 0 to 1.',
+ },
+ {
+ name: 'crosshair',
+ type: 'integer',
+ defaultValue: 1,
+ min: 0,
+ max: 1,
+ flags: ['client', 'archive'],
+ help: 'Draw the crosshair: 1 on, 0 off.',
+ },
+]
@@ -8,3 +8,5 @@ export * from './result.js'
@@ -8,3 +8,5 @@ export * from './result.js'
export * from './random.js'
export * from './random.js'
export * from './log.js'
export * from './log.js'
export * from './tick.js'
export * from './tick.js'
+export * from './commands.js'
+export * from './console/registry.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/ui/console.test.ts'
+// covers: 'apps/client/src/ui'
+// 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: 'apps/server/src/game/systems/settings.test.ts'
+// covers: 'apps/server/src/game/systems'
+// 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/gameplay/src/movement/cvars.test.ts'
+// covers: 'packages/gameplay/src/movement'
+// 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/shared/src/commands.test.ts'
+// covers: 'packages/shared/src'
+// 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/shared/src/console/registry.test.ts'
+// covers: 'packages/shared/src/console'
+// Generated by node test/run.js --index; do not edit.