import './styles.css'; import { AudioEngine } from './audio/audioEngine'; import { EFFECT_SEQUENCE, } from './audio/effects'; import { RadioStationRuntime, getProxyUrlForStream, shouldProxyStreamUrl, } from './audio/radioStationRuntime'; import { ItemEmitRuntime } from './audio/itemEmitRuntime'; import { ClockAnnouncer } from './audio/clockAnnouncer'; import { normalizeDegrees } from './audio/spatial'; import { applyPastedText, applyTextInput, describeBackspaceDeletedCharacter, describeDeleteDeletedCharacter, describeCursorCharacter, describeCursorWordOrCharacter, mapTextInputKey, moveCursorWordLeft, moveCursorWordRight, shouldReplaceCurrentText, } from './input/textInput'; import { formatCommandMenuLabel, type CommandDescriptor, type ModeInput } from './input/commandTypes'; import { getAvailableMainModeCommands } from './input/mainModeCommands'; import { resolveMainModeCommand, type MainModeCommand } from './input/mainCommandRouter'; import { dispatchModeInput } from './input/modeDispatcher'; import { handleListControlKey } from './input/listController'; import { createAdminController, type AdminMenuAction } from './input/adminController'; import { setupKeyboardInputHandlers } from './input/keyboardController'; import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu'; import { getEditSessionAction } from './input/editSession'; import { formatSteppedNumber, snapNumberToStep } from './input/numeric'; import { type IncomingMessage, type OutgoingMessage } from './network/protocol'; import { createOnMessageHandler } from './network/messageHandlers'; import { SignalingClient } from './network/signalingClient'; import { CanvasRenderer } from './render/canvasRenderer'; import { GRID_SIZE, MOVE_COOLDOWN_MS, createInitialState, getDirection, getNearestItem, getNearestPeer, type GameMode, type WorldItem, } from './state/gameState'; import { applyServerItemUiDefinitions, getItemManagementActionMetadata, getServerMainModeCommandMetadata, getItemTypeGlobalProperties, getItemTypeSequence, getEditableItemPropertyKeys, getInspectItemPropertyKeys, getItemPropertyOptionValues, getItemPropertyMetadata, itemPropertyLabel, getItemTypeTooltip, itemTypeLabel, } from './items/itemRegistry'; import { createItemInteractionController } from './items/itemInteractionController'; import { createItemPropertyEditor } from './items/itemPropertyEditor'; import { createItemPropertyPresentation } from './items/itemPropertyPresentation'; import { ItemBehaviorRegistry } from './items/types/behaviorRegistry'; import { SettingsStore } from './settings/settingsStore'; import { createAuthController } from './session/authController'; import { runConnectFlow, runDisconnectFlow, type ConnectFlowDeps } from './session/connectionFlow'; import { MediaSession } from './session/mediaSession'; import { type AudioLayerState } from './types/audio'; import { setupUiHandlers as setupDomUiHandlers } from './ui/domBindings'; import { PeerManager } from './webrtc/peerManager'; const NICKNAME_MAX_LENGTH = 32; const MIC_CALIBRATION_DURATION_MS = 5000; const MIC_CALIBRATION_SAMPLE_INTERVAL_MS = 50; const MIC_CALIBRATION_MIN_GAIN = 0.5; const MIC_CALIBRATION_MAX_GAIN = 4; const MIC_CALIBRATION_TARGET_RMS = 0.12; const MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD = 0.003; const MIC_INPUT_GAIN_SCALE_MULTIPLIER = 2; const MIC_INPUT_GAIN_STEP = 0.05; const HEARTBEAT_INTERVAL_MS = 10_000; const RECONNECT_DELAY_MS = 5_000; const RECONNECT_MAX_ATTEMPTS = 3; const AUDIO_SUBSCRIPTION_REFRESH_MS = 500; const TELEPORT_SQUARES_PER_SECOND = 20; const AUTH_POLICY_STORAGE_KEY = 'chgridAuthPolicy'; declare global { interface Window { CHGRID_RELEASE_VERSION?: string; CHGRID_BUILD_REVISION?: string; CHGRID_WEB_VERSION?: string; } } type Dom = { connectionStatus: HTMLElement; appVersion: HTMLElement; loginView: HTMLElement; registerView: HTMLElement; authUsername: HTMLInputElement; authPassword: HTMLInputElement; registerUsername: HTMLInputElement; registerPassword: HTMLInputElement; registerPasswordConfirm: HTMLInputElement; registerEmail: HTMLInputElement; authPolicyHintRegister: HTMLParagraphElement; authSessionView: HTMLElement; authSessionText: HTMLParagraphElement; authModeSeparator: HTMLElement; showRegisterButton: HTMLButtonElement; updatesSection: HTMLElement; updatesToggle: HTMLButtonElement; updatesPanel: HTMLDivElement; connectButton: HTMLButtonElement; logoutButton: HTMLButtonElement; disconnectButton: HTMLButtonElement; focusGridButton: HTMLButtonElement; settingsButton: HTMLButtonElement; closeSettingsButton: HTMLButtonElement; settingsModal: HTMLDivElement; audioInputSelect: HTMLSelectElement; audioOutputSelect: HTMLSelectElement; audioInputCurrent: HTMLParagraphElement; audioOutputCurrent: HTMLParagraphElement; canvas: HTMLCanvasElement; status: HTMLDivElement; instructions: HTMLDivElement; }; const dom: Dom = { connectionStatus: requiredById('connectionStatus'), appVersion: requiredById('appVersion'), loginView: requiredById('loginView'), registerView: requiredById('registerView'), authUsername: requiredById('authUsername'), authPassword: requiredById('authPassword'), registerUsername: requiredById('registerUsername'), registerPassword: requiredById('registerPassword'), registerPasswordConfirm: requiredById('registerPasswordConfirm'), registerEmail: requiredById('registerEmail'), authPolicyHintRegister: requiredById('authPolicyHintRegister'), authSessionView: requiredById('authSessionView'), authSessionText: requiredById('authSessionText'), authModeSeparator: requiredById('authModeSeparator'), showRegisterButton: requiredById('showRegisterButton'), updatesSection: requiredById('updatesSection'), updatesToggle: requiredById('updatesToggle'), updatesPanel: requiredById('updatesPanel'), connectButton: requiredById('connectButton'), logoutButton: requiredById('logoutButton'), disconnectButton: requiredById('disconnectButton'), focusGridButton: requiredById('focusGridButton'), settingsButton: requiredById('settingsButton'), closeSettingsButton: requiredById('closeSettingsButton'), settingsModal: requiredById('settingsModal'), audioInputSelect: requiredById('audioInputSelect'), audioOutputSelect: requiredById('audioOutputSelect'), audioInputCurrent: requiredById('audioInputCurrent'), audioOutputCurrent: requiredById('audioOutputCurrent'), canvas: requiredById('gameCanvas'), status: requiredById('status'), instructions: requiredById('instructions'), }; type ChangelogSection = { date: string; items: string[]; }; type ChangelogData = { sections: ChangelogSection[]; }; type HelpItem = { keys: string; description: string; }; type HelpSection = { title: string; items: HelpItem[]; }; type HelpData = { sections: HelpSection[]; }; /** Builds linearized help-view lines from sectioned help content. */ function buildHelpLines(help: HelpData): string[] { const lines: string[] = []; for (const section of help.sections) { lines.push(section.title); for (const item of section.items) { lines.push(`${item.keys}: ${item.description}`); } } return lines; } /** Announces standardized menu entry as `Title. First option.` */ function announceMenuEntry(title: string, firstOption: string): void { const trimmedTitle = title.trim(); const trimmedOption = firstOption.trim(); const titleSuffix = /[.!?]$/.test(trimmedTitle) ? '' : '.'; const optionSuffix = /[.!?]$/.test(trimmedOption) ? '' : '.'; updateStatus(`${trimmedTitle}${titleSuffix} ${trimmedOption}${optionSuffix}`); audio.sfxUiBlip(); } const APP_RELEASE_VERSION = String(window.CHGRID_RELEASE_VERSION ?? '').trim(); const APP_BUILD_REVISION = String(window.CHGRID_BUILD_REVISION ?? '').trim(); const APP_VERSION = String( window.CHGRID_WEB_VERSION ?? [APP_RELEASE_VERSION, APP_BUILD_REVISION].filter((value) => value.length > 0).join(' ') ).trim(); dom.appVersion.textContent = APP_VERSION ? `Another AI experiment with Jage. Version ${APP_VERSION}` : 'Another AI experiment with Jage. Version unknown'; const APP_BASE_URL = import.meta.env.BASE_URL || '/'; /** Resolves an app-relative path against the configured Vite base path. */ function withBase(path: string): string { const normalizedBase = APP_BASE_URL.endsWith('/') ? APP_BASE_URL : `${APP_BASE_URL}/`; return `${normalizedBase}${path.replace(/^\/+/, '')}`; } const SYSTEM_SOUND_URLS = { logon: withBase('sounds/logon.ogg'), logout: withBase('sounds/logout.ogg'), notify: withBase('sounds/notify.ogg'), } as const; const AUTH_SESSION_COOKIE_SET_URL = '/auth/session/set'; const AUTH_SESSION_COOKIE_CLEAR_URL = '/auth/session/clear'; const AUTH_SESSION_COOKIE_CLIENT_HEADER = 'X-Chgrid-Auth-Client'; const ACTION_SOUND_URL = withBase('sounds/action.ogg'); const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`)); const FOOTSTEP_GAIN = 0.7; const TELEPORT_START_SOUND_URL = withBase('sounds/teleport_start.ogg'); const TELEPORT_START_GAIN = 0.1; const TELEPORT_SOUND_URL = withBase('sounds/teleport.ogg'); const WALL_SOUND_URL = withBase('sounds/wall.ogg'); const state = createInitialState(); const renderer = new CanvasRenderer(dom.canvas); const audio = new AudioEngine(); const settings = new SettingsStore(); let worldGridSize = GRID_SIZE; let movementTickMs = MOVE_COOLDOWN_MS; let lastWallCollisionDirection: string | null = null; let statusTimeout: number | null = null; let lastFocusedElement: Element | null = null; let lastAnnouncementText = ''; let lastAnnouncementAt = 0; let outputMode = settings.loadOutputMode(); const messageBuffer: string[] = []; let messageCursor = -1; const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig); const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl, getItemSpatialConfig); const clockAnnouncer = new ClockAnnouncer(audio, () => ({ x: state.player.x, y: state.player.y })); let replaceTextOnNextType = false; let pendingEscapeDisconnect = false; let micGainLoopbackRestoreState: boolean | null = null; let mainHelpViewerLines: string[] = []; let helpViewerLines: string[] = []; let helpViewerIndex = 0; let helpViewerReturnMode: GameMode = 'normal'; const commandPaletteCommands: Array void | Promise }> = []; let commandPaletteIndex = 0; let commandPaletteReturnMode: GameMode = 'normal'; let heartbeatTimerId: number | null = null; let heartbeatNextPingId = -1; let heartbeatAwaitingPong = false; let reconnectInFlight = false; let activeServerInstanceId: string | null = null; let reloadScheduledForVersionMismatch = false; let peerNegotiationReady = false; let pendingSignalMessages: Array> = []; let peerListenGainByNickname = settings.loadPeerListenGains(); let audioLayers: AudioLayerState = { voice: true, item: true, media: true, world: true, }; let lastSubscriptionRefreshAt = 0; let lastSubscriptionRefreshTileX = Math.round(state.player.x); let lastSubscriptionRefreshTileY = Math.round(state.player.y); let subscriptionRefreshInFlight = false; let subscriptionRefreshPending = false; let suppressItemPropertyEchoUntilMs = 0; let activeTeleportLoopStop: (() => void) | null = null; let activeTeleportLoopToken = 0; let activeTeleport: | { startX: number; startY: number; targetX: number; targetY: number; startedAtMs: number; durationMs: number; lastSyncAtMs: number; lastSentX: number; lastSentY: number; completionStatus: string; } | null = null; const signalingProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const signalingUrl = `${signalingProtocol}://${window.location.host}/ws`; const signaling = new SignalingClient(signalingUrl, handleSignalingStatus); const peerManager = new PeerManager( audio, (targetId, payload) => { signaling.send({ type: 'signal', targetId, ...payload }); }, () => mediaSession.getOutboundStream(), updateStatus, ); const mediaSession = new MediaSession({ state, audio, peerManager, settings, dom, updateStatus, micCalibrationDurationMs: MIC_CALIBRATION_DURATION_MS, micCalibrationSampleIntervalMs: MIC_CALIBRATION_SAMPLE_INTERVAL_MS, micCalibrationMinGain: MIC_CALIBRATION_MIN_GAIN, micCalibrationMaxGain: MIC_CALIBRATION_MAX_GAIN, micCalibrationTargetRms: MIC_CALIBRATION_TARGET_RMS, micCalibrationActiveRmsThreshold: MIC_CALIBRATION_ACTIVE_RMS_THRESHOLD, micInputGainScaleMultiplier: MIC_INPUT_GAIN_SCALE_MULTIPLIER, micInputGainStep: MIC_INPUT_GAIN_STEP, }); const itemBehaviorRegistry = new ItemBehaviorRegistry({ state, audio, signalingSend: (message) => signaling.send(message), updateStatus, openHelpViewer: (lines, returnMode) => openHelpViewer(lines, returnMode), withBase, }); audio.setOutputMode(outputMode); loadEffectLevels(); loadAudioLayerState(); loadMicInputGain(); loadMasterVolume(); void loadHelp(); void itemBehaviorRegistry.initialize(); void loadChangelog(); /** Fetches a required DOM element and casts it to the requested element type. */ function requiredById(id: string): T { const found = document.getElementById(id); if (!found) { throw new Error(`Missing element: ${id}`); } return found as T; } const itemPropertyPresentation = createItemPropertyPresentation(); const getItemPropertyValue = itemPropertyPresentation.getItemPropertyValue; const isItemPropertyEditable = itemPropertyPresentation.isItemPropertyEditable; const describeItemPropertyHelp = itemPropertyPresentation.describeItemPropertyHelp; const validateNumericItemPropertyInput = itemPropertyPresentation.validateNumericItemPropertyInput; const authController = createAuthController({ dom, authPolicyStorageKey: AUTH_POLICY_STORAGE_KEY, authSessionCookieSetUrl: AUTH_SESSION_COOKIE_SET_URL, authSessionCookieClearUrl: AUTH_SESSION_COOKIE_CLEAR_URL, authSessionCookieClientHeader: AUTH_SESSION_COOKIE_CLIENT_HEADER, initialAuthUsername: settings.loadAuthUsername(), isRunning: () => state.running, isMuted: () => state.isMuted, isConnecting: () => mediaSession.isConnecting(), setConnecting: (value) => mediaSession.setConnecting(value), applyMuteToTrack: (muted) => { mediaSession.applyMuteToTrack(muted); }, signalingSend: (message) => signaling.send(message), disconnect, saveAuthUsername: (username) => { settings.saveAuthUsername(username); }, setConnectionStatus, updateStatus, pushChatMessage, onServerAdminMenuActions: (actions) => { adminController.setServerAdminMenuActions(actions); }, }); const adminController = createAdminController({ state, signalingSend: (message) => signaling.send(message), announceMenuEntry, updateStatus, sfxUiBlip: () => audio.sfxUiBlip(), sfxUiCancel: () => audio.sfxUiCancel(), applyTextInputEdit, setReplaceTextOnNextType: (value) => { replaceTextOnNextType = value; }, }); const itemInteractionController = createItemInteractionController({ state, signalingSend: (message) => signaling.send(message), announceMenuEntry, updateStatus, sfxUiBlip: () => audio.sfxUiBlip(), sfxUiCancel: () => audio.sfxUiCancel(), hasPermission: (key) => authController.hasPermission(key), getAuthUserId: () => authController.getAuthUserId(), getItemManagementActionMetadata, itemLabel, getEditableItemPropertyKeys, getInspectItemPropertyKeys, getItemPropertyValue, itemPropertyLabel, useItem: (item) => useItem(item), secondaryUseItem: (item) => secondaryUseItem(item), }); /** Toggles updates panel visibility and syncs associated ARIA state. */ function setUpdatesExpanded(expanded: boolean): void { dom.updatesToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); dom.updatesToggle.textContent = expanded ? 'Hide updates' : 'Show updates'; dom.updatesPanel.hidden = !expanded; dom.updatesPanel.classList.toggle('hidden', !expanded); } /** Renders help sections into the footer help container and builds linearized viewer lines. */ function renderHelp(help: HelpData): void { const lines = buildHelpLines(help); dom.instructions.innerHTML = ''; const heading = document.createElement('h2'); heading.textContent = 'Help'; dom.instructions.appendChild(heading); for (const section of help.sections) { const sectionHeading = document.createElement('h3'); sectionHeading.textContent = section.title; dom.instructions.appendChild(sectionHeading); for (const item of section.items) { const line = document.createElement('p'); const keys = document.createElement('b'); keys.textContent = `${item.keys}:`; line.appendChild(keys); line.append(` ${item.description}`); dom.instructions.appendChild(line); } } mainHelpViewerLines = lines; helpViewerLines = lines; helpViewerIndex = 0; } /** Loads runtime help content from `help.json` and applies it when available. */ async function loadHelp(): Promise { try { const response = await fetch(withBase('help.json'), { cache: 'no-store' }); if (!response.ok) { return; } const help = (await response.json()) as HelpData; if (!Array.isArray(help.sections) || help.sections.length === 0) { return; } renderHelp(help); } catch { // Keep existing/static help if loading fails. } } /** Renders changelog sections into the collapsible updates panel. */ function renderChangelog(changelog: ChangelogData): void { dom.updatesPanel.innerHTML = ''; for (const section of changelog.sections) { const heading = document.createElement('h3'); heading.textContent = section.date; dom.updatesPanel.appendChild(heading); const list = document.createElement('ul'); for (const item of section.items) { const li = document.createElement('li'); li.textContent = item; list.appendChild(li); } dom.updatesPanel.appendChild(list); } } /** Loads changelog entries from `changelog.json` and wires the panel toggle button. */ async function loadChangelog(): Promise { try { const response = await fetch(withBase('changelog.json'), { cache: 'no-store' }); if (!response.ok) { dom.updatesSection.classList.add('hidden'); return; } const changelog = (await response.json()) as ChangelogData; if (!Array.isArray(changelog.sections) || changelog.sections.length === 0) { dom.updatesSection.classList.add('hidden'); return; } renderChangelog(changelog); setUpdatesExpanded(false); dom.updatesToggle.addEventListener('click', () => { const expanded = dom.updatesToggle.getAttribute('aria-expanded') === 'true'; setUpdatesExpanded(!expanded); }); } catch { dom.updatesSection.classList.add('hidden'); } } /** Announces status text via ARIA with brief de-duplication and auto-clear timing. */ function updateStatus(message: string): void { if (!state.running) { return; } const normalized = String(message) .replace(/\s*\n+\s*/g, ' ') .replace(/\s{2,}/g, ' ') .trim(); const now = performance.now(); if (normalized && normalized === lastAnnouncementText && now - lastAnnouncementAt < 300) { return; } lastAnnouncementText = normalized; lastAnnouncementAt = now; if (statusTimeout !== null) { window.clearTimeout(statusTimeout); } dom.status.textContent = ''; requestAnimationFrame(() => { dom.status.textContent = normalized; }); statusTimeout = window.setTimeout(() => { if (dom.status.textContent === normalized) { dom.status.textContent = ''; } }, 4000); } /** Updates persistent connection/update status shown under the page heading. */ function setConnectionStatus(message: string): void { dom.connectionStatus.textContent = String(message).trim(); } /** Sanitizes user nicknames to printable/safe characters and enforces max length. */ function sanitizeName(value: string): string { return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH); } /** Enables/disables the connect button based on state and nickname validity. */ function updateConnectAvailability(): void { authController.updateConnectAvailability(); } /** Restores persisted outbound effect levels from local storage. */ function loadEffectLevels(): void { const parsed = settings.loadEffectLevels(); if (!parsed) return; audio.setEffectLevels(parsed); } /** Persists current outbound effect levels to local storage. */ function persistEffectLevels(): void { settings.saveEffectLevels(audio.getEffectLevels()); } /** Restores local audio-layer toggles and applies initial voice-layer state. */ function loadAudioLayerState(): void { audioLayers = settings.loadAudioLayers(); audio.setVoiceLayerEnabled(audioLayers.voice); } /** Persists current audio-layer toggles to local storage. */ function persistAudioLayerState(): void { settings.saveAudioLayers(audioLayers); } /** Clamps microphone input gain to the supported calibration bounds. */ function clampMicInputGain(value: number): number { if (!Number.isFinite(value)) return 1; return Math.max(MIC_CALIBRATION_MIN_GAIN, Math.min(MIC_CALIBRATION_MAX_GAIN, value)); } /** Loads persisted microphone input gain and applies default when missing. */ function loadMicInputGain(): void { const parsed = settings.loadMicInputGain(); if (parsed === null) { audio.setOutboundInputGain(2); return; } audio.setOutboundInputGain(clampMicInputGain(parsed)); } /** Persists microphone input gain to local storage. */ function persistMicInputGain(value: number): void { settings.saveMicInputGain(value); } /** Loads persisted master output volume and applies default when missing. */ function loadMasterVolume(): void { const parsed = settings.loadMasterVolume(); if (parsed === null) { audio.setMasterVolume(50); return; } audio.setMasterVolume(parsed); } /** Persists master output volume to local storage. */ function persistMasterVolume(value: number): void { settings.saveMasterVolume(value); } /** Normalizes nickname for local per-user listen-gain preference keys. */ function peerListenGainKey(nickname: string): string { return nickname.trim().toLowerCase(); } /** Returns configured listen gain for a nickname (default 1.0). */ function getPeerListenGainForNickname(nickname: string): number { const key = peerListenGainKey(nickname); const raw = peerListenGainByNickname[key]; if (!Number.isFinite(raw)) return 1; return clampMicInputGain(raw); } /** Persists local listen gain preference for a nickname. */ function setPeerListenGainForNickname(nickname: string, gain: number): void { const key = peerListenGainKey(nickname); peerListenGainByNickname = { ...peerListenGainByNickname, [key]: clampMicInputGain(gain) }; settings.savePeerListenGains(peerListenGainByNickname); } /** Applies stored listen-gain preferences to currently known peer runtimes. */ function applyConfiguredPeerListenGains(): void { for (const [peerId, peerState] of state.peers.entries()) { peerManager.setPeerListenGain(peerId, getPeerListenGainForNickname(peerState.nickname)); } } /** Applies current layer toggles to peer voice, media streams, and item emitters. */ async function applyAudioLayerState(): Promise { audio.setVoiceLayerEnabled(audioLayers.voice); if (audioLayers.voice) { await peerManager.resumeRemoteAudio(); } else { peerManager.suspendRemoteAudio(); } const listenerPosition = { x: state.player.x, y: state.player.y }; await radioRuntime.setLayerEnabled(audioLayers.media, state.items.values(), listenerPosition); await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values(), listenerPosition); } /** Refreshes distance-gated radio/item stream subscriptions for a listener position. */ async function refreshAudioSubscriptionsAt(listenerPosition: { x: number; y: number }, force = false): Promise { await refreshAudioSubscriptionsForListeners([listenerPosition], force); } /** Refreshes distance-gated radio/item stream subscriptions for one or more listener positions. */ async function refreshAudioSubscriptionsForListeners( listenerPositions: Array<{ x: number; y: number }>, force = false, ): Promise { if (!state.running) return; if (listenerPositions.length === 0) return; const now = Date.now(); const anchorListener = listenerPositions[listenerPositions.length - 1]; const tileX = Math.round(anchorListener.x); const tileY = Math.round(anchorListener.y); const moved = tileX !== lastSubscriptionRefreshTileX || tileY !== lastSubscriptionRefreshTileY; if (!force && !moved && now - lastSubscriptionRefreshAt < AUDIO_SUBSCRIPTION_REFRESH_MS) { return; } if (subscriptionRefreshInFlight) { subscriptionRefreshPending = true; return; } subscriptionRefreshInFlight = true; lastSubscriptionRefreshAt = now; lastSubscriptionRefreshTileX = tileX; lastSubscriptionRefreshTileY = tileY; try { await radioRuntime.sync(state.items.values(), listenerPositions); await itemEmitRuntime.sync(state.items.values(), listenerPositions); } finally { subscriptionRefreshInFlight = false; if (subscriptionRefreshPending) { subscriptionRefreshPending = false; void refreshAudioSubscriptions(true); } } } /** Refreshes distance-gated radio/item stream subscriptions on movement or timer cadence. */ async function refreshAudioSubscriptions(force = false): Promise { if (activeTeleport) { await refreshAudioSubscriptionsForListeners( [ { x: activeTeleport.startX, y: activeTeleport.startY }, { x: activeTeleport.targetX, y: activeTeleport.targetY }, ], force, ); return; } await refreshAudioSubscriptionsAt({ x: state.player.x, y: state.player.y }, force); } /** Toggles a single audio layer and applies the change immediately. */ function toggleAudioLayer(layer: keyof AudioLayerState): void { audioLayers = { ...audioLayers, [layer]: !audioLayers[layer] }; persistAudioLayerState(); void applyAudioLayerState(); updateStatus(`${layer} layer ${audioLayers[layer] ? 'on' : 'off'}.`); audio.sfxUiBlip(); } /** Routes signaling transport status messages through chat buffer + status output. */ function handleSignalingStatus(message: string): void { if (message === 'Connected.') { return; } if (message === 'Disconnected.' && state.running && !reconnectInFlight) { setConnectionStatus('Disconnected from server. Reconnecting...'); pushChatMessage('Disconnected from server. Reconnecting...'); void reconnectAfterSocketClose(); return; } if (message === 'Disconnected.') { setConnectionStatus('Disconnected from server.'); pushChatMessage('Disconnected from server.'); return; } pushChatMessage(message); } /** Performs cache-busted navigation so the browser loads the newest client bundle. */ function reloadClientForVersion(version: string): void { const nextUrl = new URL(window.location.href); nextUrl.searchParams.set('v', version || 'unknown'); nextUrl.searchParams.set('t', String(Date.now())); window.location.replace(nextUrl.toString()); } /** Returns true when this page load came from the version-mismatch reload flow. */ function isVersionReloadedSession(): boolean { const params = new URLSearchParams(window.location.search); return params.has('v') && params.has('t'); } /** Appends a chat/system line to the bounded status history buffer. */ function pushChatMessage(message: string): void { messageBuffer.push(message); if (messageBuffer.length > 300) { messageBuffer.shift(); } messageCursor = messageBuffer.length - 1; updateStatus(message); } /** Classifies a system chat line into a corresponding notification sound, when applicable. */ function classifySystemMessageSound(message: string): keyof typeof SYSTEM_SOUND_URLS | null { const normalized = message.trim().toLowerCase(); if (!normalized) return null; if (normalized.startsWith('welcome. logged in as ') || normalized.endsWith(' has logged in.')) { return 'logon'; } if (normalized.endsWith(' has logged out.')) { return 'logout'; } if (normalized.includes(' is now known as ') || normalized.startsWith('you are now known as ')) { return 'notify'; } if (normalized.startsWith('server rebooting in ')) { return 'notify'; } return null; } /** Resolves incoming sound references to playable URLs, including proxy routing when needed. */ function resolveIncomingSoundUrl(url: string): string { const raw = String(url || '').trim(); if (!raw) return ''; const lowered = raw.toLowerCase(); if (lowered === 'none' || lowered === 'off') return ''; if (/^https?:/i.test(raw)) { return shouldProxyStreamUrl(raw) ? getProxyUrlForStream(raw) : raw; } if (/^(data:|blob:)/i.test(raw)) return raw; if (raw.startsWith('/sounds/')) { return withBase(raw.slice(1)); } if (raw.startsWith('sounds/')) { return withBase(raw); } return raw; } /** Navigates buffered chat lines and speaks the selected entry. */ function navigateChatBuffer(target: 'prev' | 'next' | 'first' | 'last'): void { if (messageBuffer.length === 0) { updateStatus('No chat messages.'); audio.sfxUiCancel(); return; } if (target === 'first') { messageCursor = 0; } else if (target === 'last') { messageCursor = messageBuffer.length - 1; } else if (target === 'prev') { messageCursor = Math.max(0, messageCursor - 1); } else if (target === 'next') { messageCursor = Math.min(messageBuffer.length - 1, messageCursor + 1); } updateStatus(messageBuffer[messageCursor]); if (target === 'prev' || target === 'next') { const atStart = messageCursor === 0; const atEnd = messageCursor === messageBuffer.length - 1; if (atStart || atEnd) { audio.sfxUiBlip(); } } } /** Updates compact input/output device summary labels in the pre-connect UI. */ function updateDeviceSummary(): void { mediaSession.updateDeviceSummary(); } /** Returns peer nicknames currently occupying the given grid cell. */ function getPeerNamesAtPosition(x: number, y: number): string[] { return Array.from(state.peers.values()) .filter((peer) => peer.x === x && peer.y === y) .map((peer) => peer.nickname); } /** Returns a user-facing item label including type information. */ function itemLabel(item: WorldItem): string { return `${item.title} (${itemTypeLabel(item.type)})`; } /** Resolves effective spatial audio configuration for an item, with global fallbacks. */ function getItemSpatialConfig(item: WorldItem): { range: number; directional: boolean; facingDeg: number } { const global = getItemTypeGlobalProperties(item.type); const rawParamRange = Number(item.params.emitRange); const rawGlobalRange = Number(global.emitRange); const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange; const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15; const directional = typeof item.params.directional === 'boolean' ? item.params.directional : global.directional === true; const rawFacing = Number(item.params.facing ?? 0); const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0; return { range, directional, facingDeg }; } /** Enters help-view mode and announces the first help line. */ function openHelpViewer(lines: string[], returnMode: GameMode = 'normal'): void { if (lines.length === 0) { updateStatus('Help unavailable.'); audio.sfxUiCancel(); return; } helpViewerLines = lines; helpViewerReturnMode = returnMode; state.mode = 'helpView'; helpViewerIndex = 0; updateStatus(helpViewerLines[helpViewerIndex]); audio.sfxUiBlip(); } /** Returns non-carried items occupying a given grid position. */ function getItemsAtPosition(x: number, y: number): WorldItem[] { return Array.from(state.items.values()).filter((item) => !item.carrierId && item.x === x && item.y === y); } /** Returns the item currently carried by the local player, if any. */ function getCarriedItem(): WorldItem | null { if (!state.player.id) return null; return Array.from(state.items.values()).find((item) => item.carrierId === state.player.id) || null; } /** Opens the shared item-selection flow for the provided context and items. */ function beginItemSelection( context: 'pickup' | 'delete' | 'edit' | 'use' | 'secondaryUse' | 'inspect' | 'manage', items: WorldItem[], ): void { itemInteractionController.beginItemSelection(context, items); } /** Builds available item-management actions for one selected item. */ function itemManagementOptionsFor(item: WorldItem) { return itemInteractionController.getManagementOptions(item); } /** Opens item-management options for one selected item. */ function beginItemManagement(item: WorldItem): void { itemInteractionController.beginItemManagement(item); } /** Opens item property browsing/editing mode for one item. */ function beginItemProperties(item: WorldItem, showAll = false): void { itemInteractionController.beginItemProperties(item, showAll); } /** Recomputes visible property rows for the active item-property view after item updates. */ function recomputeActiveItemPropertyKeys(itemId: string): void { itemInteractionController.recomputeActiveItemPropertyKeys(itemId); } /** Sends an item-use request for the selected item. */ function useItem(item: WorldItem): void { signaling.send({ type: 'item_use', itemId: item.id }); } /** Sends an item secondary-use request for the selected item. */ function secondaryUseItem(item: WorldItem): void { signaling.send({ type: 'item_secondary_use', itemId: item.id }); } /** Opens option-list selection mode for list-based item properties. */ function openItemPropertyOptionSelect(item: WorldItem, key: string): void { const options = getItemPropertyOptionValues(item.type, key); if (!options || options.length === 0) { return; } state.mode = 'itemPropertyOptionSelect'; state.editingPropertyKey = key; state.itemPropertyOptionValues = options; const currentValue = getItemPropertyValue(item, key); const currentIndex = options.indexOf(currentValue); state.itemPropertyOptionIndex = currentIndex >= 0 ? currentIndex : 0; updateStatus(`Select ${itemPropertyLabel(key)}: ${state.itemPropertyOptionValues[state.itemPropertyOptionIndex]}`); audio.sfxUiBlip(); } /** Returns the active text-input max length for the current UI mode, if applicable. */ function textInputMaxLengthForMode(mode: typeof state.mode): number | null { if (mode === 'nickname') return NICKNAME_MAX_LENGTH; if (mode === 'chat') return 500; if (mode === 'itemPropertyEdit') return 500; if (mode === 'micGainEdit') return 8; if (mode === 'adminRoleNameEdit') return 32; return null; } /** Applies pasted text into whichever mode currently owns the shared text edit buffer. */ function pasteIntoActiveTextInput(raw: string): boolean { const maxLength = textInputMaxLengthForMode(state.mode); if (maxLength === null) { return false; } const result = applyPastedText(raw, state.nicknameInput, state.cursorPos, maxLength, replaceTextOnNextType); if (!result.handled) return false; state.nicknameInput = result.newString; state.cursorPos = result.newCursorPos; replaceTextOnNextType = result.replaceTextOnNextType; return true; } /** Whether the current mode uses the shared single-line text editing pipeline. */ function isTextEditingMode(mode: typeof state.mode): boolean { return ( mode === 'nickname' || mode === 'chat' || mode === 'itemPropertyEdit' || mode === 'micGainEdit' || mode === 'adminRoleNameEdit' ); } /** Applies keyboard edits to the shared text buffer and emits cursor/deletion speech hints. */ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKey = false, allowReplaceOnNextType = false): void { if (ctrlKey && code === 'KeyA') { replaceTextOnNextType = true; state.cursorPos = state.nicknameInput.length; updateStatus(`${state.nicknameInput} selected`); return; } if (ctrlKey && code === 'ArrowLeft') { state.cursorPos = moveCursorWordLeft(state.nicknameInput, state.cursorPos); const spoken = describeCursorWordOrCharacter(state.nicknameInput, state.cursorPos); if (spoken) updateStatus(spoken); return; } if (ctrlKey && code === 'ArrowRight') { state.cursorPos = moveCursorWordRight(state.nicknameInput, state.cursorPos); const spoken = describeCursorWordOrCharacter(state.nicknameInput, state.cursorPos); if (spoken) updateStatus(spoken); return; } const beforeText = state.nicknameInput; const beforeCursor = state.cursorPos; const mappedKey = mapTextInputKey(code, key); const replaceDecision = shouldReplaceCurrentText(code, key, replaceTextOnNextType); replaceTextOnNextType = replaceDecision.replaceTextOnNextType; if (allowReplaceOnNextType && replaceDecision.shouldReplace) { state.nicknameInput = key; state.cursorPos = key.length; return; } const result = applyTextInput(mappedKey, state.nicknameInput, state.cursorPos, maxLength); state.nicknameInput = result.newString; state.cursorPos = result.newCursorPos; if (code === 'Backspace') { const spoken = describeBackspaceDeletedCharacter(beforeText, beforeCursor); if (spoken) updateStatus(spoken); } if (code === 'Delete') { const spoken = describeDeleteDeletedCharacter(beforeText, beforeCursor); if (spoken) updateStatus(spoken); } if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') { const spoken = describeCursorCharacter(state.nicknameInput, state.cursorPos); if (spoken) updateStatus(spoken); } } /** Returns singular/plural square wording for distance announcements. */ function squareWord(distance: number): string { return distance === 1 ? 'square' : 'squares'; } /** Builds a spoken distance+direction phrase between two grid coordinates. */ function distanceDirectionPhrase(px: number, py: number, tx: number, ty: number): string { const distance = Math.round(Math.hypot(tx - px, ty - py)); const direction = getDirection(px, py, tx, ty); if (direction === 'here') return 'here'; return `${distance} ${squareWord(distance)} ${direction}`; } /** Formats a coordinate with up to 2 decimals while trimming trailing zeros. */ function formatCoordinate(value: number): string { if (!Number.isFinite(value)) return '0'; return value.toFixed(2).replace(/\.?0+$/, ''); } /** Picks one random footstep sample URL. */ function randomFootstepUrl(): string { return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)]; } /** Stops active teleport loop audio, if one is running. */ function stopTeleportLoopAudio(): void { if (!activeTeleportLoopStop) return; activeTeleportLoopStop(); activeTeleportLoopStop = null; } /** Starts animated teleport movement toward a target tile at fixed squares-per-second pace. */ function startTeleportTo(targetX: number, targetY: number, completionStatus: string): void { const startX = state.player.x; const startY = state.player.y; const distance = Math.hypot(targetX - startX, targetY - startY); const durationMs = Math.max(1, (distance / TELEPORT_SQUARES_PER_SECOND) * 1000); const nowMs = performance.now(); activeTeleport = { startX, startY, targetX, targetY, startedAtMs: nowMs, durationMs, lastSyncAtMs: nowMs, lastSentX: Math.round(startX), lastSentY: Math.round(startY), completionStatus, }; stopTeleportLoopAudio(); activeTeleportLoopToken += 1; const loopToken = activeTeleportLoopToken; void audio.startLoopingSample(TELEPORT_START_SOUND_URL, TELEPORT_START_GAIN).then((stopLoop) => { if (!stopLoop) return; if (activeTeleport && loopToken === activeTeleportLoopToken) { activeTeleportLoopStop = stopLoop; return; } stopLoop(); }); void refreshAudioSubscriptionsForListeners( [ { x: startX, y: startY }, { x: targetX, y: targetY }, ], true, ); state.keysPressed.ArrowUp = false; state.keysPressed.ArrowDown = false; state.keysPressed.ArrowLeft = false; state.keysPressed.ArrowRight = false; lastWallCollisionDirection = null; } /** Advances active teleport animation, syncs intermediate server positions, and finalizes arrival. */ function updateTeleport(): void { if (!activeTeleport) return; const nowMs = performance.now(); const elapsedMs = nowMs - activeTeleport.startedAtMs; const progress = Math.max(0, Math.min(1, elapsedMs / activeTeleport.durationMs)); state.player.x = activeTeleport.startX + (activeTeleport.targetX - activeTeleport.startX) * progress; state.player.y = activeTeleport.startY + (activeTeleport.targetY - activeTeleport.startY) * progress; if (nowMs - activeTeleport.lastSyncAtMs >= movementTickMs) { activeTeleport.lastSyncAtMs = nowMs; const desiredX = Math.round(state.player.x); const desiredY = Math.round(state.player.y); const stepX = Math.sign(desiredX - activeTeleport.lastSentX); const stepY = Math.sign(desiredY - activeTeleport.lastSentY); const syncX = activeTeleport.lastSentX + stepX; const syncY = activeTeleport.lastSentY + stepY; if (syncX !== activeTeleport.lastSentX || syncY !== activeTeleport.lastSentY) { activeTeleport.lastSentX = syncX; activeTeleport.lastSentY = syncY; signaling.send({ type: 'update_position', x: syncX, y: syncY }); } } if (progress < 1) { return; } const completionStatus = activeTeleport.completionStatus; state.player.x = activeTeleport.targetX; state.player.y = activeTeleport.targetY; signaling.send({ type: 'teleport_complete', x: activeTeleport.targetX, y: activeTeleport.targetY }); activeTeleport = null; stopTeleportLoopAudio(); void refreshAudioSubscriptions(true); void audio.playSample(TELEPORT_SOUND_URL, FOOTSTEP_GAIN); updateStatus(completionStatus); } /** Main animation/update loop for movement, spatial audio, and rendering. */ function gameLoop(): void { if (!state.running) return; updateTeleport(); handleMovement(); if (!activeTeleport) { void refreshAudioSubscriptions(); } audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y }); audio.updateSpatialSamples({ x: state.player.x, y: state.player.y }); radioRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y }); itemEmitRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y }); state.cursorVisible = Math.floor(Date.now() / 500) % 2 === 0; renderer.draw(state); requestAnimationFrame(gameLoop); } /** Applies held-arrow movement with bounds checks, tile cues, and server position sync. */ function handleMovement(): void { if (state.mode !== 'normal') return; if (activeTeleport) return; const now = Date.now(); if (now - state.player.lastMoveTime < movementTickMs) return; let dx = 0; let dy = 0; if (state.keysPressed.ArrowUp) dy = 1; if (state.keysPressed.ArrowDown) dy = -1; if (state.keysPressed.ArrowLeft) dx = -1; if (state.keysPressed.ArrowRight) dx = 1; if (dx === 0 && dy === 0) { lastWallCollisionDirection = null; return; } const nextX = state.player.x + dx; const nextY = state.player.y + dy; const attemptedDirection = `${dx},${dy}`; if (nextX < 0 || nextY < 0 || nextX >= worldGridSize || nextY >= worldGridSize) { state.player.lastMoveTime = now; if (lastWallCollisionDirection !== attemptedDirection) { void audio.playSample(WALL_SOUND_URL, 1); lastWallCollisionDirection = attemptedDirection; } return; } state.player.x = nextX; state.player.y = nextY; lastWallCollisionDirection = null; state.player.lastMoveTime = now; void refreshAudioSubscriptions(true); void audio.playSample(randomFootstepUrl(), FOOTSTEP_GAIN, movementTickMs); signaling.send({ type: 'update_position', x: nextX, y: nextY }); const namesOnTile = getPeerNamesAtPosition(nextX, nextY); const itemsOnTile = getItemsAtPosition(nextX, nextY); const tileAnnouncements: string[] = []; if (namesOnTile.length > 0) { tileAnnouncements.push(namesOnTile.join(', ')); audio.sfxTileUserPing(); } if (itemsOnTile.length > 0) { tileAnnouncements.push(itemsOnTile.map((item) => itemLabel(item)).join(', ')); audio.sfxTileItemPing(); } if (tileAnnouncements.length > 0) { updateStatus(tileAnnouncements.join('. ')); } } /** Checks microphone permission state when Permissions API support is available. */ async function checkMicPermission(): Promise { return mediaSession.checkMicPermission(); } /** Starts local microphone capture and rebuilds the outbound track pipeline. */ async function setupLocalMedia(audioDeviceId = ''): Promise { await mediaSession.setupLocalMedia(audioDeviceId); applyVoiceSendPermission(); } /** Runs a short RMS sample to estimate and apply a usable microphone input gain. */ async function calibrateMicInputGain(): Promise { await mediaSession.calibrateMicInputGain(clampMicInputGain, persistMicInputGain); } /** Stops local capture tracks and clears outbound stream references. */ function stopLocalMedia(): void { mediaSession.stopLocalMedia(); } /** Maps browser media/capture errors to user-facing remediation text. */ function describeMediaError(error: unknown): string { return mediaSession.describeMediaError(error); } /** Restores loopback state captured when entering microphone gain edit mode. */ function restoreLoopbackAfterMicGainEdit(): void { if (micGainLoopbackRestoreState === null) { return; } audio.setLoopbackEnabled(micGainLoopbackRestoreState); micGainLoopbackRestoreState = null; } /** Stops heartbeat timer and clears in-memory heartbeat state. */ function stopHeartbeat(): void { if (heartbeatTimerId !== null) { window.clearInterval(heartbeatTimerId); heartbeatTimerId = null; } heartbeatAwaitingPong = false; } /** Sends one heartbeat ping packet using reserved negative ids. */ function sendHeartbeatPing(): void { signaling.send({ type: 'ping', clientSentAt: heartbeatNextPingId }); heartbeatNextPingId -= 1; heartbeatAwaitingPong = true; } /** Starts heartbeat timer for stale-connection detection. */ function startHeartbeat(): void { stopHeartbeat(); heartbeatAwaitingPong = false; sendHeartbeatPing(); heartbeatTimerId = window.setInterval(() => { if (!state.running) return; if (heartbeatAwaitingPong) { void reconnectAfterHeartbeatTimeout(); return; } sendHeartbeatPing(); }, HEARTBEAT_INTERVAL_MS); } /** Performs one reconnect attempt when heartbeat timeout indicates stale signaling. */ async function reconnectAfterHeartbeatTimeout(): Promise { await reconnectWithRetry('heartbeat'); } /** Performs immediate reconnect when websocket closes unexpectedly. */ async function reconnectAfterSocketClose(): Promise { await reconnectWithRetry('socketClose'); } /** Reconnects after disconnect with delay and bounded retry attempts. */ async function reconnectWithRetry(reason: 'heartbeat' | 'socketClose'): Promise { if (reconnectInFlight || !state.running) return; reconnectInFlight = true; stopHeartbeat(); if (reason === 'heartbeat') { pushChatMessage('Connection stale. Reconnecting...'); } disconnect(); for (let attempt = 1; attempt <= RECONNECT_MAX_ATTEMPTS; attempt += 1) { await new Promise((resolve) => window.setTimeout(resolve, RECONNECT_DELAY_MS)); await connect(); const waitStartedAt = Date.now(); while (!state.running && Date.now() - waitStartedAt < 4_000) { await new Promise((resolve) => window.setTimeout(resolve, 100)); } if (state.running) { reconnectInFlight = false; return; } if (attempt < RECONNECT_MAX_ATTEMPTS) { pushChatMessage(`Reconnect attempt ${attempt} failed. Retrying in 5 seconds...`); } } pushChatMessage('Reconnect failed after 3 attempts. Press Connect to retry.'); audio.sfxUiCancel(); reconnectInFlight = false; } /** Sends current auth request over signaling websocket after socket open. */ function sendAuthRequest(): void { authController.sendAuthRequest(); } /** Handles server auth-required prompts prior to world welcome. */ function handleAuthRequired(message: Extract): void { authController.handleAuthRequired(message); } /** Applies auth result state and terminates failed auth attempts quickly. */ async function handleAuthResult(message: Extract): Promise { if (message.nickname) { const resolved = sanitizeName(message.nickname); if (resolved) { state.player.nickname = resolved; } } await authController.handleAuthResult(message); } /** Handles server-pushed role/permission refresh events for the current session. */ function handleAuthPermissions(message: Extract): void { authController.handleAuthPermissions(message); } /** Returns available admin-menu root actions based on current permission set. */ function getAvailableAdminActions(): AdminMenuAction[] { return adminController.getAvailableAdminActions(); } /** Handles server role-list response for admin menu flows. */ function handleAdminRolesList(message: Extract): void { adminController.handleAdminRolesList(message); } /** Handles server user-list response for admin menu flows. */ function handleAdminUsersList(message: Extract): void { adminController.handleAdminUsersList(message); } /** Handles server transfer-target list response for item-management transfer flow. */ function handleItemTransferTargets(message: Extract): void { itemInteractionController.handleItemTransferTargets(message); } /** Handles structured admin action result packets. */ function handleAdminActionResult(message: Extract): void { adminController.handleAdminActionResult(message); audio.sfxUiConfirm(); } /** Builds dependencies shared by connect/disconnect flow helpers. */ function getConnectionFlowDeps(): ConnectFlowDeps { return { state, dom, sanitizeName, updateStatus: (message) => { if (!state.running) { setConnectionStatus(message); return; } if (message === 'Disconnected.') { setConnectionStatus('Disconnected.'); } else if (message.startsWith('Connect failed.')) { setConnectionStatus(message); } if (reconnectInFlight && message === 'Disconnected.') { return; } pushChatMessage(message); }, updateConnectAvailability, mediaIsConnecting: () => mediaSession.isConnecting(), mediaSetConnecting: (value) => mediaSession.setConnecting(value), mediaStopLocalMedia: () => stopLocalMedia(), signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise), signalingSendAuth: () => sendAuthRequest(), signalingDisconnect: () => signaling.disconnect(), onMessage: (message) => onSignalingMessage(message as IncomingMessage), peerManagerCleanupAll: () => peerManager.cleanupAll(), radioCleanupAll: () => radioRuntime.cleanupAll(), emitCleanupAll: () => itemEmitRuntime.cleanupAll(), playLogoutSound: () => { void audio.playSample(SYSTEM_SOUND_URLS.logout, 1); }, }; } /** Performs end-to-end connect flow: validation, media setup, then signaling connection. */ async function connect(): Promise { setConnectionStatus('Connecting...'); await runConnectFlow(getConnectionFlowDeps()); } /** Tears down active session state, media, peers, and UI back to pre-connect mode. */ function disconnect(): void { stopHeartbeat(); runDisconnectFlow(getConnectionFlowDeps()); setConnectionStatus('Disconnected.'); pendingEscapeDisconnect = false; restoreLoopbackAfterMicGainEdit(); subscriptionRefreshPending = false; subscriptionRefreshInFlight = false; lastSubscriptionRefreshAt = 0; lastSubscriptionRefreshTileX = Math.round(state.player.x); lastSubscriptionRefreshTileY = Math.round(state.player.y); stopTeleportLoopAudio(); activeTeleport = null; peerNegotiationReady = false; pendingSignalMessages = []; itemInteractionController.reset(); itemBehaviorRegistry.cleanup(); } /** Starts peer negotiation only after welcome + media setup sequencing is complete. */ async function activatePeerNegotiation(): Promise { if (!state.running) return; if (peerNegotiationReady) return; peerNegotiationReady = true; for (const peer of state.peers.values()) { await peerManager.createOrGetPeer(peer.id, true, peer); } if (pendingSignalMessages.length === 0) return; const queued = pendingSignalMessages; pendingSignalMessages = []; for (const signal of queued) { await onAppMessage(signal); } } const onAppMessage = createOnMessageHandler({ getWorldGridSize: () => worldGridSize, setWorldGridSize: (size) => { worldGridSize = size; }, setMovementTickMs: (value) => { movementTickMs = Math.max(1, value); }, setConnecting: (value) => { mediaSession.setConnecting(value); updateConnectAvailability(); }, rendererSetGridSize: (size) => renderer.setGridSize(size), applyServerItemUiDefinitions: (defs) => applyServerItemUiDefinitions(defs as Parameters[0]), state, dom, signalingSend: (message) => signaling.send(message as OutgoingMessage), peerManager, refreshAudioSubscriptions, cleanupItemAudio: (itemId) => { radioRuntime.cleanup(itemId); itemEmitRuntime.cleanup(itemId); }, applyAudioLayerState, gameLoop, sanitizeName, randomFootstepUrl, playRemoteSpatialStepOrTeleport: (url, peerX, peerY) => { const gain = url === TELEPORT_START_SOUND_URL ? TELEPORT_START_GAIN : FOOTSTEP_GAIN; void audio.playSpatialSample( url, { x: peerX, y: peerY }, { x: state.player.x, y: state.player.y }, gain, ); }, handleItemActionResultStatus: (message) => itemBehaviorRegistry.onActionResultStatus(message), handleItemBehaviorIncomingMessage: (message) => itemBehaviorRegistry.onIncomingMessage(message), handleItemBehaviorPeerLeft: (senderId) => itemBehaviorRegistry.onPeerLeft(senderId), TELEPORT_SOUND_URL, TELEPORT_START_SOUND_URL, getAudioLayers: () => audioLayers, pushChatMessage, classifySystemMessageSound, ACTION_SOUND_URL, SYSTEM_SOUND_URLS, playSample: (url, gain = 1) => { void audio.playSample(url, gain); }, updateStatus, audioUiBlip: () => audio.sfxUiBlip(), audioUiConfirm: () => audio.sfxUiConfirm(), audioUiCancel: () => audio.sfxUiCancel(), getCarriedItemId: () => getCarriedItem()?.id ?? null, recomputeActiveItemPropertyKeys, itemPropertyLabel, getItemPropertyValue, getItemById: (itemId) => state.items.get(itemId), shouldAnnounceItemPropertyEcho: () => Date.now() >= suppressItemPropertyEchoUntilMs, playLocateToneAt: (x, y) => audio.sfxLocate({ x: x - state.player.x, y: y - state.player.y }), resolveIncomingSoundUrl, playIncomingItemUseSound: (url, x, y, range) => { void audio.playSpatialSample(url, { x, y }, { x: state.player.x, y: state.player.y }, 1, range ?? HEARING_RADIUS); }, playClockAnnouncement: (sounds, x, y, range) => { void clockAnnouncer.playSequence(sounds.map(resolveIncomingSoundUrl), x, y, range); }, handleAuthRequired, handleAuthResult, handleAuthPermissions, handleAdminRolesList, handleAdminUsersList, handleAdminActionResult, handleItemTransferTargets, isPeerNegotiationReady: () => peerNegotiationReady, enqueuePendingSignal: (message) => { pendingSignalMessages.push(message); if (pendingSignalMessages.length > 500) { pendingSignalMessages.splice(0, pendingSignalMessages.length - 500); } }, }); /** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */ async function onSignalingMessage(message: IncomingMessage): Promise { if (message.type === 'pong' && message.clientSentAt < 0) { heartbeatAwaitingPong = false; return; } let restartAnnouncement: string | null = null; let connectedAnnouncement: string | null = null; let playSelfLoginSound = false; if (message.type === 'welcome') { const uiAdminActions = (message.uiDefinitions as { adminMenu?: { actions?: Array<{ id: string; label: string }> } } | undefined)?.adminMenu?.actions ?? message.auth?.adminMenuActions; authController.applyWelcomeAuth(message.auth, uiAdminActions); const incomingInstanceId = String(message.serverInfo?.instanceId ?? '').trim() || null; const incomingVersion = String(message.serverInfo?.version ?? '').trim() || 'unknown'; connectedAnnouncement = reconnectInFlight ? `Reconnected to server. Version ${incomingVersion}.` : `Connected to server. Version ${incomingVersion}.`; playSelfLoginSound = !reconnectInFlight; if ( !reloadScheduledForVersionMismatch && APP_VERSION && incomingVersion && incomingVersion !== 'unknown' && incomingVersion !== APP_VERSION ) { reloadScheduledForVersionMismatch = true; pushChatMessage(`Server version ${incomingVersion} detected. Reloading client...`); window.setTimeout(() => { reloadClientForVersion(incomingVersion); }, 50); return; } if (activeServerInstanceId && incomingInstanceId && activeServerInstanceId !== incomingInstanceId) { restartAnnouncement = 'Server restarted.'; } activeServerInstanceId = incomingInstanceId; startHeartbeat(); } await onAppMessage(message); if (message.type === 'welcome') { signaling.send({ type: 'welcome_ready' }); await setupMediaAfterAuth(); if (playSelfLoginSound) { void audio.playSample(SYSTEM_SOUND_URLS.logon, 1); } } itemBehaviorRegistry.onUseResultMessage(message); itemBehaviorRegistry.onWorldUpdate(); applyConfiguredPeerListenGains(); if (restartAnnouncement) { setConnectionStatus(restartAnnouncement); pushChatMessage(restartAnnouncement); audio.sfxUiConfirm(); } if (connectedAnnouncement) { setConnectionStatus(connectedAnnouncement); pushChatMessage(connectedAnnouncement); } } /** Requests microphone access and initializes local media after successful auth/welcome. */ async function setupMediaAfterAuth(): Promise { if (!state.running) return; const canProceed = await checkMicPermission(); if (!canProceed) { setConnectionStatus('Microphone access is required.'); await activatePeerNegotiation(); return; } try { await populateAudioDevices(); if (dom.audioInputSelect.options.length === 0) { setConnectionStatus('No audio input device found. Open Audio setup or connect a microphone.'); await activatePeerNegotiation(); return; } const inputDeviceId = dom.audioInputSelect.value || mediaSession.getPreferredInputDeviceId(); await setupLocalMedia(inputDeviceId); } catch (error) { console.error(error); setConnectionStatus(describeMediaError(error)); } finally { await activatePeerNegotiation(); } } /** Toggles local microphone track mute state. */ function toggleMute(): void { if (!authController.getVoiceSendAllowed()) { updateStatus('Voice send is disabled for this account.'); audio.sfxUiCancel(); return; } state.isMuted = !state.isMuted; mediaSession.applyMuteToTrack(state.isMuted); updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.'); } function getCurrentSquareItems(): WorldItem[] { return getItemsAtPosition(state.player.x, state.player.y); } function getUsableItemsOnCurrentSquare(): WorldItem[] { return getCurrentSquareItems().filter((item) => item.capabilities.includes('usable')); } function getManageableItemsOnCurrentSquare(): WorldItem[] { return getCurrentSquareItems().filter((item) => itemManagementOptionsFor(item).length > 0); } function canEditCurrentItem(): boolean { return getCurrentSquareItems().length > 0 || Boolean(getCarriedItem()); } function canInspectCurrentItem(): boolean { return canEditCurrentItem(); } function openNicknameEditor(): void { state.mode = 'nickname'; state.nicknameInput = state.player.nickname; state.cursorPos = state.player.nickname.length; replaceTextOnNextType = true; updateStatus(`Nickname edit: ${state.nicknameInput}`); audio.sfxUiBlip(); } function toggleOutputModeCommand(): void { outputMode = audio.toggleOutputMode(); mediaSession.saveOutputMode(outputMode); updateStatus(outputMode === 'mono' ? 'Mono output.' : 'Stereo output.'); audio.sfxUiBlip(); } function toggleLoopbackCommand(): void { const enabled = audio.toggleLoopback(); updateStatus(enabled ? 'Loopback on.' : 'Loopback off.'); audio.sfxUiBlip(); } function adjustMasterVolumeCommand(step: number): void { const next = audio.adjustMasterVolume(step); persistMasterVolume(next); updateStatus(`Master volume ${next}`); audio.sfxEffectLevel(next === 50); } function openEffectSelectCommand(): void { const currentEffect = audio.getCurrentEffect(); const currentIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === currentEffect.id); state.effectSelectIndex = currentIndex >= 0 ? currentIndex : 0; state.mode = 'effectSelect'; announceMenuEntry('Effects', EFFECT_SEQUENCE[state.effectSelectIndex].label); } function adjustEffectValueCommand(step: number): void { const adjusted = audio.adjustCurrentEffectLevel(step); if (!adjusted) return; persistEffectLevels(); audio.sfxEffectLevel(adjusted.value === adjusted.defaultValue); updateStatus(`${adjusted.label} ${adjusted.value}`); } function speakCoordinatesCommand(): void { updateStatus(`${formatCoordinate(state.player.x)}, ${formatCoordinate(state.player.y)}`); audio.sfxUiBlip(); } function openMicGainEditCommand(): void { if (!authController.getVoiceSendAllowed()) { updateStatus('Voice send is disabled for this account.'); audio.sfxUiCancel(); return; } state.mode = 'micGainEdit'; state.nicknameInput = formatSteppedNumber(audio.getOutboundInputGain(), MIC_INPUT_GAIN_STEP); state.cursorPos = state.nicknameInput.length; replaceTextOnNextType = true; micGainLoopbackRestoreState = audio.isLoopbackEnabled(); audio.setLoopbackEnabled(true); announceMenuEntry('Microphone gain', state.nicknameInput); } function calibrateMicrophoneCommand(): void { if (!authController.getVoiceSendAllowed()) { updateStatus('Voice send is disabled for this account.'); audio.sfxUiCancel(); return; } void calibrateMicInputGain(); } function openAdminMenuCommand(): void { adminController.openAdminMenu(); } function useItemCommand(): void { const carried = getCarriedItem(); if (carried) { useItem(carried); return; } const usable = getUsableItemsOnCurrentSquare(); if (usable.length === 0) { updateStatus('No usable items here.'); audio.sfxUiCancel(); return; } if (usable.length === 1) { useItem(usable[0]); return; } beginItemSelection('use', usable); } function secondaryUseItemCommand(): void { const carried = getCarriedItem(); if (carried) { secondaryUseItem(carried); return; } const usable = getUsableItemsOnCurrentSquare(); if (usable.length === 0) { updateStatus('No usable items here.'); audio.sfxUiCancel(); return; } if (usable.length === 1) { secondaryUseItem(usable[0]); return; } beginItemSelection('secondaryUse', usable); } function speakUsersCommand(): void { const allUsers = [state.player.nickname, ...Array.from(state.peers.values()).map((peer) => peer.nickname)]; const label = allUsers.length === 1 ? 'user' : 'users'; updateStatus(`${allUsers.length} ${label}: ${allUsers.join(', ')}`); audio.sfxUiBlip(); } function addItemCommand(): void { const itemTypeSequence = getItemTypeSequence(); if (itemTypeSequence.length === 0) { updateStatus('No item types available.'); audio.sfxUiCancel(); return; } state.addItemTypeIndex = Math.max(0, Math.min(state.addItemTypeIndex, itemTypeSequence.length - 1)); state.mode = 'addItem'; announceMenuEntry('Add item', itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])); } function listItemsCommand(): void { state.sortedItemIds = Array.from(state.items.entries()) .filter(([, item]) => !item.carrierId) .sort( (a, b) => Math.hypot(a[1].x - state.player.x, a[1].y - state.player.y) - Math.hypot(b[1].x - state.player.x, b[1].y - state.player.y), ) .map(([id]) => id); if (state.sortedItemIds.length === 0) { updateStatus('No items to list.'); audio.sfxUiCancel(); return; } state.itemListIndex = 0; state.mode = 'listItems'; const first = state.items.get(state.sortedItemIds[0]); if (!first) { audio.sfxUiCancel(); return; } const itemCount = state.sortedItemIds.length; const itemLabelText = itemCount === 1 ? 'item' : 'items'; announceMenuEntry( `${itemCount} ${itemLabelText}`, `${itemLabel(first)}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`, ); } function locateNearestItemCommand(): void { const nearest = getNearestItem(state); if (!nearest.itemId) { updateStatus('No items to locate.'); audio.sfxUiCancel(); return; } const item = state.items.get(nearest.itemId); if (!item) return; audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y }); updateStatus(`${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`); } function pickupDropItemCommand(): void { const carried = getCarriedItem(); if (carried) { signaling.send({ type: 'item_drop', itemId: carried.id, x: state.player.x, y: state.player.y }); return; } const squareItems = getCurrentSquareItems(); if (squareItems.length === 0) { updateStatus('No items to pick up.'); audio.sfxUiCancel(); return; } if (squareItems.length === 1) { signaling.send({ type: 'item_pickup', itemId: squareItems[0].id }); return; } beginItemSelection('pickup', squareItems); } function openItemManagementCommand(): void { const squareItems = getCurrentSquareItems(); if (squareItems.length === 0) { updateStatus('No items to manage on this square.'); audio.sfxUiCancel(); return; } const manageable = squareItems.filter((item) => itemManagementOptionsFor(item).length > 0); if (manageable.length === 0) { updateStatus('No permitted item management actions here.'); audio.sfxUiCancel(); return; } if (manageable.length === 1) { beginItemManagement(manageable[0]); return; } beginItemSelection('manage', manageable); } function editItemCommand(): void { const squareItems = getCurrentSquareItems(); const carried = getCarriedItem(); if (squareItems.length === 0) { if (!carried) { updateStatus('No editable item here.'); audio.sfxUiCancel(); return; } beginItemProperties(carried); return; } if (squareItems.length === 1) { beginItemProperties(squareItems[0]); return; } beginItemSelection('edit', squareItems); } function inspectItemCommand(): void { const squareItems = getCurrentSquareItems(); const carried = getCarriedItem(); if (squareItems.length === 0) { if (!carried) { updateStatus('No item to inspect.'); audio.sfxUiCancel(); return; } beginItemProperties(carried, true); return; } if (squareItems.length === 1) { beginItemProperties(squareItems[0], true); return; } beginItemSelection('inspect', squareItems); } function pingServerCommand(): void { signaling.send({ type: 'ping', clientSentAt: Date.now() }); } function listUsersCommand(): void { if (state.peers.size === 0) { updateStatus('No users to list.'); audio.sfxUiCancel(); return; } state.sortedPeerIds = Array.from(state.peers.entries()) .sort((a, b) => a[1].nickname.localeCompare(b[1].nickname, undefined, { sensitivity: 'base' })) .map(([id]) => id); state.listIndex = 0; state.mode = 'listUsers'; const first = state.peers.get(state.sortedPeerIds[0]); if (!first) { audio.sfxUiCancel(); return; } const userCount = state.sortedPeerIds.length; const userLabelText = userCount === 1 ? 'user' : 'users'; const gainPhrase = `volume ${formatSteppedNumber(getPeerListenGainForNickname(first.nickname), MIC_INPUT_GAIN_STEP)}`; announceMenuEntry( `${userCount} ${userLabelText}`, `${first.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, first.x, first.y)}, ${first.x}, ${first.y}`, ); } function locateNearestUserCommand(): void { const nearest = getNearestPeer(state); if (!nearest.peerId) { updateStatus('No users to locate.'); audio.sfxUiCancel(); return; } const peer = state.peers.get(nearest.peerId); if (!peer) return; audio.sfxLocate({ x: peer.x - state.player.x, y: peer.y - state.player.y }); updateStatus(`${peer.nickname}, ${distanceDirectionPhrase(state.player.x, state.player.y, peer.x, peer.y)}, ${peer.x}, ${peer.y}`); } function openHelpCommand(): void { openHelpViewer(mainHelpViewerLines); } function openChatCommand(): void { state.mode = 'chat'; state.nicknameInput = ''; state.cursorPos = 0; replaceTextOnNextType = false; updateStatus('Chat.'); audio.sfxUiBlip(); } function escapeCommand(): void { if (pendingEscapeDisconnect) { pendingEscapeDisconnect = false; disconnect(); return; } pendingEscapeDisconnect = true; updateStatus('Press Escape again to disconnect.'); audio.sfxUiCancel(); } const mainModeCommandHandlers: Record void> = { editNickname: openNicknameEditor, toggleMute, toggleOutputMode: toggleOutputModeCommand, toggleLoopback: toggleLoopbackCommand, toggleVoiceLayer: () => toggleAudioLayer('voice'), toggleItemLayer: () => toggleAudioLayer('item'), toggleMediaLayer: () => toggleAudioLayer('media'), toggleWorldLayer: () => toggleAudioLayer('world'), masterVolumeUp: () => adjustMasterVolumeCommand(5), masterVolumeDown: () => adjustMasterVolumeCommand(-5), openEffectSelect: openEffectSelectCommand, effectValueUp: () => adjustEffectValueCommand(5), effectValueDown: () => adjustEffectValueCommand(-5), speakCoordinates: speakCoordinatesCommand, openMicGainEdit: openMicGainEditCommand, calibrateMicrophone: calibrateMicrophoneCommand, useItem: useItemCommand, secondaryUseItem: secondaryUseItemCommand, speakUsers: speakUsersCommand, addItem: addItemCommand, locateNearestItem: locateNearestItemCommand, listItems: listItemsCommand, pickupDropItem: pickupDropItemCommand, openItemManagement: openItemManagementCommand, editItem: editItemCommand, inspectItem: inspectItemCommand, pingServer: pingServerCommand, locateNearestUser: locateNearestUserCommand, listUsers: listUsersCommand, openHelp: openHelpCommand, openChat: openChatCommand, openAdminMenu: openAdminMenuCommand, chatPrev: () => navigateChatBuffer('prev'), chatNext: () => navigateChatBuffer('next'), chatFirst: () => navigateChatBuffer('first'), chatLast: () => navigateChatBuffer('last'), escape: escapeCommand, }; function getAvailableCommandPaletteEntriesForMode(mode: GameMode): Array void | Promise }> { if (mode === 'normal') { const descriptors = getAvailableMainModeCommands({ voiceSendAllowed: authController.getVoiceSendAllowed(), mainHelpAvailable: mainHelpViewerLines.length > 0, hasAdminActions: getAvailableAdminActions().length > 0, itemTypeCount: getItemTypeSequence().length, visibleItemCount: Array.from(state.items.values()).filter((item) => !item.carrierId).length, userCount: state.peers.size, chatMessageCount: messageBuffer.length, hasCarriedItem: Boolean(getCarriedItem()), squareItemCount: getCurrentSquareItems().length, usableItemCount: getUsableItemsOnCurrentSquare().length, manageableItemCount: getManageableItemsOnCurrentSquare().length, hasEditableItemTarget: canEditCurrentItem(), hasInspectableItemTarget: canInspectCurrentItem(), }); return descriptors.map((descriptor) => ({ ...descriptor, label: getServerMainModeCommandMetadata(descriptor.id)?.label ?? descriptor.label, tooltip: getServerMainModeCommandMetadata(descriptor.id)?.tooltip ?? descriptor.tooltip, run: mainModeCommandHandlers[descriptor.id], })); } if (itemBehaviorRegistry.canOpenModeCommandPalette(mode)) { return itemBehaviorRegistry.getModeCommands(mode).map((descriptor) => ({ ...descriptor, run: () => { itemBehaviorRegistry.runModeCommand(mode, descriptor.id); }, })); } return []; } function canOpenCommandPaletteInMode(mode: GameMode): boolean { return mode === 'normal' || mode === 'commandPalette' || itemBehaviorRegistry.canOpenModeCommandPalette(mode); } function openCommandPalette(): void { const sourceMode = state.mode; if (sourceMode === 'commandPalette') { return; } const commands = getAvailableCommandPaletteEntriesForMode(sourceMode); if (commands.length === 0) { updateStatus('No commands available in this mode.'); audio.sfxUiCancel(); return; } commandPaletteCommands.splice(0, commandPaletteCommands.length, ...commands); commandPaletteIndex = 0; commandPaletteReturnMode = sourceMode; state.mode = 'commandPalette'; announceMenuEntry('Commands', formatCommandMenuLabel(commandPaletteCommands[0])); } function executeCommandPaletteSelection(): void { const selected = commandPaletteCommands[commandPaletteIndex]; if (!selected) return; state.mode = commandPaletteReturnMode; void selected.run(); } /** Handles command-mode keybindings while in main gameplay mode. */ function handleNormalModeInput(code: string, shiftKey: boolean): void { if (code !== 'Escape' && pendingEscapeDisconnect) { pendingEscapeDisconnect = false; } const command = resolveMainModeCommand(code, shiftKey); if (!command) return; mainModeCommandHandlers[command](); } /** Handles linear help viewer navigation and exit keys. */ function handleHelpViewModeInput(code: string): void { if (helpViewerLines.length === 0) { state.mode = 'normal'; updateStatus('Help unavailable.'); audio.sfxUiCancel(); return; } if (code === 'ArrowDown') { helpViewerIndex = Math.min(helpViewerLines.length - 1, helpViewerIndex + 1); updateStatus(helpViewerLines[helpViewerIndex]); audio.sfxUiBlip(); return; } if (code === 'ArrowUp') { helpViewerIndex = Math.max(0, helpViewerIndex - 1); updateStatus(helpViewerLines[helpViewerIndex]); audio.sfxUiBlip(); return; } if (code === 'Home') { helpViewerIndex = 0; updateStatus(helpViewerLines[helpViewerIndex]); audio.sfxUiBlip(); return; } if (code === 'End') { helpViewerIndex = helpViewerLines.length - 1; updateStatus(helpViewerLines[helpViewerIndex]); audio.sfxUiBlip(); return; } if (code === 'Escape') { state.mode = helpViewerReturnMode; updateStatus('Closed help.'); audio.sfxUiCancel(); } } /** Handles command palette list navigation, tooltips, and execution. */ function handleCommandPaletteModeInput(code: string, key: string): void { if (commandPaletteCommands.length === 0) { state.mode = commandPaletteReturnMode; updateStatus('No commands available.'); audio.sfxUiCancel(); return; } const control = handleListControlKey(code, key, commandPaletteCommands, commandPaletteIndex, (entry) => formatCommandMenuLabel(entry)); if (control.type === 'move') { commandPaletteIndex = control.index; updateStatus(formatCommandMenuLabel(commandPaletteCommands[commandPaletteIndex])); audio.sfxUiBlip(); return; } if (code === 'Space') { const selected = commandPaletteCommands[commandPaletteIndex]; if (!selected) return; updateStatus(selected.tooltip || 'No tooltip available.'); audio.sfxUiBlip(); return; } if (control.type === 'select') { executeCommandPaletteSelection(); return; } if (control.type === 'cancel') { state.mode = commandPaletteReturnMode; updateStatus('Closed commands.'); audio.sfxUiCancel(); } } /** Handles chat compose mode including submit/cancel and inline editing keys. */ function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void { const editAction = getEditSessionAction(code); if (editAction === 'submit') { const rawMessage = state.nicknameInput; if (rawMessage.trim().length > 0) { signaling.send({ type: 'chat_message', message: rawMessage }); state.mode = 'normal'; state.nicknameInput = ''; state.cursorPos = 0; if (!/^\/me(?:\s|$)/i.test(rawMessage)) { audio.sfxUiConfirm(); } } else { state.mode = 'normal'; audio.sfxUiCancel(); updateStatus('Cancelled.'); } return; } if (editAction === 'cancel') { state.mode = 'normal'; state.nicknameInput = ''; state.cursorPos = 0; updateStatus('Cancelled.'); audio.sfxUiCancel(); return; } applyTextInputEdit(code, key, 500, ctrlKey); } /** Handles direct microphone gain editing mode with keyboard stepping and validation. */ function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean): void { if (code === 'ArrowUp' || code === 'ArrowDown' || code === 'PageUp' || code === 'PageDown') { const raw = Number(state.nicknameInput.trim()); const base = Number.isFinite(raw) ? raw : audio.getOutboundInputGain(); const multiplier = code === 'PageUp' || code === 'PageDown' ? 10 : 1; const delta = (code === 'ArrowUp' || code === 'PageUp' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP) * multiplier; const attempted = snapNumberToStep(base + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN); const next = clampMicInputGain(attempted); state.nicknameInput = formatSteppedNumber(next, MIC_INPUT_GAIN_STEP); state.cursorPos = state.nicknameInput.length; replaceTextOnNextType = false; audio.setOutboundInputGain(next); updateStatus(state.nicknameInput); if (Math.abs(next - base) < 1e-9 || Math.abs(next - attempted) > 1e-9) { audio.sfxUiCancel(); } else { audio.sfxUiBlip(); } return; } const editAction = getEditSessionAction(code); if (editAction === 'submit') { const value = Number(state.nicknameInput.trim()); if (!Number.isFinite(value)) { updateStatus(`Volume must be between ${MIC_CALIBRATION_MIN_GAIN} and ${MIC_CALIBRATION_MAX_GAIN}.`); audio.sfxUiCancel(); return; } const snapped = snapNumberToStep(value, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN); if (snapped < MIC_CALIBRATION_MIN_GAIN || snapped > MIC_CALIBRATION_MAX_GAIN) { updateStatus(`Volume must be between ${MIC_CALIBRATION_MIN_GAIN} and ${MIC_CALIBRATION_MAX_GAIN}.`); audio.sfxUiCancel(); return; } const applied = audio.setOutboundInputGain(snapped); persistMicInputGain(applied); state.mode = 'normal'; replaceTextOnNextType = false; restoreLoopbackAfterMicGainEdit(); updateStatus(`Microphone gain set to ${formatSteppedNumber(applied, MIC_INPUT_GAIN_STEP)}.`); audio.sfxUiConfirm(); return; } if (editAction === 'cancel') { state.mode = 'normal'; replaceTextOnNextType = false; restoreLoopbackAfterMicGainEdit(); updateStatus('Cancelled.'); audio.sfxUiCancel(); return; } applyTextInputEdit(code, key, 8, ctrlKey, true); } /** Handles effect menu list navigation and selection. */ function handleEffectSelectModeInput(code: string, key: string): void { const control = handleListControlKey(code, key, EFFECT_SEQUENCE, state.effectSelectIndex, (effect) => effect.label); if (control.type === 'move') { state.effectSelectIndex = control.index; updateStatus(EFFECT_SEQUENCE[state.effectSelectIndex].label); audio.sfxUiBlip(); return; } if (control.type === 'select') { const selected = EFFECT_SEQUENCE[state.effectSelectIndex]; const effect = audio.setOutboundEffect(selected.id); state.mode = 'normal'; updateStatus(effect.label); audio.sfxUiBlip(); return; } if (control.type === 'cancel') { state.mode = 'normal'; updateStatus('Cancelled.'); audio.sfxUiCancel(); } } /** Handles list navigation for nearby/known users and teleport-on-select. */ function handleListModeInput(code: string, key: string): void { if (state.sortedPeerIds.length === 0) { state.mode = 'normal'; return; } if (code === 'ArrowLeft' || code === 'ArrowRight') { const peerId = state.sortedPeerIds[state.listIndex]; const entry = state.peers.get(peerId); if (!entry) return; const current = getPeerListenGainForNickname(entry.nickname); const delta = code === 'ArrowRight' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP; const attempted = snapNumberToStep(current + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN); const next = clampMicInputGain(attempted); setPeerListenGainForNickname(entry.nickname, next); peerManager.setPeerListenGain(peerId, next); updateStatus(`${entry.nickname} volume ${formatSteppedNumber(next, MIC_INPUT_GAIN_STEP)}.`); if (Math.abs(next - current) < 1e-9 || Math.abs(next - attempted) > 1e-9) { audio.sfxUiCancel(); } else { audio.sfxUiBlip(); } return; } const control = handleListControlKey( code, key, state.sortedPeerIds, state.listIndex, (peerId) => state.peers.get(peerId)?.nickname ?? '', ); if (control.type === 'move') { state.listIndex = control.index; const entry = state.peers.get(state.sortedPeerIds[state.listIndex]); if (!entry) return; const gainPhrase = `volume ${formatSteppedNumber(getPeerListenGainForNickname(entry.nickname), MIC_INPUT_GAIN_STEP)}`; updateStatus( `${entry.nickname}, ${gainPhrase}, ${distanceDirectionPhrase(state.player.x, state.player.y, entry.x, entry.y)}, ${entry.x}, ${entry.y}`, ); if (control.reason === 'initial') { audio.sfxUiBlip(); } return; } if (control.type === 'select') { const entry = state.peers.get(state.sortedPeerIds[state.listIndex]); if (!entry) return; if (state.player.x === entry.x && state.player.y === entry.y) { updateStatus('Already here.'); return; } state.mode = 'normal'; startTeleportTo(entry.x, entry.y, `Moved to ${entry.nickname}.`); return; } if (control.type === 'cancel') { state.mode = 'normal'; updateStatus('Exit list mode.'); audio.sfxUiCancel(); } } /** Handles item list navigation and teleport-on-select. */ function handleListItemsModeInput(code: string, key: string): void { if (state.sortedItemIds.length === 0) { state.mode = 'normal'; return; } const control = handleListControlKey(code, key, state.sortedItemIds, state.itemListIndex, (itemId) => { const item = state.items.get(itemId); return item ? itemLabel(item) : ''; }); if (control.type === 'move') { state.itemListIndex = control.index; const item = state.items.get(state.sortedItemIds[state.itemListIndex]); if (!item) return; updateStatus( `${itemLabel(item)}, ${distanceDirectionPhrase(state.player.x, state.player.y, item.x, item.y)}, ${item.x}, ${item.y}`, ); if (control.reason === 'initial') { audio.sfxUiBlip(); } return; } if (control.type === 'select') { const item = state.items.get(state.sortedItemIds[state.itemListIndex]); if (!item) return; if (state.player.x === item.x && state.player.y === item.y) { updateStatus('Already here.'); return; } state.mode = 'normal'; startTeleportTo(item.x, item.y, `Moved to ${itemLabel(item)}.`); return; } if (control.type === 'cancel') { state.mode = 'normal'; updateStatus('Exit item list mode.'); audio.sfxUiCancel(); } } /** Handles add-item type selection and item-type tooltip readout. */ function handleAddItemModeInput(code: string, key: string): void { const itemTypeSequence = getItemTypeSequence(); if (itemTypeSequence.length === 0) { state.mode = 'normal'; updateStatus('No item types available.'); audio.sfxUiCancel(); return; } const control = handleListControlKey(code, key, itemTypeSequence, state.addItemTypeIndex, (itemType) => itemTypeLabel(itemType)); if (control.type === 'move') { state.addItemTypeIndex = control.index; updateStatus(`${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`); audio.sfxUiBlip(); return; } if (code === 'Space') { const itemType = itemTypeSequence[state.addItemTypeIndex]; const tooltip = getItemTypeTooltip(itemType); updateStatus(tooltip ? tooltip : 'No tooltip available.'); audio.sfxUiBlip(); return; } if (control.type === 'select') { signaling.send({ type: 'item_add', itemType: itemTypeSequence[state.addItemTypeIndex] }); state.mode = 'normal'; return; } if (control.type === 'cancel') { state.mode = 'normal'; updateStatus('Cancelled.'); audio.sfxUiCancel(); } } /** Handles generic selected-item list flow used by pickup/delete/edit/use/inspect contexts. */ function handleSelectItemModeInput(code: string, key: string): void { itemInteractionController.handleSelectItemModeInput(code, key); } /** Handles item-management action menu (`z`) for the selected square item. */ function handleItemManageOptionsModeInput(code: string, key: string): void { itemInteractionController.handleItemManageOptionsModeInput(code, key); } /** Handles target-user selection for item transfer action. */ function handleItemManageTransferUserModeInput(code: string, key: string): void { itemInteractionController.handleItemManageTransferUserModeInput(code, key); } /** Handles standardized yes/no confirmation for pending item-management actions. */ function handleConfirmYesNoModeInput(code: string, key: string): void { itemInteractionController.handleConfirmYesNoModeInput(code, key); } /** Handles top-level Shift+Z admin menu action selection. */ function handleAdminMenuModeInput(code: string, key: string): void { adminController.handleAdminMenuModeInput(code, key); } /** Handles role list selection flow, including add-role entry. */ function handleAdminRoleListModeInput(code: string, key: string): void { adminController.handleAdminRoleListModeInput(code, key); } /** Handles role permission toggle and delete flow. */ function handleAdminRolePermissionListModeInput(code: string, key: string): void { adminController.handleAdminRolePermissionListModeInput(code, key); } /** Handles replacement-role selection while deleting a role. */ function handleAdminRoleDeleteReplacementModeInput(code: string, key: string): void { adminController.handleAdminRoleDeleteReplacementModeInput(code, key); } /** Handles user list selection for change-role/ban/unban flows. */ function handleAdminUserListModeInput(code: string, key: string): void { adminController.handleAdminUserListModeInput(code, key); } /** Handles role selection for a previously selected user target. */ function handleAdminUserRoleSelectModeInput(code: string, key: string): void { adminController.handleAdminUserRoleSelectModeInput(code, key); } /** Handles yes/no confirmation for delete-account admin flow. */ function handleAdminUserDeleteConfirmModeInput(code: string, key: string): void { adminController.handleAdminUserDeleteConfirmModeInput(code, key); } /** Handles text edit for new-role creation from admin role list. */ function handleAdminRoleNameEditModeInput(code: string, key: string, ctrlKey: boolean): void { adminController.handleAdminRoleNameEditModeInput(code, key, ctrlKey); } const itemPropertyEditor = createItemPropertyEditor({ state, signalingSend: (message) => signaling.send(message as OutgoingMessage), getItemPropertyValue, itemPropertyLabel, isItemPropertyEditable, getItemPropertyOptionValues, openItemPropertyOptionSelect, describeItemPropertyHelp, getItemPropertyMetadata, validateNumericItemPropertyInput, applyTextInputEdit, setReplaceTextOnNextType: (value) => { replaceTextOnNextType = value; }, suppressItemPropertyEchoMs: (ms) => { suppressItemPropertyEchoUntilMs = Math.max(suppressItemPropertyEchoUntilMs, Date.now() + Math.max(0, ms)); }, onPreviewPropertyChange: (item, key, value) => { itemBehaviorRegistry.onPropertyPreviewChange(item, key, value); }, updateStatus, sfxUiBlip: () => audio.sfxUiBlip(), sfxUiCancel: () => audio.sfxUiCancel(), }); /** Handles nickname edit mode submission/cancel and text editing keys. */ function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): void { const editAction = getEditSessionAction(code); if (editAction === 'submit') { const clean = sanitizeName(state.nicknameInput); if (clean) { const payload: OutgoingMessage = { type: 'update_nickname', nickname: clean }; signaling.send(payload); audio.sfxUiConfirm(); } else { updateStatus('Cancelled.'); audio.sfxUiCancel(); } state.mode = 'normal'; replaceTextOnNextType = false; return; } if (editAction === 'cancel') { state.mode = 'normal'; replaceTextOnNextType = false; updateStatus('Cancelled.'); audio.sfxUiCancel(); return; } applyTextInputEdit(code, key, NICKNAME_MAX_LENGTH, ctrlKey, true); } function handleModeInput(input: ModeInput): void { if (itemBehaviorRegistry.handleModeInput(state.mode, input)) { return; } dispatchModeInput({ mode: state.mode, input, handlers: { nickname: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => handleNicknameModeInput(currentCode, currentKey, currentCtrlKey), chat: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => handleChatModeInput(currentCode, currentKey, currentCtrlKey), micGainEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => handleMicGainEditModeInput(currentCode, currentKey, currentCtrlKey), commandPalette: ({ code: currentCode, key: currentKey }) => handleCommandPaletteModeInput(currentCode, currentKey), effectSelect: ({ code: currentCode, key: currentKey }) => handleEffectSelectModeInput(currentCode, currentKey), helpView: ({ code: currentCode }) => handleHelpViewModeInput(currentCode), listUsers: ({ code: currentCode, key: currentKey }) => handleListModeInput(currentCode, currentKey), listItems: ({ code: currentCode, key: currentKey }) => handleListItemsModeInput(currentCode, currentKey), addItem: ({ code: currentCode, key: currentKey }) => handleAddItemModeInput(currentCode, currentKey), selectItem: ({ code: currentCode, key: currentKey }) => handleSelectItemModeInput(currentCode, currentKey), itemManageOptions: ({ code: currentCode, key: currentKey }) => handleItemManageOptionsModeInput(currentCode, currentKey), itemManageTransferUser: ({ code: currentCode, key: currentKey }) => handleItemManageTransferUserModeInput(currentCode, currentKey), confirmYesNo: ({ code: currentCode, key: currentKey }) => handleConfirmYesNoModeInput(currentCode, currentKey), adminMenu: ({ code: currentCode, key: currentKey }) => handleAdminMenuModeInput(currentCode, currentKey), adminRoleList: ({ code: currentCode, key: currentKey }) => handleAdminRoleListModeInput(currentCode, currentKey), adminRolePermissionList: ({ code: currentCode, key: currentKey }) => handleAdminRolePermissionListModeInput(currentCode, currentKey), adminRoleDeleteReplacement: ({ code: currentCode, key: currentKey }) => handleAdminRoleDeleteReplacementModeInput(currentCode, currentKey), adminUserList: ({ code: currentCode, key: currentKey }) => handleAdminUserListModeInput(currentCode, currentKey), adminUserRoleSelect: ({ code: currentCode, key: currentKey }) => handleAdminUserRoleSelectModeInput(currentCode, currentKey), adminUserDeleteConfirm: ({ code: currentCode, key: currentKey }) => handleAdminUserDeleteConfirmModeInput(currentCode, currentKey), adminRoleNameEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => handleAdminRoleNameEditModeInput(currentCode, currentKey, currentCtrlKey), itemProperties: ({ code: currentCode, key: currentKey }) => itemPropertyEditor.handleItemPropertiesModeInput(currentCode, currentKey), itemPropertyEdit: ({ code: currentCode, key: currentKey, ctrlKey: currentCtrlKey }) => itemPropertyEditor.handleItemPropertyEditModeInput(currentCode, currentKey, currentCtrlKey), itemPropertyOptionSelect: ({ code: currentCode, key: currentKey }) => itemPropertyEditor.handleItemPropertyOptionSelectModeInput(currentCode, currentKey), }, onNormalMode: handleNormalModeInput, }); } /** Enumerates audio devices, updates selectors, and persists preferred choices. */ async function populateAudioDevices(): Promise { await mediaSession.populateAudioDevices(); } /** Opens settings modal and focuses device controls. */ function openSettings(): void { lastFocusedElement = document.activeElement; dom.settingsModal.classList.remove('hidden'); void populateAudioDevices(); dom.audioInputSelect.focus(); } /** Closes settings modal and restores focus back to prior element or game canvas. */ function closeSettings(): void { dom.settingsModal.classList.add('hidden'); if (lastFocusedElement instanceof HTMLElement) { lastFocusedElement.focus(); } else { dom.canvas.focus(); } } setupKeyboardInputHandlers({ dom: { settingsModal: dom.settingsModal, canvas: dom.canvas, }, state, isTextEditingMode, closeSettings, hasBlockedArrowTeleport: (code) => Boolean(activeTeleport && code.startsWith('Arrow')), handleModeInput, canOpenCommandPaletteInMode, openCommandPalette, getModeKeyUpTarget: (activeMode) => itemBehaviorRegistry.getModeKeyUpTarget(activeMode, commandPaletteReturnMode), onModeKeyUp: (mode, { code, shiftKey }) => { itemBehaviorRegistry.handleModeKeyUp(mode, { code, shiftKey, }); }, pasteIntoActiveTextInput, updateStatus, setReplaceTextOnNextType: (value) => { replaceTextOnNextType = value; }, }); setupDomUiHandlers({ dom, updateConnectAvailability, connect, disconnect, openSettings, closeSettings, updateStatus, sfxUiBlip: () => audio.sfxUiBlip(), setupLocalMedia, setPreferredInput: (id, name) => { mediaSession.setPreferredInput(id, name); }, setPreferredOutput: (id, name) => { mediaSession.setPreferredOutput(id, name); }, updateDeviceSummary, setOutputDevice: (id) => peerManager.setOutputDevice(id), }); authController.setupUiHandlers({ connect, }); authController.initializeUi(); updateDeviceSummary(); setConnectionStatus( isVersionReloadedSession() ? 'Client updated, please reconnect.' : 'Welcome to the Chat Grid, your immersive audio playground. Configure your audio, then Log in or register to join the grid.', );