]> git.sesse.net Git - ultimatescore/blobdiff - update_sheets.js
Make the roster scripts executable.
[ultimatescore] / update_sheets.js
index d9757b21bee385b92276b05dc5cc3f9cab171d89..24381358ed52713cca0f129ef5ac424cccede69e 100644 (file)
@@ -65,58 +65,562 @@ function possibly_update_oauth_key(cb) {
        }
 }
 
-function publish_group_rank(group_name)
+function publish_group_rank(teams, games, group_name)
 {
-       get_group(group_name, function(response, group_name) {
-               let updates = [];
-               let cols = ["Results!B", "Results!C", "Results!D"];
-               if (group_name === 'Group B') {
-                       cols = ["Results!F", "Results!G", "Results!H"];
-               }
+       let updates = [];
+       let config = ultimateconfig['group_cells'][group_name];
+       let cols = config['score_sheet_cols'];
 
-               let teams = parse_teams_from_spreadsheet(response);
-               let games = parse_games_from_spreadsheet(response, group_name, false);
-               apply_games_to_teams(games, teams);
+       apply_games_to_teams(games, teams, group_name);
+       teams.sort(function(a, b) { return a.seeding - b.seeding });
 
-               // Write the points total to the unsorted columns.
+       // Write the points total to the unsorted columns.
+       if (config['point_total_start_row'] !== null) {
                for (let i = 0; i < teams.length; ++i) {
-                       updates.push({ "range": cols[2] + (4 + i), "values": [ [ teams[i].pts ] ] });
+                       let row = config['point_total_start_row'] + i;
+                       updates.push({ "range": cols[2] + row, "values": [ [ teams[i].pts ] ] });
                }
+       }
 
-               let tiebreakers = [];
-               teams = rank(games, teams, 1, tiebreakers);
+       let tiebreakers = [];
+       teams = rank(games, teams, 1, tiebreakers);
 
-               // Write the ranking table, from scratch.
-               for (let i = 0; i < teams.length; ++i) {
-                       updates.push({ "range": cols[0] + (10 + i), "values": [ [ teams[i].rank ] ] });
-                       updates.push({ "range": cols[1] + (10 + i), "values": [ [ teams[i].shortname ] ] });
-                       updates.push({ "range": cols[2] + (10 + i), "values": [ [ teams[i].pts ] ] });
+       // Write the ranking table, from scratch.
+       for (let i = 0; i < teams.length; ++i) {
+               let row = config['ranking_list_start_row'] + i;
+               updates.push({ "range": cols[0] + row, "values": [ [ teams[i].rank + config['rank_offset'] - 1] ] });
+               updates.push({ "range": cols[1] + row, "values": [ [ teams[i].mediumname ] ] });
+               updates.push({ "range": cols[2] + row, "values": [ [ teams[i].pts ] ] });
+       }
+
+       let tb_str = "";
+       if (tiebreakers.length != 0) {
+               tb_str = tiebreakers.join("\n");
+       }
+       updates.push({ "range": config['ranking_list_explain_cell'], "values": [ [ tb_str ] ]});
+
+       let json = {
+               "valueInputOption": "USER_ENTERED",
+               "data": updates 
+       };
+       possibly_update_oauth_key(function() {
+               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);
+       });
+}
+
+function montecarlo(all_teams, groups, games, groups_to_calc) {
+       let pseudo_group_names = ['X', 'Y', 'Z'];
+       let real_group_names = ['A', 'B', 'C'];  // Better be corresponding to groups_to_calc...
+       let teams_to_idx = [];
+
+       let third_groups = [];
+       let busted_thirds = false;
+
+       // Split teams by group.
+       let teams = [];
+       for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
+               let teams_group = filter_teams_by_group(all_teams, groups, groups_to_calc[group_idx]);
+               teams.push(teams_group);
+               teams_to_idx.push(make_teams_to_idx(teams_group));
+       }
+
+       for (let simulation_idx = 0; simulation_idx < 100; ++simulation_idx) {  // 100 seems to be enough.
+               let thirds = [], ignoreds = [];
+               let calc_groups = [];
+
+               for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
+                       let teams_copy = [];
+                       for (const team of teams[group_idx]) {
+                               teams_copy.push(Object.assign({}, team));
+                       }
+
+                       // Fill in random results. We deliberately use a uniform [-13,+13]
+                       // model here, since we are interested in the extremal results.
+                       // Of course, not all real games go to 13, but the risk of that
+                       // influencing the tiebreakers is very slim.
+                       let games_copy = [], games_with_synth = [];
+                       for (const game of games) {
+                               games_copy.push(Object.assign({}, game));
+                       }
+
+                       for (let i = 0; i < games_copy.length; ++i) {
+                               let idx1 = teams_to_idx[group_idx][games_copy[i].name1];
+                               let idx2 = teams_to_idx[group_idx][games_copy[i].name2];
+                               if (idx1 === undefined || idx2 === undefined) continue;
+                               if (games_copy[i].score1 === undefined || games_copy[i].score2 === undefined ||
+                                   isNaN(games_copy[i].score1) || isNaN(games_copy[i].score2) ||
+                                   games_copy[i].score1 == games_copy[i].score2) {
+                                       // These were skipped by apply_games_to_teams() earlier.
+                                       let score1 = 0, score2 = 0;
+                                       let r = Math.floor(Math.random() * 26);
+                                       if (r < 13) {
+                                               score1 = 13;
+                                               score2 = r;
+                                               teams_copy[idx1].pts += 2;
+                                       } else {
+                                               score1 = r - 13;
+                                               score2 = 13;
+                                               teams_copy[idx2].pts += 2;
+                                       }
+                                       games_copy[i].score1 = score1;
+                                       games_copy[i].score2 = score2;
+                                       ++teams_copy[idx1].nplayed;
+                                       ++teams_copy[idx2].nplayed;
+                                       teams_copy[idx1].goals += score1;
+                                       teams_copy[idx2].goals += score2;
+                                       teams_copy[idx1].gd += score1;
+                                       teams_copy[idx2].gd += score2;
+                                       teams_copy[idx1].gd -= score2;
+                                       teams_copy[idx2].gd -= score1;
+
+                                       games_with_synth.push({
+                                               "name1": games_copy[i].name1,
+                                               "name2": games_copy[i].name2,
+                                               "score1": score1,
+                                               "score2": score2
+                                       });
+                               } else {
+                                       games_with_synth.push(games_copy[i]);
+                                       continue;
+                               }
+                       }
+                       
+                       // Now rank according to the simulation.
+                       let tiebreakers = [];
+                       teams_copy = rank(games_copy, teams_copy, 1, tiebreakers);
+
+                       // See if we have conflicting information with other simulations.
+                       if (simulation_idx == 0) {
+                               for (let i = 0; i < teams[group_idx].length; ++i) {
+                                       let idx = teams_to_idx[group_idx][teams_copy[i].name];
+                                       teams[group_idx][idx].simulated_rank = teams_copy[i].rank;
+                               }
+                       } else {
+                               for (let i = 0; i < teams[group_idx].length; ++i) {
+                                       let idx = teams_to_idx[group_idx][teams_copy[i].name];
+                                       if (teams[group_idx][idx].simulated_rank !== teams_copy[i].rank) {
+                                               teams[group_idx][idx].simulated_rank = null;
+                                       }
+                               }
+                       }
+
+                       if (!busted_thirds) {
+                               let any_third_found = false;
+                               for (let i = 0; i < teams[group_idx].length; ++i) {
+                                       // Store the third.
+                                       if (i == 2 || teams_copy[i].rank == 3) {
+                                               if (any_third_found) {
+                                                       busted_thirds = true;
+                                               } else {
+                                                       teams_copy[i].group_idx = group_idx;
+                                                       thirds.push(teams_copy[i]);
+                                                       any_third_found = true;
+                                               }
+                                       }
+                               }
+                               if (ultimateconfig['kick_fifth_from_third'] && teams_copy.length >= 5) {
+                                       if (teams_copy[4].rank != 5) {
+                                               // A real tie for fifth; the rules are unclear, so just give up.
+                                               busted_thirds = true;
+                                       } else {
+                                               ignoreds.push(teams_copy[4]);
+                                       }
+                               }
+                       }
+
+                       calc_groups.push({
+                               "games": games_with_synth,
+                               "teams": teams_copy
+                       });
+               }
+
+               // Also rank thirds.
+               if (!busted_thirds) {
+                       let tiebreakers = [];
+                       if (ultimateconfig['kick_fifth_from_third']) {
+                               // Recompute scores (but not ranks!) without the ignored games. (thirds point to these objects.)
+                               for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
+                                       apply_games_to_teams(calc_groups[group_idx].games, calc_groups[group_idx].teams, groups_to_calc[group_idx], ignoreds);
+                               }
+                       }
+                       let ranked = rank_thirds([], thirds, 1, tiebreakers);
+                       if (simulation_idx == 0) {
+                               third_groups = ranked;
+                       } else {
+                               for (let i = 0; i < groups_to_calc.length; ++i) {
+                                       if (third_groups[i].group_idx !== ranked[i].group_idx ||  // Different from a previous simulation.
+                                           (i < (third_groups.length - 1) && ranked[i].rank === ranked[i + 1].rank) ||  // Disallow ties.
+                                           (i > 0 && ranked[i].rank === ranked[i - 1].rank)) {  // Disallow ties.
+                                               third_groups[i].group_idx = null;
+                                       }
+                               }
+                       }
                }
+       }
+
+       let replacements = [];
+       for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
+               if (third_groups[group_idx].group_idx !== null) {
+                       replacements.push([ pseudo_group_names[group_idx], real_group_names[third_groups[group_idx].group_idx] ]);
+               }
+       }
+
+       // These are pretty hard-coded, but that's probably fine. Must come after we've concretized X, Y, etc.
+       for (const group_name of real_group_names) {
+               let teams = filter_teams_by_group(all_teams, groups, 'Group ' + group_name);
+               if (teams.length >= 5) {
+                       for (const other_group_name of real_group_names) {
+                               replacements.push([ group_name + other_group_name + '5', group_name + '5' ]);
+                       }
+                       for (const other_group_name of pseudo_group_names) {
+                               replacements.push([ group_name + other_group_name + '5', group_name + '5' ]);
+                       }
+               } else {  // Perhaps a bit overkill.
+                       for (const other_group_name of real_group_names) {
+                               replacements.push([ group_name + other_group_name + '5', other_group_name + '5' ]);
+                       }
+                       for (const other_group_name of pseudo_group_names) {
+                               replacements.push([ group_name + other_group_name + '5', other_group_name + '5' ]);
+                       }
+               }
+       }
 
-               let tb_str = "";
-               if (tiebreakers.length != 0) {
-                       tb_str = tiebreakers.join("\n");
+       for (let group_idx = 0; group_idx < groups_to_calc.length; ++group_idx) {
+               for (let i = 0; i < teams[group_idx].length; ++i) {
+                       if (teams[group_idx][i].simulated_rank !== null) {
+                               replacements.push([ real_group_names[group_idx] + teams[group_idx][i].simulated_rank, teams[group_idx][i].shortname ]);
+                       }
                }
-               updates.push({ "range": cols[0] + "15", "values": [ [ tb_str ] ]});
+       }
+
+       return replacements;
+}
+
+function names_for_team(team, expansions) {
+       if (expansions.hasOwnProperty(team)) {
+               return expansions[team];
+       }
+       let longteam = team.replace("W ", "Win. ").replace("L ", "Los. ");
+       return [ longteam, longteam, team ];
+}
+
+function expand_mediumname_if_single_team(team, expansions) {
+       if (expansions.hasOwnProperty(team)) {
+               return expansions[team][1];
+       }
+       return team;
+}
+
+function do_replacements(str, replacements) {
+       for (const r of replacements) {
+               str = str.replace(r[0], r[1]);
+       }
+       return str;
+}
+
+function fill_playoff(all_teams, groups, replacements) {
+       let team_expansions = {};
+       for (const team of all_teams) {
+               team_expansions[team.name] = team_expansions[team.mediumname] = team_expansions[team.shortname] =
+                       [ team.name, team.mediumname, team.shortname ];
+       }
+
+       let games = ultimateconfig['playoff_games'];
+       get_results('Results', function(response) {
+               let updates = [], meta_updates = [];
+               for (const game of games) {
+                       let team1 = do_replacements(game[0], replacements);
+                       let team2 = do_replacements(game[1], replacements);
+                       let team1_mediumname = expand_mediumname_if_single_team(team1, team_expansions);
+                       let team2_mediumname = expand_mediumname_if_single_team(team2, team_expansions);
+                       let row = ultimateconfig['playoff_games_start_row'] + game[3];
+                       let cols = ultimateconfig['playoff_games_cols'][game[2]];
+                       let cell_team1 = "Results!" + String.fromCharCode(cols[0] + 65) + row;
+                       let cell_score1 = "Results!" + String.fromCharCode(cols[1] + 65) + row;
+                       let cell_score2 = "Results!" + String.fromCharCode(cols[2] + 65) + row;
+                       let cell_team2 = "Results!" + String.fromCharCode(cols[3] + 65) + row;
+                       updates.push({ "range": cell_team1, "values": [ [ team1_mediumname ] ] });
+                       updates.push({ "range": cell_team2, "values": [ [ team2_mediumname ] ] });
+
+                       let score1 = response['values'][row - 1][cols[1]];
+                       let score2 = response['values'][row - 1][cols[2]];
+                       let game_name = game[4];
+                       let game_name2 = game_name.replace("Semi", "semi");
+                       let game_day = game[7];
+                       if (game_day === undefined) {
+                               game_day = 7;  // Sunday.
+                       }
+
+                       let range = {
+                               "sheetId": ultimateconfig['score_sheet_index'],
+                               "startColumnIndex": cols[1],
+                               "endColumnIndex": cols[2] + 1,
+                               "startRowIndex": row - 1,
+                               "endRowIndex": row
+                       };
 
+                       if (parseInt(score1) >= 0 && parseInt(score2) >= 0 && score1 != score2) {
+                               if (parseInt(score1) > parseInt(score2)) {
+                                       replacements.unshift(["W " + game_name, team1]);
+                                       replacements.unshift(["L " + game_name, team2]);
+                                       replacements.unshift(["W " + game_name2, team1]);
+                                       replacements.unshift(["L " + game_name2, team2]);
+                               } else {
+                                       replacements.unshift(["W " + game_name, team2]);
+                                       replacements.unshift(["L " + game_name, team1]);
+                                       replacements.unshift(["W " + game_name2, team2]);
+                                       replacements.unshift(["L " + game_name2, team1]);
+                               }
+                               meta_updates.push({ "unmergeCells": { "range": range }});
+                       } else if (game[5]) {
+                               // No score yet, so write the name of the game (e.g. “L-semi 1”)
+                               // where the score would normally be, to mark what this game is called.
+                               // This is useful with the limited space on the tablet.
+                               score1 = score2 = "";
+                               updates.push({ "range": cell_score1, "values": [ [ game[4] ] ] });
+                               meta_updates.push({ "mergeCells": { "range": range, "mergeType": "MERGE_ALL" }});
+                       }
+               }
                let json = {
                        "valueInputOption": "USER_ENTERED",
                        "data": updates 
                };
+               let meta_json = {
+                       "requests": meta_updates
+               };
                possibly_update_oauth_key(function() {
-                       post_json('https://sheets.googleapis.com/v4/spreadsheets/1ygfeR6njBUmLyuLRq7zfYW5slMWBGnj4XxzQQ4WJklU/values:batchUpdate?key=AIzaSyAuP9yQn8g0bSay6r_RpGtpFeIbwprH1TU', json, function(response) {}, current_oauth_access_token);
+                       if (updates.length > 0) {
+                               post_json('https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values:batchUpdate?key=' + ultimateconfig['api_key'], json, function(response) {
+                                       get_all_group_games(all_teams, groups, function(group_games) {
+                                               // NOTE: filter_teams_by_group will be delayed by one cycle
+                                               // after W P1 etc. becomes determined for the first time.
+                                               // Note that this requires the Groups sheet to pick out
+                                               // the right teams from the group matches in the Results sheet!
+                                               let teams_l1 = filter_teams_by_group(all_teams, groups, 'Playoffs 9th–11th');
+                                               let teams_l2 = filter_teams_by_group(all_teams, groups, 'Playoffs 12th–14th');
+                                               publish_group_rank(teams_l1, group_games, 'Playoffs 9th–11th');
+                                               publish_group_rank(teams_l2, group_games, 'Playoffs 12th–14th');
+                                       });
+                               }, current_oauth_access_token);
+                       }
+                       if (meta_updates.length > 0) {
+                               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);
+                       }
                });
-
        });
 }
 
+function get_results(sheet_name, cb)
+{
+       let req = new XMLHttpRequest();
+       req.onload = function(e) {
+               cb(JSON.parse(req.responseText), sheet_name);
+       };
+       req.open('GET', 'https://sheets.googleapis.com/v4/spreadsheets/' + ultimateconfig['score_sheet_id'] + '/values/\'' + sheet_name + '\'!A1:Q50?key=' + ultimateconfig['api_key']);
+       req.send();
+}
+
 function publish_group_ranks() {
-       publish_group_rank('Group A');
-       publish_group_rank('Group B');
+       get_teams(function(teams) {
+               get_groups(function(groups) {
+                       get_all_group_games(teams, groups, function(games) {
+                               let teams_a = filter_teams_by_group(teams, groups, 'Group A');
+                               let teams_b = filter_teams_by_group(teams, groups, 'Group B');
+                               let teams_c = filter_teams_by_group(teams, groups, 'Group C');
+                               publish_group_rank(teams_a, games, 'Group A');
+                               publish_group_rank(teams_b, games, 'Group B');
+                               publish_group_rank(teams_c, games, 'Group C');
+
+                               let replacements = montecarlo(teams, groups, games, ['Group A', 'Group B', 'Group C']);
+                               fill_playoff(teams, groups, replacements);
+                       });
+               });
+       });
+}
+
+function get_all_playoff_games(teams, groups, group_games, cb) {
+       let replacements = montecarlo(teams, groups, group_games, ['Group A', 'Group B', 'Group C']);
+       fill_playoff(teams, groups, replacements);  // To get the replacements.
+       let games = ultimateconfig['playoff_games'];
+       get_results('Results', function(response) {
+               let playoff_games = [];
+               for (const game of games) {
+                       let team1 = do_replacements(game[0], replacements);
+                       let team2 = do_replacements(game[1], replacements);
+                       let row = ultimateconfig['playoff_games_start_row'] + game[3];
+                       let cols = ultimateconfig['playoff_games_cols'][game[2]];
+                       let score1 = response['values'][row - 1][cols[1]];
+                       let score2 = response['values'][row - 1][cols[2]];
+                       let streamday = game[7];
+                       if (streamday === undefined && game[2] === 0) {  // Stream field is by default on stream.
+                               streamday = 7;
+                       }
+                        playoff_games.push({
+                                "name1": team1,
+                                "name2": team2,
+                                "score1": parseInt(score1),
+                                "score2": parseInt(score2),
+                                "streamday": streamday,
+                                "streamtime": response['values'][row - 1][1].replace('.', ':'),
+                                "group_name": game[6]
+                        });
+               }
+               cb(playoff_games);
+       });
+}
+
+function get_ranked(teams, games, group_name) {
+       apply_games_to_teams(games, teams, group_name);
+       let tiebreakers = [];
+       teams = rank(games, teams, 1, tiebreakers);
+       return teams;
+}
+
+// Pick out everything that is at rank N _or_ avoids rank N by lack of tiebreakers only.
+function pick_out_rank(teams, rank, candidates) {
+       if (teams.length < rank) {
+               return;
+       }
+
+       let lowest_rank = teams[rank - 1].rank;
+
+       let count = 0;
+       for (const team of teams) {
+               if (team.rank >= lowest_rank && team.rank <= rank) {
+                       ++count;
+               }
+       }
+
+       if (count >= teams.length / 2) {
+               // We have no info yet, ignore this group.
+               return;
+       }
+
+       for (const team of teams) {
+               if (team.rank >= lowest_rank && team.rank <= rank) {
+                       candidates.push(team);
+               }
+       }
+}
+
+function addsign(x)
+{
+       if (x < 0) {
+               return "−" + (-x);
+       } else if (x == 0) {
+               return "=0";
+       } else {
+               return "+" + x;
+       }
+}
+
+function publish_best_thirds() {
+       if (!ultimateconfig['best_thirds']) return;
+       get_teams(function(teams) {
+               get_groups(function(groups) {
+                       get_all_group_games(teams, groups, function(games) {
+                               let teams_a = filter_teams_by_group(teams, groups, 'Group A');
+                               let teams_b = filter_teams_by_group(teams, groups, 'Group B');
+                               let teams_c = filter_teams_by_group(teams, groups, 'Group C');
+                               let A = get_ranked(teams_a, games, 'Group A');
+                               let B = get_ranked(teams_b, games, 'Group B');
+                               let C = get_ranked(teams_c, games, 'Group C');
+
+                               let candidates = [];
+                               pick_out_rank(A, 3, candidates);
+                               pick_out_rank(B, 3, candidates);
+                               pick_out_rank(C, 3, candidates);
+
+                               let ignoreds = [];
+                               let ignored_games = [], ignored_games_expl = [];
+                               if (ultimateconfig['kick_fifth_from_third']) {
+                                       let ignoreds_A = [], ignoreds_B = [], ignoreds_C = [];
+                                       pick_out_rank(A, 5, ignoreds_A);
+                                       pick_out_rank(B, 5, ignoreds_B);
+                                       pick_out_rank(C, 5, ignoreds_C);
+
+                                       if (ignoreds_A.length >= 2) {
+                                               ignoreds_A = [ ignoreds_A[ignoreds_A.length - 1] ];
+                                       }
+                                       if (ignoreds_B.length >= 2) {
+                                               ignoreds_B = [ ignoreds_B[ignoreds_B.length - 1] ];
+                                       }
+                                       if (ignoreds_C.length >= 2) {
+                                               ignoreds_C = [ ignoreds_C[ignoreds_C.length - 1] ];
+                                       }
+                                       ignoreds = ignoreds_A.concat(ignoreds_B).concat(ignoreds_C);
+
+                                       // Protect the “candidates” array, so that apply_games_to_teams() further down
+                                       // doesn't modify it (we want to compare old and new).
+                                       A = jsonclone(A);
+                                       B = jsonclone(B);
+                                       C = jsonclone(C);
+
+                                       // Recompute scores (but not ranks!) without the ignored games.
+                                       apply_games_to_teams(games, A, 'Group A', ignoreds, ignored_games);
+                                       apply_games_to_teams(games, B, 'Group B', ignoreds, ignored_games);
+                                       apply_games_to_teams(games, C, 'Group C', ignoreds, ignored_games);
+
+                                       // Filter out ignored games involving the candidate thirds.
+                                       let candidates_to_idx = make_teams_to_idx(candidates);
+                                       for (const game of ignored_games) {
+                                               if (candidates_to_idx[game[0]] !== undefined ||
+                                                   candidates_to_idx[game[1]] !== undefined) {
+                                                       if (game[2]) {
+                                                               ignored_games_expl.push("Ignoring (arbitrarily) " + game[0] + "–" + game[1]);
+                                                       } else {
+                                                               ignored_games_expl.push("Ignoring " + game[0] + "–" + game[1]);
+                                                       }
+                                               }
+                                       }
+
+                                       let new_teams = A.concat(B).concat(C);
+                                       let new_teams_to_idx = make_teams_to_idx(new_teams);
+
+                                       // Move back the scores (points, gd, goals).
+                                       for (let cand of candidates) {
+                                               let new_version = new_teams[new_teams_to_idx[cand.shortname]];
+                                               if (cand.pts != new_version.pts ||
+                                                   cand.gd != new_version.gd ||
+                                                   cand.goals != new_version.goals) {
+                                                       cand.pts = new_version.pts;
+                                                       cand.gd = new_version.gd;
+                                                       cand.goals = new_version.goals;
+                                                       ignored_games_expl.push(cand.shortname + " at " + cand.pts + " pts, " + addsign(new_version.gd) + " GD");
+                                               }
+                                       }
+                               }
+
+                               let tiebreakers = [];
+                               let text = "";
+                               if (candidates.length >= 2) {
+                                       let ranked = rank_thirds([], candidates, 1, tiebreakers);
+                                       let best_thirds = ranked.filter(function(team) { return team.rank <= 2; });
+                                       if (best_thirds.length == 2) {
+                                               text = "Best thirds: " + best_thirds.map(function(team) { return team.mediumname }).join(', ') + "\n";
+                                               if (ignored_games_expl.length > 0) {
+                                                       text += ignored_games_expl.join("; ") + "\n";
+                                               }
+                                               text += tiebreakers.join("\n");
+                                       }
+                               }
+                               let updates = [];
+                               updates.push({ "range": ultimateconfig['explain_third_cell'], "values": [ [ text ] ] });
+                               let json = {
+                                       "valueInputOption": "USER_ENTERED",
+                                       "data": updates 
+                               };
+                               possibly_update_oauth_key(function() {
+                                       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);
+                               });
+                       });
+               });
+       });
 }
 
 update_oauth_key();
 setTimeout(function() {
        publish_group_ranks();
-       setInterval(function() { publish_group_ranks(); }, 60000);
+       publish_best_thirds();
+       setInterval(function() { publish_group_ranks(); publish_best_thirds(); }, 60000);
 }, 5000);