// ==UserScript==
// @name Geoguessr duel guess times & team duels player list
// @version 1.4.0
// @description Display guess times, rating changes for duels, and a list of players for team duels
// @match https://www.geoguessr.com/*
// @author victheturtle#5159
// @grant none
// @license MIT
// @require https://greasyfork.org/scripts/460322-geoguessr-styles-scan/code/Geoguessr%20Styles%20Scan.js?version=1151654
// @icon https://www.svgrepo.com/show/139928/katana.svg
// @namespace https://greasyfork.org/users/967692-victheturtle
// ==/UserScript==
let game = {};
let doingRequest = false;
let lastUrlDone = 0;
const green = () => cn("game-summary_healing__");
const red = () => cn("game-summary_damage__");
const grey = () => cn("game-summary_smallText__");
const big_white = () => cn("game-summary_text__");
const summary_table = () => cn("game-summary_playedRounds__");
const summary_line = () => cn("game-summary_playedRound__");
const summary_text = () => cn("game-summary_text__");
const replay_header = () => cn("replay_playedRoundsHeader__");
const color = (diff) => (diff>=0) ? ((diff==0) ? grey() : green()) : red();
const greenOrGrey = (diff) => (diff>0) ? green() : grey();
const replay_compact = () => cn("game-summary_compact__");
const replay_table = () => cn("replay_playedRounds__");
const rounds_header = () => cn("game-summary_playedRoundsHeader__");
const best_guess_value = () => cn("game-summary_bestGuessValue__");
const user_nick_root = () => cn("user-nick_root__");
const user_nick_wrapper = () => cn("user-nick_nickWrapper__");
const user_nick_nick = () => cn("user-nick_nick__");
const user_nick_verified_wrapper = () => cn("user-nick_verifiedWrapper__");
const user_nick_verified = () => cn("user-nick_verified__");
const verified_badge_svg = "/_next/static/images/verified-badge-566f0efd4d90928c6e044cbe588456dc.svg"
const ignore_list = ["633a8a81af04a94fb02d8b1b", "633c8040723d43ea09977ea2"]; // Plonk It bots
const style = document.createElement("style");
document.head.appendChild(style);
style.sheet.insertRule(".GDGTtooltip { position: relative; display: inline-block; }");
style.sheet.insertRule(`.GDGTtooltip .GDGTtooltiptext {
visibility: hidden; width: 11rem; background-color: black; color: white; text-align: center; padding: 5px 0; border-radius: 6px;
top: 100%; left: 50%; margin-left: -5.5rem; position: absolute; z-index: 0.5; }`);
style.sheet.insertRule(".GDGTtooltip:hover .GDGTtooltiptext { visibility: visible; }");
style.sheet.insertRule('h1[class*="game-summary_summaryTitle__"] { z-index: 1 }')
style.sheet.insertRule('div[class*="game-summary_mapContainer__"] { z-index: 1 }')
function checkURL() {
if (location.pathname.includes("duels") && location.pathname.endsWith("/summary") && document.querySelector('[class*="game-summary_playedRounds__"]') != null) return 1;
if (location.pathname.includes("duels") && location.pathname.endsWith("/replay") && document.querySelector('[class*="replay_playedRoundsHeader__"]') != null) return 2;
return 0;
};
function round(x) {
return Math.round(x * 10) / 10;
}
function handleTeamDuels(isReplay) {
const result_lines = document.getElementsByClassName(summary_line());
const inversion = document.querySelector(`#__next div.${(isReplay) ? replay_header() : rounds_header()} img`).alt.includes(game.teams[1].name);
const roundResults1 = game.teams[inversion ? 1 : 0].roundResults;
const roundResults2 = game.teams[inversion ? 0 : 1].roundResults;
const team1Players = game.teams[inversion ? 1 : 0].players;
const team2Players = game.teams[inversion ? 0 : 1].players;
for (let i = 0; i < result_lines.length; i++) {
const time0 = new Date(game.rounds[i].startTime);
// Check for no guess
if (roundResults1[i].bestGuess == null) roundResults1[i].bestGuess = {created:NaN};
const time1 = new Date(roundResults1[i].bestGuess.created);
let team1Earliest = time1;
// Loop through players to check for earlier guess
team1Players.forEach(player => {
if (player.guesses.length <= i || player.guesses[i].roundNumber != i+1) player.guesses.splice(i,0,{created:NaN});
if (!isNaN(player.guesses[i]).created) {
let tempTime = new Date(player.guesses[i].created);
if ((tempTime - team1Earliest) < 0) {
team1Earliest = tempTime;
}
}
})
if (roundResults2[i].bestGuess == null) roundResults2[i].bestGuess = {created:NaN};
const time2 = new Date(roundResults2[i].bestGuess.created);
let team2Earliest = time2;
// Loop through players to check for earlier guess
team2Players.forEach(player => {
if (player.guesses.length <= i || player.guesses[i].roundNumber != i+1) player.guesses.splice(i,0,{created:NaN});
if (!isNaN(player.guesses[i]).created) {
let tempTime = new Date(player.guesses[i].created);
if ((tempTime - team2Earliest) < 0) {
team2Earliest = tempTime;
}
}
})
// Add tooltip on the line header text to show the full date of the round
const header = result_lines[i].childNodes[0].childNodes[0].childNodes[0];
header.classList.add("GDGTtooltip");
const globalTimeText = `${game.rounds[i].startTime.substr(0, 19)} - ${game.rounds[i].endTime.substr(11, 8)}`.replace("T", "<br/>");
header.innerHTML += `<span class="GDGTtooltiptext">${globalTimeText}</span>`;
// If one team didn't guess, then the team that did has a green timestamp, otherwise compare
const text1 = document.createElement("div");
text1.classList.add(grey());
text1.innerText = isNaN(time1) ? "-" : round((time1-time0)/1000.) + " s";
result_lines[i].childNodes[1].appendChild(text1);
const text2 = document.createElement("div");
text2.classList.add(grey());
text2.innerText = isNaN(time2) ? "-" : round((time2-time0)/1000.) + " s";
result_lines[i].childNodes[2].appendChild(text2);
// Add the earliest guess for each team
const t1EarlyDiv = document.createElement("div");
t1EarlyDiv.classList.add(isNaN(team2Earliest) ? green() : greenOrGrey(team2Earliest-team1Earliest));
t1EarlyDiv.innerText = isNaN(team1Earliest) ? "-" : "Team Earliest: " + round((team1Earliest-time0)/1000.) + " s";
result_lines[i].childNodes[1].appendChild(t1EarlyDiv);
const t2EarlyDiv = document.createElement("div");
t2EarlyDiv.classList.add(isNaN(team2Earliest) ? green() : greenOrGrey(team1Earliest-team2Earliest));
t2EarlyDiv.innerText = isNaN(team2Earliest) ? "-" : "Team Earliest: " + round((team2Earliest-time0)/1000.) + " s";
result_lines[i].childNodes[2].appendChild(t2EarlyDiv);
};
if (game.options.isRated) {
addRatingChanges(isReplay, true, team1Players[0], team2Players[0]);
}
addProfileLinks(isReplay, inversion);
}
function handleDuels(isReplay) {
const result_lines = document.getElementsByClassName(summary_line());
const player2_link = document.getElementsByClassName((isReplay) ? replay_header() : rounds_header())[0].children[2].firstChild.href;
const player2_id = player2_link.slice(player2_link.lastIndexOf("/")+1);
const inversion = game.teams[1].players[0].playerId != player2_id && player2_id != "profile";
const player1 = game.teams[inversion ? 1 : 0].players[0];
const player2 = game.teams[inversion ? 0 : 1].players[0];
const guesses1 = player1.guesses;
const guesses2 = player2.guesses;
for (let i = 0; i < result_lines.length; i++) {
const time0 = (typeof game.rounds[i].startTime === "string") ? Date.parse(game.rounds[i].startTime) : game.rounds[i].startTime;
// Check for no guess
if (guesses1.length <= i || guesses1[i].roundNumber != i+1) guesses1.splice(i, 0, {created:NaN});
const time1 = (typeof guesses1[i].created === "string") ? Date.parse(guesses1[i].created) : guesses1[i].created;
if (guesses2.length <= i || guesses2[i].roundNumber != i+1) guesses2.splice(i, 0, {created:NaN});
const time2 = (typeof guesses2[i].created === "string") ? Date.parse(guesses2[i].created) : guesses2[i].created;
const text1 = document.createElement("div");
// Add tooltip on the line header text to show the full date of the round
if (!isReplay) {
const header = result_lines[i].childNodes[0].childNodes[0].childNodes[0];
header.classList.add("GDGTtooltip");
const globalTimeText = `${game.rounds[i].startTime.substr(0, 19)} - ${game.rounds[i].endTime.substr(11, 8)}`.replace("T", "<br/>");
header.innerHTML += `<span class="GDGTtooltiptext">${globalTimeText}</span>`;
}
// If one team didn't guess, then the team that did has a green timestamp, otherwise compare
text1.classList.add(isNaN(time2) ? green() : greenOrGrey(time2-time1));
if (isReplay) text1.classList.add(replay_compact());
text1.innerText = isNaN(time1) ? "-" : (time1-time0)/1000. + " s";
result_lines[i].childNodes[1].appendChild(text1);
const text2 = document.createElement("div");
text2.classList.add(isNaN(time1) ? green() : greenOrGrey(time1-time2));
if (isReplay) text2.classList.add(replay_compact());
text2.innerText = isNaN(time2) ? "-" : (time2-time0)/1000. + " s";
result_lines[i].childNodes[2].appendChild(text2);
}
if (game.options.isRated) {
addRatingChanges(isReplay, false, player1, player2);
}
}
function addRatingChanges(isReplay, isTeamDuel, player1, player2) {
const compact = (isReplay) ? " " + replay_compact() : ""
const summary = document.getElementsByClassName((isReplay) ? replay_table() : summary_table())[0];
const newRatingLine = document.createElement("div");
newRatingLine.classList.add(summary_line());
if (isReplay) newRatingLine.classList.add(replay_compact());
// currently, rankedSystemProgress is used, but old summaries don't have this field, for them we need to use competitiveProgress
// also, for solo duels without ranking changes, the progressChange field might be missing, but player.rating is always be there
const progressField = isTeamDuel ? "rankedTeamDuelsProgress" : "rankedSystemProgress";
const legacyField = "competitiveProgress";
const oldRating1 = (player1.progressChange?.[progressField] || player1.progressChange?.[legacyField])?.ratingBefore || 0;
const newRating1 = (player1.progressChange?.[progressField] || player1.progressChange?.[legacyField])?.ratingAfter || 0;
const oldRating2 = (player2.progressChange?.[progressField] || player2.progressChange?.[legacyField])?.ratingBefore || 0;
const newRating2 = (player2.progressChange?.[progressField] || player2.progressChange?.[legacyField])?.ratingAfter || 0;
// we can't rely on player.rating for team duels because this is the duels elo, which is different from the team duels elo
const fallback1 = isTeamDuel ? "unknown" : player1.rating;
const fallback2 = isTeamDuel ? "unknown" : player2.rating;
newRatingLine.innerHTML = `
<div><span><div class="${grey()}${compact}">Rating change</div><div class="${big_white()}${compact}">New rating</div></span></div>
<div><div class="${color(newRating1-oldRating1)}${compact}">${newRating1-oldRating1}</div><div class="${big_white()}${compact}">${newRating1 || fallback1}</div></div>
<div><div class="${color(newRating2-oldRating2)}${compact}">${newRating2-oldRating2}</div><div class="${big_white()}${compact}">${newRating2 || fallback2}</div></div>
<div><div class="${big_white()}${compact}"> </div></div>
<div><div class="${big_white()}${compact}"> </div></div>`;
summary.appendChild(newRatingLine);
};
function addProfileLinks(isReplay, inversion) {
const nameMap = {};
const teamMap = {};
const verifiedMap = {};
const gameRef = __NEXT_DATA__.props.pageProps.game
if (!gameRef) return; // you'll have to refresh to get that extra header line
const teamName1 = gameRef.teams[0].name
const teamName2 = gameRef.teams[1].name
gameRef.teams[0].players.map(y => {
nameMap[y.playerId] = y.nick; verifiedMap[y.playerId] = y.isVerified; teamMap[y.playerId] = teamName1;
});
gameRef.teams[1].players.map(y => {
nameMap[y.playerId] = y.nick; verifiedMap[y.playerId] = y.isVerified; teamMap[y.playerId] = teamName2;
});
const playerTemplate = (playerId) => `<span class="${best_guess_value()}" style="margin:2px"><div class="${user_nick_root()}">
<div class="${user_nick_wrapper()}">
<div class="${user_nick_nick()}"><a href="/user/${playerId}" style="color:white">${nameMap[playerId]} </a></div>
${verifiedMap[playerId] ? `<div class="${user_nick_verified_wrapper()}"><img class="${user_nick_verified()}" src="${verified_badge_svg}" alt="Verified user"></div>` : ''}
</div>
</div></span>`;
const teamTemplate = (team) => {
let s = "";
for (let playerId in nameMap) {
if (teamMap[playerId] == team && !ignore_list.includes(playerId)) {
s = s + playerTemplate(playerId);
}
}
return s;
}
const mapTemplate = (mapId, mapName) => `<span class="${best_guess_value()}" style="margin:2px"><div class="${user_nick_root()}">
<div class="${user_nick_wrapper()}">
<div class="${user_nick_nick()}"><a href="/maps/${mapId}" style="color:white">${mapName} </a></div>
</div>
</div></span>`;
const movementTemplate = (text) => `<span class="${summary_text()}" style="margin:2px">${text}</span>`;
const options = gameRef.options;
const playersLine = document.createElement("div");
playersLine.classList.add(summary_line());
const rules = {NM: options.movementOptions.forbidMoving, NP: options.movementOptions.forbidRotating, NZ: options.movementOptions.forbidZooming};
const isMoving = !rules.NM && !rules.NP && !rules.NZ
const movementType = (isMoving) ? "Moving" : `N${(rules.NM) ? "M" : ""}${(rules.NP) ? "P" : ""}${(rules.NZ) ? "Z" : ""}`;
playersLine.innerHTML = `
<div><span><div class="${summary_text()}">Players</div></span></div>
<div>${teamTemplate((inversion) ? teamName2 : teamName1)}</div>
<div>${teamTemplate((inversion) ? teamName1 : teamName2)}</div>
<div>${movementTemplate(movementType)}</div>
<div>${mapTemplate(options.map?.slug, options.map?.name || "(private map)")}</div>`;
if (isReplay) {
playersLine.classList.add(replay_compact());
}
const summary = document.getElementsByClassName((isReplay) ? replay_table() : summary_table())[0];
summary.insertBefore(playersLine, summary.firstChild);
};
function check() {
const split = location.pathname.split("/");
const api_url = `https://game-server.geoguessr.com/api/duels/${(split[2].length > 5) ? split[2] : split[3]}`;
doingRequest = true;
fetch(api_url, {method: "GET", "credentials": "include"})
.then(res => res.json())
.then(json => {
doingRequest = false;
game = json;
const urlType = checkURL()
if (urlType != 0 && lastUrlDone != urlType) {
lastUrlDone = urlType;
scanStyles().then(_ => {
const isReplay = location.pathname.includes("replay");
if (game.options.isTeamDuels) handleTeamDuels(isReplay);
else handleDuels(isReplay);
});
}
}).catch(err => { doingRequest = false; throw(err); });
};
function doCheck() {
scanStyles().then(_ => {
const urlType = checkURL()
if (urlType == 0) {
lastUrlDone = 0;
} else if (game != {} && lastUrlDone != urlType && !doingRequest) {
check();
}
});
};
new MutationObserver((mutations) => {
if (checkURL() == 0) return;
doCheck();
}).observe(document.body, { subtree: true, childList: true });