Skip to content

Commit

Permalink
Implement support for creating balanced teams
Browse files Browse the repository at this point in the history
  • Loading branch information
RussellLVP committed Jul 8, 2020
1 parent 5b932ba commit f5060f9
Show file tree
Hide file tree
Showing 3 changed files with 337 additions and 2 deletions.
1 change: 0 additions & 1 deletion javascript/features/games_deathmatch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ multi-player deathmatch fights, for example gang wars.
## TODO
The following items are still to do for the Games Deathmatch API to be complete.

* Implement support for **balanced teams**.
* Implement the **Last man standing** objective.
* Implement the **Best of...** objective.
* Implement the **First to...** objective.
Expand Down
216 changes: 216 additions & 0 deletions javascript/features/games_deathmatch/teams/balanced_teams_resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,226 @@
// Use of this source code is governed by the MIT license, a copy of which can
// be found in the LICENSE file.

import { DeathmatchGame } from 'features/games_deathmatch/deathmatch_game.js';
import { TeamResolver } from 'features/games_deathmatch/teams/team_resolver.js';

// Comperator function for sorting an array in descending order.
function sortDescending(left, right) {
if (left === right)
return 0;

return left > right ? -1 : 1;
}

// Resolves teams in a balanced manner, i.e. looks at player statistics, experience, ranks them and
// then tries to divide the players in two groups in a fair manner.
export class BalancedTeamsResolver extends TeamResolver {
#teamAlpha_ = new Set();
#teamAlphaScore_ = 0;

#teamBravo_ = new Set();
#teamBravoScore_ = 0;

// Called when the |player| has been removed from the game. Keep track of that
onPlayerRemoved(player) {
if (this.#teamAlpha_.has(player)) {
this.#teamAlphaScore_ -= this.#teamAlpha_.get(player);
this.#teamAlpha_.delete(player);
}

if (this.#teamBravo_.has(player)) {
this.#teamBravoScore_ -= this.#teamBravo_.get(player);
this.#teamBravo_.delete(player);
}
}

// Resolves the intended teams for the given |players|, a sequence. We achieve this by ranking
// all |players| based on their skill, and then even-odd dividing them into teams.
resolve(players) {
const scoresArray = [ ...this.computePlayerScores(players) ];

// (1) Sort the |scoresArray| in descending order based on the scores. That gives us the
// rankings based on which we can divide the teams.
scoresArray.sort((left, right) => {
if (left[1] === right[1])
return 0;

return left[1] > right[1] ? -1 : 1;
});

// (2) Now even/odd divide each of the players. Begin by assigning the top player to team
// Alpha, and then work our way down the list. A future improvement on this algorithm would
// be to allow uneven participant counts from initial division.
const teams = [];

let currentTeam = DeathmatchGame.kTeamAlpha;
for (const [ player, score ] of scoresArray) {
teams.push({ player, team: currentTeam });

if (currentTeam === DeathmatchGame.kTeamAlpha) {
this.#teamAlpha_.add(player);
this.#teamAlphaScore_ += score;

currentTeam = DeathmatchGame.kTeamBravo;

} else {
this.#teamBravo_.add(player);
this.#teamBravoScore_ += score;

currentTeam = DeathmatchGame.kTeamAlpha;
}
}

// (3) Return the |teams| per the API contract, and we're done.
return teams;
}

// Resolves the intended team for the given |player|, who may have joined late. We will do a
// recalculation of the team scores, and then add the |player| to the team who needs them most.
resolveForPlayer(player) {
this.#teamAlphaScore_ = 0;
this.#teamBravoScore_ = 0;

// (1) Recalculate the total scores of both teams based on the adjusted rankings.
const scores = this.computePlayerScores([ player ]);
for (const [ participant, score ] of scores) {
if (this.#teamAlpha_.has(participant))
this.#teamAlphaScore_ += score;
else if (this.#teamBravo_.has(participant))
this.#teamBravoScore_ += score;
}

// (2) We allow a difference in number of participants per team that equates 10% of the
// total number of participants. That means that for 10 participants we allow 4/6, and for
// 20 participants we allow 8/12, if the skill level allows.
const allowableDifference =
Math.floor((this.#teamAlpha_.size + this.#teamBravo_.size + 1) / 10);

if (this.#teamAlpha_.size < (this.#teamBravo_.size - allowableDifference)) {
this.#teamAlpha_.add(player);
this.#teamAlphaScore_ += scores.get(player);

return DeathmatchGame.kTeamAlpha;

} else if (this.#teamBravo_.size < (this.#teamAlpha_.size - allowableDifference)) {
this.#teamBravo_.add(player);
this.#teamBravoScore_ += scores.get(player);

return DeathmatchGame.kTeamBravo;
}

// (3) Otherwise, we assign the |player| to the team who needs them most.
if (this.#teamAlphaScore_ > this.#teamBravoScore_) {
this.#teamBravo_.add(player);
this.#teamBravoScore_ += scores.get(player);

return DeathmatchGame.kTeamBravo;

} else {
this.#teamAlpha_.add(player);
this.#teamAlphaScore_ += scores.get(player);

return DeathmatchGame.kTeamAlpha;
}
}

// Determines the score of the given |player|. We rank all participants, both current and new
// ones in the given |players|, and assign each a score based. The score is based on their ranks
// in number of kills (45%), damage ratio (20%), shot ratio (20%) and accuracy (15%).
computePlayerScores(players) {
const participants = [ ...this.#teamAlpha_.keys(), ...this.#teamBravo_.keys(), ...players ];
const statistics = new Map();

// (1) Store all dimensions for the |player| in the |statistics| map. A separate routine
// will sort each of the individual metrics, and assign ranks to them.
for (const player of participants) {
const stats = player.stats.enduring;

statistics.set(player, {
kills: stats.killCount,
damageRatio: stats.damageTaken > 0 ? stats.damageGiven / stats.damageTaken : 0,
shotRatio: stats.shotsTaken > 0 ? stats.shotsHit / stats.shotsTaken : 0,
accuracy:
stats.shotsHit > 0 ? stats.shotsHit / (stats.shotsHit + stats.shotsMissed) : 0,
});
}

// (2) Determine the rankings of each player in each of those catagories.
const rankings = this.determineRankings(statistics);
const scores = new Map();

// (3) Populate the score map based on the rankings and the documented calculation.
for (const player of participants) {
const playerRankings = rankings.get(player);

// Get the values to calculate with. Negate parity, as better rankings should give the
// player a higher score. It keeps the rest of this system more readable.
const killsValue = participants.length - playerRankings.kills;
const damageRatioValue = participants.length - playerRankings.damageRatio;
const shotRatioValue = participants.length - playerRankings.shotRatio;
const accuracyValue = participants.length - playerRankings.accuracy;

// Calculate the score for the player.
scores.set(
player,
killsValue * 45 + damageRatioValue * 20 + shotRatioValue * 20 + accuracyValue * 15);
}

return scores;
}

// Determines the rankings of each players in each of the categories stored in |statistics|.
// Will return a map with the same keys & statistics, but with ranks rather than values. This
// method looks complicated, but due to sorting it comes down to having a time complexity of
// Θ(n log(n)), which is more than reasonable for what it does.
determineRankings(statistics) {
const individualMetrics = {
kills: [],
damageRatio: [],
shotRatio: [],
accuracy: [],
};

// (1) Tally the individual metrics from each of the players in an array.
for (const metrics of statistics.values()) {
for (const [ property, value ] of Object.entries(metrics))
individualMetrics[property].push(value);
}

// (2) Sort each of the |individualMetrics| in either ascending or descending order, which
// depends on the sort of data it contains. The best should come first.
individualMetrics.kills.sort(sortDescending);
individualMetrics.damageRatio.sort(sortDescending);
individualMetrics.shotRatio.sort(sortDescending);
individualMetrics.accuracy.sort(sortDescending);

// (3) Create a map with the individual ranks for each of the metrics. They're keyed by
// the value, and valued by the rank associated with the value.
const individualRanks = {
kills: new Map(),
damageRatio: new Map(),
shotRatio: new Map(),
accuracy: new Map(),
};

for (const [ property, rankings ] of Object.entries(individualMetrics)) {
for (let index = 0; index < rankings.length; ++index)
individualRanks[property].set(rankings[index], /* rank= */ index + 1);
}

// (4) Create the rankings map based on the |individualRanks|, associated by player.
const rankings = new Map();

for (const [ player, metrics ] of statistics) {
rankings.set(player, {
kills: individualRanks.kills.get(metrics.kills),
damageRatio: individualRanks.damageRatio.get(metrics.damageRatio),
shotRatio: individualRanks.shotRatio.get(metrics.shotRatio),
accuracy: individualRanks.accuracy.get(metrics.accuracy),
});
}

// (5) Return the |rankings|, and our job is completed.
return rankings;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,127 @@
// be found in the LICENSE file.

import { BalancedTeamsResolver } from 'features/games_deathmatch/teams/balanced_teams_resolver.js';
import { DeathmatchGame } from 'features/games_deathmatch/deathmatch_game.js';

describe('BalancedTeamsResolver', it => {
describe('BalancedTeamsResolver', (it, beforeEach) => {
let gunther = null;
let lucy = null;
let resolver = null;
let russell = null;

beforeEach(() => {
resolver = new BalancedTeamsResolver();

gunther = server.playerManager.getById(/* Gunther= */ 0);
russell = server.playerManager.getById(/* Russell= */ 1);
lucy = server.playerManager.getById(/* Lucy= */ 2);

populateEnduringStatistics();
});

// Populates the enduring statistics of the players.
function populateEnduringStatistics() {
// -- kills
gunther.stats.enduring.killCount = 100; // 1st
russell.stats.enduring.killCount = 90; // 2nd
lucy.stats.enduring.killCount = 80; // 3rd

// -- damage ratio
gunther.stats.enduring.damageTaken = 100;
gunther.stats.enduring.damageGiven = 50; // 2nd (0.5)

russell.stats.enduring.damageTaken = 200;
russell.stats.enduring.damageGiven = 200; // 1st (1)

lucy.stats.enduring.damageTaken = 0;
lucy.stats.enduring.damageGiven = 0; // 3rd (0)

// -- shots ratio
gunther.stats.enduring.shotsHit = 50;
gunther.stats.enduring.shotsTaken = 200; // 3rd (0.25)

russell.stats.enduring.shotsHit = 50;
russell.stats.enduring.shotsTaken = 100; // 2nd (0.5)

lucy.stats.enduring.shotsHit = 200;
lucy.stats.enduring.shotsTaken = 66.6666; // 1st (3)

// -- accuracy
gunther.stats.enduring.shotsMissed = 50; // 2nd (0.5)
russell.stats.enduring.shotsMissed = 50; // 2nd (0.5)
lucy.stats.enduring.shotsMissed = 50; // 1st (0.8)
}

it('should be able to rank players by their statistics', assert => {
const rankings = resolver.determineRankings(new Map([
[ gunther, { kills: 100, damageRatio: 1, shotRatio: 2, accuracy: 0.5 } ],
[ russell, { kills: 200, damageRatio: .5, shotRatio: 1, accuracy: 0.8 } ],
[ lucy, { kills: 150, damageRatio: 2, shotRatio: 0, accuracy: 0.3 } ],
]));

assert.deepEqual([ ...rankings.values() ], [
{
kills: 3,
damageRatio: 2,
shotRatio: 1,
accuracy: 2,
},
{
kills: 1,
damageRatio: 3,
shotRatio: 2,
accuracy: 1,
},
{
kills: 2,
damageRatio: 1,
shotRatio: 3,
accuracy: 3,
}
]);
});

it('should be able to create scores for each player based on their rankings', assert => {
const scores = {};

for (const [ player, score ] of resolver.computePlayerScores([ gunther, russell, lucy ]))
scores[player.name] = score;

// Verify that the scores are what we expect them to be. Gunther wins because he's first
// in regards to kill count, which we carry most.
assert.deepEqual(scores, {
Gunther: 110,
Russell: 105,
Lucy: 70
});
});

it('should be able to divide players in equally sized teams', assert => {
const players = [ russell, lucy ];

// (1) Have Russell and Lucy join the game. Russell will become part of Team Alpha (higher
// score), and Lucy will become part of Team Bravo.
const teams = {
[DeathmatchGame.kTeamAlpha]: new Set(),
[DeathmatchGame.kTeamBravo]: new Set(),
};

for (const { player, team } of resolver.resolve(players))
teams[team].add(player);

assert.equal(teams[DeathmatchGame.kTeamAlpha].size, 1);
assert.equal(teams[DeathmatchGame.kTeamBravo].size, 1);

assert.isTrue(teams[DeathmatchGame.kTeamAlpha].has(russell));
assert.isTrue(teams[DeathmatchGame.kTeamBravo].has(lucy));

// (2) Have Gunther join the game. Team Bravo is the weaker team, so he should be slotted in
// there to balance things out more evenly.
teams[resolver.resolveForPlayer(gunther)].add(gunther);

assert.equal(teams[DeathmatchGame.kTeamAlpha].size, 1);
assert.equal(teams[DeathmatchGame.kTeamBravo].size, 2);

assert.isTrue(teams[DeathmatchGame.kTeamBravo].has(gunther));
});
});

0 comments on commit f5060f9

Please sign in to comment.