diff --git a/electron/main/game/game.service.ts b/electron/main/game/game.service.ts index 38ef8701..678ebd5d 100644 --- a/electron/main/game/game.service.ts +++ b/electron/main/game/game.service.ts @@ -1,26 +1,15 @@ -import * as net from 'node:net'; -import { merge } from 'lodash'; -import { runInBackground, sleep } from '../../common/async'; +import fs from 'fs-extra'; +import * as rxjs from 'rxjs'; +import { waitUntil } from '../../common/async'; import type { Maybe } from '../../common/types'; import { createLogger } from '../logger'; import type { SGEGameCredentials } from '../sge'; -import type { Dispatcher } from '../types'; -import type { GameService } from './game.types'; +import { GameSocketImpl } from './game.socket'; +import type { GameEvent, GameService, GameSocket } from './game.types'; const logger = createLogger('game'); class GameServiceImpl implements GameService { - /** - * Psuedo-observable pattern. - * The process that instantiates this class can subscribe to events. - */ - private dispatch: Dispatcher; - - /** - * Credentials used to connect to the game server. - */ - private credentials: SGEGameCredentials; - /** * Indicates if the protocol to authenticate to the game server has completed. * There is a brief delay after sending credentials before the game server @@ -32,167 +21,83 @@ class GameServiceImpl implements GameService { /** * Socket to communicate with the game server. */ - private socket?: net.Socket; + private socket: GameSocket; - constructor(options: { - credentials: SGEGameCredentials; - dispatch: Dispatcher; - }) { - const { credentials, dispatch } = options; - this.dispatch = dispatch; - this.credentials = credentials; + constructor(options: { credentials: SGEGameCredentials }) { + const { credentials } = options; + this.socket = new GameSocketImpl({ + credentials, + onConnect: () => { + this.isConnected = true; + this.isDestroyed = false; + }, + onDisconnect: () => { + this.isConnected = false; + this.isDestroyed = true; + }, + }); } - public async connect(): Promise { - logger.info('connecting'); - if (this.socket) { - // Due to async nature of socket event handling and that we need - // to manage instance variable state, we cannot allow the socket - // to be recreated because it causes inconsistent and invalid state. - logger.warn('instance may only connect once, ignoring request'); - return false; + public async connect(): Promise> { + if (this.isConnected) { + await this.disconnect(); } - const { host, port } = this.credentials; - this.socket = this.createGameSocket({ host, port }); - await this.waitUntilConnectedOrDestroyed(); - return this.isConnected; + + logger.info('connecting'); + + const writeStream = fs.createWriteStream('game.log'); // TODO remove + const gameStream = new rxjs.Subject(); + const socketStream = await this.socket.connect(); + + socketStream.subscribe({ + next: (data) => { + writeStream.write(data); + // TODO parse data into game event(s) + const gameEvents = new Array() as Array; + gameEvents.forEach((gameEvent) => { + gameStream.next(gameEvent); + }); + }, + error: (error) => { + logger.error('game socket stream error', { error }); + writeStream.end(); + gameStream.error(error); + }, + complete: () => { + logger.info('game socket stream completed'); + writeStream.end(); + gameStream.complete(); + }, + }); + + return {} as any; } public async disconnect(): Promise { - logger.info('disconnecting'); - if (!this.socket) { - logger.warn('instance never connected, ignoring request'); - return; - } - if (this.isDestroyed) { - logger.warn('instance already disconnected, ignoring request'); - return; + if (!this.isDestroyed) { + logger.info('disconnecting'); + await this.socket.disconnect(); + await this.waitUntilDestroyed(); } - this.send('quit'); // log character out of game - this.socket.destroySoon(); // flush writes then end socket connection - this.isConnected = false; - this.isDestroyed = true; } public send(command: string): void { - if (!this.socket?.writable) { - throw new Error( - `[GAME:SOCKET:STATUS:INVALID] cannot send commands: ${command}` - ); - } - if (this.isConnected) { - logger.debug('sending command', { command }); - this.socket.write(`${command}\n`); - } + this.socket.send(command); } - protected async waitUntilConnectedOrDestroyed(): Promise { - // TODO add timeout - while (!this.isConnected && !this.isDestroyed) { - await sleep(200); - } - } - - protected createGameSocket(connectOptions?: net.NetConnectOpts): net.Socket { - const defaultOptions: net.NetConnectOpts = { - host: 'dr.simutronics.net', - port: 11024, - }; - - const mergedOptions = merge(defaultOptions, connectOptions); - - const { host, port } = mergedOptions; - - this.isConnected = false; - this.isDestroyed = false; - - const onGameConnect = (): void => { - if (!this.isConnected) { - this.isConnected = true; - this.isDestroyed = false; - this.dispatch('TODO-channel-name', 'connect'); - } - }; - - const onGameDisconnect = (): void => { - if (!this.isDestroyed) { - this.isConnected = false; - this.isDestroyed = true; - socket.destroySoon(); - this.dispatch('TODO-channel-name', 'disconnect'); - } - }; - - logger.info('connecting to game server', { host, port }); - const socket = net.connect(mergedOptions, (): void => { - logger.info('connected to game server', { host, port }); - }); + protected async waitUntilDestroyed(): Promise { + const interval = 200; + const timeout = 5000; - let buffer: string = ''; - socket.on('data', (data: Buffer): void => { - // TODO parse game data - // TODO eventually emit formatted messages via this.dispatch - // TODO explore if should use rxjs with socket - - logger.debug('socket received fragment'); - buffer += data.toString('utf8'); - if (buffer.endsWith('\n')) { - const message = buffer; - logger.debug('socket received message', { message }); - if (!this.isConnected && message.startsWith('')) { - onGameConnect(); - } - // TODO this is when I would emit a payload via rxjs - this.dispatch('TODO-channel-name', message); - buffer = ''; - } + const result = await waitUntil({ + condition: () => this.isDestroyed, + interval, + timeout, }); - socket.on('connect', () => { - logger.info('authenticating with game key'); - - // The frontend used to be named "StormFront" or "Storm" but around 2023 - // it was renamed to "Wrayth". The version is something I found common - // on GitHub among other clients. I did not notice a theme for the platform - // of the code I reviewed. I assume the last flag is to request XML formatted feed. - const frontendHeader = `FE:WRAYTH /VERSION:1.0.1.26 /P:${process.platform.toUpperCase()} /XML`; - - socket.write(`${this.credentials.key}\n`); - socket.write(`${frontendHeader}\n`); - - // Once authenticated, send newlines to get to the game prompt. - // Otherwise the game may not begin streaming data to us. - // There needs to be a delay to allow the server to negotiate the connect. - setTimeout(() => { - // Handle if socket is closed before this timeout. - if (socket.writable) { - socket.write(`\n\n`); - } - }, 1000); - }); - - socket.on('end', (): void => { - logger.info('connection to game server ended', { host, port }); - onGameDisconnect(); - }); - - socket.on('close', (): void => { - logger.info('connection to game server closed', { host, port }); - onGameDisconnect(); - }); - - socket.on('timeout', (): void => { - const timeout = socket.timeout; - logger.error('game server inactivity timeout', { host, port, timeout }); - onGameDisconnect(); - }); - - socket.on('error', (error: Error): void => { - logger.error('game server error', { host, port, error }); - onGameDisconnect(); - }); - - return socket; + if (!result) { + throw new Error(`[GAME:SERVICE:DISCONNECT:TIMEOUT] ${timeout}`); + } } } @@ -210,20 +115,16 @@ const Game = { * * Use the `getInstance` method to get a refence to the current game instance. */ - newInstance: (options: { + newInstance: async (options: { credentials: SGEGameCredentials; - dispatch: Dispatcher; - }): GameService => { - const { credentials, dispatch } = options; + }): Promise => { + const { credentials } = options; if (gameInstance) { logger.info('disconnecting from existing game instance'); - const oldInstance = gameInstance; - runInBackground(async () => { - await oldInstance.disconnect(); - }); + await gameInstance.disconnect(); } logger.info('creating new game instance'); - gameInstance = new GameServiceImpl({ credentials, dispatch }); + gameInstance = new GameServiceImpl({ credentials }); return gameInstance; }, diff --git a/electron/main/game/game.types.ts b/electron/main/game/game.types.ts index ba3ab55f..63530502 100644 --- a/electron/main/game/game.types.ts +++ b/electron/main/game/game.types.ts @@ -1,23 +1,26 @@ -import type { Observable } from 'rxjs'; +import type * as rxjs from 'rxjs'; + +export interface GameEvent { + eventType: GameEventType; +} export interface GameService { /** * Connect to the game server. - * Does nothing and returns false if has already connected once. - * Does not support 'connect => disconnect => connect' flow. - * To reconnect, you must create a new game service instance. + * Returns an observable that emits game events parsed from raw output. + * Upon disconnect, the observable will complete and no longer emit values. */ - connect(): Promise; + connect(): Promise>; /** * Disconnect from the game server. * Does nothing if already disconnected. - * Always returns true. */ disconnect(): Promise; /** * Send a command to the game server. + * Throws error if not connected. * https://elanthipedia.play.net/Category:Commands */ send(command: string): void; @@ -29,12 +32,12 @@ export interface GameSocket { * Returns an observable that emits game server output. * Upon disconnect, the observable will complete and no longer emit values. * - * This is a raw data stream that may contain multiple XML tags. - * Each emitted value contains one or more fully formed XML tags. + * This is a raw data stream that may contain multiple XML tags and text. + * Each emitted value may contain one or more fully formed XML tags and text. * For example, detailing the character's inventory, health, room, etc. * It is the caller's responsibility to parse and make sense of the data. */ - connect(): Promise>; + connect(): Promise>; /** * Disconnect from the game server.