diff --git a/src/audio/MediaService.ts b/src/audio/MediaService.ts index c062b492..2aa6a477 100644 --- a/src/audio/MediaService.ts +++ b/src/audio/MediaService.ts @@ -481,8 +481,16 @@ export class MediaService { } } - shutdown(): void { + reset(): void { this.stopAllSounds(); + this.defaultUrl = ''; + this.currentMusic = undefined; + this.mediaSession.clear(); + this.effects.shutdown(); + } + + shutdown(): void { + this.reset(); if (this.shutdownComplete) { return; } @@ -493,9 +501,6 @@ export class MediaService { } this.unsubscribePreferences?.(); this.unsubscribePreferences = null; - this.currentMusic = undefined; - this.mediaSession.clear(); - this.effects.shutdown(); } private readonly handleWindowFocus = (): void => { diff --git a/src/client.test.ts b/src/client.test.ts index 0f4d14df..d247c758 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -110,6 +110,7 @@ vi.mock('./gmcp', async () => { ...actual, GMCPClientFileTransfer: class { packageName = 'Client.FileTransfer'; + reset = vi.fn(); sendReject = vi.fn(); shutdown = vi.fn(); }, @@ -144,11 +145,8 @@ vi.mock('./mcp', () => ({ import MudClient from './client'; import { GMCPClientFileTransfer } from './gmcp'; -import { useItemsStore } from './stores/itemsStore'; import { useOutputStore } from './stores/outputStore'; -import { useSessionStore } from './stores/sessionStore'; -import { useSkillsStore } from './stores/skillsStore'; -import { useUserlistStore } from './stores/userlistStore'; +import type { Stream } from './telnet'; class MockWebSocket { static CONNECTING = 0; @@ -215,13 +213,10 @@ describe('MudClient lifecycle cleanup', () => { mockFileTransferManagerInstances.length = 0; mockPreferenceListeners.clear(); mockPreferenceSubscribe.mockClear(); + mockPreferencesState.general.localEcho = false; mockPreferencesState.sound.muteInBackground = false; mockWebSocketInstances.length = 0; - useItemsStore.getState().reset(); useOutputStore.getState().reset(); - useSessionStore.getState().reset(); - useSkillsStore.getState().reset(); - useUserlistStore.getState().reset(); vi.stubGlobal('WebSocket', MockWebSocket); Object.defineProperty(window, 'WebSocket', { configurable: true, @@ -292,43 +287,136 @@ describe('MudClient lifecycle cleanup', () => { expect(cleanupOrder).toEqual(['fileTransferManager.cleanup', 'gmcp.reset']); }); - it('clears item state during connection cleanup', () => { + it('runs disconnect resets in registration order and isolates failures', () => { const client = new MudClient('example.test', 443); + const resetOrder: string[] = []; + const resetError = new Error('reset failed'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + client.registerDisconnectReset(() => resetOrder.push('first')); + client.registerDisconnectReset(() => { + resetOrder.push('second'); + throw resetError; + }); + client.registerDisconnectReset(() => resetOrder.push('third')); client.connect(); - useItemsStore.getState().setLocationItems('room', [ - { id: 'lantern', name: 'Lantern' }, - ]); - useItemsStore.getState().setLocationItems('inv', [ - { id: 'coin', name: 'Coin' }, - ]); client.close(); - expect(useItemsStore.getState().itemsByLocation).toEqual({}); - expect(useItemsStore.getState().hasReceivedList).toBe(false); + expect(resetOrder).toEqual(['first', 'second', 'third']); + expect(consoleError).toHaveBeenCalledWith('Disconnect reset failed:', resetError); }); - it('clears session, skills, and userlist state during connection cleanup', () => { + it('runs disconnect resets once per disconnect and again after reconnect', () => { const client = new MudClient('example.test', 443); + const reset = vi.fn(); + client.registerDisconnectReset(reset); client.connect(); - useSessionStore.getState().setPlayer('q', 'Q the Mongoose'); - useSessionStore.getState().setRoomId('101'); - useSkillsStore.getState().setGroups([{ name: 'Combat', rank: 'Adept' }]); - useSkillsStore.getState().setList({ group: 'combat', list: ['slash'] }); - useUserlistStore.getState().setPlayers([ - { Object: 'q', Name: 'Q', Icon: 0, away: false, idle: false }, - ]); + const firstSocket = mockWebSocketInstances[0]; + + client.close(); + firstSocket.onclose?.(new Event('close')); + + expect(reset).toHaveBeenCalledTimes(1); + + client.connect(); + client.close(); + + expect(reset).toHaveBeenCalledTimes(2); + }); + + it('automatically registers MCP package disconnect resets', () => { + const client = new MudClient('example.test', 443); + const mcpPackage = client.registerMcpPackage( + class { + packageName = 'test-package'; + reset = vi.fn(); + } as never, + ) as unknown as { reset: ReturnType }; + client.connect(); + + client.close(); + + expect(mcpPackage.reset).toHaveBeenCalledOnce(); + }); + + it('clears a closed local transport and rejects later sends', () => { + const client = new MudClient('example.test', 443); + const closeListeners: Array<() => void> = []; + const stream = { + close: vi.fn(() => { + closeListeners.forEach((listener) => { + listener(); + }); + }), + on: vi.fn((event: string, callback: () => void) => { + if (event === 'close') closeListeners.push(callback); + }), + write: vi.fn(), + } as unknown as Stream & { close(): void }; + client.connectLocal(stream); + + client.send('look\r\n'); + expect(stream.write).toHaveBeenCalledOnce(); + + client.close(); + + expect(stream.close).toHaveBeenCalledOnce(); + expect(client.connected).toBe(false); + expect( + client as unknown as { localMode: boolean; localStream?: Stream }, + ).toMatchObject({ + localMode: false, + localStream: undefined, + }); + expect(() => client.send('look\r\n')).toThrow( + new Error('Cannot send while disconnected'), + ); + expect(stream.write).toHaveBeenCalledOnce(); + }); + + it.each([ + ['CONNECTING', MockWebSocket.CONNECTING], + ['CLOSING', MockWebSocket.CLOSING], + ['CLOSED', MockWebSocket.CLOSED], + ])('rejects sends while the WebSocket is %s', (_label, readyState) => { + const client = new MudClient('example.test', 443); + client.connect(); + const socket = mockWebSocketInstances[0]; + socket.onopen?.(new Event('open')); + socket.readyState = readyState; + expect(() => client.send('look\r\n')).toThrow( + new Error('Cannot send while disconnected'), + ); + expect(socket.send).not.toHaveBeenCalled(); client.close(); + }); + + it('sends through a connected open WebSocket', () => { + const client = new MudClient('example.test', 443); + client.connect(); + const socket = mockWebSocketInstances[0]; + socket.onopen?.(new Event('open')); + + client.send('look\r\n'); + + expect(socket.send).toHaveBeenCalledWith('look\r\n'); + }); + + it('reports a disconnected command without adding local echo', () => { + mockPreferencesState.general.localEcho = true; + const client = new MudClient('example.test', 443); - expect(useSessionStore.getState().playerId).toBe(''); - expect(useSessionStore.getState().playerName).toBe(''); - expect(useSessionStore.getState().roomId).toBe(''); - expect(useSkillsStore.getState().groups).toEqual([]); - expect(useSkillsStore.getState().skillsByGroup).toEqual({}); - expect(useSkillsStore.getState().infoBySkill).toEqual({}); - expect(useUserlistStore.getState().players).toEqual([]); - expect(useUserlistStore.getState().hasReceivedList).toBe(false); + expect(() => client.sendCommand('look')).not.toThrow(); + + expect(useOutputStore.getState().entries).toEqual([ + { + id: 1, + type: 'error', + error: new Error('Cannot send while disconnected'), + }, + ]); + expect(mockWebSocketInstances).toEqual([]); }); it('buffers text split across frames until the line is complete', () => { diff --git a/src/client.ts b/src/client.ts index 1538e03c..c6563b17 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,23 +16,13 @@ import { McpSession, } from "./mcp"; -import { MediaService } from "./audio/MediaService"; -import { AutoreadMode, usePreferences } from "./stores/preferencesStore"; -import { WebRTCService } from "./WebRTCService"; -import FileTransferManager from "./FileTransferManager.js"; -import { useRoomStore } from "./stores/roomStore"; -import { useSpatialStore } from "./stores/spatialStore"; -import { useLiveKitStore } from "./stores/liveKitStore"; +import { MediaService } from "./audio/MediaService"; +import { AutoreadMode, usePreferences } from "./stores/preferencesStore"; +import { WebRTCService } from "./WebRTCService"; +import FileTransferManager from "./FileTransferManager.js"; import { useInputStore } from "./stores/inputStore"; -import { useItemsStore } from "./stores/itemsStore"; -import { useServerLinksStore } from "./stores/serverLinksStore"; -import { useWorldMapStore } from "./stores/worldMapStore"; import { useConnectionStore } from "./stores/connectionStore"; -import { useCharacterStatusStore } from "./stores/characterStatusStore"; import { useOutputStore } from "./stores/outputStore"; -import { useSessionStore } from "./stores/sessionStore"; -import { useSkillsStore } from "./stores/skillsStore"; -import { useUserlistStore } from "./stores/userlistStore"; function resetMidiIntentionalDisconnectFlags(): void { if (!usePreferences.getState().midi.enabled) return; @@ -73,6 +63,7 @@ class MudClient { private _autosay: boolean = false; private connectionCleanupComplete: boolean = true; private shutdownComplete: boolean = false; + private disconnectResetCallbacks: Array<() => void> = []; private cleanupCallbacks: Array<() => void> = []; get autosay(): boolean { @@ -101,11 +92,14 @@ class MudClient { this.fileTransferManager = new FileTransferManager( this.webRTCService, this.gmcp_fileTransfer, - ); - } + ); + this.registerDisconnectReset(() => this.fileTransferManager.cleanup()); + } registerMcpPackage(p: new () => MCPPackage): MCPPackage { - return this.mcpSession.registerPackage(p); + const mcpPackage = this.mcpSession.registerPackage(p); + this.registerDisconnectReset(() => mcpPackage.reset()); + return mcpPackage; } configureEditors(simpleEdit: McpSimpleEdit): void { @@ -266,40 +260,49 @@ class MudClient { } public send(data: string) { - if (this.localMode && this.localStream) { + if (this._connected && this.localMode && this.localStream) { // In local mode, write through the stream (WorkerStream -> Worker) this.localStream.write(Buffer.from(data)); - } else { - this.ws.send(data); + return; } + if ( + this._connected && + this.ws && + this.ws.readyState === WebSocket.OPEN + ) { + this.ws.send(data); + return; + } + throw new Error("Cannot send while disconnected"); } registerCleanup(callback: () => void): void { this.cleanupCallbacks.push(callback); } - - private cleanupConnection(): void { - if (this.connectionCleanupComplete) return; - this.connectionCleanupComplete = true; - this._connected = false; - this.mcpSession.reset(); - this.decoder = new TextDecoder("utf8"); + + registerDisconnectReset(callback: () => void): void { + this.disconnectResetCallbacks.push(callback); + } + + private cleanupConnection(): void { + if (this.connectionCleanupComplete) return; + this.connectionCleanupComplete = true; + this._connected = false; + for (const callback of this.disconnectResetCallbacks) { + try { + callback(); + } catch (error) { + console.error("Disconnect reset failed:", error); + } + } + this.mcpSession.reset(); + this.decoder = new TextDecoder("utf8"); this.telnetBuffer = ""; - useRoomStore.getState().reset(); // Reset room info on cleanup - useSpatialStore.getState().reset(); // Reset spatial scene on cleanup - useItemsStore.getState().reset(); - useWorldMapStore.getState().reset(); - useServerLinksStore.getState().reset(); - useInputStore.getState().resetCommands(); - useCharacterStatusStore.getState().reset(); - useSessionStore.getState().reset(); - useSkillsStore.getState().reset(); - useUserlistStore.getState().reset(); - this.fileTransferManager?.cleanup(); this.gmcp.reset(); - useLiveKitStore.getState().reset(); + this.localMode = false; + this.localStream = undefined; useConnectionStore.getState().setConnected(false); - } + } public close(): void { this.intentionalDisconnect = true; @@ -320,17 +323,23 @@ class MudClient { this.cleanupConnection(); } - public sendCommand(command: string): void { - const localEchoEnabled = usePreferences.getState().general.localEcho; - if (localEchoEnabled) { + public sendCommand(command: string): void { + if (this.autosay && !command.startsWith("-") && !command.startsWith("'")) { + command = `say ${command}`; + } + try { + this.send(`${command}\r\n`); + } catch (error) { + useOutputStore + .getState() + .addError(error instanceof Error ? error : new Error(String(error))); + return; + } + if (usePreferences.getState().general.localEcho) { useOutputStore.getState().addCommand(command); } - if (this.autosay && !command.startsWith("-") && !command.startsWith("'")) { - command = `say ${command}`; - } - this.send(`${command}\r\n`); - console.log(`> ${command}`); - } + console.log(`> ${command}`); + } /* ::= diff --git a/src/createConfiguredClient.test.ts b/src/createConfiguredClient.test.ts index ac599c2c..5eee481c 100644 --- a/src/createConfiguredClient.test.ts +++ b/src/createConfiguredClient.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type MudClient from "./client"; import { createConfiguredClient } from "./createConfiguredClient"; +import type { Stream } from "./telnet"; import { useInputStore } from "./stores/inputStore"; import { useItemsStore } from "./stores/itemsStore"; import { usePreferences } from "./stores/preferencesStore"; @@ -12,6 +13,11 @@ import { useWorldMapStore } from "./stores/worldMapStore"; import { useConnectionStore } from "./stores/connectionStore"; import { useCharacterStatusStore } from "./stores/characterStatusStore"; import { useChannelHistoryStore } from "./stores/channelHistoryStore"; +import { useLiveKitStore } from "./stores/liveKitStore"; +import { useOutputStore } from "./stores/outputStore"; +import { useRoomStore } from "./stores/roomStore"; +import { useSessionStore } from "./stores/sessionStore"; +import { useSpatialStore } from "./stores/spatialStore"; vi.mock("cacophony", () => ({ Cacophony: class { @@ -30,6 +36,11 @@ describe("createConfiguredClient", () => { useCharacterStatusStore.getState().reset(); useChannelHistoryStore.getState().reset(); useConnectionStore.getState().reset(); + useLiveKitStore.getState().reset(); + useOutputStore.getState().reset(); + useRoomStore.getState().reset(); + useSessionStore.getState().reset(); + useSpatialStore.getState().reset(); Object.defineProperty(globalThis.navigator, "geolocation", { configurable: true, value: originalGeolocation, @@ -211,6 +222,112 @@ describe("createConfiguredClient", () => { expect(useUserlistStore.getState().hasReceivedList).toBe(true); }); + it("resets session-owned feature state while preserving user history and input", () => { + client = createConfiguredClient(); + const closeListeners: Array<() => void> = []; + const stream = { + close: vi.fn(() => { + closeListeners.forEach((listener) => { + listener(); + }); + }), + on: vi.fn((event: string, callback: () => void) => { + if (event === "close") closeListeners.push(callback); + }), + write: vi.fn(), + } as unknown as Stream & { close(): void }; + client.connectLocal(stream); + + client.gmcp.require("Char").receiveRegisteredMessage("Name", { + fullname: "Q", + name: "q", + }); + client.gmcp.require("Char").receiveRegisteredMessage("Vitals", { hp: "10" }); + client.gmcp.require("Room").receiveRegisteredMessage("Info", { + num: 101, + name: "The Test Chamber", + }); + client.gmcp.require("Char.Items").receiveRegisteredMessage("List", { + location: "inv", + items: [{ id: "coin", name: "a coin" }], + }); + client.gmcp.require("Char.Skills").receiveRegisteredMessage("Groups", [ + { name: "Combat", rank: "Novice" }, + ]); + client.gmcp.require("Comm.LiveKit").receiveRegisteredMessage("room_token", { + token: "token-a", + }); + const channel = client.gmcp.require("Comm.Channel"); + channel.receiveRegisteredMessage("List", ["chat"]); + channel.receiveRegisteredMessage("Text", { + channel: "chat", + talker: "Alice", + text: "Remember me", + }); + const keystrokes = client.gmcp.require("Client.Keystrokes"); + keystrokes.receiveRegisteredMessage("Bind", { + key: "F1", + modifiers: [], + command: "look", + autosend: true, + }); + client.mcpSession.packageHandlers["dns-com-awns-displayurl"].handle({ + name: "dns-com-awns-displayurl", + keyvals: { url: "https://example.test/help" }, + }); + client.mcpSession.packageHandlers["dns-com-awns-serverinfo"].handle({ + name: "dns-com-awns-serverinfo", + keyvals: { + home_url: "https://example.test/", + help_url: "https://example.test/help", + }, + }); + client.mcpSession.packageHandlers["dns-com-awns-rehash"].handle({ + name: "dns-com-awns-rehash-commands", + keyvals: { list: "look" }, + }); + client.mcpSession.packageHandlers["dns-com-awns-visual"].handle({ + name: "dns-com-awns-visual-location", + keyvals: { id: "#100" }, + }); + useInputStore.getState().setText("unfinished command"); + client.autosay = true; + useOutputStore.getState().addMessage("connection history"); + + client.close(); + + expect(useCharacterStatusStore.getState().vitals).toBeNull(); + expect(useItemsStore.getState().itemsByLocation).toEqual({}); + expect(useLiveKitStore.getState().tokens).toEqual([]); + expect(useRoomStore.getState().roomInfo).toBeNull(); + expect(useSessionStore.getState()).toMatchObject({ + playerId: "", + playerName: "", + roomId: "", + }); + expect(useSkillsStore.getState().groups).toEqual([]); + expect(useSpatialStore.getState().spatialEntities).toEqual({}); + expect(useServerLinksStore.getState()).toMatchObject({ + homeUrl: "", + helpUrl: "", + recentUrls: [], + }); + expect(useInputStore.getState().visibleCommands).toEqual([]); + expect(useWorldMapStore.getState().locationId).toBe(""); + expect(useUserlistStore.getState().players).toEqual([]); + expect(channel.channels).toEqual([]); + expect(keystrokes.listBindings()).toEqual([]); + + expect(useChannelHistoryStore.getState().entries).toEqual([ + { id: 1, channel: "chat", talker: "Alice", text: "Remember me" }, + ]); + expect(useInputStore.getState().text).toBe("unfinished command"); + expect(useInputStore.getState().autosay).toBe(true); + expect(useOutputStore.getState().entries).toEqual([ + { id: 1, type: "message", message: "connection history" }, + ]); + }); + it("requests AWNS MCP data after MCP negotiation ends", () => { client = createConfiguredClient(); const sent: string[] = []; diff --git a/src/gmcp/Char.ts b/src/gmcp/Char.ts index b20863cb..d5dae61e 100644 --- a/src/gmcp/Char.ts +++ b/src/gmcp/Char.ts @@ -1,4 +1,5 @@ import { useSessionStore } from "../stores/sessionStore"; +import { useCharacterStatusStore } from "../stores/characterStatusStore"; import { inbound, outbound } from "../protocol/messages"; import { gmcpJsonMessage } from "./messages"; import { GMCPMessage, GMCPPackage } from "./package"; @@ -54,4 +55,9 @@ export class GMCPChar extends GMCPCharBase { console.log("Received Char.Status:", data); // TODO: Update character status based on received values } + + override reset(): void { + useCharacterStatusStore.getState().reset(); + useSessionStore.getState().reset(); + } } diff --git a/src/gmcp/Char/Items.ts b/src/gmcp/Char/Items.ts index 6c56ba65..f5bb432f 100644 --- a/src/gmcp/Char/Items.ts +++ b/src/gmcp/Char/Items.ts @@ -88,4 +88,8 @@ export class GMCPCharItems extends GMCPCharItemsBase { console.log(`Received Char.Items.Update for ${data.location}:`, data.item); useItemsStore.getState().updateItem(data.location, data.item); } + + override reset(): void { + useItemsStore.getState().reset(); + } } diff --git a/src/gmcp/Char/Skills.ts b/src/gmcp/Char/Skills.ts index 095d55a9..fa20f396 100644 --- a/src/gmcp/Char/Skills.ts +++ b/src/gmcp/Char/Skills.ts @@ -63,4 +63,8 @@ export class GMCPCharSkills extends GMCPCharSkillsBase { console.log(`Received Char.Skills.Info for ${data.group}.${data.skill}:`, data.info); useSkillsStore.getState().setInfo(data); } + + override reset(): void { + useSkillsStore.getState().reset(); + } } diff --git a/src/gmcp/Client/FileTransfer.test.ts b/src/gmcp/Client/FileTransfer.test.ts index 78c2997a..43d6de0f 100644 --- a/src/gmcp/Client/FileTransfer.test.ts +++ b/src/gmcp/Client/FileTransfer.test.ts @@ -8,6 +8,7 @@ import { GMCPClientFileTransfer } from './FileTransfer'; function createFileTransferPackage() { const client = { emit: vi.fn(), + registerDisconnectReset: vi.fn(), } as unknown as MudClient; const session = new GmcpSession(client); (client as MudClient).gmcp = session; diff --git a/src/gmcp/Client/Haptics.test.ts b/src/gmcp/Client/Haptics.test.ts index 326986f3..f29e9832 100644 --- a/src/gmcp/Client/Haptics.test.ts +++ b/src/gmcp/Client/Haptics.test.ts @@ -503,6 +503,34 @@ describe("GMCPClientHaptics", () => { ); }); + it("resets server session state without removing service listeners", () => { + handler.handleStatus({ + enabled: true, + maxCommandRate: 30, + maxSensorRate: 20, + serverVersion: 1, + }); + handler.handleSensorSubscribe({ sensors: [0, 3], rate: 10 }); + mockHapticsService.unsubscribeSensor.mockClear(); + mockHapticsService.stop.mockClear(); + mockHapticsService.off.mockClear(); + + handler.reset(); + + expect(handler.getServerStatus()).toEqual({ + enabled: false, + maxCommandRate: 0, + maxSensorRate: 0, + serverVersion: 0, + }); + expect(mockHapticsService.maxCommandRateHz).toBe(0); + expect(mockHapticsService.unsubscribeSensor).toHaveBeenCalledTimes(2); + expect(mockHapticsService.unsubscribeSensor).toHaveBeenCalledWith(0); + expect(mockHapticsService.unsubscribeSensor).toHaveBeenCalledWith(3); + expect(mockHapticsService.stop).toHaveBeenCalledWith(); + expect(mockHapticsService.off).not.toHaveBeenCalled(); + }); + it("shutdown stops all devices", () => { handler.shutdown(); diff --git a/src/gmcp/Client/Haptics.ts b/src/gmcp/Client/Haptics.ts index 5c9fa9b4..9fe7d8d3 100644 --- a/src/gmcp/Client/Haptics.ts +++ b/src/gmcp/Client/Haptics.ts @@ -276,20 +276,26 @@ export class GMCPClientHaptics extends GMCPClientHapticsBase { // Shutdown // ------------------------------------------------------------------- + override reset(): void { + for (const cleanup of this.sensorSubscriptions.values()) { + cleanup(); + } + this.sensorSubscriptions.clear(); + this.isAdvertised = false; + this.serverEnabled = false; + this.serverMaxCommandRate = 0; + this.serverMaxSensorRate = 0; + this.serverVersion = 0; + hapticsService.maxCommandRateHz = 0; + hapticsService.stop(); + } + shutdown(): void { + this.reset(); // Clean up service event listeners for (const cleanup of this.serviceCleanup) { cleanup(); } this.serviceCleanup = []; - - // Clean up sensor subscriptions - for (const cleanup of this.sensorSubscriptions.values()) { - cleanup(); - } - this.sensorSubscriptions.clear(); - - // Stop all devices - hapticsService.stop(); } } diff --git a/src/gmcp/Client/Keystrokes.ts b/src/gmcp/Client/Keystrokes.ts index 5599d341..a9c05786 100644 --- a/src/gmcp/Client/Keystrokes.ts +++ b/src/gmcp/Client/Keystrokes.ts @@ -87,10 +87,15 @@ export class GMCPClientKeystrokes extends GMCPClientKeystrokesBase { } shutdown() { + this.reset(); // Use the stored handler reference for removal document.removeEventListener('keydown', this.boundKeyDownHandler); } + override reset(): void { + this.unbindAll(); + } + private findBinding(event: KeyboardEvent): KeyBinding | undefined { try { return this.bindings.find(binding => { diff --git a/src/gmcp/Client/Media.test.ts b/src/gmcp/Client/Media.test.ts index 511ae34f..81942f2a 100644 --- a/src/gmcp/Client/Media.test.ts +++ b/src/gmcp/Client/Media.test.ts @@ -272,6 +272,19 @@ describe('GMCPClientMedia', () => { expect(handler.sounds['https://media.example/sound-32.ogg']).toBe(sounds[32]); }); + it('clears media session state without disposing lifetime ownership', async () => { + handler.handleDefault('https://media.example/'); + const sound = createMockSound('https://media.example/chime.ogg'); + mockCreateSound.mockResolvedValue(sound); + await handler.handleLoad({ name: 'chime.ogg' }); + + handler.reset(); + + expect(sound.cleanup).toHaveBeenCalledOnce(); + expect(handler.sounds).toEqual({}); + expect(client.media.defaultUrl).toBe(''); + }); + it('passes string sound types to Cacophony', async () => { mockCreateSound.mockResolvedValue(createMockSound('https://media.example/theme.ogg')); diff --git a/src/gmcp/Client/Media.ts b/src/gmcp/Client/Media.ts index a70de058..e36f801d 100644 --- a/src/gmcp/Client/Media.ts +++ b/src/gmcp/Client/Media.ts @@ -283,7 +283,12 @@ export class GMCPClientMedia extends GMCPClientMediaBase { this.client.media.stopAllSounds(); } + override reset(): void { + this.client.media.reset(); + } + override shutdown(): void { + this.reset(); this.unsubscribeSpatialStore?.(); } diff --git a/src/gmcp/Client/Midi.ts b/src/gmcp/Client/Midi.ts index 808d9494..923537c5 100644 --- a/src/gmcp/Client/Midi.ts +++ b/src/gmcp/Client/Midi.ts @@ -379,16 +379,18 @@ export class GMCPClientMidi extends GMCPClientMidiBase { this.debugCallback = callback; } + override reset(): void { + this.sendAllNotesOff(); + this.isAdvertised = false; + } + shutdown(): void { + this.reset(); if (this.midiServicePromise) { void this.midiServicePromise.then((midiService) => { midiService.disconnect(); this.syncConnectionState(midiService); }); } - this.activeNotes.forEach((timeout) => { - clearTimeout(timeout); - }); - this.activeNotes.clear(); } } diff --git a/src/gmcp/Client/Spatial.ts b/src/gmcp/Client/Spatial.ts index 87dbf5cc..0fc5e2f5 100644 --- a/src/gmcp/Client/Spatial.ts +++ b/src/gmcp/Client/Spatial.ts @@ -251,4 +251,10 @@ export class GMCPClientSpatial extends GMCPClientSpatialBase { handleEmitterStop(data: GMCPMessageClientSpatialEmitterStop): void { useSpatialStore.getState().stopEmitter(data.emitterId); } + + override reset(): void { + useSpatialStore.getState().reset(); + this.syncCacophonyListenerPosition(null); + this.syncCacophonyListenerOrientation(null); + } } diff --git a/src/gmcp/Client/Speech.test.ts b/src/gmcp/Client/Speech.test.ts index ff337802..6addea06 100644 --- a/src/gmcp/Client/Speech.test.ts +++ b/src/gmcp/Client/Speech.test.ts @@ -102,4 +102,9 @@ describe("GMCPClientSpeech", () => { handler.shutdown(); expect(cancel).toHaveBeenCalledTimes(1); }); + + it("cancels pending speech on disconnect reset", () => { + handler.reset(); + expect(cancel).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/gmcp/Client/Speech.ts b/src/gmcp/Client/Speech.ts index 2612718a..288e0026 100644 --- a/src/gmcp/Client/Speech.ts +++ b/src/gmcp/Client/Speech.ts @@ -57,9 +57,13 @@ export class GMCPClientSpeech extends GMCPClientSpeechBase { speechSynthesis.speak(utterance); } - // Stop any pending or looping server speech when the client tears down. - override shutdown(): void { + override reset(): void { if (!("speechSynthesis" in window)) return; speechSynthesis.cancel(); } + + // Stop any pending or looping server speech when the client tears down. + override shutdown(): void { + this.reset(); + } } diff --git a/src/gmcp/Client/WebPush.test.ts b/src/gmcp/Client/WebPush.test.ts index 01d85fc4..8d94f746 100644 --- a/src/gmcp/Client/WebPush.test.ts +++ b/src/gmcp/Client/WebPush.test.ts @@ -44,4 +44,15 @@ describe("GMCPClientWebPush", () => { await expect(tokenPromise).resolves.toBe("token-b"); }); + + it("requests a fresh token after disconnect reset", async () => { + handler.handleToken({ token: "token-a" }); + handler.reset(); + + const tokenPromise = handler.requestToken(); + + expect(client.gmcp.send).toHaveBeenCalledWith("Client.WebPush.Request", "{}"); + handler.receiveRegisteredMessage("Token", { token: "token-b" }); + await expect(tokenPromise).resolves.toBe("token-b"); + }); }); diff --git a/src/gmcp/Client/WebPush.ts b/src/gmcp/Client/WebPush.ts index 5422ed3d..a2b0e2a6 100644 --- a/src/gmcp/Client/WebPush.ts +++ b/src/gmcp/Client/WebPush.ts @@ -69,11 +69,15 @@ export class GMCPClientWebPush extends GMCPClientWebPushBase { }); } - shutdown(): void { + override reset(): void { this.token = null; this.expiresAt = null; } + shutdown(): void { + this.reset(); + } + private hasUsableToken(): boolean { if (!this.token) { return false; diff --git a/src/gmcp/Comm/Channel.ts b/src/gmcp/Comm/Channel.ts index 7d7ffc4a..a70d417d 100644 --- a/src/gmcp/Comm/Channel.ts +++ b/src/gmcp/Comm/Channel.ts @@ -73,4 +73,8 @@ export class GMCPCommChannel extends GMCPCommChannelBase { console.log(`Received Comm.Channel.End for ${channelName}`); // TODO: Clear the flag set by handleStart } + + override reset(): void { + this.channels = []; + } } diff --git a/src/gmcp/Comm/LiveKit.ts b/src/gmcp/Comm/LiveKit.ts index 9298d575..dc6b449c 100644 --- a/src/gmcp/Comm/LiveKit.ts +++ b/src/gmcp/Comm/LiveKit.ts @@ -29,4 +29,8 @@ export class GMCPCommLiveKit extends GMCPCommLiveKitBase { handleroom_leave(data: GMCPMessageCommLiveKitToken): void { useLiveKitStore.getState().removeToken(data.token); } + + override reset(): void { + useLiveKitStore.getState().reset(); + } } diff --git a/src/gmcp/Room.ts b/src/gmcp/Room.ts index 083ac595..6773a705 100644 --- a/src/gmcp/Room.ts +++ b/src/gmcp/Room.ts @@ -84,4 +84,12 @@ export class GMCPRoom extends GMCPRoomBase { console.log("Received Room.RemovePlayer:", playerName); useRoomStore.getState().removePlayer(playerName); } + + override reset(): void { + this.name = ""; + this.id = ""; + this.exits = []; + this.people = []; + useRoomStore.getState().reset(); + } } diff --git a/src/gmcp/package.ts b/src/gmcp/package.ts index 05f9cb37..e82c3354 100644 --- a/src/gmcp/package.ts +++ b/src/gmcp/package.ts @@ -57,6 +57,10 @@ export class GMCPPackage { // Do nothing } + reset(): void { + // Do nothing + } + receiveRegisteredMessage(wireName: string, payload: unknown): boolean { const receiver = (this as { receive?: (name: string, data: unknown) => boolean }) .receive; diff --git a/src/gmcp/session.test.ts b/src/gmcp/session.test.ts index f117069b..1a1ac5de 100644 --- a/src/gmcp/session.test.ts +++ b/src/gmcp/session.test.ts @@ -37,9 +37,12 @@ class MockClientMediaPackage extends GMCPPackage { } function createSession() { - const client = {} as unknown as MudClient; + const disconnectResets: Array<() => void> = []; + const client = { + registerDisconnectReset: (reset: () => void) => disconnectResets.push(reset), + } as unknown as MudClient; const session = new GmcpSession(client); - return { session }; + return { disconnectResets, session }; } describe('GmcpSession', () => { @@ -58,6 +61,18 @@ describe('GmcpSession', () => { expect(listener).toHaveBeenCalledWith({ ok: true }); }); + it('automatically registers package disconnect resets', () => { + const { disconnectResets, session } = createSession(); + const handler = session.register(RegistryPackage); + const reset = vi.spyOn(handler, 'reset'); + + expect(disconnectResets).toHaveLength(1); + + disconnectResets[0](); + + expect(reset).toHaveBeenCalledOnce(); + }); + it('logs malformed registered GMCP messages without throwing', () => { const { session } = createSession(); session.register(RegistryPackage); diff --git a/src/gmcp/session.ts b/src/gmcp/session.ts index 21dd68d1..f680e581 100644 --- a/src/gmcp/session.ts +++ b/src/gmcp/session.ts @@ -37,6 +37,7 @@ export class GmcpSession { register

(PackageConstructor: GMCPPackageConstructor

): P { const gmcpPackage = new PackageConstructor(this.client); this.packageHandlers[gmcpPackage.packageName] = gmcpPackage; + this.client.registerDisconnectReset(() => gmcpPackage.reset()); console.log('Registered GMCP Package:', gmcpPackage.packageName); return gmcpPackage; } diff --git a/src/mcp.test.ts b/src/mcp.test.ts index 16310259..24ac8e7d 100644 --- a/src/mcp.test.ts +++ b/src/mcp.test.ts @@ -200,6 +200,29 @@ describe('McpSession', () => { ]); }); + it('drops partial simpleedit sessions on disconnect reset', () => { + const opened: EditorSession[] = []; + const session = new McpSession( + { + sendLine: vi.fn(), + }, + () => 'auth01', + ); + const simpleEdit = session.registerPackage(McpSimpleEdit); + simpleEdit.on('openSession', (editorSession) => opened.push(editorSession)); + + session.receiveLine('#$#MCP version: 2.1 to: 2.1'); + session.receiveLine( + '#$#dns-org-mud-moo-simpleedit-content auth01 _data-tag: one name: first reference: ref1 type: moo-code', + ); + session.receiveLine('#$#* one content: partial line'); + + simpleEdit.reset(); + session.receiveLine('#$#: one'); + + expect(opened).toEqual([]); + }); + it('sends multiline payloads without mutating caller keyvals', () => { const sent: string[] = []; const tags = ['auth01', 'mltag1']; @@ -276,6 +299,15 @@ describe('McpSession', () => { '#$#dns-com-awns-getset-get auth01 id: 1 property: theme', ]); expect(values).toEqual([{ key: 'theme', value: 'dark' }]); + + getSet.reset(); + sent.length = 0; + getSet.requestGet('font'); + + expect(getSet.LocalCache).toEqual({}); + expect(sent).toEqual([ + '#$#dns-com-awns-getset-get auth01 id: 1 property: font', + ]); }); it('removes userlist players whose MOO object ids parse as numbers', () => { @@ -304,5 +336,11 @@ describe('McpSession', () => { idle: false, }, ]); + + userlist.reset(); + + expect(userlist.player).toBeUndefined(); + expect(userlist.fields).toEqual(['Object', 'Name', 'Icon']); + expect(userlist.players).toEqual([]); }); }); diff --git a/src/mcp/package.ts b/src/mcp/package.ts index 4d5ebb3c..51acbfd7 100644 --- a/src/mcp/package.ts +++ b/src/mcp/package.ts @@ -83,6 +83,10 @@ export class MCPPackage { // Do nothing } + reset(): void { + // Do nothing + } + send(command: string, data?: McpOutboundData): void { if (!this.sendMessage) { throw new Error(`MCP package ${this.packageName} is not registered`); diff --git a/src/mcp/packages/getSet.ts b/src/mcp/packages/getSet.ts index 09fa45bd..123cb5bf 100644 --- a/src/mcp/packages/getSet.ts +++ b/src/mcp/packages/getSet.ts @@ -86,4 +86,10 @@ export class McpAwnsGetSet extends McpAwnsGetSetBase { property, }); } + + override reset(): void { + this.id = 1; + this.cache.clear(); + this.LocalCache = {}; + } } diff --git a/src/mcp/packages/ping.ts b/src/mcp/packages/ping.ts index 1a31f636..c2ab8492 100644 --- a/src/mcp/packages/ping.ts +++ b/src/mcp/packages/ping.ts @@ -20,4 +20,8 @@ export class McpAwnsPing extends MCPPackage { ping(): void { this.send('dns-com-awns-ping', { id: this.id++ }); } + + override reset(): void { + this.id = 1; + } } diff --git a/src/mcp/packages/rehash.ts b/src/mcp/packages/rehash.ts index 81a2e95b..311fb535 100644 --- a/src/mcp/packages/rehash.ts +++ b/src/mcp/packages/rehash.ts @@ -1,4 +1,5 @@ import { identityCodec, inbound, messageEnvelope, outbound } from '../../protocol/messages'; +import { useInputStore } from '../../stores/inputStore'; import { MCPPackage } from '../package'; import type { McpMessage } from '../types'; @@ -63,6 +64,11 @@ export class McpAwnsRehash extends McpAwnsRehashBase { requestCommands(): void { this.sendGetcommands(undefined); } + + override reset(): void { + this.commands = []; + useInputStore.getState().resetCommands(); + } } function commandUpdateFrom(list: string): AwnsRehashCommandUpdate { diff --git a/src/mcp/packages/serverInfo.ts b/src/mcp/packages/serverInfo.ts index c7b62c63..60854462 100644 --- a/src/mcp/packages/serverInfo.ts +++ b/src/mcp/packages/serverInfo.ts @@ -1,4 +1,5 @@ import { identityCodec, inbound, messageEnvelope, outbound } from '../../protocol/messages'; +import { useServerLinksStore } from '../../stores/serverLinksStore'; import { MCPPackage } from '../package'; import type { McpMessage } from '../types'; @@ -30,4 +31,8 @@ export class McpAwnsServerInfo extends McpAwnsServerInfoBase { requestServerInfo(): void { this.sendGet({}); } + + override reset(): void { + useServerLinksStore.getState().reset(); + } } diff --git a/src/mcp/packages/simpleEdit.ts b/src/mcp/packages/simpleEdit.ts index e3f7bfeb..8ed301b2 100644 --- a/src/mcp/packages/simpleEdit.ts +++ b/src/mcp/packages/simpleEdit.ts @@ -83,4 +83,8 @@ export class McpSimpleEdit extends McpSimpleEditBase { this.emitRegisteredMessage(simpleEditContent.wireName, session); this.sessionsByTag.delete(closure.name); } + + override reset(): void { + this.sessionsByTag.clear(); + } } diff --git a/src/mcp/packages/userlist.ts b/src/mcp/packages/userlist.ts index 33cf543b..d3b48335 100644 --- a/src/mcp/packages/userlist.ts +++ b/src/mcp/packages/userlist.ts @@ -16,6 +16,21 @@ export interface UserlistPlayer { idle: boolean; } +const DEFAULT_FIELDS = ['Object', 'Name', 'Icon']; +const DEFAULT_ICONS = [ + 'Idle', + 'Away', + 'Idle+Away', + 'Friend', + 'Newbie', + 'Inhabitant', + 'Inhabitant+', + 'Schooled', + 'Wizard', + 'Key', + 'Star', +]; + const userlist = messageEnvelope('userlist', identityCodec()); const McpVmooUserlistBase = MCPPackage.with({ @@ -26,20 +41,8 @@ const McpVmooUserlistBase = MCPPackage.with({ export class McpVmooUserlist extends McpVmooUserlistBase { public maxVersion = 1.1; public player: string | undefined; - public fields: string[] = ['Object', 'Name', 'Icon']; - public icons: string[] = [ - 'Idle', - 'Away', - 'Idle+Away', - 'Friend', - 'Newbie', - 'Inhabitant', - 'Inhabitant+', - 'Schooled', - 'Wizard', - 'Key', - 'Star', - ]; + public fields: string[] = [...DEFAULT_FIELDS]; + public icons: string[] = [...DEFAULT_ICONS]; public players: UserlistPlayer[] = []; handle(message: McpMessage): void { @@ -136,6 +139,14 @@ export class McpVmooUserlist extends McpVmooUserlistBase { } } + override reset(): void { + this.player = undefined; + this.fields = [...DEFAULT_FIELDS]; + this.icons = [...DEFAULT_ICONS]; + this.players = []; + useUserlistStore.getState().reset(); + } + private playerFromArray(values: MooListValue[]): UserlistPlayer { const player: Partial = {}; values.forEach((value, index) => { diff --git a/src/mcp/packages/visual.ts b/src/mcp/packages/visual.ts index 5650bf7b..236a9d75 100644 --- a/src/mcp/packages/visual.ts +++ b/src/mcp/packages/visual.ts @@ -1,4 +1,5 @@ import { identityCodec, inbound, messageEnvelope, outbound } from '../../protocol/messages'; +import { useWorldMapStore } from '../../stores/worldMapStore'; import { MCPPackage } from '../package'; import type { McpMessage } from '../types'; @@ -200,6 +201,15 @@ export class McpAwnsVisual extends McpAwnsVisualBase { this.sendGetself(undefined); } + override reset(): void { + this.location = undefined; + this.self = undefined; + this.users = []; + this.topology = []; + this.pendingMessages.clear(); + useWorldMapStore.getState().reset(); + } + private beginUsers(message: McpMessage): void { const tag = message.keyvals['_data-tag']; if (!tag) {