|
2 | 2 | // Use of this source code is governed by the MIT license, a copy of which can |
3 | 3 | // be found in the LICENSE file. |
4 | 4 |
|
| 5 | +import { DeathmatchGame } from 'features/games_deathmatch/deathmatch_game.js'; |
5 | 6 | import { TeamResolver } from 'features/games_deathmatch/teams/team_resolver.js'; |
6 | 7 |
|
| 8 | +// Comperator function for sorting an array in descending order. |
| 9 | +function sortDescending(left, right) { |
| 10 | + if (left === right) |
| 11 | + return 0; |
| 12 | + |
| 13 | + return left > right ? -1 : 1; |
| 14 | +} |
| 15 | + |
7 | 16 | // Resolves teams in a balanced manner, i.e. looks at player statistics, experience, ranks them and |
8 | 17 | // then tries to divide the players in two groups in a fair manner. |
9 | 18 | export class BalancedTeamsResolver extends TeamResolver { |
| 19 | + #teamAlpha_ = new Set(); |
| 20 | + #teamAlphaScore_ = 0; |
| 21 | + |
| 22 | + #teamBravo_ = new Set(); |
| 23 | + #teamBravoScore_ = 0; |
| 24 | + |
| 25 | + // Called when the |player| has been removed from the game. Keep track of that |
| 26 | + onPlayerRemoved(player) { |
| 27 | + if (this.#teamAlpha_.has(player)) { |
| 28 | + this.#teamAlphaScore_ -= this.#teamAlpha_.get(player); |
| 29 | + this.#teamAlpha_.delete(player); |
| 30 | + } |
| 31 | + |
| 32 | + if (this.#teamBravo_.has(player)) { |
| 33 | + this.#teamBravoScore_ -= this.#teamBravo_.get(player); |
| 34 | + this.#teamBravo_.delete(player); |
| 35 | + } |
| 36 | + } |
| 37 | + |
| 38 | + // Resolves the intended teams for the given |players|, a sequence. We achieve this by ranking |
| 39 | + // all |players| based on their skill, and then even-odd dividing them into teams. |
| 40 | + resolve(players) { |
| 41 | + const scoresArray = [ ...this.computePlayerScores(players) ]; |
| 42 | + |
| 43 | + // (1) Sort the |scoresArray| in descending order based on the scores. That gives us the |
| 44 | + // rankings based on which we can divide the teams. |
| 45 | + scoresArray.sort((left, right) => { |
| 46 | + if (left[1] === right[1]) |
| 47 | + return 0; |
| 48 | + |
| 49 | + return left[1] > right[1] ? -1 : 1; |
| 50 | + }); |
| 51 | + |
| 52 | + // (2) Now even/odd divide each of the players. Begin by assigning the top player to team |
| 53 | + // Alpha, and then work our way down the list. A future improvement on this algorithm would |
| 54 | + // be to allow uneven participant counts from initial division. |
| 55 | + const teams = []; |
| 56 | + |
| 57 | + let currentTeam = DeathmatchGame.kTeamAlpha; |
| 58 | + for (const [ player, score ] of scoresArray) { |
| 59 | + teams.push({ player, team: currentTeam }); |
| 60 | + |
| 61 | + if (currentTeam === DeathmatchGame.kTeamAlpha) { |
| 62 | + this.#teamAlpha_.add(player); |
| 63 | + this.#teamAlphaScore_ += score; |
| 64 | + |
| 65 | + currentTeam = DeathmatchGame.kTeamBravo; |
| 66 | + |
| 67 | + } else { |
| 68 | + this.#teamBravo_.add(player); |
| 69 | + this.#teamBravoScore_ += score; |
| 70 | + |
| 71 | + currentTeam = DeathmatchGame.kTeamAlpha; |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + // (3) Return the |teams| per the API contract, and we're done. |
| 76 | + return teams; |
| 77 | + } |
| 78 | + |
| 79 | + // Resolves the intended team for the given |player|, who may have joined late. We will do a |
| 80 | + // recalculation of the team scores, and then add the |player| to the team who needs them most. |
| 81 | + resolveForPlayer(player) { |
| 82 | + this.#teamAlphaScore_ = 0; |
| 83 | + this.#teamBravoScore_ = 0; |
| 84 | + |
| 85 | + // (1) Recalculate the total scores of both teams based on the adjusted rankings. |
| 86 | + const scores = this.computePlayerScores([ player ]); |
| 87 | + for (const [ participant, score ] of scores) { |
| 88 | + if (this.#teamAlpha_.has(participant)) |
| 89 | + this.#teamAlphaScore_ += score; |
| 90 | + else if (this.#teamBravo_.has(participant)) |
| 91 | + this.#teamBravoScore_ += score; |
| 92 | + } |
| 93 | + |
| 94 | + // (2) We allow a difference in number of participants per team that equates 10% of the |
| 95 | + // total number of participants. That means that for 10 participants we allow 4/6, and for |
| 96 | + // 20 participants we allow 8/12, if the skill level allows. |
| 97 | + const allowableDifference = |
| 98 | + Math.floor((this.#teamAlpha_.size + this.#teamBravo_.size + 1) / 10); |
| 99 | + |
| 100 | + if (this.#teamAlpha_.size < (this.#teamBravo_.size - allowableDifference)) { |
| 101 | + this.#teamAlpha_.add(player); |
| 102 | + this.#teamAlphaScore_ += scores.get(player); |
| 103 | + |
| 104 | + return DeathmatchGame.kTeamAlpha; |
| 105 | + |
| 106 | + } else if (this.#teamBravo_.size < (this.#teamAlpha_.size - allowableDifference)) { |
| 107 | + this.#teamBravo_.add(player); |
| 108 | + this.#teamBravoScore_ += scores.get(player); |
| 109 | + |
| 110 | + return DeathmatchGame.kTeamBravo; |
| 111 | + } |
| 112 | + |
| 113 | + // (3) Otherwise, we assign the |player| to the team who needs them most. |
| 114 | + if (this.#teamAlphaScore_ > this.#teamBravoScore_) { |
| 115 | + this.#teamBravo_.add(player); |
| 116 | + this.#teamBravoScore_ += scores.get(player); |
| 117 | + |
| 118 | + return DeathmatchGame.kTeamBravo; |
| 119 | + |
| 120 | + } else { |
| 121 | + this.#teamAlpha_.add(player); |
| 122 | + this.#teamAlphaScore_ += scores.get(player); |
| 123 | + |
| 124 | + return DeathmatchGame.kTeamAlpha; |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + // Determines the score of the given |player|. We rank all participants, both current and new |
| 129 | + // ones in the given |players|, and assign each a score based. The score is based on their ranks |
| 130 | + // in number of kills (45%), damage ratio (20%), shot ratio (20%) and accuracy (15%). |
| 131 | + computePlayerScores(players) { |
| 132 | + const participants = [ ...this.#teamAlpha_.keys(), ...this.#teamBravo_.keys(), ...players ]; |
| 133 | + const statistics = new Map(); |
| 134 | + |
| 135 | + // (1) Store all dimensions for the |player| in the |statistics| map. A separate routine |
| 136 | + // will sort each of the individual metrics, and assign ranks to them. |
| 137 | + for (const player of participants) { |
| 138 | + const stats = player.stats.enduring; |
| 139 | + |
| 140 | + statistics.set(player, { |
| 141 | + kills: stats.killCount, |
| 142 | + damageRatio: stats.damageTaken > 0 ? stats.damageGiven / stats.damageTaken : 0, |
| 143 | + shotRatio: stats.shotsTaken > 0 ? stats.shotsHit / stats.shotsTaken : 0, |
| 144 | + accuracy: |
| 145 | + stats.shotsHit > 0 ? stats.shotsHit / (stats.shotsHit + stats.shotsMissed) : 0, |
| 146 | + }); |
| 147 | + } |
| 148 | + |
| 149 | + // (2) Determine the rankings of each player in each of those catagories. |
| 150 | + const rankings = this.determineRankings(statistics); |
| 151 | + const scores = new Map(); |
| 152 | + |
| 153 | + // (3) Populate the score map based on the rankings and the documented calculation. |
| 154 | + for (const player of participants) { |
| 155 | + const playerRankings = rankings.get(player); |
| 156 | + |
| 157 | + // Get the values to calculate with. Negate parity, as better rankings should give the |
| 158 | + // player a higher score. It keeps the rest of this system more readable. |
| 159 | + const killsValue = participants.length - playerRankings.kills; |
| 160 | + const damageRatioValue = participants.length - playerRankings.damageRatio; |
| 161 | + const shotRatioValue = participants.length - playerRankings.shotRatio; |
| 162 | + const accuracyValue = participants.length - playerRankings.accuracy; |
| 163 | + |
| 164 | + // Calculate the score for the player. |
| 165 | + scores.set( |
| 166 | + player, |
| 167 | + killsValue * 45 + damageRatioValue * 20 + shotRatioValue * 20 + accuracyValue * 15); |
| 168 | + } |
| 169 | + |
| 170 | + return scores; |
| 171 | + } |
| 172 | + |
| 173 | + // Determines the rankings of each players in each of the categories stored in |statistics|. |
| 174 | + // Will return a map with the same keys & statistics, but with ranks rather than values. This |
| 175 | + // method looks complicated, but due to sorting it comes down to having a time complexity of |
| 176 | + // Θ(n log(n)), which is more than reasonable for what it does. |
| 177 | + determineRankings(statistics) { |
| 178 | + const individualMetrics = { |
| 179 | + kills: [], |
| 180 | + damageRatio: [], |
| 181 | + shotRatio: [], |
| 182 | + accuracy: [], |
| 183 | + }; |
| 184 | + |
| 185 | + // (1) Tally the individual metrics from each of the players in an array. |
| 186 | + for (const metrics of statistics.values()) { |
| 187 | + for (const [ property, value ] of Object.entries(metrics)) |
| 188 | + individualMetrics[property].push(value); |
| 189 | + } |
| 190 | + |
| 191 | + // (2) Sort each of the |individualMetrics| in either ascending or descending order, which |
| 192 | + // depends on the sort of data it contains. The best should come first. |
| 193 | + individualMetrics.kills.sort(sortDescending); |
| 194 | + individualMetrics.damageRatio.sort(sortDescending); |
| 195 | + individualMetrics.shotRatio.sort(sortDescending); |
| 196 | + individualMetrics.accuracy.sort(sortDescending); |
| 197 | + |
| 198 | + // (3) Create a map with the individual ranks for each of the metrics. They're keyed by |
| 199 | + // the value, and valued by the rank associated with the value. |
| 200 | + const individualRanks = { |
| 201 | + kills: new Map(), |
| 202 | + damageRatio: new Map(), |
| 203 | + shotRatio: new Map(), |
| 204 | + accuracy: new Map(), |
| 205 | + }; |
| 206 | + |
| 207 | + for (const [ property, rankings ] of Object.entries(individualMetrics)) { |
| 208 | + for (let index = 0; index < rankings.length; ++index) |
| 209 | + individualRanks[property].set(rankings[index], /* rank= */ index + 1); |
| 210 | + } |
| 211 | + |
| 212 | + // (4) Create the rankings map based on the |individualRanks|, associated by player. |
| 213 | + const rankings = new Map(); |
| 214 | + |
| 215 | + for (const [ player, metrics ] of statistics) { |
| 216 | + rankings.set(player, { |
| 217 | + kills: individualRanks.kills.get(metrics.kills), |
| 218 | + damageRatio: individualRanks.damageRatio.get(metrics.damageRatio), |
| 219 | + shotRatio: individualRanks.shotRatio.get(metrics.shotRatio), |
| 220 | + accuracy: individualRanks.accuracy.get(metrics.accuracy), |
| 221 | + }); |
| 222 | + } |
10 | 223 |
|
| 224 | + // (5) Return the |rankings|, and our job is completed. |
| 225 | + return rankings; |
| 226 | + } |
11 | 227 | } |
0 commit comments