Skip to content

Commit

Permalink
feat: game service and socket
Browse files Browse the repository at this point in the history
  • Loading branch information
KatoakDR committed Dec 27, 2023
1 parent 4c0c3a0 commit 5e0f7df
Show file tree
Hide file tree
Showing 2 changed files with 83 additions and 179 deletions.
241 changes: 71 additions & 170 deletions electron/main/game/game.service.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<boolean> {
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<rxjs.Observable<GameEvent>> {
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<GameEvent>();
const socketStream = await this.socket.connect();

socketStream.subscribe({
next: (data) => {
writeStream.write(data);
// TODO parse data into game event(s)
const gameEvents = new Array<any>() as Array<GameEvent>;
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<void> {
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<void> {
// 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<void> {
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('<mode id="GAME"/>')) {
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}`);
}
}
}

Expand All @@ -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<GameService> => {
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;
},

Expand Down
21 changes: 12 additions & 9 deletions electron/main/game/game.types.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
connect(): Promise<rxjs.Observable<GameEvent>>;

/**
* Disconnect from the game server.
* Does nothing if already disconnected.
* Always returns true.
*/
disconnect(): Promise<void>;

/**
* Send a command to the game server.
* Throws error if not connected.
* https://elanthipedia.play.net/Category:Commands
*/
send(command: string): void;
Expand All @@ -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<Observable<string>>;
connect(): Promise<rxjs.Observable<string>>;

/**
* Disconnect from the game server.
Expand Down

0 comments on commit 5e0f7df

Please sign in to comment.