xsanda / GeoGuessr Path Logger - beta

// ==UserScript==
// @name GeoGuessr Path Logger - beta
// @namespace   xsanda
// @description Add a trace of where you have been to GeoGuessr’s results screen
// @version 0.4.8
// @include https://www.geoguessr.com/*
// @require https://openuserjs.org/src/libs/xsanda/Run_code_as_client.js
// @require https://openuserjs.org/src/libs/xsanda/Google_Maps_Promise.js
// @run-at document-start
// @connect path-logger.xsanda.me
// @grant GM.xmlHttpRequest
// @license MIT
// ==/UserScript==

/*jshint esversion: 8 */
/* globals unsafeWindow, runAsClient, googleMapsPromise, exportFunction */

// An AWS server, running the code at https://gist.github.com/xsanda/f916f964c100b9194d21a21628364d98
const SERVER = "https://path-logger.xsanda.me";

// Send a route trace to the server.
unsafeWindow.saveRouteToServer = exportFunction((data) => {
  GM.xmlHttpRequest({
    method: 'POST',
    url: SERVER + "/challenges.php",
    data: JSON.stringify(data),
    onabort: e => console.error('abort submitting route', e),
    onerror: e => console.error('error submitting route', e),
    ontimeout: e => console.error('timeout submitting route', e),
    onload: response => {
      if (response.status > 400) console.error("Server failure", response);
    }
  });
}, unsafeWindow);

// Receive the route traces from the server.
unsafeWindow.getRoutesFromServer = exportFunction((token, resolve, reject) => {
  try {
    GM.xmlHttpRequest({
      method: 'GET',
      url: SERVER + "/challenges/" + token + ".json",
      onabort: e => (console.error('aborted submitting route', e), reject(e)),
      onerror: e => (console.error('error submitting route', e), reject(e)),
      ontimeout: e => (console.error('timeout submitting route', e), reject(e)),
      onload: response => {
        if (response.status > 400) {
          console.error("Server failure", response), reject(response.status);
        } else resolve(response.responseText);
      }
    });
  }
  catch (e) {
    console.error('error submitting route', e);
    reject(e);
  }
}, unsafeWindow);

// When Google maps has loaded, run the following code:
googleMapsPromise.then(() => runAsClient(() => {
  const google = window.google;

  const KEEP_FOR = 1000 * 60 * 60 * 24 * 7; // 1 week

  // Keep a track of the lines drawn on the map, so they can be removed
  let markers = [];

  const NOT_GIVEN = Symbol();
  // An ES5 version of ?.
  const safeNav = (obj, ...path) => {
    if (obj === null || obj === undefined) return undefined;
    const [first = NOT_GIVEN, ...rest] = path;
    if (first === NOT_GIVEN) return obj;
    let next;
    for (const option of Array.isArray(first) ? first : [first]) {
      next = typeof option === 'function' ? option(obj) : obj[option];
      if (next !== undefined && next !== null) break;
    }
    return safeNav(next, ...rest);
  };

  // window.next?.router?.components?.[window?.next?.router?.route]?.props?.pageProps
  const getProps = (...rest) => safeNav(
    window,
    'next',
    'router',
    'components',
    safeNav(window, 'next', 'router', 'route'),
    'props',
    'pageProps',
    ...rest,
  );

  // Get the current user.
  const getGame = (...rest) => getProps(
      ['gamePlayedByCurrentUser', 'game'],
      ...rest,
  );

  const loadGameForChallenge = async () => {
    const match = /^\/challenge\/(\w+)/.exec(location.pathname);
    if (!match) return;
    const props = getProps();
    if (!props || props.game || props.gamePlayedByCurrentUser) return;
    const token = match[1];
    const serverData = await fetch(`https://www.geoguessr.com/api/v3/challenges/${token}`, { "method": "POST" });
    props.game = await serverData.json();
  };

  const isBreakdown = () => location.pathname.startsWith("/results/");
  const isChallenge = () => location.pathname.startsWith("/challenge/");
  const isGamePage = () => isChallenge() || location.pathname.startsWith("/results/") || location.pathname.startsWith("/game/");

  // Detect if a results screen is visible, so the traces should be shown
  const resultShown = () => !!document.querySelector('.result') || location.href.includes('results');

  // Detect if only a single result is shown
  const singleResult = () => !!document.querySelector("[data-qa=guess-description]") || !!document.querySelector('.country-streak-result__sub-title');

  // Get the game ID, for storing the trace against
  const challengeToken = () => getProps('challenge', 'token') || getProps('challengeToken');
  const id = () => challengeToken() || getGame('token');
  const roundNumber = () => {
    const el = document.querySelector('[data-qa=round-number] :nth-child(2)');
    return el ? parseInt(el.innerHTML) : 0;
  };
  const roundID = () => id() + '-' + roundNumber();

  // Get the location of the street view
  const getPosition = sv => ({
    lat: sv.position.lat(),
    lng: sv.position.lng(),
  });

  // Record the time a game was played
  const updateTimestamp = () => {
    const timestamps = JSON.parse(localStorage.timestamps || "{}");
    timestamps[id()] = Date.now();
    localStorage.timestamps = JSON.stringify(timestamps);
  };

  // Remove all games older than a week
  const clearOldGames = logErrors(() => {
    const timestamps = JSON.parse(localStorage.timestamps || "{}");
    // Delete all games older than a week
    const cutoff = Date.now() - KEEP_FOR;
    for (const [gameID, gameTime] of Object.entries(timestamps)) {
      if (gameTime < cutoff) {
        delete timestamps[gameID];
        for (const recording of Object.keys(localStorage).filter(key => key.startsWith(gameID + '-'))) {
          delete localStorage[recording];
        }
      }
    }
    localStorage.timestamps = JSON.stringify(timestamps);
  });

  // Keep a track of whether we are in a round already
  let inGame = false;

  // Keep a track of the current round’s route
  let route;

  // Keep a track of the start location for the current round, for detecting the return to start button
  let start;

  clearOldGames();

  // Wrap a function in an error logger. This is good for making sure that errors in callbacks aren’t lost
  function logErrors(f) {
    return async (...args) => {
      try {
        await f(...args);
      }
      catch (e) {
        console.error("GeoGuessr Path logger error:", e);
      }
    };
  }

  // Handle the street view being navigated
  const onMove = logErrors((sv) => {
    try {
      if (!isGamePage()) return;

      const position = getPosition(sv);

      if (!inGame) {
        // Do nothing if the map is being updated in the background, e.g. on page load while the results are still shown
        if (resultShown()) return;
        // otherwise start the round
        inGame = true;
        start = position;
        route = [];
      }
      // If we’re at the start, begin a new trace
      if (position.lat == start.lat && position.lng == start.lng) route.push([]);

      // Add the location to the trace
      route[route.length - 1].push(position);
    }
    catch (e) {
      console.error("GeoGuessr Path Logger Error:", e);
    }
  });

  let mapState = 0;

  // The geometry API isn’t loaded unless a Street View has been displayed since the last load.
  const loadGeometry = () => new Promise(logErrors((resolve, reject) => {
    const existingScript = document.querySelector("script[src^='https://maps.googleapis.com/maps-api-v3/api/js/']");
    if (!existingScript) reject("No Google Maps loaded yet");
    const libraryURL = existingScript.src.replace(/(.+\/)(.+?)(\.js)/, '$1geometry$3');
    document.head.appendChild(Object.assign(document.createElement("script"), {
      onload: resolve,
      type: "text/javascript",
      src: libraryURL,
    }));
  }));

  const saveRoute = async () => {
    // encode the route to reduce the storage required.
    const encodedRoutes = route.map(path => google.maps.geometry.encoding.encodePath(path.map(point => new google.maps.LatLng(point))));
    localStorage[roundID()] = JSON.stringify(encodedRoutes);

    // Get the current user info
    let game = getGame();
    if (game && game.type === "challenge") {
      window.saveRouteToServer({
        player: game.player.id,
        challenge: challengeToken(),
        round: roundNumber(),
        route: encodedRoutes
      });
    }
    updateTimestamp();
  };

  const showMoreMarkers = (map, traces, color) => {
    const newMarkers = (traces || []).flatMap(route => (
      // Render each trace within each round as a red line
      route.map(polyline => (
        new google.maps.Polyline({
          path: google.maps.geometry.encoding.decodePath(polyline),
          geodesic: true,
          strokeColor: color || '#FF0000',
          strokeOpacity: 1.0,
          strokeWeight: 2,
        })
      ))
    ));

    // Add all traces to the map
    newMarkers.forEach(m => m.setMap(map));

    markers.push(...newMarkers);
  };

  const showMarkers = (map, traces, color) => {
    // Hide all previous traces
    markers.forEach(m => m.setMap(null));
    markers = [];
    showMoreMarkers(map, traces, color);
  };

  const playerCache = {
    token: undefined,
    loaded: 0,
    // Promise<{ id /* unique, not in DOM */, pinUrl /* unique or empty */, nick /* not unique */, score /* sorted */ }>
    leaderboardPromise: undefined,
  };

  const loadPlayers = async (token, count) => {
    if (playerCache.token !== token || playerCache.loaded < count) {
      playerCache.token = token;
      playerCache.loaded = count;
      playerCache.leaderboardPromise = fetch(`https://www.geoguessr.com/api/v3/results/scores/${token}/0/${count + 1}`)
        .then(response => response.json())
        .then(results => results.map(result => ({
          id: result.userId,
          pinUrl: result.pinUrl,
          nick: result.game.player.nick,
          score: result.totalScore
        })));
    }
    return await playerCache.leaderboardPromise;
  };

  const findPlayer = async ({
    score,
    pinUrl,
    nick
  }) => {
    // SVG images are only used when no user picture is given
    const rawUrl = pinUrl && pinUrl.endsWith(".svg") ? "" : pinUrl;

    // Check the current player
    const currentPlayer = getGame('player');
    if (currentPlayer.totalScore.amount === score && currentPlayer.pin.url === rawUrl && currentPlayer.nick.trim() === nick.trim()) return currentPlayer.id;

    // Load the leaderboard (because it is not possible to get the user ID from the DOM)
    const visible = Array.from(document.querySelectorAll('.results-highscore__player-place')).filter(x => x.innerText).length;
    const results = await loadPlayers(challengeToken(), visible);
    const selected = results.filter(result => (
      (!score || result.score === score) &&
      (pinUrl === undefined || result.pinUrl === rawUrl) &&
      (!nick || result.nick.trim() === nick.trim())
    ));
    return safeNav(selected, 0, 'id');
  };

  const getSelectedPlayers = async () => {
    const PLAYER_IMG_SELECTOR = '.results-highscore__player-pin img';
    const PLAYER_NAME_SELECTOR = '.results-highscore__player-nick';
    const selected = [...document.querySelectorAll('.results-highscore__player-cell.results-highscore__cell--selected')];
    const selectedTotals = [...document.querySelectorAll([
      '.results-highscore__guess-cell--total.results-highscore__cell--selected .results-highscore__guess-cell-score',
      '.results-highscore__country-cell.results-highscore__cell--selected .results-highscore__guess-cell-score'
    ].join())];
    const foundPlayers = selected.map((el, i) => findPlayer({
      pinUrl: safeNav(el.querySelector('.results-highscore__player-pin img'), 'src', str => str.replace(/^.+(?=pin)/, '')),
      nick: el.querySelector('.results-highscore__player-nick').innerText,
      score: Number.parseInt(selectedTotals[i].innerText.replace(/,/g, '')),
    }));
    return (await Promise.all(foundPlayers)).filter(Boolean);

  };

  const routesCache = {
    token: undefined,
    dataPromise: undefined,
  };

  const fetchOthersRoutes = async (token) => {
    if (routesCache.token !== token) {
      routesCache.dataPromise = new Promise((resolve, reject) => window.getRoutesFromServer(token, resolve, reject))
        .then(res => JSON.parse(res))
        .catch(() => ({}));
    }
    return await routesCache.dataPromise;
  };

  const showMyMarkers = (map) => {
    // Show all rounds for the current game when viewing the full results
    const roundsToShow = singleResult() ? [roundID()] : Object.keys(localStorage).filter(map => map.startsWith(id()));

    const routes = roundsToShow.map(key => localStorage[key]).filter(Boolean).map(route => JSON.parse(route));
    if (routes.length === 0) return false;
    showMoreMarkers(map, routes);
    return true;
  };

  const showAllMarkers = logErrors(async (map) => {
    const players = await getSelectedPlayers();
    const routes = await fetchOthersRoutes(challengeToken());
    const me = getGame('player', 'id');
    const myPos = players.indexOf(me);
    showMarkers(null, []);
    if (myPos !== -1) {
      const loadedLocally = showMyMarkers(map);
      if (loadedLocally) players.splice(myPos, 1);
    }
    showMoreMarkers(map, players.flatMap(player => Object.values(routes[player] || {})), "#F99");
  });

  let clickHandler;

  const onMapUpdate = logErrors(async (map) => {
    if (!isGamePage()) return;

    // Wait for any server requests to resolve
    await Promise.all([
      loadGameForChallenge(),
      !google.maps.geometry && loadGeometry(),
    ]);

    // create a checksum of the game state, only updating the map when this changes, to save on computation
    const newMapState = (inGame ? 5 : 0) + (resultShown() ? 10 : 0) + (singleResult() ? 20 : 0) + roundNumber();
    if (newMapState == mapState) return;
    mapState = newMapState;

    // If we’re looking at the results, draw the traces again
    if (resultShown()) {
      // If we were in a round the last time we checked, then we need to save the route
      if (inGame) saveRoute(), inGame = false;

      if (isBreakdown() && getGame('type') === "challenge") {
        const resultsTablePromise = new Promise(resolve => {
          const interval = setInterval(() => {
            const element = document.querySelector('.results-highscore');
            if (element) clearInterval(interval), resolve(element);
          }, 100);
        });
        resultsTablePromise.then(logErrors(element => {
          showAllMarkers(map);
          element.removeEventListener('click', clickHandler);
          clickHandler = () => setTimeout(logErrors(() => showAllMarkers(map)));
          element.addEventListener('click', clickHandler);
        }));
      }
      else {
        showMarkers(null);
        showMyMarkers(map);
      }
    }
    else if (markers.length) {
      showMarkers(null);
    }
  });

  // When a StreetViewPanorama is constructed, add a listener for moving
  const oldSV = google.maps.StreetViewPanorama;
  google.maps.StreetViewPanorama = Object.assign(function (...args) {
    const res = oldSV.apply(this, args);
    this.addListener('position_changed', () => onMove(this));
    return res;
  }, {
    prototype: Object.create(oldSV.prototype)
  });

  // When a Map is constructed, add a listener for updating
  const oldMap = google.maps.Map;
  google.maps.Map = Object.assign(function (...args) {
    const res = oldMap.apply(this, args);
    this.addListener('idle', () => onMapUpdate(this));
    return res;
  }, {
    prototype: Object.create(oldMap.prototype)
  });
}));