From 9754bc84165c6de2f2795cfadea98cb66c30a8a1 Mon Sep 17 00:00:00 2001 From: maskedmonkyman Date: Sat, 19 Mar 2022 16:11:24 -0700 Subject: [PATCH] initial commit --- config_tool.html | 352 ++++++++++++++++++++++++++++++++ mapvote.js | 442 ++++++++++++++++++++++++++++++++++++++++ vote_rules_example.json | 42 ++++ 3 files changed, 836 insertions(+) create mode 100644 config_tool.html create mode 100644 mapvote.js create mode 100644 vote_rules_example.json diff --git a/config_tool.html b/config_tool.html new file mode 100644 index 0000000..e20f512 --- /dev/null +++ b/config_tool.html @@ -0,0 +1,352 @@ + + + + + + + Map vote plugin configuration generator + + + +
+
+
+ + +
+
+

vote rules:

+ +
    +
    +
    +

    mode strings (case sensitive):

    +
      + + + +
      +
      +

      addon layer strings:

      + (not currently implemented) +
        + + + +
        +
        +
        +

        info:

        + tool by maskedmonkyman
        + version 1.0
        +

        warning:

        +

        + this tool is a thin wrapper around + the Squad JS pluggin config schemea. This tool + does NO INPUT VALIDATION and is just a graphical utility. + It is very possible to create broken configs using + this tool. Be sure to check your inputs. +

        + +

        notes:

        +
          +
        1. + be sure to always have a "defualt" vote rule. +
        2. +
        3. + the Any option for modes, will select modes from the + mode strings section so be sure to fill it out. +
        4. +
        +

        +
        +
        + + + \ No newline at end of file diff --git a/mapvote.js b/mapvote.js new file mode 100644 index 0000000..7519a19 --- /dev/null +++ b/mapvote.js @@ -0,0 +1,442 @@ +//Plugin by MaskedMonkeyMan + +import BasePlugin from "./base-plugin.js"; + +import fs from "fs"; +import { Layers } from "../layers/index.js" + +function randomElement(array) +{ + return array[Math.floor(Math.random() * array.length)]; +} + +function formatChoice(choiceIndex, mapString, currentVotes) +{ + return `type !vote ${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" + }, + voteRulesPath: + { + required: false, + description: 'the path to the layersConfig file', + default: '' + }, + minPlayersForVote: + { + required: false, + description: 'number of players needed on the server for a vote to start', + default: 50 + }, + voteWaitTimeFromMatchStart: + { + required: false, + description: 'time in mils from the start of a round to the start of a new map vote', + default: 20 + }, + voteBroadcastInterval: + { + required: false, + description: 'broadcast interval for vote notification in mils', + default: 15 + }, + }; + } + + 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.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.msgBroadcast = (msg) => {this.server.rcon.broadcast(msg);}; + this.msgDirect = (steamid, msg) => {this.server.rcon.warn(steamid, msg);}; + + //load voteRules with options from source file + this.loadLayersConfig(); + } + + async mount() + { + this.server.on('NEW_GAME', this.onNewGame); + this.server.on('CHAT_MESSAGE', this.onChatMessage); + this.server.on('PLAYER_DISCONNECTED', this.onPlayerDisconnected); + this.verbose(1, 'Map vote was mounted.'); + } + + 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.'); + } + + //loads layer configs from disk into plugin memory + loadLayersConfig() + { + this.verbose(1, `Fetching Map Voting Lists...`); + + let layersConfigString = ''; + try + { + if (!fs.existsSync(this.options.voteRulesPath)) + throw new Error(`Could not find Map Vote List at ${this.options.voteRulesPath}`); + layersConfigString = fs.readFileSync(this.options.voteRulesPath, 'utf8'); + } + catch (error) + { + this.verbose('SquadServer', 1, `Error fetching mapvoting list: ${options.voteRulesPath}`, error); + } + + this.voteRules = JSON.parse(layersConfigString); + } + + async onNewGame() + { + //wait to start voting + this.endVoting(); + this.trackedVotes = {}; + this.tallies = []; + this.nominations = []; + setTimeout(this.beginVoting, toMils(this.options.voteWaitTimeFromMatchStart)); + } + + async onPlayerDisconnected() + { + await this.server.updatePlayerList(); + this.clearVote(); + this.updateNextMap(); + } + + 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)) + return; + + const subCommand = message.substring(this.options.commandPrefix.length).trim(); + if(!isNaN(subCommand)) // if this succeeds player is voting for a map + { + const mapNumber = parseInt(subCommand); //try to get a vote number + if (!this.votingEnabled) + { + await this.msgDirect(steamID, "There is no vote running right now"); + return; + } + await this.registerVote(steamID, mapNumber, playerName); + this.updateNextMap(); + return; + } + + const isAdmin = info.chat === "ChatAdmin"; + switch(subCommand) // select the sub command + { + case "choices": //sends choices to player in the from of a warning + if (!this.votingEnabled) + { + await this.msgDirect(steamID, "There is no vote running right now"); + return; + } + this.directMsgNominations(steamID); + return; + case "results": //sends player the results in a warning + if (!this.votingEnabled) + { + await this.msgDirect(steamID, "There is no vote running right now"); + return; + } + this.directMsgNominations(steamID); + return; + case "restart": //starts the vote again if it was canceled + if(!isAdmin) return; + + if(this.votingEnabled) + { + await this.msgDirect(steamID, "Voting is already enabled"); + return; + } + this.beginVoting(true); + return; + case "cancel": //cancels the current vote and wont set next map to current winnner + if(!isAdmin) return; + + if(!this.votingEnabled) + { + await this.msgDirect(steamID, "Voting is already disabled"); + return; + } + this.endVoting(); + await this.msgDirect(steamID, "ending current vote"); + return; + case "reload": //allows for config hot reloads + if(!isAdmin) return; + + this.loadLayersConfig(); + await this.msgDirect(steamID, "Reloaded map vote layers configuration") + return; + case "help": //displays available commands + await this.msgDirect(steamID, `!vote `); + if(!isAdmin) return; + + await this.msgDirect(steamID, `!vote (admin only)`); + return; + default: + //give them an error + await this.msgDirect(steamID, `Unknown vote subcommand: ${subCommand}`); + return; + } + + } + + updateNextMap() //sets next map to current mapvote winner, if there is a tie will pick at random + { + const nextMap = randomElement(this.currentWinners); + this.server.rcon.execute(`AdminSetNextLayer ${nextMap}`); + } + + //TODO: right now if version is set to "Any" no caf layers will be selected + populateNominations() //gets nomination strings from layer options + { + if (!this.server.currentLayer) + { + this.verbose(1, "Error: unknown currentLayer"); + endVoting(); + return; + } + const splitName = name => name.substring(0, name.lastIndexOf("_")); + const removeCAF = name => name.replace("CAF_", ""); + + this.nominations = []; + const rulesList = this.voteRules.rules; + let layerString = this.server.currentLayer.layerid; + let nominationsList = rulesList.default; + + const matchLayers = builtString => Layers.layers.filter((element) => element.layerid.startsWith(builtString)); + + while(layerString.length > 0) + { + if(layerString in rulesList) + { + nominationsList = rulesList[layerString]; + break; + } + layerString = removeCAF(layerString); + layerString = splitName(layerString); + } + + for(const nomination of nominationsList) + { + const mapName = nomination.map; + let mode = randomElement(nomination.modes); + let version = randomElement(nomination.versions); + let cafPrefix = ""; + + if (version.includes("CAF_")) + { + cafPrefix = "CAF_"; + version = removeCAF(version); + } + + if (mode === "Any") + { + let modes = this.voteRules.modes; + while (modes.length > 0) + { + mode = randomElement(this.voteRules.modes); + modes = modes.filter((elem) => elem !== mode); + if (matchLayers(`${cafPrefix}${mapName}_${mode}`).length > 0) + break; + } + } + + let builtLayerString = `${cafPrefix}${mapName}_${mode}_${version}`; + if (version === "Any") + { + let versions = matchLayers(`${cafPrefix}${mapName}_${mode}`); + if (versions.length == 0) + { + this.verbose(1, `error: could not find layer for ${builtLayerString} from vote rule \"${layerString}\"`); + continue; + } + versions = versions.map(l => l.layerid); + version = randomElement(versions); + version = version.substring(version.lastIndexOf("_") + 1, version.length); + builtLayerString = `${cafPrefix}${mapName}_${mode}_${version}`; + } + + if (!Layers.getLayerByCondition((layer) => layer.layerid === builtLayerString)) + { + this.verbose(1, `error: could not find layer for ${builtLayerString} from vote rule \"${layerString}\"`); + continue; + } + this.nominations.push(builtLayerString); + this.tallies.push(0); + } + } + + //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) + { + const playerCount = this.server.players.length; + const minPlayers = this.options.minPlayersForVote; + 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(); + + this.votingEnabled = true; + this.broadcastNominations(); + this.broadcastIntervalTask = setInterval(this.broadcastNominations, toMils(this.options.voteBroadcastInterval)); + } + + endVoting() + { + this.votingEnabled = false; + clearInterval(this.broadcastIntervalTask); + this.broadcastIntervalTask = null; + } + + //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() + { + await this.msgBroadcast("Type !vote in chat to cast your vote, Candidates:\n"); + let nominationStrings = []; + for(let choice in this.nominations) + { + choice = Number(choice); + nominationStrings.push(formatChoice(choice, this.nominations[choice], this.tallies[choice])); + } + await this.msgBroadcast(nominationStrings.join("\n")); + //const winners = this.currentWinners; + //await this.msgBroadcast(`Current winner${winners.length > 1 ? "s" : ""}: ${winners.join(", ")}`); + } + + async directMsgNominations(steamID) + { + for(let choice in this.nominations) + { + choice = Number(choice); + await this.msgDirect(steamID, formatChoice(choice, this.nominations[choice], this.tallies[choice])); + } + + 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.msgDirect(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.msgDirect(steamID, `you voted for ${this.nominations[nominationIndex]}`); + } + + //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]); + } +} \ No newline at end of file diff --git a/vote_rules_example.json b/vote_rules_example.json new file mode 100644 index 0000000..4ed7f9a --- /dev/null +++ b/vote_rules_example.json @@ -0,0 +1,42 @@ +{ + "modes": + [ + "AAS", + "RAAS", + "Invasion" + ], + + "rules": + { + "Gorodok": + [ + {"map": "Yehorivka", "modes": ["Any"], "versions": ["Any"]}, + {"map": "Gorodok", "modes": ["Any"], "versions": ["Any"]} + ], + + "Gorodok_Invasion": + [ + {"map": "Yehorivka", "modes": ["Any"], "versions": ["Any"]} + ], + + "Yehorivka_RAAS_v1": + [ + {"map": "Gorodok", "modes": ["AAS"], "versions": ["Any"]} + ], + + "default": + [ + {"map": "Yehorivka", "modes": ["Any"], "versions": ["Any"]}, + {"map": "Gorodok", "modes": ["Any"], "versions": ["Any"]} + ] + }, + + "addon_map_layer_strings": + { + "example": + [ + "string1", + "string2" + ] + } +} \ No newline at end of file