]> git.sesse.net Git - ultimatescore/blob - update_sheets.js
Make the roster scripts executable.
[ultimatescore] / update_sheets.js
1 // Updates back to the Google spreadsheet.
2 // There's basically zero error handling here, but OK, missing some updates is fine, really.
3
4 let jwt_key = {
5         "type": "service_account",
6         "project_id": "solskogen-cubemap",
7         "private_key_id": "9eaf56bb4d6b688c3c73bd532fecdde943eea718",
8         "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCPlcoZuj+tiaiw\nH5tjcZmCAFuCS3LhND+4WgA7BPvA3yrHvgm23T9aYVhhntA/uv2MSNjbZLj9Bk5z\nTfhSF6X6mr6JdtK05X5FXOiZdk8/36FT+aLANFqhyTD4WGXQVaHVjp1i6YNm9NvH\nUCt+R99VSteuvEyMQbqQtqTgTAisCmiO6bMssK90xKH9hwc5Zew/OUEaxa+ivgPR\nbgD3cTTQrPh0SKrZQFvdxx9ikGI/7rTZUazGU8r+VHBRCTMExCx1uQa2QzMd3REM\nngQCQ9TIkS0fNsE6NE8omrbbDJK1ob5tm4Jm6O7a7cN+yCel8sWoqW4fHdUb1z5n\n9IOUgHgFAgMBAAECggEAAWYJL2scghpvzbNf+JlbdO1a9tRvvIaK+cbvOBGqST7e\nqynVf2O0KqEh91MsMIq6O/Gl/fWns1fPBoc10zZOwcnugeb8LbcLZwlqtbtjo8wi\nV8sgn1kVfKDwjvT/LyuHgPI7mqbTxp7iGN36ZnnZLB9wkxjJKBe6YPznl8yROeoK\n4BdLaTWSv5w9mp1wnPG5RVsS5oAkoSFyDY0U7gAetsUjNf7bdlGtLvobw3kOpa7W\nm8WdN6jGbbyxmpe5Ql66/DhTBI4giNDDVvhf6fcRCOO+aAWNzZ5R6SgrdSYHbuBQ\npzGI7nBmBg3Nu1EYpm42wrUpoh6czy1Uf0F6VDmIkQKBgQDEh1Ado/QICWGQu6MB\nP0tX+APhN10x9Oq511Fd9SnTLZz2yzUN5Sshor+nevpes1Ljf8hS2FSV0fl0nRg3\nb7uMRt6EZNmzJJlPCQeBHNevVg5Z3kn3cpGE2cIr5JWB3r+EVd9wTOz7ihOVOboY\nt0yREiMeRuVmrwPi98hyoaNiHQKBgQC7CQzLGUHQDORBlQA0V6NFyamgrpkbSdML\nIZ3VThxbSxFGROC6W8At914F3XXTeP4f/kU1jjYOYhKpQy2RpOg6oLyCIx78sC/J\nkZS5eMeqv/hLSLt5eebAx0tVpDO++z/MWbbr/EpPMwQlSMMFlU2HqUK3XnAlSv9Q\njBxrs1sJCQKBgGy1GFi85vBHGCOx1rGK7EcllifOsws+GVRgyM47HT6FvYw5zQf5\nmokJeA/RE4qskI3skcdZiDgzJFQfzVRkxo4KaW08R7sy5GZ2bSM67Ac9h8SoE6v/\nQIUG2sPitdxXdQJjaau5sWBV+Q0TGGAxi/W23ZwSxTOuXWz/eG4IANL1AoGBAJdc\nmpLejMk/NZXxbGnvpn161yDnS5au5vEyMlYGUaJ8HK2+XhPS3rMUZm3erFUIrLfd\ngcr2nL6FFc8PQ5iDWUDhBc1XeONL/lBk1XRHz2Za1yit4rJLObg3ULstGIdtM1NA\nI23VDZoMkkVOHi2th0HLc+eLsLwtdnOMABAU5Q5pAoGBAKoZY3MflCEIj1S2hKQB\ncmz68DcwwXiwwuwE4zXoTWO95xApl7IP9ElNr1LFjYEhRp0VKyeZJ8UASKLN0nKF\ncD36qa71rd9VvKsNOiiKwbNy/E9WQ2B5rfovPbg2xSr8AQJxwZww2iv0zsP/Z+fG\nWYKJbvIPySmSrXhg9seBoSOL\n-----END PRIVATE KEY-----\n",
9         "client_email": "ultimate-nm-2018@solskogen-cubemap.iam.gserviceaccount.com",
10         "client_id": "102636658655884526659",
11         "auth_uri": "https://accounts.google.com/o/oauth2/auth",
12         "token_uri": "https://accounts.google.com/o/oauth2/token",
13         "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
14         "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/ultimate-nm-2018%40solskogen-cubemap.iam.gserviceaccount.com"
15 };
16
17 function post_data(url, contents, content_type, cb, auth) {
18         let req = new XMLHttpRequest();
19         req.onload = function(e) {
20                 cb(req.responseText);
21         };
22         req.open('POST', url);
23         req.setRequestHeader("Content-type", content_type);
24         if (auth !== undefined) {
25                 req.setRequestHeader("Authorization", "Bearer " + auth);
26         }
27         req.send(contents);
28 }
29
30 function post_json(url, json, cb, auth) {
31         post_data(url, JSON.stringify(json), "application/json;charset=UTF-8", cb, auth);
32 }
33
34 let current_oauth_access_token = null;
35 let oauth_expire = 0;
36
37 function update_oauth_key(cb) {
38         let now = Math.floor(new Date().getTime() / 1000);
39         let jwt = {
40                 "iss": jwt_key.client_email,
41                 "scope": "https://www.googleapis.com/auth/spreadsheets",
42                 "aud":"https://www.googleapis.com/oauth2/v4/token",
43                 "exp": now + 1800,
44                 "iat": now,
45         };
46         let sJWS = KJUR.jws.JWS.sign(null, {"alg": "RS256"}, jwt, jwt_key.private_key);
47         post_data('https://www.googleapis.com/oauth2/v4/token',
48                 "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=" + sJWS,
49                 "application/x-www-form-urlencoded",
50                 function(response) {
51                         current_oauth_access_token = JSON.parse(response)['access_token'];
52                         console.log("Got new OAuth key.");
53                         oauth_expire = now + 1800;
54                         if (cb !== undefined) { cb(); }
55                 });
56 }
57
58 function possibly_update_oauth_key(cb) {
59         let now = Math.floor(new Date().getTime() / 1000);
60         if (oauth_expire - now < 60) {
61                 console.log("Getting new OAuth key...");
62                 update_oauth_key(cb);
63         } else {
64                 cb();
65         }
66 }
67
68 function publish_group_rank(teams, games, group_name)
69 {
70         let updates = [];
71         let config = ultimateconfig['group_cells'][group_name];
72         let cols = config['score_sheet_cols'];
73
74         apply_games_to_teams(games, teams, group_name);
75         teams.sort(function(a, b) { return a.seeding - b.seeding });
76
77         // Write the points total to the unsorted columns.
78         if (config['point_total_start_row'] !== null) {
79                 for (let i = 0; i < teams.length; ++i) {
80                         let row = config['point_total_start_row'] + i;
81                         updates.push({ "range": cols[2] + row, "values": [ [ teams[i].pts ] ] });
82                 }
83         }
84
85         let tiebreakers = [];
86         teams = rank(games, teams, 1, tiebreakers);
87
88         // Write the ranking table, from scratch.
89         for (let i = 0; i < teams.length; ++i) {
90                 let row = config['ranking_list_start_row'] + i;
91                 updates.push({ "range": cols[0] + row, "values": [ [ teams[i].rank + config['rank_offset'] - 1] ] });
92                 updates.push({ "range": cols[1] + row, "values": [ [ teams[i].mediumname ] ] });
93                 updates.push({ "range": cols[2] + row, "values": [ [ teams[i].pts ] ] });
94         }
95
96         let tb_str = "";
97         if (tiebreakers.length != 0) {
98                 tb_str = tiebreakers.join("\n");
99         }
100         updates.push({ "range": config['ranking_list_explain_cell'], "values": [ [ tb_str ] ]});
101
102         let json = {
103                 "valueInputOption": "USER_ENTERED",
104                 "data": updates 
105         };
106         possibly_update_oauth_key(function() {
107                 post_json('https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values:batchUpdate?key=' + ultimateconfig['api_key'], json, function(response) {}, current_oauth_access_token);
108         });
109 }
110
111 function montecarlo(all_teams, groups, games, groups_to_calc) {
112         let pseudo_group_names = ['X', 'Y', 'Z'];
113         let real_group_names = ['A', 'B', 'C'];  // Better be corresponding to groups_to_calc...
114         let teams_to_idx = [];
115
116         let third_groups = [];
117         let busted_thirds = false;
118
119         // Split teams by group.
120         let teams = [];
121         for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
122                 let teams_group = filter_teams_by_group(all_teams, groups, groups_to_calc[group_idx]);
123                 teams.push(teams_group);
124                 teams_to_idx.push(make_teams_to_idx(teams_group));
125         }
126
127         for (let simulation_idx = 0; simulation_idx < 100; ++simulation_idx) {  // 100 seems to be enough.
128                 let thirds = [], ignoreds = [];
129                 let calc_groups = [];
130
131                 for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
132                         let teams_copy = [];
133                         for (const team of teams[group_idx]) {
134                                 teams_copy.push(Object.assign({}, team));
135                         }
136
137                         // Fill in random results. We deliberately use a uniform [-13,+13]
138                         // model here, since we are interested in the extremal results.
139                         // Of course, not all real games go to 13, but the risk of that
140                         // influencing the tiebreakers is very slim.
141                         let games_copy = [], games_with_synth = [];
142                         for (const game of games) {
143                                 games_copy.push(Object.assign({}, game));
144                         }
145
146                         for (let i = 0; i < games_copy.length; ++i) {
147                                 let idx1 = teams_to_idx[group_idx][games_copy[i].name1];
148                                 let idx2 = teams_to_idx[group_idx][games_copy[i].name2];
149                                 if (idx1 === undefined || idx2 === undefined) continue;
150                                 if (games_copy[i].score1 === undefined || games_copy[i].score2 === undefined ||
151                                     isNaN(games_copy[i].score1) || isNaN(games_copy[i].score2) ||
152                                     games_copy[i].score1 == games_copy[i].score2) {
153                                         // These were skipped by apply_games_to_teams() earlier.
154                                         let score1 = 0, score2 = 0;
155                                         let r = Math.floor(Math.random() * 26);
156                                         if (r < 13) {
157                                                 score1 = 13;
158                                                 score2 = r;
159                                                 teams_copy[idx1].pts += 2;
160                                         } else {
161                                                 score1 = r - 13;
162                                                 score2 = 13;
163                                                 teams_copy[idx2].pts += 2;
164                                         }
165                                         games_copy[i].score1 = score1;
166                                         games_copy[i].score2 = score2;
167                                         ++teams_copy[idx1].nplayed;
168                                         ++teams_copy[idx2].nplayed;
169                                         teams_copy[idx1].goals += score1;
170                                         teams_copy[idx2].goals += score2;
171                                         teams_copy[idx1].gd += score1;
172                                         teams_copy[idx2].gd += score2;
173                                         teams_copy[idx1].gd -= score2;
174                                         teams_copy[idx2].gd -= score1;
175
176                                         games_with_synth.push({
177                                                 "name1": games_copy[i].name1,
178                                                 "name2": games_copy[i].name2,
179                                                 "score1": score1,
180                                                 "score2": score2
181                                         });
182                                 } else {
183                                         games_with_synth.push(games_copy[i]);
184                                         continue;
185                                 }
186                         }
187                         
188                         // Now rank according to the simulation.
189                         let tiebreakers = [];
190                         teams_copy = rank(games_copy, teams_copy, 1, tiebreakers);
191
192                         // See if we have conflicting information with other simulations.
193                         if (simulation_idx == 0) {
194                                 for (let i = 0; i < teams[group_idx].length; ++i) {
195                                         let idx = teams_to_idx[group_idx][teams_copy[i].name];
196                                         teams[group_idx][idx].simulated_rank = teams_copy[i].rank;
197                                 }
198                         } else {
199                                 for (let i = 0; i < teams[group_idx].length; ++i) {
200                                         let idx = teams_to_idx[group_idx][teams_copy[i].name];
201                                         if (teams[group_idx][idx].simulated_rank !== teams_copy[i].rank) {
202                                                 teams[group_idx][idx].simulated_rank = null;
203                                         }
204                                 }
205                         }
206
207                         if (!busted_thirds) {
208                                 let any_third_found = false;
209                                 for (let i = 0; i < teams[group_idx].length; ++i) {
210                                         // Store the third.
211                                         if (i == 2 || teams_copy[i].rank == 3) {
212                                                 if (any_third_found) {
213                                                         busted_thirds = true;
214                                                 } else {
215                                                         teams_copy[i].group_idx = group_idx;
216                                                         thirds.push(teams_copy[i]);
217                                                         any_third_found = true;
218                                                 }
219                                         }
220                                 }
221                                 if (ultimateconfig['kick_fifth_from_third'] && teams_copy.length >= 5) {
222                                         if (teams_copy[4].rank != 5) {
223                                                 // A real tie for fifth; the rules are unclear, so just give up.
224                                                 busted_thirds = true;
225                                         } else {
226                                                 ignoreds.push(teams_copy[4]);
227                                         }
228                                 }
229                         }
230
231                         calc_groups.push({
232                                 "games": games_with_synth,
233                                 "teams": teams_copy
234                         });
235                 }
236
237                 // Also rank thirds.
238                 if (!busted_thirds) {
239                         let tiebreakers = [];
240                         if (ultimateconfig['kick_fifth_from_third']) {
241                                 // Recompute scores (but not ranks!) without the ignored games. (thirds point to these objects.)
242                                 for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
243                                         apply_games_to_teams(calc_groups[group_idx].games, calc_groups[group_idx].teams, groups_to_calc[group_idx], ignoreds);
244                                 }
245                         }
246                         let ranked = rank_thirds([], thirds, 1, tiebreakers);
247                         if (simulation_idx == 0) {
248                                 third_groups = ranked;
249                         } else {
250                                 for (let i = 0; i < groups_to_calc.length; ++i) {
251                                         if (third_groups[i].group_idx !== ranked[i].group_idx ||  // Different from a previous simulation.
252                                             (i < (third_groups.length - 1) && ranked[i].rank === ranked[i + 1].rank) ||  // Disallow ties.
253                                             (i > 0 && ranked[i].rank === ranked[i - 1].rank)) {  // Disallow ties.
254                                                 third_groups[i].group_idx = null;
255                                         }
256                                 }
257                         }
258                 }
259         }
260
261         let replacements = [];
262         for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
263                 if (third_groups[group_idx].group_idx !== null) {
264                         replacements.push([ pseudo_group_names[group_idx], real_group_names[third_groups[group_idx].group_idx] ]);
265                 }
266         }
267
268         // These are pretty hard-coded, but that's probably fine. Must come after we've concretized X, Y, etc.
269         for (const group_name of real_group_names) {
270                 let teams = filter_teams_by_group(all_teams, groups, 'Group ' + group_name);
271                 if (teams.length >= 5) {
272                         for (const other_group_name of real_group_names) {
273                                 replacements.push([ group_name + other_group_name + '5', group_name + '5' ]);
274                         }
275                         for (const other_group_name of pseudo_group_names) {
276                                 replacements.push([ group_name + other_group_name + '5', group_name + '5' ]);
277                         }
278                 } else {  // Perhaps a bit overkill.
279                         for (const other_group_name of real_group_names) {
280                                 replacements.push([ group_name + other_group_name + '5', other_group_name + '5' ]);
281                         }
282                         for (const other_group_name of pseudo_group_names) {
283                                 replacements.push([ group_name + other_group_name + '5', other_group_name + '5' ]);
284                         }
285                 }
286         }
287
288         for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
289                 for (let i = 0; i < teams[group_idx].length; ++i) {
290                         if (teams[group_idx][i].simulated_rank !== null) {
291                                 replacements.push([ real_group_names[group_idx] + teams[group_idx][i].simulated_rank, teams[group_idx][i].shortname ]);
292                         }
293                 }
294         }
295
296         return replacements;
297 }
298
299 function names_for_team(team, expansions) {
300         if (expansions.hasOwnProperty(team)) {
301                 return expansions[team];
302         }
303         let longteam = team.replace("W ", "Win. ").replace("L ", "Los. ");
304         return [ longteam, longteam, team ];
305 }
306
307 function expand_mediumname_if_single_team(team, expansions) {
308         if (expansions.hasOwnProperty(team)) {
309                 return expansions[team][1];
310         }
311         return team;
312 }
313
314 function do_replacements(str, replacements) {
315         for (const r of replacements) {
316                 str = str.replace(r[0], r[1]);
317         }
318         return str;
319 }
320
321 function fill_playoff(all_teams, groups, replacements) {
322         let team_expansions = {};
323         for (const team of all_teams) {
324                 team_expansions[team.name] = team_expansions[team.mediumname] = team_expansions[team.shortname] =
325                         [ team.name, team.mediumname, team.shortname ];
326         }
327
328         let games = ultimateconfig['playoff_games'];
329         get_results('Results', function(response) {
330                 let updates = [], meta_updates = [];
331                 for (const game of games) {
332                         let team1 = do_replacements(game[0], replacements);
333                         let team2 = do_replacements(game[1], replacements);
334                         let team1_mediumname = expand_mediumname_if_single_team(team1, team_expansions);
335                         let team2_mediumname = expand_mediumname_if_single_team(team2, team_expansions);
336                         let row = ultimateconfig['playoff_games_start_row'] + game[3];
337                         let cols = ultimateconfig['playoff_games_cols'][game[2]];
338                         let cell_team1 = "Results!" + String.fromCharCode(cols[0] + 65) + row;
339                         let cell_score1 = "Results!" + String.fromCharCode(cols[1] + 65) + row;
340                         let cell_score2 = "Results!" + String.fromCharCode(cols[2] + 65) + row;
341                         let cell_team2 = "Results!" + String.fromCharCode(cols[3] + 65) + row;
342                         updates.push({ "range": cell_team1, "values": [ [ team1_mediumname ] ] });
343                         updates.push({ "range": cell_team2, "values": [ [ team2_mediumname ] ] });
344
345                         let score1 = response['values'][row - 1][cols[1]];
346                         let score2 = response['values'][row - 1][cols[2]];
347                         let game_name = game[4];
348                         let game_name2 = game_name.replace("Semi", "semi");
349                         let game_day = game[7];
350                         if (game_day === undefined) {
351                                 game_day = 7;  // Sunday.
352                         }
353
354                         let range = {
355                                 "sheetId": ultimateconfig['score_sheet_index'],
356                                 "startColumnIndex": cols[1],
357                                 "endColumnIndex": cols[2] + 1,
358                                 "startRowIndex": row - 1,
359                                 "endRowIndex": row
360                         };
361
362                         if (parseInt(score1) >= 0 && parseInt(score2) >= 0 && score1 != score2) {
363                                 if (parseInt(score1) > parseInt(score2)) {
364                                         replacements.unshift(["W " + game_name, team1]);
365                                         replacements.unshift(["L " + game_name, team2]);
366                                         replacements.unshift(["W " + game_name2, team1]);
367                                         replacements.unshift(["L " + game_name2, team2]);
368                                 } else {
369                                         replacements.unshift(["W " + game_name, team2]);
370                                         replacements.unshift(["L " + game_name, team1]);
371                                         replacements.unshift(["W " + game_name2, team2]);
372                                         replacements.unshift(["L " + game_name2, team1]);
373                                 }
374                                 meta_updates.push({ "unmergeCells": { "range": range }});
375                         } else if (game[5]) {
376                                 // No score yet, so write the name of the game (e.g. “L-semi 1”)
377                                 // where the score would normally be, to mark what this game is called.
378                                 // This is useful with the limited space on the tablet.
379                                 score1 = score2 = "";
380                                 updates.push({ "range": cell_score1, "values": [ [ game[4] ] ] });
381                                 meta_updates.push({ "mergeCells": { "range": range, "mergeType": "MERGE_ALL" }});
382                         }
383                 }
384                 let json = {
385                         "valueInputOption": "USER_ENTERED",
386                         "data": updates 
387                 };
388                 let meta_json = {
389                         "requests": meta_updates
390                 };
391                 possibly_update_oauth_key(function() {
392                         if (updates.length > 0) {
393                                 post_json('https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values:batchUpdate?key=' + ultimateconfig['api_key'], json, function(response) {
394                                         get_all_group_games(all_teams, groups, function(group_games) {
395                                                 // NOTE: filter_teams_by_group will be delayed by one cycle
396                                                 // after W P1 etc. becomes determined for the first time.
397                                                 // Note that this requires the Groups sheet to pick out
398                                                 // the right teams from the group matches in the Results sheet!
399                                                 let teams_l1 = filter_teams_by_group(all_teams, groups, 'Playoffs 9th–11th');
400                                                 let teams_l2 = filter_teams_by_group(all_teams, groups, 'Playoffs 12th–14th');
401                                                 publish_group_rank(teams_l1, group_games, 'Playoffs 9th–11th');
402                                                 publish_group_rank(teams_l2, group_games, 'Playoffs 12th–14th');
403                                         });
404                                 }, current_oauth_access_token);
405                         }
406                         if (meta_updates.length > 0) {
407                                 post_json('https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + ':batchUpdate?key=' + ultimateconfig['api_key'], meta_json, function(response) {}, current_oauth_access_token);
408                         }
409                 });
410         });
411 }
412
413 function get_results(sheet_name, cb)
414 {
415         let req = new XMLHttpRequest();
416         req.onload = function(e) {
417                 cb(JSON.parse(req.responseText), sheet_name);
418         };
419         req.open('GET', 'https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values/\'' + sheet_name + '\'!A1:Q50?key=' + ultimateconfig['api_key']);
420         req.send();
421 }
422
423 function publish_group_ranks() {
424         get_teams(function(teams) {
425                 get_groups(function(groups) {
426                         get_all_group_games(teams, groups, function(games) {
427                                 let teams_a = filter_teams_by_group(teams, groups, 'Group A');
428                                 let teams_b = filter_teams_by_group(teams, groups, 'Group B');
429                                 let teams_c = filter_teams_by_group(teams, groups, 'Group C');
430                                 publish_group_rank(teams_a, games, 'Group A');
431                                 publish_group_rank(teams_b, games, 'Group B');
432                                 publish_group_rank(teams_c, games, 'Group C');
433
434                                 let replacements = montecarlo(teams, groups, games, ['Group A', 'Group B', 'Group C']);
435                                 fill_playoff(teams, groups, replacements);
436                         });
437                 });
438         });
439 }
440
441 function get_all_playoff_games(teams, groups, group_games, cb) {
442         let replacements = montecarlo(teams, groups, group_games, ['Group A', 'Group B', 'Group C']);
443         fill_playoff(teams, groups, replacements);  // To get the replacements.
444         let games = ultimateconfig['playoff_games'];
445         get_results('Results', function(response) {
446                 let playoff_games = [];
447                 for (const game of games) {
448                         let team1 = do_replacements(game[0], replacements);
449                         let team2 = do_replacements(game[1], replacements);
450                         let row = ultimateconfig['playoff_games_start_row'] + game[3];
451                         let cols = ultimateconfig['playoff_games_cols'][game[2]];
452                         let score1 = response['values'][row - 1][cols[1]];
453                         let score2 = response['values'][row - 1][cols[2]];
454                         let streamday = game[7];
455                         if (streamday === undefined && game[2] === 0) {  // Stream field is by default on stream.
456                                 streamday = 7;
457                         }
458                         playoff_games.push({
459                                 "name1": team1,
460                                 "name2": team2,
461                                 "score1": parseInt(score1),
462                                 "score2": parseInt(score2),
463                                 "streamday": streamday,
464                                 "streamtime": response['values'][row - 1][1].replace('.', ':'),
465                                 "group_name": game[6]
466                         });
467                 }
468                 cb(playoff_games);
469         });
470 }
471
472 function get_ranked(teams, games, group_name) {
473         apply_games_to_teams(games, teams, group_name);
474         let tiebreakers = [];
475         teams = rank(games, teams, 1, tiebreakers);
476         return teams;
477 }
478
479 // Pick out everything that is at rank N _or_ avoids rank N by lack of tiebreakers only.
480 function pick_out_rank(teams, rank, candidates) {
481         if (teams.length < rank) {
482                 return;
483         }
484
485         let lowest_rank = teams[rank - 1].rank;
486
487         let count = 0;
488         for (const team of teams) {
489                 if (team.rank >= lowest_rank && team.rank <= rank) {
490                         ++count;
491                 }
492         }
493
494         if (count >= teams.length / 2) {
495                 // We have no info yet, ignore this group.
496                 return;
497         }
498
499         for (const team of teams) {
500                 if (team.rank >= lowest_rank && team.rank <= rank) {
501                         candidates.push(team);
502                 }
503         }
504 }
505
506 function addsign(x)
507 {
508         if (x < 0) {
509                 return "−" + (-x);
510         } else if (x == 0) {
511                 return "=0";
512         } else {
513                 return "+" + x;
514         }
515 }
516
517 function publish_best_thirds() {
518         if (!ultimateconfig['best_thirds']) return;
519         get_teams(function(teams) {
520                 get_groups(function(groups) {
521                         get_all_group_games(teams, groups, function(games) {
522                                 let teams_a = filter_teams_by_group(teams, groups, 'Group A');
523                                 let teams_b = filter_teams_by_group(teams, groups, 'Group B');
524                                 let teams_c = filter_teams_by_group(teams, groups, 'Group C');
525                                 let A = get_ranked(teams_a, games, 'Group A');
526                                 let B = get_ranked(teams_b, games, 'Group B');
527                                 let C = get_ranked(teams_c, games, 'Group C');
528
529                                 let candidates = [];
530                                 pick_out_rank(A, 3, candidates);
531                                 pick_out_rank(B, 3, candidates);
532                                 pick_out_rank(C, 3, candidates);
533
534                                 let ignoreds = [];
535                                 let ignored_games = [], ignored_games_expl = [];
536                                 if (ultimateconfig['kick_fifth_from_third']) {
537                                         let ignoreds_A = [], ignoreds_B = [], ignoreds_C = [];
538                                         pick_out_rank(A, 5, ignoreds_A);
539                                         pick_out_rank(B, 5, ignoreds_B);
540                                         pick_out_rank(C, 5, ignoreds_C);
541
542                                         if (ignoreds_A.length >= 2) {
543                                                 ignoreds_A = [ ignoreds_A[ignoreds_A.length - 1] ];
544                                         }
545                                         if (ignoreds_B.length >= 2) {
546                                                 ignoreds_B = [ ignoreds_B[ignoreds_B.length - 1] ];
547                                         }
548                                         if (ignoreds_C.length >= 2) {
549                                                 ignoreds_C = [ ignoreds_C[ignoreds_C.length - 1] ];
550                                         }
551                                         ignoreds = ignoreds_A.concat(ignoreds_B).concat(ignoreds_C);
552
553                                         // Protect the “candidates” array, so that apply_games_to_teams() further down
554                                         // doesn't modify it (we want to compare old and new).
555                                         A = jsonclone(A);
556                                         B = jsonclone(B);
557                                         C = jsonclone(C);
558
559                                         // Recompute scores (but not ranks!) without the ignored games.
560                                         apply_games_to_teams(games, A, 'Group A', ignoreds, ignored_games);
561                                         apply_games_to_teams(games, B, 'Group B', ignoreds, ignored_games);
562                                         apply_games_to_teams(games, C, 'Group C', ignoreds, ignored_games);
563
564                                         // Filter out ignored games involving the candidate thirds.
565                                         let candidates_to_idx = make_teams_to_idx(candidates);
566                                         for (const game of ignored_games) {
567                                                 if (candidates_to_idx[game[0]] !== undefined ||
568                                                     candidates_to_idx[game[1]] !== undefined) {
569                                                         if (game[2]) {
570                                                                 ignored_games_expl.push("Ignoring (arbitrarily) " + game[0] + "–" + game[1]);
571                                                         } else {
572                                                                 ignored_games_expl.push("Ignoring " + game[0] + "–" + game[1]);
573                                                         }
574                                                 }
575                                         }
576
577                                         let new_teams = A.concat(B).concat(C);
578                                         let new_teams_to_idx = make_teams_to_idx(new_teams);
579
580                                         // Move back the scores (points, gd, goals).
581                                         for (let cand of candidates) {
582                                                 let new_version = new_teams[new_teams_to_idx[cand.shortname]];
583                                                 if (cand.pts != new_version.pts ||
584                                                     cand.gd != new_version.gd ||
585                                                     cand.goals != new_version.goals) {
586                                                         cand.pts = new_version.pts;
587                                                         cand.gd = new_version.gd;
588                                                         cand.goals = new_version.goals;
589                                                         ignored_games_expl.push(cand.shortname + " at " + cand.pts + " pts, " + addsign(new_version.gd) + " GD");
590                                                 }
591                                         }
592                                 }
593
594                                 let tiebreakers = [];
595                                 let text = "";
596                                 if (candidates.length >= 2) {
597                                         let ranked = rank_thirds([], candidates, 1, tiebreakers);
598                                         let best_thirds = ranked.filter(function(team) { return team.rank <= 2; });
599                                         if (best_thirds.length == 2) {
600                                                 text = "Best thirds: " + best_thirds.map(function(team) { return team.mediumname }).join(', ') + "\n";
601                                                 if (ignored_games_expl.length > 0) {
602                                                         text += ignored_games_expl.join("; ") + "\n";
603                                                 }
604                                                 text += tiebreakers.join("\n");
605                                         }
606                                 }
607                                 let updates = [];
608                                 updates.push({ "range": ultimateconfig['explain_third_cell'], "values": [ [ text ] ] });
609                                 let json = {
610                                         "valueInputOption": "USER_ENTERED",
611                                         "data": updates 
612                                 };
613                                 possibly_update_oauth_key(function() {
614                                         post_json('https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values:batchUpdate?key=' + ultimateconfig['api_key'], json, function(response) {}, current_oauth_access_token);
615                                 });
616                         });
617                 });
618         });
619 }
620
621 update_oauth_key();
622 setTimeout(function() {
623         publish_group_ranks();
624         publish_best_thirds();
625         setInterval(function() { publish_group_ranks(); publish_best_thirds(); }, 60000);
626 }, 5000);