// ==UserScript== // @name Geoguessr head-to-head duel statistics // @namespace http://tampermonkey.net/ // @version 0.5.0 // @description Index competitive duels and display head-to-head statistics on user pages // @author irrational // @match https://www.geoguessr.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=geoguessr.com // @license MIT // @require https://greasyfork.org/scripts/460322-geoguessr-styles-scan/code/Geoguessr%20Styles%20Scan.js?version=1408713 // @grant GM_registerMenuCommand // @downloadURL https://update.greasyfork.icu/scripts/550251/Geoguessr%20head-to-head%20duel%20statistics.user.js // @updateURL https://update.greasyfork.icu/scripts/550251/Geoguessr%20head-to-head%20duel%20statistics.meta.js // ==/UserScript== const THE_INFINITE_PAST = null; const ECMASCRIPT_MIN_TIME = new Date(-8640000000000000); // https://262.ecma-international.org/15.0/#sec-time-values-and-time-range const USERSCRIPT_DATA_BLOCK_CLASS = '__userscript_data_block'; const USERSCRIPT_DATA_ELEMENT_CLASS = '__userscript_data_element'; const USERSCRIPT_GAME_LIST_CLASS = '__userscript_game_list'; const USERSCRIPT_GAME_LIST_CLASS_INACTIVE = '__userscript_game_list_inactive'; const USERSCRIPT_UPDATE_NOTIFICATION_CLASS = '__userscript_update_notification'; const USERSCRIPT_CUSTOM_SPACER_CLASS = '__userscript_custom_spacer'; const RENEW_TIME_SECONDS = 3600; const DUEL_DOWNLOAD_SPACING_SECONDS = 0.3; const GAME_LIST_MAX_COUNT = 20; const DATABASE_VERSION = 7; const delay = async (ms) => new Promise(resolve => setTimeout(resolve, ms)); const fetchJSON = async (url, retryDelay = 10000) => { return fetch(url, {credentials: "include"}) .then(async (response) => { if (response.status === 429) { await delay(retryDelay); return fetchJSON(url, retryDelay); } return response.ok ? response.json() : Promise.resolve(null); }, async (error) => { /* Because Geoguessr's 429s can (or regularly do?) not include correct CORS headers, we can end up here and have to just guess and hope that it's due to a 429. */ await delay(retryDelay); return fetchJSON(url, retryDelay); }); }; const fetchFeed = async (userId, fromDate) => { const date = fromDate.toISOString(); /* Pagination tokens are really a base64-encoded LastEvaluatedKey from Amazon DynamoDB, which contains the starting date of items on the next page. Conveniently, this allows us to query the feed from a starting date of our choosing. */ const token = btoa(JSON.stringify({ HashKey: {S: `${userId}_activity`}, Created: {S: `${date}`}})); return fetchJSON(`https://www.geoguessr.com/api/v4/feed/private?paginationToken=${token}`); }; const fetchDuel = async (duelId) => { return fetchJSON(`https://game-server.geoguessr.com/api/duels/${duelId}`); }; const fetchUserId = async () => { return fetchJSON('https://www.geoguessr.com/api/v3/profiles') .then((json) => json.user.id); }; const fetchTeam = async (userId, members) => { const mapTeamToReturnValues = (team) => { return { name: team.teamName, ids: team.members.map(member => member.userId), nicks: team.members.map(member => member.nick), ccs: team.members.map(member => member.countryCode)}; }; return fetchJSON('https://www.geoguessr.com/api/v4/ranked-team-duels/teams?' + members.map(member => `userId=${member}`).join('&')).then(async json => { if (json) { /* This is a currently ranked team, so we got a response. */ return mapTeamToReturnValues(json); } else if (members.includes(userId)) { /* This endpoint is the most reliable if available, it returns data even for inactive teams. Unfortunately, it is quite slow as it can respond with about a megabyte of data, most of which we don't care about. So we only use it if we must. */ const partner = members[0] == userId ? members[1] : members[0]; return fetchJSON('https://www.geoguessr.com/api/v4/ranked-team-duels/me/teams/' + partner).then( json => { return mapTeamToReturnValues(json.team); }); } else { /* Team not including us is no longer on the leaderboard; we have to build our own record. */ const team = { ids: members, nicks: [], ccs: [] }; for (const member of members) { const user = await fetchJSON('https://www.geoguessr.com/api/v3/users/' + member); if (user) { team.nicks.push(user.nick); team.ccs.push(user.countryCode); } else { /* Likely a deleted account */ team.nicks.push("Anonymous"); team.ccs.push("zz"); } } team.name = team.nicks.map(nick => nick.substring(0, 3)).join('/'); return team; } }); }; const extractDuelData = (duel, userId) => { let winner, player, opponent, winners, partner, opponents; if (duel.result.isDraw) return null; const isTeamDuel = duel.teams[0].players.length > 1; for (const team of duel.teams) { if (team.id == duel.result.winningTeamId) { winner = team.players[0]; winners = team.players; } if (team.players.some(player => player.playerId == userId)) { if (team.players[0].playerId == userId) { player = team.players[0]; if (isTeamDuel) partner = team.players[1]; } else { player = team.players[1]; partner = team.players[0]; } } else { opponent = team.players[0]; opponents = team.players; } } /* Sometimes the rating system fails after a duel, in which case progressChange is null. */ const playerProgress = isTeamDuel ? player.progressChange?.rankedTeamDuelsProgress : player.progressChange?.rankedSystemProgress; const opponentProgress = isTeamDuel ? opponent.progressChange?.rankedTeamDuelsProgress : opponent.progressChange?.rankedSystemProgress; const scoreResult = isTeamDuel ? {partner: partner.playerId, opponents: opponents.map(player => player.playerId), winners: winners.map(player => player.playerId)} : {opponent: opponent.playerId, winner: winner.playerId}; const overallRatingResult = { ourEloBefore: playerProgress?.ratingBefore, theirEloBefore: opponentProgress?.ratingBefore, ourEloAfter: playerProgress?.ratingAfter, theirEloAfter: opponentProgress?.ratingAfter }; const gameModeRatingResult = isTeamDuel ? {} : { ourModeEloBefore: playerProgress?.gameModeRatingBefore, theirModeEloBefore: opponentProgress?.gameModeRatingBefore, ourModeEloAfter: playerProgress?.gameModeRatingAfter, theirModeEloAfter: opponentProgress?.gameModeRatingAfter }; return {...scoreResult, ...overallRatingResult, ...gameModeRatingResult}; }; const extractRoundData = (duel, userId) => { if (duel.result.isDraw) return null; if (duel.teams[0].players.length > 1) return null; const weAreTeam0 = duel.teams[0].players.some(player => player.playerId == userId); const us = weAreTeam0 ? duel.teams[0].players[0] : duel.teams[1].players[0]; const them = weAreTeam0 ? duel.teams[1].players[0] : duel.teams[0].players[0]; let rounds = []; for (let round = 0; round < duel.currentRoundNumber; ++round) { const duelRound = duel.rounds[round]; const ourGuess = us.guesses[round]; const theirGuess = them.guesses[round]; const roundData = { roundNumber: duelRound.roundNumber, panorama: duelRound.panorama, startTime: new Date(duelRound.startTime), timerStartTime: new Date(duelRound.timerStartTime), endTime: new Date(duelRound.endTime), multiplier: duelRound.multiplier, ourGuess: ourGuess ? { lat: ourGuess.lat, lng: ourGuess.lng, guessTime: new Date(ourGuess.created), distance: ourGuess.distance, score: ourGuess.score } : null, theirGuess: theirGuess ? { lat: theirGuess.lat, lng: theirGuess.lng, guessTime: new Date(theirGuess.created), distance: theirGuess.distance, score: theirGuess.score } : null, }; rounds.push(roundData); } return rounds; }; const processDuel = async (payload, time, userId) => { const duel = await fetchDuel(payload.gameId); if (duel) { /* For mysterious reasons, duels can go permanently missing from Geoguessr's database. */ const duelData = extractDuelData(duel, userId); const roundData = extractRoundData(duel, userId); let rounds = null; if (duelData) { duelData.gameId = payload.gameId; duelData.time = new Date(time); duelData.gameMode = payload.competitiveGameMode; } if (roundData) { rounds = { gameId: payload.gameId, time: new Date(time), gameMode: payload.competitiveGameMode, rounds: roundData } } return { duelData: duelData, roundData: rounds }; } else { console.log("Skipped irretrievable duel:", payload.gameId); return null; } }; const downloadDuel = async (duels, rounds, payload, time, userId, db) => { const tx = db.transaction([`duels_${userId}`, `teamduels_${userId}`], 'readonly'); const duelsStore = tx.objectStore(`duels_${userId}`); const teamDuelsStore = tx.objectStore(`teamduels_${userId}`); const gameIdIndex = teamDuelsStore.index('gameIdIndex'); if ((payload.gameMode == "TeamDuels" && ! await hasKey(gameIdIndex, payload.gameId)) || (payload.gameMode == "Duels" && ! await hasKey(duelsStore, payload.gameId))) { const startTime = new Date(); const data = await processDuel(payload, time, userId); if (data.duelData) duels.push(data.duelData); if (data.roundData) rounds.push(data.roundData); /* Try to avoid rate limits. */ await delay(1000*DUEL_DOWNLOAD_SPACING_SECONDS - (new Date() - startTime)); } }; const processFeed = async (feed, userId, db) => { let duels = {Duels: [], TeamDuels: []}; let rounds = []; for (const entry of feed.entries) { if (entry.type == 7) { // a list of (team) duels const payload = JSON.parse(entry.payload); for (const game of payload) { if (game.type == 6) { // a competitive duel or team duel await downloadDuel(duels[game.payload.gameMode], rounds, game.payload, game.time, userId, db); } } } else if (entry.type == 6) { // a single (team) duel const payload = JSON.parse(entry.payload); await downloadDuel(duels[payload.gameMode], rounds, payload, entry.time, userId, db); } } // On the last retrievable page of activity, the paginationToken is null. let toDate = THE_INFINITE_PAST; if (feed.paginationToken) { const token = JSON.parse(atob(feed.paginationToken)); toDate = new Date(token.Created.S); } return {duels: duels.Duels, teamDuels: duels.TeamDuels, rounds: rounds, toDate: toDate}; }; const openDB = async (userId) => { const request = indexedDB.open('userscript_duels', DATABASE_VERSION); request.onupgradeneeded = (event) => { const db = event.target.result; const oldVersion = event.oldVersion; if (oldVersion < 1) { const duelsStore = db.createObjectStore('duels', {keyPath: 'gameId'}); duelsStore.createIndex('opponentIndex', 'opponent', {unique: false}); duelsStore.createIndex('gameModeOpponentIndex', ['opponent', 'gameMode'], {unique: false}); db.createObjectStore('intervals', {keyPath: 'from'}); } if (oldVersion < 2) { db.deleteObjectStore('intervals'); db.createObjectStore('intervals', {keyPath: 'from'}); } if (oldVersion < 3) { const duelsStore = event.target.transaction.objectStore('duels'); duelsStore.createIndex('timeOpponentIndex', ['opponent', 'time'], {unique: false}); duelsStore.createIndex('timeGameModeOpponentIndex', ['opponent', 'gameMode', 'time'], {unique: false}); } if (oldVersion < 4) { const duelsStore = event.target.transaction.objectStore('duels'); duelsStore.name = `duels_${userId}`; const intervalsStore = event.target.transaction.objectStore('intervals'); intervalsStore.name = `intervals_${userId}`; const teamDuelsStore = db.createObjectStore(`teamduels_${userId}`, {keyPath: ['gameId', 'opponent']}); teamDuelsStore.createIndex('gameIdIndex', 'gameId', {unique: false}); teamDuelsStore.createIndex('opponentIndex', 'opponent', {unique: false}); teamDuelsStore.createIndex('timeOpponentIndex', ['opponent', 'time'], {unique: false}); teamDuelsStore.createIndex('timeGameModeOpponentIndex', ['opponent', 'gameMode', 'time'], {unique: false}); event.target.transaction.objectStore(`intervals_${userId}`).clear(); } if (oldVersion < 5) { // indexes for other userscripts that use the database const duelsStore = event.target.transaction.objectStore(`duels_${userId}`); duelsStore.createIndex('timeIndex', 'time', {unique: false}); duelsStore.createIndex('timeGameModeIndex', ['gameMode', 'time'], {unique: false}); const teamDuelsStore = event.target.transaction.objectStore(`teamduels_${userId}`); teamDuelsStore.createIndex('timeIndex', 'time', {unique: false}); teamDuelsStore.createIndex('timeGameModeIndex', ['gameMode', 'time'], {unique: false}); } if (oldVersion < 6) { // more indexes for other userscripts to use const teamDuelsStore = event.target.transaction.objectStore(`teamduels_${userId}`); teamDuelsStore.createIndex('timePartnerIndex', ['partner', 'time'], {unique: false}); teamDuelsStore.createIndex('timeGameModePartnerIndex', ['partner', 'gameMode', 'time'], {unique: false}); } if (oldVersion < 7) { const roundDataStore = db.createObjectStore(`rounds_${userId}`, {keyPath: 'gameId'}); roundDataStore.createIndex('timeIndex', ['time']); roundDataStore.createIndex('timeGameModeIndex', ['gameMode', 'time']); } }; return new Promise((resolve, reject) => { request.onsuccess = (event) => resolve(event.target.result); request.onerror = (event) => reject(event); }); }; const hasKey = async (storeOrIndex, key) => await fetchResult(storeOrIndex.count(key)) > 0; const fetchResult = (request) => new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = (event) => reject(event); }); const fetchDuels = async (userId, fromDate, toDate) => { const db = await openDB(userId); let currentFromDate = new Date(fromDate); while (true) { console.log("Fetching duels from", currentFromDate); currentFromDate = await fetchFeed(userId, currentFromDate) .then((feed) => processFeed(feed, userId, db)) .then((feedData) => { const tx = db.transaction([`duels_${userId}`, `teamduels_${userId}`, `rounds_${userId}`, `intervals_${userId}`], 'readwrite'); const duelsStore = tx.objectStore(`duels_${userId}`); const roundsStore = tx.objectStore(`rounds_${userId}`); const teamDuelsStore = tx.objectStore(`teamduels_${userId}`); for (const duel of feedData.duels) duelsStore.put(duel); for (const duel of feedData.rounds) roundsStore.put(duel); for (const teamDuel of feedData.teamDuels) { /* A multiEntry index won't do because these don't work with a composite index that includes a time key at the end. So we add records for each opponent separately and index by opponent. */ for (const opponent of teamDuel.opponents) { teamDuelsStore.put({opponent: opponent, ...teamDuel}); } } const intervalsStore = tx.objectStore(`intervals_${userId}`); intervalsStore.put({from: fromDate, to: feedData.toDate}); return feedData.toDate; }); if (currentFromDate === THE_INFINITE_PAST || (toDate && currentFromDate <= toDate)) break; } }; const findHistoryGaps = async (userId) => { const db = await openDB(userId); const tx = db.transaction([`intervals_${userId}`], 'readonly'); const intervalsStore = tx.objectStore(`intervals_${userId}`); return new Promise(resolve => { let currentInterval = null; let gaps = []; let top = THE_INFINITE_PAST, bottom = THE_INFINITE_PAST; const cursorRequest = intervalsStore.openCursor(null, 'prev'); cursorRequest.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const interval = cursor.value; if (top === THE_INFINITE_PAST) top = interval.from; if (! currentInterval) currentInterval = interval; if (interval.from >= currentInterval.to) { currentInterval.to = interval.to === THE_INFINITE_PAST || currentInterval.to === THE_INFINITE_PAST ? THE_INFINITE_PAST : new Date(Math.min(currentInterval.to, interval.to)); } else { gaps.push({from: currentInterval.to, to: interval.from}); currentInterval = interval; } bottom = interval.to; cursor.continue(); } else { resolve([gaps, top, bottom]); } }; }); }; const countDuels = async (userId) => { const db = await openDB(userId); const tx = db.transaction([`duels_${userId}`, `teamduels_${userId}`, `rounds_${userId}`], 'readonly'); return { duels: await fetchResult(tx.objectStore(`duels_${userId}`).count()), teamDuels: await fetchResult(tx.objectStore(`teamduels_${userId}`).count()) / 2, rounds: await fetchResult(tx.objectStore(`rounds_${userId}`).count()) }; }; const updateInProgress = async () => { const locks = await navigator.locks.query(); return locks.held.some(lock => lock.name == "userscript_duels_history_download"); }; const updateMessages = new BroadcastChannel("userscript_duels_history_messages"); // Receive messages on the same tab also const updateMessageReceiver = new BroadcastChannel("userscript_duels_history_messages"); updateMessageReceiver.addEventListener('message', (event) => { handleUpdateNotification(); if (event.data == "stop") { if (location.pathname.match(/^\/(..\/)?user\//)) runOnUserPage(true); else if (location.pathname.match(/^\/(..\/)?me\/profile$/)) runOnProfilePage(true); } else if (event.data == "refresh") { // Allow other userscripts to trigger a database update updateDatabase(true); } }); let updateTimeout = null; const updateDatabase = async (force_update = false) => { await navigator.locks.request("userscript_duels_history_download", async (lock) => { updateMessages.postMessage("start"); console.log("Updating duels history database..."); const userId = await fetchUserId(); await findHistoryGaps(userId).then(async ([gaps, top, bottom]) => { const now = new Date(); if (force_update || new Date(Date.now() - 1000 * RENEW_TIME_SECONDS) > top) { gaps.unshift({from: now, to: top}); top = now; } if (top !== THE_INFINITE_PAST && bottom !== THE_INFINITE_PAST) { gaps.push({from: bottom, to: THE_INFINITE_PAST}); } for (const gap of gaps) { console.log('Downloading duels history gap:', gap); await fetchDuels(userId, gap.from, gap.to); } const counts = await countDuels(userId); console.log(`Duels history update complete. ${counts.duels} solo duels and ${counts.teamDuels} team duels in store.` ); console.log('Next routine check at', new Date(top.getTime() + 1000 * RENEW_TIME_SECONDS)); const nextCheck = top.getTime() + 1000 * RENEW_TIME_SECONDS - now.getTime(); if (updateTimeout) clearTimeout(updateTimeout); updateTimeout = setTimeout(updateDatabase, nextCheck); }); }); updateMessages.postMessage("stop"); }; updateDatabase(); GM_registerMenuCommand("Update duels history now", () => updateDatabase(true)); const fillInRoundData = async (userId) => { await navigator.locks.request("userscript_duels_history_download", async (lock) => { console.log('Filling in round data...'); updateMessages.postMessage("start"); const db = await openDB(userId); const tx = db.transaction([`duels_${userId}`], 'readonly'); const duelsStore = tx.objectStore(`duels_${userId}`); const duels = await fetchResult(duelsStore.index('timeIndex').getAll()); const missingRounds = []; for (const duel of duels) { const tx = db.transaction([`rounds_${userId}`], 'readonly'); const alreadyHaveRounds = await hasKey(tx.objectStore(`rounds_${userId}`), duel.gameId); if (! alreadyHaveRounds) { missingRounds.push({gameId: duel.gameId, time: duel.time, gameMode: duel.gameMode}); } } let counter = 0; for (const duel of missingRounds) { const startTime = new Date(); const duelData = await fetchDuel(duel.gameId); const roundData = extractRoundData(duelData, userId); if (roundData) { const writeTx = db.transaction([`rounds_${userId}`], 'readwrite'); const rounds = { gameId: duel.gameId, time: duel.time, gameMode: duel.gameMode, rounds: roundData }; writeTx.objectStore(`rounds_${userId}`).put(rounds); } counter += 1; if (counter % 20 == 0) console.log(`Filled in rounds for ${counter} of ${missingRounds.length} duels.`); await delay(1000*DUEL_DOWNLOAD_SPACING_SECONDS - (new Date() - startTime)); } console.log('Finished filling in round data.'); updateMessages.postMessage("stop"); }); } const fetchStats = async (userId, opponents, partner = null) => { const db = await openDB(userId); const tx = db.transaction([`${partner ? "team" : ""}duels_${userId}`], 'readonly'); const duelsStore = tx.objectStore(`${partner ? "team" : ""}duels_${userId}`); const opponentIndex = duelsStore.index('opponentIndex'); const opponentId = partner ? opponents[0] : opponents; const request = opponentIndex.getAll(opponentId); const duels = await fetchResult(request); let count = 0, wins = 0, eloBalance = 0; let modeCount = {StandardDuels: 0, NoMoveDuels: 0, NmpzDuels: 0}; let modeWins = {StandardDuels: 0, NoMoveDuels: 0, NmpzDuels: 0}; let modeEloBalance = {StandardDuels: 0, NoMoveDuels: 0, NmpzDuels: 0}; for (const duel of duels) { if (partner && duel.partner != partner) continue; if (partner && ! opponents.every((opponent) => duel.opponents.includes(opponent))) continue; count += 1; wins += (partner ? duel.winners.includes(opponentId) : duel.winner == opponentId) ? 0 : 1; eloBalance += (duel.ourEloAfter && duel.ourEloBefore) ? duel.ourEloAfter - duel.ourEloBefore : 0; modeCount[duel.gameMode] += 1; modeWins[duel.gameMode] += (partner ? duel.winners.includes(opponentId) : duel.winner == opponentId) ? 0 : 1; if (! partner) { modeEloBalance[duel.gameMode] += (duel.ourModeEloAfter && duel.ourModeEloBefore) ? duel.ourModeEloAfter - duel.ourModeEloBefore : 0; } } let stats = {overall: {wins: wins, count: count, eloBalance: eloBalance}}; for (const mode of ['StandardDuels', 'NoMoveDuels', 'NmpzDuels']) { stats[mode] = {wins: modeWins[mode], count: modeCount[mode]}; if (! partner) stats[mode].eloBalance = modeEloBalance[mode]; } return stats; }; const fetchOpponentTeams = async (userId, opponentId) => { const db = await openDB(userId); const tx = db.transaction([`teamduels_${userId}`], 'readonly'); const duelsStore = tx.objectStore(`teamduels_${userId}`); const index = duelsStore.index('opponentIndex'); const games = await fetchResult(index.getAll(opponentId)); const partners = new Set(); for (const game of games) { for (const opponent of game.opponents) { if (opponent != opponentId) partners.add(opponent); } } return Array.from(partners.values().map(partner => [opponentId, partner])); }; const fetchOwnTeams = async (userId, opponentTeam) => { const db = await openDB(userId); const tx = db.transaction([`teamduels_${userId}`], 'readonly'); const duelsStore = tx.objectStore(`teamduels_${userId}`); const index = duelsStore.index('opponentIndex'); const games = await fetchResult(index.getAll(opponentTeam[0])); const partners = new Set(); for (const game of games) { for (const opponent of game.opponents) { if (opponent == opponentTeam[1]) partners.add(game.partner); } } return Array.from(partners.values().map(partner => [userId, partner])); }; const fetchDuelList = async (userId, opponents, gameMode = 'overall', partner = null) => { const db = await openDB(userId); const tx = db.transaction(`${partner ? "team" : ""}duels_${userId}`, 'readonly'); const duelsStore = tx.objectStore(`${partner ? "team" : ""}duels_${userId}`); const index = gameMode == 'overall' ? duelsStore.index('timeOpponentIndex') : duelsStore.index('timeGameModeOpponentIndex'); const opponentId = partner ? opponents[0] : opponents; const constantBound = gameMode == 'overall' ? [opponentId] : [opponentId, gameMode]; const lowerBound = constantBound.concat(ECMASCRIPT_MIN_TIME); const upperBound = constantBound.concat(new Date()); const keyRange = IDBKeyRange.bound(lowerBound, upperBound); return new Promise(resolve => { const games = []; const cursorRequest = index.openCursor(keyRange, 'prev'); cursorRequest.onsuccess = (event) => { const cursor = event.target.result; if (games.length < GAME_LIST_MAX_COUNT && cursor) { if (! partner || (opponents.every(opponent => cursor.value.opponents.includes(opponent)) && cursor.value.partner == partner)) { games.push(cursor.value); } cursor.continue(); } else { resolve(games); } }; }); }; const fetchDuelsByAllModes = async (userId, opponents, partner = null) => { const [allGames, movingGames, noMoveGames, nmpzGames] = await Promise.all([ fetchDuelList(userId, opponents, 'overall', partner), fetchDuelList(userId, opponents, 'StandardDuels', partner), fetchDuelList(userId, opponents, 'NoMoveDuels', partner), fetchDuelList(userId, opponents, 'NmpzDuels', partner)]); return {overall: allGames, StandardDuels: movingGames, NoMoveDuels: noMoveGames, NmpzDuels: nmpzGames}; }; const formatGameMode = (gameMode) => { switch (gameMode) { case 'StandardDuels': return 'Moving'; case 'NoMoveDuels': return 'No Move'; case 'NmpzDuels': return 'NMPZ'; } }; const formatTeam = (occludedId, teamData) => `${flag(teamData.ccs[0])}${flag(teamData.ccs[1])} ${teamData.name} ` + `(with ${teamData.ids[0] == occludedId ? teamData.nicks[1] : teamData.nicks[0]})`; const flag = (cc) => cc.toUpperCase() == "ZZ" ? "\u{1F1FA}\u{1F1F3}" // UN flag : String.fromCodePoint(...cc.toUpperCase().split('') .map(char => 127397 + char.charCodeAt())); const signedNumber = (n) => `${n > 0 ? "+" : ""}${n}`; const makeDataDiv = (label, greyLabel = true, reloadButton = false) => { const div = document.createElement('div'); div.style = '--direction: column; --gap: 4; --justify: center; --align: flex-start'; div.className = `${cn('flex_flex__')} ${cn('flex_direction__')} ${cn('flex_gap__')} ${cn('flex_justify__')} ${cn('flex_align__')} ${USERSCRIPT_DATA_ELEMENT_CLASS}`; const labelElement = document.createElement('label'); labelElement.className = `${cn('label_label__')} ${greyLabel ? cn('shared_white60Variant__') : cn('shared_orange50Variant__')} ${cn('shared_boldWeight__')} ${cn('label_italic__')} ${cn('label_uppercase__')}`; labelElement.style = "--fs: var(--font-size-9); --lh: var(--line-height-9)"; labelElement.innerHTML = label; if (reloadButton) { const reloadElement = document.createElement('span'); reloadElement.style.marginLeft = '1ex'; reloadElement.style.cursor = 'pointer'; reloadElement.style.color = '#fff'; reloadElement.innerHTML = '\u21bb'; // clockwise open circle arrow reloadElement.addEventListener('click', () => updateDatabase(true)); labelElement.append(reloadElement); } const valueElements = Array.from([0, 1], _ => document.createElement('label')); valueElements.map(el => el.classList.add(cn('label_label__'), cn('shared_boldWeight__'), cn('label_italic__'), cn('label_uppercase__'))); valueElements[0].style = "--fs: var(--font-size-16); --lh: var(--line-height-16)"; valueElements[1].style = "--fs: var(--font-size-12); --lh: var(--line-height-12)"; div.append(labelElement, ...valueElements); return [div, ...valueElements]; }; const makeDataBlockDiv = (reloadButton = false) => { const dataBlockDiv = document.createElement('div'); dataBlockDiv.className = `${cn('daily-challenge-streak_root__')} ${USERSCRIPT_DATA_BLOCK_CLASS}`; dataBlockDiv.style.transform = 'none'; // disable the inherited zoom animation on hover const valueDivs = {score: {}, balance: {}}; const gamesDivs = {}; let overallDiv; [overallDiv, valueDivs.score.overall, valueDivs.balance.overall] = makeDataDiv('Score', false, reloadButton); gamesDivs.overall = makeGamesDiv('overall'); overallDiv.append(gamesDivs.overall); dataBlockDiv.append(overallDiv); for (const gameMode of ['StandardDuels', 'NoMoveDuels', 'NmpzDuels']) { [overallDiv, valueDivs.score[gameMode], valueDivs.balance[gameMode]] = makeDataDiv(formatGameMode(gameMode)); gamesDivs[gameMode] = makeGamesDiv(gameMode); overallDiv.append(gamesDivs[gameMode]); dataBlockDiv.append(overallDiv); } return [dataBlockDiv, valueDivs, gamesDivs]; }; const fillDataBlock = (valueDivs, stats) => { for (const gameMode of ['overall', 'StandardDuels', 'NoMoveDuels', 'NmpzDuels']) { valueDivs.score[gameMode].innerHTML = `${stats[gameMode].wins}–${stats[gameMode].count - stats[gameMode].wins}`; const element = valueDivs.balance[gameMode]; if ('eloBalance' in stats[gameMode]) { element.innerHTML = `${signedNumber(stats[gameMode].eloBalance)} Elo`; } else { element.innerHTML = "—"; } } }; const makeGamesDiv = (gameMode) => { const div = document.createElement('div'); div.className = `${cn('multiplayer_ratingBox__')} ${USERSCRIPT_GAME_LIST_CLASS_INACTIVE}`; div.style.padding = '0.5rem'; div.style.position = 'absolute'; div.style.zIndex = 99; // remain above the data block element but below modal overlays return div; }; const fillGames = (div, gameMode, games, opponentId, isGameMode = false) => { div.innerHTML = ''; const headerTable = document.createElement('table'); headerTable.style.width = '100%'; const headerTableRow = document.createElement('tr'); const heading = document.createElement('td'); heading.innerHTML = `Most recent games ${isGameMode && games.length > 0 ? '(' + formatGameMode(games[0].gameMode) + ')' : ''}`; heading.style.fontWeight = 'bold'; headerTableRow.append(heading); const reloadElement = document.createElement('td'); reloadElement.style.textAlign = 'right'; reloadElement.style.cursor = 'pointer'; reloadElement.innerHTML = '\u21bb'; // clockwise open circle arrow reloadElement.addEventListener('click', () => { updateDatabase(true) }); headerTableRow.append(reloadElement); headerTable.append(headerTableRow); div.append(headerTable); const gamesTable = document.createElement('table'); let tableHTML = ""; for (const game of games) { const lost = 'winner' in game ? game.winner == opponentId : game.winners.includes(opponentId); // Team duels don't have a mode rating let eloBalance = isGameMode && game.ourModeEloAfter ? game.ourModeEloAfter - game.ourModeEloBefore : game.ourEloAfter - game.ourEloBefore; // Handle missing rating changes in duel files (duels missing progressChange) eloBalance = isNaN(eloBalance) ? 0 : eloBalance; tableHTML += ` ${game.time.toLocaleString()} ${isGameMode ? "" : `(${formatGameMode(game.gameMode)})`} ${lost ? 'lost' : 'won'} ${eloBalance > 0 ? "+" : ""}${eloBalance} Elo `; } gamesTable.innerHTML = tableHTML; div.append(gamesTable); }; const fillGamesDivs = (gamesDivs, games, opponentId) => { const hasGames = Object.fromEntries(Object.entries(games).map(([k, v]) => [k, v.length > 0])); for (const gameMode of ['overall', 'StandardDuels', 'NoMoveDuels', 'NmpzDuels']) { if (hasGames[gameMode]) { gamesDivs[gameMode].classList.remove(USERSCRIPT_GAME_LIST_CLASS_INACTIVE); gamesDivs[gameMode].classList.add(USERSCRIPT_GAME_LIST_CLASS); fillGames(gamesDivs[gameMode], gameMode, games[gameMode], opponentId, gameMode != 'overall'); } else { gamesDivs[gameMode].classList.remove(USERSCRIPT_GAME_LIST_CLASS); gamesDivs[gameMode].classList.add(USERSCRIPT_GAME_LIST_CLASS_INACTIVE); } } }; const makeTeamsSelect = async (userId, occludedId, teams, listener, soloOption = true) => { const select = document.createElement('select'); select.className = cn('text-input_textInput__'); select.style.width = "2.5rem"; select.style.padding = "4px"; select.style.fontSize = "12px"; select.style.background = "transparent"; let teamsHTML = soloOption ? `` : ""; const teamsData = await Promise.all(teams.map(team => fetchTeam(userId, team))); for (const teamData of teamsData) { teamsHTML += ``; } select.innerHTML = teamsHTML; select.addEventListener('change', listener); return select; }; const makeTeamsSelectHandler = (ourTeamsDiv, ourTeamsValue, ourTeamsDummy, userId, opponentId, valueDivs, gamesDivs, soloStats, soloGames) => { return async (event) => { // Selected opponent team const value = event.target.value; ourTeamsValue.innerHTML = "..."; ourTeamsDummy.innerHTML = "\u200b"; // zero width space if (value != "solo") { ourTeamsDiv.style.display = "flex"; const opponentIds = value.split(','); const ourTeams = await fetchOwnTeams(userId, opponentIds); const ourTeamsSelect = await makeTeamsSelect(userId, userId, ourTeams, async (event) => { // Selected own team const partner = event.target.value.split(',').filter((id) => id != userId)[0]; const teamStats = await fetchStats(userId, opponentIds, partner); fillDataBlock(valueDivs, teamStats); fillGamesDivs(gamesDivs, await fetchDuelsByAllModes(userId, opponentIds, partner), opponentId); }, false); ourTeamsValue.innerHTML = ""; ourTeamsValue.append(ourTeamsSelect); // Data for automatically selected own team const partner = ourTeamsSelect.value.split(',').filter((id) => id != userId)[0]; const teamStats = await fetchStats(userId, opponentIds, partner); fillDataBlock(valueDivs, teamStats); fillGamesDivs(gamesDivs, await fetchDuelsByAllModes(userId, opponentIds, partner), opponentId); } else { // Fill solo data back in ourTeamsDiv.style.display = "none"; fillDataBlock(valueDivs, soloStats); fillGamesDivs(gamesDivs, soloGames, opponentId); } }; }; let lastOpponentId = null; const runOnUserPage = async (forceRun = false) => { let opponentId = location.pathname.split('/').pop(); const insertHookElement = document.querySelector("." + cn('friend-status_section__')); if (! insertHookElement) return; navigator.locks.request("userscript_duels_history", async (lock) => { const dataBlock = document.querySelector("." + USERSCRIPT_DATA_BLOCK_CLASS); if (! forceRun && opponentId == lastOpponentId && dataBlock) return; lastOpponentId = opponentId; if (dataBlock) dataBlock.remove(); [...document.querySelectorAll("." + USERSCRIPT_CUSTOM_SPACER_CLASS)].map(el => el.remove()); const userId = await fetchUserId(); insertHookElement.insertAdjacentHTML( "beforebegin", ``); // Singleplayer stats const stats = await fetchStats(userId, opponentId); const games = await fetchDuelsByAllModes(userId, opponentId); insertHookElement.insertAdjacentElement("beforebegin", makeSpacer(16)); const [dataBlockDiv, valueDivs, gamesDivs] = makeDataBlockDiv(stats.overall.count == 0); insertHookElement.insertAdjacentElement("beforebegin", dataBlockDiv); fillDataBlock(valueDivs, stats); fillGamesDivs(gamesDivs, games, opponentId); insertHookElement.insertAdjacentElement("beforebegin", makeSpacer(16)); // Team duels interface const opponentTeams = await fetchOpponentTeams(userId, opponentId); if (opponentTeams.length > 0) { const [opponentTeamsDiv, opponentTeamsValue, opponentTeamsDummy] = makeDataDiv("Team", false); const [ourTeamsDiv, ourTeamsValue, ourTeamsDummy] = makeDataDiv("Your team"); ourTeamsDiv.style.display = "none"; dataBlockDiv.append(opponentTeamsDiv, ourTeamsDiv); opponentTeamsValue.innerHTML = "..."; opponentTeamsDummy.innerHTML = "\u200b"; // zero width space const opponentTeamsSelect = await makeTeamsSelect(userId, opponentId, opponentTeams, makeTeamsSelectHandler(ourTeamsDiv, ourTeamsValue, ourTeamsDummy, userId, opponentId, valueDivs, gamesDivs, stats, games)); opponentTeamsValue.innerHTML = ""; opponentTeamsValue.append(opponentTeamsSelect); } }); }; const makeSpacer = (height) => { const spacer = document.createElement('div'); spacer.className = `${cn('spacer_space__')} ${cn('spacer_height__')} ${USERSCRIPT_CUSTOM_SPACER_CLASS}`; spacer.style = `--height: ${height}`; return spacer; }; const makeSectionHeader = (title) => { const header = document.createElement('div'); header.innerHTML = `

${title}

`; return header; }; const makeButton = (label) => { const button = document.createElement('button'); button.type = "button"; button.innerHTML = label; button.className = `${cn('button_button__')} ${cn('button_variantSecondary__')}`; button.style = "margin: 0.25rem"; return button; }; const createDownload = async (userId) => { const db = await openDB(); const storeNames = ["duels", "teamduels", "rounds"]; const tx = db.transaction(storeNames.map(name => `${name}_${userId}`)); const data = { version: DATABASE_VERSION, stores: {} }; for (const storeName of storeNames) { const objStore = tx.objectStore(`${storeName}_${userId}`); const json = await fetchResult(objStore.getAll()); data.stores[storeName] = json; } const blob = new Blob([JSON.stringify(data)], { type: 'application/octet-stream'}); const anchor = document.createElement('a'); anchor.href = URL.createObjectURL(blob); anchor.download = `geoguessr_duels_history_${new Date().getTime()}.json`; anchor.click(); }; const importDuels = async (userId, databaseStatus) => { const filePicker = document.createElement('input'); filePicker.type = 'file'; filePicker.addEventListener('change', (event) => { let reader = new FileReader(); reader.addEventListener('load', async () => { try { const json = JSON.parse(reader.result); const db = await openDB(); const storeNames = ["duels", "teamduels", "rounds"]; const tx = db.transaction(storeNames.map(name => `${name}_${userId}`), 'readwrite'); const data = {}; for (const storeName of storeNames) { const store = tx.objectStore(`${storeName}_${userId}`); for (const entry of json.stores[storeName]) { entry.time = new Date(entry.time); if (storeName == "rounds") { for (const round in entry.rounds) { round.startTime = new Date(round.startTime); round.timerStartTime = new Date(round.timerStartTime); round.endTime = new Date(round.endTime); if (round.ourGuess) round.ourGuess.guessTime = new Date(round.ourGuess.guessTime); if (round.theirGuess) round.theirGuess.guessTime = new Date(round.theirGuess.guessTime); } } store.put(entry); } } databaseStatus.innerHTML = "Import complete.
"; } catch (err) { databaseStatus.innerHTML = "Import failed. Is this really a duels history export file?
"; console.error("Duels history import error:", err); } const counts = await countDuels(userId); databaseStatus.innerHTML += `Solo duels: ${counts.duels}
Team duels: ${counts.teamDuels}
Solo duels with round data: ${counts.rounds}`; }); reader.readAsText(event.target.files[0]); }); filePicker.click(); }; const makeDatabaseStatus = async (userId) => { const div = document.createElement('div'); div.style.display = "inline-block"; div.style.margin = "1rem"; div.style.textAlign = "left"; div.style.verticalAlign = "middle"; const contents = await countDuels(userId); div.innerHTML = `Duels in database
Solo duels: ${contents.duels}
Team duels: ${contents.teamDuels}
Solo duels with round data: ${contents.rounds}

`; return div; }; let lastProfileHook = null; const runOnProfilePage = async (forceRun = false) => { const hookElement = document.querySelector("." + cn('profile-v2_staticWidgets__')); if (! hookElement) return; navigator.locks.request("userscript_duels_history_database_download", async (lock) => { if (! forceRun && lastProfileHook == hookElement) return; lastProfileHook = hookElement; const userId = await fetchUserId(); const statusDiv = await makeDatabaseStatus(userId); const spacer1 = makeSpacer(64); hookElement.insertAdjacentElement("afterend", spacer1); const header = makeSectionHeader("Duels history database"); spacer1.insertAdjacentElement("afterend", header); const container = document.createElement('div'); const downloadButton = makeButton("Export database"); downloadButton.addEventListener('click', () => createDownload(userId)); container.append(downloadButton); const importButton = makeButton("Import duels into database"); importButton.addEventListener('click', () => importDuels(userId, statusDiv)); container.append(importButton); const fillInButton = makeButton("Fill in round data"); fillInButton.addEventListener('click', () => fillInRoundData(userId)); container.append(fillInButton); container.append(statusDiv); const spacer2 = makeSpacer(64); container.append(spacer2); header.insertAdjacentElement("afterend", container); }); }; let lastHeaderHook = null; const handleUpdateNotification = async () => { const headerSection = document.querySelector("." + cn('header-desktop_desktopSectionRight__')); if (! headerSection) return; const updating = await updateInProgress(); const updateNotification = document.querySelector("." + USERSCRIPT_UPDATE_NOTIFICATION_CLASS); if (Boolean(updateNotification) === updating) return; if (updateNotification && ! updating) { updateNotification.remove(); return; } navigator.locks.request("userscript_duels_history_download_notification", async (lock) => { if (updating && headerSection == lastHeaderHook) return; lastHeaderHook = headerSection; headerSection.insertAdjacentHTML( "afterbegin", `
` + "Updating duels history... \u23F3
"); // hourglass with flowing sand }); }; const run = async (mutations) => { scanStyles().then(_ => { handleUpdateNotification(); if (location.pathname.match(/^\/(..\/)?user\//)) runOnUserPage(); else if (location.pathname.match(/^\/(..\/)?me\/profile$/)) runOnProfilePage(); }); }; new MutationObserver(run).observe(document.body, { subtree: true, childList: true }); run();