diff --git a/package.json b/package.json index d047a797..58074f57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slippi/slippi-js", - "version": "5.0.7", + "version": "5.1.0", "description": "Official Project Slippi Javascript SDK", "license": "LGPL-3.0-or-later", "repository": "project-slippi/slippi-js", @@ -46,6 +46,7 @@ ], "dependencies": { "@shelacek/ubjson": "^1.0.1", + "enet": "^0.2.9", "iconv-lite": "^0.6.2", "lodash": "^4.17.19", "moment": "^2.27.0", diff --git a/src/console/connection.ts b/src/console/consoleConnection.ts similarity index 72% rename from src/console/connection.ts rename to src/console/consoleConnection.ts index 5d240a54..cfeafce2 100644 --- a/src/console/connection.ts +++ b/src/console/consoleConnection.ts @@ -4,44 +4,12 @@ import { EventEmitter } from "events"; import inject from "reconnect-core"; import { ConsoleCommunication, CommunicationType, CommunicationMessage } from "./communication"; +import { ConnectionDetails, Connection, ConnectionStatus, Ports, ConnectionSettings, ConnectionEvent } from "./types"; export const NETWORK_MESSAGE = "HELO\0"; const DEFAULT_CONNECTION_TIMEOUT_MS = 20000; -export enum ConnectionEvent { - HANDSHAKE = "handshake", - STATUS_CHANGE = "statusChange", - DATA = "data", - INFO = "loginfo", - WARN = "logwarn", -} - -export enum ConnectionStatus { - DISCONNECTED = 0, - CONNECTING = 1, - CONNECTED = 2, - RECONNECT_WAIT = 3, -} - -export enum Ports { - DEFAULT = 51441, - LEGACY = 666, - RELAY_START = 53741, -} - -export interface ConnectionDetails { - consoleNick: string; - gameDataCursor: Uint8Array; - version: string; - clientToken: number; -} - -export interface ConnectionSettings { - ipAddress: string; - port: number; -} - enum CommunicationState { INITIAL = "initial", LEGACY = "legacy", @@ -55,6 +23,12 @@ const defaultConnectionDetails: ConnectionDetails = { clientToken: 0, }; +const consoleConnectionOptions = { + autoReconnect: true, +}; + +export type ConsoleConnectionOptions = typeof consoleConnectionOptions; + /** * Responsible for maintaining connection to a Slippi relay connection or Wii connection. * Events are emitted whenever data is received. @@ -77,20 +51,21 @@ const defaultConnectionDetails: ConnectionDetails = { * }); * ``` */ -export class ConsoleConnection extends EventEmitter { +export class ConsoleConnection extends EventEmitter implements Connection { private ipAddress: string; private port: number; private connectionStatus = ConnectionStatus.DISCONNECTED; private connDetails: ConnectionDetails = { ...defaultConnectionDetails }; - private clientsByPort: Array; - private connectionsByPort: Array>; + private client: net.Socket | null = null; + private connection: inject.Instance | null = null; + private options: ConsoleConnectionOptions; + private shouldReconnect = false; - public constructor() { + public constructor(options?: Partial) { super(); this.ipAddress = "0.0.0.0"; this.port = Ports.DEFAULT; - this.clientsByPort = []; - this.connectionsByPort = []; + this.options = Object.assign({}, consoleConnectionOptions, options); } /** @@ -114,7 +89,7 @@ export class ConsoleConnection extends EventEmitter { * @returns The specific details about the connected console. */ public getDetails(): ConnectionDetails { - return this.connDetails; + return { ...this.connDetails }; } /** @@ -122,28 +97,19 @@ export class ConsoleConnection extends EventEmitter { * @param ip The IP address of the Wii or Slippi relay. * @param port The port to connect to. * @param timeout Optional. The timeout in milliseconds when attempting to connect - * to the Wii or relay. Default: 5000. + * to the Wii or relay. */ public connect(ip: string, port: number, timeout = DEFAULT_CONNECTION_TIMEOUT_MS): void { this.ipAddress = ip; this.port = port; - - if (port === Ports.LEGACY || port === Ports.DEFAULT) { - // Connect to both legacy and default in case somebody accidentally set it - // and they would encounter issues with the new Nintendont - this._connectOnPort(Ports.DEFAULT, timeout); - this._connectOnPort(Ports.LEGACY, timeout); - } else { - // If port is manually set, use that port. - this._connectOnPort(port, timeout); - } + this._connectOnPort(ip, port, timeout); } - private _connectOnPort(port: number, timeout: number): void { + private _connectOnPort(ip: string, port: number, timeout: number): void { // set up reconnect const reconnect = inject(() => net.connect({ - host: this.ipAddress, + host: ip, port: port, timeout: timeout, }), @@ -165,13 +131,16 @@ export class ConsoleConnection extends EventEmitter { failAfter: Infinity, }, (client) => { - this.clientsByPort[port] = client; + this.emit(ConnectionEvent.CONNECT); + // We successfully connected so turn on auto-reconnect + this.shouldReconnect = this.options.autoReconnect; + this.client = client; let commState: CommunicationState = CommunicationState.INITIAL; client.on("data", (data) => { if (commState === CommunicationState.INITIAL) { commState = this._getInitialCommState(data); - console.log(`Connected to ${this.ipAddress}:${this.port} with type: ${commState}`); + console.log(`Connected to ${ip}:${port} with type: ${commState}`); this._setStatus(ConnectionStatus.CONNECTED); console.log(data.toString("hex")); } @@ -186,13 +155,13 @@ export class ConsoleConnection extends EventEmitter { try { consoleComms.receive(data); } catch (err) { - console.warn("Failed to process new data from server...", { + console.error("Failed to process new data from server...", { error: err, prevDataBuf: consoleComms.getReceiveBuffer(), rcvData: data, }); client.destroy(); - + this.emit(ConnectionEvent.ERROR, err); return; } const messages = consoleComms.getMessages(); @@ -202,20 +171,23 @@ export class ConsoleConnection extends EventEmitter { messages.forEach((message) => this._processMessage(message)); } catch (err) { // Disconnect client to send another handshake message - client.destroy(); console.error(err); + client.destroy(); + this.emit(ConnectionEvent.ERROR, err); } }); client.on("timeout", () => { // const previouslyConnected = this.connectionStatus === ConnectionStatus.CONNECTED; - console.warn(`Attempted connection to ${this.ipAddress}:${this.port} timed out after ${timeout}ms`); + console.warn(`Attempted connection to ${ip}:${port} timed out after ${timeout}ms`); client.destroy(); }); client.on("end", () => { console.log("disconnect"); - client.destroy(); + if (!this.shouldReconnect) { + client.destroy(); + } }); client.on("close", () => { @@ -223,7 +195,7 @@ export class ConsoleConnection extends EventEmitter { }); const handshakeMsgOut = consoleComms.genHandshakeOut( - this.connDetails.gameDataCursor, + this.connDetails.gameDataCursor as Uint8Array, this.connDetails.clientToken, ); @@ -233,25 +205,18 @@ export class ConsoleConnection extends EventEmitter { const setConnectingStatus = (): void => { // Indicate we are connecting - this._setStatus(ConnectionStatus.CONNECTING); + this._setStatus(this.shouldReconnect ? ConnectionStatus.RECONNECT_WAIT : ConnectionStatus.CONNECTING); }; connection.on("connect", setConnectingStatus); connection.on("reconnect", setConnectingStatus); connection.on("disconnect", () => { - // If one of the connections was successful, we no longer need to try connecting this one - this.connectionsByPort.forEach((iConn, iPort) => { - if (iPort === port || !iConn.connected) { - // Only disconnect if a different connection was connected - return; - } - - // Prevent reconnections and disconnect + if (!this.shouldReconnect) { connection.reconnect = false; connection.disconnect(); - }); - + this._setStatus(ConnectionStatus.DISCONNECTED); + } // TODO: Figure out how to set RECONNECT_WAIT state here. Currently it will stay on // TODO: Connecting... forever }); @@ -260,8 +225,7 @@ export class ConsoleConnection extends EventEmitter { console.error(`Connection on port ${port} encountered an error.`, error); }); - this.connectionsByPort[port] = connection; - console.log("Starting connection"); + this.connection = connection; connection.connect(port); } @@ -269,18 +233,16 @@ export class ConsoleConnection extends EventEmitter { * Terminate the current connection. */ public disconnect(): void { - console.log("Disconnect request"); - - this.connectionsByPort.forEach((connection) => { - // Prevent reconnections and disconnect - connection.reconnect = false; // eslint-disable-line - connection.disconnect(); - }); + // Prevent reconnections and disconnect + if (this.connection) { + this.connection.reconnect = false; + this.connection.disconnect(); + this.connection = null; + } - this.clientsByPort.forEach((client) => { - client.destroy(); - }); - this._setStatus(ConnectionStatus.DISCONNECTED); + if (this.client) { + this.client.destroy(); + } } private _getInitialCommState(data: Buffer): CommunicationState { @@ -296,6 +258,7 @@ export class ConsoleConnection extends EventEmitter { } private _processMessage(message: CommunicationMessage): void { + this.emit(ConnectionEvent.MESSAGE, message); switch (message.type) { case CommunicationType.KEEP_ALIVE: // console.log("Keep alive message received"); @@ -310,16 +273,12 @@ export class ConsoleConnection extends EventEmitter { break; case CommunicationType.REPLAY: const readPos = Uint8Array.from(message.payload.pos); - const cmp = Buffer.compare(this.connDetails.gameDataCursor, readPos); + const cmp = Buffer.compare(this.connDetails.gameDataCursor as Uint8Array, readPos); if (!message.payload.forcePos && cmp !== 0) { - console.warn( - "Position of received data is not what was expected. Expected, Received:", - this.connDetails.gameDataCursor, - readPos, - ); - // The readPos is not the one we are waiting on, throw error - throw new Error("Position of received data is incorrect."); + throw new Error( + `Position of received data is incorrect. Expected: ${this.connDetails.gameDataCursor.toString()}, Received: ${readPos.toString()}`, + ); } if (message.payload.forcePos) { @@ -355,7 +314,10 @@ export class ConsoleConnection extends EventEmitter { } private _setStatus(status: ConnectionStatus): void { - this.connectionStatus = status; - this.emit(ConnectionEvent.STATUS_CHANGE, this.connectionStatus); + // Don't fire the event if the status hasn't actually changed + if (this.connectionStatus !== status) { + this.connectionStatus = status; + this.emit(ConnectionEvent.STATUS_CHANGE, this.connectionStatus); + } } } diff --git a/src/console/dolphinConnection.ts b/src/console/dolphinConnection.ts new file mode 100644 index 00000000..6da8428c --- /dev/null +++ b/src/console/dolphinConnection.ts @@ -0,0 +1,167 @@ +import enet from "enet"; + +import { EventEmitter } from "events"; +import { Connection, ConnectionStatus, ConnectionSettings, ConnectionDetails, Ports, ConnectionEvent } from "./types"; + +const MAX_PEERS = 32; + +export enum DolphinMessageType { + CONNECT_REPLY = "connect_reply", + GAME_EVENT = "game_event", + START_GAME = "start_game", + END_GAME = "end_game", +} + +export class DolphinConnection extends EventEmitter implements Connection { + private ipAddress: string; + private port: number; + private connectionStatus = ConnectionStatus.DISCONNECTED; + private gameCursor = 0; + private nickname = "unknown"; + private version = ""; + private client: enet.Host; + private peer: enet.Peer | null = null; + + public constructor() { + super(); + this.ipAddress = "0.0.0.0"; + this.port = Ports.DEFAULT; + + // Create the enet client + this.client = enet.createClient({ peers: MAX_PEERS, channels: 3, down: 0, up: 0 }, (err) => { + if (err) { + console.error(err); + return; + } + }); + } + + /** + * @returns The current connection status. + */ + public getStatus(): ConnectionStatus { + return this.connectionStatus; + } + + /** + * @returns The IP address and port of the current connection. + */ + public getSettings(): ConnectionSettings { + return { + ipAddress: this.ipAddress, + port: this.port, + }; + } + + public getDetails(): ConnectionDetails { + return { + consoleNick: this.nickname, + gameDataCursor: this.gameCursor, + version: this.version, + }; + } + + public connect(ip: string, port: number): void { + console.log(`Connecting to: ${ip}:${port}`); + this.ipAddress = ip; + this.port = port; + + this.peer = this.client.connect( + { + address: this.ipAddress, + port: this.port, + }, + 3, + 1337, // Data to send, not sure what this is or what this represents + (err, newPeer) => { + if (err) { + console.error(err); + return; + } + + newPeer.ping(); + this.emit(ConnectionEvent.CONNECT); + this._setStatus(ConnectionStatus.CONNECTED); + }, + ); + + this.peer.on("connect", () => { + // Reset the game cursor to the beginning of the game. Do we need to do this or + // should it just continue from where it left off? + this.gameCursor = 0; + + const request = { + type: "connect_request", + cursor: this.gameCursor, + }; + const packet = new enet.Packet(JSON.stringify(request), enet.PACKET_FLAG.RELIABLE); + this.peer.send(0, packet); + }); + + this.peer.on("message", (packet: enet.Packet) => { + const data = packet.data(); + if (data.length === 0) { + return; + } + + const dataString = data.toString("ascii"); + const message = JSON.parse(dataString); + this.emit(ConnectionEvent.MESSAGE, message); + switch (message.type) { + case DolphinMessageType.CONNECT_REPLY: + this.connectionStatus = ConnectionStatus.CONNECTED; + this.gameCursor = message.cursor; + this.nickname = message.nick; + this.version = message.version; + this.emit(ConnectionEvent.HANDSHAKE, this.getDetails()); + break; + case DolphinMessageType.GAME_EVENT: + const { payload, cursor } = message; + if (!payload) { + // We got a disconnection request + this.disconnect(); + return; + } + + if (this.gameCursor !== cursor) { + const err = new Error( + `Unexpected game data cursor. Expected: ${this.gameCursor} but got: ${cursor}. Payload: ${dataString}`, + ); + console.error(err); + this.emit(ConnectionEvent.ERROR, err); + } + + const gameData = Buffer.from(payload, "base64"); + this.gameCursor = message.next_cursor; + this._handleReplayData(gameData); + break; + } + }); + + this.peer.on("disconnect", () => { + this.disconnect(); + }); + + this._setStatus(ConnectionStatus.CONNECTING); + } + + public disconnect(): void { + if (this.peer) { + this.peer.disconnect(); + this.peer = null; + } + this._setStatus(ConnectionStatus.DISCONNECTED); + } + + private _handleReplayData(data: Uint8Array): void { + this.emit(ConnectionEvent.DATA, data); + } + + private _setStatus(status: ConnectionStatus): void { + // Don't fire the event if the status hasn't actually changed + if (this.connectionStatus !== status) { + this.connectionStatus = status; + this.emit(ConnectionEvent.STATUS_CHANGE, this.connectionStatus); + } + } +} diff --git a/src/console/index.ts b/src/console/index.ts index 4de10ebf..55dc6e49 100644 --- a/src/console/index.ts +++ b/src/console/index.ts @@ -1 +1,3 @@ -export * from "./connection"; +export * from "./types"; +export * from "./consoleConnection"; +export * from "./dolphinConnection"; diff --git a/src/console/types.ts b/src/console/types.ts new file mode 100644 index 00000000..f0ffa1c3 --- /dev/null +++ b/src/console/types.ts @@ -0,0 +1,43 @@ +import { EventEmitter } from "events"; + +export enum ConnectionEvent { + CONNECT = "connect", + MESSAGE = "message", + HANDSHAKE = "handshake", + STATUS_CHANGE = "statusChange", + DATA = "data", + ERROR = "error", +} + +export enum ConnectionStatus { + DISCONNECTED = 0, + CONNECTING = 1, + CONNECTED = 2, + RECONNECT_WAIT = 3, +} + +export enum Ports { + DEFAULT = 51441, + LEGACY = 666, + RELAY_START = 53741, +} + +export interface ConnectionDetails { + consoleNick: string; + gameDataCursor: number | Uint8Array; + version: string; + clientToken?: number; +} + +export interface ConnectionSettings { + ipAddress: string; + port: number; +} + +export interface Connection extends EventEmitter { + getStatus(): ConnectionStatus; + getSettings(): ConnectionSettings; + getDetails(): ConnectionDetails; + connect(ip: string, port: number): void; + disconnect(): void; +} diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 00000000..8c050785 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +declare module "enet" { + import { EventEmitter } from "events"; + + export const PACKET_FLAG: any; + export class Packet { + public constructor(data: string | Buffer, flag: any); + public data(): Buffer; + } + export interface Peer extends EventEmitter { + ping(): void; + disconnect(data?: any): void; + send(channel: number, packet: Packet): boolean; + } + interface ClientArguments { + peers: number; + channels: number; + down: number; + up: number; + } + export interface Host extends Record { + connect(args: any, channels: number, data: any, callback: (err: Error, peer: Peer) => void): Peer; + destroy(): void; + } + export function createClient(args: ClientArguments, callback: (err: Error, client: Host) => void): Host; +} + +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/src/melee/characters.ts b/src/melee/characters.ts index 64770b76..668f96b3 100644 --- a/src/melee/characters.ts +++ b/src/melee/characters.ts @@ -1,3 +1,5 @@ +import { Character } from "./types"; + export type CharacterColor = string; export interface CharacterInfo { @@ -9,157 +11,157 @@ export interface CharacterInfo { const externalCharacters: CharacterInfo[] = [ { - id: 0, + id: Character.CAPTAIN_FALCON, name: "Captain Falcon", shortName: "Falcon", colors: ["Default", "Black", "Red", "White", "Green", "Blue"], }, { - id: 1, + id: Character.DONKEY_KONG, name: "Donkey Kong", shortName: "DK", colors: ["Default", "Black", "Red", "Blue", "Green"], }, { - id: 2, + id: Character.FOX, name: "Fox", shortName: "Fox", colors: ["Default", "Red", "Blue", "Green"], }, { - id: 3, + id: Character.GAME_AND_WATCH, name: "Mr. Game & Watch", shortName: "G&W", colors: ["Default", "Red", "Blue", "Green"], }, { - id: 4, + id: Character.KIRBY, name: "Kirby", shortName: "Kirby", colors: ["Default", "Yellow", "Blue", "Red", "Green", "White"], }, { - id: 5, + id: Character.BOWSER, name: "Bowser", shortName: "Bowser", colors: ["Default", "Red", "Blue", "Black"], }, { - id: 6, + id: Character.LINK, name: "Link", shortName: "Link", colors: ["Default", "Red", "Blue", "Black", "White"], }, { - id: 7, + id: Character.LUIGI, name: "Luigi", shortName: "Luigi", colors: ["Default", "White", "Blue", "Red"], }, { - id: 8, + id: Character.MARIO, name: "Mario", shortName: "Mario", colors: ["Default", "Yellow", "Black", "Blue", "Green"], }, { - id: 9, + id: Character.MARTH, name: "Marth", shortName: "Marth", colors: ["Default", "Red", "Green", "Black", "White"], }, { - id: 10, + id: Character.MEWTWO, name: "Mewtwo", shortName: "Mewtwo", colors: ["Default", "Red", "Blue", "Green"], }, { - id: 11, + id: Character.NESS, name: "Ness", shortName: "Ness", colors: ["Default", "Yellow", "Blue", "Green"], }, { - id: 12, + id: Character.PEACH, name: "Peach", shortName: "Peach", colors: ["Default", "Daisy", "White", "Blue", "Green"], }, { - id: 13, + id: Character.PIKACHU, name: "Pikachu", shortName: "Pikachu", colors: ["Default", "Red", "Party Hat", "Cowboy Hat"], }, { - id: 14, + id: Character.ICE_CLIMBERS, name: "Ice Climbers", shortName: "ICs", colors: ["Default", "Green", "Orange", "Red"], }, { - id: 15, + id: Character.JIGGLYPUFF, name: "Jigglypuff", shortName: "Puff", colors: ["Default", "Red", "Blue", "Headband", "Crown"], }, { - id: 16, + id: Character.SAMUS, name: "Samus", shortName: "Samus", colors: ["Default", "Pink", "Black", "Green", "Purple"], }, { - id: 17, + id: Character.YOSHI, name: "Yoshi", shortName: "Yoshi", colors: ["Default", "Red", "Blue", "Yellow", "Pink", "Cyan"], }, { - id: 18, + id: Character.ZELDA, name: "Zelda", shortName: "Zelda", colors: ["Default", "Red", "Blue", "Green", "White"], }, { - id: 19, + id: Character.SHEIK, name: "Sheik", shortName: "Sheik", colors: ["Default", "Red", "Blue", "Green", "White"], }, { - id: 20, + id: Character.FALCO, name: "Falco", shortName: "Falco", colors: ["Default", "Red", "Blue", "Green"], }, { - id: 21, + id: Character.YOUNG_LINK, name: "Young Link", shortName: "YLink", colors: ["Default", "Red", "Blue", "White", "Black"], }, { - id: 22, + id: Character.DR_MARIO, name: "Dr. Mario", shortName: "Doc", colors: ["Default", "Red", "Blue", "Green", "Black"], }, { - id: 23, + id: Character.ROY, name: "Roy", shortName: "Roy", colors: ["Default", "Red", "Blue", "Green", "Yellow"], }, { - id: 24, + id: Character.PICHU, name: "Pichu", shortName: "Pichu", colors: ["Default", "Red", "Blue", "Green"], }, { - id: 25, + id: Character.GANONDORF, name: "Ganondorf", shortName: "Ganon", colors: ["Default", "Red", "Blue", "Green", "Purple"], diff --git a/src/melee/index.ts b/src/melee/index.ts index 0f036fe4..cf9825ce 100644 --- a/src/melee/index.ts +++ b/src/melee/index.ts @@ -3,4 +3,6 @@ import * as characters from "./characters"; import * as moves from "./moves"; import * as stages from "./stages"; +export * from "./types"; + export { animations, characters, moves, stages }; diff --git a/src/melee/stages.ts b/src/melee/stages.ts index 818571f9..ab0b7a4e 100644 --- a/src/melee/stages.ts +++ b/src/melee/stages.ts @@ -1,139 +1,134 @@ -export interface Stage { +import { Stage } from "./types"; + +export interface StageInfo { id: number; name: string; } -const stages: { [id: number]: Stage } = { - 2: { - id: 2, +const stages: { [id: number]: StageInfo } = { + [Stage.FOUNTAIN_OF_DREAMS]: { + id: Stage.FOUNTAIN_OF_DREAMS, name: "Fountain of Dreams", }, - 3: { - id: 3, + [Stage.POKEMON_STADIUM]: { + id: Stage.POKEMON_STADIUM, name: "Pokémon Stadium", }, - 4: { - id: 4, + [Stage.PEACHS_CASTLE]: { + id: Stage.PEACHS_CASTLE, name: "Princess Peach's Castle", }, - 5: { - id: 5, + [Stage.KONGO_JUNGLE]: { + id: Stage.KONGO_JUNGLE, name: "Kongo Jungle", }, - 6: { - id: 6, + [Stage.BRINSTAR]: { + id: Stage.BRINSTAR, name: "Brinstar", }, - 7: { - id: 7, + [Stage.CORNERIA]: { + id: Stage.CORNERIA, name: "Corneria", }, - 8: { - id: 8, + [Stage.YOSHIS_STORY]: { + id: Stage.YOSHIS_STORY, name: "Yoshi's Story", }, - 9: { - id: 9, + [Stage.ONETT]: { + id: Stage.ONETT, name: "Onett", }, - 10: { - id: 10, + [Stage.MUTE_CITY]: { + id: Stage.MUTE_CITY, name: "Mute City", }, - 11: { - id: 11, + [Stage.RAINBOW_CRUISE]: { + id: Stage.RAINBOW_CRUISE, name: "Rainbow Cruise", }, - 12: { - id: 12, + [Stage.JUNGLE_JAPES]: { + id: Stage.JUNGLE_JAPES, name: "Jungle Japes", }, - 13: { - id: 13, + [Stage.GREAT_BAY]: { + id: Stage.GREAT_BAY, name: "Great Bay", }, - 14: { - id: 14, + [Stage.HYRULE_TEMPLE]: { + id: Stage.HYRULE_TEMPLE, name: "Hyrule Temple", }, - 15: { - id: 15, + [Stage.BRINSTAR_DEPTHS]: { + id: Stage.BRINSTAR_DEPTHS, name: "Brinstar Depths", }, - 16: { - id: 16, + [Stage.YOSHIS_ISLAND]: { + id: Stage.YOSHIS_ISLAND, name: "Yoshi's Island", }, - 17: { - id: 17, + [Stage.GREEN_GREENS]: { + id: Stage.GREEN_GREENS, name: "Green Greens", }, - 18: { - id: 18, + [Stage.FOURSIDE]: { + id: Stage.FOURSIDE, name: "Fourside", }, - 19: { - id: 19, + [Stage.MUSHROOM_KINGDOM]: { + id: Stage.MUSHROOM_KINGDOM, name: "Mushroom Kingdom I", }, - 20: { - id: 20, + [Stage.MUSHROOM_KINGDOM_2]: { + id: Stage.MUSHROOM_KINGDOM_2, name: "Mushroom Kingdom II", }, - 22: { - id: 22, + [Stage.VENOM]: { + id: Stage.VENOM, name: "Venom", }, - 23: { - id: 23, + [Stage.POKE_FLOATS]: { + id: Stage.POKE_FLOATS, name: "Poké Floats", }, - 24: { - id: 24, + [Stage.BIG_BLUE]: { + id: Stage.BIG_BLUE, name: "Big Blue", }, - 25: { - id: 25, + [Stage.ICICLE_MOUNTAIN]: { + id: Stage.ICICLE_MOUNTAIN, name: "Icicle Mountain", }, - 26: { - id: 26, + [Stage.ICETOP]: { + id: Stage.ICETOP, name: "Icetop", }, - 27: { - id: 27, + [Stage.FLAT_ZONE]: { + id: Stage.FLAT_ZONE, name: "Flat Zone", }, - 28: { - id: 28, + [Stage.DREAMLAND]: { + id: Stage.DREAMLAND, name: "Dream Land N64", }, - 29: { - id: 29, + [Stage.YOSHIS_ISLAND_N64]: { + id: Stage.YOSHIS_ISLAND_N64, name: "Yoshi's Island N64", }, - 30: { - id: 30, + [Stage.KONGO_JUNGLE_N64]: { + id: Stage.KONGO_JUNGLE_N64, name: "Kongo Jungle N64", }, - 31: { - id: 31, + [Stage.BATTLEFIELD]: { + id: Stage.BATTLEFIELD, name: "Battlefield", }, - 32: { - id: 32, + [Stage.FINAL_DESTINATION]: { + id: Stage.FINAL_DESTINATION, name: "Final Destination", }, }; -export const STAGE_FOD = 2; -export const STAGE_POKEMON = 3; -export const STAGE_YOSHIS = 8; -export const STAGE_DREAM_LAND = 28; -export const STAGE_BATTLEFIELD = 31; -export const STAGE_FD = 32; - -export function getStageInfo(stageId: number): Stage { +export function getStageInfo(stageId: number): StageInfo { const s = stages[stageId]; if (!s) { throw new Error(`Invalid stage with id ${stageId}`); diff --git a/src/melee/types.ts b/src/melee/types.ts new file mode 100644 index 00000000..9373b09d --- /dev/null +++ b/src/melee/types.ts @@ -0,0 +1,61 @@ +export enum Character { + CAPTAIN_FALCON = 0, + DONKEY_KONG = 1, + FOX = 2, + GAME_AND_WATCH = 3, + KIRBY = 4, + BOWSER = 5, + LINK = 6, + LUIGI = 7, + MARIO = 8, + MARTH = 9, + MEWTWO = 10, + NESS = 11, + PEACH = 12, + PIKACHU = 13, + ICE_CLIMBERS = 14, + JIGGLYPUFF = 15, + SAMUS = 16, + YOSHI = 17, + ZELDA = 18, + SHEIK = 19, + FALCO = 20, + YOUNG_LINK = 21, + DR_MARIO = 22, + ROY = 23, + PICHU = 24, + GANONDORF = 25, +} + +export enum Stage { + FOUNTAIN_OF_DREAMS = 2, + POKEMON_STADIUM = 3, + PEACHS_CASTLE = 4, + KONGO_JUNGLE = 5, + BRINSTAR = 6, + CORNERIA = 7, + YOSHIS_STORY = 8, + ONETT = 9, + MUTE_CITY = 10, + RAINBOW_CRUISE = 11, + JUNGLE_JAPES = 12, + GREAT_BAY = 13, + HYRULE_TEMPLE = 14, + BRINSTAR_DEPTHS = 15, + YOSHIS_ISLAND = 16, + GREEN_GREENS = 17, + FOURSIDE = 18, + MUSHROOM_KINGDOM = 19, + MUSHROOM_KINGDOM_2 = 20, + VENOM = 22, + POKE_FLOATS = 23, + BIG_BLUE = 24, + ICICLE_MOUNTAIN = 25, + ICETOP = 26, + FLAT_ZONE = 27, + DREAMLAND = 28, + YOSHIS_ISLAND_N64 = 29, + KONGO_JUNGLE_N64 = 30, + BATTLEFIELD = 31, + FINAL_DESTINATION = 32, +} diff --git a/src/utils/slpFile.ts b/src/utils/slpFile.ts index 8ab004b4..47d821d3 100644 --- a/src/utils/slpFile.ts +++ b/src/utils/slpFile.ts @@ -135,7 +135,11 @@ export class SlpFile extends Writable { this.on("finish", () => { // Update file with bytes written const fd = fs.openSync(this.filePath, "r+"); + + // Not sure why writeSync isn't defined on fs so just ignore the lint warning for now + // eslint-disable-next-line @typescript-eslint/no-explicit-any (fs as any).writeSync(fd, createUInt32Buffer(this.rawDataLength), 0, "binary", 11); + fs.closeSync(fd); // Unsubscribe from the stream diff --git a/src/utils/slpFileWriter.ts b/src/utils/slpFileWriter.ts index 420b4f66..2d549c08 100644 --- a/src/utils/slpFileWriter.ts +++ b/src/utils/slpFileWriter.ts @@ -13,7 +13,7 @@ function getNewFilePath(folder: string, m: Moment): string { return path.join(folder, `Game_${m.format("YYYYMMDD")}T${m.format("HHmmss")}.slp`); } -export interface SlpFileWriterOptions { +export interface SlpFileWriterOptions extends Partial { outputFiles: boolean; folderPath: string; consoleNickname: string; @@ -49,12 +49,8 @@ export class SlpFileWriter extends SlpStream { /** * Creates an instance of SlpFileWriter. */ - public constructor( - options?: Partial, - slpOptions?: Partial, - opts?: WritableOptions, - ) { - super(slpOptions, opts); + public constructor(options?: Partial, opts?: WritableOptions) { + super(options, opts); this.options = Object.assign({}, defaultSettings, options); this._setupListeners(); } @@ -101,6 +97,16 @@ export class SlpFileWriter extends SlpStream { return null; } + /** + * Ends the current file being written to. + * + * @returns {(string | null)} + * @memberof SlpFileWriter + */ + public endCurrentFile(): void { + this._handleEndGame(); + } + /** * Updates the settings to be the desired ones passed in. * diff --git a/src/utils/slpParser.ts b/src/utils/slpParser.ts index b7f57433..3ff40e30 100644 --- a/src/utils/slpParser.ts +++ b/src/utils/slpParser.ts @@ -49,6 +49,7 @@ export class SlpParser extends EventEmitter { this.options = Object.assign({}, defaultSlpParserOptions, options); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public handleCommand(command: Command, payload: any): void { switch (command) { case Command.GAME_START: diff --git a/src/utils/slpStream.ts b/src/utils/slpStream.ts index 3c4af6d4..8926efb5 100644 --- a/src/utils/slpStream.ts +++ b/src/utils/slpStream.ts @@ -1,7 +1,7 @@ import { Writable, WritableOptions } from "stream"; import { Command, EventPayloadTypes } from "../types"; import { parseMessage } from "./slpReader"; -import { NETWORK_MESSAGE } from "../console/connection"; +import { NETWORK_MESSAGE } from "../console"; export enum SlpStreamMode { AUTO = "AUTO", // Always reading data, but errors on invalid command @@ -65,6 +65,7 @@ export class SlpStream extends Writable { this.payloadSizes = null; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public _write(newData: Buffer, encoding: string, callback: (error?: Error | null, data?: any) => void): void { if (encoding !== "buffer") { throw new Error(`Unsupported stream encoding. Expected 'buffer' got '${encoding}'.`); @@ -137,7 +138,7 @@ export class SlpStream extends Writable { private _processCommand(command: Command, entirePayload: Uint8Array, dataView: DataView): number { // Handle the message size command - if (command === Command.MESSAGE_SIZES && this.payloadSizes === null) { + if (command === Command.MESSAGE_SIZES) { const payloadSize = dataView.getUint8(0); // Set the payload sizes this.payloadSizes = processReceiveCommands(dataView); @@ -154,7 +155,7 @@ export class SlpStream extends Writable { // Fetch the payload and parse it let payload: Uint8Array; - let parsedPayload: any; + let parsedPayload: EventPayloadTypes | null = null; if (payloadSize > 0) { payload = this._writeCommand(command, entirePayload, payloadSize); parsedPayload = parseMessage(command, payload); @@ -168,9 +169,6 @@ export class SlpStream extends Writable { // Stop parsing data until we manually restart the stream if (this.settings.mode === SlpStreamMode.MANUAL) { this.gameEnded = true; - } else { - // We're in auto-mode so reset the payload sizes for the next game - this.payloadSizes = null; } break; } diff --git a/yarn.lock b/yarn.lock index 71199fdb..0f567e07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1280,6 +1280,11 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" +enet@^0.2.9: + version "0.2.9" + resolved "https://registry.yarnpkg.com/enet/-/enet-0.2.9.tgz#d7fd68a0bf8bb3891408ea465e26ed6955822a1a" + integrity sha1-1/1ooL+Ls4kUCOpGXibtaVWCKho= + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"