openvibers/openvibe.games Commits

The Source developer console: ~, cvars, commands, completions (#197)

OP OpenVibers committed 16 days ago · 6b9a038ad1 · 37 files, +3238 −66

* WIP: Source developer console (timed out; to finish)

* Console drops from the top as in Source; completions as a list; test index

---------

Co-authored-by: CommandCodeBot <[email protected]>

Changes

ddiff --git a/apps/client/e2e/consoleCheck.ts b/apps/client/e2e/consoleCheck.ts
nnew file mode 100644
iindex 0000000..db9f0b6
---- /dev/null
++++ b/apps/client/e2e/consoleCheck.ts
@@@ -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
++})
ddiff --git a/apps/client/src/casual/app.ts b/apps/client/src/casual/app.ts
iindex 08e2361..02c7988 100644
---- a/apps/client/src/casual/app.ts
++++ b/apps/client/src/casual/app.ts
@@@ -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: GameConsole
constructor(
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 {
ddiff --git a/apps/client/src/game/localPlayer.ts b/apps/client/src/game/localPlayer.ts
iindex d00d8f4..0608c15 100644
---- a/apps/client/src/game/localPlayer.ts
++++ b/apps/client/src/game/localPlayer.ts
@@@ -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()
ddiff --git a/apps/client/src/input/inputSource.test.ts b/apps/client/src/input/inputSource.test.ts
iindex 2962a8a..f724bf9 100644
---- a/apps/client/src/input/inputSource.test.ts
++++ b/apps/client/src/input/inputSource.test.ts
@@@ -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')
ddiff --git a/apps/client/src/input/inputSource.ts b/apps/client/src/input/inputSource.ts
iindex c144d06..9d56c07 100644
---- a/apps/client/src/input/inputSource.ts
++++ b/apps/client/src/input/inputSource.ts
@@@ -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 }
ddiff --git a/apps/client/src/input/inputTracker.test.ts b/apps/client/src/input/inputTracker.test.ts
iindex 5edc0ca..cbc0131 100644
---- a/apps/client/src/input/inputTracker.test.ts
++++ b/apps/client/src/input/inputTracker.test.ts
@@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { 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([])
++ })
})
ddiff --git a/apps/client/src/input/inputTracker.ts b/apps/client/src/input/inputTracker.ts
iindex 5e6d530..50beb36 100644
---- a/apps/client/src/input/inputTracker.ts
++++ b/apps/client/src/input/inputTracker.ts
@@@ -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 = 0
pitch = 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)) return
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) {
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' }
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 751573c..e0bc1f4 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -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)
++ : undefined
const 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') > 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[] = []
// 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.camera
releaseCamera()
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 = runBoundCommand
interact.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 = true
let fallSpeed = 0
-- hud.onUiCaptureChange = (captured) => {
++ const applyUiCapture = (captured: boolean): void => {
input.uiCapture = captured
touchControls.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 = false
hud.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(
ddiff --git a/apps/client/src/state/clientState.ts b/apps/client/src/state/clientState.ts
iindex 8a5ad69..5534f40 100644
---- a/apps/client/src/state/clientState.ts
++++ b/apps/client/src/state/clientState.ts
@@@ -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: WireEntity
entityRemoved: string
entityUpdated: 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 = null
instanceCount = 1
@@@ -120,7 +125,19 @@ export class ClientState {
this.tickRate = msg.tickRate
this.snapshotRate = msg.snapshotRate
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('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
case 'transfer_result':
break
ddiff --git a/apps/client/src/ui/console.css b/apps/client/src/ui/console.css
nnew file mode 100644
iindex 0000000..dee6286
---- /dev/null
++++ b/apps/client/src/ui/console.css
@@@ -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;
++ }
++}
ddiff --git a/apps/client/src/ui/console.test.ts b/apps/client/src/ui/console.test.ts
nnew file mode 100644
iindex 0000000..e00c60a
---- /dev/null
++++ b/apps/client/src/ui/console.test.ts
@@@ -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')
++ })
++})
ddiff --git a/apps/client/src/ui/console.ts b/apps/client/src/ui/console.ts
nnew file mode 100644
iindex 0000000..796d719
---- /dev/null
++++ b/apps/client/src/ui/console.ts
@@@ -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}`,
++ ),
++ ]
++ }
++}
ddiff --git a/apps/client/src/ui/hud.ts b/apps/client/src/ui/hud.ts
iindex 0a19521..056b7b4 100644
---- a/apps/client/src/ui/hud.ts
++++ b/apps/client/src/ui/hud.ts
@@@ -46,6 +46,11 @@ export class Hud {
/** Source-style chat (Y/U); the HUD owns its mount and capture handoff. */
readonly chat: Chat
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 dragFrom: number | null = null
onUiCaptureChange: ((captured: boolean) => void) | null = null
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 2177a74..f915be9 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -24,6 +24,7 @@ import {
ProductionSystem,
ReplicationSystem,
SessionsSystem,
++ SettingsSystem,
SocialSystem,
SurfSystem,
VehicleSystem,
@@@ -83,6 +84,7 @@ export class GameServer {
)
const ctx = this.ctx
const 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.systems
this.ordered = [
sessions,
++ settings,
chat,
movement,
surf,
ddiff --git a/apps/server/src/game/surfRules.test.ts b/apps/server/src/game/surfRules.test.ts
iindex e4f41c3..36db1c7 100644
---- a/apps/server/src/game/surfRules.test.ts
++++ b/apps/server/src/game/surfRules.test.ts
@@@ -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') },
},
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex 982771d..e2da564 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -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: SettingsSystem
chat: ChatSystem
movement: MovementSystem
surf: SurfSystem
ddiff --git a/apps/server/src/game/systems/index.ts b/apps/server/src/game/systems/index.ts
iindex b7948a1..4ad6003 100644
---- a/apps/server/src/game/systems/index.ts
++++ b/apps/server/src/game/systems/index.ts
@@@ -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'
ddiff --git a/apps/server/src/game/systems/movement.ts b/apps/server/src/game/systems/movement.ts
iindex 3f861a5..3fc4f69 100644
---- a/apps/server/src/game/systems/movement.ts
++++ b/apps/server/src/game/systems/movement.ts
@@@ -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)
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 7523028..4a92a91 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -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}`,
ddiff --git a/apps/server/src/game/systems/settings.test.ts b/apps/server/src/game/systems/settings.test.ts
nnew file mode 100644
iindex 0000000..8f3509a
---- /dev/null
++++ b/apps/server/src/game/systems/settings.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/game/systems/settings.ts b/apps/server/src/game/systems/settings.ts
nnew file mode 100644
iindex 0000000..8848261
---- /dev/null
++++ b/apps/server/src/game/systems/settings.ts
@@@ -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,
++ })
++ }
++}
ddiff --git a/package.json b/package.json
iindex 9f3b91d..8da30d9 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/packages/gameplay/src/movement/cvars.test.ts b/packages/gameplay/src/movement/cvars.test.ts
nnew file mode 100644
iindex 0000000..1fd64d5
---- /dev/null
++++ b/packages/gameplay/src/movement/cvars.test.ts
@@@ -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)
++ })
++})
ddiff --git a/packages/gameplay/src/movement/styles.ts b/packages/gameplay/src/movement/styles.ts
iindex 2452ee6..0579691 100644
---- a/packages/gameplay/src/movement/styles.ts
++++ b/packages/gameplay/src/movement/styles.ts
@@@ -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
++}
ddiff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts
iindex 0a4d585..f6a4bec 100644
---- a/packages/protocol/src/codec.test.ts
++++ b/packages/protocol/src/codec.test.ts
@@@ -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(
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex fd06345..b368b6a 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -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'
ddiff --git a/packages/protocol/src/messages/server.ts b/packages/protocol/src/messages/server.ts
iindex fb9b875..b380c92 100644
---- a/packages/protocol/src/messages/server.ts
++++ b/packages/protocol/src/messages/server.ts
@@@ -29,6 +29,23 @@ export interface ServerWelcome {
/** Present when multi-instance mode is enabled. */
instanceId?: string
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 {
@@@ -298,6 +315,7 @@ export type ServerMessage =
scores: { name: string; score: number; rank: number }[]
}
| ServerWelcome
++ | ServerCvarState
| ServerTransferResult
| ServerReject
| ServerSpawn
ddiff --git a/packages/shared/src/commands.test.ts b/packages/shared/src/commands.test.ts
nnew file mode 100644
iindex 0000000..07d248a
---- /dev/null
++++ b/packages/shared/src/commands.test.ts
@@@ -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'])
++ })
++})
ddiff --git a/packages/shared/src/commands.ts b/packages/shared/src/commands.ts
nnew file mode 100644
iindex 0000000..d56ba58
---- /dev/null
++++ b/packages/shared/src/commands.ts
@@@ -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
++ }
++}
ddiff --git a/packages/shared/src/console/registry.test.ts b/packages/shared/src/console/registry.test.ts
nnew file mode 100644
iindex 0000000..f60c947
---- /dev/null
++++ b/packages/shared/src/console/registry.test.ts
@@@ -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')
++ })
++})
ddiff --git a/packages/shared/src/console/registry.ts b/packages/shared/src/console/registry.ts
nnew file mode 100644
iindex 0000000..08d1560
---- /dev/null
++++ b/packages/shared/src/console/registry.ts
@@@ -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.',
++ },
++]
ddiff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
iindex be61133..4d4774a 100644
---- a/packages/shared/src/index.ts
++++ b/packages/shared/src/index.ts
@@@ -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'
ddiff --git a/test/apps__client__src__ui__console.test.js b/test/apps__client__src__ui__console.test.js
nnew file mode 100644
iindex 0000000..a3ef3de
---- /dev/null
++++ b/test/apps__client__src__ui__console.test.js
@@@ -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.
ddiff --git a/test/apps__server__src__game__systems__settings.test.js b/test/apps__server__src__game__systems__settings.test.js
nnew file mode 100644
iindex 0000000..1544373
---- /dev/null
++++ b/test/apps__server__src__game__systems__settings.test.js
@@@ -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.
ddiff --git a/test/packages__gameplay__src__movement__cvars.test.js b/test/packages__gameplay__src__movement__cvars.test.js
nnew file mode 100644
iindex 0000000..cd0104d
---- /dev/null
++++ b/test/packages__gameplay__src__movement__cvars.test.js
@@@ -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.
ddiff --git a/test/packages__shared__src__commands.test.js b/test/packages__shared__src__commands.test.js
nnew file mode 100644
iindex 0000000..edb19f8
---- /dev/null
++++ b/test/packages__shared__src__commands.test.js
@@@ -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.
ddiff --git a/test/packages__shared__src__console__registry.test.js b/test/packages__shared__src__console__registry.test.js
nnew file mode 100644
iindex 0000000..9545701
---- /dev/null
++++ b/test/packages__shared__src__console__registry.test.js
@@@ -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.