const EventEmitter = require("events").EventEmitter; const nodesu = require("nodesu"); const BanchoMods = require("./Enums/BanchoMods"); const BanchoLobbyPlayer = require("./BanchoLobbyPlayer"); const BanchoLobbyPlayerScore = require("./BanchoLobbyPlayerScore"); const BanchoLobbyPlayerStates = require("./Enums/BanchoLobbyPlayerStates"); const BanchoLobbyTeams = require("./Enums/BanchoLobbyTeams"); const Regexes = require("./BanchoLobbyRegexes"); const Teams = require("./Enums/BanchoLobbyTeams"); const BanchoLobbyTeamModes = require("./Enums/BanchoLobbyTeamModes"); /** * Represents a Bancho multiplayer lobby * * Highly recommended to await updateSettings before manipulating (else some properties will be null). * * @prop {BanchoMultiplayerChannel} channel * @prop {number} id Multiplayer lobby ID (used in multiplayer history links) * @prop {string} name Name of the lobby, as seen in-game * @prop {Array<BanchoLobbyPlayer>} slots Array of BanchoLobbyPlayer determining each users' slot, from 0 to 15 * @prop {number} size Current size of the lobby * @prop {nodesu.ModeType} gamemode Current gamemode of the lobby, as specified by the latest !mp map call (Bancho limitation) * @prop {number} beatmapId * @prop {nodesu.Beatmap} beatmap Beatmap fetched from the API (late/not as reliable, use beatmapId when possible) * @prop {number} winCondition See BanchoLobbyWinConditions * @prop {number} teamMode See BanchoLobbyTeamModes * @prop {Array<BanchoMod>} mods * @prop {boolean} freemod * @prop {boolean} playing Whether we're currently playing or not * @prop {Array<BanchoLobbyPlayerScore>} scores Scores set during the currently ongoing match, or the previous match. Emptied when a new match starts. Sorted by pass and score once match is finished. */ class BanchoLobby extends EventEmitter { constructor(channel) { super(); this.channel = channel; this.banchojs = this.channel.banchojs; this.id = Number(channel.name.substring("#mp_".length)); this.scores = []; this._name = ""; this._beatmapId = null; this._beatmap = null; this._teamMode = null; this._winCondition = null; this._mods = null; this._freemod = false; this._playing = false; this._slots = this._createSlotsArray(); this._size = 16; this._gamemode = nodesu.Mode.osu; this._allPlayersReadyOnUpdateSettings = true; // Players cache. Even when players leave, we wanna leave them cached here // for performance/API requests reasons. this.players = {}; // stored by names this.playersById = {}; this.slotsUpdatesQueue = []; this.playerCreationQueue = []; this.channel.on("JOIN", (member) => { if(member.user.isClient()) // We have just joined the channel this.updateSettings(); // Let's retrieve all the properties of the lobby to begin with. }); this.channel.on("message", (msg) => { if(msg.user.ircUsername.toLowerCase() == "banchobot") this.handleBanchoBotMessage(msg.message); }); this.updateSettingsPromise = null; } /** * Synchronous handling of operations on the room slots to avoid race conditions. * Slots management involves resolving usernames from BanchoBot's messages and can lead to race conditions. * @private * @param {function} func */ pushSlotsUpdateQueue(func) { this.slotsUpdatesQueue.push(func); if(this.slotsUpdatesQueue.length == 1) this.slotsUpdatesQueue[0](this.slotsUpdateCallback); // If there's no currently running function, execute the newest now } /** * Called by a function in the slots update queue when the next one can be called. * @private */ slotsUpdateCallback() { this.slotsUpdatesQueue.shift(); if(this.slotsUpdatesQueue[0]) this.slotsUpdatesQueue[0](this.slotsUpdateCallback); } /** * Synchronous handling of BanchoLobbyPlayer object creations. * As every first fetch of a BanchoLobbyPlayer object involves an API call and * we only want one BanchoLobbyPlayer at a time, we need to make creations of them async. * @private * @param {function} func */ pushPlayerCreationQueue(func) { this.playerCreationQueue.push(func); if(this.playerCreationQueue.length == 1) this.playerCreationQueue[0](this.playersCreationCallback); // If there's no currently running function, execute the newest now } /** * Called by a function in the players creation queue when the next one can be called. * @private */ playersCreationCallback() { this.playerCreationQueue.shift(); if(this.playerCreationQueue[0]) this.playerCreationQueue[0](this.playersCreationCallback); } /** * Find the regex corresponding to a BanchoBot's message and process them * @private * @param {string} str message from BanchoBot to parse */ handleBanchoBotMessage(str) { const regex = Regexes.findRegex(str); if(regex) { const ret = regex.ret; switch(regex.name) { case "roomName": case "refereeChangedName": this.name = ret.name; break; case "teamModeWinConditions": this.teamMode = ret.teamMode; this.winCondition = ret.winCondition; break; case "activeMods": this.mods = ret.mods; this.freemod = ret.freemod; break; case "playerChangedBeatmap": case "refereeChangedBeatmap": case "beatmapFromSettings": if(ret.id != this.beatmapId) { this.beatmapId = ret.id; this.beatmap = null; for(const player of this.slots) if(player != null) player.state = BanchoLobbyPlayerStates.NotReady; this.banchojs.osuApi.beatmaps.getByBeatmapId(this.beatmapId) .then((beatmap) => { if(beatmap[0]) { if(beatmap[0].id == this.beatmapId) this.beatmap = beatmap[0]; } else /** * Emitted when a selected map is not found on the osu! API. * @event BanchoLobby#beatmapNotFound * @type {number} */ this.emit("beatmapNotFound", this.beatmapId); }) .catch((err) => this.banchojs.emit("error", err)); } break; case "refereeChangedMode": this.gamemode = ret.mode; break; case "playerChangedTeam": this.getPlayerByName(ret.name).then((player) => { /** * @event BanchoLobby#playerChangedTeam * @type {object} * @prop {BanchoLobbyPlayer} player * @prop {string} team See BanchoLobbyTeams */ if(player.team == BanchoLobbyTeams[ret.team]) return; player.team = BanchoLobbyTeams[ret.team]; for(const player of this.slots) if(player != null && player.state == BanchoLobbyPlayerStates.Ready) player.state = BanchoLobbyPlayerStates.NotReady; this.emit("playerChangedTeam", { player: player, team: player.team }); }); break; case "playerChangingBeatmap": this.beatmapId = null; this.beatmap = null; break; case "refereeChangedMods": this.mods = ret.mods; this.freemod = ret.freemod; for(const player of this.slots) if(player != null && player.state == BanchoLobbyPlayerStates.Ready) player.state = BanchoLobbyPlayerStates.NotReady; break; case "playerJoined": this.pushSlotsUpdateQueue(() => { this.getPlayerByName(ret.username).then((player) => { /** * @event BanchoLobby#playerJoined * @type {object} * @prop {BanchoLobbyPlayer} player * @prop {number} slot Starting from 0 * @prop {string} team Blue or Red */ player.reset(); player.team = ret.team; this.slots[ret.slot - 1] = player; this.emit("playerJoined", { player: player, slot: (ret.slot - 1), team: ret.team }); this.emit("slots", this.slots); this.slotsUpdateCallback(); }, (err) => { this.banchojs.emit("error", err); this.slotsUpdateCallback(); }); }); break; case "playerMoved": this.pushSlotsUpdateQueue(() => { this.getPlayerByName(ret.username).then((player) => { /** * @event BanchoLobby#playerMoved * @type {object} * @prop {BanchoLobbyPlayer} player * @prop {number} slot Starting from 0 */ const oldSlot = this.getPlayerSlot(player); this.slots[ret.slot - 1] = player; if(oldSlot) { this.slots[oldSlot] = null; } this.emit("playerMoved", { player: player, slot: (ret.slot - 1) }); this.emit("slots", this.slots); this.slotsUpdateCallback(); }, (err) => { this.banchojs.emit("error", err); this.slotsUpdateCallback(); }); }); break; case "playerLeft": this.pushSlotsUpdateQueue(() => { this.getPlayerByName(ret.username).then((player) => { /** * @event BanchoLobby#playerLeft * @type {BanchoLobbyPlayer} */ const slot = this.getPlayerSlot(player); if(slot) { this.slots[slot] = null; } this._allPlayersReadyOnUpdateSettings = true; this.emit("playerLeft", player); if(player.isHost) { this.emit("hostCleared"); this.emit("host", null); } this.emit("slots", this.slots); this.slotsUpdateCallback(); }, (err) => { this.banchojs.emit("error", err); this.slotsUpdateCallback(); }); }); break; case "playerBecameTheHost": this.getPlayerByName(ret.username).then((player) => { /** * @event BanchoLobby#host * @type {BanchoLobbyPlayer} */ if(this.getHost() != null) this.getHost().isHost = false; player.isHost = true; this.emit("host", player); }, (err) => this.banchojs.emit("error", err)); break; case "hostCleared": const host = this.getHost(); if(host != null) host.isHost = false; /** * @event BanchoLobby#hostCleared */ this.emit("hostCleared"); this.emit("host", null); break; case "allPlayersReady": /** * @event BanchoLobby#allPlayersReady */ for(const player of this.slots) if(player != null) player.state = BanchoLobbyPlayerStates.Ready; this._allPlayersReadyOnUpdateSettings = false; this.emit("allPlayersReady"); break; case "matchStarted": /** * @event BanchoLobby#matchStarted */ for(const player of this.slots) { if(player != null && player.state == BanchoLobbyPlayerStates.Ready) { player.state = BanchoLobbyPlayerStates.NotReady; player.score = null; } } this.scores.length = 0; this.playing = true; this.emit("matchStarted"); break; case "playerFinished": /** * @event BanchoLobby#playerFinished * @type {BanchoLobbyPlayerScore} */ this.getPlayerByName(ret.username).then((player) => { player.score = new BanchoLobbyPlayerScore(ret.score, ret.pass, player); this.scores.push(player.score); this.emit("playerFinished", player.score); }, (err) => this.banchojs.emit("error", err)); break; case "matchAborted": /** * @event BanchoLobby#matchAborted */ this.playing = false; this.emit("matchAborted"); break; case "matchFinished": /** * @event BanchoLobby#matchFinished * @type {Array.<BanchoLobbyPlayerScore>} Sorted scores array */ // Due to players resolving, matchFinished may be emitted and scores sorted before every playerFinished are done being processed. // For some reason, even though a Promise may not have anything async, the "then" functions may still be executed asynchronously... // However, they seem to be executed in order for some reason. So a workaround for this is to wait for the callback of a resolved Promise to be called. Promise.resolve().then(() => { // This ensures sorting/emitting is executed after all names are resolved. this.pushPlayerCreationQueue(() => { this.sortScores(); this.playing = false; this.emit(regex.name, this.scores); this.playersCreationCallback(); }); }); break; case "invalidBeatmapId": case "passwordRemoved": case "passwordChanged": case "userNotFound": case "slotsLocked": case "slotsUnlocked": /** * @event BanchoLobby#invalidBeatmapId */ /** * @event BanchoLobby#passwordRemoved */ /** * @event BanchoLobby#passwordChanged */ /** * @event BanchoLobby#userNotFound */ /** * @event BanchoLobby#slotsLocked */ /** * @event BanchoLobby#slotsUnlocked */ this.emit(regex.name); break; case "refereeAdded": case "refereeRemoved": case "userNotFoundUsername": /** * @event BanchoLobby#refereeAdded * @type {string} */ /** * @event BanchoLobby#refereeRemoved * @type {string} */ /** * @event BanchoLobby#userNotFoundUsername * @type {string} */ this.emit(regex.name, ret.username); break; case "matchSize": this.size = ret.size; break; case "matchSettings": /** * @event BanchoLobby#matchSettings * @type {object} * @prop {number} size * @prop {number} teamMode See BanchoLobbyTeamModes * @prop {number} winCondition See BanchoLobbyWinConditions */ this.teamMode = ret.teamMode; if(!isNaN(ret.winCondition)) this.winCondition = ret.winCondition; if(!isNaN(ret.size)) this.size = ret.size; for(const player of this.slots) if(player != null && player.state == BanchoLobbyPlayerStates.Ready) player.state = BanchoLobbyPlayerStates.NotReady; this.emit("matchSettings", ret); break; case "timerEnded": case "timerAborted": /** * Emitted when a timer ends * @event BanchoLobby#timerEnded */ /** * Emitted when a timer is aborted * @event BanchoLobby#timerAborted */ this.emit(regex.name); break; case "timerTick": /** * Emitted when a timer ticks * @event BanchoLobby#timerTick * @type {object} * @prop {number} seconds */ this.emit(regex.name, ret.seconds); break; case "startTimerStarted": /** * Emitted when a start timer is started * @event BanchoLobby#startTimerStarted */ this.emit(regex.name, ret.seconds); break; case "startTimerAborted": /** * Emitted when a start timer is aborted * @event BanchoLobby#startTimerAborted */ this.emit(regex.name); break; case "startTimerTick": /** * Emitted when a start timer ticks * @event BanchoLobby#startTimerTick * @type {object} * @prop {number} seconds */ this.emit(regex.name, ret.seconds); break; } } } /** * Fetch the lobby from the osu! API. * * @async * @returns Promise<nodesu.Multi> */ fetchFromAPI() { return this.banchojs.osuApi.multi.getMatch(this.id); } /** * Set a given map in the lobby * @param {number|nodesu.Beatmap} map Either a beatmap ID or a Beatmap object from nodesu * @param {number} [gamemode] See nodesu.Mode. Defaults to current mode (osu! if undetected). * @async */ setMap(map, gamemode = this.gamemode) { return new Promise((resolve, reject) => { if(map instanceof nodesu.Beatmap) map = map.id; else if(isNaN(map)) return reject(new Error("Not a valid number/beatmap")); this.channel.sendMessage("!mp map "+map+" "+gamemode+" "+this.randomString()).catch(reject); const validMapListener = () => { resolve(); this.removeListener("_beatmapId", validMapListener); this.removeListener("invalidBeatmapId", invalidMapListener); }; const invalidMapListener = () => { reject(new Error("Invalid map ID provided")); this.removeListener("_beatmapId", validMapListener); this.removeListener("invalidBeatmapId", invalidMapListener); }; this.on("_beatmapId", validMapListener); this.on("invalidBeatmapId", invalidMapListener); }); } /** * Set given mods in the lobby * @param {Array<BanchoMod>|string} mods Either an array of BanchoMods or a mods string joined by spaces * @param {boolean} freemod * @async */ setMods(mods, freemod = false) { return new Promise((resolve, reject) => { let modsString = "!mp mods "; if(typeof mods == "string") modsString += mods+" "; else if(Array.isArray(mods)) { let value = 0; for(const mod of mods) value += mod.enumValue; modsString += value + " "; } else return reject(new Error("mods need to be string or array of BanchoMods")); if(freemod) modsString += "freemod "; modsString += this.randomString(); this.channel.sendMessage(modsString).catch(reject); const validModListener = () => { resolve(); this.removeListener("_mods", validModListener); }; this.on("_mods", validModListener); }); } /** * Sets the lobby's name * @param {string} name * @async */ setName(name) { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp name "+name).catch(reject); const listener = (newName) => { if(name == newName) { resolve(); this.removeListener("_name", listener); } }; this.on("_name", listener); }); } /** * Sets the lobby's password * @param {string} password * @async */ setPassword(password) { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp password "+password).catch(reject); const changedListener = () => { resolve(); this.removeListener("passwordChanged", changedListener); this.removeListener("passwordRemoved", removedListener); }; this.on("passwordChanged", changedListener); const removedListener = () => { resolve(); this.removeListener("passwordChanged", changedListener); this.removeListener("passwordRemoved", removedListener); }; this.on("passwordRemoved", removedListener); }); } /** * Adds referees to the lobby * @param {Array<string>|string} ref A string or array of strings of referee(s) to add, referenced by their usernames or #<userid> * @async */ addRef(ref) { return new Promise((resolve, reject) => { let refString; if(typeof ref == "string") refString = ref; else if(Array.isArray(ref)) refString = ref.join(", "); else return reject(new Error("a string or an array of string must be passed")); this.channel.sendMessage("!mp addref "+refString).then(resolve, reject); }); } /** * Removes referees from the lobby * @param {Array<string>|string} ref A string or array of strings of referee(s) to remove, referenced by their usernames or #<userid> */ removeRef(ref) { return new Promise((resolve, reject) => { let refString; if(typeof ref == "string") refString = ref; else if(Array.isArray(ref)) refString = ref.join(", "); else return reject(new Error("a string or an array of string must be passed")); this.channel.sendMessage("!mp removeref "+refString).then(resolve, reject); }); } /** * Locks the lobby's slots and teams * @async */ lockSlots() { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp lock "+this.randomString()).catch(reject); const listener = () => { resolve(); this.removeListener("slotsLocked", listener); }; this.on("slotsLocked", listener); }); } /** * Unlocks the lobby's slots and teams * @async */ unlockSlots() { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp unlock "+this.randomString()).catch(reject); const listener = () => { resolve(); this.removeListener("slotsUnlocked", listener); }; this.on("slotsUnlocked", listener); }); } /** * Set the amount of open slots in the lobby * @param {number} size * @async */ setSize(size) { return new Promise((resolve, reject) => { if(isNaN(size) || size < 1 || size > 16) return reject(new Error("how can the size not be a number between 1 and 16? try again.")); this.channel.sendMessage("!mp size "+size+" "+this.randomString()).catch(reject); const listener = (newSize) => { if(size == newSize) { resolve(); this.removeListener("_size", listener); } }; this.on("_size", listener); }); } /** * Sets the settings of the lobby * @param {number} [teamMode] See BanchoLobbyTeamModes * @param {number} [winCondition] See BanchoLobbyWinConditions * @param {number} [size] * @async */ setSettings(teamMode = this.teamMode, winCondition = this.winCondition, size) { return new Promise((resolve, reject) => { if(isNaN(teamMode) || isNaN(winCondition) || size != null && (isNaN(size) || size < 1 || size > 16)) return reject(new Error("one of the arguments isn't a valid number. try again")); const listener = (ret) => { if(teamMode == ret.teamMode && winCondition == ret.winCondition && size == ret.size) { resolve(); this.removeListener("matchSettings", listener); } }; this.on("matchSettings", listener); this.channel.sendMessage("!mp set "+teamMode+" "+winCondition+" "+(size != null ? size : "")+" "+this.randomString()).catch(reject); }); } /** * Moves a player from one slot to another * @param {BanchoLobbyPlayer} player * @param {number} slot starting from 0 * @async */ movePlayer(player, slot) { return new Promise((resolve, reject) => { if(!(player instanceof BanchoLobbyPlayer)) return reject(new Error("invalid player")); if(isNaN(slot) || slot < 0 || slot > 15) return reject(new Error("how can a slot not be a number between 0 and 15? try again.")); this.channel.sendMessage("!mp move #"+player.user.id+" "+(slot+1)).then(resolve, reject); }); } /** * Invites a player to the lobby * @param {string} player Referenced by their username or #<userid> * @async */ invitePlayer(player) { return new Promise((resolve, reject) => this.channel.sendMessage("!mp invite "+player).then(resolve, reject) ); } /** * Sets a player as the host of the lobby * @param {string} player Referenced by their username or #<userid> * @async */ setHost(player) { return new Promise((resolve, reject) => this.channel.sendMessage("!mp host "+player).then(resolve, reject) ); } /** * Kicks a player from the lobby * @param {string} player Referenced by their username or #<userid> * @async */ kickPlayer(player) { return new Promise((resolve, reject) => this.channel.sendMessage("!mp kick "+player).then(resolve, reject) ); } /** * Bans a player from the lobby * @param {string} player Referenced by their username or #<userid> * @async */ banPlayer(player) { return new Promise((resolve, reject) => this.channel.sendMessage("!mp ban "+player).then(resolve, reject) ); } /** * Get back the host from one's hand * @async */ clearHost() { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp clearhost "+this.randomString()).catch(reject); const listener = () => { resolve(); this.removeListener("hostCleared", listener); }; this.on("hostCleared", listener); }); } /** * Close the lobby * @async */ closeLobby() { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp close "+this.randomString()).catch(reject); const listener = (member) => { if(member.user.isClient()) { resolve(); this.channel.removeListener("PART", listener); } }; this.channel.on("PART", listener); }); } /** * Start the match * @param {number} [timeout] Timeout before the game starts. Defaults to 0 * @async */ startMatch(timeout = null) { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp start"+((timeout != null && Number(timeout) != 0) ? " " + Number(timeout) : "")+" "+this.randomString()) .then(() => { if(timeout > 0) resolve(); }) .catch(reject); if(timeout == null) { const listener = () => { resolve(); this.removeListener("matchStarted", listener); }; this.on("matchStarted", listener); } }); } /** * Start a timer * @param {number} timeout Timeout * @async */ startTimer(timeout) { return new Promise((resolve, reject) => { if(isNaN(timeout)) return reject(new Error("Not a number!")); this.channel.sendMessage("!mp timer "+timeout+" "+this.randomString()).then(resolve).catch(reject); }); } /** * Aborts an ongoing timer * @async */ abortTimer() { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp aborttimer "+this.randomString()).then(resolve).catch(reject); }); } /** * Abort the match * @async */ abortMatch() { return new Promise((resolve, reject) => { this.channel.sendMessage("!mp abort "+this.randomString()).catch(reject); const listener = () => { resolve(); this.removeListener("matchAborted", listener); }; this.on("matchAborted", listener); }); } /** * Change's one team * @param {BanchoLobbyPlayer} player * @param {string} team See BanchoLobbyTeams * @async */ changeTeam(player, team) { return new Promise((resolve, reject) => { if(team != Teams.Blue && team != Teams.Red) return reject(new Error("invalid team")); this.channel.sendMessage("!mp team #"+player.user.id+" "+team).then(resolve, reject); }); } /** * Fires !mp settings, updates properties and player slots * @async */ updateSettings() { if(this.updateSettingsPromise != null) return this.updateSettingsPromise; return this.updateSettingsPromise = new Promise((callerResolve, callerReject) => { this.channel.sendMessage("!mp settings "+this.randomString()); // Beginning of the !mp settings message will be handled by handleBanchoBotMessage; ending (player infos) will be handled in the following listener. let amountOfPlayers = null; const slots = this._createSlotsArray(); let initializeMods = true; const modsListener = () => initializeMods = false; this.on("mods", modsListener); const listener = (msg) => { if(msg.user.ircUsername.toLowerCase() == "banchobot") { if(amountOfPlayers == null) { const playersRegex = Regexes.regexes.playersAmount(msg.message); if(playersRegex) { amountOfPlayers = playersRegex.playersAmount; if(amountOfPlayers == 0) { this.pushSlotsUpdateQueue(() => { this.slots = slots; this.slotsUpdateCallback(); if(initializeMods) this.mods = []; this.updateSettingsPromise = null; this.removeListener("mods", modsListener); callerResolve(); }); this.channel.removeListener("message", listener); } } } else { const playerRegex = /^Slot (\d+) +(Not Ready|Ready|No Map) +https:\/\/osu\.ppy\.sh\/u\/(\d+) (.+)$/; if(playerRegex.test(msg.message)) { const match = playerRegex.exec(msg.message); this.getPlayerById(Number(match[3])).then((player) => { slots[Number(match[1])-1] = player; player.state = BanchoLobbyPlayerStates[match[2]]; player.isHost = false; player.team = null; player.mods = (this.freemod) ? [] : this.mods; let metadatasString = match[4].substring(player.user.username.length).trim(); if(metadatasString.length > 0) { metadatasString = metadatasString.substr(1, metadatasString.length - 2); for(let metadatasSplit of metadatasString.split("/")) { metadatasSplit = metadatasSplit.trim(); switch(metadatasSplit) { case "Host": player.isHost = true; break; case "Team Blue": player.team = BanchoLobbyTeams.Blue; break; case "Team Red": player.team = BanchoLobbyTeams.Red; break; default: player.mods = BanchoMods.parseLongMods(metadatasSplit); } } } let completedPlayers = 0; for(const slot of slots) { if(slot != null) completedPlayers++; } if(completedPlayers == amountOfPlayers) { this.pushSlotsUpdateQueue(() => { if (this._allPlayersReadyOnUpdateSettings && !slots.some((slot) => (slot && slot.state === BanchoLobbyPlayerStates.NotReady))) { this.emit("allPlayersReady"); this._allPlayersReadyOnUpdateSettings = false; } this.slots = slots; this.slotsUpdateCallback(); if(initializeMods) this.mods = []; this.updateSettingsPromise = null; this.removeListener("mods", modsListener); callerResolve(); }); this.channel.removeListener("message", listener); } }, (err) => { this.updateSettingsPromise = null; this.removeListener("mods", modsListener); callerReject(err); }); } } } }; this.channel.on("message", listener); }); } /** * Gets the player who is currently host * @returns {BanchoLobbyPlayer} */ getHost() { for(const player of this.slots) if(player != null && player.isHost) return player; } /** * Gets the slot of a player * @param {BanchoLobbyPlayer} player * @return {number} */ getPlayerSlot(player) { for(const slotNum in this.slots) if(this.slots[slotNum] == player) return slotNum; } /** * Gets or instanciate a player by its username * @param {string} name * @async */ getPlayerByName(name) { return new Promise((callerResolve, callerReject) => { if(this.players[name]) return callerResolve(this.players[name]); this.pushPlayerCreationQueue(() => { if(this.players[name]) { callerResolve(this.players[name]); this.playersCreationCallback(); } else { const user = this.banchojs.getUser(name); const player = new BanchoLobbyPlayer(this, user); const cb = () => { this.playersById[user.id] = player; this.players[user.username] = player; callerResolve(player); this.playersCreationCallback(); }; if(!user.username || !user.id) user.fetchFromAPI().then(cb).catch((err) => { callerReject(err); this.playersCreationCallback(); }); else cb(); } }); }); } /** * Gets or instanciate a player by its userid * @async */ getPlayerById(id) { return new Promise((callerResolve, callerReject) => { if(isNaN(id)) return callerReject(new Error("id needs to be a number!")); if(this.playersById[id]) return callerResolve(this.playersById[id]); this.pushPlayerCreationQueue(() => { if(this.playersById[id]) { callerResolve(this.playersById[id]); this.playersCreationCallback(); } else this.banchojs.getUserById(id) .then((user) => { const player = new BanchoLobbyPlayer(this, this.banchojs.getUser(user.username)); this.playersById[id] = player; this.players[user.username] = player; callerResolve(player); this.playersCreationCallback(); }); }); }); } /** * Gets the mp link or however you name it. * @returns {string} */ getHistoryUrl() { return "https://osu.ppy.sh/community/matches/"+this.id; } /** * Creates a slots array of 16 players * @private * @returns {Array<null>} */ _createSlotsArray() { const slots = []; for(let i = 0; i < 16; i++) slots[i] = null; return Object.seal(slots); } /** * Sort scores by pass and score. */ sortScores() { this.scores.sort((a, b) => { if(a.pass && !b.pass) return -1; else if(!a.pass && b.pass) return 1; else if(a.score > b.score) return -1; else if(b.score > a.score) return 1; else return 0; }); } get beatmap() { return this._beatmap; } /** * Fired when the beatmap property is updated from the API * @event BanchoLobby#beatmap * @type {nodesu.Beatmap} */ set beatmap(val) { this._setter("beatmap", val); } get name() { return this._name; } /** * Fired when the name of the lobby is updated * @event BanchoLobby#name * @type {string} */ set name(val) { this._setter("name", val); } get beatmapId() { return this._beatmapId; } /** * Fired when the beatmapId is updated * @event BanchoLobby#beatmapId * @type {number} */ set beatmapId(val) { this._setter("beatmapId", val); } get teamMode() { return this._teamMode; } /** * Fired when the team mode is updated * @event BanchoLobby#teamMode * @type {number} * @see BanchoLobbyTeamModes */ set teamMode(val) { const teamModes = [BanchoLobbyTeamModes.TeamVs, BanchoLobbyTeamModes.TagTeamVs]; if(!teamModes.includes(this.teamMode) && teamModes.includes(val)) { for(let i = 0; i < this.slots.length; i++) { const slot = this.slots[i]; if (slot !== null) { slot.team = i % 2 === 0 ? BanchoLobbyTeams.Blue : BanchoLobbyTeams.Red; } } } this._setter("teamMode", val); } get winCondition() { return this._winCondition; } /** * Fired when the win condition is updated * @event BanchoLobby#winCondition * @type {number} * @see BanchoLobbyWinConditions */ set winCondition(val) { this._setter("winCondition", val); } get mods() { return this._mods; } /** * Fired when the lobby's mods are updated * @event BanchoLobby#mods * @type {Array<BanchoMod>} */ set mods(val) { this._setter("mods", val); } get freemod() { return this._freemod; } /** * Fired when the lobby's freemod property is updated * @event BanchoLobby#freemod * @type {boolean} */ set freemod(val) { this._setter("freemod", val); } get playing() { return this._playing; } /** * Fired when the lobby starts or stops playing * @event BanchoLobby#playing * @type {Symbol} * @see {BanchoLobbyState} */ set playing(val) { this._setter("playing", val); } get slots() { return this._slots; } /** * Fired when the slots of the lobby are updated * @event BanchoLobby#slots * @type {Array<BanchoLobbyPlayer>} */ set slots(val) { this._setter("slots", val); } get size() { return this._size; } /** * Fired when the size of the lobby is updated * @event BanchoLobby#size * @type {number} */ set size(val) { this._setter("size", val); } get gamemode() { return this._gamemode; } /** * Fired when the gamemode of the lobby is updated * @event BanchoLobby#gamemode * @type {nodesu.Mode} */ set gamemode(val) { this._setter("gamemode", val); } /** * Setter for all properties that can fire events * @param {string} prop Property's name * @param {any} val New property * @private */ _setter(prop, val) { if(this[prop] != val) { this["_"+prop] = val; this.emit(prop, val); } // Private event, emitted even when no update. Necessary for some Promises to resolve. this.emit("_"+prop, val); } /** * Generates a random string. Used with generic commands with no args to work around Bancho's anti-spam. * @private */ randomString() { let str = "0"; while(!isNaN(str)) str = Math.random().toString(36).slice(2); return str; } } module.exports = BanchoLobby;