/** * @module core */ import { Account, GameState, isGameState, Server, createHello } from './models'; import { Socket } from 'net'; import { PacketIO, HelloPacket, PacketType, UpdateAckPacket, GotoPacket, GotoAckPacket, WorldPosData, AoeAckPacket, MovePacket, NewTickPacket, PingPacket, PongPacket, UpdatePacket, CreateSuccessPacket, LoadPacket, CreatePacket, MapInfoPacket, RSA, FailurePacket } from '../net'; import { Logger, LogLevel, Classes, PlayerData } from '../common'; import { StatusParser } from './parsers'; import { MapInfo } from './models/map'; import { EventEmitter } from 'events'; /** * A lightweight client for a clientless application * which will reply to any necessary packets in order to stay connected. */ export class Client extends EventEmitter { //#region Game state /** * The object id of this client when connected, * and `undefined` if the client is not connected. */ objectId: number; /** * The current position of this client when connected, * and `undefined` if the client is not connected */ position: WorldPosData; /** * The stat data of this client when it is connected, and `undefined` * if the client is not connected. */ stats: PlayerData; /** * The map which the client is currently connected to, * or `undefined` if the client is not connected. */ map: MapInfo; //#endregion /** * Information about this client's account. */ account: Account; /** * @readonly * Whether or not the socket is currently connected. */ get connected(): boolean { return this._connected; } /** * The packet interface for this client. */ readonly io: PacketIO; /** * @readonly * The time in milliseconds since this client was created. */ get time(): number { return Date.now() - this._time; } /** * The last server that this client was connected to. */ get lastServer(): Server { return this._lastServer; } private _connected: boolean; private _time: number; private _lastServer: Server; private eventHandlers: Map void>; /** * Creates a new client for the specified account. * @param account The account to create this client for. */ constructor(account: Account) { super(); if (!account || typeof account.guid !== 'string' && typeof account.password !== 'string') { this.emitError(new TypeError(`Parameter "account" must be an Account, not ${typeof account}`)); return; } this.account = account; if (!this.account.name) { let crypto = require('crypto'); this.account.name = `Client ${crypto.randomBytes(2).toString('hex')}`; crypto = null; } this._connected = false; this.io = new PacketIO(); this._time = Date.now(); this.eventHandlers = new Map([ ['connect', this.onConnect.bind(this)], ['close', this.onClose.bind(this)], ['error', this.emitError.bind(this)], ]); this.io .on(PacketType.CREATE_SUCCESS, (createSuccess: CreateSuccessPacket) => { this.objectId = createSuccess.objectId; this.log(`Connected to ${this._lastServer.name}.`, LogLevel.Success); }) .on(PacketType.MAPINFO, (mapInfo: MapInfoPacket) => { this.map = { name: mapInfo.name, size: mapInfo.width, tiles: {} }; this._lastServer.name = mapInfo.name; }) .on(PacketType.UPDATE, (update: UpdatePacket) => { this.io.send(new UpdateAckPacket()); for (const tile of update.tiles) { this.map.tiles[tile.x + tile.y * this.map.size] = tile; } if (!this.position) { for (const newObj of update.newObjects) { if (newObj.status.objectId === this.objectId) { this.stats = StatusParser.parseObject(newObj); this.position = newObj.status.pos.clone(); break; } } } }) .on(PacketType.GOTO, (goto: GotoPacket) => { const ack = new GotoAckPacket(); ack.time = this.time; this.io.send(ack); if (goto.objectId === this.objectId) { this.position = goto.position.clone(); } }) .on(PacketType.AOE, () => { const ack = new AoeAckPacket(); ack.time = this.time; ack.position = this.position.clone(); this.io.send(ack); }) .on(PacketType.NEWTICK, (newTick: NewTickPacket) => { const ack = new MovePacket(); ack.tickId = newTick.tickId; ack.time = this.time; ack.newPosition = this.position.clone(); this.io.send(ack); for (const status of newTick.statuses) { if (status.objectId === this.objectId) { this.stats = StatusParser.processObjectStatus(status, this.stats); break; } } }) .on(PacketType.PING, (ping: PingPacket) => { const ack = new PongPacket(); ack.serial = ping.serial; ack.time = this.time; this.io.send(ack); }); } /** * Attaches the client to the `Socket`. * @param socket The socket to attach to. */ attach(socket: Socket): void { if (!(socket instanceof Socket)) { this.emitError(new TypeError(`Parameter "socket" should be a Socket, not ${typeof socket}`)); return; } if (this.io.socket) { this.detach(); } this.io.attach(socket); for (const kvp of this.eventHandlers) { this.io.socket.on(kvp[0], kvp[1]); } } /** * Detaches this client from its socket. */ detach(): void { if (this.io.socket) { for (const kvp of this.eventHandlers) { this.io.socket.removeListener(kvp[0], kvp[1]); } this.io.socket.destroy(); this.io.detach(); } } /** * Connects to the specified game server. If no server is specified then * the hello handshake will be initiated. If a server is specified the * socket will be connected to the server before beginning the handshake. * @param gameState The state to use when connecting. * @param server The server to connect to. */ async connect(gameState: GameState, server?: Server): Promise { if (server && typeof server.ip !== 'string') { return Promise.reject(new TypeError(`Parameter "server" must be a Server, not ${typeof server}`)); } if (!isGameState(gameState)) { return Promise.reject(new TypeError(`Parameter "gameState" must be a GameState, not ${typeof gameState}`)); } if (!this.io.socket) { return Promise.reject(new Error('No socket attached.')); } if (server) { if (this.io.socket.writable) { this.io.socket.destroy(); } await new Promise((resolve) => { this.io.socket.connect(2050, server.ip, resolve); }); } else { if (!this.io.socket.writable) { return Promise.reject(new Error('Socket is closed.')); } } const helloPacket: HelloPacket = createHello(gameState); helloPacket.guid = RSA.encrypt(this.account.guid); helloPacket.password = RSA.encrypt(this.account.password); this.io.send(helloPacket); this.io.once(PacketType.MAPINFO, () => { if (gameState.characterId > 0) { const load = new LoadPacket(); load.charId = gameState.characterId; load.isFromArena = false; this.io.send(load); } else { const create = new CreatePacket(); create.classType = Classes.Wizard; create.skinType = 0; this.io.send(create); } }); this._lastServer = server; return new Promise((resolve, reject) => { const success = (createSuccess: CreateSuccessPacket) => { this.io.removeListener(PacketType.CREATE_SUCCESS, failure); resolve(createSuccess); }; const failure = (failurePacket: FailurePacket) => { this.io.removeListener(PacketType.CREATE_SUCCESS, success); reject(failurePacket); }; this.io.once(PacketType.CREATE_SUCCESS, success); this.io.once(PacketType.FAILURE, failure); }); } /** * Disconnects the client from the current server. */ disconnect(): Promise { if (this._connected) { return new Promise((resolve) => { this.io.socket.once('close', () => resolve()); this.io.socket.destroy(); }); } else { return Promise.resolve(); } } private onConnect() { if (!this._lastServer) { this._lastServer = {} as Server; } this._lastServer.ip = this.io.socket.remoteAddress; this._connected = true; this.log(`Socket connected to ${this._lastServer.name || this._lastServer.ip}.`, LogLevel.Success); } private onClose() { this._connected = false; this.position = undefined; this.objectId = undefined; this.log('Socket closed.', LogLevel.Warning); } private emitError(error: Error): void { if (this.listenerCount('error') === 0) { throw error; } else { this.emit('error', error); } } private log(msg: string, level: LogLevel = LogLevel.Message): void { let name = 'Client'; if (this.account && this.account.name) { name = this.account.name; } Logger.log(name, msg, level); } }