openvibers/openvibe.games Commits

Source-style chat and surf commands (#196)

OP OpenVibers committed 16 days ago · 12cf25cc0d · 25 files, +1295 −4

* Source-style chat and surf commands: say/say_team, !r !top !wr !pr !practice !stage, fading lines

* test index: the chat tests

Changes

ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 39fa687..751573c 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -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 = null
let firstStepsRef: FirstSteps | null = null
ddiff --git a/apps/client/src/state/clientState.ts b/apps/client/src/state/clientState.ts
iindex 8f62dc5..8a5ad69 100644
---- a/apps/client/src/state/clientState.ts
++++ b/apps/client/src/state/clientState.ts
@@@ -46,6 +46,8 @@ export interface ClientStateEvents {
weather: string
container: { 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: unknown
jobs: unknown
reputation: { 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)
++ break
case 'market':
this.events.emit('market', msg)
break
ddiff --git a/apps/client/src/ui/chat.css b/apps/client/src/ui/chat.css
nnew file mode 100644
iindex 0000000..e95fa17
---- /dev/null
++++ b/apps/client/src/ui/chat.css
@@@ -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);
++ }
++}
ddiff --git a/apps/client/src/ui/chat.test.ts b/apps/client/src/ui/chat.test.ts
nnew file mode 100644
iindex 0000000..55bfc63
---- /dev/null
++++ b/apps/client/src/ui/chat.test.ts
@@@ -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')
++ })
++})
ddiff --git a/apps/client/src/ui/chat.ts b/apps/client/src/ui/chat.ts
nnew file mode 100644
iindex 0000000..124a02b
---- /dev/null
++++ b/apps/client/src/ui/chat.ts
@@@ -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()
++ }
++}
ddiff --git a/apps/client/src/ui/hud.ts b/apps/client/src/ui/hud.ts
iindex b153477..0a19521 100644
---- a/apps/client/src/ui/hud.ts
++++ b/apps/client/src/ui/hud.ts
@@@ -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!: HTMLElement
private toastArea!: HTMLElement
++ /** Source-style chat (Y/U); the HUD owns its mount and capture handoff. */
++ readonly chat: Chat
menuOpen = false
private activeTab: MenuTab = 'inventory'
private dragFrom: number | null = null
@@@ -63,6 +66,8 @@ export class Hud {
) {
this.root = root
this.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.menuOpen
this.menuEl.style.display = this.menuOpen ? 'flex' : 'none'
this.renderTabs()
ddiff --git a/apps/server/src/game/chat.integration.test.ts b/apps/server/src/game/chat.integration.test.ts
nnew file mode 100644
iindex 0000000..46342a3
---- /dev/null
++++ b/apps/server/src/game/chat.integration.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/game/chatCommands.test.ts b/apps/server/src/game/chatCommands.test.ts
nnew file mode 100644
iindex 0000000..8630291
---- /dev/null
++++ b/apps/server/src/game/chatCommands.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/game/chatCommands.ts b/apps/server/src/game/chatCommands.ts
nnew file mode 100644
iindex 0000000..ebd5f02
---- /dev/null
++++ b/apps/server/src/game/chatCommands.ts
@@@ -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)
++ }
++}
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 5dcffc7..2177a74 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -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.ctx
const 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.systems
this.ordered = [
sessions,
++ chat,
movement,
surf,
replication,
ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex 47157ec..213eb7d 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -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 | null
surfWr: 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: boolean
send(text: string): void
closeConnection(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,
}
ddiff --git a/apps/server/src/game/surfRecords.ts b/apps/server/src/game/surfRecords.ts
iindex dfd2b29..ca70b1d 100644
---- a/apps/server/src/game/surfRecords.ts
++++ b/apps/server/src/game/surfRecords.ts
@@@ -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>
}
ddiff --git a/apps/server/src/game/surfRules.test.ts b/apps/server/src/game/surfRules.test.ts
iindex c91f1c2..e4f41c3 100644
---- a/apps/server/src/game/surfRules.test.ts
++++ b/apps/server/src/game/surfRules.test.ts
@@@ -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 }
},
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex 3e45d0e..982771d 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -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: ChatSystem
movement: MovementSystem
surf: SurfSystem
replication: ReplicationSystem
ddiff --git a/apps/server/src/game/systems/index.ts b/apps/server/src/game/systems/index.ts
iindex 28d60b4..b7948a1 100644
---- a/apps/server/src/game/systems/index.ts
++++ b/apps/server/src/game/systems/index.ts
@@@ -16,6 +16,7 @@ export { buildHandlerTable } from './system.js'
export type { HandlerMap, MessageHandler, System } from './system.js'
export { SessionsSystem } from './sessions.js'
++export { ChatSystem } from '../chatCommands.js'
export { MovementSystem, MOVE, MAX_INPUT_QUEUE } from './movement.js'
export { SurfSystem } from './surf.js'
export { ReplicationSystem } from './replication.js'
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 4676f5e..7523028 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -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 = true
ctx.net.send(session, {
t: 'welcome',
v: msg.v,
ddiff --git a/apps/server/src/game/systems/surf.ts b/apps/server/src/game/systems/surf.ts
iindex 70d8c2c..13df7de 100644
---- a/apps/server/src/game/systems/surf.ts
++++ b/apps/server/src/game/systems/surf.ts
@@@ -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) return
session.surf = new SurfRunState(map, this.ctx.config.tickRate)
++ session.surfPractice = false
session.surfPb = null
session.surfWr = null
session.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) return
for (const session of ctx.sessions.values()) {
const run = session.surf
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) {
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) return
const records = this.ctx.integrations.surf
let pb = session.surfPb === null || result.timeMs < session.surfPb
let 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 } : {}),
})
}
ddiff --git a/apps/server/src/platform/surfRuns.test.ts b/apps/server/src/platform/surfRuns.test.ts
iindex 6211b6e..0574a32 100644
---- a/apps/server/src/platform/surfRuns.test.ts
++++ b/apps/server/src/platform/surfRuns.test.ts
@@@ -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()
ddiff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts
iindex 80e25ea..0a4d585 100644
---- a/packages/protocol/src/codec.test.ts
++++ b/packages/protocol/src/codec.test.ts
@@@ -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', () => {
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex 807471f..fd06345 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -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>
ddiff --git a/packages/protocol/src/messages/server.ts b/packages/protocol/src/messages/server.ts
iindex ff055d7..fb9b875 100644
---- a/packages/protocol/src/messages/server.ts
++++ b/packages/protocol/src/messages/server.ts
@@@ -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
ddiff --git a/packages/protocol/src/messages/surf.ts b/packages/protocol/src/messages/surf.ts
iindex b36dddd..9d2aef1 100644
---- a/packages/protocol/src/messages/surf.ts
++++ b/packages/protocol/src/messages/surf.ts
@@@ -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>
ddiff --git a/test/apps__client__src__ui__chat.test.js b/test/apps__client__src__ui__chat.test.js
nnew file mode 100644
iindex 0000000..5e23295
---- /dev/null
++++ b/test/apps__client__src__ui__chat.test.js
@@@ -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.
ddiff --git a/test/apps__server__src__game__chat.integration.test.js b/test/apps__server__src__game__chat.integration.test.js
nnew file mode 100644
iindex 0000000..34c794f
---- /dev/null
++++ b/test/apps__server__src__game__chat.integration.test.js
@@@ -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.
ddiff --git a/test/apps__server__src__game__chatCommands.test.js b/test/apps__server__src__game__chatCommands.test.js
nnew file mode 100644
iindex 0000000..6d2eb6e
---- /dev/null
++++ b/test/apps__server__src__game__chatCommands.test.js
@@@ -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.