Browse Source

Merge branch 'june-release' of kirkleon/terrassumptions into master

kirkleon 3 years ago
parent
commit
ae77c8e0d2
37 changed files with 893 additions and 295 deletions
  1. 2 0
      README.md
  2. 1 1
      client/package.json
  3. 9 1
      client/src/App.jsx
  4. 11 2
      client/src/App.module.css
  5. BIN
      client/src/assets/chime.mp3
  6. 6 0
      client/src/assets/hitmarker.svg
  7. BIN
      client/src/assets/hitsound.wav
  8. 3 2
      client/src/components/screens/GamePanel/GamePanel.jsx
  9. 21 16
      client/src/components/screens/GamePanel/GamePanel.module.css
  10. 70 37
      client/src/components/screens/GamePanel/GuessPane/GuessPane.jsx
  11. 30 0
      client/src/components/screens/GamePanel/GuessPane/GuessPane.module.css
  12. 190 0
      client/src/components/screens/GamePanel/GuessPane/GunGame.jsx
  13. 18 2
      client/src/components/screens/GamePanel/RaceMode.jsx
  14. 18 2
      client/src/components/screens/GamePanel/hooks.jsx
  15. 5 3
      client/src/components/screens/GameSummary/ScoreBoard/ScoreBoard.jsx
  16. 5 1
      client/src/components/screens/HomePage/HomePage.jsx
  17. 45 5
      client/src/components/screens/HomePage/HomePage.module.css
  18. 44 0
      client/src/components/screens/HomePage/RecentGames.jsx
  19. 7 1
      client/src/components/screens/Lobby/Lobby.jsx
  20. 7 1
      client/src/components/screens/RoundSummary/RoundSummary.jsx
  21. 5 1
      client/src/components/screens/RoundSummary/useClickToCheckScore.jsx
  22. 48 51
      client/src/components/util/GameCreationForm/GameCreationForm.jsx
  23. 98 0
      client/src/components/util/KillFeed/KillFeed.jsx
  24. 36 0
      client/src/components/util/KillFeed/KillFeed.module.css
  25. 22 2
      client/src/domain/apiMethods.js
  26. 3 0
      client/src/domain/constants.js
  27. 5 0
      client/src/domain/gameStore.js
  28. 3 0
      client/src/hooks/useGameInfo.jsx
  29. 2 2
      client/src/hooks/useMarkersFromGuesses/getColorGenerator.js
  30. 5 0
      client/src/index.css
  31. 2 8
      client/src/index.js
  32. 0 135
      client/src/serviceWorker.js
  33. 7 1
      server/app/api/game.py
  34. 31 5
      server/app/api/other.py
  35. 102 1
      server/app/point_gen/__init__.py
  36. 3 0
      server/app/schemas.py
  37. 29 15
      server/app/scoring.py

+ 2 - 0
README.md

@@ -181,3 +181,5 @@ None currently! Submit ideas!
 ## Attributions
 
 Urban game data is based on the [World Cities Database](https://simplemaps.com/data/world-cities)
+
+Chime sound effect is [Mike Koenig's Japanese Temple Bell Small](https://soundbible.com/1496-Japanese-Temple-Bell-Small.html)

+ 1 - 1
client/package.json

@@ -15,7 +15,7 @@
     "react-transition-group": "^4.4.2"
   },
   "scripts": {
-    "start": "react-scripts start",
+    "start": "ESLINT_NO_DEV_ERRORS=true react-scripts start",
     "build": "react-scripts build",
     "format": "prettier --check src/",
     "format:fix": "prettier --write src/",

+ 9 - 1
client/src/App.jsx

@@ -15,7 +15,7 @@ import {
   PRE_GAME,
   PRE_ROUND,
 } from "./domain/constants";
-import { dispatch, useGameState } from "./domain/gameStore";
+import { dispatch, useGameState, useIsMuted } from "./domain/gameStore";
 import Loading from "./components/util/Loading";
 
 const needsHeaderFooter = {
@@ -94,6 +94,7 @@ export const State = ({ show, children, setTransitioning }) => {
 const App = () => {
   const [loading, setLoading] = useState(true);
   const [transitioning, setTransitioning] = useState(true);
+  const muted = useIsMuted();
   const gameState = useGameState();
   useEffect(() => {
     const url = new URL(window.location.href);
@@ -113,6 +114,13 @@ const App = () => {
 
   return (
     <StrictMode>
+      <button
+        type="button"
+        onClick={dispatch.muteToggle}
+        className={styles.volume}
+      >
+        {muted ? "🔇" : "🔊"}
+      </button>
       <div className={styles.page}>
         <Header show={needsHF} />
         <State show={loading} setTransitioning={setTransitioning}>

+ 11 - 2
client/src/App.module.css

@@ -14,7 +14,6 @@
 }
 
 .footer {
-  bottom: 0px;
   width: 100%;
   display: block;
   text-align: center;
@@ -22,7 +21,7 @@
 }
 
 .state {
-  height: 100%;
+  flex: 1;
   width: 100%;
 }
 
@@ -32,3 +31,13 @@
   top: 50%;
   transform: translate(-50%, -50%);
 }
+
+.volume {
+  position: absolute;
+  top: 4px;
+  left: 4px;
+  text-align: center;
+  vertical-align: center;
+  font-size: 1em;
+  z-index: 100;
+}

BIN
client/src/assets/chime.mp3


+ 6 - 0
client/src/assets/hitmarker.svg

@@ -0,0 +1,6 @@
+<svg overflow="visible" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" fill="#ffffff" stroke="#000000" stroke-width="5">
+    <rect x="95" y="0" width="150" height="40" transform="rotate(45)"></rect>
+    <rect x="305" y="0" width="150" height="40" transform="rotate(45)"></rect>
+    <rect x="10" y="255" width="150" height="40" transform="rotate(-45)"></rect>
+    <rect x="-200" y="255" width="150" height="40" transform="rotate(-45)"></rect>
+</svg>

BIN
client/src/assets/hitsound.wav


+ 3 - 2
client/src/components/screens/GamePanel/GamePanel.jsx

@@ -1,4 +1,4 @@
-import { FROZEN, RACE } from "../../../domain/constants";
+import { GUN_GAME, RACE } from "../../../domain/constants";
 import { useGameConfig } from "../../../hooks/useGameInfo";
 import usePreventNavigation from "../../../hooks/usePreventNavigation";
 import Loading from "../../util/Loading";
@@ -7,6 +7,7 @@ import GuessPane from "./GuessPane";
 import PositionedStreetView from "./PositionedStreetView";
 import RaceMode from "./RaceMode";
 import { useIsFinished } from "./hooks";
+import KillFeed from "../../util/KillFeed/KillFeed";
 
 const GamePanel = () => {
   // warn the user if they navigate away
@@ -21,7 +22,7 @@ const GamePanel = () => {
       {clockMode === RACE && <RaceMode rate={1000} cutoffTime={10} />}
       <div className={styles.streetview}>
         <PositionedStreetView />
-        {gameMode === FROZEN && <div className={styles.freeze} />}
+        {gameMode === GUN_GAME && <KillFeed />}
       </div>
       <GuessPane />
     </div>

+ 21 - 16
client/src/components/screens/GamePanel/GamePanel.module.css

@@ -44,29 +44,34 @@
   color: #b1b1b1;
 }
 
-.freeze {
+.cutoff {
   position: absolute;
-  top: 0px;
-  left: 0px;
-  bottom: 0px;
-  right: 0px;
-  z-index: 1;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  box-shadow: 0 0 100vw rgba(240, 0, 0, 0.9) inset;
+  pointer-events: none;
+
+  z-index: 3;
+
+  opacity: 1;
+  transition: opacity 2s;
 }
 
-.cutoff {
-  position: absolute;
-  top: 20%;
+.cutoffMessage {
+  position: relative;
+  top: 30%;
   left: 50%;
   transform: translate(-50%, -50%);
-  text-align: center;
-  z-index: 3;
-  background-color: #333;
+
+  width: 50%;
   padding: 0.2em 1em;
   border-radius: 1em;
-  font-size: 20px;
-
-  opacity: 1;
-  transition: opacity 2s;
+  font-size: 32px;
+  background-color: #333;
+  text-align: center;
+  vertical-align: middle;
 }
 
 .hidden {

+ 70 - 37
client/src/components/screens/GamePanel/GuessPane/GuessPane.jsx

@@ -1,9 +1,17 @@
 import { useCallback, useState } from "react";
-import { dispatch } from "../../../../domain/gameStore";
+import {
+  dispatch,
+  useCurrentRound,
+  useTargetPoint,
+} from "../../../../domain/gameStore";
 import ClickMarkerMap from "./ClickMarkerMap";
 import styles from "./GuessPane.module.css";
 import RoundTimer from "./RoundTimer";
+import GunGame from "./GunGame";
 import { useMapResizeKeybindings } from "./hooks";
+import { useGameConfig } from "../../../../hooks/useGameInfo";
+import { GUN_GAME } from "../../../../domain/constants";
+import { checkScore } from "../../../../domain/apiMethods";
 
 const mapSizeOpts = {
   small: styles["pane--small"],
@@ -18,9 +26,14 @@ const GuessPane = () => {
   const [selectedPoint, setSelectedPoint] = useState(null);
   const [submitted, setSubmitted] = useState(false);
   const [mapSize, setMapSize] = useState("small");
+  const [gunGameBlock, setGunGameBlock] = useState(false);
+  const unblock = useCallback(() => setGunGameBlock(false), []);
   const toggleBig = useCallback(() => setMapSize(toggleMapSize("big")), []);
   const toggleMed = useCallback(() => setMapSize(toggleMapSize("medium")), []);
   useMapResizeKeybindings(toggleBig);
+  const { gameMode, scoreMethod } = useGameConfig();
+  const targetPoint = useTargetPoint();
+  const roundNum = useCurrentRound();
 
   const handleSubmitGuess = async () => {
     setSubmitted(true);
@@ -29,45 +42,65 @@ const GuessPane = () => {
     }
   };
 
+  const handleGateKeeping = async ({ target }) => {
+    const { score } = await checkScore(
+      selectedPoint,
+      targetPoint,
+      scoreMethod,
+      roundNum
+    );
+    if (score < 4000) {
+      target.blur();
+      setGunGameBlock(true);
+    } else {
+      await handleSubmitGuess();
+    }
+  };
+
   return (
-    <div className={`${styles.pane} ${mapSizeOpts[mapSize]}`}>
-      <button
-        type="button"
-        className={styles.submit}
-        onClick={handleSubmitGuess}
-        disabled={submitted || selectedPoint === null}
-      >
-        Submit Guess
-      </button>
-      <ClickMarkerMap onMarkerMoved={setSelectedPoint} />
-      <RoundTimer onTimeout={handleSubmitGuess} />
-      <div
-        className={styles.resize}
-        onClick={toggleBig}
-        role="button"
-        tabIndex="0"
-        onKeyDown={({ key }) => {
-          if (key === "Enter") {
-            toggleBig();
-          }
-        }}
-      >
-        {mapSize === "small" ? "↗️" : "↙️"}
-      </div>
-      <div
-        className={`${styles.resize} ${styles["resize--medium"]}`}
-        onClick={toggleMed}
-        role="button"
-        tabIndex="0"
-        onKeyDown={({ key }) => {
-          if (key === "Enter") {
-            toggleMed();
+    <>
+      {gunGameBlock && gameMode === GUN_GAME && <GunGame onFinish={unblock} />}
+      <div className={`${styles.pane} ${mapSizeOpts[mapSize]}`}>
+        <button
+          type="button"
+          className={styles.submit}
+          onClick={
+            gameMode === GUN_GAME ? handleGateKeeping : handleSubmitGuess
           }
-        }}
-      >
-        {mapSize === "small" ? "➡️" : "⬅️"}
+          disabled={gunGameBlock || submitted || selectedPoint === null}
+        >
+          Submit Guess
+        </button>
+        <ClickMarkerMap onMarkerMoved={setSelectedPoint} />
+        <RoundTimer onTimeout={handleSubmitGuess} />
+        <div
+          className={styles.resize}
+          onClick={toggleBig}
+          role="button"
+          tabIndex="0"
+          onKeyDown={({ key }) => {
+            if (key === "Enter") {
+              toggleBig();
+            }
+          }}
+        >
+          {mapSize === "small" ? "↗️" : "↙️"}
+        </div>
+        <div
+          className={`${styles.resize} ${styles["resize--medium"]}`}
+          onClick={toggleMed}
+          role="button"
+          tabIndex="0"
+          onKeyDown={({ key }) => {
+            if (key === "Enter") {
+              toggleMed();
+            }
+          }}
+        >
+          {mapSize === "small" ? "➡️" : "⬅️"}
+        </div>
       </div>
-    </div>
+    </>
   );
 };
 

+ 30 - 0
client/src/components/screens/GamePanel/GuessPane/GuessPane.module.css

@@ -37,6 +37,36 @@
   display: none;
 }
 
+.gungame {
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  bottom: 0px;
+  right: 0px;
+  z-index: 3;
+  display: flex;
+  flex-flow: column nowrap;
+  justify-content: center;
+  align-items: center;
+}
+
+.gungamePanel {
+  background-color: #333;
+  width: 90%;
+  height: 90%;
+  box-sizing: border-box;
+  padding-top: 2em;
+  padding-bottom: 10em;
+  display: flex;
+  flex-flow: column nowrap;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.gungameLabel {
+  text-align: center;
+}
+
 @media only screen and (min-width: 600px) and (min-height: 600px) {
   :root {
     --margin: 10px;

+ 190 - 0
client/src/components/screens/GamePanel/GuessPane/GunGame.jsx

@@ -0,0 +1,190 @@
+import { useEffect, useRef } from "react";
+import styles from "./GuessPane.module.css";
+
+const tileSize = 16;
+const tiles = 32;
+const rawSize = tileSize * tiles;
+
+// A standard normal can be approximated by 12 summed uniform random values, shifted and rescaled
+// https://en.wikipedia.org/wiki/Irwin%E2%80%93Hall_distribution#Approximating_a_Normal_distribution
+// This can then be reshifted to a better normal distribution for apple locations
+// https://en.wikipedia.org/wiki/Normal_distribution#General_normal_distribution
+// Specifically, desired mean is tiles / 2, with a std deviation of tiles / 4
+// (tiles / 2) + (tiles / 4) * (sum(U_(0, 1), 12) - 6)
+// = (tiles / 2) + ((tiles / 4) * sum(U_(0, 1), 12) - (tiles * 3 / 2))
+// = (tiles / 4) * sum(U_(0, 1), 12) - tiles
+// Then, take round and clamp that to [0, tiles - 1] (which does technically change the dist a bit)
+const tileFactor = tiles / 4;
+const gaussianRandomTile = () => {
+  const rands = Array.from({ length: 12 }, () => Math.random()).reduce(
+    (x, y) => x + y
+  );
+  const rescaled = Math.round(tileFactor * rands - tiles);
+  return Math.max(0, Math.min(tiles - 1, rescaled));
+};
+
+const OPPOSITE_KEY = {
+  ArrowUp: "ArrowDown",
+  ArrowDown: "ArrowUp",
+  ArrowLeft: "ArrowRight",
+  ArrowRight: "ArrowLeft",
+};
+
+const drawTile = (ctx, x, y, color) => {
+  ctx.fillStyle = color;
+  ctx.fillRect(x * tileSize, y * tileSize, tileSize, tileSize);
+};
+
+const snakeGame = () => {
+  const snakeHead = { x: null, y: null, vx: null, vy: null };
+  let snakeTail = [];
+  const apple = { x: null, y: null };
+  let lost = false;
+
+  const randomApple = () => {
+    apple.x = gaussianRandomTile();
+    apple.y = gaussianRandomTile();
+  };
+
+  const reset = () => {
+    snakeHead.x = Math.floor(tiles / 2);
+    snakeHead.y = snakeHead.x;
+    snakeHead.vx = 0;
+    snakeHead.vy = -1;
+    snakeTail = [{ ...snakeHead }];
+    randomApple();
+    lost = false;
+  };
+
+  reset();
+
+  let lastKey;
+
+  const onKey = ({ code }) => {
+    if (snakeTail.length <= 1 || OPPOSITE_KEY[lastKey] !== code) {
+      lastKey = code;
+    }
+  };
+
+  let interval;
+
+  const start = (ctx, finishScore, onFinish) => {
+    document.addEventListener("keydown", onKey);
+    interval = setInterval(() => {
+      if (lost) {
+        return;
+      }
+
+      switch (lastKey) {
+        case "ArrowUp":
+          snakeHead.vx = 0;
+          snakeHead.vy = -1;
+          break;
+        case "ArrowDown":
+          snakeHead.vx = 0;
+          snakeHead.vy = 1;
+          break;
+        case "ArrowLeft":
+          snakeHead.vx = -1;
+          snakeHead.vy = 0;
+          break;
+        case "ArrowRight":
+          snakeHead.vx = 1;
+          snakeHead.vy = 0;
+          break;
+        default:
+          break;
+      }
+
+      snakeHead.x += snakeHead.vx;
+      snakeHead.y += snakeHead.vy;
+
+      if (
+        snakeHead.x < 0 ||
+        snakeHead.x >= tiles ||
+        snakeHead.y < 0 ||
+        snakeHead.y >= tiles ||
+        snakeTail.find(({ x, y }) => x === snakeHead.x && y === snakeHead.y)
+      ) {
+        lost = true;
+        ctx.font = "48px serif";
+        ctx.textAlign = "center";
+        ctx.textBaseline = "middle";
+        ctx.fillText("Crashed!", rawSize / 2, rawSize / 2);
+        setTimeout(() => reset(), 250);
+        return;
+      }
+
+      snakeTail.push({ ...snakeHead });
+
+      if (apple.x === snakeHead.x && apple.y === snakeHead.y) {
+        randomApple();
+      } else {
+        snakeTail.shift();
+      }
+
+      const finished = snakeTail.length >= finishScore;
+
+      ctx.fillStyle = "black";
+      ctx.fillRect(0, 0, rawSize, rawSize);
+
+      snakeTail.forEach(({ x, y }) => {
+        drawTile(ctx, x, y, "green");
+      });
+
+      if (!finished) {
+        drawTile(ctx, apple.x, apple.y, "red");
+      }
+      ctx.font = "24px serif";
+      ctx.fillStyle = "#fff";
+      ctx.textAlign = "left";
+      ctx.textBaseline = "top";
+      ctx.fillText(`${snakeTail.length}/${finishScore}`, tileSize, tileSize);
+
+      if (finished) {
+        clearInterval(interval);
+        interval = null;
+
+        ctx.font = "48px serif";
+        ctx.textAlign = "center";
+        ctx.textBaseline = "middle";
+        ctx.fillText("Done!", rawSize / 2, rawSize / 2);
+
+        setTimeout(onFinish, 500);
+      }
+    }, 60);
+  };
+
+  const stop = () => {
+    document.removeEventListener("keydown", onKey);
+    if (interval) {
+      clearInterval(interval);
+    }
+  };
+
+  return { start, stop, onKey };
+};
+
+const GunGame = ({ onFinish }) => {
+  const canvasRef = useRef();
+  useEffect(() => {
+    const { start, stop } = snakeGame();
+    start(canvasRef.current?.getContext("2d"), 10, onFinish);
+    return () => {
+      stop();
+    };
+  }, [onFinish]);
+
+  return (
+    <div className={styles.gungame}>
+      <div className={styles.gungamePanel}>
+        <div className={styles.gungameLabel}>
+          <div>Not good enough! Finish this snake game to try again!</div>
+        </div>
+        <canvas ref={canvasRef} width={rawSize} height={rawSize} />
+      </div>
+    </div>
+  );
+};
+
+export default GunGame;

+ 18 - 2
client/src/components/screens/GamePanel/RaceMode.jsx

@@ -1,18 +1,34 @@
+import { useEffect } from "react";
 import ms from "pretty-ms";
 import styles from "./GamePanel.module.css";
 import { useFirstSubmitter, useIsFaded } from "./hooks";
+import chime from "../../../assets/chime.mp3";
+import { useIsMuted } from "../../../domain/gameStore";
+
+const chimeAudio = new Audio(chime);
+chimeAudio.volume = 0.25;
 
 const RaceMode = ({ rate, cutoffTime }) => {
   const first = useFirstSubmitter(rate, cutoffTime);
   const faded = useIsFaded(first);
+  const muted = useIsMuted();
+  const cutoff = first !== null;
+
+  useEffect(() => {
+    if (cutoff && !muted) {
+      chimeAudio.play();
+    }
+  }, [cutoff, muted]);
 
-  if (first === null) {
+  if (!cutoff) {
     return <></>;
   }
 
   return (
     <div className={`${styles.cutoff} ${faded ? styles.hidden : ""}`}>
-      You were cut off by {first}! Only {ms(cutoffTime * 1000)} left!
+      <div className={styles.cutoffMessage}>
+        You were cut off by {first}! Only {ms(cutoffTime * 1000)} left!
+      </div>
     </div>
   );
 };

+ 18 - 2
client/src/components/screens/GamePanel/hooks.jsx

@@ -9,6 +9,8 @@ import {
   savePanoPositionToLocalStorage,
   savePanoPovToLocalStorage,
 } from "../../../domain/localStorageMethods";
+import { useGameConfig } from "../../../hooks/useGameInfo";
+import { FROZEN } from "../../../domain/constants";
 /* global google */
 
 export const useFirstSubmitter = (rate, cutoffTime) => {
@@ -57,8 +59,12 @@ export const useIsFinished = () => {
 };
 
 export const usePano = (panoDivRef, { lat, lng }, { heading, pitch }) => {
+  const { gameMode } = useGameConfig();
   const panoRef = useRef(null);
   useEffect(() => {
+    if (gameMode === undefined) {
+      return;
+    }
     const position = { lat, lng };
     const pov = { heading, pitch };
     if (panoRef.current) {
@@ -67,18 +73,28 @@ export const usePano = (panoDivRef, { lat, lng }, { heading, pitch }) => {
       return;
     }
 
+    const settings =
+      gameMode === FROZEN
+        ? {
+            disableDefaultUI: true,
+            panControl: true,
+            zoomControl: true,
+            clickToGo: false,
+          }
+        : { clickToGo: true };
+
     panoRef.current = new google.maps.StreetViewPanorama(panoDivRef.current, {
       position,
       pov,
       fullscreenControl: false,
       addressControl: false,
       showRoadLabels: false,
-      clickToGo: true,
       visible: true,
       motionTracking: false,
       motionTrackingControl: false,
+      ...settings,
     });
-  }, [panoDivRef, lat, lng, heading, pitch]);
+  }, [gameMode, panoDivRef, lat, lng, heading, pitch]);
 
   return panoRef;
 };

+ 5 - 3
client/src/components/screens/GameSummary/ScoreBoard/ScoreBoard.jsx

@@ -47,10 +47,12 @@ export const PlayerScoreTile = ({ name, guesses, totalScore, winner }) => (
   </div>
 );
 
-const ScoreBoard = ({ players }) => (
+const ScoreBoard = ({ players, requireFinished }) => (
   <div className={styles.scoreboard}>
-    {players
-      .filter(({ currentRound }) => currentRound === null)
+    {(requireFinished
+      ? players.filter(({ currentRound }) => currentRound === null)
+      : players.slice()
+    )
       .sort(({ totalScore: p1 }, { totalScore: p2 }) =>
         // eslint-disable-next-line no-nested-ternary
         p1 > p2 ? -1 : p1 < p2 ? 1 : 0

+ 5 - 1
client/src/components/screens/HomePage/HomePage.jsx

@@ -4,6 +4,7 @@ import { dispatch } from "../../../domain/gameStore";
 import DelayedButton from "../../util/DelayedButton";
 import GameCreationForm from "../../util/GameCreationForm";
 import styles from "./HomePage.module.css";
+import RecentGames from "./RecentGames";
 import useHasSavedInfo from "./useHasSavedInfo";
 
 export const Rejoin = forwardRef((_, ref) => (
@@ -36,7 +37,10 @@ const HomePage = () => {
       >
         <Rejoin ref={transitionRef} />
       </CSSTransition>
-      <GameCreationForm afterCreate={gameId => dispatch.goToLobby(gameId)} />
+      <div className={styles.row}>
+        <GameCreationForm afterCreate={gameId => dispatch.goToLobby(gameId)} />
+        <RecentGames />
+      </div>
     </div>
   );
 };

+ 45 - 5
client/src/components/screens/HomePage/HomePage.module.css

@@ -1,9 +1,17 @@
 .page {
-  width: 100%;
   height: 100%;
   display: flex;
   flex-flow: column nowrap;
-  justify-content: center;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.row {
+  width: 80%;
+  flex: 1;
+  display: flex;
+  flex-flow: row nowrap;
+  justify-content: space-evenly;
   align-items: center;
 }
 
@@ -12,11 +20,43 @@
 }
 
 .rejoinSection {
-  top: 20%;
-  position: absolute;
-  flex: 1;
+  height: 30%;
   display: flex;
   flex-flow: column nowrap;
   justify-content: flex-end;
   align-items: center;
 }
+
+.recentSection {
+  display: flex;
+  flex-flow: column nowrap;
+  justify-content: flex-start;
+  align-items: stretch;
+  width: 24em;
+}
+
+.recentTitle {
+  width: 100%;
+  padding-bottom: 0.5em;
+}
+
+.recentItem {
+  width: 100%;
+  margin-bottom: 0.5em;
+  display: inline-flex;
+  justify-content: space-between;
+}
+
+.recentPlayerInfo {
+  max-width: 50%;
+  display: inline-flex;
+  justify-content: flex-end;
+}
+
+.recentPlayerName {
+  margin-right: 0.125em;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  text-align: right;
+}

+ 44 - 0
client/src/components/screens/HomePage/RecentGames.jsx

@@ -0,0 +1,44 @@
+import ms from "pretty-ms";
+import { RACE } from "../../../domain/constants";
+import { dispatch } from "../../../domain/gameStore";
+import { useRecentGameInfo } from "../../../hooks/useGameInfo";
+import styles from "./HomePage.module.css";
+
+const RecentGames = () => {
+  const recent = useRecentGameInfo();
+  return (
+    <div className={styles.recentSection}>
+      <div className={styles.recentTitle}>Recently Created Games:</div>
+      {recent?.map(
+        ({ gameId, gameMode, clockMode, rounds, timer, player, numPlayers }) => (
+          <button
+            key={gameId}
+            type="button"
+            className={styles.recentItem}
+            onClick={() => dispatch.goToLobby(gameId)}
+          >
+            <div>
+              {clockMode === RACE ? "Duel" : gameMode
+                .split("_")
+                .map(
+                  part => part[0].toUpperCase() + part.slice(1).toLowerCase()
+                )
+                .join(" ")}
+              ,&nbsp;
+              {rounds} round(s),&nbsp;
+              {ms(timer * 1000)}
+            </div>
+            <div className={styles.recentPlayerInfo}>
+              <div className={styles.recentPlayerName}>
+                {player ?? "(no players)"}
+              </div>
+              {numPlayers > 1 ? `+${numPlayers - 1}` : ""}
+            </div>
+          </button>
+        )
+      )}
+    </div>
+  );
+};
+
+export default RecentGames;

+ 7 - 1
client/src/components/screens/Lobby/Lobby.jsx

@@ -17,6 +17,7 @@ import {
   NIGHTMARE,
   RAMP,
   RAMP_HARD,
+  GUN_GAME,
 } from "../../../domain/constants";
 
 export const GameInfo = () => {
@@ -79,7 +80,12 @@ export const GameInfo = () => {
       )}
       {gameMode === FROZEN && (
         <span className={styles.label}>
-          You will not be able to adjust your view
+          You will not be able to move, but will be able to zoom and pan
+        </span>
+      )}
+      {gameMode === GUN_GAME && (
+        <span className={styles.label}>
+          You will need to score over 4000 points to progress through each round
         </span>
       )}
       {roundPointCap && (

+ 7 - 1
client/src/components/screens/RoundSummary/RoundSummary.jsx

@@ -1,14 +1,16 @@
 import { useRef } from "react";
+import { GUN_GAME } from "../../../domain/constants";
 import {
   dispatch,
   useCurrentRound,
   useLastRound,
 } from "../../../domain/gameStore";
-import { usePlayers } from "../../../hooks/useGameInfo";
+import { useGameConfig, usePlayers } from "../../../hooks/useGameInfo";
 import useMap from "../../../hooks/useMap";
 import useMarkersFromGuesses from "../../../hooks/useMarkersFromGuesses";
 import usePreventNavigation from "../../../hooks/usePreventNavigation";
 import DelayedButton from "../../util/DelayedButton";
+import KillFeed from "../../util/KillFeed/KillFeed";
 import styles from "./RoundSummary.module.css";
 import useClickToCheckScore from "./useClickToCheckScore";
 
@@ -57,8 +59,12 @@ const RoundSummary = () => {
   // warn the user if they navigate away
   usePreventNavigation();
 
+  // get the current gamemode
+  const { gameMode } = useGameConfig();
+
   return (
     <div>
+      {gameMode === GUN_GAME && <KillFeed />}
       <div className={styles.map} ref={mapDivRef} />
       <div className={styles.panel}>
         <span className={styles.score}>

+ 5 - 1
client/src/components/screens/RoundSummary/useClickToCheckScore.jsx

@@ -1,10 +1,14 @@
 import useClickMarker from "../../../hooks/useClickMarker";
 import { checkScore } from "../../../domain/apiMethods";
+import { useGameConfig } from "../../../hooks/useGameInfo";
+import { useLastRound } from "../../../domain/gameStore";
 
 const useClickToCheckScore = (mapRef, point1) => {
+  const { scoreMethod } = useGameConfig();
+  const { roundNum } = useLastRound();
   // when the map is clicked, call the scoring API and update the marker's title
   useClickMarker(mapRef, async (point2, marker) => {
-    const { score } = await checkScore(point1, point2);
+    const { score } = await checkScore(point1, point2, scoreMethod, roundNum);
     marker.setLabel({
       fontWeight: "500",
       text: `Potential Score: ${score}`,

+ 48 - 51
client/src/components/util/GameCreationForm/GameCreationForm.jsx

@@ -14,6 +14,9 @@ import {
   NIGHTMARE,
   RAMP,
   RAMP_HARD,
+  GUN_GAME,
+  DIFFICULTY_TIERED,
+  COUNTRY_DIST,
 } from "../../../domain/constants";
 import useCountryLookup from "../../../hooks/useCountryLookup";
 import Loading from "../Loading";
@@ -33,15 +36,6 @@ const DEFAULTS = {
 };
 
 const PRESETS = {
-  URBAN_AMERICA: {
-    ...DEFAULTS,
-    generationMethod: URBAN,
-    countryLock: "us",
-  },
-  URBAN_GLOBAL: {
-    ...DEFAULTS,
-    generationMethod: URBAN,
-  },
   FAST_FROZEN: {
     ...DEFAULTS,
     timer: 30,
@@ -49,21 +43,21 @@ const PRESETS = {
     generationMethod: RANDOM_STREET_VIEW,
     gameMode: FROZEN,
   },
-  COUNTRY_RACE: {
-    ...DEFAULTS,
-    scoreMethod: COUNTRY_RACE,
-  },
-  FROZEN_COUNTRY_RACE: {
-    ...DEFAULTS,
-    timer: 30,
-    gameMode: FROZEN,
-    scoreMethod: COUNTRY_RACE,
-  },
   BOOTLEG_GG_DUEL: {
     ...DEFAULTS,
+    timer: 120,
     clockMode: RACE,
     scoreMethod: RAMP,
   },
+  GUN_GAME: {
+    ...DEFAULTS,
+    timer: 120,
+    rounds: 10,
+    gameMode: GUN_GAME,
+    clockMode: TIME_BANK,
+    scoreMethod: COUNTRY_DIST,
+    generationMethod: DIFFICULTY_TIERED,
+  },
 };
 
 export const LastSettingsButton = ({ onClick }) => (
@@ -100,8 +94,9 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
   const countryLookup = useCountryLookup(generationMethod);
 
   const [presetOpen, setPresetOpen] = useState(false);
-  const setPreset = useCallback(
-    ({
+  const [selectedPreset, setSelectedPreset] = useState(DEFAULTS);
+  const setPreset = useCallback(preset => {
+    const {
       timer: newTimer,
       rounds: newRounds,
       countryLock: newCountryLock,
@@ -110,18 +105,17 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
       clockMode: newClockMode,
       scoreMethod: newScoreMethod,
       roundPointCap: newRoundPointCap,
-    }) => {
-      setTimer(newTimer);
-      setRounds(newRounds);
-      setCountryLock(newCountryLock);
-      setGenMethod(newGenMethod);
-      setGameMode(newGameMode);
-      setClockMode(newClockMode);
-      setScoreMethod(newScoreMethod);
-      setRoundPointCap(newRoundPointCap);
-    },
-    []
-  );
+    } = preset;
+    setTimer(newTimer);
+    setRounds(newRounds);
+    setCountryLock(newCountryLock);
+    setGenMethod(newGenMethod);
+    setGameMode(newGameMode);
+    setClockMode(newClockMode);
+    setScoreMethod(newScoreMethod);
+    setRoundPointCap(newRoundPointCap);
+    setSelectedPreset(preset);
+  }, []);
 
   if (loading || countryLookup === null) {
     return <Loading />;
@@ -160,7 +154,7 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
       <div className={styles.buttoncontainer}>
         <Dropdown
           buttonClass={styles.favbutton}
-          selected={DEFAULTS}
+          selected={selectedPreset}
           onClick={() => setPresetOpen(o => !o)}
           onSelect={v => {
             setPresetOpen(false);
@@ -171,23 +165,14 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
           <Item value={DEFAULTS} display="⭐">
             Default
           </Item>
-          <Item value={PRESETS.URBAN_AMERICA} display="⭐">
-            Urban America
-          </Item>
-          <Item value={PRESETS.URBAN_GLOBAL} display="⭐">
-            Urban Global
-          </Item>
-          <Item value={PRESETS.FAST_FROZEN} display="⭐">
+          <Item value={PRESETS.FAST_FROZEN} display="🧊">
             Fast Frozen
           </Item>
-          <Item value={PRESETS.COUNTRY_RACE} display="⭐">
-            Country Race
+          <Item value={PRESETS.BOOTLEG_GG_DUEL} display="⚔️">
+            Duel
           </Item>
-          <Item value={PRESETS.FROZEN_COUNTRY_RACE} display="⭐">
-            Frozen Country Race
-          </Item>
-          <Item value={PRESETS.BOOTLEG_GG_DUEL} display="⭐">
-            Legally Distinct from Geoguessr Duels
+          <Item value={PRESETS.GUN_GAME} display="🔫">
+            Gun Game
           </Item>
         </Dropdown>
         {lastSettings && (
@@ -212,6 +197,9 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
             <Item value={3600} display={ms(60 * 60 * 1000)}>
               1 Hour
             </Item>
+            <Item value={7 * 24 * 3600} display={ms(7 * 24 * 60 * 60 * 1000)}>
+              &quot;Limitless&quot; (1 Week)
+            </Item>
           </Dropdown>
           <Dropdown selected={rounds} onSelect={setRounds} open="rounds">
             <Item value={1}>1 Round</Item>
@@ -230,6 +218,9 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
             <Item value={URBAN} display="🏙️">
               Urban Centers
             </Item>
+            <Item value={DIFFICULTY_TIERED} display="🪜">
+              Difficulty Tiered
+            </Item>
           </Dropdown>
           <CountryDropdown
             countryLookup={countryLookup}
@@ -244,6 +235,9 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
             <Item value={FROZEN} display="❄️">
               Frozen
             </Item>
+            <Item value={GUN_GAME} display="🚪">
+              Gatekeeper
+            </Item>
           </Dropdown>
           <Dropdown
             selected={clockMode}
@@ -256,8 +250,8 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
             <Item value={TIME_BANK} display="🏦">
               Time Bank
             </Item>
-            <Item value={RACE} display="️">
-              Duel
+            <Item value={RACE} display="🏎️">
+              Race
             </Item>
           </Dropdown>
           <Dropdown
@@ -268,10 +262,13 @@ const GameCreationForm = ({ afterCreate, lastSettings = null }) => {
             <Item value={DISTANCE} display="📏">
               Distance
             </Item>
+            <Item value={COUNTRY_DIST} display="🗾">
+              Distance + Country
+            </Item>
             <Item value={RAMP} display="📈">
               Ramping
             </Item>
-            <Item value={COUNTRY_RACE} display="🗾">
+            <Item value={COUNTRY_RACE} display="🏁">
               Country Race
             </Item>
             <Item value={HARD} display="😬">

+ 98 - 0
client/src/components/util/KillFeed/KillFeed.jsx

@@ -0,0 +1,98 @@
+import { useEffect, useState } from "react";
+import {
+  dispatch,
+  useGameId,
+  useIsMuted,
+  usePlayerName,
+} from "../../../domain/gameStore";
+import { usePlayers } from "../../../hooks/useGameInfo";
+import styles from "./KillFeed.module.css";
+import hitmarker from "../../../assets/hitmarker.svg";
+import hitsound from "../../../assets/hitsound.wav";
+
+// okay, in an ideal world this would be part of the game store or something
+// and it would get properly managed by reactive state
+// but also, this totally works as is, and the only downside is it might potentially grow too big
+// but that only happens if someone plays that many gun games without ever leaving the window
+const shownItems = new Set();
+
+const KillFeed = () => {
+  const muted = useIsMuted();
+  const playerName = usePlayerName();
+  const gameId = useGameId();
+  const players = usePlayers();
+  useEffect(() => {
+    if (players?.find(({ currentRound }) => currentRound === null)) {
+      dispatch.goToSummary();
+    }
+  }, [players]);
+  const [shownItemsState, setShownItemsState] = useState(shownItems);
+  const [display, setDisplay] = useState([]);
+  useEffect(() => {
+    const toDisplay =
+      players
+        ?.filter(({ name }) => name !== playerName)
+        ?.flatMap(({ name, guesses }) =>
+          Object.entries(guesses).map(([round, { score }]) => ({
+            name,
+            round,
+            score,
+          }))
+        )
+        ?.filter(
+          ({ name, round }) =>
+            !shownItemsState.has(`${gameId}-${name}-${round}`)
+        ) ?? [];
+    setDisplay(toDisplay);
+    const timeout = setTimeout(() => {
+      toDisplay.forEach(({ name, round }) => {
+        shownItems.add(`${gameId}-${name}-${round}`);
+      });
+      setShownItemsState(new Set(shownItems));
+    }, 5000);
+    return () => {
+      clearTimeout(timeout);
+    };
+  }, [shownItemsState, gameId, players, playerName]);
+  useEffect(() => {
+    if (!muted) {
+      display.forEach(() => {
+        const audio = new Audio(hitsound);
+        audio.volume = 0.5;
+        // delay up to half a second so overlapping sounds better
+        const delayedPlay = () =>
+          setTimeout(() => audio.play(), Math.random() * 500);
+        audio.addEventListener("canplaythrough", delayedPlay);
+        // clean up after ourselves in the hopes that the browser actually deletes this audio element
+        audio.addEventListener("ended", () =>
+          audio.removeEventListener("canplaythrough", delayedPlay)
+        );
+      });
+    }
+  }, [display, muted]);
+  return (
+    <>
+      {display.map(() => (
+        <img
+          alt="hitmarker"
+          className={styles.hitmarker}
+          style={{
+            top: `${10 + Math.random() * 80}vh`,
+            left: `${10 + Math.random() * 80}vw`,
+          }}
+          src={hitmarker}
+        />
+      ))}
+      <div className={styles.feed}>
+        {display.map(({ name, round, score }) => (
+          <span className={styles.item}>
+            <span className={styles.name}>{name}</span>{" "}
+            {score >= 4950 ? "🎯" : "🖱️"} 🗺️ {round}
+          </span>
+        ))}
+      </div>
+    </>
+  );
+};
+
+export default KillFeed;

+ 36 - 0
client/src/components/util/KillFeed/KillFeed.module.css

@@ -0,0 +1,36 @@
+.feed {
+  position: absolute;
+  z-index: 4;
+  right: 8px;
+  top: 8px;
+  display: flex;
+  flex-flow: column nowrap;
+  justify-content: flex-end;
+  align-items: flex-start;
+  pointer-events: none;
+}
+
+.item {
+  display: inline-flex;
+  background: #333;
+  padding: 0.5em;
+  justify-content: flex-end;
+  align-items: center;
+}
+
+.name {
+  display: inline-block;
+  width: 10em;
+  margin-right: 0.5em;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  text-align: right;
+}
+
+.hitmarker {
+  position: absolute;
+  z-index: 5;
+  width: 100px;
+  pointer-events: none;
+}

+ 22 - 2
client/src/domain/apiMethods.js

@@ -12,13 +12,13 @@ export const getStatus = async () => {
   }
 };
 
-export const checkScore = async (point1, point2) => {
+export const checkScore = async (point1, point2, scoreMethod, roundNumber) => {
   const res = await fetch(`${API_BASE}/score`, {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
     },
-    body: JSON.stringify({ point1, point2 }),
+    body: JSON.stringify({ point1, point2, scoreMethod, roundNumber }),
   });
   if (!res.ok) {
     throw Error(res.statusText);
@@ -92,6 +92,26 @@ export const getPlayers = async gameId => {
   return players;
 };
 
+export const getRecentGameInfo = async () => {
+  const res = await fetch(`${API_BASE}/recent`);
+  if (!res.ok) {
+    throw Error(res.statusText);
+  }
+  const { recentGames } = await res.json();
+  return Promise.all(
+    recentGames.slice(0, 5).map(async gameId => {
+      const config = await getGameConfig(gameId);
+      const players = await getPlayers(gameId);
+      return {
+        gameId,
+        ...config,
+        player: players?.[0]?.name,
+        numPlayers: players?.length ?? 0,
+      };
+    })
+  );
+};
+
 export const getLinkedGame = async gameId => {
   const res = await fetch(`${API_BASE}/game/${gameId}/linked`);
   if (!res.ok) {

+ 3 - 0
client/src/domain/constants.js

@@ -9,14 +9,17 @@ export const ERROR = "ERROR"; // Error state
 // Generation Methods
 export const RANDOM_STREET_VIEW = "RANDOMSTREETVIEW";
 export const URBAN = "URBAN";
+export const DIFFICULTY_TIERED = "DIFFICULTY_TIERED";
 
 // Game Config Options
 export const NORMAL = "NORMAL";
 export const TIME_BANK = "TIMEBANK";
 export const FROZEN = "FROZEN";
+export const GUN_GAME = "GUN_GAME";
 export const RACE = "RACE";
 export const COUNTRY_RACE = "COUNTRYRACE";
 export const DISTANCE = "DISTANCE";
+export const COUNTRY_DIST = "COUNTRYDIST";
 export const HARD = "HARD";
 export const NIGHTMARE = "NIGHTMARE";
 export const RAMP = "RAMP";

+ 5 - 0
client/src/domain/gameStore.js

@@ -22,6 +22,7 @@ import {
 
 const [hooks, actions, watch] = create(store => ({
   // state
+  isMuted: false,
   gameId: null,
   playerName: null,
   lastRound: {
@@ -42,6 +43,9 @@ const [hooks, actions, watch] = create(store => ({
   panoStartPov: null,
   // actions
   /* eslint-disable no-param-reassign */
+  muteToggle: () => {
+    store.isMuted = !store.isMuted;
+  },
   setPlayerName: name => {
     store.playerName = name;
   },
@@ -143,6 +147,7 @@ if (process.env.REACT_APP_MONITOR_STORE === "true") {
 }
 
 export const {
+  useIsMuted,
   useGameId,
   usePlayerName,
   useLastRound,

+ 3 - 0
client/src/hooks/useGameInfo.jsx

@@ -5,6 +5,7 @@ import {
   getGameCoords,
   getPlayers,
   getLinkedGame,
+  getRecentGameInfo,
 } from "../domain/apiMethods";
 import { useGameId } from "../domain/gameStore";
 
@@ -52,3 +53,5 @@ const useAutoRefresh = apiCall => {
 export const usePlayers = () => useAutoRefresh(getPlayers);
 
 export const useLinkedGame = () => useAutoRefresh(getLinkedGame);
+
+export const useRecentGameInfo = () => useAutoRefresh(getRecentGameInfo);

+ 2 - 2
client/src/hooks/useMarkersFromGuesses/getColorGenerator.js

@@ -2,8 +2,8 @@
 // https://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/
 const goldenRatioConj = 0.618033988749895;
 
-const getColorGenerator = () => {
-  let h = 0;
+const getColorGenerator = (hInit = null) => {
+  let h = hInit ?? Math.random();
   const nextColor = () => {
     const h6 = h * 6;
     h += goldenRatioConj;

+ 5 - 0
client/src/index.css

@@ -77,3 +77,8 @@ button:disabled {
   opacity: 0;
   transition: opacity 500ms;
 }
+
+.gm-style-cc {
+  display: none !important;
+  /* hide the copyright information */
+}

+ 2 - 8
client/src/index.js

@@ -1,12 +1,6 @@
 import React from "react";
-import ReactDOM from "react-dom";
+import { createRoot } from "react-dom/client";
 import "./index.css";
 import App from "./App";
-import * as serviceWorker from "./serviceWorker";
 
-ReactDOM.render(<App />, document.getElementById("root")); // eslint-disable-line react/jsx-filename-extension
-
-// If you want your app to work offline and load faster, you can change
-// unregister() to register() below. Note this comes with some pitfalls.
-// Learn more about service workers: https://bit.ly/CRA-PWA
-serviceWorker.unregister();
+createRoot(document.getElementById("root")).render(<App />); // eslint-disable-line react/jsx-filename-extension

+ 0 - 135
client/src/serviceWorker.js

@@ -1,135 +0,0 @@
-// This optional code is used to register a service worker.
-// register() is not called by default.
-
-// This lets the app load faster on subsequent visits in production, and gives
-// it offline capabilities. However, it also means that developers (and users)
-// will only see deployed updates on subsequent visits to a page, after all the
-// existing tabs open on the page have been closed, since previously cached
-// resources are updated in the background.
-
-// To learn more about the benefits of this model and instructions on how to
-// opt-in, read https://bit.ly/CRA-PWA
-
-const isLocalhost = Boolean(
-  window.location.hostname === 'localhost' ||
-    // [::1] is the IPv6 localhost address.
-    window.location.hostname === '[::1]' ||
-    // 127.0.0.1/8 is considered localhost for IPv4.
-    window.location.hostname.match(
-      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
-    )
-);
-
-export function register(config) {
-  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
-    // The URL constructor is available in all browsers that support SW.
-    const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
-    if (publicUrl.origin !== window.location.origin) {
-      // Our service worker won't work if PUBLIC_URL is on a different origin
-      // from what our page is served on. This might happen if a CDN is used to
-      // serve assets; see https://github.com/facebook/create-react-app/issues/2374
-      return;
-    }
-
-    window.addEventListener('load', () => {
-      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
-
-      if (isLocalhost) {
-        // This is running on localhost. Let's check if a service worker still exists or not.
-        checkValidServiceWorker(swUrl, config);
-
-        // Add some additional logging to localhost, pointing developers to the
-        // service worker/PWA documentation.
-        navigator.serviceWorker.ready.then(() => {
-          console.log(
-            'This web app is being served cache-first by a service ' +
-              'worker. To learn more, visit https://bit.ly/CRA-PWA'
-          );
-        });
-      } else {
-        // Is not localhost. Just register service worker
-        registerValidSW(swUrl, config);
-      }
-    });
-  }
-}
-
-function registerValidSW(swUrl, config) {
-  navigator.serviceWorker
-    .register(swUrl)
-    .then(registration => {
-      registration.onupdatefound = () => {
-        const installingWorker = registration.installing;
-        if (installingWorker == null) {
-          return;
-        }
-        installingWorker.onstatechange = () => {
-          if (installingWorker.state === 'installed') {
-            if (navigator.serviceWorker.controller) {
-              // At this point, the updated precached content has been fetched,
-              // but the previous service worker will still serve the older
-              // content until all client tabs are closed.
-              console.log(
-                'New content is available and will be used when all ' +
-                  'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
-              );
-
-              // Execute callback
-              if (config && config.onUpdate) {
-                config.onUpdate(registration);
-              }
-            } else {
-              // At this point, everything has been precached.
-              // It's the perfect time to display a
-              // "Content is cached for offline use." message.
-              console.log('Content is cached for offline use.');
-
-              // Execute callback
-              if (config && config.onSuccess) {
-                config.onSuccess(registration);
-              }
-            }
-          }
-        };
-      };
-    })
-    .catch(error => {
-      console.error('Error during service worker registration:', error);
-    });
-}
-
-function checkValidServiceWorker(swUrl, config) {
-  // Check if the service worker can be found. If it can't reload the page.
-  fetch(swUrl)
-    .then(response => {
-      // Ensure service worker exists, and that we really are getting a JS file.
-      const contentType = response.headers.get('content-type');
-      if (
-        response.status === 404 ||
-        (contentType != null && contentType.indexOf('javascript') === -1)
-      ) {
-        // No service worker found. Probably a different app. Reload the page.
-        navigator.serviceWorker.ready.then(registration => {
-          registration.unregister().then(() => {
-            window.location.reload();
-          });
-        });
-      } else {
-        // Service worker found. Proceed as normal.
-        registerValidSW(swUrl, config);
-      }
-    })
-    .catch(() => {
-      console.log(
-        'No internet connection found. App is running in offline mode.'
-      );
-    });
-}
-
-export function unregister() {
-  if ('serviceWorker' in navigator) {
-    navigator.serviceWorker.ready.then(registration => {
-      registration.unregister();
-    });
-  }
-}

+ 7 - 1
server/app/api/game.py

@@ -7,7 +7,7 @@ from pydantic import conint, constr
 from sqlalchemy.orm import Session
 
 from .. import scoring
-from ..schemas import GameConfig, Guess, ScoreMethodEnum
+from ..schemas import GameConfig, GameModeEnum, Guess, ScoreMethodEnum
 from ..db import get_db, queries, models
 from ..point_gen import points, ExhaustedSourceError, reverse_geocode
 
@@ -149,6 +149,9 @@ async def submit_guess(round_number: conint(gt=0),
     country_code = await reverse_geocode(guess.lat, guess.lng)
     
     match game.score_method:
+        case ScoreMethodEnum.country_distance:
+            score_fn = scoring.score if country_code == target.country_code else scoring.score_pow
+            score, distance = score_fn((target.latitude, target.longitude), (guess.lat, guess.lng))
         case ScoreMethodEnum.country_race:
             score = scoring.score_country_race(target.country_code, country_code, guess.time_remaining, game.timer)
             distance = None
@@ -167,6 +170,9 @@ async def submit_guess(round_number: conint(gt=0),
     
     if game.round_point_cap is not None:
         score = min(score, max(0, game.round_point_cap - sum(g.round_score for p in game.players for g in p.guesses if g.round_number == round_number)))
+    if game.game_mode == GameModeEnum.gun_game and round_number == game.rounds and queries.get_first_submitter(db, game.game_id, round_number) is None:
+        # first to submit in the last round of gun game gets 10k bonus points to ensure they are the winner of the whole game
+        score += 10_000
     added = queries.add_guess(db, guess, player, country_code, round_number, score)
     if not added:
         raise HTTPException(status_code=409, detail="Already submitted guess for this round")

+ 31 - 5
server/app/api/other.py

@@ -1,4 +1,4 @@
-from typing import List
+from typing import List, Optional
 
 from fastapi import APIRouter, Depends
 from fastapi_camelcase import CamelModel
@@ -7,9 +7,9 @@ from pydantic import confloat
 from sqlalchemy.orm import Session
 
 from .. import scoring
-from ..schemas import CacheInfo, GeneratorInfo
+from ..schemas import CacheInfo, GameConfig, GeneratorInfo, ScoreMethodEnum
 from ..db import get_db, queries
-from ..point_gen import points, generator_info
+from ..point_gen import points, generator_info, reverse_geocode
 
 router = APIRouter()
 
@@ -22,6 +22,8 @@ class Point(CamelModel):
 class ScoreCheck(CamelModel):
     point1: Point
     point2: Point
+    score_method: Optional[ScoreMethodEnum] = None
+    round_number: Optional[int] = None
 
 
 class Score(CamelModel):
@@ -47,8 +49,32 @@ def health():
 
 
 @router.post("/score", response_model=Score)
-def check_score(points: ScoreCheck):
-    score, distance = scoring.score((points.point1.lat, points.point1.lng), (points.point2.lat, points.point2.lng))
+async def check_score(check: ScoreCheck):
+    match check.score_method:
+        case ScoreMethodEnum.country_distance:
+            p1_code = await reverse_geocode(check.point1.lat, check.point1.lng)
+            p2_code = await reverse_geocode(check.point2.lat, check.point2.lng)
+            score_fn = scoring.score if p1_code == p2_code else scoring.score_pow
+            score, distance = score_fn((check.point1.lat, check.point1.lng), (check.point2.lat, check.point2.lng))
+        case ScoreMethodEnum.country_race:
+            p1_code = await reverse_geocode(check.point1.lat, check.point1.lng)
+            p2_code = await reverse_geocode(check.point2.lat, check.point2.lng)
+            score = 5000 if p1_code == p2_code else 0
+            distance = 0
+        case ScoreMethodEnum.hard:
+            score, distance = scoring.score_hard((check.point1.lat, check.point1.lng), (check.point2.lat, check.point2.lng))
+        case ScoreMethodEnum.nightmare:
+            score, distance = scoring.score_nightmare((check.point1.lat, check.point1.lng), (check.point2.lat, check.point2.lng))
+        case ScoreMethodEnum.ramp:
+            score, distance = scoring.score((check.point1.lat, check.point1.lng), (check.point2.lat, check.point2.lng))
+            if check.round_number is not None:
+                score *= 1 + ((check.round_number - 1) * 0.5)
+        case ScoreMethodEnum.ramp_hard:
+            score, distance = scoring.score_hard((check.point1.lat, check.point1.lng), (check.point2.lat, check.point2.lng))
+            if check.round_number is not None:
+                score *= 1 + ((check.round_number - 1) * 0.5)
+        case _:
+            score, distance = scoring.score((check.point1.lat, check.point1.lng), (check.point2.lat, check.point2.lng))
     return Score(distance=distance, score=score)
 
 

+ 102 - 1
server/app/point_gen/__init__.py

@@ -34,6 +34,85 @@ class ExhaustedSourceError(Exception):
     pass
 
 
+DIFFICULTY_1 = [
+    # Singapore - very small, pretty obvious from lang combo
+    "sg",
+    # Israel, Taiwan, Japan, South Korea, Greece, Poland - immediately obvious from language
+    "il", "tw", "jp", "kr", "gr", "pl",
+    # Hong Kong - distraction from Taiwan
+    "hk",
+]
+DIFFICULTY_2 = [
+    # USA! USA! USA! USA! (suck it Europe)
+    "us", "us", "us", "us",
+    # Western Europe minus a few more interesting ones
+    "ie", "gb", "es", "fr",
+    "be", "nl", "lu",
+    "de", "ch", "li", "at",
+    "it", "mt",
+    # Southern Africa (b/c English)
+    "za", "ls", "sz", "na", "bw", "zw",
+    # New Zealand (b/c English)
+    "nz",
+]
+DIFFICULTY_3 = [
+    # Nordic languages 
+    "is", "fo", "se", "no", "dk", "gl",
+    # Finno-urgic
+    "fi", "ee",
+    # Other Baltics
+    "lv", "lt",
+    # Central + Eastern Europe + Balkans (non-Cyrillic, non-Polish confusable)
+    "cz", "sk", "hu", "ro", "si", "hr", "ba", "al", "md",
+    # Cyrillic Balkans
+    "bg", "rs", "me", "mk",
+    # Turkey can also have its language confused with some of the above
+    "tr",
+    # Caucasus
+    "am", "az", "ge",
+    # SE Asia (partial - mainly the ones with non-Latin scripts)
+    "bt", "np", "bd",
+    "mm", "kh", "vn", "th", "la",
+]
+DIFFICULTY_4 = [
+    # SE Asia (partial - mainly the ones with harder to differentiate languages)   
+    "id", "my", 
+    # Middle East
+    "iq", "jo", "lb", "sa", "ae", "om",
+    # North Africa
+    "eg", "dz", "tn", "ma",
+    # West Africa
+    "sn", "gi", "ng",
+    # East Africa
+    "ke", "et",
+    # Mexico + Central America + South America minus Brazil (i.e., all Spanish)
+    "mx", "gt", "ni", "pa", "co", "ec", "pe", "bo", "ar", "cl",
+]
+DIFFICULTY_5 = [
+    # Canada + Australia
+    "ca", "au",
+    # Brazil + Portugal (lol)
+    "br", "pt",
+    # Russia + other Cyrillic + Mongolia
+    "ru", 
+    "ua", "by",
+    "kz", "kg", "tj", "tm", "uz",
+    "mn", 
+    # India (basically all photo orbs)
+    "in",
+]
+DIFFICULTY_X = [
+    # tropical/subtropical island nations
+    "lk", "cv", "cu", "do", "jm", "mg", "mv", "pg", "ph", "ws", "tt", "pr",
+]
+DIFFICULTY_TIER_ORDER = (
+    DIFFICULTY_1, DIFFICULTY_2, DIFFICULTY_3, DIFFICULTY_4,
+    DIFFICULTY_5,
+    DIFFICULTY_4, DIFFICULTY_3, DIFFICULTY_2, DIFFICULTY_1,
+    DIFFICULTY_X,
+)
+
+
 class PointStore:
     def __init__(self, 
                  cache_targets: Dict[Tuple[GenMethodEnum, CountryCode], int],
@@ -125,7 +204,29 @@ class PointStore:
         most likely due to time constraints, this will raise an ExhaustedSourceError.
         """
         try:
-            point_tasks = [self.get_point(config.generation_method, config.country_lock) for _ in range(config.rounds)]
+            if config.generation_method == GenMethodEnum.diff_tiered:
+                # in the case of using the "difficulty tiered" generator there is some special logic
+                # assume that, in general, we want 10 points (4 normal rounds going up in difficulty, 1 max difficulty round, 4 normal going down, 1 nightmare tier)
+                # if more are requested, it repeats. if less, it only goes that far.
+
+                def make_point_task(tier, attempts):
+                    if attempts <= 0:
+                        raise ExhaustedSourceError
+                    
+                    try:
+                        country_lock = random.choice(tier)
+                        if country_lock in random_street_view.VALID_COUNTRIES:
+                            return self.get_point(GenMethodEnum.rsv, country_lock)
+                        elif country_lock in urban_centers.VALID_COUNTRIES:
+                            return self.get_point(GenMethodEnum.urban, country_lock) 
+                        else:
+                            raise ExhaustedSourceError
+                    except:
+                        return make_point_task(tier, attempts - 1)
+
+                point_tasks = [make_point_task(DIFFICULTY_TIER_ORDER[i % len(DIFFICULTY_TIER_ORDER)], 3) for i in range(config.rounds)]
+            else:
+                point_tasks = [self.get_point(config.generation_method, config.country_lock) for _ in range(config.rounds)]
             gathered = asyncio.gather(*point_tasks)
             return await asyncio.wait_for(gathered, 60)
             # TODO - it would be nice to keep partially generated sets around if there's a timeout or exhaustion

+ 3 - 0
server/app/schemas.py

@@ -11,11 +11,13 @@ CountryCode = constr(to_lower=True, min_length=2, max_length=2)
 class GenMethodEnum(str, Enum):
     rsv = "RANDOMSTREETVIEW"
     urban = "URBAN"
+    diff_tiered = "DIFFICULTY_TIERED"
 
 
 class GameModeEnum(str, Enum):
     normal = "NORMAL"
     frozen = "FROZEN"
+    gun_game = "GUN_GAME"
 
 
 class ClockModeEnum(str, Enum):
@@ -26,6 +28,7 @@ class ClockModeEnum(str, Enum):
 
 class ScoreMethodEnum(str, Enum):
     distance = "DISTANCE"
+    country_distance = "COUNTRYDIST"
     country_race = "COUNTRYRACE"
     hard = "HARD"
     nightmare = "NIGHTMARE"

+ 29 - 15
server/app/scoring.py

@@ -51,6 +51,23 @@ def score_within(raw_dist: float, min_dist: float, max_dist: float) -> int:
     return int(1000 * (1 - r))
 
 
+def ramp(dist_km: float) -> float:
+    if dist_km <= min_dist_km:
+        return 5000
+    elif dist_km <= avg_country_rad_km:
+        return 4000 + score_within(dist_km, min_dist_km, avg_country_rad_km)
+    elif dist_km <= one_thousand:
+        return 3000 + score_within(dist_km, avg_country_rad_km, one_thousand)
+    elif dist_km <= avg_continental_rad_km:
+        return 2000 + score_within(dist_km, one_thousand, avg_continental_rad_km)
+    elif dist_km <= quarter_of_max_km:
+        return 1000 + score_within(dist_km, avg_continental_rad_km, quarter_of_max_km)
+    elif dist_km <= max_dist_km:
+        return score_within(dist_km, quarter_of_max_km, max_dist_km)
+    else: # dist_km > max_dist_km
+        return 0
+
+
 def score(target: Tuple[float, float], guess: Tuple[float, float]) -> Tuple[int, float]:
     """
     Takes in two (latitude, longitude) pairs and produces an int score.
@@ -60,23 +77,20 @@ def score(target: Tuple[float, float], guess: Tuple[float, float]) -> Tuple[int,
     Returns (score, distance in km)
     """
     dist_km = haversine.haversine(target, guess)
+    return ramp(dist_km), dist_km
 
-    if dist_km <= min_dist_km:
-        point_score = 5000
-    elif dist_km <= avg_country_rad_km:
-        point_score = 4000 + score_within(dist_km, min_dist_km, avg_country_rad_km)
-    elif dist_km <= one_thousand:
-        point_score = 3000 + score_within(dist_km, avg_country_rad_km, one_thousand)
-    elif dist_km <= avg_continental_rad_km:
-        point_score = 2000 + score_within(dist_km, one_thousand, avg_continental_rad_km)
-    elif dist_km <= quarter_of_max_km:
-        point_score = 1000 + score_within(dist_km, avg_continental_rad_km, quarter_of_max_km)
-    elif dist_km <= max_dist_km:
-        point_score = score_within(dist_km, quarter_of_max_km, max_dist_km)
-    else: # dist_km > max_dist_km
-        point_score = 0
 
-    return point_score, dist_km
+def score_pow(target: Tuple[float, float], guess: Tuple[float, float]) -> Tuple[int, float]:
+    """
+    Takes in two (latitude, longitude) pairs and produces an int score.
+    Score is in the (inclusive) range [0, 5000]
+    Higher scores are closer.
+    Uses the same ramp as standard score, but raises the distance to a power first
+
+    Returns (score, distance in km)
+    """
+    dist_km = haversine.haversine(target, guess)
+    return ramp(dist_km ** 1.2), dist_km
 
 
 def score_country_race(target: str, guess: str, time_remaining: int, time_total: int):