Skip to content

Commit f48088a

Browse files
committed
feat: game service
1 parent 6e593a0 commit f48088a

File tree

4 files changed

+269
-13
lines changed

4 files changed

+269
-13
lines changed

electron/main/game/game.service.ts

+222
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import net from 'node:net';
2+
import { merge } from 'lodash';
3+
import { runInBackground, sleep } from '../../common/async';
4+
import type { Maybe } from '../../common/types';
5+
import { createLogger } from '../logger';
6+
import type { SGEGameCredentials } from '../sge';
7+
import type { Dispatcher } from '../types';
8+
import type { GameService } from './game.types';
9+
10+
const logger = createLogger('game');
11+
12+
class GameServiceImpl implements GameService {
13+
/**
14+
* Psuedo-observable pattern.
15+
* The process that instantiates this class can subscribe to events.
16+
*/
17+
private dispatch: Dispatcher;
18+
19+
/**
20+
* Credentials used to connect to the game server.
21+
*/
22+
private credentials: SGEGameCredentials;
23+
24+
/**
25+
* Indicates if the protocol to authenticate to the game server has completed.
26+
* There is a brief delay after sending credentials before the game server
27+
* is ready to receive commands. Sending commands too early will fail.
28+
*/
29+
private isConnected = false;
30+
private isDestroyed = false;
31+
32+
/**
33+
* Socket to communicate with the game server.
34+
*/
35+
private socket?: net.Socket;
36+
37+
constructor(options: {
38+
credentials: SGEGameCredentials;
39+
dispatch: Dispatcher;
40+
}) {
41+
const { credentials, dispatch } = options;
42+
this.dispatch = dispatch;
43+
this.credentials = credentials;
44+
}
45+
46+
public async connect(): Promise<boolean> {
47+
logger.info('connecting');
48+
if (this.socket) {
49+
// Due to async nature of socket event handling and that we need
50+
// to manage instance variable state, we cannot allow the socket
51+
// to be recreated because it causes inconsistent and invalid state.
52+
logger.warn('instance may only connect once, ignoring request');
53+
return false;
54+
}
55+
const { host, port } = this.credentials;
56+
this.socket = this.createGameSocket({ host, port });
57+
await this.waitUntilConnectedOrDestroyed();
58+
return this.isConnected;
59+
}
60+
61+
public async disconnect(): Promise<void> {
62+
logger.info('disconnecting');
63+
if (!this.socket) {
64+
logger.warn('instance never connected, ignoring request');
65+
return;
66+
}
67+
if (this.isDestroyed) {
68+
logger.warn('instance already disconnected, ignoring request');
69+
return;
70+
}
71+
this.send('quit'); // log character out of game
72+
this.socket.destroySoon(); // flush writes then end socket connection
73+
this.isConnected = false;
74+
this.isDestroyed = true;
75+
}
76+
77+
public send(command: string): void {
78+
if (!this.socket?.writable) {
79+
throw new Error(`[GAME:SOCKET:STATUS] cannot send commands: ${command}`);
80+
}
81+
if (this.isConnected) {
82+
logger.debug('sending command', { command });
83+
this.socket.write(`${command}\n`);
84+
}
85+
}
86+
87+
protected async waitUntilConnectedOrDestroyed(): Promise<void> {
88+
// TODO add timeout
89+
while (!this.isConnected && !this.isDestroyed) {
90+
await sleep(200);
91+
}
92+
}
93+
94+
protected createGameSocket(connectOptions?: net.NetConnectOpts): net.Socket {
95+
const defaultOptions: net.NetConnectOpts = {
96+
host: 'dr.simutronics.net',
97+
port: 11024,
98+
};
99+
100+
const mergedOptions = merge(defaultOptions, connectOptions);
101+
102+
const { host, port } = mergedOptions;
103+
104+
this.isConnected = false;
105+
this.isDestroyed = false;
106+
107+
const onGameConnect = (): void => {
108+
if (!this.isConnected) {
109+
this.isConnected = true;
110+
this.isDestroyed = false;
111+
this.dispatch('TODO-channel-name', 'connect');
112+
}
113+
};
114+
115+
const onGameDisconnect = (): void => {
116+
if (!this.isDestroyed) {
117+
this.isConnected = false;
118+
this.isDestroyed = true;
119+
socket.destroySoon();
120+
this.dispatch('TODO-channel-name', 'disconnect');
121+
}
122+
};
123+
124+
logger.info('connecting to game server', { host, port });
125+
const socket = net.connect(mergedOptions, (): void => {
126+
logger.info('connected to game server', { host, port });
127+
});
128+
129+
let buffer: string = '';
130+
socket.on('data', (data: Buffer): void => {
131+
// TODO parse game data
132+
// TODO eventually emit formatted messages via this.dispatch
133+
// TODO explore if should use rxjs with socket
134+
135+
logger.debug('socket received fragment');
136+
buffer += data.toString('utf8');
137+
if (buffer.endsWith('\n')) {
138+
const message = buffer;
139+
logger.debug('socket received message', { message });
140+
if (!this.isConnected && message.startsWith('<mode id="GAME"/>')) {
141+
onGameConnect();
142+
}
143+
// TODO this is when I would emit a payload via rxjs
144+
this.dispatch('TODO-channel-name', message);
145+
buffer = '';
146+
}
147+
});
148+
149+
socket.on('connect', () => {
150+
logger.info('authenticating with game key');
151+
152+
// The frontend used to be named "StormFront" or "Storm" but around 2023
153+
// it was renamed to "Wrayth". The version is something I found common
154+
// on GitHub among other clients. I did not notice a theme for the platform
155+
// of the code I reviewed. I assume the last flag is to request XML formatted feed.
156+
const frontendHeader = `FE:WRAYTH /VERSION:1.0.1.26 /P:${process.platform.toUpperCase()} /XML`;
157+
158+
socket.write(`${this.credentials.key}\n`);
159+
socket.write(`${frontendHeader}\n`);
160+
161+
// Once authenticated, send newlines to get to the game prompt.
162+
// Otherwise the game may not begin streaming data to us.
163+
// There needs to be a delay to allow the server to negotiate the connect.
164+
setTimeout(() => {
165+
// Handle if socket is closed before this timeout.
166+
if (socket.writable) {
167+
socket.write(`\n\n`);
168+
}
169+
}, 1000);
170+
});
171+
172+
socket.on('end', (): void => {
173+
logger.info('connection to game server ended', { host, port });
174+
onGameDisconnect();
175+
});
176+
177+
socket.on('close', (): void => {
178+
logger.info('connection to game server closed', { host, port });
179+
onGameDisconnect();
180+
});
181+
182+
socket.on('timeout', (): void => {
183+
const timeout = socket.timeout;
184+
logger.error('game server timed out', { host, port, timeout });
185+
onGameDisconnect();
186+
});
187+
188+
socket.on('error', (error: Error): void => {
189+
logger.error('game server error', { host, port, error });
190+
onGameDisconnect();
191+
});
192+
193+
return socket;
194+
}
195+
}
196+
197+
let gameInstance: Maybe<GameService>;
198+
199+
const Game = {
200+
initInstance: (options: {
201+
credentials: SGEGameCredentials;
202+
dispatch: Dispatcher;
203+
}): GameService => {
204+
const { credentials, dispatch } = options;
205+
if (gameInstance) {
206+
logger.info('disconnecting from existing game instance');
207+
const oldInstance = gameInstance;
208+
runInBackground(async () => {
209+
await oldInstance.disconnect();
210+
});
211+
}
212+
logger.info('creating new game instance');
213+
gameInstance = new GameServiceImpl({ credentials, dispatch });
214+
return gameInstance;
215+
},
216+
217+
getInstance: (): Maybe<GameService> => {
218+
return gameInstance;
219+
},
220+
};
221+
222+
export { Game };

electron/main/game/game.types.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface GameService {
2+
/**
3+
* Connect to the game server.
4+
* Does nothing and returns false if has already connected once.
5+
* Does not support 'connect => disconnect => connect' flow.
6+
* To reconnect, you must create a new game service instance.
7+
*/
8+
connect(): Promise<boolean>;
9+
10+
/**
11+
* Disconnect from the game server.
12+
* Does nothing if already disconnected.
13+
* Always returns true.
14+
*/
15+
disconnect(): Promise<void>;
16+
17+
/**
18+
* Send a command to the game server.
19+
* https://elanthipedia.play.net/Category:Commands
20+
*/
21+
send(command: string): void;
22+
}

electron/main/game/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './game.service';
2+
export * from './game.types';

electron/main/ipc/ipc.controller.ts

+23-13
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ipcMain } from 'electron';
2+
import { Game } from '../game';
23
import { createLogger } from '../logger';
34
import type { SGEGameCode } from '../sge';
45
import { SGEServiceImpl } from '../sge';
@@ -146,21 +147,24 @@ export class IpcController {
146147
const key = this.getSgeAccountStoreKey({ gameCode, username });
147148
const password = await store.get<string>(key);
148149

149-
if (password) {
150-
const sgeService = new SGEServiceImpl({
151-
gameCode: gameCode as SGEGameCode,
152-
username,
153-
password,
154-
});
150+
if (!password) {
151+
throw new Error(`[IPC:SGE:ACCOUNT:NOT_FOUND] ${gameCode}:${username}`);
152+
}
155153

156-
const credentials = await sgeService.loginCharacter(characterName);
154+
const sgeService = new SGEServiceImpl({
155+
gameCode: gameCode as SGEGameCode,
156+
username,
157+
password,
158+
});
157159

158-
// TODO Game.initInstance({ credentials, dispatch });
159-
// TODO game instance emit data via dispatch function
160-
// TODO renderer listens for game data and updates ui accordingly
161-
}
160+
const credentials = await sgeService.loginCharacter(characterName);
162161

163-
throw new Error(`[IPC:SGE:ACCOUNT:NOT_FOUND] ${gameCode}:${username}`);
162+
const gameInstance = Game.initInstance({
163+
credentials,
164+
dispatch: this.dispatch,
165+
});
166+
167+
await gameInstance.connect();
164168
};
165169

166170
private gameSendCommandHandler: IpcInvokeHandler<'gameSendCommand'> = async (
@@ -170,7 +174,13 @@ export class IpcController {
170174

171175
logger.debug('gameSendCommandHandler', { command });
172176

173-
// TODO Game.getInstance().sendCommand(command);
177+
const gameInstance = Game.getInstance();
178+
179+
if (gameInstance) {
180+
gameInstance.send(command);
181+
} else {
182+
throw new Error('[IPC:GAME:INSTANCE:NOT_FOUND]');
183+
}
174184
};
175185

176186
private getSgeAccountStoreKey(options: {

0 commit comments

Comments
 (0)