diff --git a/backend/src/core/middleware/use-sockets.ts b/backend/src/core/middleware/use-sockets.ts index acb8fd9e..20726cc0 100644 --- a/backend/src/core/middleware/use-sockets.ts +++ b/backend/src/core/middleware/use-sockets.ts @@ -7,6 +7,7 @@ import { SocketManager } from '../sockets/socket-manager.js'; export const useSockets = ( httpServer: HttpServer | HttpsServer, environment: Environment, + serverId: string, ) => { return new SocketManager(httpServer, { telnetHost: environment.telnetHost, @@ -14,5 +15,6 @@ export const useSockets = ( useTelnetTls: environment.telnetTLS, socketRoot: environment.socketRoot, clientName: environment.name, + serverId, }); }; diff --git a/backend/src/core/sockets/socket-manager.ts b/backend/src/core/sockets/socket-manager.ts index f81be467..67d15192 100644 --- a/backend/src/core/sockets/socket-manager.ts +++ b/backend/src/core/sockets/socket-manager.ts @@ -12,6 +12,10 @@ import { TelnetOptions } from '../../features/telnet/models/telnet-options.js'; import { TelnetClient } from '../../features/telnet/telnet-client.js'; import { TelnetControlSequences } from '../../features/telnet/types/telnet-control-sequences.js'; import { EchoState } from '../../features/telnet/utils/handle-echo-option.js'; +import type { + GmcpMessage, + GmcpState, +} from '../../features/telnet/utils/handle-gmcp-option.js'; import { logger } from '../../shared/utils/logger.js'; import { mapToServerEncodings } from '../../shared/utils/supported-encodings.js'; import { Environment } from '../environment/environment.js'; @@ -32,6 +36,7 @@ export class SocketManager extends Server< useTelnetTls: boolean; socketRoot: string; clientName: string; + serverId: string; }, ) { const environment = Environment.getInstance(); @@ -52,6 +57,10 @@ export class SocketManager extends Server< recovered: socket.recovered, }); + // Tell the client which backend instance it just connected to. + // The frontend uses this to detect a backend restart and reload itself. + socket.emit('serverHello', this.managerOptions.serverId); + const handshakeSessionToken = this.getSessionTokenFromSocket(socket); if (handshakeSessionToken) { @@ -201,6 +210,56 @@ export class SocketManager extends Server< } }); + socket.on('mudGmcpOutgoing', (module: string, data: unknown) => { + const existing = this.getConnectionBySocketId(socket.id); + + if (existing === undefined) { + logger.error( + `[${socket.id}] [Socket-Manager] Client has no session - can not send GMCP message!`, + { + socketId: socket.id, + }, + ); + + return; + } + + const telnetClient = existing.connection.telnet; + + if (telnetClient === undefined || telnetClient.isConnected === false) { + logger.error( + `[${socket.id}] [Socket-Manager] Client has no telnet connection - can not send GMCP message!`, + { + socketId: socket.id, + }, + ); + + return; + } + + logger.debug( + `[${socket.id}] [Socket-Manager] Client sending GMCP: ${module}`, + ); + + let payload = data; + + if (module.toLowerCase() === 'core.hello') { + const realIp = this.getRealIp(socket); + const baseData = + data !== null && typeof data === 'object' + ? (data as Record) + : {}; + + payload = { ...baseData, real_ip: realIp }; + logger.info( + `[${socket.id}] [Socket-Manager] Client sent Core.Hello`, + payload, + ); + } + + telnetClient.sendGmcp(module, payload); + }); + socket.on( 'mudConnect', ( @@ -420,11 +479,53 @@ export class SocketManager extends Server< break; } + case TelnetOptions.TELOPT_GMCP: { + const gmcpState = state as GmcpState; + + logger.verbose( + `[${socket.id}] [Socket-Manager] Telnet Option GMCP has changed. Emitting 'mudGmcpActive'`, + { + name: TelnetOptions[TelnetOptions.TELOPT_GMCP], + state: state, + }, + ); + + socket.emit('mudGmcpActive', gmcpState.active); + + break; + } + default: break; } }); + // Register GMCP message listener to forward incoming GMCP data to the client + const gmcpHandler = telnetClient.getGmcpHandler(); + + if (gmcpHandler !== undefined) { + const gmcpListener = (message: GmcpMessage) => { + const currentSocket = this.getSocketById( + this.mudConnections[resolvedSessionToken]?.socketId, + ); + + if (currentSocket !== undefined) { + currentSocket.emit( + 'mudGmcpIncoming', + message.packageName, + message.messageName, + message.data, + ); + } + }; + + gmcpHandler.onGmcpMessage(gmcpListener); + + telnetClient.on('close', () => { + gmcpHandler.offGmcpMessage(gmcpListener); + }); + } + logger.info( `[${socket.id}] [Socket-Manager] Client .. telnet connection established. Emitting 'mudConnected'`, ); @@ -448,6 +549,76 @@ export class SocketManager extends Server< }); } + /** + * Notifies all connected clients about a server shutdown, closes every + * active telnet session and disconnects all socket.io clients. Returns + * a promise that resolves after the underlying socket.io server has been + * closed so the caller can chain the HTTP server shutdown. + */ + public async shutdownAll(): Promise { + logger.info( + '[Socket-Manager] Server is shutting down. Closing all telnet sessions and notifying clients.', + ); + + // 1. Tell all clients we're going down so they can refresh / show a banner. + // socket.io has no built-in "wait until delivered" hook, so we send the + // event to every connected socket individually and then sleep long + // enough for the underlying transport to flush before we tear down. + try { + const sockets = await this.fetchSockets(); + + logger.info( + `[Socket-Manager] Notifying ${sockets.length} client(s) about shutdown.`, + ); + + for (const socket of sockets) { + try { + socket.emit('serverShutdown'); + } catch (error) { + logger.error( + '[Socket-Manager] Failed to emit serverShutdown to socket', + { socketId: socket.id, error }, + ); + } + } + + // Flush window — websocket frames need a tick to actually be written + // to the OS socket before we close the transport. + await new Promise((resolve) => setTimeout(resolve, 500)); + } catch (error) { + logger.error('[Socket-Manager] Failed to broadcast serverShutdown', { + error, + }); + } + + // 2. Close every active telnet connection. + for (const sessionToken of Object.keys(this.mudConnections)) { + try { + this.closeTelnetConnections(sessionToken); + } catch (error) { + logger.error('[Socket-Manager] Failed to close telnet session', { + sessionToken, + error, + }); + } + } + + // 3. Disconnect every connected socket.io client (force = true closes the + // underlying transport so the browser sees an immediate disconnect). + try { + this.disconnectSockets(true); + } catch (error) { + logger.error('[Socket-Manager] Failed to disconnect sockets', { error }); + } + + // 4. Close the socket.io server itself. + await new Promise((resolve) => { + this.close(() => resolve()); + }); + + logger.info('[Socket-Manager] Shutdown complete.'); + } + private closeTelnetConnections(sessionToken: string) { const telnetClient = this.mudConnections[sessionToken]?.telnet; @@ -484,6 +655,14 @@ export class SocketManager extends Server< if (linemodeState !== undefined) { socket.emit('setLinemode', linemodeState); } + + const gmcpState = telnetClient.getOptionState( + TelnetOptions.TELOPT_GMCP, + ); + + if (gmcpState !== undefined) { + socket.emit('mudGmcpActive', gmcpState.active); + } } /** @@ -527,4 +706,28 @@ export class SocketManager extends Server< return undefined; } + + private getRealIp( + socket: Socket, + ): string { + const forwarded = socket.handshake.headers['x-forwarded-for']; + + let realIp: string; + + if (typeof forwarded === 'string' && forwarded.length > 0) { + realIp = forwarded; + } else if (Array.isArray(forwarded) && forwarded.length > 0) { + realIp = forwarded[0]; + } else { + realIp = socket.handshake.address; + } + + const commaIndex = realIp.indexOf(','); + + if (commaIndex > -1) { + realIp = realIp.slice(0, commaIndex); + } + + return realIp.trim(); + } } diff --git a/backend/src/features/telnet/telnet-client.ts b/backend/src/features/telnet/telnet-client.ts index e840b0d7..28d2c1db 100644 --- a/backend/src/features/telnet/telnet-client.ts +++ b/backend/src/features/telnet/telnet-client.ts @@ -20,6 +20,10 @@ import { } from './utils/handle-naws-option.js'; import { handleSGAOption } from './utils/handle-sga-option.js'; import { handleStatusOption } from './utils/handle-status-option.js'; +import { + GmcpOptionHandler, + handleGmcpOption, +} from './utils/handle-gmcp-option.js'; import { handleTTypeOption } from './utils/handle-ttype-option.js'; import { TelnetSocketWrapper } from './utils/telnet-socket-wrapper.js'; @@ -157,6 +161,7 @@ export class TelnetClient extends EventEmitter { [TelnetOptions.TELOPT_STATUS, handleStatusOption(this.telnetSocket)], [TelnetOptions.TELOPT_MSSP, handleMSSPOption(this.telnetSocket)], [TelnetOptions.TELOPT_EOR, handleEorOption(this.telnetSocket)], + [TelnetOptions.TELOPT_GMCP, handleGmcpOption(this.telnetSocket)], ]); this.setupOptionStateTracking(); @@ -290,6 +295,22 @@ export class TelnetClient extends EventEmitter { this.telnetSocket.writeSub(TelnetOptions.TELOPT_STATUS, buffer); } + /** + * Returns the GMCP handler, or undefined if GMCP is not registered. + */ + public getGmcpHandler(): GmcpOptionHandler | undefined { + return this.optionsHandler.get(TelnetOptions.TELOPT_GMCP) as + | GmcpOptionHandler + | undefined; + } + + /** + * Sends a GMCP message to the MUD server. + */ + public sendGmcp(module: string, data: unknown): void { + this.getGmcpHandler()?.sendGmcp(module, data); + } + public sendTimingMark(): void { this.telnetSocket.writeWill(TelnetOptions.TELOPT_TM); } diff --git a/backend/src/features/telnet/utils/handle-gmcp-option.ts b/backend/src/features/telnet/utils/handle-gmcp-option.ts new file mode 100644 index 00000000..c926db4c --- /dev/null +++ b/backend/src/features/telnet/utils/handle-gmcp-option.ts @@ -0,0 +1,216 @@ +import EventEmitter from 'events'; +import { TelnetSocket } from 'telnet-stream'; + +import { logger } from '../../../shared/utils/logger.js'; +import { TelnetOptions } from '../models/telnet-options.js'; +import { TelnetControlSequences } from '../types/telnet-control-sequences.js'; +import { TelnetOptionHandler } from '../types/telnet-option-handler.js'; +import { TelnetOptionResult } from '../types/telnet-option-result.js'; +import { TelnetSubnegotiationResult } from '../types/telnet-subnegotiation-result.js'; + +/** + * Parsed GMCP message received from the MUD server. + */ +export type GmcpMessage = { + /** The GMCP package, e.g. "Char" from "Char.Name" */ + packageName: string; + /** The GMCP message name, e.g. "Name" from "Char.Name" */ + messageName: string; + /** The full GMCP module string, e.g. "Char.Name" */ + fullMessage: string; + /** The parsed JSON data payload, or empty object if none */ + data: unknown; +}; + +export type GmcpState = { + /** Whether GMCP negotiation was successful */ + active: boolean; +}; + +export type GmcpOptionHandler = TelnetOptionHandler & { + /** + * Send a GMCP message to the MUD server. + * @param module - The GMCP module string, e.g. "Core.Hello" + * @param data - The data payload (will be JSON-serialized) + */ + sendGmcp: (module: string, data: unknown) => void; + + /** + * Register a listener for incoming GMCP messages from the MUD server. + */ + onGmcpMessage: (listener: (message: GmcpMessage) => void) => void; + + /** + * Remove a listener for incoming GMCP messages. + */ + offGmcpMessage: (listener: (message: GmcpMessage) => void) => void; +}; + +/** + * Parses a raw GMCP subnegotiation buffer into a structured GmcpMessage. + * + * GMCP format: "Package.Message {json_data}" or "Package.Message" (no data) + */ +function parseGmcpBuffer(chunkData: Buffer): GmcpMessage | null { + const raw = chunkData.toString('utf-8'); + const spaceIndex = raw.indexOf(' '); + const dotIndex = raw.indexOf('.'); + + if (dotIndex < 0) { + logger.warn( + `[Telnet-Client] [GMCP-Option] Received GMCP message without dot separator: ${raw}`, + ); + + return null; + } + + const fullMessage = spaceIndex >= 0 ? raw.substring(0, spaceIndex) : raw; + const packageName = raw.substring(0, dotIndex); + const messageName = + spaceIndex >= 0 + ? raw.substring(dotIndex + 1, spaceIndex) + : raw.substring(dotIndex + 1); + + let data: unknown = {}; + + if (spaceIndex >= 0) { + const jsonString = raw.substring(spaceIndex + 1); + + try { + data = JSON.parse(jsonString); + } catch { + logger.warn( + `[Telnet-Client] [GMCP-Option] Failed to parse GMCP JSON data for ${fullMessage}: ${jsonString}`, + ); + + data = jsonString; + } + } + + return { packageName, messageName, fullMessage, data }; +} + +const handleGmcpDo = (socket: TelnetSocket) => (): TelnetOptionResult => { + socket.writeWill(TelnetOptions.TELOPT_GMCP); + + return { + controlSequence: TelnetControlSequences.WILL, + }; +}; + +const handleGmcpDont = (socket: TelnetSocket) => (): TelnetOptionResult => { + socket.writeWont(TelnetOptions.TELOPT_GMCP); + + return { + controlSequence: TelnetControlSequences.WONT, + }; +}; + +const handleGmcpWill = + ( + socket: TelnetSocket, + stateEmitter: EventEmitter, + ) => + (): TelnetOptionResult => { + socket.writeDo(TelnetOptions.TELOPT_GMCP); + + stateEmitter.emit('state', { active: true } satisfies GmcpState); + + return { + controlSequence: TelnetControlSequences.DO, + }; + }; + +const handleGmcpWont = + ( + socket: TelnetSocket, + stateEmitter: EventEmitter, + ) => + (): TelnetOptionResult => { + socket.writeDont(TelnetOptions.TELOPT_GMCP); + + stateEmitter.emit('state', { active: false } satisfies GmcpState); + + return { + controlSequence: TelnetControlSequences.DONT, + }; + }; + +const handleGmcpSub = + (gmcpEmitter: EventEmitter) => + (serverChunk: Buffer): TelnetSubnegotiationResult => { + const message = parseGmcpBuffer(serverChunk); + + if (message !== null) { + logger.debug( + `[Telnet-Client] [GMCP-Option] Received: ${message.fullMessage}`, + ); + + gmcpEmitter.emit('gmcp', message); + } + + return null; + }; + +export const handleGmcpOption = ( + socket: TelnetSocket, +): GmcpOptionHandler => { + const stateEmitter = new EventEmitter(); + const gmcpEmitter = new EventEmitter(); + + let state: GmcpState = { active: false }; + + stateEmitter.on('state', (newState: GmcpState) => { + state = newState; + }); + + return { + handleDo: handleGmcpDo(socket), + handleDont: handleGmcpDont(socket), + handleWill: handleGmcpWill(socket, stateEmitter), + handleWont: handleGmcpWont(socket, stateEmitter), + handleSub: handleGmcpSub(gmcpEmitter), + + getState(): GmcpState { + return state; + }, + + onStateChange(listener: (state: GmcpState) => void): void { + stateEmitter.on('state', listener); + + listener(state); + }, + + offStateChange(listener: (state: GmcpState) => void): void { + stateEmitter.off('state', listener); + }, + + sendGmcp(module: string, data: unknown): void { + if (!state.active) { + logger.warn( + `[Telnet-Client] [GMCP-Option] Cannot send GMCP - not active`, + ); + + return; + } + + const jsonString = + data !== undefined && data !== null ? ` ${JSON.stringify(data)}` : ''; + const payload = Buffer.from(`${module}${jsonString}`, 'utf-8'); + + logger.debug( + `[Telnet-Client] [GMCP-Option] Sending: ${module}${jsonString}`, + ); + + socket.writeSub(TelnetOptions.TELOPT_GMCP, payload); + }, + + onGmcpMessage(listener: (message: GmcpMessage) => void): void { + gmcpEmitter.on('gmcp', listener); + }, + + offGmcpMessage(listener: (message: GmcpMessage) => void): void { + gmcpEmitter.off('gmcp', listener); + }, + }; +}; diff --git a/backend/src/features/telnet/utils/handle-ttype-option.ts b/backend/src/features/telnet/utils/handle-ttype-option.ts index d1cb5a78..1901f665 100644 --- a/backend/src/features/telnet/utils/handle-ttype-option.ts +++ b/backend/src/features/telnet/utils/handle-ttype-option.ts @@ -39,7 +39,9 @@ const handleTTypeSub = (socket: TelnetSocket, clientName: string) => (serverChunk: Buffer): TelnetSubnegotiationResult => { if (new Uint8Array(serverChunk)[0] === TelnetTTypeSubnogiation.TTYPE_SEND) { - const buffer = Buffer.from(clientName); + const buffer = Buffer.from([0, ...Buffer.from(clientName)]); + // prefix 0 for IS (set to). + // IAC SB TERMINAL TYPE IS (77,65,62,6d,75,64,33,62) socket.writeSub(TelnetOptions.TELOPT_TTYPE, buffer); diff --git a/backend/src/main.ts b/backend/src/main.ts index f3233bbf..daacf9ab 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -32,7 +32,7 @@ useBodyParser(app); useStaticFiles(app, 'wwwroot'); -const socketManager = useSockets(httpServer, environment); +const socketManager = useSockets(httpServer, environment, UNIQUE_SERVER_ID); useConfigEndpoint(app); @@ -48,3 +48,48 @@ httpServer.listen(environment.port, environment.host, 10000, () => { UNIQUE_SERVER_ID, }); }); + +// --------------------------------------------------------------------------- +// Graceful shutdown +// --------------------------------------------------------------------------- +// On SIGTERM (docker stop, k8s) or SIGINT (Ctrl+C) we notify all connected +// clients (so they can reload), close every active telnet session, then close +// the http and socket.io servers before exiting. + +const SHUTDOWN_TIMEOUT_MS = 10_000; +let isShuttingDown = false; + +async function gracefulShutdown(signal: string): Promise { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + logger.info(`[Main] Received ${signal}. Initiating graceful shutdown.`); + + // Hard exit if shutdown takes too long (e.g. hung connection). + const forceExit = setTimeout(() => { + logger.error( + `[Main] Graceful shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms. Forcing exit.`, + ); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + forceExit.unref(); + + try { + await socketManager.shutdownAll(); + + await new Promise((resolve, reject) => { + httpServer.close((error) => (error ? reject(error) : resolve())); + }); + + logger.info('[Main] Shutdown complete.'); + process.exit(0); + } catch (error) { + logger.error('[Main] Error during graceful shutdown', { error }); + process.exit(1); + } +} + +process.on('SIGTERM', () => void gracefulShutdown('SIGTERM')); +process.on('SIGINT', () => void gracefulShutdown('SIGINT')); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 67132b90..694b6675 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -3,7 +3,6 @@ "target": "ES2020", "module": "nodenext", "moduleResolution": "nodenext", - "baseUrl": "./", "rootDir": "./src", "outDir": "./dist", "strict": true, diff --git a/dockerfiles/README.md b/dockerfiles/README.md index 1963b9ea..640dc95b 100644 --- a/dockerfiles/README.md +++ b/dockerfiles/README.md @@ -9,10 +9,16 @@ docker build -f Dockerfile -t myonara/webmud3:develop . docker build -f Dockerfile -t myonara/webmud3:latest . -docker build -f dockerfiles/ng_unitopia_test.dockerfile -t myonara/webmud3:unitopiatest . +docker build -f dockerfiles/unitopia_dev.dockerfile -t myonara/webmud3:unitopiatest . + +docker build -f dockerfiles/unitopia_dev.dockerfile -t myonara/webmud3:wm3local . ### To run the docker containers in a swarm: +docker stack deploy -c dockerfiles/wm3_local_dev.yml webmud3local + +docker stack rm webmud3local + docker stack deploy -c dockerfiles/w3_docker_compose_local.yml webmud3alocal docker stack deploy -c dockerfiles/w3_docker_compose.yml webmud3a @@ -40,12 +46,21 @@ docker compose -f dockerfiles/wm3_local_dev.yml -p webmud3dev up -d podman-compose -f dockerfiles/w3_docker_compose.yml -p webmud_unitopia up -d -podman-compose -f dockerfiles/w3_docker_compose_sb.yml -p webmud_seifenblase up -d +podman-compose -f /UNItopia/ftpwww/webmud3/dockerfiles/w3_docker_compose_sb.yml -p webmud_seifenblase up -d + +podman-compose -f /UNItopia/ftpwww/webmud3/dockerfiles/wm3_sb_mystiker.yml -p webmud_seifenblase up -d podman-compose -f dockerfiles/w3_docker_compose_test.yml -p webmud_test up -d podman-compose -f dockerfiles/w3_docker_compose_test_neu.yml -p webmud_test up -d - + +podman-compose -f /UNItopia/ftpwww/webmud3/dockerfiles/wm3_test_mystiker.yml -p webmud3_newtest up -d + +podman-compose -f /UNItopia/ftpwww/webmud3/dockerfiles/wm3t_prod_mystiker.yml -p webmud3_newprod up -d + +podman-compose -f /UNItopia/ftpwww/webmud3/dockerfiles/wm3_old_test.yml -p webmud3_oldtest up -d + +podman run -d --network pasta:--address,10.0.2.0,--netmask,24,--gateway,10.0.2.2 #### to stop podman-compose -f dockerfiles/w3_docker_compose.yml -p webmud_unitopia down diff --git a/dockerfiles/wm3_local_dev.yml b/dockerfiles/wm3_local_dev.yml index 9ca525c8..38395a8d 100644 --- a/dockerfiles/wm3_local_dev.yml +++ b/dockerfiles/wm3_local_dev.yml @@ -1,7 +1,7 @@ services: # docker compose -f dockerfiles/wm3_local_dev.yml -p webmud3dev up -d web: - image: myonara/webmud3:develop + image: myonara/webmud3:wm3local environment: ENVIRONMENT: 'development' NODE_ENV: 'development' @@ -35,4 +35,4 @@ services: - webnet networks: webnet: - driver: bridge + driver: overlay diff --git a/frontend/package.json b/frontend/package.json index 37ebbd4c..a82a734c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,6 @@ }, "private": true, "dependencies": { - "@webmud3/shared": "1.0.0-alpha", "@angular-eslint/schematics": "~20.3.0", "@angular/animations": "~20.3.4", "@angular/common": "~20.3.4", @@ -36,10 +35,13 @@ "@angular/platform-browser": "~20.3.4", "@angular/platform-browser-dynamic": "~20.3.4", "@angular/router": "~20.3.4", + "@monaco-editor/loader": "^1.7.0", + "@webmud3/shared": "1.0.0-alpha", "@xterm/addon-attach": "~0.11.0", "@xterm/addon-clipboard": "~0.2.0", "@xterm/addon-fit": "~0.10.0", "@xterm/xterm": "~5.5.0", + "monaco-editor": "^0.55.1", "normalize.css": "~8.0.1", "rxjs": "~7.8.2", "socket.io-client": "~4.8.1", diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index e307e2bb..2122c431 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1 +1,3 @@ + + diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index ec0f4ad8..e302e45e 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,11 +1,25 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { MudClientComponent } from '@webmud3/frontend/core/mud/components/mud-client/mud-client.component'; +import { CharFooterComponent } from '@webmud3/frontend/features/footer/char-footer.component'; +import { WindowContainerComponent } from '@webmud3/frontend/features/windows/window-container.component'; +import { WindowService } from '@webmud3/frontend/features/windows/window.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'], - imports: [MudClientComponent], + imports: [MudClientComponent, WindowContainerComponent, CharFooterComponent], standalone: true, }) -export class AppComponent {} +export class AppComponent { + private readonly windowService = inject(WindowService); + + constructor() { + // Expose WindowService on window for debugging / manual testing in the + // browser console. Example: + // __windowService.newWindow({ title: 'Test', component: 'demo', data: { hello: 'world' } }) + (window as unknown as Record)['__windowService'] = + this.windowService; + console.log('[AppComponent] __windowService exposed on window', this.windowService); + } +} diff --git a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts index bf4550ec..9d20c686 100644 --- a/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts +++ b/frontend/src/app/core/mud/components/mud-client/mud-client.component.ts @@ -15,6 +15,14 @@ import { Subscription } from 'rxjs'; import { MudService } from '../../services/mud.service'; import { SecureString } from '@webmud3/frontend/shared/types/secure-string'; import { OutputHistoryService } from '@webmud3/frontend/shared/services/output-history.service'; +import { DebugSettingsService } from '@webmud3/frontend/features/debug/debug-settings.service'; +import { FooterMenuService } from '@webmud3/frontend/features/footer/footer-menu.service'; +import { DirlistWindowService } from '@webmud3/frontend/features/editor/dirlist-window.service'; +import { EditorWindowService } from '@webmud3/frontend/features/editor/editor-window.service'; +import { CharGmcpModule } from '@webmud3/frontend/features/gmcp/modules/char-gmcp.module'; +import { InventoryWindowService } from '@webmud3/frontend/features/inventory/inventory-window.service'; +import { ConnectionMenuService } from '@webmud3/frontend/features/connection/connection-menu.service'; +import { NumpadWindowService } from '@webmud3/frontend/features/numpad/numpad-window.service'; import type { LinemodeState } from '@webmud3/shared'; import { MudInputController, @@ -50,6 +58,28 @@ type MudClientState = { export class MudClientComponent implements AfterViewInit, OnDestroy { private readonly mudService = inject(MudService); private readonly outputHistoryService = inject(OutputHistoryService); + private readonly debugSettings = inject(DebugSettingsService); + private readonly footerMenu = inject(FooterMenuService); + // Bootstraps the Char GMCP module (registers it with the GmcpService so that + // "Char 1" is included in Core.Supports.Set sent to the MUD). + private readonly _charGmcp = inject(CharGmcpModule); + // Bootstraps the inventory feature: registers Char.Items GMCP module and + // adds the "Inventar" toggle to the footer menu. + private readonly _inventoryWindow = inject(InventoryWindowService); + // Bootstraps the "Verbinden / Trennen" entry in the footer menu. + private readonly _connectionMenu = inject(ConnectionMenuService); + // Bootstraps the "Numpad-Konfiguration" entry in the footer menu and + // loads numpad bindings from localStorage. + private readonly _numpadWindow = inject(NumpadWindowService); + // Bootstraps the editor feature: registers the Files GMCP module and + // auto-opens an editor window whenever the MUD pushes Files.OpenFile. + private readonly _editorWindow = inject(EditorWindowService); + // Bootstraps the directory browser: shows a "Verzeichnis" footer menu + // entry once Char.Name reports the connected player as a wizard. + private readonly _dirlistWindow = inject(DirlistWindowService); + + private readonly SR_MENU_ID = 'screenreader-debug'; + private readonly PASTE_MENU_ID = 'paste-debug'; private readonly fontSizeBreakpoints = [ { minWidth: 0, fontSize: 8.5 }, @@ -157,13 +187,16 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.liveRegionRef.nativeElement, this.historyRegionRef.nativeElement, this.inputRegionRef.nativeElement, + () => this.debugSettings.screenReaderLogging, ); - console.debug( + this.srLog( '[MudClient] Screenreader announcer initialized, live region:', this.liveRegionRef.nativeElement, ); + this.registerDebugMenuItems(); + // Now initialize socket adapter AFTER screenreader is ready this.socketAdapter = new MudSocketAdapter(this.mudService.mudOutput$, { transformMessage: (data) => this.transformMudOutput(data), @@ -182,6 +215,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.terminal.loadAddon(this.terminalClipboardAddon); this.terminal.loadAddon(this.terminalFitAddon); this.terminal.loadAddon(this.terminalAttachAddon); + this.installCopyShortcutHandler(); this.terminal.focus(); // Cache helper textarea created by xterm (used to mirror prompt + input) @@ -230,6 +264,8 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { * Cleans up subscriptions and disposes terminal resources. */ ngOnDestroy() { + this.footerMenu.unregister(this.SR_MENU_ID); + this.footerMenu.unregister(this.PASTE_MENU_ID); this.resizeObs.disconnect(); // Unregister visibility change listener @@ -373,14 +409,14 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { * Special handling for Ctrl+V: intercepts clipboard content and injects it properly. */ private handleInput(data: string) { - console.log('[PASTE-DEBUG] Edit mode:', this.state.isEditMode); - console.log('[PASTE-DEBUG] Data received:', JSON.stringify(data)); - console.log('[PASTE-DEBUG] Data length:', data.length); + this.pasteLog('[PASTE-DEBUG] Edit mode:', this.state.isEditMode); + this.pasteLog('[PASTE-DEBUG] Data received:', JSON.stringify(data)); + this.pasteLog('[PASTE-DEBUG] Data length:', data.length); // Special handling for Ctrl+V (paste): xterm converts paste to \u0016 in onData() // We need to read the clipboard and inject the actual content if (data === '\u0016') { - console.log( + this.pasteLog( '[MudClient] Ctrl+V detected, reading clipboard from native event...', ); this.handlePasteFromClipboard(); @@ -499,7 +535,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.pendingEchoSuppression = null; } - console.debug('[MudClient] Announcing to screenreader:', { + this.srLog('[MudClient] Announcing to screenreader:', { rawLength: data.length, raw: data, }); @@ -539,7 +575,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { // this.screenReader?.appendToHistory(entry.data); } - console.log( + this.srLog( '[MudClient] History loaded to terminal and screenreader history', ); } @@ -652,6 +688,74 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { this.helperTextarea.value = `${prompt}${effectiveBuffer ?? ''}`; } + /** + * Wires up Ctrl+C / Cmd+C as a copy-to-clipboard shortcut for terminal text + * selections. + * + * xterm's `attachCustomKeyEventHandler` runs before its own key processing. + * Returning `false` swallows the event entirely, so the keystroke is not + * forwarded to onData (and therefore not to the MUD). + * + * Behaviour: + * - Ctrl+C (Linux/Win) or Cmd+C (Mac) with active terminal selection: + * copy the selection and swallow the event. + * - Ctrl+C without selection: bubble through (preserves the existing + * behaviour of sending  to the MUD as an interrupt). + * - Anything else: bubble through. + */ + private installCopyShortcutHandler(): void { + this.terminal.attachCustomKeyEventHandler((event) => { + if (event.type !== 'keydown') { + return true; + } + + // History navigation: Up/Down (with optional Alt for prefix filter) only + // makes sense in line-edit mode. We handle it directly on the + // KeyboardEvent because some browsers/OSes swallow Alt+ArrowUp/Down + // before xterm sees it via onData. + if ( + this.state.isEditMode && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + (event.key === 'ArrowUp' || event.key === 'ArrowDown') + ) { + if (event.key === 'ArrowUp') { + this.inputController.historyBack(event.altKey); + } else { + this.inputController.historyForward(event.altKey); + } + + event.preventDefault(); + return false; + } + + const isCopyShortcut = + (event.ctrlKey || event.metaKey) && + !event.shiftKey && + !event.altKey && + (event.key === 'c' || event.key === 'C'); + + if (!isCopyShortcut || !this.terminal.hasSelection()) { + return true; + } + + const selection = this.terminal.getSelection(); + + if (selection) { + void navigator.clipboard.writeText(selection).catch((err) => { + console.warn('[MudClient] Clipboard write failed:', err); + }); + } + + // Prevent the browser default (which would also try to copy from xterm's + // hidden helper textarea and may end up empty) and stop xterm from + // emitting  as input. + event.preventDefault(); + return false; + }); + } + /** * Sets up a paste event handler to intercept native clipboard paste operations. * This method listens on the terminal container for paste events that come @@ -663,13 +767,13 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { */ private setupPasteHandler(element: HTMLElement): void { this.pasteHandler = (event: ClipboardEvent) => { - console.log('[MudClient] Native paste event intercepted'); + this.pasteLog('[MudClient] Native paste event intercepted'); // Don't prevent default for now - let xterm handle the visual part // We'll just read the clipboard data and inject it properly const pastedText = event.clipboardData?.getData('text/plain'); - console.log('[MudClient] Clipboard content:', { + this.pasteLog('[MudClient] Clipboard content:', { length: pastedText?.length ?? 0, preview: pastedText?.substring(0, 50), }); @@ -700,7 +804,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { try { const pastedText = await navigator.clipboard.readText(); - console.log('[MudClient] Clipboard content read:', { + this.pasteLog('[MudClient] Clipboard content read:', { length: pastedText.length, preview: pastedText.substring(0, 50), }); @@ -718,4 +822,49 @@ export class MudClientComponent implements AfterViewInit, OnDestroy { console.error('[MudClient] Failed to read clipboard:', err); } } + + /** Logs only when screenreader debug logging is enabled in the footer menu. */ + private srLog(...args: unknown[]): void { + if (this.debugSettings.screenReaderLogging) { + console.debug(...args); + } + } + + /** Logs only when paste debug logging is enabled in the footer menu. */ + private pasteLog(...args: unknown[]): void { + if (this.debugSettings.pasteLogging) { + console.debug(...args); + } + } + + /** + * Registers two toggle entries in the footer menu: + * - "Screenreader-Debug" — enables [ScreenReader] / SR-related console logs + * - "Paste-Debug" — enables [PASTE-DEBUG] / clipboard-related logs + * + * Both default to off. The menu's checked state is kept in sync via subscriptions. + */ + private registerDebugMenuItems(): void { + this.footerMenu.register({ + id: this.SR_MENU_ID, + label: 'Screenreader-Debug', + checked: this.debugSettings.screenReaderLogging, + action: () => this.debugSettings.toggleScreenReaderLogging(), + }); + + this.footerMenu.register({ + id: this.PASTE_MENU_ID, + label: 'Paste-Debug', + checked: this.debugSettings.pasteLogging, + action: () => this.debugSettings.togglePasteLogging(), + }); + + this.debugSettings.screenReaderLogging$.subscribe((enabled) => { + this.footerMenu.setChecked(this.SR_MENU_ID, enabled); + }); + + this.debugSettings.pasteLogging$.subscribe((enabled) => { + this.footerMenu.setChecked(this.PASTE_MENU_ID, enabled); + }); + } } diff --git a/frontend/src/app/core/mud/services/mud.service.ts b/frontend/src/app/core/mud/services/mud.service.ts index b4237b2e..da30f67a 100644 --- a/frontend/src/app/core/mud/services/mud.service.ts +++ b/frontend/src/app/core/mud/services/mud.service.ts @@ -1,10 +1,15 @@ import { inject, Injectable } from '@angular/core'; import { SocketsService } from '@webmud3/frontend/features/sockets/sockets.service'; +import { GmcpService } from '@webmud3/frontend/features/gmcp/gmcp.service'; +import { MudSignalService } from '@webmud3/frontend/features/gmcp/signals/mud-signal.service'; import { SecureString } from '@webmud3/frontend/shared/types/secure-string'; +import type { MudSignalType, MudSignal } from '@webmud3/frontend/features/gmcp/signals/mud-signals'; @Injectable({ providedIn: 'root' }) export class MudService { private readonly sockets = inject(SocketsService); + private readonly gmcp = inject(GmcpService); + private readonly signalService = inject(MudSignalService); /** Observable, das den Verbindungsstatus zum MUD anzeigt */ public readonly connectedToMud$ = this.sockets.connectedToMud$; @@ -21,7 +26,24 @@ export class MudService { /** Emittiert isNewConnection wenn MUD-Verbindung hergestellt wurde */ public readonly mudConnect$ = this.sockets.onMudConnect.asObservable(); + /** Ob GMCP aktiv ist (erfolgreich mit MUD-Server verhandelt) */ + public readonly gmcpActive$ = this.gmcp.active$; + + /** Stream aller eingehenden GMCP-Nachrichten */ + public readonly gmcpMessages$ = this.gmcp.messages$; + + /** Stream aller typisierten MUD-Signals */ + public readonly signals$ = this.signalService.signals$; + + // Cache the last viewport so reconnect() can reuse it without the caller + // having to know about terminal dimensions. + private lastViewport: { columns: number; rows: number } = { + columns: 80, + rows: 25, + }; + public connect(initialViewPort: { columns: number; rows: number }) { + this.lastViewport = initialViewPort; this.sockets.connectToMud(initialViewPort); } @@ -29,11 +51,40 @@ export class MudService { this.sockets.disconnectFromMud(); } + /** Reconnects using the most recently used viewport. */ + public reconnect() { + this.sockets.connectToMud(this.lastViewport); + } + public sendMessage(msg: string | SecureString) { this.sockets.sendMessage(msg); } public updateViewportSize(columns: number, rows: number) { + this.lastViewport = { columns, rows }; this.sockets.updateViewportSize(columns, rows); } + + /** Sendet eine GMCP-Nachricht an den MUD-Server */ + public sendGmcp(module: string, data?: unknown) { + this.gmcp.send(module, data); + } + + /** Observable das nur GMCP-Nachrichten eines bestimmten Moduls liefert */ + public onGmcpMessage(fullMessage: string) { + return this.gmcp.onMessage(fullMessage); + } + + /** Observable das alle GMCP-Nachrichten eines Pakets liefert */ + public onGmcpPackage(packageName: string) { + return this.gmcp.onPackage(packageName); + } + + /** + * Typsicheres Observable fuer einen bestimmten Signal-Typ. + * Beispiel: `onSignal('Char.Name').subscribe(s => s.name)` + */ + public onSignal(type: T) { + return this.signalService.on(type); + } } diff --git a/frontend/src/app/features/connection/connection-menu.service.ts b/frontend/src/app/features/connection/connection-menu.service.ts new file mode 100644 index 00000000..f0b1d0d0 --- /dev/null +++ b/frontend/src/app/features/connection/connection-menu.service.ts @@ -0,0 +1,54 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { MudService } from '@webmud3/frontend/core/mud/services/mud.service'; +import { FooterMenuService } from '@webmud3/frontend/features/footer/footer-menu.service'; + +const MENU_ID = 'connection'; + +/** + * Adds a "Verbinden / Trennen" entry to the footer menu. + * + * The label flips depending on the current MUD connection state: + * - connected → "Trennen" (action: disconnect) + * - disconnected → "Verbinden" (action: reconnect using the last viewport) + * + * The default auto-connect at app start is unaffected; this service only + * provides a manual handle. + */ +@Injectable({ providedIn: 'root' }) +export class ConnectionMenuService implements OnDestroy { + private readonly mudService = inject(MudService); + private readonly footerMenu = inject(FooterMenuService); + + private subscription: Subscription | undefined; + + constructor() { + // Initial registration (assume disconnected — first connectedToMud$ value + // will overwrite immediately). + this.updateItem(false); + + this.subscription = this.mudService.connectedToMud$.subscribe( + (connected) => this.updateItem(connected), + ); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + this.footerMenu.unregister(MENU_ID); + } + + private updateItem(connected: boolean): void { + this.footerMenu.register({ + id: MENU_ID, + label: connected ? 'Trennen' : 'Verbinden', + action: () => { + if (connected) { + this.mudService.disconnect(); + } else { + this.mudService.reconnect(); + } + }, + }); + } +} diff --git a/frontend/src/app/features/debug/debug-settings.service.ts b/frontend/src/app/features/debug/debug-settings.service.ts new file mode 100644 index 00000000..d878cef2 --- /dev/null +++ b/frontend/src/app/features/debug/debug-settings.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +/** + * Runtime-toggleable debug switches. Each flag is exposed both as a + * synchronous getter (for non-Angular helpers like the screen-reader + * announcer) and as an Observable (for components / menu state). + * + * All flags default to `false` so no extra console output is produced + * in normal usage. + */ +@Injectable({ providedIn: 'root' }) +export class DebugSettingsService { + private readonly screenReaderLoggingSubject = new BehaviorSubject( + false, + ); + private readonly pasteLoggingSubject = new BehaviorSubject(false); + + public readonly screenReaderLogging$ = + this.screenReaderLoggingSubject.asObservable(); + public readonly pasteLogging$ = this.pasteLoggingSubject.asObservable(); + + public get screenReaderLogging(): boolean { + return this.screenReaderLoggingSubject.value; + } + + public get pasteLogging(): boolean { + return this.pasteLoggingSubject.value; + } + + public setScreenReaderLogging(enabled: boolean): void { + if (this.screenReaderLoggingSubject.value !== enabled) { + this.screenReaderLoggingSubject.next(enabled); + } + } + + public setPasteLogging(enabled: boolean): void { + if (this.pasteLoggingSubject.value !== enabled) { + this.pasteLoggingSubject.next(enabled); + } + } + + public toggleScreenReaderLogging(): boolean { + const next = !this.screenReaderLoggingSubject.value; + this.screenReaderLoggingSubject.next(next); + return next; + } + + public togglePasteLogging(): boolean { + const next = !this.pasteLoggingSubject.value; + this.pasteLoggingSubject.next(next); + return next; + } +} diff --git a/frontend/src/app/features/editor/dirlist-window.service.ts b/frontend/src/app/features/editor/dirlist-window.service.ts new file mode 100644 index 00000000..bc0fb853 --- /dev/null +++ b/frontend/src/app/features/editor/dirlist-window.service.ts @@ -0,0 +1,160 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { FooterMenuService } from '@webmud3/frontend/features/footer/footer-menu.service'; +import { GmcpService } from '@webmud3/frontend/features/gmcp/gmcp.service'; +import { MudSignalService } from '@webmud3/frontend/features/gmcp/signals/mud-signal.service'; +import { WindowService } from '@webmud3/frontend/features/windows/window.service'; + +const MENU_ID = 'dirlist-window'; + +const DIRLIST_DEFAULT_WIDTH = 460; +const DIRLIST_DEFAULT_HEIGHT = 360; +const DIRLIST_DEFAULT_X = 60; +const DIRLIST_DEFAULT_Y = 60; + +/** + * Wires the directory browser into the application. + * + * Visibility rule: the "Verzeichnis" footer menu entry is only registered + * when the connected character is a wizard. We detect that via the `wizard` + * field in the `Char.Name` GMCP message (UNItopia sends a non-zero number + * for wizards, absent for regular players); ordinary players never see the + * entry. + * + * When the user clicks the entry, a directory window is opened. The actual + * directory contents arrive asynchronously via `Files.DirectoryList` pushes + * (handled by `FilesService` -> `DirlistComponent`). + */ +@Injectable({ providedIn: 'root' }) +export class DirlistWindowService implements OnDestroy { + private readonly windowService = inject(WindowService); + private readonly footerMenu = inject(FooterMenuService); + private readonly signals = inject(MudSignalService); + private readonly gmcp = inject(GmcpService); + + private readonly subscriptions: Subscription[] = []; + + private windowId: string | undefined; + private windowSubscription: Subscription | undefined; + private menuRegistered = false; + + constructor() { + this.subscriptions.push( + this.signals.on('Char.Name').subscribe((s) => { + // UNItopia sends `wizard` as a non-zero number for wizard accounts + // and omits it for regular players. Treat any truthy value as wizard. + if (s.wizard) { + this.ensureMenuRegistered(); + } else { + this.ensureMenuUnregistered(); + } + }), + ); + } + + ngOnDestroy(): void { + for (const sub of this.subscriptions) { + sub.unsubscribe(); + } + + this.windowSubscription?.unsubscribe(); + this.ensureMenuUnregistered(); + } + + public toggle(): void { + if (this.windowId !== undefined) { + this.close(); + } else { + this.open(); + } + } + + public open(): void { + if (this.windowId !== undefined) { + this.windowService.focus(this.windowId); + this.requestRefresh(); + return; + } + + this.windowId = this.windowService.newWindow({ + title: 'Verzeichnis', + component: 'dirlist', + posX: DIRLIST_DEFAULT_X, + posY: DIRLIST_DEFAULT_Y, + width: DIRLIST_DEFAULT_WIDTH, + height: DIRLIST_DEFAULT_HEIGHT, + }); + + this.footerMenu.setChecked(MENU_ID, true); + this.requestRefresh(); + + this.windowSubscription = this.windowService.windows$.subscribe( + (windows) => { + if ( + this.windowId !== undefined && + !windows.some((w) => w.windowId === this.windowId) + ) { + this.windowId = undefined; + this.windowSubscription?.unsubscribe(); + this.windowSubscription = undefined; + this.footerMenu.setChecked(MENU_ID, false); + } + }, + ); + } + + public close(): void { + if (this.windowId === undefined) { + return; + } + + const id = this.windowId; + this.windowId = undefined; + this.windowSubscription?.unsubscribe(); + this.windowSubscription = undefined; + this.footerMenu.setChecked(MENU_ID, false); + this.windowService.close(id); + } + + /** + * Asks the MUD to (re)send the current directory listing. UNItopia + * answers with `Files.DirectoryList`. Silently no-ops if GMCP is not + * active yet. + */ + public requestRefresh(): void { + this.gmcp.send('Files.RequestDir', {}); + } + + // --------------------------------------------------------------------------- + // Footer menu lifecycle + // --------------------------------------------------------------------------- + + private ensureMenuRegistered(): void { + if (this.menuRegistered) { + return; + } + + this.footerMenu.register({ + id: MENU_ID, + label: 'Verzeichnis', + checked: this.windowId !== undefined, + action: () => this.toggle(), + }); + + this.menuRegistered = true; + } + + private ensureMenuUnregistered(): void { + if (!this.menuRegistered) { + return; + } + + if (this.windowId !== undefined) { + this.close(); + } + + this.footerMenu.unregister(MENU_ID); + this.menuRegistered = false; + } +} diff --git a/frontend/src/app/features/editor/dirlist.component.html b/frontend/src/app/features/editor/dirlist.component.html new file mode 100644 index 00000000..bfc3e419 --- /dev/null +++ b/frontend/src/app/features/editor/dirlist.component.html @@ -0,0 +1,79 @@ + + + +
+ (Verzeichnis ist leer) +
+ + + + + + + + + + + + + + + + + + +
NameGrößeDatumZeit
+ + {{ entry.name }} + + + {{ entry.name }}/ + + {{ entry.isdir === 0 ? entry.size : '' }}{{ entry.filedate }}{{ entry.filetime }}
+
+ + +
+ Lade Verzeichnis … +
+
diff --git a/frontend/src/app/features/editor/dirlist.component.scss b/frontend/src/app/features/editor/dirlist.component.scss new file mode 100644 index 00000000..be421e75 --- /dev/null +++ b/frontend/src/app/features/editor/dirlist.component.scss @@ -0,0 +1,115 @@ +:host { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 240px; + font-family: sans-serif; + font-size: 12px; + color: #ddd; + background: #1e1e1e; + overflow: hidden; +} + +.dirlist-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + background: #2a2d2e; + border-bottom: 1px solid #3a3a3a; + flex: 0 0 auto; +} + +.dirlist-path { + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 70%; + color: #4ec9b0; + font-weight: bold; + font-family: monospace; +} + +.dirlist-flex-spacer { + flex: 1 1 auto; +} + +.dirlist-btn { + padding: 2px 8px; + background: #0e639c; + border: 1px solid #1177bb; + color: #fff; + font-size: 11px; + border-radius: 2px; + cursor: pointer; + + &:hover:not(:disabled) { + background: #1177bb; + } + + &:disabled { + background: #444; + border-color: #555; + color: #888; + cursor: not-allowed; + } +} + +.dirlist-empty { + padding: 14px; + color: #888; + font-style: italic; + text-align: center; +} + +.dirlist-table { + flex: 1 1 auto; + width: 100%; + border-collapse: collapse; + overflow-y: auto; + display: block; + + thead { + position: sticky; + top: 0; + background: #2a2d2e; + } + + th, + td { + text-align: left; + padding: 2px 6px; + white-space: nowrap; + } + + th { + color: #4ec9b0; + font-weight: bold; + border-bottom: 1px solid #3a3a3a; + } + + td { + border-bottom: 1px solid #2a2a2a; + } + + tr.is-dir td a { + color: #4ec9b0; + } + + a { + color: #9cdcfe; + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &:focus { + outline: 1px dashed #4ec9b0; + outline-offset: 1px; + } + } +} diff --git a/frontend/src/app/features/editor/dirlist.component.ts b/frontend/src/app/features/editor/dirlist.component.ts new file mode 100644 index 00000000..38fe38ac --- /dev/null +++ b/frontend/src/app/features/editor/dirlist.component.ts @@ -0,0 +1,73 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + Input, +} from '@angular/core'; + +import { GmcpService } from '@webmud3/frontend/features/gmcp/gmcp.service'; +import type { WindowConfig } from '@webmud3/frontend/features/windows/window-config'; +import type { FileEntry } from '../gmcp/signals/mud-signals'; +import { FilesService } from './files.service'; + +/** + * Directory browser window content. + * + * Shows the latest directory listing pushed by the MUD via `Files.DirectoryList` + * (mapped to MudSignal `Files.Dir`). Clicking a file asks the MUD to open it + * (`Files.OpenFile`); clicking a sub-directory navigates into it + * (`Files.ChDir`). The MUD answers each navigation with a fresh + * `Files.DirectoryList`, which the component picks up reactively via the + * async pipe in the template. + */ +@Component({ + selector: 'app-dirlist', + templateUrl: './dirlist.component.html', + styleUrls: ['./dirlist.component.scss'], + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DirlistComponent { + @Input({ required: true }) config!: WindowConfig; + + private readonly files = inject(FilesService); + private readonly gmcp = inject(GmcpService); + + public readonly listing$ = this.files.directoryList$; + + public onFileClick(entry: FileEntry, currentPath: string, event?: Event): void { + event?.preventDefault(); + + const fullPath = this.joinPath(currentPath, entry.name); + this.gmcp.send('Files.OpenFile', { file: fullPath }); + } + + public onDirClick(entry: FileEntry, currentPath: string, event?: Event): void { + event?.preventDefault(); + + // ".." stays a relative reference — the MUD resolves it server-side. + // Anything else is sent as an absolute path beneath the current path. + const dir = + entry.name === '..' ? '..' : this.joinPath(currentPath, entry.name); + + this.gmcp.send('Files.ChDir', { dir }); + } + + public onParentDir(): void { + this.gmcp.send('Files.ChDir', { dir: '..' }); + } + + public trackByEntry(_index: number, entry: FileEntry): string { + return `${entry.isdir ? 'd' : 'f'}:${entry.name}`; + } + + private joinPath(base: string, name: string): string { + if (base === '' || base === '/') { + return `/${name}`; + } + + return base.endsWith('/') ? `${base}${name}` : `${base}/${name}`; + } +} diff --git a/frontend/src/app/features/editor/editor-window.service.ts b/frontend/src/app/features/editor/editor-window.service.ts new file mode 100644 index 00000000..d80bf8fe --- /dev/null +++ b/frontend/src/app/features/editor/editor-window.service.ts @@ -0,0 +1,78 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { WindowService } from '@webmud3/frontend/features/windows/window.service'; +import type { FileInfo } from '../gmcp/signals/mud-signals'; +import { FilesService } from './files.service'; + +const EDITOR_DEFAULT_WIDTH = 720; +const EDITOR_DEFAULT_HEIGHT = 480; +const EDITOR_DEFAULT_X = 120; +const EDITOR_DEFAULT_Y = 80; +const EDITOR_OFFSET_PER_WINDOW = 24; + +/** + * Wires the editor feature into the application: + * - listens to FilesService.fileOpen$ (MUD-pushed file requests) + * - opens an editor window for each new file + * - re-focuses an existing window if the same file path is pushed again + * - keeps the path -> windowId mapping in sync when the user closes a window + */ +@Injectable({ providedIn: 'root' }) +export class EditorWindowService implements OnDestroy { + private readonly windowService = inject(WindowService); + private readonly files = inject(FilesService); + + private readonly pathToWindow = new Map(); + private readonly subscriptions: Subscription[] = []; + + constructor() { + this.subscriptions.push( + this.files.fileOpen$.subscribe((fi) => this.openEditorFor(fi)), + this.windowService.windows$.subscribe((windows) => { + // Drop mappings for windows that have been closed externally. + const liveIds = new Set(windows.map((w) => w.windowId)); + + for (const [path, id] of this.pathToWindow) { + if (!liveIds.has(id)) { + this.pathToWindow.delete(path); + } + } + }), + ); + } + + ngOnDestroy(): void { + for (const sub of this.subscriptions) { + sub.unsubscribe(); + } + } + + private openEditorFor(fileinfo: FileInfo): void { + const existingId = this.pathToWindow.get(fileinfo.file); + + if (existingId !== undefined) { + // Same file pushed again — focus the existing window. The editor + // component itself is responsible for refreshing its content if + // the MUD reissues a load (e.g. after a save handshake). + this.windowService.focus(existingId); + return; + } + + const offset = this.pathToWindow.size * EDITOR_OFFSET_PER_WINDOW; + + const windowId = this.windowService.newWindow({ + title: fileinfo.title || fileinfo.filename || 'Editor', + tooltip: fileinfo.file, + component: 'editor', + data: { fileinfo }, + posX: EDITOR_DEFAULT_X + offset, + posY: EDITOR_DEFAULT_Y + offset, + width: EDITOR_DEFAULT_WIDTH, + height: EDITOR_DEFAULT_HEIGHT, + saveable: true, + }); + + this.pathToWindow.set(fileinfo.file, windowId); + } +} diff --git a/frontend/src/app/features/editor/editor.component.html b/frontend/src/app/features/editor/editor.component.html new file mode 100644 index 00000000..0b77b734 --- /dev/null +++ b/frontend/src/app/features/editor/editor.component.html @@ -0,0 +1,41 @@ + + +
+ +
+ {{ errorMessage }} + {{ statusMessage }} +
diff --git a/frontend/src/app/features/editor/editor.component.scss b/frontend/src/app/features/editor/editor.component.scss new file mode 100644 index 00000000..a61b9324 --- /dev/null +++ b/frontend/src/app/features/editor/editor.component.scss @@ -0,0 +1,72 @@ +:host { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 320px; + font-family: sans-serif; + font-size: 13px; + color: #ddd; + background: #1e1e1e; +} + +.editor-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + background: #2a2d2e; + border-bottom: 1px solid #3a3a3a; +} + +.editor-path { + flex: 0 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 60%; + color: #4ec9b0; + font-weight: bold; +} + +.editor-flex-spacer { + flex: 1 1 auto; +} + +.editor-btn { + padding: 3px 10px; + background: #0e639c; + border: 1px solid #1177bb; + color: #fff; + font-size: 12px; + border-radius: 2px; + cursor: pointer; + + &:hover:not(:disabled) { + background: #1177bb; + } + + &:disabled { + background: #444; + border-color: #555; + color: #888; + cursor: not-allowed; + } +} + +.editor-host { + flex: 1 1 auto; + min-height: 240px; + width: 100%; +} + +.editor-status { + padding: 3px 8px; + font-size: 11px; + background: #2a2d2e; + border-top: 1px solid #3a3a3a; +} + +.editor-error { + color: #f48771; +} diff --git a/frontend/src/app/features/editor/editor.component.ts b/frontend/src/app/features/editor/editor.component.ts new file mode 100644 index 00000000..bae897dd --- /dev/null +++ b/frontend/src/app/features/editor/editor.component.ts @@ -0,0 +1,238 @@ +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + Input, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import loader from '@monaco-editor/loader'; +import type * as MonacoNs from 'monaco-editor'; + +import type { WindowConfig } from '@webmud3/frontend/features/windows/window-config'; +import type { FileInfo } from '../gmcp/signals/mud-signals'; +import { FilesService } from './files.service'; + +type EditorPayload = { + fileinfo: FileInfo; +}; + +/** + * Code editor window content. Hosts a lazy-loaded Monaco editor instance. + * + * Receives its `WindowConfig` via the `config` input wired up by the + * Window-Container's `ngComponentOutletInputs`. The hosted file is read from + * `config.data.fileinfo`; the body is fetched via `FilesService.loadContent`. + * + * Save / cancel actions communicate back to the WindowService through + * `config.outgoing` so the surrounding window can be closed by the host. + * + * Accessibility: `accessibilitySupport: 'on'` activates Monaco's screen-reader + * paging mode (NVDA / JAWS / VoiceOver compatible). + */ +@Component({ + selector: 'app-editor', + templateUrl: './editor.component.html', + styleUrls: ['./editor.component.scss'], + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EditorComponent implements OnInit, AfterViewInit, OnDestroy { + @Input({ required: true }) config!: WindowConfig; + + @ViewChild('host', { static: true }) + private hostRef!: ElementRef; + + private readonly files = inject(FilesService); + + private editor: MonacoNs.editor.IStandaloneCodeEditor | undefined; + private initialContent = ''; + private hasUnsavedChanges = false; + private destroyed = false; + + public saving = false; + + public statusMessage = 'Lade Datei …'; + public errorMessage: string | null = null; + + public get fileinfo(): FileInfo | undefined { + return (this.config?.data as EditorPayload | undefined)?.fileinfo; + } + + public get title(): string { + return this.fileinfo?.title || this.fileinfo?.filename || 'Editor'; + } + + public get readOnly(): boolean { + return this.fileinfo?.writeacl === false; + } + + ngOnInit(): void { + if (!this.fileinfo) { + this.errorMessage = 'Keine Datei-Information vorhanden.'; + this.statusMessage = ''; + } + } + + ngAfterViewInit(): void { + if (!this.fileinfo) { + return; + } + + void this.bootstrap(this.fileinfo); + } + + ngOnDestroy(): void { + this.destroyed = true; + + if (this.editor) { + const model = this.editor.getModel(); + this.editor.dispose(); + model?.dispose(); + this.editor = undefined; + } + } + + public onSave(closeAfter: boolean): void { + if (this.readOnly || this.editor === undefined || this.saving) { + return; + } + + const fileinfo = this.fileinfo; + if (!fileinfo) { + return; + } + + const content = this.editor.getValue(); + + this.saving = true; + this.statusMessage = 'Speichere …'; + this.errorMessage = null; + + this.files.saveFile(fileinfo, content).subscribe({ + next: () => { + if (this.destroyed) { + return; + } + + this.initialContent = content; + this.hasUnsavedChanges = false; + this.saving = false; + this.statusMessage = 'Gespeichert.'; + + if (closeAfter) { + this.config.outgoing.next('do_close'); + } + }, + error: (err: unknown) => { + if (this.destroyed) { + return; + } + + this.saving = false; + this.errorMessage = `Fehler beim Speichern: ${this.formatError(err)}`; + this.statusMessage = ''; + }, + }); + } + + public onCancel(): void { + if ( + this.hasUnsavedChanges && + !window.confirm('Willst Du ohne Speichern schliessen?') + ) { + return; + } + + this.config.outgoing.next('do_close'); + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private async bootstrap(fileinfo: FileInfo): Promise { + let monaco: typeof MonacoNs; + + try { + monaco = await loader.init(); + } catch (err) { + this.errorMessage = `Editor konnte nicht geladen werden: ${this.formatError(err)}`; + this.statusMessage = ''; + return; + } + + if (this.destroyed) { + return; + } + + let content = fileinfo.content ?? ''; + + if (content === '' && fileinfo.lasturl) { + try { + content = await this.files.loadContent(fileinfo).toPromise() ?? ''; + } catch (err) { + this.errorMessage = `Datei konnte nicht geladen werden: ${this.formatError(err)}`; + this.statusMessage = ''; + return; + } + } + + if (this.destroyed) { + return; + } + + this.initialContent = content; + this.statusMessage = ''; + + this.editor = monaco.editor.create(this.hostRef.nativeElement, { + value: content, + language: this.toMonacoLanguage(fileinfo.editortype), + readOnly: this.readOnly, + automaticLayout: true, + accessibilitySupport: 'on', + ariaLabel: `Datei-Editor: ${this.title}`, + tabSize: 4, + insertSpaces: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'on', + }); + + this.editor.onDidChangeModelContent(() => { + this.hasUnsavedChanges = this.editor?.getValue() !== this.initialContent; + }); + } + + private toMonacoLanguage(editortype: string | undefined): string { + switch (editortype) { + case 'c_cpp': + return 'cpp'; + + case 'text': + case undefined: + case '': + return 'plaintext'; + + default: + return editortype; + } + } + + private formatError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + + if (typeof err === 'string') { + return err; + } + + return String(err); + } +} diff --git a/frontend/src/app/features/editor/files-gmcp.module.ts b/frontend/src/app/features/editor/files-gmcp.module.ts new file mode 100644 index 00000000..760dbf4a --- /dev/null +++ b/frontend/src/app/features/editor/files-gmcp.module.ts @@ -0,0 +1,27 @@ +import { inject, Injectable } from '@angular/core'; + +import type { GmcpModule } from '../gmcp/gmcp-module'; +import { GmcpMessage, GmcpService } from '../gmcp/gmcp.service'; + +/** + * GMCP module that announces support for the "Files" package to the MUD. + * + * Once registered, `Core.Supports.Set` includes "Files 1", which causes + * UNItopia to send `Files.OpenFile` and `Files.DirectoryList` messages. + * + * The actual UI handling lives in FilesService, which subscribes to the + * matching MudSignals (`Files.Open`, `Files.Dir`). `handleMessage` is + * intentionally a no-op. + */ +@Injectable({ providedIn: 'root' }) +export class FilesGmcpModule implements GmcpModule { + readonly supports = [{ name: 'Files', version: 1 }]; + + constructor() { + inject(GmcpService).registerModule(this); + } + + handleMessage(_message: GmcpMessage): void { + // No-op. MudSignalService routes Files.* to FilesService. + } +} diff --git a/frontend/src/app/features/editor/files.service.ts b/frontend/src/app/features/editor/files.service.ts new file mode 100644 index 00000000..5bdce535 --- /dev/null +++ b/frontend/src/app/features/editor/files.service.ts @@ -0,0 +1,152 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + BehaviorSubject, + filter, + map, + Observable, + Subject, + Subscription, + switchMap, + take, + tap, +} from 'rxjs'; + +import { GmcpService } from '../gmcp/gmcp.service'; +import { MudSignalService } from '../gmcp/signals/mud-signal.service'; +import type { + FileEntry, + FileInfo, +} from '../gmcp/signals/mud-signals'; +import { FilesGmcpModule } from './files-gmcp.module'; + +export type DirectoryListing = { + path: string; + entries: FileEntry[]; +}; + +/** + * Manages file metadata and HTTP-based load/save for files exposed by the MUD + * via the GMCP `Files` package. + * + * Responsibilities: + * - Cache `FileInfo` objects pushed by the MUD (`Files.OpenFile` -> Files.Open signal) + * - Stream incoming open requests and directory listings to consumers + * (Editor / Dirlist components — added in later migration steps) + * - Perform HTTP GET (load body) and HTTP PUT (save body) against the URL + * provided by the MUD in `FileInfo.lasturl` + * - Coordinate the save handshake: client sends `Files.OpenFile {flag:1}` to + * ask the MUD for a writable URL, waits for the matching `Files.Open` + * response, PUTs the new content, then notifies the MUD via + * `Files.fileSaved`. + */ +@Injectable({ providedIn: 'root' }) +export class FilesService implements OnDestroy { + private readonly signals = inject(MudSignalService); + private readonly gmcp = inject(GmcpService); + private readonly http = inject(HttpClient); + // Bootstraps the GMCP "Files" registration as a side-effect of inject. + private readonly _filesGmcp = inject(FilesGmcpModule); + + private readonly cache = new Map(); + private readonly subscriptions: Subscription[] = []; + + private readonly fileOpenSubject = new Subject(); + private readonly directorySubject = + new BehaviorSubject(null); + + /** Emits each file the MUD asks the client to open (load or save). */ + public readonly fileOpen$ = this.fileOpenSubject.asObservable(); + + /** Latest directory listing pushed by the MUD; `null` until the first arrives. */ + public readonly directoryList$ = this.directorySubject.asObservable(); + + constructor() { + this.subscriptions.push( + this.signals.on('Files.Open').subscribe((s) => { + const fileinfo = this.normalizeFileInfo(s.fileinfo); + + this.cache.set(fileinfo.file, fileinfo); + this.fileOpenSubject.next(fileinfo); + }), + this.signals.on('Files.Dir').subscribe((s) => { + this.directorySubject.next({ path: s.path, entries: s.entries }); + }), + ); + } + + ngOnDestroy(): void { + for (const sub of this.subscriptions) { + sub.unsubscribe(); + } + } + + /** Synchronous lookup of a previously-seen file by its MUD path. */ + public getCachedFile(filepath: string): FileInfo | undefined { + return this.cache.get(filepath); + } + + /** Loads the file body via HTTP GET against `fileinfo.lasturl`. */ + public loadContent(fileinfo: FileInfo): Observable { + return this.http.get(fileinfo.lasturl, { responseType: 'text' }); + } + + /** + * Saves the given content to the MUD. + * + * Protocol (mirrors u1_master): + * 1. Send `Files.OpenFile {file, title, flag:1}` to request a writable URL. + * 2. Wait for the matching `Files.Open` response carrying the new URL. + * 3. HTTP PUT the content to that URL. + * 4. Notify the MUD with `Files.fileSaved {file, title, flag:1}`. + * + * Resolves with the updated `FileInfo` (now containing the new `lasturl`). + */ + public saveFile(fileinfo: FileInfo, content: string): Observable { + this.gmcp.send('Files.OpenFile', { + file: fileinfo.file, + title: fileinfo.title, + flag: 1, + }); + + return this.fileOpenSubject.pipe( + filter((fi) => fi.file === fileinfo.file), + take(1), + switchMap((fi) => + this.http.put(fi.lasturl, content, { responseType: 'text' }).pipe( + tap(() => { + this.gmcp.send('Files.fileSaved', { + file: fi.file, + title: fi.title, + flag: 1, + }); + }), + map(() => ({ ...fi, content })), + ), + ), + ); + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private normalizeFileInfo(raw: FileInfo): FileInfo { + return { + ...raw, + editortype: raw.editortype ?? this.deriveEditorType(raw.filetype), + }; + } + + private deriveEditorType(filetype: string): string { + switch (filetype) { + case '.c': + case '.h': + case '.inc': + return 'c_cpp'; + + default: + return 'text'; + } + } +} diff --git a/frontend/src/app/features/footer/char-footer.component.html b/frontend/src/app/features/footer/char-footer.component.html new file mode 100644 index 00000000..0e1a4100 --- /dev/null +++ b/frontend/src/app/features/footer/char-footer.component.html @@ -0,0 +1,42 @@ +
+ {{ charName() }} + no character + + {{ v }} +
+ +
+ + diff --git a/frontend/src/app/features/footer/char-footer.component.scss b/frontend/src/app/features/footer/char-footer.component.scss new file mode 100644 index 00000000..e8860739 --- /dev/null +++ b/frontend/src/app/features/footer/char-footer.component.scss @@ -0,0 +1,119 @@ +:host { + display: flex; + align-items: center; + flex: 0 0 auto; + padding: 4px 8px; + background: #2d2d30; + color: #ddd; + border-top: 1px solid #444; + font-family: sans-serif; + font-size: 13px; + user-select: none; +} + +.char-info { + display: flex; + align-items: center; + gap: 8px; +} + +.char-name { + font-weight: bold; +} + +.char-vitals { + color: #4ec9b0; + font-family: monospace; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 60ch; +} + +.placeholder { + color: #777; + font-style: italic; +} + +.spacer { + flex: 1 1 auto; +} + +.menu-root { + position: relative; +} + +.menu-button { + background: transparent; + border: 1px solid transparent; + color: #ddd; + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: 3px; + + &:hover, + &.open { + background: #3e3e42; + border-color: #555; + } +} + +.menu-dropdown { + position: absolute; + bottom: calc(100% + 4px); + right: 0; + min-width: 180px; + background: #1e1e1e; + border: 1px solid #444; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + padding: 4px 0; + z-index: 1000; +} + +.menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + background: transparent; + border: none; + color: #ddd; + text-align: left; + padding: 6px 12px; + cursor: pointer; + font-size: 13px; + + &:hover:not(:disabled) { + background: #2a2d2e; + } + + &:disabled { + color: #666; + cursor: default; + } + + .check { + width: 12px; + color: #4ec9b0; + } + + .icon { + width: 16px; + text-align: center; + } + + .label { + flex: 1 1 auto; + } +} + +.menu-empty { + padding: 8px 12px; + color: #777; + font-style: italic; + font-size: 12px; +} diff --git a/frontend/src/app/features/footer/char-footer.component.ts b/frontend/src/app/features/footer/char-footer.component.ts new file mode 100644 index 00000000..e0e0e6c1 --- /dev/null +++ b/frontend/src/app/features/footer/char-footer.component.ts @@ -0,0 +1,93 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostListener, + inject, + OnDestroy, + OnInit, + signal, + ViewChild, +} from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { MudSignalService } from '@webmud3/frontend/features/gmcp/signals/mud-signal.service'; +import { FooterMenuService } from './footer-menu.service'; + +/** + * Permanent footer at the bottom of the application. + * + * Left: Character info (name, vitals, status) sourced from GMCP signals. + * Right: A menu button (gear) opening a dropdown with dynamically + * registered actions (see FooterMenuService). + * + * Components that want to expose toggles or actions in the footer menu + * register themselves via FooterMenuService.register(). + */ +@Component({ + selector: 'app-char-footer', + templateUrl: './char-footer.component.html', + styleUrls: ['./char-footer.component.scss'], + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CharFooterComponent implements OnInit, OnDestroy { + private readonly signals = inject(MudSignalService); + private readonly menu = inject(FooterMenuService); + + @ViewChild('menuRoot') menuRoot?: ElementRef; + + public readonly menuItems$ = this.menu.items$; + + public readonly charName = signal(''); + public readonly vitalsText = signal(''); + public readonly status = signal(null); + public readonly menuOpen = signal(false); + + private readonly subscriptions: Subscription[] = []; + + ngOnInit(): void { + this.subscriptions.push( + this.signals + .on('Char.Name') + .subscribe((s) => this.charName.set(s.fullName)), + this.signals + .on('Char.Vitals') + .subscribe((s) => this.vitalsText.set(s.text ?? '')), + this.signals.on('Char.Status').subscribe((s) => this.status.set(s.data)), + ); + } + + ngOnDestroy(): void { + for (const sub of this.subscriptions) { + sub.unsubscribe(); + } + } + + public toggleMenu(event: Event): void { + event.stopPropagation(); + this.menuOpen.update((v) => !v); + } + + public onMenuItemClick(action: () => void): void { + action(); + this.menuOpen.set(false); + } + + /** Closes the menu when clicking outside */ + @HostListener('document:pointerdown', ['$event']) + public onDocumentPointerDown(event: PointerEvent): void { + if (!this.menuOpen()) { + return; + } + + const target = event.target as Node; + const root = this.menuRoot?.nativeElement; + + if (root && !root.contains(target)) { + this.menuOpen.set(false); + } + } +} diff --git a/frontend/src/app/features/footer/footer-menu.service.ts b/frontend/src/app/features/footer/footer-menu.service.ts new file mode 100644 index 00000000..37b132d6 --- /dev/null +++ b/frontend/src/app/features/footer/footer-menu.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +/** + * A single entry in the footer menu. + */ +export type FooterMenuItem = { + /** Unique id of the item */ + id: string; + /** Display label */ + label: string; + /** Optional icon (emoji or character) shown before the label */ + icon?: string; + /** Whether the item is currently checked / toggled on */ + checked?: boolean; + /** Whether the item is disabled */ + disabled?: boolean; + /** Callback invoked when the item is clicked */ + action: () => void; +}; + +/** + * Manages dynamic items for the footer menu (gear button on the right + * side of the CharFooter). Other features can register entries here + * to expose actions or toggles to the user (e.g. "Show inventory"). + */ +@Injectable({ providedIn: 'root' }) +export class FooterMenuService { + private readonly itemsSubject = new BehaviorSubject([]); + + public readonly items$: Observable = + this.itemsSubject.asObservable(); + + /** + * Adds or replaces a menu item. + */ + public register(item: FooterMenuItem): void { + const current = this.itemsSubject.value.filter((i) => i.id !== item.id); + + this.itemsSubject.next([...current, item]); + } + + /** + * Removes a menu item by id. + */ + public unregister(id: string): void { + const current = this.itemsSubject.value.filter((i) => i.id !== id); + + this.itemsSubject.next(current); + } + + /** + * Updates the `checked` state of an item without rebuilding it. + */ + public setChecked(id: string, checked: boolean): void { + const current = this.itemsSubject.value.map((i) => + i.id === id ? { ...i, checked } : i, + ); + + this.itemsSubject.next(current); + } +} diff --git a/frontend/src/app/features/gmcp/gmcp-module.ts b/frontend/src/app/features/gmcp/gmcp-module.ts new file mode 100644 index 00000000..dcd15b9f --- /dev/null +++ b/frontend/src/app/features/gmcp/gmcp-module.ts @@ -0,0 +1,66 @@ +import { Observable } from 'rxjs'; + +import type { GmcpMessage } from './gmcp.service'; + +/** + * Describes a GMCP module support entry sent to the MUD server via Core.Supports.Set. + * Example: { name: 'Char', version: 1 } -> "Char 1" + */ +export type GmcpModuleSupport = { + /** Module name, e.g. "Char", "Sound", "Files" */ + name: string; + /** Module version number */ + version: number; +}; + +/** + * Interface for a GMCP module handler. + * + * Implement this interface to create a handler for specific GMCP packages. + * Register it with `GmcpService.registerModule()` to receive messages + * and have the module included in `Core.Supports.Set`. + * + * Example usage: + * ```typescript + * @Injectable({ providedIn: 'root' }) + * export class CharModule implements GmcpModule { + * readonly supports = [{ name: 'Char', version: 1 }]; + * + * handleMessage(message: GmcpMessage): void { + * switch (message.fullMessage) { + * case 'Char.Name': + * // handle character name update + * break; + * case 'Char.Vitals': + * // handle vitals update + * break; + * } + * } + * } + * ``` + */ +export interface GmcpModule { + /** + * List of GMCP modules this handler supports. + * These are sent to the MUD server via Core.Supports.Set when GMCP becomes active. + */ + readonly supports: GmcpModuleSupport[]; + + /** + * Called for each incoming GMCP message whose packageName matches one + * of the module names in `supports`. + */ + handleMessage(message: GmcpMessage): void; + + /** + * Optional: Called when GMCP becomes active (after Core.Hello handshake). + * Use this for module-specific initialization (e.g., requesting initial data). + */ + onActivate?(): void; + + /** + * Optional: Called when GMCP is deactivated (MUD disconnect). + * Use this to reset module state. + */ + onDeactivate?(): void; +} diff --git a/frontend/src/app/features/gmcp/gmcp.service.ts b/frontend/src/app/features/gmcp/gmcp.service.ts new file mode 100644 index 00000000..4e522f4a --- /dev/null +++ b/frontend/src/app/features/gmcp/gmcp.service.ts @@ -0,0 +1,254 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + BehaviorSubject, + Observable, + Subject, + Subscription, + filter, +} from 'rxjs'; + +import { SocketsService } from '../sockets/sockets.service'; +import type { GmcpModule, GmcpModuleSupport } from './gmcp-module'; + +/** + * A parsed GMCP message as received from the MUD server. + */ +export type GmcpMessage = { + /** The GMCP package, e.g. "Char" from "Char.Name" */ + packageName: string; + /** The GMCP message name, e.g. "Name" from "Char.Name" */ + messageName: string; + /** The full module string, e.g. "Char.Name" */ + fullMessage: string; + /** The parsed JSON data payload */ + data: unknown; +}; + +/** Client info sent with Core.Hello */ +const CLIENT_INFO = { + client: 'WebMud3', + version: '1.0.0-alpha', +}; + +@Injectable({ providedIn: 'root' }) +export class GmcpService implements OnDestroy { + private readonly sockets = inject(SocketsService); + + private readonly active = new BehaviorSubject(false); + private readonly messages = new Subject(); + private readonly subscriptions: Subscription[] = []; + + /** + * Registry: maps package name (e.g. "Char") to its GmcpModule handler. + * A single GmcpModule may handle multiple packages via its `supports` array. + */ + private readonly registry = new Map(); + + /** All registered modules (for lifecycle management) */ + private readonly modules = new Set(); + + /** Whether GMCP is currently active (negotiated with MUD server) */ + public readonly active$ = this.active.asObservable(); + + /** Stream of all incoming GMCP messages */ + public readonly messages$ = this.messages.asObservable(); + + constructor() { + this.subscriptions.push( + this.sockets.onGmcpActive.subscribe((isActive) => { + const wasActive = this.active.value; + + this.active.next(isActive); + + if (isActive && !wasActive) { + this.handleActivation(); + } else if (!isActive && wasActive) { + this.handleDeactivation(); + } + }), + ); + + this.subscriptions.push( + this.sockets.onGmcpIncoming.subscribe( + ({ packageName, messageName, data }) => { + const message: GmcpMessage = { + packageName, + messageName, + fullMessage: `${packageName}.${messageName}`, + data, + }; + + // Route to registered module handler + const handler = this.registry.get(packageName); + + if (handler) { + handler.handleMessage(message); + } + + // Also emit on the global stream for ad-hoc subscribers + this.messages.next(message); + }, + ), + ); + } + + ngOnDestroy(): void { + for (const sub of this.subscriptions) { + sub.unsubscribe(); + } + } + + // --------------------------------------------------------------------------- + // Module Registry + // --------------------------------------------------------------------------- + + /** + * Registers a GMCP module handler. + * The module's `supports` entries are used to: + * - Route incoming GMCP messages to the handler + * - Include the modules in `Core.Supports.Set` sent to the MUD server + * + * If GMCP is already active, sends an incremental `Core.Supports.Set` + * and calls `onActivate()` immediately. + */ + public registerModule(module: GmcpModule): void { + this.modules.add(module); + + for (const support of module.supports) { + if (this.registry.has(support.name)) { + console.warn( + `[GMCP] Module "${support.name}" is already registered — overwriting`, + ); + } + + this.registry.set(support.name, module); + } + + // If GMCP is already active, send incremental support and activate + if (this.active.value) { + this.sendSupportsSet(module.supports); + module.onActivate?.(); + } + } + + /** + * Unregisters a GMCP module handler. + * Sends `Core.Supports.Remove` to the MUD server if GMCP is active. + */ + public unregisterModule(module: GmcpModule): void { + module.onDeactivate?.(); + this.modules.delete(module); + + const toRemove: string[] = []; + + for (const support of module.supports) { + if (this.registry.get(support.name) === module) { + this.registry.delete(support.name); + + toRemove.push(`${support.name} ${support.version}`); + } + } + + if (this.active.value && toRemove.length > 0) { + this.sockets.sendGmcp('Core.Supports.Remove', toRemove); + } + } + + /** + * Returns all currently registered module support entries. + */ + public getRegisteredSupports(): GmcpModuleSupport[] { + const supports: GmcpModuleSupport[] = []; + + for (const module of this.modules) { + supports.push(...module.supports); + } + + return supports; + } + + // --------------------------------------------------------------------------- + // Message API + // --------------------------------------------------------------------------- + + /** + * Returns an Observable that emits only GMCP messages matching the given full module string. + * Example: `onMessage('Char.Name')` emits when the server sends `Char.Name {...}`. + */ + public onMessage(fullMessage: string): Observable { + return this.messages$.pipe( + filter((msg) => msg.fullMessage === fullMessage), + ); + } + + /** + * Returns an Observable that emits all GMCP messages for a given package. + * Example: `onPackage('Char')` emits for `Char.Name`, `Char.Status`, `Char.Vitals`, etc. + */ + public onPackage(packageName: string): Observable { + return this.messages$.pipe( + filter((msg) => msg.packageName === packageName), + ); + } + + /** + * Sends a GMCP message to the MUD server. + * @param module - The GMCP module string, e.g. "Core.Hello" + * @param data - The data payload (will be JSON-serialized) + */ + public send(module: string, data: unknown = undefined): void { + if (!this.active.value) { + console.warn('[GMCP] Cannot send GMCP message - not active'); + return; + } + + this.sockets.sendGmcp(module, data); + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + /** + * Called when GMCP becomes active. Sends Core.Hello, then Core.Supports.Set + * with all registered modules, then notifies each module. + */ + private handleActivation(): void { + console.info('[GMCP] GMCP activated — sending handshake'); + + // 1. Core.Hello + this.sockets.sendGmcp('Core.Hello', CLIENT_INFO); + + // 2. Core.Supports.Set with all registered modules + const allSupports = this.getRegisteredSupports(); + + if (allSupports.length > 0) { + this.sendSupportsSet(allSupports); + } + + // 3. Notify all modules + for (const module of this.modules) { + module.onActivate?.(); + } + } + + /** + * Called when GMCP is deactivated. Notifies all modules. + */ + private handleDeactivation(): void { + console.info('[GMCP] GMCP deactivated'); + + for (const module of this.modules) { + module.onDeactivate?.(); + } + } + + /** + * Sends Core.Supports.Set with the given module support entries. + */ + private sendSupportsSet(supports: GmcpModuleSupport[]): void { + const supportStrings = supports.map((s) => `${s.name} ${s.version}`); + + this.sockets.sendGmcp('Core.Supports.Set', supportStrings); + } +} diff --git a/frontend/src/app/features/gmcp/modules/char-gmcp.module.ts b/frontend/src/app/features/gmcp/modules/char-gmcp.module.ts new file mode 100644 index 00000000..b73612d3 --- /dev/null +++ b/frontend/src/app/features/gmcp/modules/char-gmcp.module.ts @@ -0,0 +1,31 @@ +import { inject, Injectable } from '@angular/core'; + +import type { GmcpModule } from '../gmcp-module'; +import { GmcpService, GmcpMessage } from '../gmcp.service'; + +/** + * GMCP module that announces support for the "Char" package to the MUD server. + * + * Once registered, the GmcpService includes "Char 1" in its `Core.Supports.Set` + * handshake, which causes UNItopia (and other MUDs) to start sending + * Char.Name, Char.Vitals, Char.Status, Char.Stats + * messages. + * + * The actual UI handling of these messages happens via MudSignalService, which + * subscribes to the global GMCP message stream and emits typed signals. So + * `handleMessage` here intentionally does nothing — its only job is to keep + * the registration alive. + */ +@Injectable({ providedIn: 'root' }) +export class CharGmcpModule implements GmcpModule { + readonly supports = [{ name: 'Char', version: 1 }]; + + constructor() { + inject(GmcpService).registerModule(this); + } + + handleMessage(_message: GmcpMessage): void { + // No-op: the MudSignalService already routes these messages + // (Char.Name, Char.Vitals, Char.Status, Char.Stats) into typed signals. + } +} diff --git a/frontend/src/app/features/gmcp/modules/char-items-gmcp.module.ts b/frontend/src/app/features/gmcp/modules/char-items-gmcp.module.ts new file mode 100644 index 00000000..221e9c2a --- /dev/null +++ b/frontend/src/app/features/gmcp/modules/char-items-gmcp.module.ts @@ -0,0 +1,27 @@ +import { inject, Injectable } from '@angular/core'; + +import type { GmcpModule } from '../gmcp-module'; +import { GmcpService, GmcpMessage } from '../gmcp.service'; + +/** + * GMCP module that announces support for the "Char.Items" package to the MUD. + * + * Once registered, `Core.Supports.Set` includes "Char.Items 1", so UNItopia + * starts sending Char.Items.List, Char.Items.Add and Char.Items.Remove + * messages. + * + * The actual UI handling lives in InventoryService, which subscribes to the + * matching MudSignals. handleMessage is intentionally a no-op. + */ +@Injectable({ providedIn: 'root' }) +export class CharItemsGmcpModule implements GmcpModule { + readonly supports = [{ name: 'Char.Items', version: 1 }]; + + constructor() { + inject(GmcpService).registerModule(this); + } + + handleMessage(_message: GmcpMessage): void { + // No-op. MudSignalService routes Char.Items.* to InventoryService. + } +} diff --git a/frontend/src/app/features/gmcp/signals/mud-signal.service.ts b/frontend/src/app/features/gmcp/signals/mud-signal.service.ts new file mode 100644 index 00000000..80bc73ca --- /dev/null +++ b/frontend/src/app/features/gmcp/signals/mud-signal.service.ts @@ -0,0 +1,410 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { Observable, Subject, Subscription, filter } from 'rxjs'; + +import { GmcpService, GmcpMessage } from '../gmcp.service'; +import type { + MudSignal, + MudSignalType, + InventoryEntry, + FileEntry, +} from './mud-signals'; + +/** + * Converts incoming GMCP messages into typed MudSignal events. + * + * Components subscribe to specific signal types instead of dealing + * with raw GMCP message parsing. This decouples UI components from + * the GMCP protocol details. + * + * Usage: + * ```typescript + * signalService.on('Char.Name').subscribe(signal => { + * console.log(signal.name, signal.wizard); + * }); + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class MudSignalService implements OnDestroy { + private readonly gmcp = inject(GmcpService); + + private readonly signals = new Subject(); + private readonly subscription: Subscription; + + /** + * Cached MUD server name from the last Core.Hello message. + * Used as a fallback when Char.Name doesn't carry the mudname itself. + */ + private cachedMudname: string | undefined; + + /** Stream of all signals */ + public readonly signals$ = this.signals.asObservable(); + + constructor() { + this.subscription = this.gmcp.messages$.subscribe((msg) => { + const signal = this.mapToSignal(msg); + + if (signal !== null) { + console.info(`[MudSignal] ${signal.type}`, signal); + this.signals.next(signal); + } + }); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + /** + * Returns an Observable emitting only signals of the given type. + * + * TypeScript narrows the signal type automatically: + * ```typescript + * service.on('Char.Name').subscribe(s => s.name); // string + * service.on('Sound.Play').subscribe(s => s.url); // string + * ``` + */ + public on( + type: T, + ): Observable> { + return this.signals$.pipe( + filter((s): s is Extract => s.type === type), + ); + } + + /** + * Maps a raw GMCP message to a typed MudSignal, or null if unknown. + */ + private mapToSignal(msg: GmcpMessage): MudSignal | null { + switch (msg.fullMessage) { + // -- Character -- + case 'Char.Name': + return this.mapCharName(msg.data); + case 'Char.Status': + return { type: 'Char.Status', data: msg.data }; + case 'Char.Vitals': + return this.mapCharVitals(msg.data); + case 'Char.Stats': + return { type: 'Char.Stats', data: msg.data }; + + // -- Inventory -- + case 'Char.Items.List': + return { + type: 'Char.Items.List', + entries: this.extractItemsArray(msg.data).map((it) => + this.normalizeInventoryEntry(it), + ), + }; + case 'Char.Items.Add': + return { + type: 'Char.Items.Add', + entry: this.normalizeInventoryEntry(this.extractSingleItem(msg.data)), + }; + case 'Char.Items.Remove': + return { + type: 'Char.Items.Remove', + entry: this.normalizeInventoryEntry(this.extractSingleItem(msg.data)), + }; + + // -- Sound -- + case 'Sound.Url': + return this.mapSoundUrl(msg.data); + case 'Sound.Event': + return this.mapSoundEvent(msg.data); + + // -- Files -- + // UNItopia sends `Files.URL` (server -> client) in response to the + // outgoing `Files.OpenFile` (client -> server) request. We accept both + // spellings for forward compatibility with other MUDs. + case 'Files.URL': + case 'Files.OpenFile': + return this.mapFilesOpen(msg.data); + case 'Files.DirectoryList': + case 'Files.Directory': + return this.mapFilesDirectory(msg.data); + + // -- Input Completion -- + case 'Input.Complete': + return this.mapInputComplete(msg.data); + + // -- Numpad -- + case 'Numpad.SendLevel': + return { type: 'Numpad.SendLevel', data: msg.data }; + case 'Numpad.Update': + return { type: 'Numpad.SendLevel', data: msg.data }; + + // -- Room -- + case 'Room.Info': + return { type: 'Room.Info', data: msg.data }; + + // -- Communication -- + case 'Comm.Say': + case 'Comm.Tell': + case 'Comm.Soul': + return { + type: 'Comm.Message', + channel: msg.messageName, + data: msg.data, + }; + + // -- Core -- + case 'Core.Hello': + return this.mapCoreHello(msg.data); + case 'Core.Ping': + return { type: 'Core.Ping' }; + case 'Core.Goodbye': + return { type: 'Core.Goodbye' }; + + default: + console.debug( + `[MudSignal] Unhandled GMCP message: ${msg.fullMessage}`, + msg.data, + ); + return null; + } + } + + // --------------------------------------------------------------------------- + // Signal Mappers + // --------------------------------------------------------------------------- + + private mapCharName(data: unknown): MudSignal { + const d = data as Record; + + const name = String(d?.['name'] ?? ''); + // Prefer a mudname carried in the Char.Name payload itself; otherwise + // fall back to whatever Core.Hello told us earlier. + const inlineMudname = + typeof d?.['mudname'] === 'string' + ? (d['mudname'] as string) + : typeof d?.['mud'] === 'string' + ? (d['mud'] as string) + : typeof d?.['host'] === 'string' + ? (d['host'] as string) + : undefined; + + const mudname = inlineMudname ?? this.cachedMudname; + const fullName = mudname ? `${name}@${mudname}` : name; + + return { + type: 'Char.Name', + name, + mudname, + fullName, + wizard: typeof d?.['wizard'] === 'number' ? d['wizard'] : undefined, + }; + } + + private mapCoreHello(data: unknown): MudSignal { + const d = data as Record; + + // UNItopia sends `name` for the MUD identifier; spec also allows "mudname". + const mudname = + typeof d?.['name'] === 'string' + ? (d['name'] as string) + : typeof d?.['mudname'] === 'string' + ? (d['mudname'] as string) + : undefined; + + const version = + typeof d?.['version'] === 'string' ? (d['version'] as string) : undefined; + + if (mudname) { + this.cachedMudname = mudname; + } + + return { type: 'Core.Hello', mudname, version }; + } + + private mapCharVitals(data: unknown): MudSignal { + // UNItopia typically supplies a pre-formatted "string" field with the + // human-readable vitals summary alongside the raw numeric fields. + let text: string | undefined; + + if (data && typeof data === 'object') { + const value = (data as Record)['string']; + if (typeof value === 'string') { + text = value; + } + } + + return { type: 'Char.Vitals', text, data }; + } + + private mapSoundUrl(data: unknown): MudSignal | null { + const d = data as Record; + const url = d?.['url']; + + if (typeof url !== 'string') { + return null; + } + + return { type: 'Sound.Play', url }; + } + + private mapSoundEvent(data: unknown): MudSignal | null { + const d = data as Record; + const url = d?.['file'] ?? d?.['url']; + + if (typeof url !== 'string') { + return null; + } + + return { type: 'Sound.Play', url }; + } + + private mapFilesDirectory(data: unknown): MudSignal { + const d = (data ?? {}) as Record; + + // UNItopia sends the listing under `entries`. We fall back to `files` + // so other MUDs that follow the IRE convention also work. + const rawEntries = Array.isArray(d['entries']) + ? (d['entries'] as unknown[]) + : Array.isArray(d['files']) + ? (d['files'] as unknown[]) + : []; + + return { + type: 'Files.Dir', + path: String(d['path'] ?? '/'), + entries: rawEntries as FileEntry[], + }; + } + + private mapFilesOpen(data: unknown): MudSignal { + const d = (data ?? {}) as Record; + + // UNItopia delivers the editable URL in `url`; the rest of the codebase + // refers to it as `lasturl`. Map it explicitly here so downstream + // consumers don't have to know the wire-level name. + const fileinfo: import('./mud-signals').FileInfo = { + lasturl: String(d['url'] ?? d['lasturl'] ?? ''), + file: String(d['file'] ?? ''), + path: String(d['path'] ?? ''), + filename: String(d['filename'] ?? ''), + filetype: String(d['filetype'] ?? ''), + title: String(d['title'] ?? ''), + filesize: typeof d['filesize'] === 'number' ? (d['filesize'] as number) : -1, + newfile: Boolean(d['newfile']), + writeacl: Boolean(d['writeacl']), + temporary: Boolean(d['temporary']), + closable: Boolean(d['closable']), + content: typeof d['content'] === 'string' ? (d['content'] as string) : undefined, + }; + + return { type: 'Files.Open', fileinfo }; + } + + private mapInputComplete(data: unknown): MudSignal { + const d = data as Record; + const type = d?.['type']; + + if (type === 'text') { + return { + type: 'Input.CompleteText', + text: String(d?.['text'] ?? ''), + }; + } + + if (type === 'choice') { + return { + type: 'Input.CompleteChoice', + choices: (d?.['choices'] ?? []) as string[], + }; + } + + return { type: 'Input.CompleteNone' }; + } + + // --------------------------------------------------------------------------- + // Inventory helpers + // --------------------------------------------------------------------------- + + /** + * Char.Items.List may arrive in several shapes: + * - direct array: [item1, item2, ...] + * - wrapped in `items`: {items: [...]} + * - wrapped in `inventory`: {inventory: [...]} + * - wrapped per location: {inv: [...], eq: [...]} -> we take the first array + */ + private extractItemsArray(data: unknown): unknown[] { + if (Array.isArray(data)) { + return data; + } + + if (data && typeof data === 'object') { + const obj = data as Record; + + for (const key of ['items', 'inventory', 'list', 'inv']) { + const candidate = obj[key]; + if (Array.isArray(candidate)) { + return candidate; + } + } + + // Fallback: first array-typed property + for (const value of Object.values(obj)) { + if (Array.isArray(value)) { + return value; + } + } + } + + return []; + } + + /** + * Char.Items.Add and Char.Items.Remove may arrive as either the bare item + * or wrapped: {location: "inv", item: {...}}. Unwrap if needed. + */ + private extractSingleItem(data: unknown): unknown { + if (data && typeof data === 'object' && !Array.isArray(data)) { + const obj = data as Record; + + // If it looks like a direct item (has a name/desc/id field), keep it. + if ( + 'name' in obj || + 'desc' in obj || + 'short' in obj || + 'title' in obj || + 'id' in obj + ) { + return obj; + } + + // Otherwise look for a wrapper property. + for (const key of ['item', 'entry']) { + const candidate = obj[key]; + if (candidate && typeof candidate === 'object') { + return candidate; + } + } + } + + return data; + } + + /** + * Each inventory item may use different field names depending on the MUD. + * Common variants: {name, category}, {desc, type}, {id, name}, plain string. + */ + private normalizeInventoryEntry(item: unknown): InventoryEntry { + if (typeof item === 'string') { + return { name: item, category: 'Sonstiges' }; + } + + if (item && typeof item === 'object') { + const o = item as Record; + + const name = String( + o['name'] ?? o['desc'] ?? o['short'] ?? o['title'] ?? o['id'] ?? '', + ); + const category = String( + o['category'] ?? o['type'] ?? o['group'] ?? 'Sonstiges', + ); + + return { name, category }; + } + + return { name: String(item ?? ''), category: 'Sonstiges' }; + } +} diff --git a/frontend/src/app/features/gmcp/signals/mud-signals.ts b/frontend/src/app/features/gmcp/signals/mud-signals.ts new file mode 100644 index 00000000..7ada9caf --- /dev/null +++ b/frontend/src/app/features/gmcp/signals/mud-signals.ts @@ -0,0 +1,215 @@ +/** + * Typed signal definitions for GMCP-based events. + * + * Each signal type corresponds to a GMCP message or group of messages + * from the MUD server, normalized into a structured format that + * components can easily consume. + */ + +// --------------------------------------------------------------------------- +// Character Signals +// --------------------------------------------------------------------------- + +export type CharNameSignal = { + type: 'Char.Name'; + /** Character name, e.g. "Myonara" */ + name: string; + /** MUD name (server identifier), e.g. "UNItopia" */ + mudname?: string; + /** "name@mudname" if mudname is known, otherwise just name */ + fullName: string; + /** Whether the character is a wizard/immortal */ + wizard?: number; +}; + +export type CharStatusSignal = { + type: 'Char.Status'; + /** Raw status data from the MUD */ + data: unknown; +}; + +export type CharVitalsSignal = { + type: 'Char.Vitals'; + /** Pre-formatted vitals string from the MUD (if provided), e.g. "100/200 LP, 50/100 KP" */ + text?: string; + /** Raw vitals data from the MUD (full payload for further processing) */ + data: unknown; +}; + +export type CharStatsSignal = { + type: 'Char.Stats'; + /** Raw stats data from the MUD */ + data: unknown; +}; + +// --------------------------------------------------------------------------- +// Inventory Signals +// --------------------------------------------------------------------------- + +export type InventoryEntry = { + name: string; + category: string; +}; + +export type CharItemsListSignal = { + type: 'Char.Items.List'; + entries: InventoryEntry[]; +}; + +export type CharItemsAddSignal = { + type: 'Char.Items.Add'; + entry: InventoryEntry; +}; + +export type CharItemsRemoveSignal = { + type: 'Char.Items.Remove'; + entry: InventoryEntry; +}; + +// --------------------------------------------------------------------------- +// Sound Signals +// --------------------------------------------------------------------------- + +export type SoundPlaySignal = { + type: 'Sound.Play'; + url: string; +}; + +// --------------------------------------------------------------------------- +// File Signals +// --------------------------------------------------------------------------- + +export type FileEntry = { + name: string; + size: number; + filedate: string; + filetime: string; + isdir: number; +}; + +export type FileInfo = { + file: string; + path: string; + filename: string; + filetype: string; + editortype?: string; + newfile: boolean; + writeacl: boolean; + temporary: boolean; + closable: boolean; + filesize: number; + title: string; + content?: string; + /** HTTP URL provided by the MUD for GET (load) / PUT (save) of the file body. */ + lasturl: string; +}; + +export type FilesDirectorySignal = { + type: 'Files.Dir'; + path: string; + entries: FileEntry[]; +}; + +export type FilesOpenSignal = { + type: 'Files.Open'; + fileinfo: FileInfo; +}; + +// --------------------------------------------------------------------------- +// Input Completion Signals +// --------------------------------------------------------------------------- + +export type InputCompleteTextSignal = { + type: 'Input.CompleteText'; + text: string; +}; + +export type InputCompleteChoiceSignal = { + type: 'Input.CompleteChoice'; + choices: string[]; +}; + +export type InputCompleteNoneSignal = { + type: 'Input.CompleteNone'; +}; + +// --------------------------------------------------------------------------- +// Numpad Signals +// --------------------------------------------------------------------------- + +export type NumpadLevelSignal = { + type: 'Numpad.SendLevel'; + data: unknown; +}; + +// --------------------------------------------------------------------------- +// Room Signals +// --------------------------------------------------------------------------- + +export type RoomInfoSignal = { + type: 'Room.Info'; + data: unknown; +}; + +// --------------------------------------------------------------------------- +// Communication Signals +// --------------------------------------------------------------------------- + +export type CommSignal = { + type: 'Comm.Message'; + channel: string; + data: unknown; +}; + +// --------------------------------------------------------------------------- +// Core Signals +// --------------------------------------------------------------------------- + +export type CorePingSignal = { + type: 'Core.Ping'; +}; + +export type CoreGoodbyeSignal = { + type: 'Core.Goodbye'; +}; + +/** + * The MUD server announces itself via Core.Hello. + * UNItopia sends this with `name` (server identifier) and `version`. + */ +export type CoreHelloSignal = { + type: 'Core.Hello'; + /** Server-side MUD name, e.g. "UNItopia" */ + mudname?: string; + /** Server version string */ + version?: string; +}; + +// --------------------------------------------------------------------------- +// Union Type +// --------------------------------------------------------------------------- + +/** Discriminated union of all possible MUD signals */ +export type MudSignal = + | CharNameSignal + | CharStatusSignal + | CharVitalsSignal + | CharStatsSignal + | CharItemsListSignal + | CharItemsAddSignal + | CharItemsRemoveSignal + | SoundPlaySignal + | FilesDirectorySignal + | FilesOpenSignal + | InputCompleteTextSignal + | InputCompleteChoiceSignal + | InputCompleteNoneSignal + | NumpadLevelSignal + | RoomInfoSignal + | CommSignal + | CorePingSignal + | CoreGoodbyeSignal + | CoreHelloSignal; + +/** All possible signal type strings */ +export type MudSignalType = MudSignal['type']; diff --git a/frontend/src/app/features/inventory/inventory-window.service.ts b/frontend/src/app/features/inventory/inventory-window.service.ts new file mode 100644 index 00000000..74e1b0f8 --- /dev/null +++ b/frontend/src/app/features/inventory/inventory-window.service.ts @@ -0,0 +1,108 @@ +import { inject, Injectable } from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { FooterMenuService } from '@webmud3/frontend/features/footer/footer-menu.service'; +import { GmcpService } from '@webmud3/frontend/features/gmcp/gmcp.service'; +import { CharItemsGmcpModule } from '@webmud3/frontend/features/gmcp/modules/char-items-gmcp.module'; +import { WindowService } from '@webmud3/frontend/features/windows/window.service'; +import { InventoryService } from './inventory.service'; + +const MENU_ID = 'inventory-window'; + +/** + * Wires the inventory feature into the application: + * - registers a "Inventar" toggle in the footer menu + * - opens / closes the inventory window + * - keeps the menu's checked state in sync if the user closes the window + * via its X button + * - bootstraps CharItemsGmcpModule so the MUD starts sending Char.Items.* + */ +@Injectable({ providedIn: 'root' }) +export class InventoryWindowService { + private readonly windowService = inject(WindowService); + private readonly footerMenu = inject(FooterMenuService); + private readonly gmcp = inject(GmcpService); + // Bootstraps the GMCP "Char.Items" registration as a side-effect of inject. + private readonly _items = inject(CharItemsGmcpModule); + // Eager-instantiate the inventory state service so it captures Char.Items.* + // signals even before the window is opened for the first time. + private readonly _inventoryState = inject(InventoryService); + + private windowId: string | undefined; + private windowSubscription: Subscription | undefined; + + constructor() { + this.footerMenu.register({ + id: MENU_ID, + label: 'Inventar', + checked: false, + action: () => this.toggle(), + }); + } + + public toggle(): void { + if (this.windowId !== undefined) { + this.close(); + } else { + this.open(); + } + } + + public open(): void { + if (this.windowId !== undefined) { + this.windowService.focus(this.windowId); + this.requestRefresh(); + return; + } + + this.windowId = this.windowService.newWindow({ + title: 'Inventar', + component: 'inventory', + posX: 80, + posY: 80, + width: 320, + height: 380, + }); + + this.footerMenu.setChecked(MENU_ID, true); + this.requestRefresh(); + + // Detect when the window is closed externally (X button, closeAll, ...) + // by observing the windows list and reacting when our id disappears. + this.windowSubscription = this.windowService.windows$.subscribe( + (windows) => { + if ( + this.windowId !== undefined && + !windows.some((w) => w.windowId === this.windowId) + ) { + this.windowId = undefined; + this.windowSubscription?.unsubscribe(); + this.windowSubscription = undefined; + this.footerMenu.setChecked(MENU_ID, false); + } + }, + ); + } + + /** + * Asks the MUD to resend the full inventory list. UNItopia answers + * `Char.Items.Inv` with a `Char.Items.List` message. + * Silently no-ops if GMCP is not active yet. + */ + public requestRefresh(): void { + this.gmcp.send('Char.Items.Inv', {}); + } + + public close(): void { + if (this.windowId === undefined) { + return; + } + + const id = this.windowId; + this.windowId = undefined; + this.windowSubscription?.unsubscribe(); + this.windowSubscription = undefined; + this.footerMenu.setChecked(MENU_ID, false); + this.windowService.close(id); + } +} diff --git a/frontend/src/app/features/inventory/inventory.component.html b/frontend/src/app/features/inventory/inventory.component.html new file mode 100644 index 00000000..02c39d43 --- /dev/null +++ b/frontend/src/app/features/inventory/inventory.component.html @@ -0,0 +1,21 @@ + + +
+

{{ category.key }}

+
    +
  • {{ item }}
  • +
+
+
+ + +
Inventar ist leer.
+
+
+ + +
Lade Inventar…
+
diff --git a/frontend/src/app/features/inventory/inventory.component.scss b/frontend/src/app/features/inventory/inventory.component.scss new file mode 100644 index 00000000..5a34ad38 --- /dev/null +++ b/frontend/src/app/features/inventory/inventory.component.scss @@ -0,0 +1,40 @@ +:host { + display: block; + font-family: sans-serif; + font-size: 13px; + color: #ddd; +} + +.inv-category { + margin: 0 0 12px; +} + +.inv-category-name { + margin: 0 0 4px; + padding: 2px 6px; + font-size: 12px; + font-weight: bold; + color: #4ec9b0; + background: #2a2d2e; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.inv-items { + list-style: none; + margin: 0; + padding: 0 0 0 8px; + + li { + padding: 2px 0; + line-height: 1.4; + } +} + +.inv-empty { + padding: 12px; + color: #777; + font-style: italic; + text-align: center; +} diff --git a/frontend/src/app/features/inventory/inventory.component.ts b/frontend/src/app/features/inventory/inventory.component.ts new file mode 100644 index 00000000..0d4f308f --- /dev/null +++ b/frontend/src/app/features/inventory/inventory.component.ts @@ -0,0 +1,30 @@ +import { CommonModule, KeyValuePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, +} from '@angular/core'; + +import { InventoryService } from './inventory.service'; + +/** + * Displays the player's inventory grouped by category. + * Reactive: subscribes to InventoryService.items$. + */ +@Component({ + selector: 'app-inventory', + templateUrl: './inventory.component.html', + styleUrls: ['./inventory.component.scss'], + standalone: true, + imports: [CommonModule, KeyValuePipe], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InventoryComponent { + private readonly inventory = inject(InventoryService); + + public readonly items$ = this.inventory.items$; + + public hasAnyItems(items: Record): boolean { + return Object.keys(items).length > 0; + } +} diff --git a/frontend/src/app/features/inventory/inventory.service.ts b/frontend/src/app/features/inventory/inventory.service.ts new file mode 100644 index 00000000..e3b9ec29 --- /dev/null +++ b/frontend/src/app/features/inventory/inventory.service.ts @@ -0,0 +1,129 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { BehaviorSubject, Subscription } from 'rxjs'; + +import { MudSignalService } from '@webmud3/frontend/features/gmcp/signals/mud-signal.service'; +import type { InventoryEntry } from '@webmud3/frontend/features/gmcp/signals/mud-signals'; + +/** + * Inventory grouped by category, e.g.: + * { "Waffen": ["Schwert", "Dolch"], "Trank": ["Heiltrank"] } + */ +export type InventoryByCategory = Record; + +/** + * Holds the player's inventory state, derived from Char.Items.* GMCP signals. + * + * The MUD sends: + * - Char.Items.List — full list (typically once after enabling Char.Items) + * - Char.Items.Add — single item added + * - Char.Items.Remove — single item removed + */ +@Injectable({ providedIn: 'root' }) +export class InventoryService implements OnDestroy { + private readonly signals = inject(MudSignalService); + + private readonly itemsSubject = new BehaviorSubject({}); + private readonly subscriptions: Subscription[] = []; + + /** Reactive view of the inventory grouped by category */ + public readonly items$ = this.itemsSubject.asObservable(); + + constructor() { + this.subscriptions.push( + this.signals.on('Char.Items.List').subscribe((s) => { + console.info( + `[Inventory] Char.Items.List received with ${s.entries?.length ?? 0} entries`, + s.entries?.slice(0, 3), + ); + this.replaceAll(s.entries); + }), + this.signals.on('Char.Items.Add').subscribe((s) => { + console.info('[Inventory] Char.Items.Add', s.entry); + this.addItem(s.entry); + }), + this.signals.on('Char.Items.Remove').subscribe((s) => { + console.info('[Inventory] Char.Items.Remove', s.entry); + this.removeItem(s.entry); + }), + ); + } + + ngOnDestroy(): void { + for (const sub of this.subscriptions) { + sub.unsubscribe(); + } + } + + /** Synchronous snapshot of the current inventory */ + public get items(): InventoryByCategory { + return this.itemsSubject.value; + } + + public clear(): void { + this.itemsSubject.next({}); + } + + // --------------------------------------------------------------------------- + // Internal mutations + // --------------------------------------------------------------------------- + + private replaceAll(entries: InventoryEntry[]): void { + const next: InventoryByCategory = {}; + + for (const entry of entries ?? []) { + this.appendInto(next, entry); + } + + this.itemsSubject.next(next); + } + + private addItem(entry: InventoryEntry): void { + const next: InventoryByCategory = { ...this.itemsSubject.value }; + + this.appendInto(next, entry); + + this.itemsSubject.next(next); + } + + private removeItem(entry: InventoryEntry): void { + const current = this.itemsSubject.value; + const list = current[entry.category]; + + if (!list) { + return; + } + + const idx = list.indexOf(entry.name); + if (idx < 0) { + return; + } + + const nextList = [...list.slice(0, idx), ...list.slice(idx + 1)]; + const next: InventoryByCategory = { ...current }; + + if (nextList.length === 0) { + delete next[entry.category]; + } else { + next[entry.category] = nextList; + } + + this.itemsSubject.next(next); + } + + private appendInto( + target: InventoryByCategory, + entry: InventoryEntry, + ): void { + if (!entry?.category || !entry?.name) { + return; + } + + const existing = target[entry.category]; + + if (existing) { + target[entry.category] = [...existing, entry.name]; + } else { + target[entry.category] = [entry.name]; + } + } +} diff --git a/frontend/src/app/features/numpad/numpad-config.component.html b/frontend/src/app/features/numpad/numpad-config.component.html new file mode 100644 index 00000000..e5c2a46c --- /dev/null +++ b/frontend/src/app/features/numpad/numpad-config.component.html @@ -0,0 +1,43 @@ +
+
+ + + + +
+ +
+ +
+ +
+

Befehl für {{ key }}:

+ +
+ + +
+
+
diff --git a/frontend/src/app/features/numpad/numpad-config.component.scss b/frontend/src/app/features/numpad/numpad-config.component.scss new file mode 100644 index 00000000..1fc27912 --- /dev/null +++ b/frontend/src/app/features/numpad/numpad-config.component.scss @@ -0,0 +1,152 @@ +:host { + display: block; + font-family: sans-serif; + color: #ddd; + font-size: 12px; + position: relative; + height: 100%; +} + +.numpad-host { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; +} + +.numpad-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + flex: 1 1 auto; +} + +.numpad-key { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #2d2d30; + border: 1px solid #555; + border-radius: 4px; + color: #ddd; + cursor: pointer; + padding: 6px 4px; + min-height: 50px; + + &:hover { + background: #3e3e42; + border-color: #6e6e6e; + } + + &:active { + background: #1e1e1e; + } + + &.span-2 { + grid-column: span 2; + } +} + +.numpad-label { + font-size: 14px; + font-weight: bold; + color: #4ec9b0; +} + +.numpad-cmd { + font-size: 10px; + color: #aaa; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + font-family: monospace; +} + +.numpad-edit { + position: absolute; + top: 2px; + right: 4px; + font-size: 10px; + color: #888; + cursor: pointer; + padding: 0 2px; + + &:hover { + color: #ddd; + } +} + +.numpad-gap { + display: block; +} + +.numpad-actions { + display: flex; + justify-content: flex-end; + + button { + background: transparent; + border: 1px solid #555; + color: #ddd; + padding: 4px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + + &:hover { + background: #3e3e42; + } + } +} + +.numpad-edit-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + flex-direction: column; + justify-content: center; + align-items: stretch; + gap: 8px; + padding: 16px; + z-index: 10; + + p { + margin: 0; + font-size: 13px; + } + + input { + background: #1e1e1e; + border: 1px solid #555; + border-radius: 3px; + color: #ddd; + padding: 6px 8px; + font-family: monospace; + font-size: 13px; + } + + .numpad-edit-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + + button { + background: transparent; + border: 1px solid #555; + color: #ddd; + padding: 4px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + + &:hover { + background: #3e3e42; + } + } + } +} diff --git a/frontend/src/app/features/numpad/numpad-config.component.ts b/frontend/src/app/features/numpad/numpad-config.component.ts new file mode 100644 index 00000000..0fef2ccd --- /dev/null +++ b/frontend/src/app/features/numpad/numpad-config.component.ts @@ -0,0 +1,105 @@ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core'; + +import { + NumpadKey, + NumpadService, + NumpadBindings, +} from './numpad.service'; + +type Cell = { + /** Logical key id (or null for empty grid cell) */ + key: NumpadKey | null; + /** Label shown on the key (e.g. "8", "+") */ + label: string; + /** Optional column-span hint (for the wide "0" key) */ + span?: number; +}; + +/** + * 4-column grid mirroring a hardware numpad. `null` cells render empty + * to preserve visual alignment. + */ +const LAYOUT: Cell[] = [ + // Row 1: / * _ _ + { key: 'NumpadDivide', label: '/' }, + { key: 'NumpadMultiply', label: '×' }, + { key: null, label: '' }, + { key: null, label: '' }, + // Row 2: 7 8 9 + + { key: 'Numpad7', label: '7' }, + { key: 'Numpad8', label: '8' }, + { key: 'Numpad9', label: '9' }, + { key: 'NumpadAdd', label: '+' }, + // Row 3: 4 5 6 − + { key: 'Numpad4', label: '4' }, + { key: 'Numpad5', label: '5' }, + { key: 'Numpad6', label: '6' }, + { key: 'NumpadSubtract', label: '−' }, + // Row 4: 1 2 3 _ + { key: 'Numpad1', label: '1' }, + { key: 'Numpad2', label: '2' }, + { key: 'Numpad3', label: '3' }, + { key: null, label: '' }, + // Row 5: 0(span 2) , Enter + { key: 'Numpad0', label: '0', span: 2 }, + { key: 'NumpadDecimal', label: ',' }, + { key: 'NumpadEnter', label: '⏎' }, +]; + +@Component({ + selector: 'app-numpad-config', + templateUrl: './numpad-config.component.html', + styleUrls: ['./numpad-config.component.scss'], + standalone: true, + imports: [CommonModule, FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NumpadConfigComponent { + private readonly numpad = inject(NumpadService); + + public readonly bindings$ = this.numpad.bindings$; + public readonly layout = LAYOUT; + public readonly editingKey = signal(null); + public readonly editingValue = signal(''); + + public commandFor(bindings: NumpadBindings, key: NumpadKey | null): string { + if (!key) return ''; + return bindings[key] ?? ''; + } + + public onKeyClick(key: NumpadKey | null): void { + if (!key || this.editingKey() !== null) return; + this.numpad.trigger(key); + } + + public startEdit(key: NumpadKey | null, current: string, event: Event): void { + event.stopPropagation(); + if (!key) return; + this.editingKey.set(key); + this.editingValue.set(current); + } + + public saveEdit(): void { + const key = this.editingKey(); + if (key === null) return; + this.numpad.setBinding(key, this.editingValue()); + this.editingKey.set(null); + this.editingValue.set(''); + } + + public cancelEdit(): void { + this.editingKey.set(null); + this.editingValue.set(''); + } + + public onResetClick(): void { + this.numpad.resetToDefaults(); + } +} diff --git a/frontend/src/app/features/numpad/numpad-window.service.ts b/frontend/src/app/features/numpad/numpad-window.service.ts new file mode 100644 index 00000000..10f31756 --- /dev/null +++ b/frontend/src/app/features/numpad/numpad-window.service.ts @@ -0,0 +1,86 @@ +import { inject, Injectable } from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { FooterMenuService } from '@webmud3/frontend/features/footer/footer-menu.service'; +import { WindowService } from '@webmud3/frontend/features/windows/window.service'; +import { NumpadService } from './numpad.service'; + +const MENU_ID = 'numpad-config-window'; + +/** + * Footer-menu integration for the numpad config window. + * Eager-instantiates NumpadService so bindings are loaded from localStorage + * at app start. + */ +@Injectable({ providedIn: 'root' }) +export class NumpadWindowService { + private readonly windowService = inject(WindowService); + private readonly footerMenu = inject(FooterMenuService); + // Eager-instantiate the numpad state service. + private readonly _numpad = inject(NumpadService); + + private windowId: string | undefined; + private windowSubscription: Subscription | undefined; + + constructor() { + this.footerMenu.register({ + id: MENU_ID, + label: 'Numpad-Konfiguration', + checked: false, + action: () => this.toggle(), + }); + } + + public toggle(): void { + if (this.windowId !== undefined) { + this.close(); + } else { + this.open(); + } + } + + public open(): void { + if (this.windowId !== undefined) { + this.windowService.focus(this.windowId); + return; + } + + this.windowId = this.windowService.newWindow({ + title: 'Numpad-Konfiguration', + component: 'numpad-config', + posX: 120, + posY: 120, + width: 280, + height: 360, + }); + + this.footerMenu.setChecked(MENU_ID, true); + + this.windowSubscription = this.windowService.windows$.subscribe( + (windows) => { + if ( + this.windowId !== undefined && + !windows.some((w) => w.windowId === this.windowId) + ) { + this.windowId = undefined; + this.windowSubscription?.unsubscribe(); + this.windowSubscription = undefined; + this.footerMenu.setChecked(MENU_ID, false); + } + }, + ); + } + + public close(): void { + if (this.windowId === undefined) { + return; + } + + const id = this.windowId; + this.windowId = undefined; + this.windowSubscription?.unsubscribe(); + this.windowSubscription = undefined; + this.footerMenu.setChecked(MENU_ID, false); + this.windowService.close(id); + } +} diff --git a/frontend/src/app/features/numpad/numpad.service.ts b/frontend/src/app/features/numpad/numpad.service.ts new file mode 100644 index 00000000..3f64b048 --- /dev/null +++ b/frontend/src/app/features/numpad/numpad.service.ts @@ -0,0 +1,123 @@ +import { inject, Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +import { MudService } from '@webmud3/frontend/core/mud/services/mud.service'; + +/** Logical key ids matching KeyboardEvent.code on the numpad */ +export const NUMPAD_KEYS = [ + 'NumpadDivide', + 'NumpadMultiply', + 'NumpadSubtract', + 'Numpad7', + 'Numpad8', + 'Numpad9', + 'NumpadAdd', + 'Numpad4', + 'Numpad5', + 'Numpad6', + 'Numpad1', + 'Numpad2', + 'Numpad3', + 'Numpad0', + 'NumpadDecimal', + 'NumpadEnter', +] as const; + +export type NumpadKey = (typeof NUMPAD_KEYS)[number]; + +/** Map of NumpadKey -> command string sent to the MUD when triggered */ +export type NumpadBindings = Partial>; + +const STORAGE_KEY = 'webmud3-numpad-bindings'; + +const DEFAULT_BINDINGS: NumpadBindings = { + Numpad8: 'norden', + Numpad2: 'sueden', + Numpad4: 'westen', + Numpad6: 'osten', + Numpad7: 'nordwesten', + Numpad9: 'nordosten', + Numpad1: 'suedwesten', + Numpad3: 'suedosten', + Numpad5: 'schau', + NumpadAdd: 'hoch', + NumpadSubtract: 'runter', +}; + +/** + * Holds the user's numpad key bindings (key -> mud command). + * + * State is persisted in localStorage so bindings survive page reloads. + * Triggering a key sends the bound command to the MUD via MudService. + */ +@Injectable({ providedIn: 'root' }) +export class NumpadService { + private readonly mudService = inject(MudService); + + private readonly bindingsSubject = new BehaviorSubject( + this.loadBindings(), + ); + + public readonly bindings$ = this.bindingsSubject.asObservable(); + + public get bindings(): NumpadBindings { + return this.bindingsSubject.value; + } + + /** Sets a single binding and persists. Empty value removes the binding. */ + public setBinding(key: NumpadKey, command: string): void { + const next: NumpadBindings = { ...this.bindingsSubject.value }; + + if (command.trim().length === 0) { + delete next[key]; + } else { + next[key] = command; + } + + this.bindingsSubject.next(next); + this.persist(next); + } + + /** Resets all bindings to the built-in defaults. */ + public resetToDefaults(): void { + this.bindingsSubject.next({ ...DEFAULT_BINDINGS }); + this.persist({ ...DEFAULT_BINDINGS }); + } + + /** Sends the command bound to `key` to the MUD. No-op if unbound. */ + public trigger(key: NumpadKey): void { + const command = this.bindingsSubject.value[key]; + + if (command !== undefined && command.length > 0) { + this.mudService.sendMessage(command); + } + } + + // --------------------------------------------------------------------------- + // Persistence + // --------------------------------------------------------------------------- + + private loadBindings(): NumpadBindings { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as NumpadBindings; + if (parsed && typeof parsed === 'object') { + return parsed; + } + } + } catch (error) { + console.warn('[Numpad] Failed to load bindings from localStorage', error); + } + + return { ...DEFAULT_BINDINGS }; + } + + private persist(bindings: NumpadBindings): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings)); + } catch (error) { + console.warn('[Numpad] Failed to save bindings to localStorage', error); + } + } +} diff --git a/frontend/src/app/features/sockets/sockets.service.ts b/frontend/src/app/features/sockets/sockets.service.ts index ebc567e5..e259871b 100644 --- a/frontend/src/app/features/sockets/sockets.service.ts +++ b/frontend/src/app/features/sockets/sockets.service.ts @@ -31,12 +31,22 @@ export class SocketsService { private sessionToken: string; // Forces a fresh session token after reconnect failed to avoid reusing a dead backend session. private forceNewSession = false; + // The first serverHello received after page load. Subsequent serverHello + // events with a different id mean the backend was restarted; we then + // reload the page to pick up any new code and drop stale state. + private knownServerId: string | undefined; public onMudConnect = new EventEmitter(); // Emits isNewConnection public onMudDisconnect = new EventEmitter(); public onMudOutput = new EventEmitter(); public onSetEchoMode = new EventEmitter(); public onSetLinemode = new EventEmitter(); + public onGmcpActive = new EventEmitter(); + public onGmcpIncoming = new EventEmitter<{ + packageName: string; + messageName: string; + data: unknown; + }>(); public readonly connectedToServer$ = this.connectedToServer.asObservable(); public readonly connectedToMud$ = this.connectedToMud.asObservable(); @@ -142,6 +152,32 @@ export class SocketsService { this.socket.on('requestTimingMark', (callback: () => void) => { this.handleTimingMark(callback); }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.socket as unknown as any).on( + 'mudGmcpActive', + (active: boolean) => { + this.handleGmcpActive(active); + }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.socket as unknown as any).on('serverShutdown', () => { + this.handleServerShutdown(); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.socket as unknown as any).on('serverHello', (serverId: string) => { + this.handleServerHello(serverId); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.socket as unknown as any).on( + 'mudGmcpIncoming', + (packageName: string, messageName: string, data: unknown) => { + this.handleGmcpIncoming(packageName, messageName, data); + }, + ); } public connectToMud(initialViewPort: { @@ -192,9 +228,11 @@ export class SocketsService { this.socket.emit('mudViewportSize', columns, rows); } - public sendGmcp(/*id: string, mod: string, msg: string, data: any*/): boolean { - console.log(`[Sockets] Sockets-Service: 'sendGmcp'`); - throw new Error('Method not implemented.'); + public sendGmcp(module: string, data: unknown): void { + console.log(`[Sockets] Sockets-Service: 'sendGmcp'`, { module, data }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.socket as unknown as any).emit('mudGmcpOutgoing', module, data); } private handleMudConnect = ( @@ -337,6 +375,71 @@ export class SocketsService { callback(); }; + private handleServerShutdown = () => { + console.warn( + '[Sockets] Sockets-Service: Server announced shutdown. Waiting for new backend to come up.', + ); + + // We deliberately keep the auto-reconnect loop running. When the backend + // is back, the next connect will produce a new serverHello with a + // different serverId, which triggers the page reload in handleServerHello. + }; + + private handleServerHello = (serverId: string) => { + if (this.knownServerId === undefined) { + this.knownServerId = serverId; + console.info( + `[Sockets] Sockets-Service: Server hello, serverId=${serverId}`, + ); + return; + } + + if (serverId === this.knownServerId) { + // Reconnect to the same backend instance — nothing to do. + return; + } + + console.warn( + `[Sockets] Sockets-Service: Backend restart detected (old=${this.knownServerId}, new=${serverId}). Clearing local state and reloading.`, + ); + + // The previous session-token and output-history belong to a backend that + // no longer exists. Clear both so the freshly loaded page starts clean + // and can establish a new session against the new backend. + this.outputHistoryService.clearAll(); + + try { + localStorage.removeItem('webmud3-session-token'); + } catch (error) { + console.error( + '[Sockets] Failed to clear session token from localStorage:', + error, + ); + } + + window.location.reload(); + }; + + private handleGmcpActive = (active: boolean) => { + console.info('[Sockets] Sockets-Service: GMCP active:', active); + + this.onGmcpActive.emit(active); + }; + + private handleGmcpIncoming = ( + packageName: string, + messageName: string, + data: unknown, + ) => { + console.debug('[Sockets] Sockets-Service: GMCP incoming:', { + packageName, + messageName, + data, + }); + + this.onGmcpIncoming.emit({ packageName, messageName, data }); + }; + /** * Flushes the input queue by sending all queued messages to the server */ diff --git a/frontend/src/app/features/terminal/models/escapes.ts b/frontend/src/app/features/terminal/models/escapes.ts index 575741c6..f7aab94c 100644 --- a/frontend/src/app/features/terminal/models/escapes.ts +++ b/frontend/src/app/features/terminal/models/escapes.ts @@ -23,6 +23,7 @@ export const CSI_CMD = { cursorLeft: (columns = 1) => `${CSI}${Math.max(columns, 1)}D`, cursorRight: (columns = 1) => `${CSI}${Math.max(columns, 1)}C`, eraseLineAll: () => `${CSI}2K`, + eraseToEol: () => `${CSI}K`, } as const; /** Regex that captures generic CSI sequences (ESC [ parameters final). */ @@ -35,6 +36,7 @@ export const SS3_LEN = 3; export const carriageReturn = CTRL.CR; export const backspace = CTRL.BS; export const eraseLine = CSI_CMD.eraseLineAll(); +export const eraseToEol = CSI_CMD.eraseToEol(); /** CR followed by CSI 2K — clear the active line and move to column 0. */ export const resetLine = `${carriageReturn}${eraseLine}`; diff --git a/frontend/src/app/features/terminal/mud-input.controller.spec.ts b/frontend/src/app/features/terminal/mud-input.controller.spec.ts index 424ebe05..191a5135 100644 --- a/frontend/src/app/features/terminal/mud-input.controller.spec.ts +++ b/frontend/src/app/features/terminal/mud-input.controller.spec.ts @@ -100,4 +100,185 @@ describe('MudInputController', () => { expect(controller.getSnapshot()).toEqual({ buffer: 'ab', cursor: 1 }); }); + + describe('command history', () => { + const ESC = ''; + const ARROW_UP = `${ESC}[A`; + const ARROW_DOWN = `${ESC}[B`; + const ALT_ARROW_UP = `${ESC}[1;3A`; + const ALT_ARROW_DOWN = `${ESC}[1;3B`; + + // Alt prefix variant (xterm meta-sends-ESC default): ESC ESC [A/B. + const META_ARROW_UP = ESC + ESC + '[A'; + const META_ARROW_DOWN = ESC + ESC + '[B'; + + it('arrow up recalls the most recent commit', () => { + const { controller } = makeController(); + + controller.handleData('look' + CTRL.CR); + controller.handleData(ARROW_UP); + + expect(controller.getSnapshot()).toEqual({ buffer: 'look', cursor: 4 }); + }); + + it('arrow up walks further back through history', () => { + const { controller } = makeController(); + + controller.handleData('north' + CTRL.CR); + controller.handleData('south' + CTRL.CR); + controller.handleData(ARROW_UP); + + expect(controller.getSnapshot()).toEqual({ buffer: 'south', cursor: 5 }); + + controller.handleData(ARROW_UP); + + expect(controller.getSnapshot()).toEqual({ buffer: 'north', cursor: 5 }); + }); + + it('arrow down past the newest entry restores the typed anchor', () => { + const { controller } = makeController(); + + controller.handleData('north' + CTRL.CR); + controller.handleData('typi'); // partially typed before browsing + controller.handleData(ARROW_UP); // go to "north" + + expect(controller.getSnapshot()).toEqual({ buffer: 'north', cursor: 5 }); + + controller.handleData(ARROW_DOWN); // back to anchor + + expect(controller.getSnapshot()).toEqual({ buffer: 'typi', cursor: 4 }); + }); + + it('deduplicates consecutive identical commits', () => { + const { controller } = makeController(); + + controller.handleData('schau' + CTRL.CR); + controller.handleData('schau' + CTRL.CR); + controller.handleData(ARROW_UP); + controller.handleData(ARROW_UP); // would walk further if duplicate stored + + expect(controller.getSnapshot()).toEqual({ buffer: 'schau', cursor: 5 }); + }); + + it('does not store empty commits', () => { + const { controller } = makeController(); + + controller.handleData(CTRL.CR); // empty commit + controller.handleData('hi' + CTRL.CR); + controller.handleData(ARROW_UP); + controller.handleData(ARROW_UP); // empty would land here if stored + + expect(controller.getSnapshot()).toEqual({ buffer: 'hi', cursor: 2 }); + }); + + it('alt+up filters history by current buffer prefix', () => { + const { controller } = makeController(); + + controller.handleData('look' + CTRL.CR); + controller.handleData('north' + CTRL.CR); + controller.handleData('look at me' + CTRL.CR); + controller.handleData('lo'); // prefix + controller.handleData(ALT_ARROW_UP); + + expect(controller.getSnapshot()).toEqual({ + buffer: 'look at me', + cursor: 10, + }); + + controller.handleData(ALT_ARROW_UP); + + // 'north' is skipped because it does not start with 'lo'. + expect(controller.getSnapshot()).toEqual({ buffer: 'look', cursor: 4 }); + }); + + it('alt+down restores anchor when no further prefix match exists', () => { + const { controller } = makeController(); + + controller.handleData('look' + CTRL.CR); + controller.handleData('lo'); + controller.handleData(ALT_ARROW_UP); // -> 'look' + controller.handleData(ALT_ARROW_DOWN); // no newer match -> anchor + + expect(controller.getSnapshot()).toEqual({ buffer: 'lo', cursor: 2 }); + }); + + it('typing exits browse mode but keeps the recalled buffer', () => { + const { controller } = makeController(); + + controller.handleData('look' + CTRL.CR); + controller.handleData(ARROW_UP); // buffer = 'look' + controller.handleData('!'); // append, exits browse + + expect(controller.getSnapshot()).toEqual({ buffer: 'look!', cursor: 5 }); + + // After exit, Down should be a no-op (browse already exited). + controller.handleData(ARROW_DOWN); + + expect(controller.getSnapshot()).toEqual({ buffer: 'look!', cursor: 5 }); + }); + + it('arrow up with empty history is a no-op', () => { + const { controller } = makeController(); + + controller.handleData(ARROW_UP); + + expect(controller.getSnapshot()).toEqual({ buffer: '', cursor: 0 }); + }); + + it('does not record commits while local echo is disabled (passwords)', () => { + const { controller } = makeController(); + + controller.setLocalEcho(false); + controller.handleData('hunter2' + CTRL.CR); + + controller.setLocalEcho(true); + controller.handleData(ARROW_UP); + + // History should not contain the password-mode commit. + expect(controller.getSnapshot()).toEqual({ buffer: '', cursor: 0 }); + }); + + it('treats the meta-sends-ESC variant the same as alt+up', () => { + const { controller } = makeController(); + + controller.handleData('look' + CTRL.CR); + controller.handleData('north' + CTRL.CR); + controller.handleData('look at me' + CTRL.CR); + controller.handleData('lo'); // prefix + controller.handleData(META_ARROW_UP); + + expect(controller.getSnapshot()).toEqual({ + buffer: 'look at me', + cursor: 10, + }); + + controller.handleData(META_ARROW_UP); + + expect(controller.getSnapshot()).toEqual({ buffer: 'look', cursor: 4 }); + }); + + it('meta-down restores anchor when no further prefix match exists', () => { + const { controller } = makeController(); + + controller.handleData('look' + CTRL.CR); + controller.handleData('lo'); + controller.handleData(META_ARROW_UP); // -> 'look' + controller.handleData(META_ARROW_DOWN); // anchor + + expect(controller.getSnapshot()).toEqual({ buffer: 'lo', cursor: 2 }); + }); + + it('still records commits made before echo was disabled', () => { + const { controller } = makeController(); + + controller.handleData('look' + CTRL.CR); // echoed -> stored + controller.setLocalEcho(false); + controller.handleData('secret' + CTRL.CR); // not stored + + controller.setLocalEcho(true); + controller.handleData(ARROW_UP); + + expect(controller.getSnapshot()).toEqual({ buffer: 'look', cursor: 4 }); + }); + }); }); diff --git a/frontend/src/app/features/terminal/mud-input.controller.ts b/frontend/src/app/features/terminal/mud-input.controller.ts index 8148b173..98ddbad9 100644 --- a/frontend/src/app/features/terminal/mud-input.controller.ts +++ b/frontend/src/app/features/terminal/mud-input.controller.ts @@ -8,9 +8,16 @@ import { backspaceErase, cursorLeft, cursorRight, + eraseToEol, sequence, } from './models/escapes'; +/** Modifier code embedded in CSI sequences for Alt (xterm convention). */ +const MODIFIER_ALT = 3; + +/** Maximum number of remembered command history entries. */ +const MAX_HISTORY = 200; + /** * Callback signature used whenever a buffered line is ready to be sent to the server. */ @@ -39,6 +46,14 @@ export class MudInputController { // Holds a partially received escape sequence to be completed by the next chunk. private pendingEscape = ''; + // Command history: chronological, newest entries appended. + private readonly history: string[] = []; + // Position within history while browsing; -1 means "not currently browsing". + private historyIndex = -1; + // Buffer the user had typed before they entered history-browse mode; restored + // when they walk past the most recent entry with Down again. + private historyAnchor = ''; + /** * @param terminal Reference to the xterm instance we mirror the editing state to. * @param onCommit Callback that receives a flushed line (with echo information). @@ -132,6 +147,34 @@ export class MudInputController { return { buffer: this.buffer, cursor: this.cursor }; } + /** + * Walks one step back through the command history. + * When `withPrefix` is true, only entries that start with the prefix the + * user originally typed (the anchor) are considered. + * + * Intended for direct invocation from a KeyboardEvent handler when the + * browser/OS swallows the matching escape sequence before xterm receives + * it on `onData`. + */ + public historyBack(withPrefix = false): void { + this.historyUp(this.resolvePrefix(withPrefix)); + } + + /** + * Walks one step forward through the command history. See `historyBack`. + */ + public historyForward(withPrefix = false): void { + this.historyDown(this.resolvePrefix(withPrefix)); + } + + private resolvePrefix(withPrefix: boolean): string | null { + if (!withPrefix) { + return null; + } + + return this.historyIndex === -1 ? this.buffer : this.historyAnchor; + } + /** * Flushes the buffer and resets the controller. When nothing has been typed * the call is a no-op and `null` is returned. @@ -155,10 +198,18 @@ export class MudInputController { /** * Commits the current buffer to the consumer and resets editing state. Local * echo is honoured by writing CRLF before the callback is fired. + * + * Only echoed input lands in the command history. Lines entered while the + * server has disabled local echo (passwords, login tokens, ...) are + * intentionally never recorded. */ private commitBuffer(): void { const message = this.buffer; + if (this.localEchoEnabled) { + this.pushHistory(message); + } + this.reset(); if (this.localEchoEnabled) { @@ -168,6 +219,29 @@ export class MudInputController { this.onCommit({ message, echoed: this.localEchoEnabled }); } + /** + * Appends `message` to the command history, deduplicating against the + * previous entry and capping at MAX_HISTORY. Empty messages are ignored. + */ + private pushHistory(message: string): void { + if (message === '') { + return; + } + + if ( + this.history.length > 0 && + this.history[this.history.length - 1] === message + ) { + return; + } + + this.history.push(message); + + if (this.history.length > MAX_HISTORY) { + this.history.shift(); + } + } + /** * Inserts a printable character at the current cursor position and, when echo * is enabled, rewrites the tail of the line and moves the cursor back. @@ -179,6 +253,8 @@ export class MudInputController { return; } + this.exitHistoryBrowse(); + const before = this.buffer.slice(0, this.cursor); const after = this.buffer.slice(this.cursor); @@ -209,6 +285,8 @@ export class MudInputController { return; } + this.exitHistoryBrowse(); + const before = this.buffer.slice(0, this.cursor - 1); const after = this.buffer.slice(this.cursor); @@ -282,6 +360,47 @@ export class MudInputController { * @returns number of characters consumed from the segment. */ private handleEscapeSequence(segment: string): number { + // xterm encodes Alt+key in two ways depending on options: + // 1) modifier-in-CSI: ESC [ 1;3 A (handled in the regular CSI path) + // 2) meta-sends-ESC: ESC ESC [ A (the leading ESC is the Alt prefix) + // Detect the second form here and dispatch to the history with a stable + // prefix (anchor while browsing, current buffer otherwise). + if (segment.length >= 2 && segment[1] === CTRL.ESC) { + if (segment.length < 3) { + return 0; // need more bytes to know what follows + } + + if (segment[2] !== '[') { + // Alt + something we don't care about: drop just the leading ESC and + // let the next iteration handle the rest. + return CTRL.ESC.length; + } + + const inner = segment.slice(CTRL.ESC.length); + const innerMatch = inner.match(CSI_REGEX); + + if (!innerMatch) { + return 0; // incomplete CSI after the Alt prefix + } + + const innerToken = innerMatch[0]; + const finalChar = innerToken[innerToken.length - 1]; + const consumed = CTRL.ESC.length + innerToken.length; + + if (finalChar === 'A' || finalChar === 'B') { + const prefix = + this.historyIndex === -1 ? this.buffer : this.historyAnchor; + + if (finalChar === 'A') { + this.historyUp(prefix); + } else { + this.historyDown(prefix); + } + } + + return consumed; + } + if (segment.startsWith(SS3)) { if (segment.length < SS3_LEN) { return 0; // incomplete SS3 @@ -290,6 +409,12 @@ export class MudInputController { const control = segment[2]; switch (control) { + case 'A': + this.historyUp(null); + break; + case 'B': + this.historyDown(null); + break; case 'C': this.moveCursorRight(1); break; @@ -324,10 +449,34 @@ export class MudInputController { const token = match[0]; const finalChar = token[token.length - 1]; const params = token.slice(2, -1); - const amount = - params.length === 0 ? 1 : Number.parseInt(params.split(';')[0], 10) || 1; + const parts = params.length === 0 ? [] : params.split(';'); + const amount = parts.length === 0 ? 1 : Number.parseInt(parts[0], 10) || 1; + const modifier = parts.length > 1 ? Number.parseInt(parts[1], 10) || 1 : 1; switch (finalChar) { + case 'A': { + // Alt+Up filters by the originally typed prefix. The first press uses + // the current buffer (which then becomes the anchor); subsequent + // presses keep using the anchor so the prefix is stable while browsing. + const prefix = + modifier === MODIFIER_ALT + ? this.historyIndex === -1 + ? this.buffer + : this.historyAnchor + : null; + this.historyUp(prefix); + break; + } + case 'B': { + const prefix = + modifier === MODIFIER_ALT + ? this.historyIndex === -1 + ? this.buffer + : this.historyAnchor + : null; + this.historyDown(prefix); + break; + } case 'C': this.moveCursorRight(amount); break; @@ -373,6 +522,8 @@ export class MudInputController { return; } + this.exitHistoryBrowse(); + const before = this.buffer.slice(0, this.cursor); const after = this.buffer.slice(this.cursor + 1); @@ -402,4 +553,99 @@ export class MudInputController { private moveCursorToEnd(): void { this.moveCursorRight(this.buffer.length - this.cursor); } + + // --------------------------------------------------------------------------- + // Command history + // --------------------------------------------------------------------------- + + /** + * Walks one step back in the command history. When `prefix` is non-null, + * only entries that start with the prefix are considered. The first call + * also stashes the current buffer in `historyAnchor` so a later Down past + * the newest match can restore it. + */ + private historyUp(prefix: string | null): void { + if (this.history.length === 0) { + return; + } + + if (this.historyIndex === -1) { + this.historyAnchor = this.buffer; + this.historyIndex = this.history.length; + } + + for (let i = this.historyIndex - 1; i >= 0; i -= 1) { + const entry = this.history[i]; + + if (prefix === null || entry.startsWith(prefix)) { + this.historyIndex = i; + this.replaceBufferTextually(entry); + return; + } + } + // No match found; stay where we are so the next Down resumes correctly. + } + + /** + * Walks one step forward in the command history. Stepping past the newest + * matching entry restores the originally typed buffer (the anchor) and + * leaves browse mode. + */ + private historyDown(prefix: string | null): void { + if (this.historyIndex === -1) { + return; + } + + for (let i = this.historyIndex + 1; i < this.history.length; i += 1) { + const entry = this.history[i]; + + if (prefix === null || entry.startsWith(prefix)) { + this.historyIndex = i; + this.replaceBufferTextually(entry); + return; + } + } + + // No more matches: restore the anchor and exit browse mode. + const anchor = this.historyAnchor; + this.historyIndex = -1; + this.historyAnchor = ''; + this.replaceBufferTextually(anchor); + } + + /** + * Marks the user as no longer browsing history without touching the buffer. + * Called whenever the user actively edits (insert / backspace / delete) so + * subsequent edits behave normally. + */ + private exitHistoryBrowse(): void { + if (this.historyIndex === -1) { + return; + } + + this.historyIndex = -1; + this.historyAnchor = ''; + } + + /** + * Replaces the buffer with `newText` and mirrors the change to the terminal: + * cursor is moved back to the buffer's start, the rest of the line is erased + * (which leaves any prompt to the left untouched), then the new text is + * written. Cursor lands at the end of the new buffer. + */ + private replaceBufferTextually(newText: string): void { + if (this.localEchoEnabled) { + if (this.cursor > 0) { + this.terminal.write(cursorLeft(this.cursor)); + } + + this.terminal.write(eraseToEol); + this.terminal.write(newText); + } + + this.buffer = newText; + this.cursor = newText.length; + + this.onInputChange?.({ buffer: this.buffer }); + } } diff --git a/frontend/src/app/features/terminal/mud-screenreader.ts b/frontend/src/app/features/terminal/mud-screenreader.ts index 4fdb8529..0906061d 100644 --- a/frontend/src/app/features/terminal/mud-screenreader.ts +++ b/frontend/src/app/features/terminal/mud-screenreader.ts @@ -19,10 +19,18 @@ export class MudScreenReaderAnnouncer { private readonly liveRegion: HTMLElement, private readonly historyRegion?: HTMLElement, private readonly inputRegion?: HTMLElement, + private readonly isLoggingEnabled: () => boolean = () => false, ) { this.sessionStartedAt = Date.now(); } + /** Internal: emits debug logs only when the runtime flag is enabled. */ + private log(...args: unknown[]): void { + if (this.isLoggingEnabled()) { + console.debug(...args); + } + } + /** * Marks the current connection session start and clears any pending output. */ @@ -38,7 +46,7 @@ export class MudScreenReaderAnnouncer { */ public announce(raw: string, receivedAt: number = Date.now()): void { if (receivedAt < this.sessionStartedAt) { - console.debug( + this.log( '[ScreenReader] Ignoring old output (before session start):', { receivedAt, @@ -51,13 +59,13 @@ export class MudScreenReaderAnnouncer { const normalized = this.normalize(raw); - console.debug('[ScreenReader] Announcing:', { + this.log('[ScreenReader] Announcing:', { raw: raw.substring(0, 100), normalized: normalized.substring(0, 100), }); if (!normalized) { - console.debug('[ScreenReader] Skipped empty normalized output'); + this.log('[ScreenReader] Skipped empty normalized output'); return; } @@ -149,7 +157,7 @@ export class MudScreenReaderAnnouncer { if (currentLength > lastLength) { const newestChar = buffer[currentLength - 1]; - console.debug('[ScreenReader] Input changed:', { + this.log('[ScreenReader] Input changed:', { newestChar, lastLength, currentLength, @@ -160,7 +168,7 @@ export class MudScreenReaderAnnouncer { const lastWord = this.extractLastWord(buffer); const normalizedWord = lastWord ? this.normalizeInput(lastWord) : ''; - console.debug('[ScreenReader] Word boundary detected:', { + this.log('[ScreenReader] Word boundary detected:', { lastWord, normalizedWord, }); @@ -171,7 +179,7 @@ export class MudScreenReaderAnnouncer { } } else if (currentLength < lastLength) { // Backspace/delete: silently track, textarea is read by SR automatically - console.debug('[ScreenReader] Buffer shortened (backspace/delete):', { + this.log('[ScreenReader] Buffer shortened (backspace/delete):', { lastLength, currentLength, }); @@ -251,7 +259,7 @@ export class MudScreenReaderAnnouncer { const normalized = this.normalize(buffer); - console.debug('[ScreenReader] Announcing committed input:', { + this.log('[ScreenReader] Announcing committed input:', { raw: buffer.substring(0, 100), normalized: normalized.substring(0, 100), }); diff --git a/frontend/src/app/features/windows/window-component-registry.ts b/frontend/src/app/features/windows/window-component-registry.ts new file mode 100644 index 00000000..ed851197 --- /dev/null +++ b/frontend/src/app/features/windows/window-component-registry.ts @@ -0,0 +1,20 @@ +import type { Type } from '@angular/core'; + +import { DirlistComponent } from '@webmud3/frontend/features/editor/dirlist.component'; +import { EditorComponent } from '@webmud3/frontend/features/editor/editor.component'; +import { InventoryComponent } from '@webmud3/frontend/features/inventory/inventory.component'; +import { NumpadConfigComponent } from '@webmud3/frontend/features/numpad/numpad-config.component'; + +/** + * Maps WindowConfig.component (a string id) to a concrete Angular component. + * + * The Window-Container looks up the component for each open window and renders + * it via ngComponentOutlet. Add new entries here when you introduce new + * window-hosted features (editor, char-stats, ...). + */ +export const WINDOW_COMPONENTS: Record> = { + inventory: InventoryComponent, + 'numpad-config': NumpadConfigComponent, + editor: EditorComponent, + dirlist: DirlistComponent, +}; diff --git a/frontend/src/app/features/windows/window-config.ts b/frontend/src/app/features/windows/window-config.ts new file mode 100644 index 00000000..15aa892c --- /dev/null +++ b/frontend/src/app/features/windows/window-config.ts @@ -0,0 +1,82 @@ +import { Subject } from 'rxjs'; + +/** + * Event sent from the window component to the WindowService. + * Format: action followed by colon-separated parameters. + * Examples: "do_focus", "do_hide", "FileOpen:/path/to/file" + */ +export type WindowEvent = string; + +/** + * Configuration for a single modeless window. + * + * Each window has a unique id, a title, position/size, a z-index, + * and bidirectional event channels to communicate with the WindowService + * and its hosting component. + */ +export type WindowConfig = { + /** Unique window id (assigned by WindowService) */ + windowId: string; + + /** Optional parent window id (used for closing children together) */ + parentWindowId?: string; + + /** Window title shown in the title bar */ + title: string; + + /** Tooltip / extended description */ + tooltip?: string; + + /** Component selector or kind (e.g. 'editor', 'inventory', 'char-stat') */ + component: string; + + /** Whether the window is currently visible */ + visible: boolean; + + /** Current z-index (managed by WindowService.focus()) */ + zIndex: number; + + /** Position X in pixels */ + posX: number; + + /** Position Y in pixels */ + posY: number; + + /** Width in pixels (0 = auto) */ + width: number; + + /** Height in pixels (0 = auto) */ + height: number; + + /** Whether the window can be saved (e.g. editor with unsaved changes) */ + saveable: boolean; + + /** Whether the cancel button should be hidden */ + noCancel: boolean; + + /** Arbitrary payload for the hosted component */ + data?: unknown; + + /** + * Events sent FROM the window component TO the WindowService. + * The WindowService subscribes to this in newWindow(). + */ + outgoing: Subject; + + /** + * Events sent FROM the WindowService TO the window component. + * The component subscribes to this in ngOnInit(). + */ + incoming: Subject; +}; + +/** + * Subset of WindowConfig that the caller provides when creating a new window. + * The WindowService fills in the rest (windowId, zIndex, outgoing, incoming). + */ +export type WindowConfigInput = Partial< + Omit +> & { + title: string; + component: string; +}; diff --git a/frontend/src/app/features/windows/window-container.component.html b/frontend/src/app/features/windows/window-container.component.html new file mode 100644 index 00000000..bdb24e32 --- /dev/null +++ b/frontend/src/app/features/windows/window-container.component.html @@ -0,0 +1,17 @@ + + + + +
+

Component: {{ cfg.component }}

+
{{ toJson(cfg.data) }}
+
+
+
diff --git a/frontend/src/app/features/windows/window-container.component.scss b/frontend/src/app/features/windows/window-container.component.scss new file mode 100644 index 00000000..9b4b69fa --- /dev/null +++ b/frontend/src/app/features/windows/window-container.component.scss @@ -0,0 +1,13 @@ +.placeholder { + color: #aaa; + font-family: monospace; + font-size: 12px; + + pre { + background: #111; + padding: 8px; + border-radius: 3px; + overflow: auto; + margin: 4px 0 0; + } +} diff --git a/frontend/src/app/features/windows/window-container.component.ts b/frontend/src/app/features/windows/window-container.component.ts new file mode 100644 index 00000000..858be30b --- /dev/null +++ b/frontend/src/app/features/windows/window-container.component.ts @@ -0,0 +1,55 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + inject, + Type, +} from '@angular/core'; + +import { WindowComponent } from './window.component'; +import { WINDOW_COMPONENTS } from './window-component-registry'; +import type { WindowConfig } from './window-config'; +import { WindowService } from './window.service'; + +/** + * Renders all currently open windows from `WindowService.windows$`. + * + * For each window the matching component is looked up in WINDOW_COMPONENTS + * (keyed by `cfg.component`) and rendered via ngComponentOutlet. Unknown + * component ids fall back to a JSON-dump placeholder for debugging. + * + * Mount this component once at the application root. + */ +@Component({ + selector: 'app-window-container', + templateUrl: './window-container.component.html', + styleUrls: ['./window-container.component.scss'], + standalone: true, + imports: [CommonModule, WindowComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WindowContainerComponent { + private readonly windowService = inject(WindowService); + + public readonly windows$ = this.windowService.windows$; + + public trackByWindowId(_index: number, cfg: WindowConfig): string { + return cfg.windowId; + } + + public componentFor(cfg: WindowConfig): Type | null { + return WINDOW_COMPONENTS[cfg.component] ?? null; + } + + public toJson(value: unknown): string { + if (value === undefined) { + return '(no data)'; + } + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } +} diff --git a/frontend/src/app/features/windows/window.component.html b/frontend/src/app/features/windows/window.component.html new file mode 100644 index 00000000..e36b89d7 --- /dev/null +++ b/frontend/src/app/features/windows/window.component.html @@ -0,0 +1,20 @@ +
+ {{ config.title }} + +
+
+ +
diff --git a/frontend/src/app/features/windows/window.component.scss b/frontend/src/app/features/windows/window.component.scss new file mode 100644 index 00000000..028e1ac7 --- /dev/null +++ b/frontend/src/app/features/windows/window.component.scss @@ -0,0 +1,57 @@ +:host { + position: fixed; + display: flex; + flex-direction: column; + min-width: 200px; + min-height: 120px; + background: #1e1e1e; + color: #ddd; + border: 1px solid #444; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + resize: both; + overflow: hidden; + font-family: sans-serif; + font-size: 13px; +} + +.title-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + background: #2d2d30; + border-bottom: 1px solid #444; + cursor: move; + user-select: none; + flex: 0 0 auto; +} + +.title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 auto; +} + +.close-btn { + background: transparent; + border: none; + color: #ddd; + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 6px; + border-radius: 3px; + + &:hover { + background: #c4302b; + color: #fff; + } +} + +.content { + flex: 1 1 auto; + overflow: auto; + padding: 8px; +} diff --git a/frontend/src/app/features/windows/window.component.ts b/frontend/src/app/features/windows/window.component.ts new file mode 100644 index 00000000..66a94ecd --- /dev/null +++ b/frontend/src/app/features/windows/window.component.ts @@ -0,0 +1,108 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostBinding, + HostListener, + Input, + ViewChild, +} from '@angular/core'; + +import type { WindowConfig } from './window-config'; + +/** + * A modeless window shell. + * Provides title bar (drag handle), close button, and content area. + * Resize is done via native CSS `resize: both`. + * + * Communicates with the WindowService via `config.outgoing` events: + * - `do_focus` — clicked anywhere + * - `do_close` — close button + * - `move:x:y` — after drag + */ +@Component({ + selector: 'app-window', + templateUrl: './window.component.html', + styleUrls: ['./window.component.scss'], + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WindowComponent { + @Input({ required: true }) config!: WindowConfig; + + @ViewChild('titleBar', { static: true }) titleBar!: ElementRef; + + private dragOffsetX = 0; + private dragOffsetY = 0; + private dragging = false; + + @HostBinding('style.left.px') get left(): number { + return this.config.posX; + } + + @HostBinding('style.top.px') get top(): number { + return this.config.posY; + } + + @HostBinding('style.width.px') get width(): number | null { + return this.config.width > 0 ? this.config.width : null; + } + + @HostBinding('style.height.px') get height(): number | null { + return this.config.height > 0 ? this.config.height : null; + } + + @HostBinding('style.z-index') get zIndex(): number { + return this.config.zIndex; + } + + @HostBinding('style.display') get display(): string { + return this.config.visible ? 'flex' : 'none'; + } + + @HostListener('pointerdown') + onHostClick(): void { + this.config.outgoing.next('do_focus'); + } + + onClose(event: Event): void { + event.stopPropagation(); + this.config.outgoing.next('do_close'); + } + + onTitleBarPointerDown(event: PointerEvent): void { + if (event.button !== 0) { + return; + } + + // Don't start a drag when clicking interactive elements like the close button. + const target = event.target as HTMLElement; + if (target.closest('button')) { + return; + } + + this.dragging = true; + this.dragOffsetX = event.clientX - this.config.posX; + this.dragOffsetY = event.clientY - this.config.posY; + this.titleBar.nativeElement.setPointerCapture(event.pointerId); + } + + onTitleBarPointerMove(event: PointerEvent): void { + if (!this.dragging) { + return; + } + + this.config.posX = event.clientX - this.dragOffsetX; + this.config.posY = event.clientY - this.dragOffsetY; + } + + onTitleBarPointerUp(event: PointerEvent): void { + if (!this.dragging) { + return; + } + + this.dragging = false; + this.titleBar.nativeElement.releasePointerCapture(event.pointerId); + this.config.outgoing.next(`move:${this.config.posX}:${this.config.posY}`); + } +} diff --git a/frontend/src/app/features/windows/window.service.ts b/frontend/src/app/features/windows/window.service.ts new file mode 100644 index 00000000..5a1d9230 --- /dev/null +++ b/frontend/src/app/features/windows/window.service.ts @@ -0,0 +1,202 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; + +import type { + WindowConfig, + WindowConfigInput, + WindowEvent, +} from './window-config'; + +const Z_INDEX_BASE = 100; + +/** + * Service for managing modeless windows. + * + * Responsibilities: + * - Create/close windows with unique ids + * - Z-index management (focus brings window to front) + * - Parent-child relationships (closing a parent closes its children) + * - Reactive window list via Observable + * + * Window components subscribe to `windows$` to render the current list. + */ +@Injectable({ providedIn: 'root' }) +export class WindowService { + private readonly windowsSubject = new BehaviorSubject([]); + private lastZIndex = Z_INDEX_BASE; + + /** Reactive list of all open windows, ordered by creation */ + public readonly windows$: Observable = + this.windowsSubject.asObservable(); + + /** + * Returns the current snapshot of all open windows. + */ + public get windows(): WindowConfig[] { + return this.windowsSubject.value; + } + + /** + * Creates and opens a new window. + * @returns The window id (also accessible via `config.windowId`) + */ + public newWindow(input: WindowConfigInput): string { + const windowId = this.generateWindowId(); + + this.lastZIndex += 1; + + const config: WindowConfig = { + windowId, + parentWindowId: input.parentWindowId, + title: input.title, + tooltip: input.tooltip, + component: input.component, + visible: input.visible ?? true, + zIndex: this.lastZIndex, + posX: input.posX ?? 0, + posY: input.posY ?? 0, + width: input.width ?? 0, + height: input.height ?? 0, + saveable: input.saveable ?? false, + noCancel: input.noCancel ?? false, + data: input.data, + outgoing: new Subject(), + incoming: new Subject(), + }; + + config.outgoing.subscribe((event) => this.handleOutgoingEvent(config, event)); + + this.windowsSubject.next([...this.windowsSubject.value, config]); + + return windowId; + } + + /** + * Returns the WindowConfig for the given id, or undefined if not found. + */ + public getWindow(windowId: string): WindowConfig | undefined { + return this.windowsSubject.value.find((w) => w.windowId === windowId); + } + + /** + * Brings the given window to the front by assigning it the next z-index. + */ + public focus(windowId: string): void { + const config = this.getWindow(windowId); + + if (config === undefined) { + return; + } + + this.lastZIndex += 1; + config.zIndex = this.lastZIndex; + + this.windowsSubject.next([...this.windowsSubject.value]); + } + + /** + * Closes the window with the given id and any of its child windows. + * Completes the window's event subjects to free subscribers. + * @returns Number of windows actually closed (parent + children) + */ + public close(windowId: string): number { + const toClose = new Set([windowId]); + + for (const w of this.windowsSubject.value) { + if (w.parentWindowId === windowId) { + toClose.add(w.windowId); + } + } + + const remaining: WindowConfig[] = []; + + for (const w of this.windowsSubject.value) { + if (toClose.has(w.windowId)) { + w.outgoing.complete(); + w.incoming.complete(); + } else { + remaining.push(w); + } + } + + this.windowsSubject.next(remaining); + + if (remaining.length === 0) { + this.lastZIndex = Z_INDEX_BASE; + } + + return toClose.size; + } + + /** + * Closes all windows and resets the z-index counter. + */ + public closeAll(): void { + for (const w of this.windowsSubject.value) { + w.outgoing.complete(); + w.incoming.complete(); + } + + this.windowsSubject.next([]); + this.lastZIndex = Z_INDEX_BASE; + } + + /** + * Sends an event to the window's hosting component via its `incoming` channel. + */ + public sendToWindow(windowId: string, event: WindowEvent): void { + const config = this.getWindow(windowId); + + config?.incoming.next(event); + } + + /** + * Updates the visibility of a window without closing it. + */ + public setVisible(windowId: string, visible: boolean): void { + const config = this.getWindow(windowId); + + if (config === undefined) { + return; + } + + config.visible = visible; + this.windowsSubject.next([...this.windowsSubject.value]); + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + /** + * Routes events sent by the window component (e.g. focus, hide, close requests). + * Event format: "action" or "action:param1:param2" + */ + private handleOutgoingEvent(config: WindowConfig, event: WindowEvent): void { + const [action] = event.split(':'); + + switch (action) { + case 'do_focus': + this.focus(config.windowId); + break; + case 'do_hide': + this.setVisible(config.windowId, false); + break; + case 'do_close': + this.close(config.windowId); + break; + default: + // Other events are passed through for application-level handling + // (the caller can subscribe directly to config.outgoing) + break; + } + } + + private generateWindowId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID(); + } + + return `win-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c7eb6956..7222c348 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -15,6 +15,7 @@ "lib": ["es2018", "dom"], "useDefineForClassFields": false, "strict": true, + "skipLibCheck": true, "resolveJsonModule": true, "paths": { "@webmud3/frontend/*": ["src/app/*"] diff --git a/package-lock.json b/package-lock.json index e5c533f5..4ad109ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,7 @@ "shared" ], "devDependencies": { - "cpy-cli": "~6.0.0", - "typescript": "~5.9.3" + "cpy-cli": "~6.0.0" }, "engines": { "node": "~22.20.0" @@ -103,11 +102,13 @@ "@angular/platform-browser": "~20.3.4", "@angular/platform-browser-dynamic": "~20.3.4", "@angular/router": "~20.3.4", + "@monaco-editor/loader": "^1.7.0", "@webmud3/shared": "1.0.0-alpha", "@xterm/addon-attach": "~0.11.0", "@xterm/addon-clipboard": "~0.2.0", "@xterm/addon-fit": "~0.10.0", "@xterm/xterm": "~5.5.0", + "monaco-editor": "^0.55.1", "normalize.css": "~8.0.1", "rxjs": "~7.8.2", "socket.io-client": "~4.8.1", @@ -5630,6 +5631,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -7762,6 +7772,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/uuid": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", @@ -12341,6 +12358,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -19047,6 +19073,18 @@ "tmpl": "1.0.5" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -19409,6 +19447,16 @@ "node": ">= 18" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -22858,6 +22906,12 @@ "node": ">=8" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/shared/src/sockets/client-to-server-events.ts b/shared/src/sockets/client-to-server-events.ts index 653ede67..573a8097 100644 --- a/shared/src/sockets/client-to-server-events.ts +++ b/shared/src/sockets/client-to-server-events.ts @@ -23,4 +23,10 @@ export interface ClientToServerEvents { * Updates the server with the client's current terminal dimensions. */ mudViewportSize: (columns: number, rows: number) => void; + /** + * Sends a GMCP message to the MUD server. + * @param module - The GMCP module string, e.g. "Core.Hello" + * @param data - The data payload (JSON-serializable) + */ + mudGmcpOutgoing: (module: string, data: unknown) => void; } diff --git a/shared/src/sockets/server-to-client-events.ts b/shared/src/sockets/server-to-client-events.ts index 3d978752..a6cf8395 100644 --- a/shared/src/sockets/server-to-client-events.ts +++ b/shared/src/sockets/server-to-client-events.ts @@ -34,4 +34,32 @@ export interface ServerToClientEvents { * Sends the current linemode negotiation state to the client. */ setLinemode: (state: LinemodeState) => void; + /** + * Signals that GMCP has been activated or deactivated. + */ + mudGmcpActive: (active: boolean) => void; + /** + * Forwards a parsed GMCP message from the MUD server to the client. + * @param packageName - The GMCP package, e.g. "Char" + * @param messageName - The GMCP message, e.g. "Name" + * @param data - The parsed JSON payload + */ + mudGmcpIncoming: ( + packageName: string, + messageName: string, + data: unknown, + ) => void; + /** + * Broadcast emitted by the backend right before it shuts down. + * Clients should disconnect cleanly and reload after the server is back. + */ + serverShutdown: () => void; + /** + * Sent immediately after a client connects. Carries the server's unique + * runtime id (assigned at process start). Clients compare this against + * the previously seen id; a mismatch indicates the backend was restarted + * and the client should reload to pick up potential code changes and + * drop any stale state. + */ + serverHello: (serverId: string) => void; }