docs: complete jsdoc pass and enforce newest-first changelog order

This commit is contained in:
Jage9
2026-02-22 17:23:33 -05:00
parent 8da737150e
commit 48fd90023e
9 changed files with 107 additions and 4 deletions

View File

@@ -3,11 +3,11 @@
{
"date": "February 22, 2026",
"items": [
"Added support for Dropbox links and HTTP streams for item sounds and radio stations.",
"You will no longer repeatedly hit walls.",
"Added user volume control and calibration.",
"Moved Use item to Enter Key instead of u, moved speak users to U from Shift U.",
"Added up/down arrows and page up/page down for numeric field adjustment.",
"Moved Use item to Enter Key instead of u, moved speak users to U from Shift U."
"Added user volume control and calibration.",
"Added support for Dropbox links and HTTP streams for item sounds and radio stations.",
"You will no longer repeatedly hit walls."
]
},
{

View File

@@ -145,6 +145,7 @@ export function connectEffectChain(
return runtime;
}
/** Generates a synthetic impulse buffer used by the reverb convolver effect. */
function createImpulseResponse(audioCtx: AudioContext, duration: number, decay: number): AudioBuffer {
const length = Math.floor(audioCtx.sampleRate * duration);
const impulse = audioCtx.createBuffer(2, length, audioCtx.sampleRate);

View File

@@ -25,6 +25,7 @@ type EmitSpatialConfig = {
const ITEM_EMIT_BASE_GAIN = 0.3;
/** Maps a 0-100 speed control to playback-rate range used by emitted audio. */
function resolveEmitPlaybackRate(raw: unknown): number {
const speed = Number(raw);
const clamped = Number.isFinite(speed) ? Math.max(0, Math.min(100, speed)) : 50;
@@ -34,6 +35,7 @@ function resolveEmitPlaybackRate(raw: unknown): number {
return 1 + ((clamped - 50) / 50) * 1;
}
/** Sets browser-specific preserve-pitch flags when changing element playback rate. */
function setElementPreservesPitch(element: HTMLAudioElement, enabled: boolean): void {
const target = element as HTMLAudioElement & {
preservesPitch?: boolean;
@@ -45,6 +47,7 @@ function setElementPreservesPitch(element: HTMLAudioElement, enabled: boolean):
if ('webkitPreservesPitch' in target) target.webkitPreservesPitch = enabled;
}
/** Resolves effective emit playback/pitch settings from item params with global fallbacks. */
function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitch: boolean } {
const globals = getItemTypeGlobalProperties(item.type);
const speed = resolveEmitPlaybackRate(item.params.emitSoundSpeed ?? globals.emitSoundSpeed ?? 50);

View File

@@ -50,6 +50,7 @@ export function normalizeRadioChannel(channel: unknown): RadioChannelMode {
return (RADIO_CHANNEL_OPTIONS as readonly string[]).includes(normalized) ? normalized : 'stereo';
}
/** Connects a shared radio media source according to channel mode. */
function connectRadioChannelSource(
audioCtx: AudioContext,
sharedSource: MediaElementAudioSourceNode,
@@ -109,6 +110,7 @@ function connectRadioChannelSource(
};
}
/** Returns whether a hostname belongs to Dropbox domains that need proxy support. */
function isDropboxHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return host.endsWith('dropbox.com') || host.endsWith('dropboxusercontent.com');
@@ -138,6 +140,7 @@ export function getProxyUrlForStream(streamUrl: string): string {
return proxy.toString();
}
/** Appends a cache-buster query parameter to avoid stale stream buffers between sessions. */
function freshStreamUrl(streamUrl: string): string {
const playbackSource = shouldProxyStreamUrl(streamUrl) ? getProxyUrlForStream(streamUrl) : streamUrl;
try {

View File

@@ -80,17 +80,20 @@ export function normalizeDegrees(value: number): number {
return wrapped < 0 ? wrapped + 360 : wrapped;
}
/** Computes compass bearing from source to listener where 0 is north and 90 is east. */
function bearingFromSourceToListener(dx: number, dy: number): number {
// 0 degrees is north (+y), 90 is east (+x), matching screen-reader compass wording.
const degrees = Math.atan2(dx, dy) * (180 / Math.PI);
return normalizeDegrees(degrees);
}
/** Returns shortest absolute angular difference in degrees on a circle. */
function angularDifferenceDeg(a: number, b: number): number {
const raw = Math.abs(normalizeDegrees(a) - normalizeDegrees(b));
return raw > 180 ? 360 - raw : raw;
}
/** Computes directional attenuation profile based on listener angle vs source facing. */
function resolveDirectionalProfile(
dx: number,
dy: number,

View File

@@ -102,6 +102,7 @@ export function mapTextInputKey(code: string, key: string): string {
return key;
}
/** Returns whether a character should be treated as part of a word for Ctrl+Arrow navigation. */
function isWordCharacter(ch: string): boolean {
return /[A-Za-z0-9_'\u2019]/.test(ch);
}
@@ -121,6 +122,7 @@ export function moveCursorWordRight(text: string, cursorPos: number): number {
return pos;
}
/** Returns the contiguous word under the current cursor index, or null when none. */
function wordAtCursor(text: string, cursorPos: number): string | null {
if (cursorPos < 0 || cursorPos >= text.length || !isWordCharacter(text[cursorPos])) {
return null;

View File

@@ -124,10 +124,12 @@ export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
Object.values(itemTypeEditableProperties).flatMap((keys) => keys),
);
/** Rebuilds the flattened editable-key lookup after item-type definitions are replaced. */
function rebuildEditablePropertyKeySet(): void {
EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(Object.values(itemTypeEditableProperties).flatMap((keys) => keys));
}
/** Normalizes server-provided property metadata into strict client metadata shape. */
function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefined): Record<string, ItemPropertyMetadata> {
if (!raw) return {};
const normalized: Record<string, ItemPropertyMetadata> = {};
@@ -166,38 +168,47 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
return normalized;
}
/** Returns current timezone option list used by clock item properties. */
export function getClockTimeZoneOptions(): string[] {
return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)];
}
/** Returns default timezone used by clock items when no override is set. */
export function getDefaultClockTimeZone(): string {
return getClockTimeZoneOptions()[0] ?? 'America/Detroit';
}
/** Returns item-type display order for add-item menus. */
export function getItemTypeSequence(): ItemType[] {
return [...itemTypeSequence];
}
/** Returns global per-type property defaults provided by server/item catalog. */
export function getItemTypeGlobalProperties(itemType: ItemType): Record<string, string | number | boolean> {
return itemTypeGlobalProperties[itemType] ?? {};
}
/** Returns item-type tooltip text, if defined. */
export function getItemTypeTooltip(itemType: ItemType): string | undefined {
return itemTypeTooltips[itemType];
}
/** Returns metadata for a given item property on a specific type. */
export function getItemPropertyMetadata(itemType: ItemType, key: string): ItemPropertyMetadata | undefined {
return itemTypePropertyMetadata[itemType]?.[key];
}
/** Returns option-list values for list-based properties, if defined. */
export function getItemPropertyOptionValues(key: string): string[] | undefined {
return optionItemPropertyValues[key];
}
/** Returns human-facing label for an item type. */
export function itemTypeLabel(type: ItemType): string {
return itemTypeLabels[type] ?? type;
}
/** Returns human-facing label for a property key. */
export function itemPropertyLabel(key: string): string {
if (key === 'use24Hour') return 'use 24 hour format';
if (key === 'emitRange') return 'emit range';
@@ -215,10 +226,12 @@ export function itemPropertyLabel(key: string): string {
return key;
}
/** Returns editable properties for one item instance/type. */
export function getEditableItemPropertyKeys(item: WorldItem): string[] {
return [...(itemTypeEditableProperties[item.type] ?? ['title'])];
}
/** Returns inspect-mode property key list (editable first, then system/global extras). */
export function getInspectItemPropertyKeys(item: WorldItem): string[] {
const editableKeys = getEditableItemPropertyKeys(item);
const seen = new Set(editableKeys);
@@ -260,6 +273,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] {
return allKeys;
}
/** Applies server-supplied UI/catalog definitions for item types, properties, and options. */
export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): void {
if (!uiDefinitions) return;

View File

@@ -169,6 +169,7 @@ 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(/^\/+/, '')}`;
@@ -237,6 +238,7 @@ loadMicInputGain();
void loadHelp();
void loadChangelog();
/** Fetches a required DOM element and casts it to the requested element type. */
function requiredById<T extends HTMLElement>(id: string): T {
const found = document.getElementById(id);
if (!found) {
@@ -245,6 +247,7 @@ function requiredById<T extends HTMLElement>(id: string): T {
return found as T;
}
/** Returns the configured display timezone when valid, otherwise the default fallback. */
function resolveDisplayTimeZone(): string {
const configured = String(window.CHGRID_TIME_ZONE ?? '').trim();
if (configured) {
@@ -258,6 +261,7 @@ function resolveDisplayTimeZone(): string {
return DEFAULT_DISPLAY_TIME_ZONE;
}
/** Formats epoch milliseconds as `YYYY-MM-DD HH:mm` in the configured display timezone. */
function formatTimestampMs(value: unknown): string {
const raw = Number(value);
if (!Number.isFinite(raw)) {
@@ -280,6 +284,7 @@ function formatTimestampMs(value: unknown): string {
return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}`;
}
/** 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';
@@ -287,6 +292,7 @@ function setUpdatesExpanded(expanded: boolean): void {
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: string[] = [];
dom.instructions.innerHTML = '';
@@ -312,6 +318,7 @@ function renderHelp(help: HelpData): void {
helpViewerIndex = 0;
}
/** Loads runtime help content from `help.json` and applies it when available. */
async function loadHelp(): Promise<void> {
try {
const response = await fetch(withBase('help.json'), { cache: 'no-store' });
@@ -328,6 +335,7 @@ async function loadHelp(): Promise<void> {
}
}
/** Renders changelog sections into the collapsible updates panel. */
function renderChangelog(changelog: ChangelogData): void {
dom.updatesPanel.innerHTML = '';
for (const section of changelog.sections) {
@@ -345,6 +353,7 @@ function renderChangelog(changelog: ChangelogData): void {
}
}
/** Loads changelog entries from `changelog.json` and wires the panel toggle button. */
async function loadChangelog(): Promise<void> {
try {
const response = await fetch(withBase('changelog.json'), { cache: 'no-store' });
@@ -368,6 +377,7 @@ async function loadChangelog(): Promise<void> {
}
}
/** Announces status text via ARIA with brief de-duplication and auto-clear timing. */
function updateStatus(message: string): void {
const normalized = String(message)
.replace(/\s*\n+\s*/g, ' ')
@@ -394,10 +404,12 @@ function updateStatus(message: string): void {
}, 4000);
}
/** 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 {
if (state.running) {
dom.connectButton.disabled = true;
@@ -407,6 +419,7 @@ function updateConnectAvailability(): void {
dom.connectButton.disabled = connecting || !hasNickname;
}
/** Restores persisted outbound effect levels from local storage. */
function loadEffectLevels(): void {
const raw = localStorage.getItem(EFFECT_LEVELS_STORAGE_KEY);
if (!raw) return;
@@ -420,10 +433,12 @@ function loadEffectLevels(): void {
}
}
/** Persists current outbound effect levels to local storage. */
function persistEffectLevels(): void {
localStorage.setItem(EFFECT_LEVELS_STORAGE_KEY, JSON.stringify(audio.getEffectLevels()));
}
/** Restores local audio-layer toggles and applies initial voice-layer state. */
function loadAudioLayerState(): void {
const raw = localStorage.getItem(AUDIO_LAYER_STATE_STORAGE_KEY);
if (raw) {
@@ -442,15 +457,18 @@ function loadAudioLayerState(): void {
audio.setVoiceLayerEnabled(audioLayers.voice);
}
/** Persists current audio-layer toggles to local storage. */
function persistAudioLayerState(): void {
localStorage.setItem(AUDIO_LAYER_STATE_STORAGE_KEY, JSON.stringify(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 raw = localStorage.getItem(MIC_INPUT_GAIN_STORAGE_KEY);
if (!raw) {
@@ -461,10 +479,12 @@ function loadMicInputGain(): void {
audio.setOutboundInputGain(clampMicInputGain(parsed));
}
/** Persists microphone input gain to local storage. */
function persistMicInputGain(value: number): void {
localStorage.setItem(MIC_INPUT_GAIN_STORAGE_KEY, String(value));
}
/** Applies current layer toggles to peer voice, media streams, and item emitters. */
async function applyAudioLayerState(): Promise<void> {
audio.setVoiceLayerEnabled(audioLayers.voice);
if (audioLayers.voice) {
@@ -476,6 +496,7 @@ async function applyAudioLayerState(): Promise<void> {
await itemEmitRuntime.setLayerEnabled(audioLayers.item, state.items.values());
}
/** Toggles a single audio layer and applies the change immediately. */
function toggleAudioLayer(layer: keyof AudioLayerState): void {
audioLayers = { ...audioLayers, [layer]: !audioLayers[layer] };
persistAudioLayerState();
@@ -484,6 +505,7 @@ function toggleAudioLayer(layer: keyof AudioLayerState): void {
audio.sfxUiBlip();
}
/** Appends a chat/system line to the bounded status history buffer. */
function pushChatMessage(message: string): void {
messageBuffer.push(message);
if (messageBuffer.length > 300) {
@@ -493,6 +515,7 @@ function pushChatMessage(message: string): void {
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;
@@ -508,6 +531,7 @@ function classifySystemMessageSound(message: string): keyof typeof SYSTEM_SOUND_
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 '';
@@ -524,6 +548,7 @@ function resolveIncomingSoundUrl(url: string): string {
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.');
@@ -545,6 +570,7 @@ function navigateChatBuffer(target: 'prev' | 'next' | 'first' | 'last'): void {
audio.sfxUiBlip();
}
/** Updates compact input/output device summary labels in the pre-connect UI. */
function updateDeviceSummary(): void {
if (preferredInputDeviceId) {
const text = dom.audioInputSelect.selectedOptions[0]?.text || preferredInputDeviceName || 'Saved microphone';
@@ -563,16 +589,19 @@ function updateDeviceSummary(): void {
}
}
/** 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);
@@ -585,6 +614,7 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo
return { range, directional, facingDeg };
}
/** Enters help-view mode and announces the first help line. */
function openHelpViewer(): void {
if (helpViewerLines.length === 0) {
updateStatus('Help unavailable.');
@@ -597,15 +627,18 @@ function openHelpViewer(): void {
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' | 'inspect', items: WorldItem[]): void {
if (items.length === 0) {
updateStatus('No items available.');
@@ -620,6 +653,7 @@ function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'ins
audio.sfxUiBlip();
}
/** Opens item property browsing/editing mode for one item. */
function beginItemProperties(item: WorldItem, showAll = false): void {
state.selectedItemId = item.id;
state.mode = 'itemProperties';
@@ -638,10 +672,12 @@ function beginItemProperties(item: WorldItem, showAll = false): void {
audio.sfxUiBlip();
}
/** Sends an item-use request for the selected item. */
function useItem(item: WorldItem): void {
signaling.send({ type: 'item_use', itemId: item.id });
}
/** Opens option-list selection mode for list-based item properties. */
function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
const options = getItemPropertyOptionValues(key);
if (!options || options.length === 0) {
@@ -657,6 +693,7 @@ function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
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;
@@ -665,6 +702,7 @@ function textInputMaxLengthForMode(mode: typeof state.mode): number | null {
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) {
@@ -678,10 +716,12 @@ function pasteIntoActiveTextInput(raw: string): boolean {
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';
}
/** 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;
@@ -731,6 +771,7 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe
}
}
/** Returns a formatted display value for an item property key, with per-key normalization. */
function getItemPropertyValue(item: WorldItem, key: string): string {
const toSoundDisplayName = (rawValue: unknown): string => {
const raw = String(rawValue ?? '').trim();
@@ -783,6 +824,7 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
return '';
}
/** Infers value type for item-property help when metadata is missing. */
function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined {
if (key === 'useSound' || key === 'emitSound') return 'sound';
if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean';
@@ -814,6 +856,7 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde
return 'text';
}
/** Provides tooltip fallbacks for inspect-only/system item properties. */
function getFallbackInspectPropertyTooltip(key: string): string | undefined {
if (key === 'type') return 'The item type identifier.';
if (key === 'x') return 'X coordinate on the grid.';
@@ -831,10 +874,12 @@ function getFallbackInspectPropertyTooltip(key: string): string | undefined {
return undefined;
}
/** Returns whether a property is editable for the given item type. */
function isItemPropertyEditable(item: WorldItem, key: string): boolean {
return getEditableItemPropertyKeys(item).includes(key);
}
/** Builds spoken tooltip/help text for the current item property row. */
function describeItemPropertyHelp(item: WorldItem, key: string): string {
const metadata = getItemPropertyMetadata(item.type, key);
const parts: string[] = [];
@@ -868,6 +913,7 @@ function describeItemPropertyHelp(item: WorldItem, key: string): string {
return parts.join(' ');
}
/** Validates and normalizes numeric item-property edits using metadata ranges/steps. */
function validateNumericItemPropertyInput(
item: WorldItem,
key: string,
@@ -896,10 +942,12 @@ function validateNumericItemPropertyInput(
return { ok: true, value: parsed };
}
/** 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);
@@ -907,6 +955,7 @@ function distanceDirectionPhrase(px: number, py: number, tx: number, ty: number)
return `${distance} ${squareWord(distance)} ${direction}`;
}
/** Persists current local player coordinates for reconnect/refresh restore. */
function persistPlayerPosition(): void {
try {
localStorage.setItem(
@@ -918,10 +967,12 @@ function persistPlayerPosition(): void {
}
}
/** Picks one random footstep sample URL. */
function randomFootstepUrl(): string {
return FOOTSTEP_SOUND_URLS[Math.floor(Math.random() * FOOTSTEP_SOUND_URLS.length)];
}
/** Main animation/update loop for movement, spatial audio, and rendering. */
function gameLoop(): void {
if (!state.running) return;
handleMovement();
@@ -933,6 +984,7 @@ function gameLoop(): void {
requestAnimationFrame(gameLoop);
}
/** Applies held-arrow movement with bounds checks, tile cues, and server position sync. */
function handleMovement(): void {
if (state.mode !== 'normal') return;
const now = Date.now();
@@ -986,6 +1038,7 @@ function handleMovement(): void {
}
}
/** Checks microphone permission state when Permissions API support is available. */
async function checkMicPermission(): Promise<boolean> {
const permissionApi = navigator.permissions;
if (!permissionApi?.query) return true;
@@ -997,6 +1050,7 @@ async function checkMicPermission(): Promise<boolean> {
}
}
/** Starts local microphone capture and rebuilds the outbound track pipeline. */
async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
stopLocalMedia();
@@ -1023,6 +1077,7 @@ async function setupLocalMedia(audioDeviceId = ''): Promise<void> {
await peerManager.replaceOutgoingTrack(outboundStream);
}
/** Runs a short RMS sample to estimate and apply a usable microphone input gain. */
async function calibrateMicInputGain(): Promise<void> {
if (calibratingMicInput) {
updateStatus('Mic calibration already running.');
@@ -1102,6 +1157,7 @@ async function calibrateMicInputGain(): Promise<void> {
audio.sfxUiConfirm();
}
/** Stops local capture tracks and clears outbound stream references. */
function stopLocalMedia(): void {
if (localStream) {
localStream.getTracks().forEach((track) => track.stop());
@@ -1110,6 +1166,7 @@ function stopLocalMedia(): void {
outboundStream = null;
}
/** Maps browser media/capture errors to user-facing remediation text. */
function describeMediaError(error: unknown): string {
if (error instanceof DOMException) {
if (error.name === 'NotAllowedError') return 'Microphone blocked. Allow mic access in browser site settings.';
@@ -1121,6 +1178,7 @@ function describeMediaError(error: unknown): string {
return 'Audio setup failed. Check browser permissions and selected input device.';
}
/** Performs end-to-end connect flow: validation, media setup, then signaling connection. */
async function connect(): Promise<void> {
if (connecting || state.running) {
return;
@@ -1192,6 +1250,7 @@ async function connect(): Promise<void> {
}
}
/** Tears down active session state, media, peers, and UI back to pre-connect mode. */
function disconnect(): void {
const wasRunning = state.running;
if (state.running) {
@@ -1290,6 +1349,7 @@ const onMessage = createOnMessageHandler({
},
});
/** Toggles local microphone track mute state. */
function toggleMute(): void {
state.isMuted = !state.isMuted;
if (localStream) {
@@ -1299,6 +1359,7 @@ function toggleMute(): void {
updateStatus(state.isMuted ? 'Muted.' : 'Unmuted.');
}
/** Handles command-mode keybindings while in main gameplay mode. */
function handleNormalModeInput(code: string, shiftKey: boolean): void {
if (code !== 'Escape' && pendingEscapeDisconnect) {
pendingEscapeDisconnect = false;
@@ -1610,6 +1671,7 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
}
}
/** Handles linear help viewer navigation and exit keys. */
function handleHelpViewModeInput(code: string): void {
if (helpViewerLines.length === 0) {
state.mode = 'normal';
@@ -1649,6 +1711,7 @@ function handleHelpViewModeInput(code: string): void {
}
}
/** 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') {
@@ -1679,6 +1742,7 @@ function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void
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());
@@ -1733,6 +1797,7 @@ function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean)
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') {
@@ -1758,6 +1823,7 @@ function handleEffectSelectModeInput(code: string, key: string): void {
}
}
/** 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';
@@ -1802,6 +1868,7 @@ function handleListModeInput(code: string, key: string): void {
}
}
/** Handles item list navigation and teleport-on-select. */
function handleListItemsModeInput(code: string, key: string): void {
if (state.sortedItemIds.length === 0) {
state.mode = 'normal';
@@ -1847,6 +1914,7 @@ function handleListItemsModeInput(code: string, key: string): void {
}
}
/** Handles add-item type selection and item-type tooltip readout. */
function handleAddItemModeInput(code: string, key: string): void {
const itemTypeSequence = getItemTypeSequence();
if (itemTypeSequence.length === 0) {
@@ -1881,6 +1949,7 @@ function handleAddItemModeInput(code: string, key: string): void {
}
}
/** Handles generic selected-item list flow used by pickup/delete/edit/use/inspect contexts. */
function handleSelectItemModeInput(code: string, key: string): void {
if (state.selectedItemIds.length === 0) {
state.mode = 'normal';
@@ -1963,6 +2032,7 @@ const itemPropertyEditor = createItemPropertyEditor({
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') {
@@ -1991,10 +2061,12 @@ function handleNicknameModeInput(code: string, key: string, ctrlKey: boolean): v
applyTextInputEdit(code, key, NICKNAME_MAX_LENGTH, ctrlKey, true);
}
/** Returns whether a key code should be treated as a repeat-suppressed typing key. */
function isTypingKey(code: string): boolean {
return code.startsWith('Key') || code === 'Space';
}
/** Wires global keyboard/paste input handlers and routes events by current mode. */
function setupInputHandlers(): void {
document.addEventListener('keydown', (event) => {
const code = event.code;
@@ -2084,6 +2156,7 @@ function setupInputHandlers(): void {
});
}
/** Enumerates audio devices, updates selectors, and persists preferred choices. */
async function populateAudioDevices(): Promise<void> {
if (!navigator.mediaDevices?.enumerateDevices) {
return;
@@ -2133,6 +2206,7 @@ async function populateAudioDevices(): Promise<void> {
}
}
/** Opens settings modal and focuses device controls. */
function openSettings(): void {
lastFocusedElement = document.activeElement;
dom.settingsModal.classList.remove('hidden');
@@ -2140,6 +2214,7 @@ function openSettings(): void {
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) {
@@ -2149,6 +2224,7 @@ function closeSettings(): void {
}
}
/** Wires button/form handlers and lifecycle hooks for the main UI shell. */
function setupUiHandlers(): void {
setupDomUiHandlers({
dom,