Skip to content

Commit f5060f9

Browse files
committed
Implement support for creating balanced teams
1 parent 5b932ba commit f5060f9

File tree

3 files changed

+337
-2
lines changed

3 files changed

+337
-2
lines changed

javascript/features/games_deathmatch/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ multi-player deathmatch fights, for example gang wars.
8484
## TODO
8585
The following items are still to do for the Games Deathmatch API to be complete.
8686

87-
* Implement support for **balanced teams**.
8887
* Implement the **Last man standing** objective.
8988
* Implement the **Best of...** objective.
9089
* Implement the **First to...** objective.

javascript/features/games_deathmatch/teams/balanced_teams_resolver.js

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,226 @@
22
// Use of this source code is governed by the MIT license, a copy of which can
33
// be found in the LICENSE file.
44

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

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+
716
// Resolves teams in a balanced manner, i.e. looks at player statistics, experience, ranks them and
817
// then tries to divide the players in two groups in a fair manner.
918
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+
}
10223

224+
// (5) Return the |rankings|, and our job is completed.
225+
return rankings;
226+
}
11227
}

javascript/features/games_deathmatch/teams/balanced_teams_resolver.test.js

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,127 @@
33
// be found in the LICENSE file.
44

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

7-
describe('BalancedTeamsResolver', it => {
8+
describe('BalancedTeamsResolver', (it, beforeEach) => {
9+
let gunther = null;
10+
let lucy = null;
11+
let resolver = null;
12+
let russell = null;
813

14+
beforeEach(() => {
15+
resolver = new BalancedTeamsResolver();
16+
17+
gunther = server.playerManager.getById(/* Gunther= */ 0);
18+
russell = server.playerManager.getById(/* Russell= */ 1);
19+
lucy = server.playerManager.getById(/* Lucy= */ 2);
20+
21+
populateEnduringStatistics();
22+
});
23+
24+
// Populates the enduring statistics of the players.
25+
function populateEnduringStatistics() {
26+
// -- kills
27+
gunther.stats.enduring.killCount = 100; // 1st
28+
russell.stats.enduring.killCount = 90; // 2nd
29+
lucy.stats.enduring.killCount = 80; // 3rd
30+
31+
// -- damage ratio
32+
gunther.stats.enduring.damageTaken = 100;
33+
gunther.stats.enduring.damageGiven = 50; // 2nd (0.5)
34+
35+
russell.stats.enduring.damageTaken = 200;
36+
russell.stats.enduring.damageGiven = 200; // 1st (1)
37+
38+
lucy.stats.enduring.damageTaken = 0;
39+
lucy.stats.enduring.damageGiven = 0; // 3rd (0)
40+
41+
// -- shots ratio
42+
gunther.stats.enduring.shotsHit = 50;
43+
gunther.stats.enduring.shotsTaken = 200; // 3rd (0.25)
44+
45+
russell.stats.enduring.shotsHit = 50;
46+
russell.stats.enduring.shotsTaken = 100; // 2nd (0.5)
47+
48+
lucy.stats.enduring.shotsHit = 200;
49+
lucy.stats.enduring.shotsTaken = 66.6666; // 1st (3)
50+
51+
// -- accuracy
52+
gunther.stats.enduring.shotsMissed = 50; // 2nd (0.5)
53+
russell.stats.enduring.shotsMissed = 50; // 2nd (0.5)
54+
lucy.stats.enduring.shotsMissed = 50; // 1st (0.8)
55+
}
56+
57+
it('should be able to rank players by their statistics', assert => {
58+
const rankings = resolver.determineRankings(new Map([
59+
[ gunther, { kills: 100, damageRatio: 1, shotRatio: 2, accuracy: 0.5 } ],
60+
[ russell, { kills: 200, damageRatio: .5, shotRatio: 1, accuracy: 0.8 } ],
61+
[ lucy, { kills: 150, damageRatio: 2, shotRatio: 0, accuracy: 0.3 } ],
62+
]));
63+
64+
assert.deepEqual([ ...rankings.values() ], [
65+
{
66+
kills: 3,
67+
damageRatio: 2,
68+
shotRatio: 1,
69+
accuracy: 2,
70+
},
71+
{
72+
kills: 1,
73+
damageRatio: 3,
74+
shotRatio: 2,
75+
accuracy: 1,
76+
},
77+
{
78+
kills: 2,
79+
damageRatio: 1,
80+
shotRatio: 3,
81+
accuracy: 3,
82+
}
83+
]);
84+
});
85+
86+
it('should be able to create scores for each player based on their rankings', assert => {
87+
const scores = {};
88+
89+
for (const [ player, score ] of resolver.computePlayerScores([ gunther, russell, lucy ]))
90+
scores[player.name] = score;
91+
92+
// Verify that the scores are what we expect them to be. Gunther wins because he's first
93+
// in regards to kill count, which we carry most.
94+
assert.deepEqual(scores, {
95+
Gunther: 110,
96+
Russell: 105,
97+
Lucy: 70
98+
});
99+
});
100+
101+
it('should be able to divide players in equally sized teams', assert => {
102+
const players = [ russell, lucy ];
103+
104+
// (1) Have Russell and Lucy join the game. Russell will become part of Team Alpha (higher
105+
// score), and Lucy will become part of Team Bravo.
106+
const teams = {
107+
[DeathmatchGame.kTeamAlpha]: new Set(),
108+
[DeathmatchGame.kTeamBravo]: new Set(),
109+
};
110+
111+
for (const { player, team } of resolver.resolve(players))
112+
teams[team].add(player);
113+
114+
assert.equal(teams[DeathmatchGame.kTeamAlpha].size, 1);
115+
assert.equal(teams[DeathmatchGame.kTeamBravo].size, 1);
116+
117+
assert.isTrue(teams[DeathmatchGame.kTeamAlpha].has(russell));
118+
assert.isTrue(teams[DeathmatchGame.kTeamBravo].has(lucy));
119+
120+
// (2) Have Gunther join the game. Team Bravo is the weaker team, so he should be slotted in
121+
// there to balance things out more evenly.
122+
teams[resolver.resolveForPlayer(gunther)].add(gunther);
123+
124+
assert.equal(teams[DeathmatchGame.kTeamAlpha].size, 1);
125+
assert.equal(teams[DeathmatchGame.kTeamBravo].size, 2);
126+
127+
assert.isTrue(teams[DeathmatchGame.kTeamBravo].has(gunther));
128+
});
9129
});

0 commit comments

Comments
 (0)