-
Notifications
You must be signed in to change notification settings - Fork 46
/
ladders-local.js
346 lines (308 loc) · 10.1 KB
/
ladders-local.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
/**
* Ladder library
* Pokemon Showdown - http://pokemonshowdown.com/
*
* This file handles ladders for all servers other than
* play.pokemonshowdown.com.
*
* Specifically, this is the file that handles calculating and keeping
* track of players' Elo ratings for all formats.
*
* Matchmaking is currently still implemented in rooms.js.
*
* @license MIT license
*/
'use strict';
const FS = require('./lib/fs');
// ladderCaches = {formatid: ladder OR Promise(ladder)}
// Use Ladders(formatid).ladder to guarantee a Promise(ladder).
// ladder is basically a 2D array representing the corresponding ladder.tsv
// with userid in front
/** @typedef {[string, number, string, number, number, number, string]} LadderRow [userid, elo, username, w, l, t, lastUpdate] */
/** @type {Map<string, LadderRow[] | Promise<LadderRow[]>>} formatid: ladder */
let ladderCaches = new Map();
class LadderStore {
/**
* @param {string} formatid
*/
constructor(formatid) {
this.formatid = formatid;
/** @type {LadderRow[]?} */
this.ladder = null;
/** @type {Promise<LadderRow[]>?} */
this.ladderPromise = null;
}
/**
* @return {Promise<LadderRow[]>}
*/
getLadder() {
if (!this.ladderPromise) this.ladderPromise = this.load();
return this.ladderPromise;
}
/**
* Internal function, returns a Promise for a ladder
* @return {Promise<LadderRow[]>}
*/
async load() {
// ladderCaches[formatid]
const cachedLadder = ladderCaches.get(this.formatid);
if (cachedLadder) {
// @ts-ignore
if (cachedLadder.then) {
let ladder = await cachedLadder;
return (this.ladder = ladder);
}
// @ts-ignore
return (this.ladder = cachedLadder);
}
try {
const data = await FS('config/ladders/' + this.formatid + '.tsv').readIfExists();
let ladder = /** @type {LadderRow[]} */ ([]);
for (const dataLine of data.split('\n')) {
let line = dataLine.trim();
if (!line) continue;
let row = line.split('\t');
ladder.push([toId(row[1]), Number(row[0]), row[1], Number(row[2]), Number(row[3]), Number(row[4]), row[5]]);
}
// console.log('Ladders(' + this.formatid + ') loaded tsv: ' + JSON.stringify(this.ladder));
ladderCaches.set(this.formatid, (this.ladder = ladder));
return this.ladder;
} catch (err) {
// console.log('Ladders(' + this.formatid + ') err loading tsv: ' + JSON.stringify(this.ladder));
}
ladderCaches.set(this.formatid, (this.ladder = []));
return this.ladder;
}
/**
* Saves the ladder in config/ladders/[formatid].tsv
*
* Called automatically by updateRating, so you don't need to manually
* call this.
*/
async save() {
if (this.saving) return;
this.saving = true;
const ladder = await this.getLadder();
if (!ladder.length) {
this.saving = false;
return;
}
let stream = FS(`config/ladders/${this.formatid}.tsv`).createWriteStream();
stream.write('Elo\tUsername\tW\tL\tT\tLast update\r\n');
for (const row of ladder) {
stream.write(row.slice(1).join('\t') + '\r\n');
}
stream.end();
this.saving = false;
}
/**
* Gets the index of a user in the ladder array.
*
* If createIfNeeded is true, the user will be created and added to
* the ladder array if it doesn't already exist.
* @param {string} username
*/
indexOfUser(username, createIfNeeded = false) {
if (!this.ladder) throw new Error(`Must be called with ladder loaded`);
let userid = toId(username);
for (const [i, user] of this.ladder.entries()) {
if (user[0] === userid) return i;
}
if (createIfNeeded) {
let index = this.ladder.length;
this.ladder.push([userid, 1000, username, 0, 0, 0, '']);
return index;
}
return -1;
}
/**
* Returns [formatid, html], where html is an the HTML source of a
* ladder toplist, to be displayed directly in the ladder tab of the
* client.
* @return {Promise<[string, string]?>}
*/
async getTop() {
let formatid = this.formatid;
let name = Dex.getFormat(formatid).name;
const ladder = await this.getLadder();
let buf = `<h3>${name} Top 100</h3>`;
buf += `<table>`;
buf += `<tr><th>` + ['', 'Username', '<abbr title="Elo rating">Elo</abbr>', 'W', 'L', 'T'].join(`</th><th>`) + `</th></tr>`;
for (const [i, row] of ladder.entries()) {
buf += `<tr><td>` + [
i + 1, row[2], `<strong>${Math.round(row[1])}</strong>`, row[3], row[4], row[5],
].join(`</td><td>`) + `</td></tr>`;
}
return [formatid, buf];
}
/**
* Returns a Promise for the Elo rating of a user
* @param {string} userid
* @return {Promise<number>}
*/
async getRating(userid) {
let formatid = this.formatid;
let user = Users.getExact(userid);
if (user && user.mmrCache[formatid]) {
return user.mmrCache[formatid];
}
const ladder = await this.getLadder();
let index = this.indexOfUser(userid);
let rating = 1000;
if (index >= 0) {
rating = ladder[index][1];
}
if (user && user.userid === userid) {
user.mmrCache[formatid] = rating;
}
return rating;
}
/**
* Internal method. Update the Elo rating of a user.
* @param {LadderRow} row
* @param {number} score
* @param {number} foeElo
*/
updateRow(row, score, foeElo) {
let elo = row[1];
// The K factor determines how much your Elo changes when you win or
// lose games. Larger K means more change.
// In the "original" Elo, K is constant, but it's common for K to
// get smaller as your rating goes up
let K = 50;
// dynamic K-scaling (optional)
if (elo < 1200) {
if (score < 0.5) {
K = 10 + (elo - 1000) * 40 / 200;
} else if (score > 0.5) {
K = 90 - (elo - 1000) * 40 / 200;
}
} else if (elo > 1350) {
K = 40;
} else if (elo > 1600) {
K = 32;
}
// main Elo formula
let E = 1 / (1 + Math.pow(10, (foeElo - elo) / 400));
elo += K * (score - E);
if (elo < 1000) elo = 1000;
row[1] = elo;
if (score > 0.6) {
row[3]++; // win
} else if (score < 0.4) {
row[4]++; // loss
} else {
row[5]++; // tie
}
row[6] = '' + new Date();
}
/**
* Update the Elo rating for two players after a battle, and display
* the results in the passed room.
* @param {string} p1name
* @param {string} p2name
* @param {number} p1score
* @param {GameRoom} room
*/
async updateRating(p1name, p2name, p1score, room) {
if (Ladders.disabled) {
room.addRaw(`Ratings not updated. The ladders are currently disabled.`).update();
return [p1score, null, null];
}
let formatid = this.formatid;
let p2score = 1 - p1score;
if (p1score < 0) {
p1score = 0;
p2score = 0;
}
const ladder = await this.getLadder();
let p1newElo, p2newElo;
try {
let p1index = this.indexOfUser(p1name, true);
let p1elo = ladder[p1index][1];
let p2index = this.indexOfUser(p2name, true);
let p2elo = ladder[p2index][1];
this.updateRow(ladder[p1index], p1score, p2elo);
this.updateRow(ladder[p2index], p2score, p1elo);
p1newElo = ladder[p1index][1];
p2newElo = ladder[p2index][1];
// console.log('L: ' + ladder.map(r => ''+Math.round(r[1])+' '+r[2]).join('\n'));
// move p1 to its new location
let newIndex = p1index;
while (newIndex > 0 && ladder[newIndex - 1][1] <= p1newElo) newIndex--;
while (newIndex === p1index || (ladder[newIndex] && ladder[newIndex][1] > p1newElo)) newIndex++;
// console.log('ni='+newIndex+', p1i='+p1index);
if (newIndex !== p1index && newIndex !== p1index + 1) {
let row = ladder.splice(p1index, 1)[0];
// adjust for removed row
if (newIndex > p1index) newIndex--;
if (p2index > p1index) p2index--;
ladder.splice(newIndex, 0, row);
// adjust for inserted row
if (p2index >= newIndex) p2index++;
}
// move p2
newIndex = p2index;
while (newIndex > 0 && ladder[newIndex - 1][1] <= p2newElo) newIndex--;
while (newIndex === p2index || (ladder[newIndex] && ladder[newIndex][1] > p2newElo)) newIndex++;
// console.log('ni='+newIndex+', p2i='+p2index);
if (newIndex !== p2index && newIndex !== p2index + 1) {
let row = ladder.splice(p2index, 1)[0];
// adjust for removed row
if (newIndex > p2index) newIndex--;
ladder.splice(newIndex, 0, row);
}
let p1 = Users.getExact(p1name);
if (p1) p1.mmrCache[formatid] = +p1newElo;
let p2 = Users.getExact(p2name);
if (p2) p2.mmrCache[formatid] = +p2newElo;
this.save();
if (!room.battle) {
Monitor.warn(`room expired before ladder update was received`);
return [p1score, null, null];
}
let reasons = '' + (Math.round(p1newElo) - Math.round(p1elo)) + ' for ' + (p1score > 0.9 ? 'winning' : (p1score < 0.1 ? 'losing' : 'tying'));
if (reasons.charAt(0) !== '-') reasons = '+' + reasons;
room.addRaw(Chat.html`${p1name}'s rating: ${Math.round(p1elo)} → <strong>${Math.round(p1newElo)}</strong><br />(${reasons})`);
reasons = '' + (Math.round(p2newElo) - Math.round(p2elo)) + ' for ' + (p2score > 0.9 ? 'winning' : (p2score < 0.1 ? 'losing' : 'tying'));
if (reasons.charAt(0) !== '-') reasons = '+' + reasons;
room.addRaw(Chat.html`${p2name}'s rating: ${Math.round(p2elo)} → <strong>${Math.round(p2newElo)}</strong><br />(${reasons})`);
room.update();
} catch (e) {
if (!room.battle) return [p1score, null, null];
room.addRaw(`There was an error calculating rating changes:`);
room.add(e.stack);
room.update();
}
return [p1score, p1newElo, p2newElo];
}
/**
* Returns a promise for a <tr> with all ratings for the current format.
* @param {string} username
*/
async visualize(username) {
const ladder = await this.getLadder();
let index = this.indexOfUser(username, false);
if (index < 0) return '';
let ratings = ladder[index];
let output = `<tr><td>${this.formatid}</td><td><strong>${Math.round(ratings[1])}</strong></td>`;
return `${output}<td>${ratings[3]}</td><td>${ratings[4]}</td><td>${ratings[3] + ratings[4]}</td></tr>`;
}
/**
* Returns a Promise for an array of strings of <tr>s for ladder ratings of the user
* @param {string} username
* @return {Promise<string[]>}
*/
static visualizeAll(username) {
let ratings = [];
for (let i in Dex.formats) {
if (Dex.formats[i].searchShow) {
ratings.push(new LadderStore(i).visualize(username));
}
}
return Promise.all(ratings);
}
}
LadderStore.formatsListPrefix = '|,LL';
LadderStore.ladderCaches = ladderCaches;
module.exports = LadderStore;