openvibers/openvibe.games Commits
Source-style chat and surf commands (#196)
Changes
@@@ -315,6 +315,22 @@ async function start(): Promise<void> {guest: identity.token,...(sso ? { bearer: sso } : {}),})++ // 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.++ document.addEventListener(++ 'keydown',++ (e) => {++ if (e.repeat) return++ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return++ if (input.uiCapture) return++ if (e.code !== 'KeyY' && e.code !== 'KeyU') return++ e.preventDefault()++ e.stopImmediatePropagation()++ hud.chat.open(e.code === 'KeyY' ? 'say' : 'say_team')++ },++ true,++ )// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).let updateWaypoint: (() => void) | null = nulllet firstStepsRef: FirstSteps | null = null@@@ -46,6 +46,8 @@ export interface ClientStateEvents {weather: stringcontainer: { id: string; size: number; slots: { i: number; def: string; count: number }[] }announce: string++ /** One chat line (conversation or a command reply). */++ chat: Extract<ServerMessage, { t: 'say' }>market: unknownjobs: unknownreputation: { id: string; name: string; value: number; stance: string }[]@@@ -226,6 +228,9 @@ export class ClientState {case 'announce':this.events.emit('announce', msg.text)break++ case 'say':++ this.events.emit('chat', msg)++ breakcase 'market':this.events.emit('market', msg)breaknnew file mode 100644@@@ -0,0 +1,113 @@++/* Source places chat lower-left. In Scraplandia the vitals already sit there,++ so chat clears them; on a surf map the vitals are hidden and it drops to the++ corner. As in Source, a closed chat lets each line fade a few seconds after it++ arrives; opening it (Y or U) brings the history back on a dark panel. */++.chat {++ position: absolute;++ z-index: 13;++ left: 16px;++ bottom: 132px;++ width: min(30rem, calc(100vw - 2rem));++ display: flex;++ flex-direction: column;++ gap: 4px;++ font-size: calc(13px * var(--ov-text-scale, 1));++ pointer-events: none;++}++++.chat-lines {++ max-height: 12rem;++ overflow-y: auto;++ list-style: none;++ padding: 2px 0;++ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);++ scrollbar-width: thin;++}++++.chat-line {++ padding: 1px 4px;++ overflow-wrap: anywhere;++ background: rgba(8, 11, 15, 0.42);++ border-radius: 3px;++}++++.chat-line + .chat-line {++ margin-top: 1px;++}++++.chat-from {++ margin-right: 0.35em;++ font-weight: 700;++}++++.chat-system {++ color: var(--g-accent-text, #f0b040);++}++++.chat-form {++ display: flex;++ align-items: center;++ gap: 6px;++ padding: 5px 8px;++ background: rgba(8, 11, 15, 0.86);++ border: 1px solid var(--border, #2a3340);++ border-radius: 5px;++ pointer-events: auto;++}++++.chat-form[hidden] {++ display: none;++}++++.chat-channel {++ font-size: 0.75em;++ font-weight: 800;++ letter-spacing: 0.08em;++ text-transform: uppercase;++ color: var(--g-accent-text, #f0b040);++}++++.chat-input {++ flex: 1;++ min-width: 0;++ padding: 2px 0;++ border: 0;++ outline: 0;++ background: transparent;++ color: var(--text-primary, #e6e6e6);++ font: inherit;++}++++.ovg-surf .chat {++ bottom: 18px;++}++++.chat:not(.is-open) .chat-line {++ animation: chat-line-fade 12s linear forwards;++}++++@keyframes chat-line-fade {++ 0%, 75% { opacity: 1; }++ 100% { opacity: 0; visibility: hidden; }++}++++.chat.is-open .chat-lines {++ padding: 4px;++ border-radius: 5px;++ background: rgba(8, 11, 15, 0.55);++ pointer-events: auto;++}++++.chat.is-open .chat-line {++ background: transparent;++}++++.chat-team .chat-from {++ color: var(--g-team-text, #7cc4ff);++}++++@media (prefers-reduced-motion: reduce) {++ .chat:not(.is-open) .chat-line {++ animation-timing-function: steps(1, end);++ }++}nnew file mode 100644@@@ -0,0 +1,91 @@++// @vitest-environment jsdom++import type { ClientMessage } from '@openvibe/protocol'++import { beforeEach, describe, expect, it } from 'vitest'++import type { Connection } from '../net/connection.js'++import { Chat } from './chat.js'++import { ClientState } from '../state/clientState.js'++++describe('chat UI', () => {++ let root: HTMLDivElement++ let state: ClientState++ let sent: ClientMessage[]++ let chat: Chat++++ beforeEach(() => {++ document.body.replaceChildren()++ root = document.createElement('div')++ document.body.append(root)++ state = new ClientState()++ sent = []++ const connection = {++ send: (msg: ClientMessage) => sent.push(msg),++ } as unknown as Connection++ chat = new Chat(root, connection, state)++ })++++ it('does not open before welcome, and sends a say once welcomed', () => {++ const form = root.querySelector('.chat-form') as HTMLFormElement++ const input = root.querySelector('.chat-input') as HTMLInputElement++ let captured: boolean | null = null++ chat.onCaptureChange = (on) => {++ captured = on++ }++++ chat.open('say')++ expect(form.hidden).toBe(true)++++ state.myEntityId = 'entity-1'++ chat.open('say')++ expect(form.hidden).toBe(false)++ expect(captured).toBe(true)++++ input.value = '!top'++ form.dispatchEvent(new Event('submit', { cancelable: true }))++ expect(sent).toEqual([{ t: 'say', text: '!top' }])++ expect(form.hidden).toBe(true)++ expect(captured).toBe(false)++ })++++ it('sends say_team from the U channel', () => {++ state.myEntityId = 'entity-1'++ chat.open('say_team')++ const form = root.querySelector('.chat-form') as HTMLFormElement++ const input = root.querySelector('.chat-input') as HTMLInputElement++ input.value = 'on my way'++ form.dispatchEvent(new Event('submit', { cancelable: true }))++ expect(sent).toEqual([{ t: 'say_team', text: 'on my way' }])++ })++++ it('renders untrusted names and text as text, never as markup', () => {++ chat.addLine({++ t: 'say',++ channel: 'say',++ from: '<b>Player</b>',++ fromId: 'e1',++ text: '<img src=x onerror="alert(1)">',++ })++ chat.addLine({++ t: 'say',++ channel: 'system',++ text: '<script>alert(2)</script>',++ })++++ expect(root.querySelector('img')).toBeNull()++ expect(root.querySelector('b')).toBeNull()++ expect(root.querySelector('script')).toBeNull()++ expect(root.querySelector('.chat-from')?.textContent).toBe('<b>Player</b>:')++ expect(root.textContent).toContain('<img src=x onerror="alert(1)">')++ expect(root.textContent).toContain('<script>alert(2)</script>')++ // innerHTML escapes the angle brackets; it never becomes a live element.++ expect(root.querySelector('.chat-lines')?.innerHTML).not.toContain('<img')++ })++++ it('keeps the scrollback bounded', () => {++ for (let i = 0; i < 105; i++) {++ chat.addLine({ t: 'say', channel: 'system', text: `line ${i}` })++ }++ expect(root.querySelectorAll('.chat-line')).toHaveLength(100)++ expect(root.querySelector('.chat-line')?.textContent).toBe('line 5')++ expect(root.textContent).toContain('line 104')++ })++})nnew file mode 100644@@@ -0,0 +1,138 @@++import { CHAT_MAX_LENGTH, type ServerMessage } from '@openvibe/protocol'++import type { Connection } from '../net/connection.js'++import type { ClientState } from '../state/clientState.js'++import './chat.css'++++type ChatLine = Extract<ServerMessage, { t: 'say' }>++type ChatMode = 'say' | 'say_team'++++const MAX_LINES = 100++++/**++ * Source-style chat: lower-left scrollback plus a one-line input opened with++ * Y (say) or U (team). The server is authoritative for delivery and rate++ * limits; this class only renders plain text and sends the typed line.++ *++ * Every piece of remote text goes through `textContent`. Never innerHTML:++ * names and chat messages are untrusted.++ */++export class Chat {++ readonly root: HTMLElement++ private readonly lines: HTMLOListElement++ private readonly form: HTMLFormElement++ private readonly input: HTMLInputElement++ private readonly channelLabel: HTMLSpanElement++ private mode: ChatMode = 'say'++ private isOpen = false++++ /** The HUD's existing capture switch: typing must not move the player. */++ onCaptureChange: ((captured: boolean) => void) | null = null++++ constructor(++ parent: HTMLElement,++ private readonly connection: Connection,++ private readonly state: ClientState,++ ) {++ this.root = document.createElement('section')++ this.root.className = 'chat'++ this.root.setAttribute('aria-label', 'Chat')++++ this.lines = document.createElement('ol')++ this.lines.className = 'chat-lines'++ this.lines.setAttribute('role', 'log')++ this.lines.setAttribute('aria-live', 'polite')++ this.lines.setAttribute('aria-relevant', 'additions')++++ this.form = document.createElement('form')++ this.form.className = 'chat-form'++ this.form.hidden = true++ this.channelLabel = document.createElement('span')++ this.channelLabel.className = 'chat-channel'++ this.channelLabel.textContent = 'Say'++ this.input = document.createElement('input')++ this.input.className = 'chat-input'++ this.input.type = 'text'++ this.input.maxLength = CHAT_MAX_LENGTH++ this.input.autocomplete = 'off'++ this.input.spellcheck = false++ this.input.setAttribute('aria-label', 'Chat message')++ this.input.placeholder = 'Message…'++ this.form.append(this.channelLabel, this.input)++++ this.root.append(this.lines, this.form)++ parent.append(this.root)++++ this.form.addEventListener('submit', (event) => {++ event.preventDefault()++ this.submit()++ })++ this.input.addEventListener('keydown', (event) => {++ // The game's global key handlers ignore inputs already; stop here too so++ // Enter/Escape never reach a game action.++ event.stopPropagation()++ if (event.key === 'Escape') {++ event.preventDefault()++ this.close()++ }++ })++++ state.events.on('chat', (line) => this.addLine(line))++ }++++ /** Open the input for a channel. Ignored until the server has welcomed us. */++ open(mode: ChatMode): void {++ if (!this.state.myEntityId) return++ this.mode = mode++ this.channelLabel.textContent = mode === 'say' ? 'Say' : 'Team'++ if (this.isOpen) return++ this.isOpen = true++ this.form.hidden = false++ this.root.classList.add('is-open')++ this.onCaptureChange?.(true)++ this.input.focus()++ }++++ close(): void {++ if (!this.isOpen) return++ this.isOpen = false++ this.form.hidden = true++ this.root.classList.remove('is-open')++ this.input.blur()++ this.onCaptureChange?.(false)++ }++++ /** Append one server line (conversation or command reply). */++ addLine(line: ChatLine): void {++ const item = document.createElement('li')++ item.className = `chat-line chat-${line.channel}`++ if (line.from) {++ const from = document.createElement('span')++ from.className = 'chat-from'++ from.textContent = line.channel === 'say_team' ? `(TEAM) ${line.from}:` : `${line.from}:`++ item.append(from)++ }++ const text = document.createElement('span')++ text.className = 'chat-text'++ text.textContent = line.text++ item.append(text)++++ this.lines.append(item)++ while (this.lines.childElementCount > MAX_LINES) this.lines.firstElementChild?.remove()++ this.lines.scrollTop = this.lines.scrollHeight++ }++++ private submit(): void {++ const text = this.input.value.trim()++ this.input.value = ''++ if (!text) {++ this.close()++ return++ }++ // The state guard is belt-and-braces: the server drops anything before++ // `welcome` anyway.++ if (this.state.myEntityId) {++ this.connection.send(this.mode === 'say' ? { t: 'say', text } : { t: 'say_team', text })++ }++ this.close()++ }++}@@@ -3,6 +3,7 @@ import { HOTBAR_SLOTS } from '../constants.js'import type { Connection } from '../net/connection.js'import type { ClientState } from '../state/clientState.js'import type { IconFactory } from './iconFactory.js'++import { Chat } from './chat.js'import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'import {getQualityOverride,@@@ -42,6 +43,8 @@ export class Hud {private statusEl!: HTMLElementprivate toastArea!: HTMLElement++ /** Source-style chat (Y/U); the HUD owns its mount and capture handoff. */++ readonly chat: ChatmenuOpen = falseprivate activeTab: MenuTab = 'inventory'private dragFrom: number | null = null@@@ -63,6 +66,8 @@ export class Hud {) {this.root = rootthis.build()++ this.chat = new Chat(this.root, connection, state)++ this.chat.onCaptureChange = (captured) => this.onUiCaptureChange?.(captured)icons.onReady = () => {this.renderHotbar()this.renderMenu()@@@ -526,6 +531,8 @@ export class Hud {}toggleMenu(): void {++ // Chat and the menu both hold input capture; only one may be open.++ if (!this.menuOpen) this.chat.close()this.menuOpen = !this.menuOpenthis.menuEl.style.display = this.menuOpen ? 'flex' : 'none'this.renderTabs()nnew file mode 100644@@@ -0,0 +1,348 @@++import { mkdtempSync } from 'node:fs'++import { tmpdir } from 'node:os'++import { join } from 'node:path'++import { createContent, setMapOverride, surfPack } from '@openvibe/content'++import type { PersistenceStore } from '@openvibe/persistence'++import { openPgStore } from '@openvibe/persistence'++import { openTestDb } from '@openvibe/persistence/testing'++import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'++import {++ PROTOCOL_VERSION,++ defaultAppearance,++ type ClientMessage,++ type ServerMessage,++ type ServerSay,++} from '@openvibe/protocol'++import { createConsoleLogger } from '@openvibe/shared'++import type { Db } from 'openvibe-sdk/db'++import { afterAll, beforeAll, describe, expect, it } from 'vitest'++import { SURF_ROOM_TIMING, loadConfig } from '../config.js'++import { hashGuestKey } from '../net/guestIdentity.js'++import { ServerMetrics } from '../observability/metrics.js'++import type { GameConnection } from './gameServer.js'++import { GameServer } from './gameServer.js'++import { GameWorld } from './gameWorld.js'++import { loadSurfOverride } from './surfMaps.js'++import type { ServerContext } from './systems/context.js'++import { PgSurfRecords } from '../platform/surfRuns.js'++import type { SurfRunRecord } from './surfRecords.js'++import { CHAT_RATE_CAPACITY } from './chatCommands.js'++import type { PlayerSession } from './playerSession.js'++++const MAP = 'surf_lagoon'++const dir = mkdtempSync(join(tmpdir(), 'openvibe-chat-'))++const config = {++ ...loadConfig({ MAP_PATH: join(dir, 'map.json'), GAMES_PLACE_ID: MAP }),++ ...SURF_ROOM_TIMING,++ placeId: MAP,++ sandbox: true,++}++const log = createConsoleLogger({ app: 'test' }, 'error')++++let rapier: RapierModule++let db: Db++let physics: ReturnType<typeof createRapierWorld> | null = null++let store: PersistenceStore++let world: GameWorld++let game: GameServer++let records: PgSurfRecords++++interface TestClient {++ conn: GameConnection++ inbox: ServerMessage[]++ closed: { code: number; reason: string } | null++}++++interface WelcomedClient extends TestClient {++ hello(token: string): Promise<void>++ send(msg: ClientMessage): void++}++++function connect(name: string): WelcomedClient {++ const inbox: ServerMessage[] = []++ const state: { closed: { code: number; reason: string } | null } = { closed: null }++ const conn: GameConnection = {++ send: (text) => inbox.push(JSON.parse(text) as ServerMessage),++ close: (code, reason) => {++ state.closed = { code, reason }++ },++ }++ return {++ conn,++ inbox,++ get closed() {++ return state.closed++ },++ async hello(token: string): Promise<void> {++ conn.identity = { guestKeyHash: hashGuestKey(token) }++ game.onMessage(conn, {++ t: 'hello',++ v: PROTOCOL_VERSION,++ contentDigest: world.content.digest,++ slot: 0,++ name,++ appearance: defaultAppearance(),++ })++ for (let i = 0; i < 200 && !state.closed && !inbox.some((m) => m.t === 'welcome'); i++) {++ await new Promise((resolve) => setTimeout(resolve, 5))++ }++ delete conn.identity++ },++ send(msg: ClientMessage): void {++ game.onMessage(conn, msg)++ },++ }++}++++function ctxOf(): ServerContext {++ return (game as unknown as { ctx: ServerContext }).ctx++}++++function sessionOf(client: TestClient): PlayerSession {++ const session = ctxOf().sessions.getByConn(client.conn)++ if (!session) throw new Error('client has no session')++ return session++}++++function say(client: WelcomedClient, text: string): void {++ client.send({ t: 'say', text })++}++++function chatTexts(client: TestClient): string[] {++ return client.inbox.filter((m): m is ServerSay => m.t === 'say').map((m) => m.text)++}++++/** Async command replies land after the handler's records query; wait for one. */++async function waitForChat(++ client: TestClient,++ predicate: (text: string) => boolean,++ timeoutMs = 3_000,++): Promise<void> {++ const end = Date.now() + timeoutMs++ while (Date.now() < end) {++ if (chatTexts(client).some(predicate)) return++ await new Promise((resolve) => setTimeout(resolve, 10))++ }++ throw new Error(`chat reply not received; saw: ${chatTexts(client).join(' | ')}`)++}++++function msOf(text: string): number {++ const [minutes, rest] = text.split(':')++ const [seconds, millis] = rest!.split('.')++ return Number(minutes) * 60_000 + Number(seconds) * 1000 + Number(millis)++}++++async function seedRun(accountKey: string, playerName: string, timeMs: number, createdAt: number) {++ const run: SurfRunRecord = {++ id: crypto.randomUUID(),++ mapId: MAP,++ accountKey,++ playerName,++ timeMs,++ splits: [timeMs],++ track: [{ tick: 1, x: 0, y: 0, z: 0, yaw: 0 }],++ tickRate: SURF_ROOM_TIMING.tickRate,++ startedTick: 0,++ finishedTick: 100,++ startFrac: 0,++ finishFrac: 0.5,++ fails: 0,++ createdAt,++ }++ await records.record(run)++}++++beforeAll(async () => {++ rapier = await loadRapier()++ db = await openTestDb()++ const content = createContent([], surfPack(MAP))++ setMapOverride(loadSurfOverride(MAP), content.world.id)++ store = openPgStore(db, { placeId: MAP })++ await store.ensurePlace()++ physics = createRapierWorld(rapier)++ world = new GameWorld(content, physics, log)++ await world.seedOrRestore(store)++++ records = new PgSurfRecords(db)++ // Eleven ordinary runs (9.000 … 19.000 s) plus Alice's 12.345 s PB. The++ // top ten therefore runs 9.000 … 17.000 with Alice's PB in fifth place.++ for (let i = 0; i < 11; i++) {++ await seedRun(`guest:seed-${i}`, `Racer ${i}`, 9_000 + i * 1000, 1_000 + i)++ }++ await seedRun(`guest:${hashGuestKey('alice-token')}`, 'Alice', 12_345, 2_000)++++ game = new GameServer(config, world, store, new ServerMetrics(), log, { surf: records })++ await game.load()++}, 120_000)++++afterAll(async () => {++ game?.shutdown()++ await game?.drain()++ physics?.dispose()++})++++describe('chat with two live clients', () => {++ it('refuses chat before welcome, then echoes say and say_team to both', async () => {++ const early = connect('Early')++ game.onMessage(early.conn, { t: 'say', text: 'too soon' })++ expect(early.closed).toEqual({ code: 4001, reason: 'hello_first' })++ expect(early.inbox).toEqual([])++++ const ana = connect('Ana')++ const bea = connect('Bea')++ await ana.hello('ana-one-token')++ await bea.hello('bea-one-token')++ expect(ana.inbox.some((m) => m.t === 'welcome')).toBe(true)++ expect(bea.inbox.some((m) => m.t === 'welcome')).toBe(true)++++ say(ana, 'hello room')++ expect(chatTexts(ana)).toContain('hello room')++ expect(chatTexts(bea)).toContain('hello room')++ const line = bea.inbox.find((m): m is ServerSay => m.t === 'say' && m.text === 'hello room')++ expect(line).toMatchObject({ channel: 'say', from: 'Ana' })++++ // Untrusted text survives as text on the wire (the client renderer is++ // covered by chat.test.ts; this proves the server never turns it into++ // markup or loses it).++ const xss = '<img src=x onerror="alert(1)">'++ say(ana, xss)++ expect(bea.inbox).toContainEqual(++ expect.objectContaining({ t: 'say', channel: 'say', from: 'Ana', text: xss }),++ )++++ bea.send({ t: 'say_team', text: 'team line' })++ expect(ana.inbox).toContainEqual(++ expect.objectContaining({ t: 'say', channel: 'say_team', from: 'Bea', text: 'team line' }),++ )++++ say(ana, '!b')++ expect(chatTexts(bea)).toContain('No bonuses on this map.')++ })++++ it('resets the run with !r, keeps /r silent, and leaves the finish state cleared', async () => {++ const ana = connect('Ana')++ const bea = connect('Bea')++ await ana.hello('ana-two-token')++ await bea.hello('bea-two-token')++ const session = sessionOf(ana)++ const run = session.surf!++ run.status = 'running'++ run.startTick = 1++ run.lastTick = 101++ run.reached = 1++ run.splits = [{ stage: 1, timeMs: 1_000 }]++++ say(ana, '!r')++ const reset = ana.inbox.filter((m) => m.t === 'surf_state').at(-1)++ expect(reset).toMatchObject({++ t: 'surf_state',++ running: false,++ elapsedMs: 0,++ stage: 0,++ splits: [],++ practice: false,++ })++ expect(run.finishResult).toBeNull()++ expect(chatTexts(bea)).toContain('Ana restarted the run.')++++ // Arm a finished card-like state again: /r must reset it without a line.++ run.status = 'running'++ run.splits = [{ stage: 1, timeMs: 500 }]++ const before = bea.inbox.length++ say(ana, '/r')++ const silentReset = ana.inbox.filter((m) => m.t === 'surf_state').at(-1)++ expect(silentReset).toMatchObject({ running: false, elapsedMs: 0, splits: [] })++ expect(bea.inbox.slice(before).filter((m) => m.t === 'say')).toEqual([])++ })++++ it('reports !top ascending as ten lines and !wr/!pr from the records store', async () => {++ const alice = connect('Alice')++ await alice.hello('alice-token')++++ say(alice, '!top')++ await waitForChat(alice, (text) => text.startsWith('Top '))++ const topLines = chatTexts(alice).filter((text) => /^\d+\. \d+:\d\d\.\d\d\d {2}/.test(text))++ expect(topLines).toHaveLength(10)++ const times = topLines.map((text) => msOf(/^\d+\. (\d+:\d\d\.\d\d\d) {2}/.exec(text)![1]!))++ expect(times).toEqual([...times].sort((a, b) => a - b))++ expect(times[0]).toBe(9_000)++ expect(times.at(-1)).toBe(17_000)++ expect(chatTexts(alice)).toContain('Top 10 — surf_lagoon')++++ say(alice, '!wr')++ await waitForChat(alice, (text) => text.includes(' WR: '))++ expect(chatTexts(alice)).toContain('surf_lagoon WR: 0:09.000')++++ say(alice, '!pr')++ await waitForChat(alice, (text) => text.startsWith('Your PB on '))++ expect(chatTexts(alice)).toContain('Your PB on surf_lagoon: 0:12.345')++ })++++ it('refuses !stage outside practice, travels stages in practice, and never writes a run', async () => {++ const dana = connect('Dana')++ await dana.hello('dana-token')++ const session = sessionOf(dana)++ const startX = session.move.pos.x++ const startZ = session.move.pos.z++++ say(dana, '!stage 1')++ expect(chatTexts(dana)).toContain('Practice mode is off. Type !practice first.')++ expect(session.move.pos.x).toBe(startX)++ expect(session.move.pos.z).toBe(startZ)++ expect(session.surfPractice).toBe(false)++++ say(dana, '!practice')++ expect(session.surfPractice).toBe(true)++ expect(chatTexts(dana)).toContain('Dana entered practice — runs are not ranked.')++++ say(dana, '!stage 2')++ const checkpoint = session.surf!.map.checkpoints.find((z) => z.stage === 2)!++ expect(session.move.pos.x).toBeCloseTo(checkpoint.spawn!.x)++ expect(session.move.pos.y).toBeCloseTo(checkpoint.spawn!.y)++ expect(session.move.pos.z).toBeCloseTo(checkpoint.spawn!.z)++ expect(chatTexts(dana)).toContain('Dana moved to stage 2.')++++ // Force the worst case: a run that would otherwise finish on this tick.++ // Practice skips the run state machine entirely, so no surf_finish and++ // no row for Dana's account may appear.++ const finish = session.surf!.map.finish++ session.surf!.status = 'running'++ session.surf!.reached = session.surf!.map.checkpoints.length++ session.surf!.startTick = 0++ session.moveSegments.push({++ from: { x: finish.min[0], y: finish.min[1], z: finish.min[2] - 1 },++ to: {++ x: (finish.min[0] + finish.max[0]) / 2,++ y: (finish.min[1] + finish.max[1]) / 2,++ z: (finish.min[2] + finish.max[2]) / 2,++ },++ yaw: 0,++ })++ ctxOf().systems.surf.tick(ctxOf(), 0.01)++ expect(dana.inbox.some((m) => m.t === 'surf_finish')).toBe(false)++ const danaKey = `guest:${hashGuestKey('dana-token')}`++ const rows = await db.many<{ n: number | string }>(++ 'SELECT COUNT(*) AS n FROM surf_runs WHERE account_key = $1',++ [danaKey],++ )++ expect(Number(rows[0]!.n)).toBe(0)++++ say(dana, '!practice')++ expect(session.surfPractice).toBe(false)++ expect(session.move.pos.x).toBeCloseTo(startX)++ expect(session.move.pos.z).toBeCloseTo(startZ)++ })++++ it('holds the burst rate limit for a fresh client', async () => {++ const frank = connect('Frank')++ await frank.hello('frank-token')++ const bea = connect('Bea')++ await bea.hello('bea-five-token')++ const before = bea.inbox.length++ for (let i = 0; i < CHAT_RATE_CAPACITY + 3; i++) say(frank, `spam ${i}`)++ const received = bea.inbox++ .slice(before)++ .filter((m): m is ServerSay => m.t === 'say' && m.from === 'Frank')++ expect(received).toHaveLength(CHAT_RATE_CAPACITY)++ })++})nnew file mode 100644@@@ -0,0 +1,96 @@++import { describe, expect, it } from 'vitest'++import {++ CHAT_RATE_CAPACITY,++ ChatRateLimiter,++ canonicalChatCommand,++ cleanChatText,++ formatChatTime,++ parseChatCommand,++} from './chatCommands.js'++++describe('chat command parser', () => {++ it('treats ordinary text as chat', () => {++ expect(parseChatCommand('hello there')).toEqual({ command: null, args: [], silent: false })++ expect(parseChatCommand(' ')).toEqual({ command: null, args: [], silent: false })++ expect(parseChatCommand('what?')).toEqual({ command: null, args: [], silent: false })++ })++++ it('parses Source triggers, lower-cases the command and keeps its arguments', () => {++ expect(parseChatCommand('!r')).toEqual({ command: 'r', args: [], silent: false })++ expect(parseChatCommand('/r')).toEqual({ command: 'r', args: [], silent: true })++ expect(parseChatCommand(' !TOP ')).toEqual({ command: 'top', args: [], silent: false })++ expect(parseChatCommand('!stage 2')).toEqual({++ command: 'stage',++ args: ['2'],++ silent: false,++ })++ expect(parseChatCommand('/stage 3 extra')).toEqual({++ command: 'stage',++ args: ['3', 'extra'],++ silent: true,++ })++ })++++ it('caps the argument list and handles a bare trigger', () => {++ expect(parseChatCommand('!stage 1 2 3 4 5 6 7 8 9 10').args).toEqual([++ '1',++ '2',++ '3',++ '4',++ '5',++ '6',++ '7',++ '8',++ ])++ expect(parseChatCommand('!')).toEqual({ command: '', args: [], silent: false })++ expect(parseChatCommand('/')).toEqual({ command: '', args: [], silent: true })++ })++++ it('maps Source aliases to one canonical command', () => {++ expect(canonicalChatCommand('r')).toBe('r')++ expect(canonicalChatCommand('restart')).toBe('r')++ expect(canonicalChatCommand('stages')).toBe('stage')++ expect(canonicalChatCommand('bonus')).toBe('b')++ expect(canonicalChatCommand('p')).toBe('practice')++ expect(canonicalChatCommand('h')).toBe('help')++ expect(canonicalChatCommand('nope')).toBeNull()++ })++})++++describe('chat text and time formatting', () => {++ it('strips control characters, trims and clamps to the wire limit', () => {++ expect(cleanChatText(' hi\u0000there\u001b[31m ')).toBe('hi there [31m')++ expect(cleanChatText('x'.repeat(500))).toHaveLength(240)++ expect(cleanChatText(' \n\t ')).toBe('')++ })++++ it('prints the surf timer format', () => {++ expect(formatChatTime(0)).toBe('0:00.000')++ expect(formatChatTime(12_345)).toBe('0:12.345')++ expect(formatChatTime(61_005)).toBe('1:01.005')++ })++})++++describe('chat rate limit', () => {++ it('allows a five-line burst and refills at two per second', () => {++ let now = 1_000++ const limiter = new ChatRateLimiter(CHAT_RATE_CAPACITY, 2, () => now)++ for (let i = 0; i < CHAT_RATE_CAPACITY; i++) expect(limiter.allow('a')).toBe(true)++ expect(limiter.allow('a')).toBe(false)++ // 500 ms buys one token back, not five.++ now += 500++ expect(limiter.allow('a')).toBe(true)++ expect(limiter.allow('a')).toBe(false)++ // A different player has their own bucket.++ expect(limiter.allow('b')).toBe(true)++ })++++ it('forgets a player on leave', () => {++ const now = 0++ const limiter = new ChatRateLimiter(1, 1, () => now)++ expect(limiter.allow('a')).toBe(true)++ expect(limiter.allow('a')).toBe(false)++ limiter.forget('a')++ expect(limiter.allow('a')).toBe(true)++ })++})nnew file mode 100644@@@ -0,0 +1,309 @@++import {++ CHAT_MAX_LENGTH,++ type ClientSay,++ type ClientSayTeam,++ type ServerSay,++} from '@openvibe/protocol'++import type { PlayerSession } from './playerSession.js'++import type { ServerContext } from './systems/context.js'++import type { HandlerMap, System } from './systems/system.js'++++/**++ * Chat and its Source-style commands (`!r`, `!top`, `!wr`, `!pr`, `!stage`).++ *++ * Two rules shape this file:++ * - A command is only ever text the player typed. The server authorizes and++ * performs every action; a `!` prefix is a convenience, not a capability.++ * - Every reply is a chat line (`say`), so command output lives in the same++ * scrollback as conversation instead of flashing past as a toast.++ *++ * `/` commands reply only to the sender; `!` commands reply to the room.++ * `!r` resets and announces, while `/r` resets silently.++ */++++/** `!`/`/` are stripped; the command word is lower-cased. */++export interface ParsedChat {++ /** Lower-cased command without its trigger; null for ordinary chat. */++ command: string | null++ args: string[]++ /** The `/` trigger: feedback goes only to the sender. */++ silent: boolean++}++++/**++ * Parse one chat line. SourceMod treats a leading `!` as public and `/` as++ * silent; ordinary text has no command. Command names are case-insensitive.++ */++export function parseChatCommand(text: string): ParsedChat {++ const trimmed = text.trim()++ if (!trimmed) return { command: null, args: [], silent: false }++ const trigger = trimmed[0]++ if (trigger !== '!' && trigger !== '/') return { command: null, args: [], silent: false }++ const body = trimmed.slice(1).trim()++ if (!body) return { command: '', args: [], silent: trigger === '/' }++ const [command = '', ...args] = body.split(/\s+/)++ return { command: command.toLowerCase(), args: args.slice(0, 8), silent: trigger === '/' }++}++++export type ChatCommandName = 'r' | 'top' | 'wr' | 'pr' | 'practice' | 'stage' | 'b' | 'help'++++/** Source's aliases, kept in one table so parsing and help cannot disagree. */++const COMMAND_ALIASES: Readonly<Record<string, ChatCommandName>> = {++ r: 'r',++ restart: 'r',++ top: 'top',++ wr: 'wr',++ pr: 'pr',++ practice: 'practice',++ p: 'practice',++ stage: 'stage',++ stages: 'stage',++ b: 'b',++ bonus: 'b',++ help: 'help',++ h: 'help',++}++++export function canonicalChatCommand(name: string): ChatCommandName | null {++ return COMMAND_ALIASES[name] ?? null++}++++/** Escape/normalise a chat line before it reaches the wire: one line, no controls. */++export function cleanChatText(text: string): string {++ return text++ .replace(/\p{Cc}/gu, ' ')++ .trim()++ .slice(0, CHAT_MAX_LENGTH)++}++++/** `m:ss.mmm`, the same format as the surf timer. */++export function formatChatTime(ms: number): string {++ const clamped = Math.max(0, Math.round(ms))++ const minutes = Math.floor(clamped / 60_000)++ const seconds = Math.floor((clamped % 60_000) / 1000)++ const millis = clamped % 1000++ return `${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`++}++++/** One second's budget for a normal player: five lines, refilling at 2/s. */++export const CHAT_RATE_CAPACITY = 5++export const CHAT_RATE_REFILL_PER_SECOND = 2++++/**++ * Per-player token bucket. Commands and conversation share it, so a client++ * cannot hammer `!top` (or the database behind it) faster than it can talk.++ */++export class ChatRateLimiter {++ private readonly buckets = new Map<string, { tokens: number; at: number }>()++++ constructor(++ private readonly capacity = CHAT_RATE_CAPACITY,++ private readonly refillPerSecond = CHAT_RATE_REFILL_PER_SECOND,++ private readonly now: () => number = Date.now,++ ) {}++++ allow(key: string): boolean {++ const at = this.now()++ const bucket = this.buckets.get(key) ?? { tokens: this.capacity, at }++ const elapsed = Math.max(0, at - bucket.at)++ bucket.tokens = Math.min(this.capacity, bucket.tokens + (elapsed * this.refillPerSecond) / 1000)++ bucket.at = at++ if (bucket.tokens < 1) {++ this.buckets.set(key, bucket)++ return false++ }++ bucket.tokens -= 1++ this.buckets.set(key, bucket)++ return true++ }++++ forget(key: string): void {++ this.buckets.delete(key)++ }++}++++/**++ * The chat system: validates the session, rate-limits, echoes conversation++ * and runs the surf commands against the same SurfSystem and records seam++ * the rest of the game uses. It is deliberately a System so the handler++ * table sees `say`/`say_team` exactly once.++ */++export class ChatSystem implements System {++ readonly name = 'chat'++ private readonly limiter = new ChatRateLimiter()++++ readonly handlers: HandlerMap = {++ say: (session, msg: ClientSay) => this.receive(session, msg.text, 'say'),++ say_team: (session, msg: ClientSayTeam) => this.receive(session, msg.text, 'say_team'),++ }++++ constructor(private readonly ctx: ServerContext) {}++++ onLeave(session: PlayerSession): void {++ this.limiter.forget(session.playerId)++ }++++ private receive(session: PlayerSession, raw: string, channel: 'say' | 'say_team'): void {++ // Until welcome the client is not in the game: no chat, no commands, and++ // no rate-limit budget to spend.++ if (!session.welcomed) return++ if (!this.limiter.allow(session.playerId)) return++ const text = cleanChatText(raw)++ if (!text) return++ const parsed = parseChatCommand(text)++ if (parsed.command === null) {++ this.ctx.net.broadcastAll({++ t: 'say',++ channel,++ from: session.name,++ fromId: session.entityId,++ text,++ })++ return++ }++ void this.runCommand(session, parsed).catch((err: unknown) => {++ this.ctx.log.warn(`chat command failed: ${String(err)}`)++ this.reply(session, true, 'That command failed. Try again.')++ })++ }++++ private async runCommand(session: PlayerSession, parsed: ParsedChat): Promise<void> {++ const command = canonicalChatCommand(parsed.command ?? '')++ const trigger = parsed.silent ? '/' : '!'++ if (!command) {++ this.reply(session, true, `Unknown command: ${trigger}${parsed.command ?? ''}`)++ return++ }++ switch (command) {++ case 'r':++ if (!session.surf) {++ this.reply(session, true, 'Surf is not active here.')++ return++ }++ this.ctx.systems.surf.restart(session)++ // `/r` is silent: the state reset is its whole reply. `!r` tells the++ // room, as a public Source command does.++ if (!parsed.silent) this.reply(session, false, `${session.name} restarted the run.`)++ return++ case 'practice':++ if (!session.surf) {++ this.reply(session, true, 'Surf is not active here.')++ return++ }++ if (session.surfPractice) {++ this.ctx.systems.surf.setPractice(session, false)++ this.reply(++ session,++ parsed.silent,++ `${session.name} left practice — runs are ranked again.`,++ )++ } else {++ this.ctx.systems.surf.setPractice(session, true)++ this.reply(++ session,++ parsed.silent,++ `${session.name} entered practice — runs are not ranked.`,++ )++ }++ return++ case 'stage':++ this.stage(session, parsed)++ return++ case 'top':++ await this.top(session, parsed)++ return++ case 'wr':++ await this.wr(session, parsed)++ return++ case 'pr':++ await this.pr(session, parsed)++ return++ case 'b':++ this.reply(session, parsed.silent, 'No bonuses on this map.')++ return++ case 'help':++ this.replyLines(session, parsed.silent, [++ 'Commands: !r restart · !top · !wr · !pr · !practice · !stage <n> · !b',++ 'Use / instead of ! to keep the reply to yourself.',++ ])++ return++ }++ }++++ private stage(session: PlayerSession, parsed: ParsedChat): void {++ if (!session.surf) {++ this.reply(session, true, 'Surf is not active here.')++ return++ }++ if (!session.surfPractice) {++ // Refusal is always private: a failed command should not spam the room.++ this.reply(session, true, 'Practice mode is off. Type !practice first.')++ return++ }++ const stages = session.surf.map.stages++ const raw = parsed.args[0]++ const stage = raw === undefined ? Math.min(session.surf.reached + 1, stages) : Number(raw)++ if (!Number.isInteger(stage) || stage < 0 || stage > stages) {++ this.reply(session, true, `Stage must be a whole number from 0 to ${stages}.`)++ return++ }++ if (!this.ctx.systems.surf.goToStage(session, stage)) {++ this.reply(session, true, `There is no stage ${stage} on this map.`)++ return++ }++ this.reply(session, parsed.silent, `${session.name} moved to stage ${stage}.`)++ }++++ private async top(session: PlayerSession, parsed: ParsedChat): Promise<void> {++ const mapId = session.surf?.map.id++ if (!mapId) return this.reply(session, true, 'Surf is not active here.')++ const records = this.ctx.integrations.surf++ if (!records) return this.reply(session, true, 'Surf records are unavailable.')++ const rows = (await records.topRuns(mapId, 10)).slice(0, 10)++ if (!rows.length) {++ this.reply(session, parsed.silent, `No times on ${mapId} yet.`)++ return++ }++ this.replyLines(session, parsed.silent, [++ `Top ${rows.length} — ${mapId}`,++ ...rows.map((row, i) => `${i + 1}. ${formatChatTime(row.timeMs)} ${row.name}`),++ ])++ }++++ private async wr(session: PlayerSession, parsed: ParsedChat): Promise<void> {++ const mapId = session.surf?.map.id++ if (!mapId) return this.reply(session, true, 'Surf is not active here.')++ const records = this.ctx.integrations.surf++ if (!records) return this.reply(session, true, 'Surf records are unavailable.')++ const wrMs = await records.wrMs(mapId)++ this.reply(++ session,++ parsed.silent,++ wrMs === null ? `No WR on ${mapId} yet.` : `${mapId} WR: ${formatChatTime(wrMs)}`,++ )++ }++++ private async pr(session: PlayerSession, parsed: ParsedChat): Promise<void> {++ const mapId = session.surf?.map.id++ if (!mapId) return this.reply(session, true, 'Surf is not active here.')++ const records = this.ctx.integrations.surf++ if (!records) return this.reply(session, true, 'Surf records are unavailable.')++ const pb = await records.pbRun(mapId, session.subjectId ?? session.token)++ this.reply(++ session,++ parsed.silent,++ pb ? `Your PB on ${mapId}: ${formatChatTime(pb.timeMs)}` : `You have no PB on ${mapId} yet.`,++ )++ }++++ /** One line to the room (`!`) or to the sender only (`/`). */++ private reply(session: PlayerSession, silent: boolean, text: string): void {++ const msg: ServerSay = { t: 'say', channel: 'system', text }++ if (silent) this.ctx.net.send(session, msg)++ else this.ctx.net.broadcastAll(msg)++ }++++ private replyLines(session: PlayerSession, silent: boolean, lines: readonly string[]): void {++ for (const line of lines) this.reply(session, silent, line)++ }++}@@@ -8,6 +8,7 @@ import type { PlayerSession } from './playerSession.js'import { ServerContext } from './systems/context.js'import type { GameConnection, GameIntegrations, ServerSystems } from './systems/context.js'import {++ ChatSystem,CombatSystem,EconomySystem,EditorSystem,@@@ -82,6 +83,7 @@ export class GameServer {)const ctx = this.ctxconst sessions = new SessionsSystem(ctx)++ const chat = new ChatSystem(ctx)const movement = new MovementSystem(ctx)// Runs see this tick's authoritative positions: construct after movement,// insert in the ordered list before replication.@@@ -107,6 +109,7 @@ export class GameServer {this.systems = {sessions,++ chat,movement,surf,replication,@@@ -131,6 +134,7 @@ export class GameServer {ctx.systems = this.systemsthis.ordered = [sessions,++ chat,movement,surf,replication,@@@ -98,6 +98,8 @@ export interface PlayerSession {fallVy: number/** Surf run tracker; null outside a surf place. */surf: SurfRunState | null++ /** Surf practice mode: stage travel and no ranked runs/PB writes. */++ surfPractice: boolean/** Cached personal best/world record shown in surf_state. */surfPb: number | nullsurfWr: number | null@@@ -125,6 +127,8 @@ export interface PlayerSession {interestCells: Set<number>/** Player state changed since last persistence flush. */dirty: boolean++ /** `welcome` was sent; chat is refused before it (the client is not in the game yet). */++ welcomed: booleansend(text: string): voidcloseConnection(code: number, reason: string): void}@@@ -187,6 +191,7 @@ export function createSession(init: SessionInit): PlayerSession {openContainer: null,fallVy: 0,surf: null,++ surfPractice: false,surfPb: null,surfWr: null,surfPbSplits: [],@@@ -202,6 +207,7 @@ export function createSession(init: SessionInit): PlayerSession {known: new Set(),interestCells: new Set(),dirty: true,++ welcomed: false,send: init.send,closeConnection: init.closeConnection,}@@@ -31,6 +31,15 @@ export interface SurfRecordOutcome {wrMs: number | null}++/** One row of a map leaderboard (`!top`), already in time order. */++export interface SurfLeaderboardRow {++ id: string++ name: string++ timeMs: number++ createdAt: number++ isPb: boolean++}++/*** The persistence/ranking seam the surf system writes runs through. Games'* implementation is platform/surfRuns.ts; tests use a fake. The server never@@@ -44,5 +53,7 @@ export interface SurfRecords {accountKey: string,): Promise<{ timeMs: number; splits: number[] } | null>wrMs(mapId: string): Promise<number | null>++ /** The fastest runs on a map, ascending by time (chat `!top`, max 10 there). */++ topRuns(mapId: string, limit: number): Promise<SurfLeaderboardRow[]>record(run: SurfRunRecord): Promise<SurfRecordOutcome>}@@@ -92,6 +92,9 @@ function ctxFor(session: any, respawns: string[], sent: unknown[]) {async wrMs() {return null},++ async topRuns() {++ return []++ },async record() {return { pb: true, wr: false, rank: null, pbMs: 1000, wrMs: 1000 }},@@@ -12,6 +12,7 @@ import type { GameEventRecorder } from '../../platform/gameEvents.js'import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'import type { ConstraintRecord, GameWorld } from '../gameWorld.js'import type { PlayerSession } from '../playerSession.js'++import type { ChatSystem } from '../chatCommands.js'import type { CombatSystem } from './combat.js'import type { EconomySystem } from './economy.js'import type { EditorSystem } from './editor.js'@@@ -191,6 +192,7 @@ export class SessionRegistry {*/export interface ServerSystems {sessions: SessionsSystem++ chat: ChatSystemmovement: MovementSystemsurf: SurfSystemreplication: ReplicationSystem@@@ -16,6 +16,7 @@ export { buildHandlerTable } from './system.js'export type { HandlerMap, MessageHandler, System } from './system.js'export { SessionsSystem } from './sessions.js'++export { ChatSystem } from '../chatCommands.js'export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'export { SurfSystem } from './surf.js'export { ReplicationSystem } from './replication.js'@@@ -272,6 +272,9 @@ export class SessionsSystem implements System {})ctx.sessions.setBody(playerId, bodyId)++ // 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 = truectx.net.send(session, {t: 'welcome',v: msg.v,@@@ -22,10 +22,7 @@ export class SurfSystem implements System {readonly handlers: HandlerMap = {surf_restart: (session, _msg: ClientSurfRestart) => {-- if (!session.surf) return-- const event = session.surf.restart(this.ctx.clock.tick)-- if (event.type === 'restarted') this.teleport(session, event.to, event.yaw)-- this.sendState(session)++ this.restart(session)},}@@@ -40,6 +37,7 @@ export class SurfSystem implements System {const map = this.runMap()if (!map) returnsession.surf = new SurfRunState(map, this.ctx.config.tickRate)++ session.surfPractice = falsesession.surfPb = nullsession.surfWr = nullsession.surfPbSplits = []@@@ -48,11 +46,71 @@ export class SurfSystem implements System {this.sendState(session)}++ /**++ * Put the run back at the start line. The same method serves the `R` key's++ * `surf_restart` message and chat's `!r`/`/r`, so they cannot drift.++ */++ restart(session: PlayerSession): boolean {++ if (!session.surf) return false++ const event = session.surf.restart(this.ctx.clock.tick)++ if (event.type === 'restarted') this.teleport(session, event.to, event.yaw)++ this.sendState(session)++ return true++ }++++ /**++ * Enter/leave practice. Both directions restart the run at the start line:++ * leaving must not resume a clock that was frozen while practising, and++ * entering must clear any ranked run already in progress.++ */++ setPractice(session: PlayerSession, on: boolean): boolean {++ if (!session.surf) return false++ if (session.surfPractice === on) return true++ session.surfPractice = on++ const event = session.surf.restart(this.ctx.clock.tick)++ if (event.type === 'restarted') this.teleport(session, event.to, event.yaw)++ this.sendState(session)++ return true++ }++++ /** The authored spawn for a stage (0 = start, checkpoints, finish), or null if it does not exist. */++ stageTarget(++ session: PlayerSession,++ stage: number,++ ): { to: { x: number; y: number; z: number }; yaw: number } | null {++ const map = session.surf?.map++ if (!map || !Number.isInteger(stage) || stage < 0 || stage > map.stages) return null++ const zone =++ (stage === map.start.stage ? map.start : undefined) ??++ map.checkpoints.find((z) => z.stage === stage) ??++ (stage === map.finish.stage ? map.finish : undefined)++ if (!zone) return null++ return { to: zone.spawn ?? map.spawn, yaw: zone.yaw ?? map.spawnYaw }++ }++++ /**++ * Practice-only stage travel. The run is reset to idle (not merely++ * teleported), so no start/finish crossing and therefore no PB write can++ * come out of a stage jump.++ */++ goToStage(session: PlayerSession, stage: number): boolean {++ if (!session.surf || !session.surfPractice) return false++ const target = this.stageTarget(session, stage)++ if (!target) return false++ session.surf.restart(this.ctx.clock.tick)++ this.teleport(session, target.to, target.yaw)++ this.sendState(session)++ return true++ }++tick(ctx: ServerContext, _dt: number): void {if (!this.enabled) returnfor (const session of ctx.sessions.values()) {const run = session.surfif (!run) continue++ // Practice is unranked by construction: the run state machine sees no++ // segments, so it can neither start nor finish (and write) a run.++ if (session.surfPractice) continuefor (const segment of session.moveSegments) {const events = run.observe(ctx.clock.tick, segment.from, segment.to, segment.yaw)for (const event of events) {@@@ -113,6 +171,9 @@ export class SurfSystem implements System {}private async finish(session: PlayerSession, result: SurfRunResult): Promise<void> {++ // Defence in depth: the tick already skips practice, but a finish queued++ // just before practice was enabled must never become a ranked result.++ if (session.surfPractice) returnconst records = this.ctx.integrations.surflet pb = session.surfPb === null || result.timeMs < session.surfPblet wr = session.surfWr === null || result.timeMs < session.surfWr@@@ -192,6 +253,7 @@ export class SurfSystem implements System {splits: run.splitTimes,pbMs: session.surfPb,wrMs: session.surfWr,++ practice: session.surfPractice,...(session.surfPbSplits.length ? { pbSplits: session.surfPbSplits } : {}),})}@@@ -98,6 +98,28 @@ describe('surf runs persistence', () => {expect(await records.ghostBytes(MAP, 'pb', 'guest:unknown')).toBeNull()})++ it('returns the top ten in ascending time for chat', async () => {++ const db = await openTestDb()++ const records = new PgSurfRecords(db)++ for (let i = 0; i < 12; i++) {++ await records.record(++ run({++ accountKey: `guest:racer-${i}`,++ playerName: `Racer ${i}`,++ timeMs: 20_000 - i * 500,++ createdAt: 1_000 + i,++ splits: [20_000 - i * 500],++ }),++ )++ }++ const top = await records.topRuns(MAP, 10)++ expect(top).toHaveLength(10)++ const times = top.map((r) => r.timeMs)++ expect(times).toEqual([...times].sort((a, b) => a - b))++ expect(times[0]).toBe(14_500)++ expect(times.at(-1)).toBe(19_000)++ })++it('play: defines and posts the `time` board with the run ms', async () => {const db = await openTestDb()const play = fakePlay()@@@ -27,6 +27,8 @@ 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: 'say', text: 'hello room' },++ { t: 'say_team', text: 'team line' },]for (const msg of messages) {expect(decodeClientMessage(encodeClientMessage(msg))).toEqual(msg)@@@ -50,6 +52,9 @@ describe('protocol codec', () => {'{"t":"input","seq":1,"moveX":5,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',),).toBeNull()++ // Empty and oversized chat is refused at the boundary.++ expect(decodeClientMessage('{"t":"say","text":" "}')).toBeNull()++ expect(decodeClientMessage(JSON.stringify({ t: 'say', text: 'x'.repeat(241) }))).toBeNull()})it('decodes an older hello without a digest for a typed join rejection', () => {@@@ -10,6 +10,9 @@ import { ClientSurfRestartSchema } from './surf.js'*/export const MAX_CHARACTERS = 24++/** Longest chat line the wire accepts (a Source `say` fits comfortably). */++export const CHAT_MAX_LENGTH = 240++/*** Client -> server messages, defined as zod schemas because the server must* treat every inbound byte as hostile. Types are inferred from schemas so@@@ -300,6 +303,21 @@ export const ClientContainerSortSchema = z.object({target: z.string().max(32),})++/**++ * 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.++ */++export const ClientSaySchema = z.object({++ t: z.literal('say'),++ text: z.string().trim().min(1).max(CHAT_MAX_LENGTH),++})++++/** Source's `say_team`: team chat. Surf rooms are one team, so it reaches the room. */++export const ClientSayTeamSchema = z.object({++ t: z.literal('say_team'),++ text: z.string().trim().min(1).max(CHAT_MAX_LENGTH),++})++export const ClientMessageSchema = z.union([ClientTransferSchema,ClientEditModeSchema,@@@ -329,6 +347,8 @@ export const ClientMessageSchema = z.union([ClientContainerOpenSchema,ClientContainerMoveSchema,ClientContainerSortSchema,++ ClientSaySchema,++ ClientSayTeamSchema,ClientSurfRestartSchema,])@@@ -360,5 +380,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 ClientSay = z.infer<typeof ClientSaySchema>++export type ClientSayTeam = z.infer<typeof ClientSayTeamSchema>export type { ClientSurfRestart } from './surf.js'export type ClientMessage = z.infer<typeof ClientMessageSchema>@@@ -233,6 +233,21 @@ export interface ServerAnnounce {text: string}++/**++ * One chat line. Player lines carry `from`; server/command lines are++ * `channel: 'system'` with no speaker. `text` is plain text — clients render++ * it with textContent, never as markup.++ */++export interface ServerSay {++ t: 'say'++ channel: 'say' | 'say_team' | 'system'++ text: string++ /** Speaker display name; absent on system lines. */++ from?: string++ /** Speaker entity id; absent on system lines. */++ fromId?: string++}++/** Market data for an opened trading post (refreshed after each deal). */export interface ServerMarket {t: 'market'@@@ -304,6 +319,7 @@ export type ServerMessage =| ServerReputation| ServerJobs| ServerAnnounce++ | ServerSay| ServerTracer| ServerFx| ServerMapReload@@@ -37,6 +37,8 @@ export const SurfStateSchema = z.object({wrMs: timeMs.nullable(),/** The PB's split times, for the per-split delta (absent until one exists). */pbSplits: splitList.optional(),++ /** Practice mode is on: the run clock and PB writes are off. */++ practice: z.boolean().optional(),})export type ServerSurfState = z.infer<typeof SurfStateSchema>nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/ui/chat.test.ts'++// covers: 'apps/client/src/ui'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/game/chat.integration.test.ts'++// covers: 'apps/server/src/game'++// Generated by node test/run.js --index; do not edit.nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/server/src/game/chatCommands.test.ts'++// covers: 'apps/server/src/game'++// Generated by node test/run.js --index; do not edit.
@@ -315,6 +315,22 @@ async function start(): Promise<void> {
@@ -315,6 +315,22 @@ async function start(): Promise<void> {
guest: identity.token,
guest: identity.token,
...(sso ? { bearer: sso } : {}),
...(sso ? { bearer: sso } : {}),
})
})
+ // 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.
+ document.addEventListener(
+ 'keydown',
+ (e) => {
+ if (e.repeat) return
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
+ if (input.uiCapture) return
+ if (e.code !== 'KeyY' && e.code !== 'KeyU') return
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ hud.chat.open(e.code === 'KeyY' ? 'say' : 'say_team')
+ },
+ true,
+ )
// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
// Scraplandia's first-steps checklist (not in Salvage Rush or a creator world, whose worlds are something else).
let updateWaypoint: (() => void) | null = null
let updateWaypoint: (() => void) | null = null
let firstStepsRef: FirstSteps | null = null
let firstStepsRef: FirstSteps | null = null
@@ -46,6 +46,8 @@ export interface ClientStateEvents {
@@ -46,6 +46,8 @@ export interface ClientStateEvents {
weather: string
weather: string
container: { id: string; size: number; slots: { i: number; def: string; count: number }[] }
container: { id: string; size: number; slots: { i: number; def: string; count: number }[] }
announce: string
announce: string
+ /** One chat line (conversation or a command reply). */
+ chat: Extract<ServerMessage, { t: 'say' }>
market: unknown
market: unknown
jobs: unknown
jobs: unknown
reputation: { id: string; name: string; value: number; stance: string }[]
reputation: { id: string; name: string; value: number; stance: string }[]
@@ -226,6 +228,9 @@ export class ClientState {
@@ -226,6 +228,9 @@ export class ClientState {
case 'announce':
case 'announce':
this.events.emit('announce', msg.text)
this.events.emit('announce', msg.text)
break
break
+ case 'say':
+ this.events.emit('chat', msg)
+ break
case 'market':
case 'market':
this.events.emit('market', msg)
this.events.emit('market', msg)
break
break
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+/* Source places chat lower-left. In Scraplandia the vitals already sit there,
+ so chat clears them; on a surf map the vitals are hidden and it drops to the
+ corner. As in Source, a closed chat lets each line fade a few seconds after it
+ arrives; opening it (Y or U) brings the history back on a dark panel. */
+.chat {
+ position: absolute;
+ z-index: 13;
+ left: 16px;
+ bottom: 132px;
+ width: min(30rem, calc(100vw - 2rem));
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: calc(13px * var(--ov-text-scale, 1));
+ pointer-events: none;
+}
+
+.chat-lines {
+ max-height: 12rem;
+ overflow-y: auto;
+ list-style: none;
+ padding: 2px 0;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
+ scrollbar-width: thin;
+}
+
+.chat-line {
+ padding: 1px 4px;
+ overflow-wrap: anywhere;
+ background: rgba(8, 11, 15, 0.42);
+ border-radius: 3px;
+}
+
+.chat-line + .chat-line {
+ margin-top: 1px;
+}
+
+.chat-from {
+ margin-right: 0.35em;
+ font-weight: 700;
+}
+
+.chat-system {
+ color: var(--g-accent-text, #f0b040);
+}
+
+.chat-form {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 8px;
+ background: rgba(8, 11, 15, 0.86);
+ border: 1px solid var(--border, #2a3340);
+ border-radius: 5px;
+ pointer-events: auto;
+}
+
+.chat-form[hidden] {
+ display: none;
+}
+
+.chat-channel {
+ font-size: 0.75em;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--g-accent-text, #f0b040);
+}
+
+.chat-input {
+ flex: 1;
+ min-width: 0;
+ padding: 2px 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text-primary, #e6e6e6);
+ font: inherit;
+}
+
+.ovg-surf .chat {
+ bottom: 18px;
+}
+
+.chat:not(.is-open) .chat-line {
+ animation: chat-line-fade 12s linear forwards;
+}
+
+@keyframes chat-line-fade {
+ 0%, 75% { opacity: 1; }
+ 100% { opacity: 0; visibility: hidden; }
+}
+
+.chat.is-open .chat-lines {
+ padding: 4px;
+ border-radius: 5px;
+ background: rgba(8, 11, 15, 0.55);
+ pointer-events: auto;
+}
+
+.chat.is-open .chat-line {
+ background: transparent;
+}
+
+.chat-team .chat-from {
+ color: var(--g-team-text, #7cc4ff);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .chat:not(.is-open) .chat-line {
+ animation-timing-function: steps(1, end);
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+import type { ClientMessage } from '@openvibe/protocol'
+import { beforeEach, describe, expect, it } from 'vitest'
+import type { Connection } from '../net/connection.js'
+import { Chat } from './chat.js'
+import { ClientState } from '../state/clientState.js'
+
+describe('chat UI', () => {
+ let root: HTMLDivElement
+ let state: ClientState
+ let sent: ClientMessage[]
+ let chat: Chat
+
+ beforeEach(() => {
+ document.body.replaceChildren()
+ root = document.createElement('div')
+ document.body.append(root)
+ state = new ClientState()
+ sent = []
+ const connection = {
+ send: (msg: ClientMessage) => sent.push(msg),
+ } as unknown as Connection
+ chat = new Chat(root, connection, state)
+ })
+
+ it('does not open before welcome, and sends a say once welcomed', () => {
+ const form = root.querySelector('.chat-form') as HTMLFormElement
+ const input = root.querySelector('.chat-input') as HTMLInputElement
+ let captured: boolean | null = null
+ chat.onCaptureChange = (on) => {
+ captured = on
+ }
+
+ chat.open('say')
+ expect(form.hidden).toBe(true)
+
+ state.myEntityId = 'entity-1'
+ chat.open('say')
+ expect(form.hidden).toBe(false)
+ expect(captured).toBe(true)
+
+ input.value = '!top'
+ form.dispatchEvent(new Event('submit', { cancelable: true }))
+ expect(sent).toEqual([{ t: 'say', text: '!top' }])
+ expect(form.hidden).toBe(true)
+ expect(captured).toBe(false)
+ })
+
+ it('sends say_team from the U channel', () => {
+ state.myEntityId = 'entity-1'
+ chat.open('say_team')
+ const form = root.querySelector('.chat-form') as HTMLFormElement
+ const input = root.querySelector('.chat-input') as HTMLInputElement
+ input.value = 'on my way'
+ form.dispatchEvent(new Event('submit', { cancelable: true }))
+ expect(sent).toEqual([{ t: 'say_team', text: 'on my way' }])
+ })
+
+ it('renders untrusted names and text as text, never as markup', () => {
+ chat.addLine({
+ t: 'say',
+ channel: 'say',
+ from: '<b>Player</b>',
+ fromId: 'e1',
+ text: '<img src=x onerror="alert(1)">',
+ })
+ chat.addLine({
+ t: 'say',
+ channel: 'system',
+ text: '<script>alert(2)</script>',
+ })
+
+ expect(root.querySelector('img')).toBeNull()
+ expect(root.querySelector('b')).toBeNull()
+ expect(root.querySelector('script')).toBeNull()
+ expect(root.querySelector('.chat-from')?.textContent).toBe('<b>Player</b>:')
+ expect(root.textContent).toContain('<img src=x onerror="alert(1)">')
+ expect(root.textContent).toContain('<script>alert(2)</script>')
+ // innerHTML escapes the angle brackets; it never becomes a live element.
+ expect(root.querySelector('.chat-lines')?.innerHTML).not.toContain('<img')
+ })
+
+ it('keeps the scrollback bounded', () => {
+ for (let i = 0; i < 105; i++) {
+ chat.addLine({ t: 'say', channel: 'system', text: `line ${i}` })
+ }
+ expect(root.querySelectorAll('.chat-line')).toHaveLength(100)
+ expect(root.querySelector('.chat-line')?.textContent).toBe('line 5')
+ expect(root.textContent).toContain('line 104')
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+import { CHAT_MAX_LENGTH, type ServerMessage } from '@openvibe/protocol'
+import type { Connection } from '../net/connection.js'
+import type { ClientState } from '../state/clientState.js'
+import './chat.css'
+
+type ChatLine = Extract<ServerMessage, { t: 'say' }>
+type ChatMode = 'say' | 'say_team'
+
+const MAX_LINES = 100
+
+/**
+ * Source-style chat: lower-left scrollback plus a one-line input opened with
+ * Y (say) or U (team). The server is authoritative for delivery and rate
+ * limits; this class only renders plain text and sends the typed line.
+ *
+ * Every piece of remote text goes through `textContent`. Never innerHTML:
+ * names and chat messages are untrusted.
+ */
+export class Chat {
+ readonly root: HTMLElement
+ private readonly lines: HTMLOListElement
+ private readonly form: HTMLFormElement
+ private readonly input: HTMLInputElement
+ private readonly channelLabel: HTMLSpanElement
+ private mode: ChatMode = 'say'
+ private isOpen = false
+
+ /** The HUD's existing capture switch: typing must not move the player. */
+ onCaptureChange: ((captured: boolean) => void) | null = null
+
+ constructor(
+ parent: HTMLElement,
+ private readonly connection: Connection,
+ private readonly state: ClientState,
+ ) {
+ this.root = document.createElement('section')
+ this.root.className = 'chat'
+ this.root.setAttribute('aria-label', 'Chat')
+
+ this.lines = document.createElement('ol')
+ this.lines.className = 'chat-lines'
+ this.lines.setAttribute('role', 'log')
+ this.lines.setAttribute('aria-live', 'polite')
+ this.lines.setAttribute('aria-relevant', 'additions')
+
+ this.form = document.createElement('form')
+ this.form.className = 'chat-form'
+ this.form.hidden = true
+ this.channelLabel = document.createElement('span')
+ this.channelLabel.className = 'chat-channel'
+ this.channelLabel.textContent = 'Say'
+ this.input = document.createElement('input')
+ this.input.className = 'chat-input'
+ this.input.type = 'text'
+ this.input.maxLength = CHAT_MAX_LENGTH
+ this.input.autocomplete = 'off'
+ this.input.spellcheck = false
+ this.input.setAttribute('aria-label', 'Chat message')
+ this.input.placeholder = 'Message…'
+ this.form.append(this.channelLabel, this.input)
+
+ this.root.append(this.lines, this.form)
+ parent.append(this.root)
+
+ this.form.addEventListener('submit', (event) => {
+ event.preventDefault()
+ this.submit()
+ })
+ this.input.addEventListener('keydown', (event) => {
+ // The game's global key handlers ignore inputs already; stop here too so
+ // Enter/Escape never reach a game action.
+ event.stopPropagation()
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ this.close()
+ }
+ })
+
+ state.events.on('chat', (line) => this.addLine(line))
+ }
+
+ /** Open the input for a channel. Ignored until the server has welcomed us. */
+ open(mode: ChatMode): void {
+ if (!this.state.myEntityId) return
+ this.mode = mode
+ this.channelLabel.textContent = mode === 'say' ? 'Say' : 'Team'
+ if (this.isOpen) return
+ this.isOpen = true
+ this.form.hidden = false
+ this.root.classList.add('is-open')
+ this.onCaptureChange?.(true)
+ this.input.focus()
+ }
+
+ close(): void {
+ if (!this.isOpen) return
+ this.isOpen = false
+ this.form.hidden = true
+ this.root.classList.remove('is-open')
+ this.input.blur()
+ this.onCaptureChange?.(false)
+ }
+
+ /** Append one server line (conversation or command reply). */
+ addLine(line: ChatLine): void {
+ const item = document.createElement('li')
+ item.className = `chat-line chat-${line.channel}`
+ if (line.from) {
+ const from = document.createElement('span')
+ from.className = 'chat-from'
+ from.textContent = line.channel === 'say_team' ? `(TEAM) ${line.from}:` : `${line.from}:`
+ item.append(from)
+ }
+ const text = document.createElement('span')
+ text.className = 'chat-text'
+ text.textContent = line.text
+ item.append(text)
+
+ this.lines.append(item)
+ while (this.lines.childElementCount > MAX_LINES) this.lines.firstElementChild?.remove()
+ this.lines.scrollTop = this.lines.scrollHeight
+ }
+
+ private submit(): void {
+ const text = this.input.value.trim()
+ this.input.value = ''
+ if (!text) {
+ this.close()
+ return
+ }
+ // The state guard is belt-and-braces: the server drops anything before
+ // `welcome` anyway.
+ if (this.state.myEntityId) {
+ this.connection.send(this.mode === 'say' ? { t: 'say', text } : { t: 'say_team', text })
+ }
+ this.close()
+ }
+}
@@ -3,6 +3,7 @@ import { HOTBAR_SLOTS } from '../constants.js'
@@ -3,6 +3,7 @@ import { HOTBAR_SLOTS } from '../constants.js'
import type { Connection } from '../net/connection.js'
import type { Connection } from '../net/connection.js'
import type { ClientState } from '../state/clientState.js'
import type { ClientState } from '../state/clientState.js'
import type { IconFactory } from './iconFactory.js'
import type { IconFactory } from './iconFactory.js'
+import { Chat } from './chat.js'
import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'
import { weaponModuleFor, type WeaponSettings } from '../weapons/registry.js'
import {
import {
getQualityOverride,
getQualityOverride,
@@ -42,6 +43,8 @@ export class Hud {
@@ -42,6 +43,8 @@ export class Hud {
private statusEl!: HTMLElement
private statusEl!: HTMLElement
private toastArea!: HTMLElement
private toastArea!: HTMLElement
+ /** Source-style chat (Y/U); the HUD owns its mount and capture handoff. */
+ readonly chat: Chat
menuOpen = false
menuOpen = false
private activeTab: MenuTab = 'inventory'
private activeTab: MenuTab = 'inventory'
private dragFrom: number | null = null
private dragFrom: number | null = null
@@ -63,6 +66,8 @@ export class Hud {
@@ -63,6 +66,8 @@ export class Hud {
) {
) {
this.root = root
this.root = root
this.build()
this.build()
+ this.chat = new Chat(this.root, connection, state)
+ this.chat.onCaptureChange = (captured) => this.onUiCaptureChange?.(captured)
icons.onReady = () => {
icons.onReady = () => {
this.renderHotbar()
this.renderHotbar()
this.renderMenu()
this.renderMenu()
@@ -526,6 +531,8 @@ export class Hud {
@@ -526,6 +531,8 @@ export class Hud {
}
}
toggleMenu(): void {
toggleMenu(): void {
+ // Chat and the menu both hold input capture; only one may be open.
+ if (!this.menuOpen) this.chat.close()
this.menuOpen = !this.menuOpen
this.menuOpen = !this.menuOpen
this.menuEl.style.display = this.menuOpen ? 'flex' : 'none'
this.menuEl.style.display = this.menuOpen ? 'flex' : 'none'
this.renderTabs()
this.renderTabs()
new file mode 100644
new file mode 100644
@@ -0,0 +1,348 @@
@@ -0,0 +1,348 @@
+import { mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createContent, setMapOverride, surfPack } from '@openvibe/content'
+import type { PersistenceStore } from '@openvibe/persistence'
+import { openPgStore } from '@openvibe/persistence'
+import { openTestDb } from '@openvibe/persistence/testing'
+import { createRapierWorld, loadRapier, type RapierModule } from '@openvibe/physics/rapier'
+import {
+ PROTOCOL_VERSION,
+ defaultAppearance,
+ type ClientMessage,
+ type ServerMessage,
+ type ServerSay,
+} from '@openvibe/protocol'
+import { createConsoleLogger } from '@openvibe/shared'
+import type { Db } from 'openvibe-sdk/db'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { SURF_ROOM_TIMING, loadConfig } from '../config.js'
+import { hashGuestKey } from '../net/guestIdentity.js'
+import { ServerMetrics } from '../observability/metrics.js'
+import type { GameConnection } from './gameServer.js'
+import { GameServer } from './gameServer.js'
+import { GameWorld } from './gameWorld.js'
+import { loadSurfOverride } from './surfMaps.js'
+import type { ServerContext } from './systems/context.js'
+import { PgSurfRecords } from '../platform/surfRuns.js'
+import type { SurfRunRecord } from './surfRecords.js'
+import { CHAT_RATE_CAPACITY } from './chatCommands.js'
+import type { PlayerSession } from './playerSession.js'
+
+const MAP = 'surf_lagoon'
+const dir = mkdtempSync(join(tmpdir(), 'openvibe-chat-'))
+const config = {
+ ...loadConfig({ MAP_PATH: join(dir, 'map.json'), GAMES_PLACE_ID: MAP }),
+ ...SURF_ROOM_TIMING,
+ placeId: MAP,
+ sandbox: true,
+}
+const log = createConsoleLogger({ app: 'test' }, 'error')
+
+let rapier: RapierModule
+let db: Db
+let physics: ReturnType<typeof createRapierWorld> | null = null
+let store: PersistenceStore
+let world: GameWorld
+let game: GameServer
+let records: PgSurfRecords
+
+interface TestClient {
+ conn: GameConnection
+ inbox: ServerMessage[]
+ closed: { code: number; reason: string } | null
+}
+
+interface WelcomedClient extends TestClient {
+ hello(token: string): Promise<void>
+ send(msg: ClientMessage): void
+}
+
+function connect(name: string): WelcomedClient {
+ const inbox: ServerMessage[] = []
+ const state: { closed: { code: number; reason: string } | null } = { closed: null }
+ const conn: GameConnection = {
+ send: (text) => inbox.push(JSON.parse(text) as ServerMessage),
+ close: (code, reason) => {
+ state.closed = { code, reason }
+ },
+ }
+ return {
+ conn,
+ inbox,
+ get closed() {
+ return state.closed
+ },
+ async hello(token: string): Promise<void> {
+ conn.identity = { guestKeyHash: hashGuestKey(token) }
+ game.onMessage(conn, {
+ t: 'hello',
+ v: PROTOCOL_VERSION,
+ contentDigest: world.content.digest,
+ slot: 0,
+ name,
+ appearance: defaultAppearance(),
+ })
+ for (let i = 0; i < 200 && !state.closed && !inbox.some((m) => m.t === 'welcome'); i++) {
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+ delete conn.identity
+ },
+ send(msg: ClientMessage): void {
+ game.onMessage(conn, msg)
+ },
+ }
+}
+
+function ctxOf(): ServerContext {
+ return (game as unknown as { ctx: ServerContext }).ctx
+}
+
+function sessionOf(client: TestClient): PlayerSession {
+ const session = ctxOf().sessions.getByConn(client.conn)
+ if (!session) throw new Error('client has no session')
+ return session
+}
+
+function say(client: WelcomedClient, text: string): void {
+ client.send({ t: 'say', text })
+}
+
+function chatTexts(client: TestClient): string[] {
+ return client.inbox.filter((m): m is ServerSay => m.t === 'say').map((m) => m.text)
+}
+
+/** Async command replies land after the handler's records query; wait for one. */
+async function waitForChat(
+ client: TestClient,
+ predicate: (text: string) => boolean,
+ timeoutMs = 3_000,
+): Promise<void> {
+ const end = Date.now() + timeoutMs
+ while (Date.now() < end) {
+ if (chatTexts(client).some(predicate)) return
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ }
+ throw new Error(`chat reply not received; saw: ${chatTexts(client).join(' | ')}`)
+}
+
+function msOf(text: string): number {
+ const [minutes, rest] = text.split(':')
+ const [seconds, millis] = rest!.split('.')
+ return Number(minutes) * 60_000 + Number(seconds) * 1000 + Number(millis)
+}
+
+async function seedRun(accountKey: string, playerName: string, timeMs: number, createdAt: number) {
+ const run: SurfRunRecord = {
+ id: crypto.randomUUID(),
+ mapId: MAP,
+ accountKey,
+ playerName,
+ timeMs,
+ splits: [timeMs],
+ track: [{ tick: 1, x: 0, y: 0, z: 0, yaw: 0 }],
+ tickRate: SURF_ROOM_TIMING.tickRate,
+ startedTick: 0,
+ finishedTick: 100,
+ startFrac: 0,
+ finishFrac: 0.5,
+ fails: 0,
+ createdAt,
+ }
+ await records.record(run)
+}
+
+beforeAll(async () => {
+ rapier = await loadRapier()
+ db = await openTestDb()
+ const content = createContent([], surfPack(MAP))
+ setMapOverride(loadSurfOverride(MAP), content.world.id)
+ store = openPgStore(db, { placeId: MAP })
+ await store.ensurePlace()
+ physics = createRapierWorld(rapier)
+ world = new GameWorld(content, physics, log)
+ await world.seedOrRestore(store)
+
+ records = new PgSurfRecords(db)
+ // Eleven ordinary runs (9.000 … 19.000 s) plus Alice's 12.345 s PB. The
+ // top ten therefore runs 9.000 … 17.000 with Alice's PB in fifth place.
+ for (let i = 0; i < 11; i++) {
+ await seedRun(`guest:seed-${i}`, `Racer ${i}`, 9_000 + i * 1000, 1_000 + i)
+ }
+ await seedRun(`guest:${hashGuestKey('alice-token')}`, 'Alice', 12_345, 2_000)
+
+ game = new GameServer(config, world, store, new ServerMetrics(), log, { surf: records })
+ await game.load()
+}, 120_000)
+
+afterAll(async () => {
+ game?.shutdown()
+ await game?.drain()
+ physics?.dispose()
+})
+
+describe('chat with two live clients', () => {
+ it('refuses chat before welcome, then echoes say and say_team to both', async () => {
+ const early = connect('Early')
+ game.onMessage(early.conn, { t: 'say', text: 'too soon' })
+ expect(early.closed).toEqual({ code: 4001, reason: 'hello_first' })
+ expect(early.inbox).toEqual([])
+
+ const ana = connect('Ana')
+ const bea = connect('Bea')
+ await ana.hello('ana-one-token')
+ await bea.hello('bea-one-token')
+ expect(ana.inbox.some((m) => m.t === 'welcome')).toBe(true)
+ expect(bea.inbox.some((m) => m.t === 'welcome')).toBe(true)
+
+ say(ana, 'hello room')
+ expect(chatTexts(ana)).toContain('hello room')
+ expect(chatTexts(bea)).toContain('hello room')
+ const line = bea.inbox.find((m): m is ServerSay => m.t === 'say' && m.text === 'hello room')
+ expect(line).toMatchObject({ channel: 'say', from: 'Ana' })
+
+ // Untrusted text survives as text on the wire (the client renderer is
+ // covered by chat.test.ts; this proves the server never turns it into
+ // markup or loses it).
+ const xss = '<img src=x onerror="alert(1)">'
+ say(ana, xss)
+ expect(bea.inbox).toContainEqual(
+ expect.objectContaining({ t: 'say', channel: 'say', from: 'Ana', text: xss }),
+ )
+
+ bea.send({ t: 'say_team', text: 'team line' })
+ expect(ana.inbox).toContainEqual(
+ expect.objectContaining({ t: 'say', channel: 'say_team', from: 'Bea', text: 'team line' }),
+ )
+
+ say(ana, '!b')
+ expect(chatTexts(bea)).toContain('No bonuses on this map.')
+ })
+
+ it('resets the run with !r, keeps /r silent, and leaves the finish state cleared', async () => {
+ const ana = connect('Ana')
+ const bea = connect('Bea')
+ await ana.hello('ana-two-token')
+ await bea.hello('bea-two-token')
+ const session = sessionOf(ana)
+ const run = session.surf!
+ run.status = 'running'
+ run.startTick = 1
+ run.lastTick = 101
+ run.reached = 1
+ run.splits = [{ stage: 1, timeMs: 1_000 }]
+
+ say(ana, '!r')
+ const reset = ana.inbox.filter((m) => m.t === 'surf_state').at(-1)
+ expect(reset).toMatchObject({
+ t: 'surf_state',
+ running: false,
+ elapsedMs: 0,
+ stage: 0,
+ splits: [],
+ practice: false,
+ })
+ expect(run.finishResult).toBeNull()
+ expect(chatTexts(bea)).toContain('Ana restarted the run.')
+
+ // Arm a finished card-like state again: /r must reset it without a line.
+ run.status = 'running'
+ run.splits = [{ stage: 1, timeMs: 500 }]
+ const before = bea.inbox.length
+ say(ana, '/r')
+ const silentReset = ana.inbox.filter((m) => m.t === 'surf_state').at(-1)
+ expect(silentReset).toMatchObject({ running: false, elapsedMs: 0, splits: [] })
+ expect(bea.inbox.slice(before).filter((m) => m.t === 'say')).toEqual([])
+ })
+
+ it('reports !top ascending as ten lines and !wr/!pr from the records store', async () => {
+ const alice = connect('Alice')
+ await alice.hello('alice-token')
+
+ say(alice, '!top')
+ await waitForChat(alice, (text) => text.startsWith('Top '))
+ const topLines = chatTexts(alice).filter((text) => /^\d+\. \d+:\d\d\.\d\d\d {2}/.test(text))
+ expect(topLines).toHaveLength(10)
+ const times = topLines.map((text) => msOf(/^\d+\. (\d+:\d\d\.\d\d\d) {2}/.exec(text)![1]!))
+ expect(times).toEqual([...times].sort((a, b) => a - b))
+ expect(times[0]).toBe(9_000)
+ expect(times.at(-1)).toBe(17_000)
+ expect(chatTexts(alice)).toContain('Top 10 — surf_lagoon')
+
+ say(alice, '!wr')
+ await waitForChat(alice, (text) => text.includes(' WR: '))
+ expect(chatTexts(alice)).toContain('surf_lagoon WR: 0:09.000')
+
+ say(alice, '!pr')
+ await waitForChat(alice, (text) => text.startsWith('Your PB on '))
+ expect(chatTexts(alice)).toContain('Your PB on surf_lagoon: 0:12.345')
+ })
+
+ it('refuses !stage outside practice, travels stages in practice, and never writes a run', async () => {
+ const dana = connect('Dana')
+ await dana.hello('dana-token')
+ const session = sessionOf(dana)
+ const startX = session.move.pos.x
+ const startZ = session.move.pos.z
+
+ say(dana, '!stage 1')
+ expect(chatTexts(dana)).toContain('Practice mode is off. Type !practice first.')
+ expect(session.move.pos.x).toBe(startX)
+ expect(session.move.pos.z).toBe(startZ)
+ expect(session.surfPractice).toBe(false)
+
+ say(dana, '!practice')
+ expect(session.surfPractice).toBe(true)
+ expect(chatTexts(dana)).toContain('Dana entered practice — runs are not ranked.')
+
+ say(dana, '!stage 2')
+ const checkpoint = session.surf!.map.checkpoints.find((z) => z.stage === 2)!
+ expect(session.move.pos.x).toBeCloseTo(checkpoint.spawn!.x)
+ expect(session.move.pos.y).toBeCloseTo(checkpoint.spawn!.y)
+ expect(session.move.pos.z).toBeCloseTo(checkpoint.spawn!.z)
+ expect(chatTexts(dana)).toContain('Dana moved to stage 2.')
+
+ // Force the worst case: a run that would otherwise finish on this tick.
+ // Practice skips the run state machine entirely, so no surf_finish and
+ // no row for Dana's account may appear.
+ const finish = session.surf!.map.finish
+ session.surf!.status = 'running'
+ session.surf!.reached = session.surf!.map.checkpoints.length
+ session.surf!.startTick = 0
+ session.moveSegments.push({
+ from: { x: finish.min[0], y: finish.min[1], z: finish.min[2] - 1 },
+ to: {
+ x: (finish.min[0] + finish.max[0]) / 2,
+ y: (finish.min[1] + finish.max[1]) / 2,
+ z: (finish.min[2] + finish.max[2]) / 2,
+ },
+ yaw: 0,
+ })
+ ctxOf().systems.surf.tick(ctxOf(), 0.01)
+ expect(dana.inbox.some((m) => m.t === 'surf_finish')).toBe(false)
+ const danaKey = `guest:${hashGuestKey('dana-token')}`
+ const rows = await db.many<{ n: number | string }>(
+ 'SELECT COUNT(*) AS n FROM surf_runs WHERE account_key = $1',
+ [danaKey],
+ )
+ expect(Number(rows[0]!.n)).toBe(0)
+
+ say(dana, '!practice')
+ expect(session.surfPractice).toBe(false)
+ expect(session.move.pos.x).toBeCloseTo(startX)
+ expect(session.move.pos.z).toBeCloseTo(startZ)
+ })
+
+ it('holds the burst rate limit for a fresh client', async () => {
+ const frank = connect('Frank')
+ await frank.hello('frank-token')
+ const bea = connect('Bea')
+ await bea.hello('bea-five-token')
+ const before = bea.inbox.length
+ for (let i = 0; i < CHAT_RATE_CAPACITY + 3; i++) say(frank, `spam ${i}`)
+ const received = bea.inbox
+ .slice(before)
+ .filter((m): m is ServerSay => m.t === 'say' && m.from === 'Frank')
+ expect(received).toHaveLength(CHAT_RATE_CAPACITY)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest'
+import {
+ CHAT_RATE_CAPACITY,
+ ChatRateLimiter,
+ canonicalChatCommand,
+ cleanChatText,
+ formatChatTime,
+ parseChatCommand,
+} from './chatCommands.js'
+
+describe('chat command parser', () => {
+ it('treats ordinary text as chat', () => {
+ expect(parseChatCommand('hello there')).toEqual({ command: null, args: [], silent: false })
+ expect(parseChatCommand(' ')).toEqual({ command: null, args: [], silent: false })
+ expect(parseChatCommand('what?')).toEqual({ command: null, args: [], silent: false })
+ })
+
+ it('parses Source triggers, lower-cases the command and keeps its arguments', () => {
+ expect(parseChatCommand('!r')).toEqual({ command: 'r', args: [], silent: false })
+ expect(parseChatCommand('/r')).toEqual({ command: 'r', args: [], silent: true })
+ expect(parseChatCommand(' !TOP ')).toEqual({ command: 'top', args: [], silent: false })
+ expect(parseChatCommand('!stage 2')).toEqual({
+ command: 'stage',
+ args: ['2'],
+ silent: false,
+ })
+ expect(parseChatCommand('/stage 3 extra')).toEqual({
+ command: 'stage',
+ args: ['3', 'extra'],
+ silent: true,
+ })
+ })
+
+ it('caps the argument list and handles a bare trigger', () => {
+ expect(parseChatCommand('!stage 1 2 3 4 5 6 7 8 9 10').args).toEqual([
+ '1',
+ '2',
+ '3',
+ '4',
+ '5',
+ '6',
+ '7',
+ '8',
+ ])
+ expect(parseChatCommand('!')).toEqual({ command: '', args: [], silent: false })
+ expect(parseChatCommand('/')).toEqual({ command: '', args: [], silent: true })
+ })
+
+ it('maps Source aliases to one canonical command', () => {
+ expect(canonicalChatCommand('r')).toBe('r')
+ expect(canonicalChatCommand('restart')).toBe('r')
+ expect(canonicalChatCommand('stages')).toBe('stage')
+ expect(canonicalChatCommand('bonus')).toBe('b')
+ expect(canonicalChatCommand('p')).toBe('practice')
+ expect(canonicalChatCommand('h')).toBe('help')
+ expect(canonicalChatCommand('nope')).toBeNull()
+ })
+})
+
+describe('chat text and time formatting', () => {
+ it('strips control characters, trims and clamps to the wire limit', () => {
+ expect(cleanChatText(' hi\u0000there\u001b[31m ')).toBe('hi there [31m')
+ expect(cleanChatText('x'.repeat(500))).toHaveLength(240)
+ expect(cleanChatText(' \n\t ')).toBe('')
+ })
+
+ it('prints the surf timer format', () => {
+ expect(formatChatTime(0)).toBe('0:00.000')
+ expect(formatChatTime(12_345)).toBe('0:12.345')
+ expect(formatChatTime(61_005)).toBe('1:01.005')
+ })
+})
+
+describe('chat rate limit', () => {
+ it('allows a five-line burst and refills at two per second', () => {
+ let now = 1_000
+ const limiter = new ChatRateLimiter(CHAT_RATE_CAPACITY, 2, () => now)
+ for (let i = 0; i < CHAT_RATE_CAPACITY; i++) expect(limiter.allow('a')).toBe(true)
+ expect(limiter.allow('a')).toBe(false)
+ // 500 ms buys one token back, not five.
+ now += 500
+ expect(limiter.allow('a')).toBe(true)
+ expect(limiter.allow('a')).toBe(false)
+ // A different player has their own bucket.
+ expect(limiter.allow('b')).toBe(true)
+ })
+
+ it('forgets a player on leave', () => {
+ const now = 0
+ const limiter = new ChatRateLimiter(1, 1, () => now)
+ expect(limiter.allow('a')).toBe(true)
+ expect(limiter.allow('a')).toBe(false)
+ limiter.forget('a')
+ expect(limiter.allow('a')).toBe(true)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,309 @@
@@ -0,0 +1,309 @@
+import {
+ CHAT_MAX_LENGTH,
+ type ClientSay,
+ type ClientSayTeam,
+ type ServerSay,
+} from '@openvibe/protocol'
+import type { PlayerSession } from './playerSession.js'
+import type { ServerContext } from './systems/context.js'
+import type { HandlerMap, System } from './systems/system.js'
+
+/**
+ * Chat and its Source-style commands (`!r`, `!top`, `!wr`, `!pr`, `!stage`).
+ *
+ * Two rules shape this file:
+ * - A command is only ever text the player typed. The server authorizes and
+ * performs every action; a `!` prefix is a convenience, not a capability.
+ * - Every reply is a chat line (`say`), so command output lives in the same
+ * scrollback as conversation instead of flashing past as a toast.
+ *
+ * `/` commands reply only to the sender; `!` commands reply to the room.
+ * `!r` resets and announces, while `/r` resets silently.
+ */
+
+/** `!`/`/` are stripped; the command word is lower-cased. */
+export interface ParsedChat {
+ /** Lower-cased command without its trigger; null for ordinary chat. */
+ command: string | null
+ args: string[]
+ /** The `/` trigger: feedback goes only to the sender. */
+ silent: boolean
+}
+
+/**
+ * Parse one chat line. SourceMod treats a leading `!` as public and `/` as
+ * silent; ordinary text has no command. Command names are case-insensitive.
+ */
+export function parseChatCommand(text: string): ParsedChat {
+ const trimmed = text.trim()
+ if (!trimmed) return { command: null, args: [], silent: false }
+ const trigger = trimmed[0]
+ if (trigger !== '!' && trigger !== '/') return { command: null, args: [], silent: false }
+ const body = trimmed.slice(1).trim()
+ if (!body) return { command: '', args: [], silent: trigger === '/' }
+ const [command = '', ...args] = body.split(/\s+/)
+ return { command: command.toLowerCase(), args: args.slice(0, 8), silent: trigger === '/' }
+}
+
+export type ChatCommandName = 'r' | 'top' | 'wr' | 'pr' | 'practice' | 'stage' | 'b' | 'help'
+
+/** Source's aliases, kept in one table so parsing and help cannot disagree. */
+const COMMAND_ALIASES: Readonly<Record<string, ChatCommandName>> = {
+ r: 'r',
+ restart: 'r',
+ top: 'top',
+ wr: 'wr',
+ pr: 'pr',
+ practice: 'practice',
+ p: 'practice',
+ stage: 'stage',
+ stages: 'stage',
+ b: 'b',
+ bonus: 'b',
+ help: 'help',
+ h: 'help',
+}
+
+export function canonicalChatCommand(name: string): ChatCommandName | null {
+ return COMMAND_ALIASES[name] ?? null
+}
+
+/** Escape/normalise a chat line before it reaches the wire: one line, no controls. */
+export function cleanChatText(text: string): string {
+ return text
+ .replace(/\p{Cc}/gu, ' ')
+ .trim()
+ .slice(0, CHAT_MAX_LENGTH)
+}
+
+/** `m:ss.mmm`, the same format as the surf timer. */
+export function formatChatTime(ms: number): string {
+ const clamped = Math.max(0, Math.round(ms))
+ const minutes = Math.floor(clamped / 60_000)
+ const seconds = Math.floor((clamped % 60_000) / 1000)
+ const millis = clamped % 1000
+ return `${minutes}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`
+}
+
+/** One second's budget for a normal player: five lines, refilling at 2/s. */
+export const CHAT_RATE_CAPACITY = 5
+export const CHAT_RATE_REFILL_PER_SECOND = 2
+
+/**
+ * Per-player token bucket. Commands and conversation share it, so a client
+ * cannot hammer `!top` (or the database behind it) faster than it can talk.
+ */
+export class ChatRateLimiter {
+ private readonly buckets = new Map<string, { tokens: number; at: number }>()
+
+ constructor(
+ private readonly capacity = CHAT_RATE_CAPACITY,
+ private readonly refillPerSecond = CHAT_RATE_REFILL_PER_SECOND,
+ private readonly now: () => number = Date.now,
+ ) {}
+
+ allow(key: string): boolean {
+ const at = this.now()
+ const bucket = this.buckets.get(key) ?? { tokens: this.capacity, at }
+ const elapsed = Math.max(0, at - bucket.at)
+ bucket.tokens = Math.min(this.capacity, bucket.tokens + (elapsed * this.refillPerSecond) / 1000)
+ bucket.at = at
+ if (bucket.tokens < 1) {
+ this.buckets.set(key, bucket)
+ return false
+ }
+ bucket.tokens -= 1
+ this.buckets.set(key, bucket)
+ return true
+ }
+
+ forget(key: string): void {
+ this.buckets.delete(key)
+ }
+}
+
+/**
+ * The chat system: validates the session, rate-limits, echoes conversation
+ * and runs the surf commands against the same SurfSystem and records seam
+ * the rest of the game uses. It is deliberately a System so the handler
+ * table sees `say`/`say_team` exactly once.
+ */
+export class ChatSystem implements System {
+ readonly name = 'chat'
+ private readonly limiter = new ChatRateLimiter()
+
+ readonly handlers: HandlerMap = {
+ say: (session, msg: ClientSay) => this.receive(session, msg.text, 'say'),
+ say_team: (session, msg: ClientSayTeam) => this.receive(session, msg.text, 'say_team'),
+ }
+
+ constructor(private readonly ctx: ServerContext) {}
+
+ onLeave(session: PlayerSession): void {
+ this.limiter.forget(session.playerId)
+ }
+
+ private receive(session: PlayerSession, raw: string, channel: 'say' | 'say_team'): void {
+ // Until welcome the client is not in the game: no chat, no commands, and
+ // no rate-limit budget to spend.
+ if (!session.welcomed) return
+ if (!this.limiter.allow(session.playerId)) return
+ const text = cleanChatText(raw)
+ if (!text) return
+ const parsed = parseChatCommand(text)
+ if (parsed.command === null) {
+ this.ctx.net.broadcastAll({
+ t: 'say',
+ channel,
+ from: session.name,
+ fromId: session.entityId,
+ text,
+ })
+ return
+ }
+ void this.runCommand(session, parsed).catch((err: unknown) => {
+ this.ctx.log.warn(`chat command failed: ${String(err)}`)
+ this.reply(session, true, 'That command failed. Try again.')
+ })
+ }
+
+ private async runCommand(session: PlayerSession, parsed: ParsedChat): Promise<void> {
+ const command = canonicalChatCommand(parsed.command ?? '')
+ const trigger = parsed.silent ? '/' : '!'
+ if (!command) {
+ this.reply(session, true, `Unknown command: ${trigger}${parsed.command ?? ''}`)
+ return
+ }
+ switch (command) {
+ case 'r':
+ if (!session.surf) {
+ this.reply(session, true, 'Surf is not active here.')
+ return
+ }
+ this.ctx.systems.surf.restart(session)
+ // `/r` is silent: the state reset is its whole reply. `!r` tells the
+ // room, as a public Source command does.
+ if (!parsed.silent) this.reply(session, false, `${session.name} restarted the run.`)
+ return
+ case 'practice':
+ if (!session.surf) {
+ this.reply(session, true, 'Surf is not active here.')
+ return
+ }
+ if (session.surfPractice) {
+ this.ctx.systems.surf.setPractice(session, false)
+ this.reply(
+ session,
+ parsed.silent,
+ `${session.name} left practice — runs are ranked again.`,
+ )
+ } else {
+ this.ctx.systems.surf.setPractice(session, true)
+ this.reply(
+ session,
+ parsed.silent,
+ `${session.name} entered practice — runs are not ranked.`,
+ )
+ }
+ return
+ case 'stage':
+ this.stage(session, parsed)
+ return
+ case 'top':
+ await this.top(session, parsed)
+ return
+ case 'wr':
+ await this.wr(session, parsed)
+ return
+ case 'pr':
+ await this.pr(session, parsed)
+ return
+ case 'b':
+ this.reply(session, parsed.silent, 'No bonuses on this map.')
+ return
+ case 'help':
+ this.replyLines(session, parsed.silent, [
+ 'Commands: !r restart · !top · !wr · !pr · !practice · !stage <n> · !b',
+ 'Use / instead of ! to keep the reply to yourself.',
+ ])
+ return
+ }
+ }
+
+ private stage(session: PlayerSession, parsed: ParsedChat): void {
+ if (!session.surf) {
+ this.reply(session, true, 'Surf is not active here.')
+ return
+ }
+ if (!session.surfPractice) {
+ // Refusal is always private: a failed command should not spam the room.
+ this.reply(session, true, 'Practice mode is off. Type !practice first.')
+ return
+ }
+ const stages = session.surf.map.stages
+ const raw = parsed.args[0]
+ const stage = raw === undefined ? Math.min(session.surf.reached + 1, stages) : Number(raw)
+ if (!Number.isInteger(stage) || stage < 0 || stage > stages) {
+ this.reply(session, true, `Stage must be a whole number from 0 to ${stages}.`)
+ return
+ }
+ if (!this.ctx.systems.surf.goToStage(session, stage)) {
+ this.reply(session, true, `There is no stage ${stage} on this map.`)
+ return
+ }
+ this.reply(session, parsed.silent, `${session.name} moved to stage ${stage}.`)
+ }
+
+ private async top(session: PlayerSession, parsed: ParsedChat): Promise<void> {
+ const mapId = session.surf?.map.id
+ if (!mapId) return this.reply(session, true, 'Surf is not active here.')
+ const records = this.ctx.integrations.surf
+ if (!records) return this.reply(session, true, 'Surf records are unavailable.')
+ const rows = (await records.topRuns(mapId, 10)).slice(0, 10)
+ if (!rows.length) {
+ this.reply(session, parsed.silent, `No times on ${mapId} yet.`)
+ return
+ }
+ this.replyLines(session, parsed.silent, [
+ `Top ${rows.length} — ${mapId}`,
+ ...rows.map((row, i) => `${i + 1}. ${formatChatTime(row.timeMs)} ${row.name}`),
+ ])
+ }
+
+ private async wr(session: PlayerSession, parsed: ParsedChat): Promise<void> {
+ const mapId = session.surf?.map.id
+ if (!mapId) return this.reply(session, true, 'Surf is not active here.')
+ const records = this.ctx.integrations.surf
+ if (!records) return this.reply(session, true, 'Surf records are unavailable.')
+ const wrMs = await records.wrMs(mapId)
+ this.reply(
+ session,
+ parsed.silent,
+ wrMs === null ? `No WR on ${mapId} yet.` : `${mapId} WR: ${formatChatTime(wrMs)}`,
+ )
+ }
+
+ private async pr(session: PlayerSession, parsed: ParsedChat): Promise<void> {
+ const mapId = session.surf?.map.id
+ if (!mapId) return this.reply(session, true, 'Surf is not active here.')
+ const records = this.ctx.integrations.surf
+ if (!records) return this.reply(session, true, 'Surf records are unavailable.')
+ const pb = await records.pbRun(mapId, session.subjectId ?? session.token)
+ this.reply(
+ session,
+ parsed.silent,
+ pb ? `Your PB on ${mapId}: ${formatChatTime(pb.timeMs)}` : `You have no PB on ${mapId} yet.`,
+ )
+ }
+
+ /** One line to the room (`!`) or to the sender only (`/`). */
+ private reply(session: PlayerSession, silent: boolean, text: string): void {
+ const msg: ServerSay = { t: 'say', channel: 'system', text }
+ if (silent) this.ctx.net.send(session, msg)
+ else this.ctx.net.broadcastAll(msg)
+ }
+
+ private replyLines(session: PlayerSession, silent: boolean, lines: readonly string[]): void {
+ for (const line of lines) this.reply(session, silent, line)
+ }
+}
@@ -8,6 +8,7 @@ import type { PlayerSession } from './playerSession.js'
@@ -8,6 +8,7 @@ import type { PlayerSession } from './playerSession.js'
import { ServerContext } from './systems/context.js'
import { ServerContext } from './systems/context.js'
import type { GameConnection, GameIntegrations, ServerSystems } from './systems/context.js'
import type { GameConnection, GameIntegrations, ServerSystems } from './systems/context.js'
import {
import {
+ ChatSystem,
CombatSystem,
CombatSystem,
EconomySystem,
EconomySystem,
EditorSystem,
EditorSystem,
@@ -82,6 +83,7 @@ export class GameServer {
@@ -82,6 +83,7 @@ export class GameServer {
)
)
const ctx = this.ctx
const ctx = this.ctx
const sessions = new SessionsSystem(ctx)
const sessions = new SessionsSystem(ctx)
+ const chat = new ChatSystem(ctx)
const movement = new MovementSystem(ctx)
const movement = new MovementSystem(ctx)
// Runs see this tick's authoritative positions: construct after movement,
// Runs see this tick's authoritative positions: construct after movement,
// insert in the ordered list before replication.
// insert in the ordered list before replication.
@@ -107,6 +109,7 @@ export class GameServer {
@@ -107,6 +109,7 @@ export class GameServer {
this.systems = {
this.systems = {
sessions,
sessions,
+ chat,
movement,
movement,
surf,
surf,
replication,
replication,
@@ -131,6 +134,7 @@ export class GameServer {
@@ -131,6 +134,7 @@ export class GameServer {
ctx.systems = this.systems
ctx.systems = this.systems
this.ordered = [
this.ordered = [
sessions,
sessions,
+ chat,
movement,
movement,
surf,
surf,
replication,
replication,
@@ -98,6 +98,8 @@ export interface PlayerSession {
@@ -98,6 +98,8 @@ export interface PlayerSession {
fallVy: number
fallVy: number
/** Surf run tracker; null outside a surf place. */
/** Surf run tracker; null outside a surf place. */
surf: SurfRunState | null
surf: SurfRunState | null
+ /** Surf practice mode: stage travel and no ranked runs/PB writes. */
+ surfPractice: boolean
/** Cached personal best/world record shown in surf_state. */
/** Cached personal best/world record shown in surf_state. */
surfPb: number | null
surfPb: number | null
surfWr: number | null
surfWr: number | null
@@ -125,6 +127,8 @@ export interface PlayerSession {
@@ -125,6 +127,8 @@ export interface PlayerSession {
interestCells: Set<number>
interestCells: Set<number>
/** Player state changed since last persistence flush. */
/** Player state changed since last persistence flush. */
dirty: boolean
dirty: boolean
+ /** `welcome` was sent; chat is refused before it (the client is not in the game yet). */
+ welcomed: boolean
send(text: string): void
send(text: string): void
closeConnection(code: number, reason: string): void
closeConnection(code: number, reason: string): void
}
}
@@ -187,6 +191,7 @@ export function createSession(init: SessionInit): PlayerSession {
@@ -187,6 +191,7 @@ export function createSession(init: SessionInit): PlayerSession {
openContainer: null,
openContainer: null,
fallVy: 0,
fallVy: 0,
surf: null,
surf: null,
+ surfPractice: false,
surfPb: null,
surfPb: null,
surfWr: null,
surfWr: null,
surfPbSplits: [],
surfPbSplits: [],
@@ -202,6 +207,7 @@ export function createSession(init: SessionInit): PlayerSession {
@@ -202,6 +207,7 @@ export function createSession(init: SessionInit): PlayerSession {
known: new Set(),
known: new Set(),
interestCells: new Set(),
interestCells: new Set(),
dirty: true,
dirty: true,
+ welcomed: false,
send: init.send,
send: init.send,
closeConnection: init.closeConnection,
closeConnection: init.closeConnection,
}
}
@@ -31,6 +31,15 @@ export interface SurfRecordOutcome {
@@ -31,6 +31,15 @@ export interface SurfRecordOutcome {
wrMs: number | null
wrMs: number | null
}
}
+/** One row of a map leaderboard (`!top`), already in time order. */
+export interface SurfLeaderboardRow {
+ id: string
+ name: string
+ timeMs: number
+ createdAt: number
+ isPb: boolean
+}
+
/**
/**
* The persistence/ranking seam the surf system writes runs through. Games'
* The persistence/ranking seam the surf system writes runs through. Games'
* implementation is platform/surfRuns.ts; tests use a fake. The server never
* implementation is platform/surfRuns.ts; tests use a fake. The server never
@@ -44,5 +53,7 @@ export interface SurfRecords {
@@ -44,5 +53,7 @@ export interface SurfRecords {
accountKey: string,
accountKey: string,
): Promise<{ timeMs: number; splits: number[] } | null>
): Promise<{ timeMs: number; splits: number[] } | null>
wrMs(mapId: string): Promise<number | null>
wrMs(mapId: string): Promise<number | null>
+ /** The fastest runs on a map, ascending by time (chat `!top`, max 10 there). */
+ topRuns(mapId: string, limit: number): Promise<SurfLeaderboardRow[]>
record(run: SurfRunRecord): Promise<SurfRecordOutcome>
record(run: SurfRunRecord): Promise<SurfRecordOutcome>
}
}
@@ -92,6 +92,9 @@ function ctxFor(session: any, respawns: string[], sent: unknown[]) {
@@ -92,6 +92,9 @@ function ctxFor(session: any, respawns: string[], sent: unknown[]) {
async wrMs() {
async wrMs() {
return null
return null
},
},
+ async topRuns() {
+ return []
+ },
async record() {
async record() {
return { pb: true, wr: false, rank: null, pbMs: 1000, wrMs: 1000 }
return { pb: true, wr: false, rank: null, pbMs: 1000, wrMs: 1000 }
},
},
@@ -12,6 +12,7 @@ import type { GameEventRecorder } from '../../platform/gameEvents.js'
@@ -12,6 +12,7 @@ import type { GameEventRecorder } from '../../platform/gameEvents.js'
import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'
import type { ProgressSummaryWriter } from '../../platform/progressSummary.js'
import type { ConstraintRecord, GameWorld } from '../gameWorld.js'
import type { ConstraintRecord, GameWorld } from '../gameWorld.js'
import type { PlayerSession } from '../playerSession.js'
import type { PlayerSession } from '../playerSession.js'
+import type { ChatSystem } from '../chatCommands.js'
import type { CombatSystem } from './combat.js'
import type { CombatSystem } from './combat.js'
import type { EconomySystem } from './economy.js'
import type { EconomySystem } from './economy.js'
import type { EditorSystem } from './editor.js'
import type { EditorSystem } from './editor.js'
@@ -191,6 +192,7 @@ export class SessionRegistry {
@@ -191,6 +192,7 @@ export class SessionRegistry {
*/
*/
export interface ServerSystems {
export interface ServerSystems {
sessions: SessionsSystem
sessions: SessionsSystem
+ chat: ChatSystem
movement: MovementSystem
movement: MovementSystem
surf: SurfSystem
surf: SurfSystem
replication: ReplicationSystem
replication: ReplicationSystem
@@ -16,6 +16,7 @@ export { buildHandlerTable } from './system.js'
@@ -16,6 +16,7 @@ export { buildHandlerTable } from './system.js'
export type { HandlerMap, MessageHandler, System } from './system.js'
export type { HandlerMap, MessageHandler, System } from './system.js'
export { SessionsSystem } from './sessions.js'
export { SessionsSystem } from './sessions.js'
+export { ChatSystem } from '../chatCommands.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { SurfSystem } from './surf.js'
export { SurfSystem } from './surf.js'
export { ReplicationSystem } from './replication.js'
export { ReplicationSystem } from './replication.js'
@@ -272,6 +272,9 @@ export class SessionsSystem implements System {
@@ -272,6 +272,9 @@ export class SessionsSystem implements System {
})
})
ctx.sessions.setBody(playerId, bodyId)
ctx.sessions.setBody(playerId, bodyId)
+ // 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
ctx.net.send(session, {
ctx.net.send(session, {
t: 'welcome',
t: 'welcome',
v: msg.v,
v: msg.v,
@@ -22,10 +22,7 @@ export class SurfSystem implements System {
@@ -22,10 +22,7 @@ export class SurfSystem implements System {
readonly handlers: HandlerMap = {
readonly handlers: HandlerMap = {
surf_restart: (session, _msg: ClientSurfRestart) => {
surf_restart: (session, _msg: ClientSurfRestart) => {
- if (!session.surf) return
- const event = session.surf.restart(this.ctx.clock.tick)
- if (event.type === 'restarted') this.teleport(session, event.to, event.yaw)
- this.sendState(session)
+ this.restart(session)
},
},
}
}
@@ -40,6 +37,7 @@ export class SurfSystem implements System {
@@ -40,6 +37,7 @@ export class SurfSystem implements System {
const map = this.runMap()
const map = this.runMap()
if (!map) return
if (!map) return
session.surf = new SurfRunState(map, this.ctx.config.tickRate)
session.surf = new SurfRunState(map, this.ctx.config.tickRate)
+ session.surfPractice = false
session.surfPb = null
session.surfPb = null
session.surfWr = null
session.surfWr = null
session.surfPbSplits = []
session.surfPbSplits = []
@@ -48,11 +46,71 @@ export class SurfSystem implements System {
@@ -48,11 +46,71 @@ export class SurfSystem implements System {
this.sendState(session)
this.sendState(session)
}
}
+ /**
+ * Put the run back at the start line. The same method serves the `R` key's
+ * `surf_restart` message and chat's `!r`/`/r`, so they cannot drift.
+ */
+ restart(session: PlayerSession): boolean {
+ if (!session.surf) return false
+ const event = session.surf.restart(this.ctx.clock.tick)
+ if (event.type === 'restarted') this.teleport(session, event.to, event.yaw)
+ this.sendState(session)
+ return true
+ }
+
+ /**
+ * Enter/leave practice. Both directions restart the run at the start line:
+ * leaving must not resume a clock that was frozen while practising, and
+ * entering must clear any ranked run already in progress.
+ */
+ setPractice(session: PlayerSession, on: boolean): boolean {
+ if (!session.surf) return false
+ if (session.surfPractice === on) return true
+ session.surfPractice = on
+ const event = session.surf.restart(this.ctx.clock.tick)
+ if (event.type === 'restarted') this.teleport(session, event.to, event.yaw)
+ this.sendState(session)
+ return true
+ }
+
+ /** The authored spawn for a stage (0 = start, checkpoints, finish), or null if it does not exist. */
+ stageTarget(
+ session: PlayerSession,
+ stage: number,
+ ): { to: { x: number; y: number; z: number }; yaw: number } | null {
+ const map = session.surf?.map
+ if (!map || !Number.isInteger(stage) || stage < 0 || stage > map.stages) return null
+ const zone =
+ (stage === map.start.stage ? map.start : undefined) ??
+ map.checkpoints.find((z) => z.stage === stage) ??
+ (stage === map.finish.stage ? map.finish : undefined)
+ if (!zone) return null
+ return { to: zone.spawn ?? map.spawn, yaw: zone.yaw ?? map.spawnYaw }
+ }
+
+ /**
+ * Practice-only stage travel. The run is reset to idle (not merely
+ * teleported), so no start/finish crossing and therefore no PB write can
+ * come out of a stage jump.
+ */
+ goToStage(session: PlayerSession, stage: number): boolean {
+ if (!session.surf || !session.surfPractice) return false
+ const target = this.stageTarget(session, stage)
+ if (!target) return false
+ session.surf.restart(this.ctx.clock.tick)
+ this.teleport(session, target.to, target.yaw)
+ this.sendState(session)
+ return true
+ }
+
tick(ctx: ServerContext, _dt: number): void {
tick(ctx: ServerContext, _dt: number): void {
if (!this.enabled) return
if (!this.enabled) return
for (const session of ctx.sessions.values()) {
for (const session of ctx.sessions.values()) {
const run = session.surf
const run = session.surf
if (!run) continue
if (!run) continue
+ // Practice is unranked by construction: the run state machine sees no
+ // segments, so it can neither start nor finish (and write) a run.
+ if (session.surfPractice) continue
for (const segment of session.moveSegments) {
for (const segment of session.moveSegments) {
const events = run.observe(ctx.clock.tick, segment.from, segment.to, segment.yaw)
const events = run.observe(ctx.clock.tick, segment.from, segment.to, segment.yaw)
for (const event of events) {
for (const event of events) {
@@ -113,6 +171,9 @@ export class SurfSystem implements System {
@@ -113,6 +171,9 @@ export class SurfSystem implements System {
}
}
private async finish(session: PlayerSession, result: SurfRunResult): Promise<void> {
private async finish(session: PlayerSession, result: SurfRunResult): Promise<void> {
+ // Defence in depth: the tick already skips practice, but a finish queued
+ // just before practice was enabled must never become a ranked result.
+ if (session.surfPractice) return
const records = this.ctx.integrations.surf
const records = this.ctx.integrations.surf
let pb = session.surfPb === null || result.timeMs < session.surfPb
let pb = session.surfPb === null || result.timeMs < session.surfPb
let wr = session.surfWr === null || result.timeMs < session.surfWr
let wr = session.surfWr === null || result.timeMs < session.surfWr
@@ -192,6 +253,7 @@ export class SurfSystem implements System {
@@ -192,6 +253,7 @@ export class SurfSystem implements System {
splits: run.splitTimes,
splits: run.splitTimes,
pbMs: session.surfPb,
pbMs: session.surfPb,
wrMs: session.surfWr,
wrMs: session.surfWr,
+ practice: session.surfPractice,
...(session.surfPbSplits.length ? { pbSplits: session.surfPbSplits } : {}),
...(session.surfPbSplits.length ? { pbSplits: session.surfPbSplits } : {}),
})
})
}
}
@@ -98,6 +98,28 @@ describe('surf runs persistence', () => {
@@ -98,6 +98,28 @@ describe('surf runs persistence', () => {
expect(await records.ghostBytes(MAP, 'pb', 'guest:unknown')).toBeNull()
expect(await records.ghostBytes(MAP, 'pb', 'guest:unknown')).toBeNull()
})
})
+ it('returns the top ten in ascending time for chat', async () => {
+ const db = await openTestDb()
+ const records = new PgSurfRecords(db)
+ for (let i = 0; i < 12; i++) {
+ await records.record(
+ run({
+ accountKey: `guest:racer-${i}`,
+ playerName: `Racer ${i}`,
+ timeMs: 20_000 - i * 500,
+ createdAt: 1_000 + i,
+ splits: [20_000 - i * 500],
+ }),
+ )
+ }
+ const top = await records.topRuns(MAP, 10)
+ expect(top).toHaveLength(10)
+ const times = top.map((r) => r.timeMs)
+ expect(times).toEqual([...times].sort((a, b) => a - b))
+ expect(times[0]).toBe(14_500)
+ expect(times.at(-1)).toBe(19_000)
+ })
+
it('play: defines and posts the `time` board with the run ms', async () => {
it('play: defines and posts the `time` board with the run ms', async () => {
const db = await openTestDb()
const db = await openTestDb()
const play = fakePlay()
const play = fakePlay()
@@ -27,6 +27,8 @@ describe('protocol codec', () => {
@@ -27,6 +27,8 @@ describe('protocol codec', () => {
{ t: 'physgun', a: 'grab' },
{ t: 'physgun', a: 'grab' },
{ t: 'physgun', a: 'rotate', dyaw: 0.1, dpitch: -0.1, snap: true },
{ t: 'physgun', a: 'rotate', dyaw: 0.1, dpitch: -0.1, snap: true },
{ t: 'physgun', a: 'grid', on: true },
{ t: 'physgun', a: 'grid', on: true },
+ { t: 'say', text: 'hello room' },
+ { t: 'say_team', text: 'team line' },
]
]
for (const msg of messages) {
for (const msg of messages) {
expect(decodeClientMessage(encodeClientMessage(msg))).toEqual(msg)
expect(decodeClientMessage(encodeClientMessage(msg))).toEqual(msg)
@@ -50,6 +52,9 @@ describe('protocol codec', () => {
@@ -50,6 +52,9 @@ describe('protocol codec', () => {
'{"t":"input","seq":1,"moveX":5,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',
'{"t":"input","seq":1,"moveX":5,"moveZ":0,"yawQ":0,"pitchQ":0,"intents":0}',
),
),
).toBeNull()
).toBeNull()
+ // Empty and oversized chat is refused at the boundary.
+ expect(decodeClientMessage('{"t":"say","text":" "}')).toBeNull()
+ expect(decodeClientMessage(JSON.stringify({ t: 'say', text: 'x'.repeat(241) }))).toBeNull()
})
})
it('decodes an older hello without a digest for a typed join rejection', () => {
it('decodes an older hello without a digest for a typed join rejection', () => {
@@ -10,6 +10,9 @@ import { ClientSurfRestartSchema } from './surf.js'
@@ -10,6 +10,9 @@ import { ClientSurfRestartSchema } from './surf.js'
*/
*/
export const MAX_CHARACTERS = 24
export const MAX_CHARACTERS = 24
+/** Longest chat line the wire accepts (a Source `say` fits comfortably). */
+export const CHAT_MAX_LENGTH = 240
+
/**
/**
* Client -> server messages, defined as zod schemas because the server must
* Client -> server messages, defined as zod schemas because the server must
* treat every inbound byte as hostile. Types are inferred from schemas so
* treat every inbound byte as hostile. Types are inferred from schemas so
@@ -300,6 +303,21 @@ export const ClientContainerSortSchema = z.object({
@@ -300,6 +303,21 @@ export const ClientContainerSortSchema = z.object({
target: z.string().max(32),
target: z.string().max(32),
})
})
+/**
+ * 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.
+ */
+export const ClientSaySchema = z.object({
+ t: z.literal('say'),
+ text: z.string().trim().min(1).max(CHAT_MAX_LENGTH),
+})
+
+/** Source's `say_team`: team chat. Surf rooms are one team, so it reaches the room. */
+export const ClientSayTeamSchema = z.object({
+ t: z.literal('say_team'),
+ text: z.string().trim().min(1).max(CHAT_MAX_LENGTH),
+})
+
export const ClientMessageSchema = z.union([
export const ClientMessageSchema = z.union([
ClientTransferSchema,
ClientTransferSchema,
ClientEditModeSchema,
ClientEditModeSchema,
@@ -329,6 +347,8 @@ export const ClientMessageSchema = z.union([
@@ -329,6 +347,8 @@ export const ClientMessageSchema = z.union([
ClientContainerOpenSchema,
ClientContainerOpenSchema,
ClientContainerMoveSchema,
ClientContainerMoveSchema,
ClientContainerSortSchema,
ClientContainerSortSchema,
+ ClientSaySchema,
+ ClientSayTeamSchema,
ClientSurfRestartSchema,
ClientSurfRestartSchema,
])
])
@@ -360,5 +380,7 @@ export type ClientJobTurnIn = z.infer<typeof ClientJobTurnInSchema>
@@ -360,5 +380,7 @@ export type ClientJobTurnIn = z.infer<typeof ClientJobTurnInSchema>
export type ClientContainerOpen = z.infer<typeof ClientContainerOpenSchema>
export type ClientContainerOpen = z.infer<typeof ClientContainerOpenSchema>
export type ClientContainerMove = z.infer<typeof ClientContainerMoveSchema>
export type ClientContainerMove = z.infer<typeof ClientContainerMoveSchema>
export type ClientContainerSort = z.infer<typeof ClientContainerSortSchema>
export type ClientContainerSort = z.infer<typeof ClientContainerSortSchema>
+export type ClientSay = z.infer<typeof ClientSaySchema>
+export type ClientSayTeam = z.infer<typeof ClientSayTeamSchema>
export type { ClientSurfRestart } from './surf.js'
export type { ClientSurfRestart } from './surf.js'
export type ClientMessage = z.infer<typeof ClientMessageSchema>
export type ClientMessage = z.infer<typeof ClientMessageSchema>
@@ -233,6 +233,21 @@ export interface ServerAnnounce {
@@ -233,6 +233,21 @@ export interface ServerAnnounce {
text: string
text: string
}
}
+/**
+ * One chat line. Player lines carry `from`; server/command lines are
+ * `channel: 'system'` with no speaker. `text` is plain text — clients render
+ * it with textContent, never as markup.
+ */
+export interface ServerSay {
+ t: 'say'
+ channel: 'say' | 'say_team' | 'system'
+ text: string
+ /** Speaker display name; absent on system lines. */
+ from?: string
+ /** Speaker entity id; absent on system lines. */
+ fromId?: string
+}
+
/** Market data for an opened trading post (refreshed after each deal). */
/** Market data for an opened trading post (refreshed after each deal). */
export interface ServerMarket {
export interface ServerMarket {
t: 'market'
t: 'market'
@@ -304,6 +319,7 @@ export type ServerMessage =
@@ -304,6 +319,7 @@ export type ServerMessage =
| ServerReputation
| ServerReputation
| ServerJobs
| ServerJobs
| ServerAnnounce
| ServerAnnounce
+ | ServerSay
| ServerTracer
| ServerTracer
| ServerFx
| ServerFx
| ServerMapReload
| ServerMapReload
@@ -37,6 +37,8 @@ export const SurfStateSchema = z.object({
@@ -37,6 +37,8 @@ export const SurfStateSchema = z.object({
wrMs: timeMs.nullable(),
wrMs: timeMs.nullable(),
/** The PB's split times, for the per-split delta (absent until one exists). */
/** The PB's split times, for the per-split delta (absent until one exists). */
pbSplits: splitList.optional(),
pbSplits: splitList.optional(),
+ /** Practice mode is on: the run clock and PB writes are off. */
+ practice: z.boolean().optional(),
})
})
export type ServerSurfState = z.infer<typeof SurfStateSchema>
export type ServerSurfState = z.infer<typeof SurfStateSchema>
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/ui/chat.test.ts'
+// covers: 'apps/client/src/ui'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/game/chat.integration.test.ts'
+// covers: 'apps/server/src/game'
+// Generated by node test/run.js --index; do not edit.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/server/src/game/chatCommands.test.ts'
+// covers: 'apps/server/src/game'
+// Generated by node test/run.js --index; do not edit.