1 // Updates back to the Google spreadsheet.
2 // There's basically zero error handling here, but OK, missing some updates is fine, really.
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"
17 function post_data(url, contents, content_type, cb, auth) {
18 let req = new XMLHttpRequest();
19 req.onload = function(e) {
22 req.open('POST', url);
23 req.setRequestHeader("Content-type", content_type);
24 if (auth !== undefined) {
25 req.setRequestHeader("Authorization", "Bearer " + auth);
30 function post_json(url, json, cb, auth) {
31 post_data(url, JSON.stringify(json), "application/json;charset=UTF-8", cb, auth);
34 let current_oauth_access_token = null;
37 function update_oauth_key(cb) {
38 let now = Math.floor(new Date().getTime() / 1000);
40 "iss": jwt_key.client_email,
41 "scope": "https://www.googleapis.com/auth/spreadsheets",
42 "aud":"https://www.googleapis.com/oauth2/v4/token",
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",
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(); }
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...");
68 function publish_group_rank(teams, games, group_name)
71 let config = ultimateconfig['group_cells'][group_name];
72 let cols = config['score_sheet_cols'];
74 apply_games_to_teams(games, teams, group_name);
76 // Write the points total to the unsorted columns.
77 if (config['point_total_start_row'] !== null) {
78 for (let i = 0; i < teams.length; ++i) {
79 let row = config['point_total_start_row'] + i;
80 updates.push({ "range": cols[2] + row, "values": [ [ teams[i].pts ] ] });
85 teams = rank(games, teams, 1, tiebreakers);
87 // Write the ranking table, from scratch.
88 for (let i = 0; i < teams.length; ++i) {
89 let row = config['ranking_list_start_row'] + i;
90 updates.push({ "range": cols[0] + row, "values": [ [ teams[i].rank + config['rank_offset'] - 1] ] });
91 updates.push({ "range": cols[1] + row, "values": [ [ teams[i].mediumname ] ] });
92 updates.push({ "range": cols[2] + row, "values": [ [ teams[i].pts ] ] });
96 if (tiebreakers.length != 0) {
97 tb_str = tiebreakers.join("\n");
99 updates.push({ "range": config['ranking_list_explain_cell'], "values": [ [ tb_str ] ]});
102 "valueInputOption": "USER_ENTERED",
105 possibly_update_oauth_key(function() {
106 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 function montecarlo(all_teams, groups, games, groups_to_calc) {
111 let pseudo_group_names = ['X', 'Y', 'Z'];
112 let real_group_names = ['A', 'B', 'C']; // Better be corresponding to groups_to_calc...
113 let teams_to_idx = [];
115 let third_groups = [];
116 let busted_thirds = false;
118 // Split teams by group.
120 for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
121 let teams_group = filter_teams_by_group(all_teams, groups, groups_to_calc[group_idx]);
122 teams.push(teams_group);
123 teams_to_idx.push(make_teams_to_idx(teams_group));
126 for (let simulation_idx = 0; simulation_idx < 100; ++simulation_idx) { // 100 seems to be enough.
127 let thirds = [], ignoreds = [];
128 let calc_groups = [];
130 for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
132 for (const team of teams[group_idx]) {
133 teams_copy.push(Object.assign({}, team));
136 // Fill in random results. We deliberately use a uniform [-13,+13]
137 // model here, since we are interested in the extremal results.
138 // Of course, not all real games go to 13, but the risk of that
139 // influencing the tiebreakers is very slim.
140 let games_copy = [], games_with_synth = [];
141 for (const game of games) {
142 games_copy.push(Object.assign({}, game));
145 for (let i = 0; i < games_copy.length; ++i) {
146 let idx1 = teams_to_idx[group_idx][games_copy[i].name1];
147 let idx2 = teams_to_idx[group_idx][games_copy[i].name2];
148 if (idx1 === undefined || idx2 === undefined) continue;
149 if (games_copy[i].score1 === undefined || games_copy[i].score2 === undefined ||
150 isNaN(games_copy[i].score1) || isNaN(games_copy[i].score2) ||
151 games_copy[i].score1 == games_copy[i].score2) {
152 // These were skipped by apply_games_to_teams() earlier.
153 let score1 = 0, score2 = 0;
154 let r = Math.floor(Math.random() * 26);
158 teams_copy[idx1].pts += 2;
162 teams_copy[idx2].pts += 2;
164 games_copy[i].score1 = score1;
165 games_copy[i].score2 = score2;
166 ++teams_copy[idx1].nplayed;
167 ++teams_copy[idx2].nplayed;
168 teams_copy[idx1].goals += score1;
169 teams_copy[idx2].goals += score2;
170 teams_copy[idx1].gd += score1;
171 teams_copy[idx2].gd += score2;
172 teams_copy[idx1].gd -= score2;
173 teams_copy[idx2].gd -= score1;
175 games_with_synth.push({
176 "name1": games_copy[i].name1,
177 "name2": games_copy[i].name2,
182 games_with_synth.push(games_copy[i]);
187 // Now rank according to the simulation.
188 let tiebreakers = [];
189 teams_copy = rank(games_copy, teams_copy, 1, tiebreakers);
191 // See if we have conflicting information with other simulations.
192 if (simulation_idx == 0) {
193 for (let i = 0; i < teams[group_idx].length; ++i) {
194 let idx = teams_to_idx[group_idx][teams_copy[i].name];
195 teams[group_idx][idx].simulated_rank = teams_copy[i].rank;
198 for (let i = 0; i < teams[group_idx].length; ++i) {
199 let idx = teams_to_idx[group_idx][teams_copy[i].name];
200 if (teams[group_idx][idx].simulated_rank !== teams_copy[i].rank) {
201 teams[group_idx][idx].simulated_rank = null;
206 if (!busted_thirds) {
207 let any_third_found = false;
208 for (let i = 0; i < teams[group_idx].length; ++i) {
210 if (i == 2 || teams_copy[i].rank == 3) {
211 if (any_third_found) {
212 busted_thirds = true;
214 teams_copy[i].group_idx = group_idx;
215 thirds.push(teams_copy[i]);
216 any_third_found = true;
220 if (ultimateconfig['kick_fifth_from_third'] && teams_copy.length >= 5) {
221 if (teams_copy[4].rank != 5) {
222 // A real tie for fifth; the rules are unclear, so just give up.
223 busted_thirds = true;
225 ignoreds.push(teams_copy[4]);
231 "games": games_with_synth,
237 if (!busted_thirds) {
238 let tiebreakers = [];
239 if (ultimateconfig['kick_fifth_from_third']) {
240 // Recompute scores (but not ranks!) without the ignored games. (thirds point to these objects.)
241 for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
242 apply_games_to_teams(calc_groups[group_idx].games, calc_groups[group_idx].teams, groups_to_calc[group_idx], ignoreds);
245 let ranked = rank_thirds([], thirds, 1, tiebreakers);
246 if (simulation_idx == 0) {
247 third_groups = ranked;
249 for (let i = 0; i < groups_to_calc.length; ++i) {
250 if (third_groups[i].group_idx !== ranked[i].group_idx) {
251 third_groups[i].group_idx = null;
258 let replacements = [];
259 for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
260 if (third_groups[group_idx].group_idx !== null) {
261 replacements.push([ pseudo_group_names[group_idx], real_group_names[third_groups[group_idx].group_idx] ]);
265 for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
266 for (let i = 0; i < teams[group_idx].length; ++i) {
267 if (teams[group_idx][i].simulated_rank !== null) {
268 replacements.push([ real_group_names[group_idx] + teams[group_idx][i].simulated_rank, teams[group_idx][i].shortname ]);
276 function names_for_team(team, expansions) {
277 if (expansions.hasOwnProperty(team)) {
278 return expansions[team];
280 let longteam = team.replace("W ", "Win. ").replace("L ", "Los. ");
281 return [ longteam, longteam, team ];
284 function expand_mediumname_if_single_team(team, expansions) {
285 if (expansions.hasOwnProperty(team)) {
286 return expansions[team][1];
291 function do_replacements(str, replacements) {
292 for (const r of replacements) {
293 str = str.replace(r[0], r[1]);
298 function fill_playoff(all_teams, groups, replacements, teams) {
299 let team_expansions = {};
300 for (const team of all_teams) {
301 team_expansions[team.name] = team_expansions[team.mediumname] = team_expansions[team.shortname] =
302 [ team.name, team.mediumname, team.shortname ];
305 let games = ultimateconfig['playoff_games'];
306 get_results('Results', function(response) {
307 let updates = [], meta_updates = [];
308 for (const game of games) {
309 let team1 = do_replacements(game[0], replacements);
310 let team2 = do_replacements(game[1], replacements);
311 let team1_mediumname = expand_mediumname_if_single_team(team1, team_expansions);
312 let team2_mediumname = expand_mediumname_if_single_team(team2, team_expansions);
313 let row = ultimateconfig['playoff_games_start_row'] + game[3];
314 let cols = ultimateconfig['playoff_games_cols'][game[2]];
315 let cell_team1 = "Results!" + String.fromCharCode(cols[0] + 65) + row;
316 let cell_score1 = "Results!" + String.fromCharCode(cols[1] + 65) + row;
317 let cell_score2 = "Results!" + String.fromCharCode(cols[2] + 65) + row;
318 let cell_team2 = "Results!" + String.fromCharCode(cols[3] + 65) + row;
319 updates.push({ "range": cell_team1, "values": [ [ team1_mediumname ] ] });
320 updates.push({ "range": cell_team2, "values": [ [ team2_mediumname ] ] });
322 let score1 = response['values'][row - 1][cols[1]];
323 let score2 = response['values'][row - 1][cols[2]];
324 let game_name = game[4];
325 let game_name2 = game_name.replace("Semi", "semi");
326 let game_day = game[7];
327 if (game_day === undefined) {
328 game_day = 7; // Sunday.
332 "sheetId": ultimateconfig['score_sheet_index'],
333 "startColumnIndex": cols[1],
334 "endColumnIndex": cols[2] + 1,
335 "startRowIndex": row - 1,
339 if (parseInt(score1) >= 0 && parseInt(score2) >= 0 && score1 != score2) {
340 if (parseInt(score1) > parseInt(score2)) {
341 replacements.unshift(["W " + game_name, team1]);
342 replacements.unshift(["L " + game_name, team2]);
343 replacements.unshift(["W " + game_name2, team1]);
344 replacements.unshift(["L " + game_name2, team2]);
346 replacements.unshift(["W " + game_name, team2]);
347 replacements.unshift(["L " + game_name, team1]);
348 replacements.unshift(["W " + game_name2, team2]);
349 replacements.unshift(["L " + game_name2, team1]);
351 meta_updates.push({ "unmergeCells": { "range": range }});
352 } else if (game[5]) {
353 // No score yet, so write the name of the game (e.g. “L-semi 1”)
354 // where the score would normally be, to mark what this game is called.
355 // This is useful with the limited space on the tablet.
356 score1 = score2 = "";
357 updates.push({ "range": cell_score1, "values": [ [ game[4] ] ] });
358 meta_updates.push({ "mergeCells": { "range": range, "mergeType": "MERGE_ALL" }});
362 "valueInputOption": "USER_ENTERED",
366 "requests": meta_updates
368 possibly_update_oauth_key(function() {
369 if (updates.length > 0) {
370 post_json('https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values:batchUpdate?key=' + ultimateconfig['api_key'], json, function(response) {
371 get_all_group_games(all_teams, groups, function(group_games) {
372 // NOTE: filter_teams_by_group will be delayed by one cycle
373 // after W P1 etc. becomes determined for the first time.
374 let teams_l1 = filter_teams_by_group(all_teams, groups, 'Playoffs 9th–11th');
375 let teams_l2 = filter_teams_by_group(all_teams, groups, 'Playoffs 12th–14th');
376 publish_group_rank(teams_l1, group_games, 'Playoffs 9th–11th');
377 publish_group_rank(teams_l2, group_games, 'Playoffs 12th–14th');
379 }, current_oauth_access_token);
381 if (meta_updates.length > 0) {
382 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);
388 function get_results(sheet_name, cb)
390 let req = new XMLHttpRequest();
391 req.onload = function(e) {
392 cb(JSON.parse(req.responseText), sheet_name);
394 req.open('GET', 'https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values/\'' + sheet_name + '\'!A1:Q50?key=' + ultimateconfig['api_key']);
398 function publish_group_ranks() {
399 get_teams(function(teams) {
400 get_groups(function(groups) {
401 get_all_group_games(teams, groups, function(games) {
402 let teams_a = filter_teams_by_group(teams, groups, 'Group A');
403 let teams_b = filter_teams_by_group(teams, groups, 'Group B');
404 let teams_c = filter_teams_by_group(teams, groups, 'Group C');
405 publish_group_rank(teams_a, games, 'Group A');
406 publish_group_rank(teams_b, games, 'Group B');
407 publish_group_rank(teams_c, games, 'Group C');
409 let replacements = montecarlo(teams, groups, games, ['Group A', 'Group B', 'Group C']);
410 fill_playoff(teams, groups, replacements, [teams_a, teams_b, teams_c]);
416 function get_all_playoff_games(teams, groups, group_games, cb) {
417 let replacements = montecarlo(teams, groups, group_games, ['Group A', 'Group B', 'Group C']);
418 let games = ultimateconfig['playoff_games'];
419 get_results('Results', function(response) {
420 let playoff_games = [];
421 for (const game of games) {
422 let team1 = do_replacements(game[0], replacements);
423 let team2 = do_replacements(game[1], replacements);
424 let row = ultimateconfig['playoff_games_start_row'] + game[3];
425 let cols = ultimateconfig['playoff_games_cols'][game[2]];
426 let score1 = response['values'][row - 1][cols[1]];
427 let score2 = response['values'][row - 1][cols[2]];
428 let streamday = game[7];
429 if (streamday === undefined && game[2] === 0) { // Stream field is by default on stream.
435 "score1": parseInt(score1),
436 "score2": parseInt(score2),
437 "streamday": streamday,
438 "streamtime": response['values'][row - 1][1].replace('.', ':'),
439 "group_name": game[6]
446 function get_ranked(teams, games, group_name) {
447 apply_games_to_teams(games, teams, group_name);
448 let tiebreakers = [];
449 teams = rank(games, teams, 1, tiebreakers);
453 // Pick out everything that is at rank N _or_ avoids rank N by lack of tiebreakers only.
454 function pick_out_rank(teams, rank, candidates) {
455 if (teams.length < rank) {
459 let lowest_rank = teams[rank - 1].rank;
462 for (const team of teams) {
463 if (team.rank >= lowest_rank && team.rank <= rank) {
468 if (count >= teams.length / 2) {
469 // We have no info yet, ignore this group.
473 for (const team of teams) {
474 if (team.rank >= lowest_rank && team.rank <= rank) {
475 candidates.push(team);
491 function publish_best_thirds() {
492 if (!ultimateconfig['best_thirds']) return;
493 get_teams(function(teams) {
494 get_groups(function(groups) {
495 get_all_group_games(teams, groups, function(games) {
496 let teams_a = filter_teams_by_group(teams, groups, 'Group A');
497 let teams_b = filter_teams_by_group(teams, groups, 'Group B');
498 let teams_c = filter_teams_by_group(teams, groups, 'Group C');
499 let A = get_ranked(teams_a, games, 'Group A');
500 let B = get_ranked(teams_b, games, 'Group B');
501 let C = get_ranked(teams_c, games, 'Group C');
504 pick_out_rank(A, 3, candidates);
505 pick_out_rank(B, 3, candidates);
506 pick_out_rank(C, 3, candidates);
509 let ignored_games = [], ignored_games_expl = [];
510 if (ultimateconfig['kick_fifth_from_third']) {
511 let ignoreds_A = [], ignoreds_B = [], ignoreds_C = [];
512 pick_out_rank(A, 5, ignoreds_A);
513 pick_out_rank(B, 5, ignoreds_B);
514 pick_out_rank(C, 5, ignoreds_C);
516 if (ignoreds_A.length >= 2) {
517 ignoreds_A = [ ignoreds_A[ignoreds_A.length - 1] ];
519 if (ignoreds_B.length >= 2) {
520 ignoreds_B = [ ignoreds_B[ignoreds_B.length - 1] ];
522 if (ignoreds_C.length >= 2) {
523 ignoreds_C = [ ignoreds_C[ignoreds_C.length - 1] ];
525 ignoreds = ignoreds_A.concat(ignoreds_B).concat(ignoreds_C);
527 // Protect the “candidates” array, so that apply_games_to_teams() further down
528 // doesn't modify it (we want to compare old and new).
533 // Recompute scores (but not ranks!) without the ignored games.
534 apply_games_to_teams(games, A, 'Group A', ignoreds, ignored_games);
535 apply_games_to_teams(games, B, 'Group B', ignoreds, ignored_games);
536 apply_games_to_teams(games, C, 'Group C', ignoreds, ignored_games);
538 // Filter out ignored games involving the candidate thirds.
539 let candidates_to_idx = make_teams_to_idx(candidates);
540 for (const game of ignored_games) {
541 if (candidates_to_idx[game[0]] !== undefined ||
542 candidates_to_idx[game[1]] !== undefined) {
544 ignored_games_expl.push("Ignoring (arbitrarily) " + game[0] + "–" + game[1]);
546 ignored_games_expl.push("Ignoring " + game[0] + "–" + game[1]);
551 let new_teams = A.concat(B).concat(C);
552 let new_teams_to_idx = make_teams_to_idx(new_teams);
554 // Move back the scores (points, gd, goals).
555 for (let cand of candidates) {
556 let new_version = new_teams[new_teams_to_idx[cand.shortname]];
557 if (cand.pts != new_version.pts ||
558 cand.gd != new_version.gd ||
559 cand.goals != new_version.goals) {
560 cand.pts = new_version.pts;
561 cand.gd = new_version.gd;
562 cand.goals = new_version.goals;
563 ignored_games_expl.push(cand.shortname + " at " + cand.pts + " pts, " + addsign(new_version.gd) + " GD");
568 let tiebreakers = [];
570 if (candidates.length >= 2) {
571 let ranked = rank_thirds([], candidates, 1, tiebreakers);
572 let best_thirds = ranked.filter(function(team) { return team.rank <= 2; });
573 if (best_thirds.length == 2) {
574 text = "Best thirds: " + best_thirds.map(function(team) { return team.mediumname }).join(', ') + "\n";
575 if (ignored_games_expl.length > 0) {
576 text += ignored_games_expl.join("; ") + "\n";
578 text += tiebreakers.join("\n");
582 updates.push({ "range": ultimateconfig['explain_third_cell'], "values": [ [ text ] ] });
584 "valueInputOption": "USER_ENTERED",
587 possibly_update_oauth_key(function() {
588 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);
596 setTimeout(function() {
597 publish_group_ranks();
598 publish_best_thirds();
599 setInterval(function() { publish_group_ranks(); publish_best_thirds(); }, 60000);