diff --git a/mapvote.js b/mapvote.js index 7bfd142..46d4684 100644 --- a/mapvote.js +++ b/mapvote.js @@ -1,26 +1,13 @@ //Plugin reworked by JetDave, original version by MaskedMonkeyMan -import BasePlugin from "./base-plugin.js"; +// import BasePlugin from "./base-plugin.js"; import DiscordBasePlugin from './discord-base-plugin.js'; import fs from "fs"; import { Layers } from "../layers/index.js" import axios from "axios" -function randomElement(array) { - return array[ Math.floor(Math.random() * array.length) ]; -} - -function formatChoice(choiceIndex, mapString, currentVotes, firstBroadcast) { - return `${choiceIndex}➤ ${mapString} ` + (!firstBroadcast ? `(${currentVotes})` : ""); - // return `${choiceIndex + 1}❱ ${mapString} (${currentVotes} votes)` -} - -function toMils(min) { - return min * 60 * 1000; -} - -export default class MapVote extends BasePlugin { +export default class MapVote extends DiscordBasePlugin { static get description() { return "Map Voting plugin"; } @@ -31,6 +18,7 @@ export default class MapVote extends BasePlugin { static get optionsSpecification() { return { + ...DiscordBasePlugin.optionsSpecification, commandPrefix: { required: false, @@ -94,14 +82,18 @@ export default class MapVote extends BasePlugin { voteBroadcastMessage: { required: false, description: 'Message that is sent as broadcast to announce a vote', - default: "✯ MAPVOTE ✯ Vote for the next map by writing in chat the corresponding number!" + default: "✯ MAPVOTE ✯\nVote for the next map by writing in chat the corresponding number!" }, logToDiscord: { required: false, description: 'Enables/disables vote logging to Discord', default: false }, - ...DiscordBasePlugin.optionsSpecification, + discordClient: { + required: false, + description: "", + default: "discord" + }, channelID: { required: false, description: 'The ID of the channel to log votes to.', @@ -135,6 +127,7 @@ export default class MapVote extends BasePlugin { this.broadcastNominations = this.broadcastNominations.bind(this); this.beginVoting = this.beginVoting.bind(this); this.setSeedingMode = this.setSeedingMode.bind(this); + // this.logVoteToDiscord = this.logVoteToDiscord.bind(this); this.broadcast = (msg) => { this.server.rcon.broadcast(msg); }; this.warn = (steamid, msg) => { this.server.rcon.warn(steamid, msg); }; @@ -603,9 +596,9 @@ export default class MapVote extends BasePlugin { } if (this.nominations[ 0 ]) nominationStrings.push(formatChoice(0, this.nominations[ 0 ], this.tallies[ 0 ], (this.options.hideVotesCount || this.firstBroadcast))) await this.broadcast(nominationStrings.join("\n")); - + if (this.firstBroadcast) - this.logVoteToDiscord(nominationStrings.join("\n")) + await this.logVoteToDiscord(nominationStrings.join("\n")) this.firstBroadcast = false; } //const winners = this.currentWinners; @@ -647,6 +640,22 @@ export default class MapVote extends BasePlugin { // await this.msgDirect(steamID, `${this.tallies[ nominationIndex ]} votes`); } + // async logVoteToDiscord(message) { + // if (!this.options.logToDiscord) return + // await this.sendDiscordMessage({ + // embed: { + // title: 'Vote Started', + // color: 16761867, + // fields: [ + // { + // name: 'Options:', + // value: `${message}` + // } + // ] + // } + // }); + // } + //removes a players vote if they disconnect from the sever clearVote() { const currentPlayers = this.server.players.map((p) => p.steamID); @@ -679,20 +688,17 @@ export default class MapVote extends BasePlugin { return ties.map(i => this.nominations[ i ]); } - - async logVoteToDiscord(message) { - if (!this.options.logToDiscord) return - await this.sendDiscordMessage({ - embed: { - title: 'Vote Started', - color: 16761867, - fields: [ - { - name: 'Options:', - value: `${message}` - } - ] - } - }); - } } + +function randomElement(array) { + return array[ Math.floor(Math.random() * array.length) ]; +} + +function formatChoice(choiceIndex, mapString, currentVotes, firstBroadcast) { + return `${choiceIndex}➤ ${mapString} ` + (!firstBroadcast ? `(${currentVotes})` : ""); + // return `${choiceIndex + 1}❱ ${mapString} (${currentVotes} votes)` +} + +function toMils(min) { + return min * 60 * 1000; +} \ No newline at end of file diff --git a/mapvote.js.bak b/mapvote.js.bak new file mode 100644 index 0000000..d70742c --- /dev/null +++ b/mapvote.js.bak @@ -0,0 +1,662 @@ +//Plugin reworked by JetDave, original version by MaskedMonkeyMan + +import BasePlugin from "./base-plugin.js"; + +import fs from "fs"; +import { Layers } from "../layers/index.js" +import axios from "axios" + +function randomElement(array) { + return array[ Math.floor(Math.random() * array.length) ]; +} + +function formatChoice(choiceIndex, mapString, currentVotes, firstBroadcast) { + return `${choiceIndex}➤ ${mapString} ` + (!firstBroadcast ? `(${currentVotes})` : ""); + // return `${choiceIndex + 1}❱ ${mapString} (${currentVotes} votes)` +} + +function toMils(min) { + return min * 60 * 1000; +} + +export default class MapVote extends BasePlugin { + static get description() { + return "Map Voting plugin"; + } + + static get defaultEnabled() { + return true; + } + + static get optionsSpecification() { + return { + commandPrefix: + { + required: false, + description: "command name to use in chat", + default: "!vote" + }, + automaticVoteStart: { + required: false, + description: "a map vote will automatically start after a new match if set to true", + default: true + }, + minPlayersForVote: + { + required: false, + description: 'number of players needed on the server for a vote to start', + default: 40 + }, + voteWaitTimeFromMatchStart: + { + required: false, + description: 'time in mins from the start of a round to the start of a new map vote', + default: 15 + }, + voteBroadcastInterval: + { + required: false, + description: 'broadcast interval for vote notification in mins', + default: 7 + }, + automaticSeedingMode: + { + required: false, + description: 'set a seeding layer if server has less than 20 players', + default: true + }, + numberRecentMapsToExlude: { + required: false, + description: 'random layer list will not include the n. recent maps', + default: 4 + }, + gamemodeWhitelist: { + required: false, + description: 'random layer list will be generated with only selected gamemodes', + default: [ "AAS", "RAAS", "INVASION" ] + }, + layerLevelBlacklist: { + required: false, + description: 'random layer list will not include the blacklisted layers or levels. (acceptable formats: Gorodok/Gorodok_RAAS/Gorodok_AAS_v1)', + default: [] + }, + hideVotesCount: { + required: false, + description: 'hides the number of votes a layer received in broadcast message', + default: false + }, + showRerollOption: { + required: false, + description: 'vote option to restart the vote with random entries', + default: false + } + }; + } + + constructor(server, options, connectors) { + super(server, options, connectors); + + this.voteRules = {}; //data object holding vote configs + this.nominations = []; //layer strings for the current vote choices + this.trackedVotes = {}; //player votes, keyed by steam id + this.tallies = []; //votes per layer, parellel with nominations + this.votingEnabled = false; + this.onConnectBound = false; + this.broadcastIntervalTask = null; + this.firstBroadcast = true; + this.newVoteTimeout = null; + this.newVoteOptions = { + steamid: null, + cmdLayers: [], + bypassRaasFilter: false + }; + + this.onNewGame = this.onNewGame.bind(this); + this.onPlayerDisconnected = this.onPlayerDisconnected.bind(this); + this.onChatMessage = this.onChatMessage.bind(this); + this.broadcastNominations = this.broadcastNominations.bind(this); + this.beginVoting = this.beginVoting.bind(this); + this.setSeedingMode = this.setSeedingMode.bind(this); + + this.broadcast = (msg) => { this.server.rcon.broadcast(msg); }; + this.warn = (steamid, msg) => { this.server.rcon.warn(steamid, msg); }; + } + + async mount() { + this.options.gamemodeWhitelist.forEach((e, k, a) => a[ k ] = e.toUpperCase()); + this.server.on('NEW_GAME', this.onNewGame); + this.server.on('CHAT_MESSAGE', this.onChatMessage); + this.server.on('PLAYER_DISCONNECTED', this.onPlayerDisconnected); + this.server.on('PLAYER_CONNECTED', this.setSeedingMode); + this.verbose(1, 'Map vote was mounted.'); + this.verbose(1, "Blacklisted Layers/Levels: " + this.options.layerLevelBlacklist.join(', ')) + // await this.checkUpdates(); + // console.log("mapvote removeEventListener", this.server) + } + + async unmount() { + this.server.removeEventListener('NEW_GAME', this.onNewGame); + this.server.removeEventListener('CHAT_MESSAGE', this.onChatMessage); + this.server.removeEventListener('PLAYER_DISCONNECTED', this.onPlayerDisconnected); + clearInterval(this.broadcastIntervalTask); + this.verbose(1, 'Map vote was un-mounted.'); + } + + async onNewGame() { + setTimeout(async () => { + this.endVoting(); + this.trackedVotes = {}; + this.tallies = []; + this.nominations = []; + this.factionStrings = []; + if (this.options.automaticVoteStart) setTimeout(this.beginVoting, toMils(this.options.voteWaitTimeFromMatchStart)); + setTimeout(() => this.setSeedingMode(true), 10000); + }, 10000) + } + + async onPlayerDisconnected() { + if (!this.votingEnabled) return; + await this.server.updatePlayerList(); + this.clearVote(); + this.updateNextMap(); + } + async checkUpdates(callback) { + const versionN = "1.0.0" + let releasesUrl = "https://api.github.com/repos/fantinodavide/squad-js-map-vote/releases"; + let curDate = new Date(); + console.log("Current version: ", versionN, "\n > Checking for updates", curDate.toLocaleString()); + axios + .get(releasesUrl) + .then(res => { + const gitResData = res.data[ 0 ]; + const checkV = gitResData.tag_name.toUpperCase().replace("V", "").split("."); + const versionSplit = versionN.toString().split("."); + + const config_authorized_update = true;//((config.other.install_beta_versions && gitResData.prerelease) || !gitResData.prerelease); + const major_version_update = (parseInt(versionSplit[ 0 ]) < parseInt(checkV[ 0 ])); + const minor_version_update = (parseInt(versionSplit[ 0 ]) <= parseInt(checkV[ 0 ]) && parseInt(versionSplit[ 1 ]) < parseInt(checkV[ 1 ])); + const patch_version_update = (parseInt(versionSplit[ 0 ]) <= parseInt(checkV[ 0 ]) && parseInt(versionSplit[ 1 ]) <= parseInt(checkV[ 1 ]) && parseInt(versionSplit[ 2 ]) < parseInt(checkV[ 2 ])); + + if (config_authorized_update && (major_version_update || minor_version_update || patch_version_update)) { + console.log(" > Update found: " + gitResData.tag_name, gitResData.name); + //if (updateFoundCallback) updateFoundCallback(); + // server.close(); + if (downloadInstallUpdate) downloadLatestUpdate(gitResData); + else if (callback) callback(); + } else { + console.log(" > No updates found"); + if (callback) callback(); + } + }) + .catch(err => { + console.error(" > Couldn't check for updates. Proceding startup", err); + if (callback) callback(); + }) + } + downloadLatestUpdate(gitResData) { + // const url = gitResData.zipball_url; + const url = gitResData.zipball_url; + console.log(" > Downloading update: " + gitResData.tag_name, gitResData.name, url); + const dwnDir = path.resolve(__dirname, 'tmp_update');//, 'gitupd.zip') + const dwnFullPath = path.resolve(dwnDir, 'gitupd.zip') + + if (!fs.existsSync(dwnDir)) fs.mkdirSync(dwnDir); + + const writer = fs.createWriteStream(dwnFullPath) + axios({ + method: "get", + url: url, + responseType: "stream" + }).then((response) => { + response.data.pipe(writer); + }); + + writer.on('finish', (res) => { + setTimeout(() => { + installLatestUpdate(dwnDir, dwnFullPath, gitResData); + }, 1000) + }) + writer.on('error', (err) => { + console.error(err); + }) + } + installLatestUpdate(dwnDir, dwnFullPath, gitResData) { + const zip = new StreamZip({ + file: dwnFullPath, + storeEntries: true, + skipEntryNameValidation: true + }); + zip.on('ready', () => { + fs.remove(__dirname + "/dist", () => { + zip.extract("release/", __dirname, (err, res) => { + zip.close(); + nrc.run('npm install'); + console.log(" > Extracted", res, "files"); + fs.remove(dwnDir, () => { + console.log(`${dwnDir} folder deleted`); + const restartTimeout = 5000; + console.log(" > Restart in", restartTimeout / 1000, "seconds"); + restartProcess(restartTimeout); + }) + }); + }) + + }); + } + + setSeedingMode(isNewGameEvent = false) { + // setTimeout(()=>{this.msgDirect('76561198419229279',"MV\ntest\ntest")},1000) + // this.msgBroadcast("[MapVote] Seeding mode active") + const baseDataExist = this && this.options && this.server && this.server.players; + if (baseDataExist) { + this.verbose(1, "Checking seeding mode"); + if (this.options.automaticSeedingMode) { + if (this.server.players.length >= 1 && this.server.players.length < 40) { + const seedingMaps = Layers.layers.filter((l) => l.layerid && l.gamemode.toUpperCase() == "SEED" && !this.options.layerLevelBlacklist.find((fl) => l.layerid.toLowerCase().startsWith(fl.toLowerCase()))) + + const rndMap = randomElement(seedingMaps); + if (this.server.currentLayer) { + if (this.server.currentLayer.gamemode.toLowerCase() != "seed") { + if (this.server.players.length <= 5) { + const newCurrentMap = rndMap.layerid; + this.verbose(1, 'Going into seeding mode.'); + this.server.rcon.execute(`AdminChangeLayer ${newCurrentMap}`); + } + } + } else this.verbose(1, "Bad data (currentLayer). Seeding mode for current layer skipped to prevent errors."); + + if (this.server.nextLayer) { + const nextMaps = seedingMaps.filter((l) => (!this.server.currentLayer || l.layerid != this.server.currentLayer.layerid)) + let rndMap2; + do rndMap2 = randomElement(nextMaps); + while (rndMap2.layerid == rndMap.layerid) + + if (isNewGameEvent && this.server.players.length < 20 && this.server.nextLayer.gamemode.toLowerCase() != "seed") { + const newNextMap = rndMap2.layerid; + this.server.rcon.execute(`AdminSetNextLayer ${newNextMap}`); + } + } else this.verbose(1, "Bad data (nextLayer). Seeding mode for next layer skipped to prevent errors."); + + } else this.verbose(1, `Player count doesn't allow seeding mode (${this.server.players.length}/20)`); + } else this.verbose(1, "Seeding mode disabled in config"); + } else console.log("[MapVote][1] Bad data (this/this.server/this.options). Seeding mode skipped to prevent errors."); + } + + async onChatMessage(info) { + const { steamID, name: playerName } = info; + const message = info.message.toLowerCase(); + //check to see if this message has a command prefix + if (!message.startsWith(this.options.commandPrefix) && isNaN(message)) + return; + + const commandSplit = (isNaN(message) ? message.substring(this.options.commandPrefix.length).trim().split(' ') : [ message ]); + let cmdLayers = commandSplit.slice(1); + for (let k in cmdLayers) cmdLayers[ k ] = cmdLayers[ k ].toLowerCase(); + const subCommand = commandSplit[ 0 ]; + if (!isNaN(subCommand)) // if this succeeds player is voting for a map + { + const mapNumber = parseInt(subCommand); //try to get a vote number + if (this.nominations[ mapNumber ]) { + if (!this.votingEnabled) { + await this.warn(steamID, "There is no vote running right now"); + return; + } + await this.registerVote(steamID, mapNumber, playerName); + this.updateNextMap(); + } else + await this.warn(steamID, "Please vote a valid option"); + return; + } + + const isAdmin = info.chat === "ChatAdmin"; + switch (subCommand) // select the sub command + { + case "choices": //sends choices to player in the from of a warning + case "results": //sends player the results in a warning + if (!this.votingEnabled) { + await this.warn(steamID, "There is no vote running right now"); + return; + } + this.directMsgNominations(steamID); + return; + case "start": //starts the vote again if it was canceled + if (!isAdmin) return; + + if (this.votingEnabled) { + await this.warn(steamID, "Voting is already enabled"); + return; + } + this.beginVoting(true, steamID, cmdLayers); + return; + case "restart": //starts the vote again if it was canceled + if (!isAdmin) return; + this.endVoting(); + this.beginVoting(true, steamID, cmdLayers); + return; + case "cancel": //cancels the current vote and wont set next map to current winnner + if (!isAdmin) return; + + if (!this.votingEnabled) { + await this.warn(steamID, "There is no vote running right now"); + return; + } + this.endVoting(); + await this.warn(steamID, "Ending current vote"); + return; + case "broadcast": + if (!this.votingEnabled) { + await this.warn(steamID, "There is no vote running right now"); + return; + } + this.broadcastNominations(); + return; + case "help": //displays available commands + let msg = ""; + msg += (`!vote\n > choices\n > results\n`); + if (isAdmin) msg += (`\n Admin only:\n > start\n > restart\n > cancel\n > broadcast`); + + await this.warn(steamID, msg + `\nMapVote SquadJS plugin built by JetDave`); + return; + default: + //give them an error + await this.warn(steamID, `Unknown vote subcommand: ${subCommand}`); + return; + } + + } + + updateNextMap() //sets next map to current mapvote winner, if there is a tie will pick at random + { + let cpyWinners = this.currentWinners; + let skipSetNextMap = false; + if (cpyWinners.find(e => e == this.nominations[ 0 ])) { + if (cpyWinners.length > 1) { + delete cpyWinners[ cpyWinners.indexOf(this.nominations[ 0 ]) ] + cpyWinners = cpyWinners.filter(e => e != null) + } + else { + skipSetNextMap = true; + if (this.newVoteTimeout == null) { + this.newVoteTimeout = setTimeout(() => { + if (this.currentWinners.find(e => e == this.nominations[ 0 ]) && this.currentWinners.length == 1) { + this.newVoteTimeout = null; + this.endVoting() + this.beginVoting(true, this.newVoteOptions.steamid, this.newVoteOptions.cmdLayers) + } + }, 2 * 60 * 1000) + setTimeout(this.broadcastNominations, 1 * 60 * 1000) + } + } + } + if (!skipSetNextMap) { + const nextMap = randomElement(cpyWinners); + this.server.rcon.execute(`AdminSetNextLayer ${nextMap}`); + } + } + + matchLayers(builtString) { + return Layers.layers.filter(element => element.layerid.includes(builtString)); + } + + getMode(nomination, currentMode) { + const mapName = nomination.map; + let modes = nomination.modes; + let mode = modes[ 0 ]; + + if (mode === "Any") + modes = this.voteRules.modes; + + if (this.voteRules.mode_repeat_blacklist.includes(currentMode)) { + modes = modes.filter(mode => !mode.includes(currentMode)); + } + + while (modes.length > 0) { + mode = randomElement(modes); + modes = modes.filter(elem => elem !== mode); + if (this.matchLayers(`${mapName}_${mode}`).length > 0) + break; + } + + return mode; + } + + //TODO: right now if version is set to "Any" no caf layers will be selected + populateNominations(steamid = null, cmdLayers = [], bypassRaasFilter = false) //gets nomination strings from layer options + { + // this.nominations.push(builtLayerString); + // this.tallies.push(0); + + const translations = { + 'United States Army': "USA", + 'United States Marine Corps': "USMC", + 'Russian Ground Forces': "RUS", + 'British Army': "GB", + 'Canadian Army': "CAF", + 'Australian Defence Force': "AUS", + 'Irregular Militia Forces': "IRR", + 'Middle Eastern Alliance': "MEA", + 'Insurgent Forces': "INS", + } + + this.nominations = []; + this.tallies = []; + this.factionStrings = []; + let rnd_layers = []; + // let rnd_layers = []; + const sanitizedLayers = Layers.layers.filter((l) => l.layerid); + if (!cmdLayers || cmdLayers.length == 0) { + + const recentlyPlayedMaps = this.objArrToValArr(this.server.layerHistory.splice(0, this.options.numberRecentMapsToExlude), "layer", "map", "name"); + this.verbose(1, "Recently played maps: " + recentlyPlayedMaps.join(', ')) + const all_layers = sanitizedLayers.filter((l) => l.layerid && l.map && this.options.gamemodeWhitelist.includes(l.gamemode.toUpperCase()) && (![ this.server.currentLayer ? this.server.currentLayer.map.name : null, ...recentlyPlayedMaps ].includes(l.map.name)) && !this.options.layerLevelBlacklist.find((fl) => l.layerid.toLowerCase().startsWith(fl.toLowerCase()))); + for (let i = 1; i <= 6; i++) { + let l, maxtries = 10; + do l = randomElement(all_layers); while (rnd_layers.find(lf => lf.layerid == l.layerid) && --maxtries == 0) + if (maxtries > 0) { + rnd_layers.push(l); + this.nominations[ i ] = l.layerid + this.tallies[ i ] = 0; + this.factionStrings[ i ] = getTranslation(l.teams[ 0 ]) + "-" + getTranslation(l.teams[ 1 ]); + } + } + if (!bypassRaasFilter && rnd_layers.filter((l) => l.gamemode === 'RAAS' && this.options.gamemodeWhitelist.includes("RAAS")).length < 3) this.populateNominations(); + } else { + const maxOptions = this.options.showRerollOption ? 5 : 6; + let singleGamemodeVote = false; + if (cmdLayers.length == 1 && cmdLayers[ 0 ].split('_')[ 0 ] == "*") { + singleGamemodeVote = true; + for (let i = 0; i < maxOptions; i++) cmdLayers.push(cmdLayers[ 0 ]) + } + if (singleGamemodeVote || cmdLayers.length <= maxOptions) { + let i = 1; + for (let cl of cmdLayers) { + const cls = cl.split('_'); + const fLayers = sanitizedLayers.filter((l) => ((cls[ 0 ] == "*" || l.layerid.toLowerCase().startsWith(cls[ 0 ])) && (l.gamemode.toLowerCase().startsWith(cls[ 1 ]) || (!cls[ 1 ] && [ 'RAAS', 'AAS', 'INVASION' ].includes(l.gamemode.toUpperCase()))) && (!cls[ 2 ] || l.version.toLowerCase().startsWith("v" + cls[ 2 ].replace(/v/gi, ''))))); + let l; + do l = randomElement(fLayers); while (rnd_layers.includes(l)) + if (l) { + rnd_layers.push(l); + this.nominations[ i ] = l.layerid + this.tallies[ i ] = 0; + this.factionStrings[ i ] = getTranslation(l.teams[ 0 ]) + "-" + getTranslation(l.teams[ 1 ]); + i++; + } + } + } + else if (steamid) { + this.warn(steamid, "You cannot start a vote with more than " + maxOptions + " options"); + return; + } + } + + if (this.options.showRerollOption) { + if (this.nominations.length > 5) { + this.nominations.splice(6, 1); + this.tallies.splice(6, 1); + this.factionStrings.splice(6, 1); + } + + this.newVoteOptions.steamid = steamid; + this.newVoteOptions.bypassRaasFilter = bypassRaasFilter; + this.newVoteOptions.cmdLayers = cmdLayers; + + this.nominations[ 0 ] = "Reroll vote list with random options" + this.tallies[ 0 ] = 0; + this.factionStrings[ 0 ] = ""; + + } + + function getTranslation(t) { + if (translations[ t.faction ]) return translations[ t.faction ] + else { + const f = t.faction.split(' '); + let fTag = ""; + f.forEach((e) => { fTag += e[ 0 ] }); + return fTag.toUpperCase(); + } + } + } + + //checks if there are enough players to start voting, if not binds itself to player connected + //when there are enough players it clears old votes, sets up new nominations, and starts broadcast + beginVoting(force = false, steamid = null, cmdLayers = []) { + this.verbose(1, "Starting vote") + const playerCount = this.server.players.length; + const minPlayers = this.options.minPlayersForVote; + + if (this.votingEnabled) //voting has already started + return; + + if (playerCount < minPlayers && !force) { + if (this.onConnectBound == false) { + this.server.on("PLAYER_CONNECTED", this.beginVoting) + this.onConnectBound = true; + } + return; + } + if (this.onConnectBound) { + + this.server.removeEventListener("PLAYER_CONNECTED", this.beginVoting); + this.onConnectBound = false; + } + + // these need to be reset after reenabling voting + this.trackedVotes = {}; + this.tallies = []; + + this.populateNominations(steamid, cmdLayers); + + this.votingEnabled = true; + this.firstBroadcast = true; + this.broadcastNominations(); + this.broadcastIntervalTask = setInterval(this.broadcastNominations, toMils(this.options.voteBroadcastInterval)); + } + + endVoting() { + this.votingEnabled = false; + clearInterval(this.broadcastIntervalTask); + clearTimeout(this.newVoteTimeout); + this.newVoteTimeout = null; + this.broadcastIntervalTask = null; + } + objArrToValArr(arr, ...key) { + let vet = []; + for (let o of arr) { + let obj = o; + for (let k of key) { + if (obj[ k ]) + obj = obj[ k ]; + } + vet.push(obj); + } + return vet; + } + //sends a message about nominations through a broadcast + //NOTE: max squad broadcast message length appears to be 485 characters + //Note: broadcast strings with multi lines are very strange + async broadcastNominations() { + if (this.nominations.length > 0 && this.votingEnabled) { + await this.broadcast("✯ MAPVOTE ✯ Vote for the next map by writing in chat the corresponding number!\n"); + let nominationStrings = []; + for (let choice = 1; choice < this.nominations.length; choice++) { + choice = Number(choice); + let vLayer = Layers.layers.find(e => e.layerid == this.nominations[ choice ]); + nominationStrings.push(formatChoice(choice, vLayer.map.name + ' ' + vLayer.gamemode + ' ' + this.factionStrings[ choice ], this.tallies[ choice ], (this.options.hideVotesCount || this.firstBroadcast))); + } + if (this.nominations[ 0 ]) nominationStrings.push(formatChoice(0, this.nominations[ 0 ], this.tallies[ 0 ], (this.options.hideVotesCount || this.firstBroadcast))) + await this.broadcast(nominationStrings.join("\n")); + + this.firstBroadcast = false; + } + //const winners = this.currentWinners; + //await this.msgBroadcast(`Current winner${winners.length > 1 ? "s" : ""}: ${winners.join(", ")}`); + } + + async directMsgNominations(steamID) { + let strMsg = ""; + for (let choice in this.nominations) { + choice = Number(choice); + // await this.msgDirect(steamID, formatChoice(choice, this.nominations[ choice ], this.tallies[ choice ])); + strMsg += (steamID, formatChoice(choice, this.nominations[ choice ], this.tallies[ choice ])) + "\n"; + } + strMsg.trim(); + if (steamID) this.warn(steamID, strMsg) + + // const winners = this.currentWinners; + // await this.msgDirect(steamID, `Current winner${winners.length > 1 ? "s" : ""}: ${winners.join(", ")}`); + } + + //counts a vote from a player and adds it to tallies + async registerVote(steamID, nominationIndex, playerName) { + // nominationIndex -= 1; // shift indices from display range + if (nominationIndex < 0 || nominationIndex > this.nominations.length) { + await this.warn(steamID, `[Map Vote] ${playerName}: invalid map number, typ !vote results to see map numbers`); + return; + } + + const previousVote = this.trackedVotes[ steamID ]; + this.trackedVotes[ steamID ] = nominationIndex; + + this.tallies[ nominationIndex ] += 1; + if (previousVote !== undefined) + this.tallies[ previousVote ] -= 1; + await this.warn(steamID, `Registered vote: ${this.nominations[ nominationIndex ].replace(/\_/gi, ' ').replace(/\sv\d{1,2}/gi, '')} ${this.factionStrings[ nominationIndex ]} ` + (this.options.hideVotesCount ? `` : `(${this.tallies[ nominationIndex ]} votes)`)); + // await this.msgDirect(steamID, `Registered vote`);// ${this.nominations[ nominationIndex ]} ${this.factionStrings[ nominationIndex ]} (${this.tallies[ nominationIndex ]} votes)`); + // await this.msgDirect(steamID, `${this.nominations[ nominationIndex ]} (${this.tallies[ nominationIndex ]} votes)`); + // await this.msgDirect(steamID, `${this.factionStrings[ nominationIndex ]}`); + // await this.msgDirect(steamID, `${this.tallies[ nominationIndex ]} votes`); + } + + //removes a players vote if they disconnect from the sever + clearVote() { + const currentPlayers = this.server.players.map((p) => p.steamID); + for (const steamID in this.trackedVotes) { + if (!(currentPlayers.includes(steamID))) { + const vote = this.trackedVotes[ steamID ]; + this.tallies[ vote ] -= 1; + delete this.trackedVotes[ steamID ]; + } + } + } + + //calculates the current winner(s) of the vote and returns thier strings in an array + get currentWinners() { + const ties = []; + + let highestScore = -Infinity; + for (let choice in this.tallies) { + const score = this.tallies[ choice ]; + if (score < highestScore) + continue; + else if (score > highestScore) { + highestScore = score; + ties.length = 0; + ties.push(choice); + } + else // equal + ties.push(choice); + } + + return ties.map(i => this.nominations[ i ]); + } +}