]> git.sesse.net Git - ultimatescore/blob - update_sheets.js
Make explain cells more flexible.
[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(response, group_name)
69 {
70         let updates = [];
71         let config = ultimateconfig['group_cells'][group_name];
72         let cols = config['score_sheet_cols'];
73
74         let teams = parse_teams_from_spreadsheet(response);
75         let games = parse_games_from_spreadsheet(response, group_name, false);
76         apply_games_to_teams(games, teams);
77         teams = filter_teams(teams, response);
78
79         // Write the points total to the unsorted columns.
80         if (config['point_total_start_row'] !== null) {
81                 for (let i = 0; i < teams.length; ++i) {
82                         let row = config['point_total_start_row'] + i;
83                         updates.push({ "range": cols[2] + row, "values": [ [ teams[i].pts ] ] });
84                 }
85         }
86
87         let tiebreakers = [];
88         teams = rank(games, teams, 1, tiebreakers);
89
90         // Write the ranking table, from scratch.
91         for (let i = 0; i < teams.length; ++i) {
92                 let row = config['ranking_list_start_row'] + i;
93                 updates.push({ "range": cols[0] + row, "values": [ [ teams[i].rank ] ] });
94                 updates.push({ "range": cols[1] + row, "values": [ [ teams[i].mediumname ] ] });
95                 updates.push({ "range": cols[2] + row, "values": [ [ teams[i].pts ] ] });
96         }
97
98         let tb_str = "";
99         if (tiebreakers.length != 0) {
100                 tb_str = tiebreakers.join("\n");
101         }
102         updates.push({ "range": config['ranking_list_explain_cell'], "values": [ [ tb_str ] ]});
103
104         let json = {
105                 "valueInputOption": "USER_ENTERED",
106                 "data": updates 
107         };
108         possibly_update_oauth_key(function() {
109                 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);
110         });
111 }
112
113 function montecarlo(responses) {
114         let pseudo_group_names = ['X', 'Y', 'Z'];
115         let real_group_names = ['A', 'B', 'C'];
116         let teams = [], games = [], teams_to_idx = [];
117
118         let third_groups = [];
119         let busted_thirds = false;
120
121         for (const response of responses) {
122                 let teams_group = parse_teams_from_spreadsheet(response);
123                 let games_group = parse_games_from_spreadsheet(response, 'irrelevant group name', true);
124                 apply_games_to_teams(games_group, teams_group);
125
126                 teams.push(teams_group);
127                 games.push(games_group);
128                 teams_to_idx.push(make_teams_to_idx(teams_group));
129         }
130
131         for (let simulation_idx = 0; simulation_idx < 100; ++simulation_idx) {  // 100 seems to be enough.
132                 let thirds = [];
133                 for (let group_idx = 0; group_idx < responses.length; ++group_idx) {
134                         // Fill in random results. We deliberately use a uniform [-13,+13]
135                         // model here, since we are interested in the extremal results.
136                         // Of course, not all real games go to 13, but the risk of that
137                         // influencing the tiebreakers is very slim.
138                         let games_copy = [];
139                         for (const game of games[group_idx]) {
140                                 games_copy.push(Object.assign({}, game));
141                         }
142                         let teams_copy = [];
143                         for (const team of teams[group_idx]) {
144                                 teams_copy.push(Object.assign({}, team));
145                         }
146
147                         for (let i = 0; i < games_copy.length; ++i) {
148                                 let idx1 = teams_to_idx[group_idx][games_copy[i].name1];
149                                 let idx2 = teams_to_idx[group_idx][games_copy[i].name2];
150                                 if (idx1 === undefined || idx2 === undefined) continue;
151                                 if (games_copy[i].score1 === undefined || games_copy[i].score2 === undefined ||
152                                     isNaN(games_copy[i].score1) || isNaN(games_copy[i].score2) ||
153                                     games_copy[i].score1 == games_copy[i].score2) {
154                                         // These were skipped by apply_games_to_teams() above.
155                                         let score1 = 0, score2 = 0;
156                                         let r = Math.floor(Math.random() * 26);
157                                         if (r < 13) {
158                                                 score1 = 13;
159                                                 score2 = r;
160                                                 teams_copy[idx1].pts += 2;
161                                         } else {
162                                                 score1 = r - 13;
163                                                 score2 = 13;
164                                                 teams_copy[idx2].pts += 2;
165                                         }
166                                         games_copy[i].score1 = score1;
167                                         games_copy[i].score2 = score2;
168                                         ++teams_copy[idx1].nplayed;
169                                         ++teams_copy[idx2].nplayed;
170                                         teams_copy[idx1].goals += score1;
171                                         teams_copy[idx2].goals += score2;
172                                         teams_copy[idx1].gd += score1;
173                                         teams_copy[idx2].gd += score2;
174                                         teams_copy[idx1].gd -= score2;
175                                         teams_copy[idx2].gd -= score1;
176                                 } else {
177                                         continue;
178                                 }
179                         }
180                         
181                         // Now rank according to the simulation.
182                         let tiebreakers = [];
183                         teams_copy = rank(games_copy, teams_copy, 1, tiebreakers);
184
185                         // See if we have conflicting information with other simulations.
186                         if (simulation_idx == 0) {
187                                 for (let i = 0; i < teams[group_idx].length; ++i) {
188                                         let idx = teams_to_idx[group_idx][teams_copy[i].name];
189                                         teams[group_idx][idx].simulated_rank = teams_copy[i].rank;
190                                 }
191                         } else {
192                                 for (let i = 0; i < teams[group_idx].length; ++i) {
193                                         let idx = teams_to_idx[group_idx][teams_copy[i].name];
194                                         if (teams[group_idx][idx].simulated_rank !== teams_copy[i].rank) {
195                                                 teams[group_idx][idx].simulated_rank = null;
196                                         }
197                                 }
198                         }
199
200                         if (!busted_thirds) {
201                                 let any_third_found = false;
202                                 for (let i = 0; i < teams[group_idx].length; ++i) {
203                                         // Store the third.
204                                         if (i == 2 || teams_copy[i].rank == 3) {
205                                                 if (any_third_found) {
206                                                         busted_thirds = true;
207                                                 } else {
208                                                         teams_copy[i].group_idx = group_idx;
209                                                         thirds.push(teams_copy[i]);
210                                                         any_third_found = true;
211                                                 }
212                                         }
213                                 }
214                         }
215                 }
216
217                 // Also rank thirds.
218                 if (!busted_thirds) {
219                         let tiebreakers = [];
220                         let ranked = rank_thirds([], thirds, 1, tiebreakers);
221                         if (simulation_idx == 0) {
222                                 third_groups = ranked;
223                         } else {
224                                 for (let i = 0; i < responses.length; ++i) {
225                                         if (third_groups[i].group_idx !== ranked[i].group_idx) {
226                                                 third_groups[i].group_idx = null;
227                                         }
228                                 }
229                         }
230                 }
231         }
232
233         let replacements = [];
234         for (let group_idx = 0; group_idx < responses.length; ++group_idx) {
235                 if (third_groups[group_idx].group_idx !== null) {
236                         replacements.push([ pseudo_group_names[group_idx], real_group_names[third_groups[group_idx].group_idx] ]);
237                 }
238         }
239
240         for (let group_idx = 0; group_idx < responses.length; ++group_idx) {
241                 for (let i = 0; i < teams[group_idx].length; ++i) {
242                         if (teams[group_idx][i].simulated_rank !== null) {
243                                 replacements.push([ real_group_names[group_idx] + teams[group_idx][i].simulated_rank, teams[group_idx][i].shortname ]);
244                         }
245                 }
246         }
247
248         return replacements;
249 }
250
251 function names_for_team(team, expansions) {
252         if (expansions.hasOwnProperty(team)) {
253                 return expansions[team];
254         }
255         let longteam = team.replace("W ", "Win. ").replace("L ", "Los. ");
256         return [ longteam, longteam, team ];
257 }
258
259 function expand_mediumname_if_single_team(team, expansions) {
260         if (expansions.hasOwnProperty(team)) {
261                 return expansions[team][1];
262         }
263         return team;
264 }
265
266 function do_replacements(str, replacements) {
267         for (const r of replacements) {
268                 str = str.replace(r[0], r[1]);
269         }
270         return str;
271 }
272
273 function fill_playoff(replacements, teams) {
274         let team_expansions = {};
275         for (const group of teams) {
276                 for (const team of group) {
277                         team_expansions[team.name] = team_expansions[team.mediumname] = team_expansions[team.shortname] =
278                                 [ team.name, team.mediumname, team.shortname ];
279                 }
280         }
281
282         let games = ultimateconfig['playoff_games'];
283         get_results('Results', function(response) {
284                 let updates = [], meta_updates = [];
285                 let game_num = 0;
286                 for (const game of games) {
287                         let team1 = do_replacements(game[0], replacements);
288                         let team2 = do_replacements(game[1], replacements);
289                         let team1_mediumname = expand_mediumname_if_single_team(team1, team_expansions);
290                         let team2_mediumname = expand_mediumname_if_single_team(team2, team_expansions);
291                         let row = ultimateconfig['playoff_games_start_row'] + game[3];
292                         let cols = ultimateconfig['playoff_games_cols'][game[2]];
293                         let cell_team1 = "Results!" + String.fromCharCode(cols[0] + 65) + row;
294                         let cell_score1 = "Results!" + String.fromCharCode(cols[1] + 65) + row;
295                         let cell_score2 = "Results!" + String.fromCharCode(cols[2] + 65) + row;
296                         let cell_team2 = "Results!" + String.fromCharCode(cols[3] + 65) + row;
297                         updates.push({ "range": cell_team1, "values": [ [ team1_mediumname ] ] });
298                         updates.push({ "range": cell_team2, "values": [ [ team2_mediumname ] ] });
299
300                         let score1 = response['values'][row - 1][cols[1]];
301                         let score2 = response['values'][row - 1][cols[2]];
302                         let game_name = game[4];
303                         let game_name2 = game_name.replace("Semi", "semi");
304                         let game_day = game[7];
305                         if (game_day === undefined) {
306                                 game_day = 7;  // Sunday.
307                         }
308
309                         let range = {
310                                 "sheetId": ultimateconfig['score_sheet_index'],
311                                 "startColumnIndex": cols[1],
312                                 "endColumnIndex": cols[2] + 1,
313                                 "startRowIndex": row - 1,
314                                 "endRowIndex": row
315                         };
316
317                         if (parseInt(score1) >= 0 && parseInt(score2) >= 0 && score1 != score2) {
318                                 if (parseInt(score1) > parseInt(score2)) {
319                                         replacements.unshift(["W " + game_name, team1]);
320                                         replacements.unshift(["L " + game_name, team2]);
321                                         replacements.unshift(["W " + game_name2, team1]);
322                                         replacements.unshift(["L " + game_name2, team2]);
323                                 } else {
324                                         replacements.unshift(["W " + game_name, team2]);
325                                         replacements.unshift(["L " + game_name, team1]);
326                                         replacements.unshift(["W " + game_name2, team2]);
327                                         replacements.unshift(["L " + game_name2, team1]);
328                                 }
329                                 meta_updates.push({ "unmergeCells": { "range": range }});
330                         } else if (game[5]) {
331                                 // No score yet, so write the name of the game (e.g. “L-semi 1”)
332                                 // where the score would normally be, to mark what this game is called.
333                                 // This is useful with the limited space on the tablet.
334                                 score1 = score2 = "";
335                                 updates.push({ "range": cell_score1, "values": [ [ game[4] ] ] });
336                                 meta_updates.push({ "mergeCells": { "range": range, "mergeType": "MERGE_ALL" }});
337                         }
338
339                         if (game[2] == 0) {  // Stream field.
340                                 // Game.
341                                 updates.push({
342                                         "range": "Playoffs!A" + (game_num + 32) + ":J" + (game_num + 32),
343                                         "values": [ [ team1, team2, score1, score2, "", "", "", game_day, response['values'][row - 1][1].replace(".",":"), game[6] ] ]
344                                 });
345
346                                 // Team codes.
347                                 updates.push({
348                                         "range": "Playoffs!A" + (2 * game_num + 3) + ":C" + (2 * game_num + 3),
349                                         "values": [ names_for_team(team1, team_expansions) ]
350                                 });
351                                 updates.push({
352                                         "range": "Playoffs!A" + (2 * game_num + 4) + ":C" + (2 * game_num + 4),
353                                         "values": [ names_for_team(team2, team_expansions) ]
354                                 });
355
356                                 ++game_num;
357                         }
358                 }
359                 let json = {
360                         "valueInputOption": "USER_ENTERED",
361                         "data": updates 
362                 };
363                 let meta_json = {
364                         "requests": meta_updates
365                 };
366                 possibly_update_oauth_key(function() {
367                         post_json('https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values:batchUpdate?key=' + ultimateconfig['api_key'], json, function(response) {
368                                 get_group('Playoffs 9th-13th', function(response_l) { publish_group_rank(response_l, 'Playoffs 9th-13th'); });
369                         }, current_oauth_access_token);
370                         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);
371                 });
372         });
373 }
374
375 function get_results(sheet_name, cb)
376 {
377         let req = new XMLHttpRequest();
378         req.onload = function(e) {
379                 cb(JSON.parse(req.responseText), sheet_name);
380         };
381         req.open('GET', 'https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values/\'' + sheet_name + '\'!A1:Q50?key=' + ultimateconfig['api_key']);
382         req.send();
383 }
384
385 function publish_group_ranks() {
386         get_group('Group A', function(response_a) {
387                 get_group('Group B', function(response_b) {
388                         get_group('Group C', function(response_c) {
389                                 publish_group_rank(response_a, 'Group A');
390                                 publish_group_rank(response_b, 'Group B');
391                                 publish_group_rank(response_c, 'Group C');
392
393                                 let replacements = montecarlo([response_a, response_b, response_c]);
394                                 let team_a = parse_teams_from_spreadsheet(response_a);
395                                 let team_b = parse_teams_from_spreadsheet(response_b);
396                                 let team_c = parse_teams_from_spreadsheet(response_c);
397                                 fill_playoff(replacements, [team_a, team_b, team_c]);
398                         });
399                 });
400         });
401 }
402
403 function get_ranked(response, group_name) {
404         let teams = parse_teams_from_spreadsheet(response);
405         let games = parse_games_from_spreadsheet(response, group_name, false);
406         apply_games_to_teams(games, teams);
407         teams = filter_teams(teams, response);
408         let tiebreakers = [];
409         teams = rank(games, teams, 1, tiebreakers);
410         return teams;
411 }
412
413 // Pick out everything that is at rank N _or_ avoids rank N by lack of tiebreakers only.
414 function pick_out_rank(teams, rank, candidates) {
415         let lowest_rank = teams[rank - 1].rank;
416
417         let count = 0;
418         for (const team of teams) {
419                 if (team.rank >= lowest_rank && team.rank <= rank) {
420                         ++count;
421                 }
422         }
423
424         if (count >= teams.length / 2) {
425                 // We have no info yet, ignore this group.
426                 return;
427         }
428
429         for (const team of teams) {
430                 if (team.rank >= lowest_rank && team.rank <= rank) {
431                         candidates.push(team);
432                 }
433         }
434 }
435
436 function publish_best_thirds() {
437         get_group('Group A', function(response_a) {
438                 get_group('Group B', function(response_b) {
439                         get_group('Group C', function(response_c) {
440                                 let A = get_ranked(response_a, 'Group A');
441                                 let B = get_ranked(response_b, 'Group B');
442                                 let C = get_ranked(response_c, 'Group C');
443
444                                 let candidates = [];
445                                 pick_out_rank(A, 3, candidates);
446                                 pick_out_rank(B, 3, candidates);
447                                 pick_out_rank(C, 3, candidates);
448
449                                 let tiebreakers = [];
450                                 let text = "";
451                                 if (candidates.length >= 2) {
452                                         let ranked = rank_thirds([], candidates, 1, tiebreakers);
453                                         text = "Best thirds: " + ranked[0].mediumname + ", " + ranked[1].mediumname + "\n" + tiebreakers.join("\n");
454                                 }
455                                 let updates = [];
456                                 updates.push({ "range": ultimateconfig['explain_third_cell'], "values": [ [ text ] ] });
457                                 let json = {
458                                         "valueInputOption": "USER_ENTERED",
459                                         "data": updates 
460                                 };
461                                 possibly_update_oauth_key(function() {
462                                         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);
463                                 });
464                         });
465                 });
466         });
467 }
468
469 update_oauth_key();
470 setTimeout(function() {
471         publish_group_ranks();
472         publish_best_thirds();
473         setInterval(function() { publish_group_ranks(); publish_best_thirds(); }, 60000);
474 }, 5000);