浏览代码

FastAPI Server Reimplementation

Kirk Trombley 5 年之前
父节点
当前提交
ed3a0ffa55

+ 1 - 1
.gitignore

@@ -2,7 +2,7 @@
 
 .venv/
 *.pyc
-__pycache__/
+**/__pycache__/
 
 dist/
 node_modules/

+ 36 - 33
README.md

@@ -47,7 +47,7 @@ PUT /game
     Returns 200 and {
         "gameId": string
     }
-GET /game/{ID}/config
+GET /game/{game_id}/config
     Returns 404 vs 200 and {
         "timer": number,
         "rounds": number,
@@ -55,14 +55,21 @@ GET /game/{ID}/config
         "generationMethod": string,
         "ruleSet": string
     }
-GET /game/{ID}/coords
+GET /game/{game_id}/coords
     Returns 404 vs 200 and {
         "1": {
             "lat": number,
             "lng": number,
         }, ...
     }
-GET /game/{ID}/players
+POST /game/{game_id}/join
+    Accepts {
+        "playerName": string
+    }
+    Returns (404, 409) vs 201 and {
+        "playerId": string
+    }
+GET /game/{game_id}/players
     Returns 404 vs 200 and {
         "players": [
             {
@@ -80,49 +87,42 @@ GET /game/{ID}/players
             }, ...
         ]
     }
-GET /game/{ID}/linked
+GET /game/{game_id}/players/{player_id}/current
+    Returns 404 vs 200 and {
+        "currentRound": string || null,
+        "coord": {
+            "lat": number,
+            "lng": number,
+        } || null,
+        "timer": number || null
+    }
+GET /game/{game_id}/linked
     Returns 404 vs 200 and {
         "linkedGame": string || null
     }
-POST /game/{ID}/linked
+POST /game/{game_id}/linked
     Accepts {
         "linkedGame": string
     }
     Returns (401, 404) vs 201
-GET /game/{ID}/round/{round}/first
-    Returns 404 vs 200 and {
-        "first": string
-    }
-POST /game/{ID}/join
-    Accepts {
-        "playerName": string
-    }
-    Returns (401, 404, 409) vs 201 and {
-        "playerId": string
-    }
-GET /game/{ID}/current
-    Header Authorization: Player string
-    Returns (400, 404) vs 200 and {
-        "currentRound": string || null,
-        "coord": {
-            "lat": number,
-            "lng": number,
-        } || null,
-        "timer": number
+GET /game/{game_id}/round/{round}/first
+    Returns 200 and {
+        "first": string || null
     }
-POST /game/{ID}/guesses/{round}
-    Header Authorization: Player string
+POST /game/{game_id}/round/{round}/guess/{player_id}
     Accepts {
         "timeRemaining": number,
         "lat": number,
         "lng": number
-    } OR {
-        "timeout": boolean
     }
-    Returns (400, 401, 404, 409) vs 201 and {
-        "score": number,
+    Returns (404, 409) vs 201 and {
         "totalScore": number,
-        "distance": number || null
+        "score": number,
+        "distance": number
+    }
+POST /game/{game_id}/round/{round}/timeout/{player_id}
+    Returns (404, 409) vs 201 and {
+        "totalScore": number
     }
 ```
 
@@ -134,12 +134,15 @@ None currently! Submit ideas!
 
 ### Code/Design Improvements
 
+- Rotation in Frozen mode
+- Improved alert of cut off in Race mode
 - Convert back-end to use a real db (persistence between deployments not necessary for now)
+- Use alembic to manage db migrations in real persistent db
 - Improve error handling in UI
 - Improve back-end logging
 - Override google controls in streetview, make custom divs
 - Timestamps/hashes in info responses so checks can be faster
-- Convert to a socket-based api, or use a separate service, to allow timing to be server-side
+- Convert to a socket-based api, to allow timing to be server-side
 
 ## Attributions
 

+ 11 - 8
client/src/domain/apiMethods.js

@@ -77,7 +77,7 @@ export const getLinkedGame = async (gameId) => {
 
 export const linkGame = async (gameId, linkedGame) => {
     const res = await fetch(`${API_BASE}/game/${gameId}/linked`, {
-        method: "POST",
+        method: "PUT",
         headers: {
             "Content-Type": "application/json",
         },
@@ -112,11 +112,7 @@ export const joinGame = async (gameId, playerName) => {
 }
 
 export const getCurrentRound = async (gameId, playerId) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/current`, {
-        headers: {
-            "Authorization": `Player ${playerId}`
-        },
-    });
+    const res = await fetch(`${API_BASE}/game/${gameId}/players/${playerId}/current`);
     if (!res.ok) {
         throw Error(res.statusText);
     }
@@ -124,10 +120,9 @@ export const getCurrentRound = async (gameId, playerId) => {
 }
 
 export const sendGuess = async (gameId, playerId, round, point, timeRemaining) => {
-    const res = await fetch(`${API_BASE}/game/${gameId}/guesses/${round}`, {
+    const res = await fetch(`${API_BASE}/game/${gameId}/round/${round}/guess/${playerId}`, {
         method: "POST",
         headers: {
-            "Authorization": `Player ${playerId}`,
             "Content-Type": "application/json",
         },
         body: JSON.stringify({ timeRemaining, ...point }),
@@ -137,3 +132,11 @@ export const sendGuess = async (gameId, playerId, round, point, timeRemaining) =
     }
     return await res.json();
 }
+
+export const sendTimeout = async (gameId, playerId, round) => {
+    const res = await fetch(`${API_BASE}/game/${gameId}/round/${round}/timeout/${playerId}`, { method: "POST" });
+    if (!res.ok) {
+        throw Error(res.statusText);
+    }
+    return await res.json();
+}

+ 10 - 8
client/src/domain/gameStore.js

@@ -1,6 +1,6 @@
 import { PRE_GAME, PRE_ROUND, IN_ROUND, POST_ROUND, POST_GAME } from "./gameStates";
 import { createStore, consoleMonitor } from "../store";
-import { joinGame, sendGuess, getCurrentRound } from "./apiMethods";
+import { joinGame, sendGuess, getCurrentRound, sendTimeout } from "./apiMethods";
 import {
   saveGameInfoToLocalStorage,
   clearGameInfoFromLocalStorage,
@@ -95,13 +95,15 @@ const submitGuess = createAction(async ([set, get], selectedPoint) => {
   const roundNum = get.currentRound();
   const targetPoint = get.targetPoint();
   const roundSeconds = get.roundSeconds();
-  const { score, totalScore } = await sendGuess(
-    gameId,
-    playerId,
-    roundNum,
-    selectedPoint || { timeout: true },
-    roundSeconds
-  );
+  const { score, totalScore } = selectedPoint
+    ? await sendGuess(
+        gameId,
+        playerId,
+        roundNum,
+        selectedPoint || { timeout: true },
+        roundSeconds
+      )
+    : await sendTimeout(gameId, playerId, roundNum);
   set({
     lastRound: {
       roundNum,

+ 4 - 0
server/.dockerignore

@@ -0,0 +1,4 @@
+**/*.pyc
+**/__pycache__/
+.venv/
+Dockerfile

+ 5 - 7
server/Dockerfile

@@ -2,18 +2,16 @@ FROM python:3.8
 
 LABEL maintainer="Kirk Trombley <ktrom3894@gmail.com>"
 
-STOPSIGNAL SIGTERM
+WORKDIR /app/
 
-RUN pip3 install gunicorn
+ENV SQLALCHEMY_URL="sqlite:///./terrassumptions.db"
 
-WORKDIR /app
-
-COPY urban-centers-non-usa.csv urban-centers-usa.csv ./
+COPY ./data /app/data
 
 COPY requirements.txt ./
 
 RUN pip3 install -r requirements.txt
 
-COPY app.py db.py extra_api.py game_api.py sources.py lib.py gunicorn.conf.py ./
+COPY ./app /app/app
 
-CMD gunicorn app:app
+CMD uvicorn app:app --host 0.0.0.0 --port 5000

+ 0 - 39
server/app.py

@@ -1,39 +0,0 @@
-"""
-Main entry-point for the TerrAssumptions API server
-
-Running this file directly will launch a development server.
-The WSGI-compatible Flask server is exposed from this module as app.
-"""
-
-import os
-
-from flask import Flask, jsonify
-from flask_cors import CORS
-
-from db import db
-from game_api import game
-from extra_api import extra
-
-app = Flask(__name__)
-CORS(app)
-
-app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URI", "sqlite:////tmp/terrassumptions.db")
-app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
-
-app.register_blueprint(game, url_prefix="/game")
-app.register_blueprint(extra, url_prefix="/")
-
-db.init_app(app)
-db.create_all(app=app)
-
-
-@app.route("/")
-def version():
-    """
-    Return the version of the API and a status message, "healthy"
-    """
-    return jsonify({"version": "5", "status": "healthy"})
-
-
-if __name__ == "__main__":
-    app.run("0.0.0.0", 5000, debug=True)

+ 25 - 0
server/app/__init__.py

@@ -0,0 +1,25 @@
+import os
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from .api import other, game
+from .db import init_db
+
+app = FastAPI()
+
+app.include_router(other)
+app.include_router(game, prefix="/game")
+
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["http://localhost:3000", "https://kirkleon.ddns.net", "https://hiram.services"],
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+@app.on_event("startup")
+def startup():
+    init_db(os.environ.get("SQLALCHEMY_URL", "sqlite:////tmp/terrassumptions.db"), connect_args={"check_same_thread": False})

+ 2 - 0
server/app/api/__init__.py

@@ -0,0 +1,2 @@
+from .other import router as other
+from .game import router as game

+ 148 - 0
server/app/api/game.py

@@ -0,0 +1,148 @@
+from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
+from fastapi_camelcase import CamelModel
+from pydantic import conint, constr
+
+from sqlalchemy.orm import Session
+
+from .. import scoring
+from ..schemas import GameConfig, Guess
+from ..db import get_db, queries, models
+from ..gen import generate_points, restock_source
+
+router = APIRouter()
+
+
+class LinkedGame(CamelModel):
+    linked_game: str
+
+
+class JoinGame(CamelModel):
+    player_name: constr(min_length=1)
+
+
+class GuessResult(CamelModel):
+    distance: int = None
+    score: int = 0
+    total_score: int
+
+
+@router.put("/")
+def create_game(config: GameConfig, bg: BackgroundTasks, db: Session = Depends(get_db)):
+    coords = generate_points(config)
+    game_id = queries.create_game(db, config, coords)
+    bg.add_task(restock_source, config)
+    return { "gameId": game_id }
+
+
+def get_game(game_id: str, db: Session = Depends(get_db)) -> models.Game:
+    game = queries.get_game(db, game_id)
+    if game is None:
+        raise HTTPException(status_code=404, detail="Game not found")
+    return game
+
+
+@router.get("/{game_id}/config", response_model=GameConfig)
+def get_game_config(game: models.Game = Depends(get_game)):
+    return game
+
+
+@router.get("/{game_id}/coords")
+def get_game_coords(game: models.Game = Depends(get_game)):
+    return {
+        str(coord.round_number): {
+            "lat": coord.latitude,
+            "lng": coord.longitude,
+        }
+        for coord in game.coordinates
+    }
+
+
+@router.post("/{game_id}/join")
+def join_game(game_id: str, join: JoinGame, db: Session = Depends(get_db)):
+    get_game(game_id, db) # confirm this game exists
+    player_id = queries.join_game(db, game_id, join.player_name)
+    if player_id is None:
+        raise HTTPException(status_code=409, detail="Player name in use")
+    return { "playerId": player_id }
+
+
+@router.get("/{game_id}/players")
+def get_players(game: models.Game = Depends(get_game)):
+    return { "players": [
+        {
+            "name": p.player_name,
+            "currentRound": queries.get_next_round_number(p),
+            "totalScore": queries.get_total_score(p),
+            "guesses": {
+                str(g.round_number): {
+                    "lat": g.latitude,
+                    "lng": g.longitude,
+                    "score": g.round_score,
+                    "timeRemaining": g.time_remaining,
+                } for g in p.guesses
+            }
+        } for p in game.players
+    ] }
+
+
+def get_player(game_id: str, player_id: str, db: Session = Depends(get_db)) -> models.Player:
+    player = queries.get_player(db, player_id)
+    if player is None:
+        raise HTTPException(status_code=404, detail="Player not found")
+    if player.game_id != game_id:
+        raise HTTPException(status_code=404, detail="Player not in game")
+    return player
+
+
+@router.get("/{game_id}/players/{player_id}/current")
+def get_current_round(db: Session = Depends(get_db), player: models.Player = Depends(get_player)):
+    coord = queries.get_next_coordinate(db, player)
+    if coord is None:
+        return {
+            "currentRound": None,
+            "coord": None,
+            "timer": None,
+        }
+    return {
+        "currentRound": coord.round_number,
+        "coord": {
+            "lat": coord.latitude,
+            "lng": coord.longitude,
+        },
+        "timer": queries.get_next_round_time(player),
+    }
+
+
+@router.put("/{game_id}/linked", status_code=204)
+def set_linked_game(game_id: str, linked_game: LinkedGame, db: Session = Depends(get_db)):
+    queries.link_game(db, game_id, linked_game.linked_game)
+
+
+@router.get("/{game_id}/linked")
+def get_linked_game(game: models.Game = Depends(get_game)):
+    return { "linkedGame": game.linked_game }
+
+
+@router.get("/{game_id}/round/{round_number}/first")
+def get_first_submitter(game_id: str, round_number: conint(gt=0), db: Session = Depends(get_db)):
+    return { "first": queries.get_first_submitter(db, game_id, round_number) }
+
+
+@router.post("/{game_id}/round/{round_number}/guess/{player_id}", response_model=GuessResult)
+def submit_guess(round_number: conint(gt=0), guess: Guess, db: Session = Depends(get_db), player: models.Player = Depends(get_player)):
+    target = queries.get_coordinate(db, player.game_id, round_number)
+    score, distance = scoring.score((target.latitude, target.longitude), (guess.lat, guess.lng))
+    added = queries.add_guess(db, guess, player, round_number, score)
+    if not added:
+        raise HTTPException(status_code=409, detail="Already submitted guess for this round")
+    total_score = queries.get_total_score(player)
+    return GuessResult(distance=distance, score=score, total_score=total_score)
+
+
+@router.post("/{game_id}/round/{round_number}/timeout/{player_id}", response_model=GuessResult, response_model_include={"total_score"})
+def submit_timeout(round_number: conint(gt=0), db: Session = Depends(get_db), player: models.Player = Depends(get_player)):
+    added = queries.add_timeout(db, player, round_number)
+    if not added:
+        raise HTTPException(status_code=409, detail="Already submitted guess for this round")
+    total_score = queries.get_total_score(player)
+    return GuessResult(total_score=total_score)

+ 46 - 0
server/app/api/other.py

@@ -0,0 +1,46 @@
+from typing import List
+
+from fastapi import APIRouter
+from fastapi_camelcase import CamelModel
+from pydantic import confloat
+
+from .. import scoring
+from ..schemas import CacheInfo
+from ..gen import get_cache_info
+
+router = APIRouter()
+
+
+class Point(CamelModel):
+    lat: confloat(ge=-90.0, le=90.0)
+    lng: confloat(ge=-180.0, le=180.0)
+
+
+class ScoreCheck(CamelModel):
+    point1: Point
+    point2: Point
+
+
+class Score(CamelModel):
+    distance: float
+    score: int
+
+
+class CacheResponse(CamelModel):
+    caches: List[CacheInfo]
+
+
+@router.get("/")
+def health():
+    return { "status": "healthy", "version": "2.0" }
+
+
+@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))
+    return Score(distance=distance, score=score)
+
+
+@router.get("/caches", response_model=CacheResponse)
+def caches():
+    return CacheResponse(caches=get_cache_info())

+ 20 - 0
server/app/db/__init__.py

@@ -0,0 +1,20 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from . import queries, models
+
+SessionLocal = sessionmaker(autocommit=False, autoflush=False)
+
+def init_db(url: str, create_tables: bool = True, **engine_kwargs):
+    engine = create_engine(url, **engine_kwargs)
+    if create_tables:
+        models.Base.metadata.create_all(bind=engine)
+    SessionLocal.configure(bind=engine)
+
+
+def get_db():
+    db = SessionLocal()
+    try:
+        yield db
+    finally:
+        db.close()

+ 47 - 0
server/app/db/models.py

@@ -0,0 +1,47 @@
+from datetime import datetime
+
+from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey
+from sqlalchemy.orm import relationship
+from sqlalchemy.ext.declarative import declarative_base
+
+Base = declarative_base()
+
+
+class Game(Base):
+    __tablename__ = 'game'
+    game_id = Column(String, primary_key=True)
+    linked_game = Column(String)
+    timer = Column(Integer)
+    rounds = Column(Integer)
+    only_america = Column(Boolean)
+    generation_method = Column(String)
+    rule_set = Column(String)
+    coordinates = relationship("Coordinate", lazy=True, order_by="Coordinate.round_number")
+    players = relationship("Player", lazy=True, backref="game")
+
+
+class Player(Base):
+    __tablename__ = "player"
+    player_id = Column(Integer, primary_key=True, autoincrement=True)
+    game_id = Column(String, ForeignKey("game.game_id"))
+    player_name = Column(String)
+    guesses = relationship("Guess", lazy=True, order_by="Guess.round_number")
+
+
+class Coordinate(Base):
+    __tablename__ = "coordinate"
+    game_id = Column(String, ForeignKey("game.game_id"), primary_key=True)
+    round_number = Column(Integer, primary_key=True, autoincrement=False)
+    latitude = Column(Float)
+    longitude = Column(Float)
+
+
+class Guess(Base):
+    __tablename__ = "guess"
+    player_id = Column(Integer, ForeignKey("player.player_id"), primary_key=True, autoincrement=False)
+    round_number = Column(Integer, primary_key=True, autoincrement=False)
+    latitude = Column(Float)
+    longitude = Column(Float)
+    round_score = Column(Integer)
+    time_remaining = Column(Float)
+    created_at = Column(DateTime, default=datetime.utcnow)

+ 126 - 0
server/app/db/queries.py

@@ -0,0 +1,126 @@
+import uuid
+from typing import List, Tuple
+
+from sqlalchemy.orm import Session
+
+from .models import Game, Coordinate, Player, Guess
+from .. import schemas
+
+
+def create_game(db: Session, conf: schemas.GameConfig, coords: List[Tuple[float, float]]) -> str:
+    if len(coords) != conf.rounds:
+        raise ValueError("Insufficient number of coordinates")
+    
+    game_id = str(uuid.uuid4())
+    while db.query(Game).get(game_id) is not None:
+        # basically impossible collision, but let's be safe
+        game_id = str(uuid.uuid4())
+    
+    new_game = Game(
+        game_id=game_id,
+        timer=conf.timer,
+        rounds=conf.rounds,
+        only_america=conf.only_america,
+        generation_method=conf.generation_method,
+        rule_set=conf.rule_set,
+    )
+    db.add(new_game)
+    db.add_all([Coordinate(
+        game_id=game_id,
+        round_number=round_num + 1,
+        latitude=lat,
+        longitude=lng,
+    ) for (round_num, (lat, lng)) in enumerate(coords)])
+    db.commit()
+
+    return game_id
+
+
+def get_game(db: Session, game_id: str) -> Game:
+    return db.query(Game).get(game_id)
+
+
+def join_game(db: Session, game_id: str, player_name: str) -> str:
+    existing = db.query(Player).filter(Player.game_id == game_id, Player.player_name == player_name).first()
+    if existing is not None:
+        return None
+    new_player = Player(game_id=game_id, player_name=player_name)
+    db.add(new_player)
+    db.commit()
+    return str(new_player.player_id)
+
+
+def link_game(db: Session, game_id: str, linked_game: str):
+    db.query(Game).get(game_id).linked_game = linked_game
+    db.commit()
+
+
+def get_player(db: Session, player_id: str) -> Player:
+    return db.query(Player).get(int(player_id))
+
+
+def get_total_score(player: Player) -> int:
+    return sum(g.round_score or 0 for g in player.guesses)
+
+
+def get_next_round_number(player: Player) -> int:
+    if len(player.guesses) == 0:
+        return 1
+    next_round = player.guesses[-1].round_number + 1
+    if next_round <= player.game.rounds:
+        return next_round
+    return None
+
+
+def get_coordinate(db: Session, game_id: str, round_number: int) -> Coordinate:
+    return db.query(Coordinate).get((game_id, round_number))
+
+
+def get_next_coordinate(db: Session, player: Player) -> Coordinate:
+    round_number = get_next_round_number(player)
+    if round_number is None:
+        return None
+    return get_coordinate(db, player.game_id, round_number)
+
+
+def get_next_round_time(player: Player) -> int:
+    if player.game.rule_set == schemas.RuleSetEnum.time_bank:
+        if len(player.guesses) == 0:
+            return player.game.timer * player.game.rounds
+        return player.guesses[-1].time_remaining
+    return player.game.timer
+
+
+def add_guess(db: Session, guess: schemas.Guess, player: Player, round_number: int, score: int) -> bool:
+    existing = db.query(Guess).filter(Guess.player_id == player.player_id, Guess.round_number == round_number).first()
+    if existing is not None:
+        return False
+    g = Guess(
+        player_id=player.player_id,
+        round_number=round_number,
+        latitude=guess.lat,
+        longitude=guess.lng,
+        round_score=score,
+        time_remaining=guess.time_remaining,
+    )
+    db.add(g)
+    db.commit()
+    if guess.time_remaining <= 0 and player.game.rule_set == schemas.RuleSetEnum.time_bank:
+        for r in range(round_number, player.game.rounds):
+            add_timeout(db, player, r + 1)
+    return True
+
+
+def add_timeout(db: Session, player: Player, round_number: int) -> bool:
+    return add_guess(db, schemas.Guess(lat=0, lng=0, time_remaining=0), player, round_number, None)
+
+
+def get_first_submitter(db: Session, game_id: str, round_number: int) -> str:
+    first = db.query(Player.player_name, Guess).filter(
+            Player.game_id == game_id,
+            Guess.round_number == round_number,
+            Player.player_id == Guess.player_id,
+        ).order_by(Guess.created_at.desc()).first()
+    if first is None:
+        return None
+    return first[0]

+ 56 - 0
server/app/gen/__init__.py

@@ -0,0 +1,56 @@
+from typing import Dict, Tuple, List
+
+from .shared import PointSource
+from .map_crunch import MapCrunchPointSource
+from .random_street_view import RSVPointSource
+from .urban_centers import UrbanPointSource
+
+from ..schemas import GenMethodEnum, GameConfig, CacheInfo
+
+stock_target = 20
+
+"""
+Dictionary of PointSources
+Maps (generation_method, only_america) -> PointSource
+"""
+sources: Dict[Tuple[GenMethodEnum, bool], PointSource] = {
+    (GenMethodEnum.map_crunch, False): MapCrunchPointSource(stock_target=stock_target, max_retries=1000, only_america=False),
+    (GenMethodEnum.map_crunch, True): MapCrunchPointSource(stock_target=stock_target, max_retries=1000, only_america=True),
+    (GenMethodEnum.rsv, False): RSVPointSource(stock_target=stock_target, only_america=False),
+    (GenMethodEnum.rsv, True): RSVPointSource(stock_target=stock_target, only_america=True),
+    (GenMethodEnum.urban, False): UrbanPointSource(
+        stock_target=stock_target,
+        max_retries=100,
+        retries_per_point=30,
+        max_dist_km=25,
+        usa_chance=0.1
+    ),
+    (GenMethodEnum.urban, True): UrbanPointSource(
+        stock_target=stock_target,
+        max_retries=100,
+        retries_per_point=30,
+        max_dist_km=25,
+        usa_chance=1.0
+    )
+}
+
+
+def generate_points(config: GameConfig) -> List[Tuple[float, float]]:
+    """
+    Generate points according to the GameConfig.
+    """
+    return sources[(config.generation_method, config.only_america)].get_points(config.rounds)
+
+
+def restock_source(config: GameConfig):
+    """
+    Restock the PointSource associated with the GameConfig
+    """
+    sources[(config.generation_method, config.only_america)].restock()
+
+
+def get_cache_info() -> List[CacheInfo]:
+    """
+    Get CacheInfo for each of the PointSources
+    """
+    return [CacheInfo(generation_method=gm, only_america=oa, size=len(src.stock)) for ((gm, oa), src) in sources.items()]

+ 46 - 0
server/app/gen/map_crunch.py

@@ -0,0 +1,46 @@
+import json
+
+import requests
+
+from .shared import point_has_streetview, PointSource
+
+mapcrunch_url = "http://www.mapcrunch.com/_r/"
+
+
+def generate_coord(max_retries=100, only_america=False):
+    """
+    Returns (latitude, longitude) of usable coord (where google has data).
+    This function will attempt at most max_retries calls to map crunch to fetch
+    candidate points, and will exit as soon as a suitable candidate is found.
+    If no suitable candidate is found in this allotted number of retries, None is
+    returned.
+
+    This function calls the streetview metadata endpoint - there is no quota consumed.
+    """
+    mc_url = mapcrunch_url + ("?c=21" if only_america else "")
+    for _ in range(max_retries):
+        points_res = requests.get(mc_url).text
+        points_js = json.loads(points_res.strip("while(1); "))
+        if "c=" not in mc_url:
+            mc_url += f"?c={points_js['country']}" # lock to the first country randomed
+        for lat, lng in points_js["points"]:
+            if point_has_streetview(lat, lng):
+                return (lat, lng)
+
+
+class MapCrunchPointSource(PointSource):
+    def __init__(self, stock_target=20, max_retries=100, only_america=False):
+        super().__init__(stock_target=stock_target)
+        self.max_retries = max_retries
+        self.only_america = only_america
+
+    def _restock_impl(self, n):
+        points = []
+        while len(points) < n:
+            pt = generate_coord(
+                max_retries=self.max_retries,
+                only_america=self.only_america
+            )
+            if pt is not None:
+                points.append(pt)
+        return points

+ 36 - 0
server/app/gen/random_street_view.py

@@ -0,0 +1,36 @@
+import requests
+
+from .shared import point_has_streetview, PointSource
+
+rsv_url = "https://randomstreetview.com/data"
+
+
+def call_random_street_view(only_america=False):
+    """
+    Returns an array of (some number of) tuples, each being (latitude, longitude).
+    All points will be valid streetview coordinates. There is no guarantee as to the
+    length of this array (it may be empty), but it will never be None.
+
+    This function calls the streetview metadata endpoint - there is no quota consumed.
+    """
+    rsv_js = requests.post(rsv_url, data={"country": "us" if only_america else "all"}).json()
+    if not rsv_js["success"]:
+        return []
+    
+    return [
+        (point["lat"], point["lng"])
+        for point in rsv_js["locations"]
+        if point_has_streetview(point["lat"], point["lng"])
+    ]
+
+
+class RSVPointSource(PointSource):
+    def __init__(self, stock_target=20, only_america=False):
+        super().__init__(stock_target=stock_target)
+        self.only_america = only_america
+    
+    def _restock_impl(self, n):
+        points = []
+        while len(points) < n:
+            points.extend(call_random_street_view(only_america=self.only_america))
+        return points

+ 75 - 0
server/app/gen/shared.py

@@ -0,0 +1,75 @@
+import collections
+import logging
+
+import requests
+
+# Google API key, with access to Street View Static API
+google_api_key = "AIzaSyAqjCYR6Szph0X0H_iD6O1HenFhL9jySOo"
+metadata_url = "https://maps.googleapis.com/maps/api/streetview/metadata"
+
+logger = logging.getLogger(__name__)
+
+
+def point_has_streetview(lat, lng):
+    """
+    Returns True if the streetview metadata endpoint says a given point has
+    data available, and False otherwise.
+
+    This function calls the streetview metadata endpoint - there is no quota consumed.
+    """
+    return requests.get(metadata_url, params={
+        "key": google_api_key,
+        "location": f"{lat},{lng}",
+    }).json()["status"] == "OK"
+
+
+class PointSource:
+    """
+    Base class to handle the logic of managing a cache of (lat, lng) points.
+    """
+    def __init__(self, stock_target):
+        self.stock = collections.deque()
+        self.stock_target = stock_target
+
+    def _restock_impl(self, n):
+        """
+        Returns a list of new points to add to the stock.
+        Implementations of this method should try to return at least n points for performance.
+        """
+        raise NotImplementedError("Subclasses must implement this")
+    
+    def restock(self, n=None):
+        """
+        Restock at least n points into this source.
+        If n is not provided, it will default to stock_target, as set during the
+        construction of this point source.
+        """
+        n = n if n is not None else self.stock_target - len(self.stock)
+        if n > 0:
+            logger.info(f"Restocking {type(self).__name__} with {n} points")
+            pts = self._restock_impl(n)
+            self.stock.extend(pts)
+            diff = n - len(pts)
+            if diff > 0:
+                # if implementations of _restock_impl are well behaved, this will
+                # never actually need to recurse to finish the job.
+                self.restock(n=diff)
+            logger.info(f"Finished restocking {type(self).__name__}")
+
+    def get_points(self, n=1):
+        """
+        Pull n points from the current stock.
+        It is recommended to call PointSource.restock after this, to ensure the
+        stock is not depleted. If possible, calling restock in another thread is
+        recommended, as it can be a long operation depending on implementation.
+        """
+        if len(self.stock) >= n:
+            pts = []
+            for _ in range(n):
+                pts.append(self.stock.popleft())
+            return pts
+        self.restock(n=n)
+        # this is safe as long as restock does actually add enough new points.
+        # unless this object is being rapidly drained by another thread,
+        # this will recur at most once.
+        return self.get_points(n=n)

+ 87 - 0
server/app/gen/urban_centers.py

@@ -0,0 +1,87 @@
+import math
+import random
+
+from .shared import point_has_streetview, PointSource
+from ..scoring import mean_earth_radius_km
+
+initialized = False
+urban_centers_usa = []
+urban_centers_non_usa = []
+
+
+def init():
+    """
+    Read in the urban centers data files. Should be called before trying to generate points.
+    """
+    global initialized
+    if initialized:
+        return
+    with open("./data/urban-centers-usa.csv") as infile:
+        for line in infile:
+            lat, lng = line.split(",")
+            urban_centers_usa.append((float(lat.strip()), float(lng.strip())))
+    with open("./data/urban-centers-non-usa.csv") as infile:
+        for line in infile:
+            lat, lng = line.split(",")
+            urban_centers_non_usa.append((float(lat.strip()), float(lng.strip())))
+    initialized = True
+
+
+def urban_coord(max_retries=10, retries_per_point=30, max_dist_km=25, usa_chance=0.1):
+    """
+    Returns (latitude, longitude) of usable coord (where google has data) that is near
+    a known urban center. Points will be at most max_dist_km kilometers away. This function will
+    generate at most retries_per_point points around an urban center, and will try at most
+    max_retries urban centers. If none of the generated points have street view data, 
+    this will return None. Otherwise, it will exit as soon as suitable point is found.
+
+    This function calls the streetview metadata endpoint - there is no quota consumed.
+    """
+
+    src = urban_centers_usa if random.random() <= usa_chance else urban_centers_non_usa
+
+    for _ in range(max_retries):
+        # logic adapted from https://stackoverflow.com/a/7835325
+        # start in a city
+        (city_lat, city_lng) = random.choice(src)
+        city_lat_rad = math.radians(city_lat)
+        sin_lat = math.sin(city_lat_rad)
+        cos_lat = math.cos(city_lat_rad)
+        city_lng_rad = math.radians(city_lng)
+        for _ in range(retries_per_point):
+            # turn a random direction, and go random distance
+            dist_km = random.random() * max_dist_km
+            angle_rad = random.random() * 2 * math.pi
+            d_over_radius = dist_km / mean_earth_radius_km
+            sin_dor = math.sin(d_over_radius)
+            cos_dor = math.cos(d_over_radius)
+            pt_lat_rad = math.asin(sin_lat * cos_dor + cos_lat * sin_dor * math.cos(angle_rad))
+            pt_lng_rad = city_lng_rad + math.atan2(math.sin(angle_rad) * sin_dor * cos_lat, cos_dor - sin_lat * math.sin(pt_lat_rad))
+            pt_lat = math.degrees(pt_lat_rad)
+            pt_lng = math.degrees(pt_lng_rad)
+            if point_has_streetview(pt_lat, pt_lng):
+                return (pt_lat, pt_lng)
+
+
+class UrbanPointSource(PointSource):
+    def __init__(self, stock_target=20, max_retries=10, retries_per_point=30, max_dist_km=25, usa_chance=0.1):
+        super().__init__(stock_target=stock_target)
+        self.max_retries = max_retries
+        self.retries_per_point = retries_per_point
+        self.max_dist_km = max_dist_km
+        self.usa_chance = usa_chance
+        if not initialized:
+            init()
+    
+    def _restock_impl(self, n):
+        points = []
+        while len(points) < n:
+            pt = urban_coord(
+                max_retries=self.max_retries,
+                retries_per_point=self.retries_per_point,
+                max_dist_km=self.max_dist_km,
+                usa_chance=self.usa_chance
+            )
+            if pt is not None:
+                points.append(pt)
+        return points

+ 40 - 0
server/app/schemas.py

@@ -0,0 +1,40 @@
+from enum import Enum
+
+from fastapi_camelcase import CamelModel
+from pydantic import conint, confloat
+
+
+class GenMethodEnum(str, Enum):
+    map_crunch = "MAPCRUNCH"
+    rsv = "RANDOMSTREETVIEW"
+    urban = "URBAN"
+
+
+class RuleSetEnum(str, Enum):
+    normal = "NORMAL"
+    time_bank = "TIMEBANK"
+    frozen = "FROZEN"
+    race = "RACE"
+
+
+class GameConfig(CamelModel):
+    timer: conint(gt=0)
+    rounds: conint(gt=0)
+    only_america: bool = False
+    generation_method: GenMethodEnum = GenMethodEnum.map_crunch
+    rule_set: RuleSetEnum = RuleSetEnum.normal
+
+    class Config:
+        orm_mode = True
+
+
+class Guess(CamelModel):
+    lat: confloat(ge=-90.0, le=90.0)
+    lng: confloat(ge=-180.0, le=180.0)
+    time_remaining: int
+
+
+class CacheInfo(CamelModel):
+    generation_method: str
+    only_america: bool
+    size: int

+ 68 - 0
server/app/scoring.py

@@ -0,0 +1,68 @@
+import math
+from typing import Tuple
+
+import haversine
+
+mean_earth_radius_km = (6378 + 6357) / 2
+
+# if you're more than 1/4 of the Earth's circumfrence away, you get 0
+max_dist_km = (math.pi * mean_earth_radius_km) / 2 # this is about 10,000 km
+
+# if you're within 1/16 of the Earth's circumfrence away, you get at least 1000 points
+quarter_of_max_km = max_dist_km / 4 # this is about 2,500 km
+
+# https://www.wolframalpha.com/input/?i=sqrt%28%28%28land+mass+of+earth%29+%2F+7%29%29+%2F+pi%29+in+kilometers
+# this is the average "radius" of a continent
+# within this radius, you get at least 2000 points
+avg_continental_rad_km = 1468.0
+
+# somewhat arbitrarily, if you're within 1000 km, you get at least 3000 points
+one_thousand = 1000.0
+
+# https://www.wolframalpha.com/input/?i=sqrt%28%28%28land+mass+of+earth%29+%2F+%28number+of+countries+on+earth%29%29+%2F+pi%29+in+kilometers
+# this is the average "radius" of a country
+# within this radius, you get at least 4000 points
+avg_country_rad_km = 479.7
+
+# if you're within 150m, you get a perfect score of 5000
+min_dist_km = 0.15  
+
+
+def score_within(raw_dist: float, min_dist: float, max_dist: float) -> int:
+    """
+    Gives a score between 0 and 1000, with 1000 for the min_dist and 0 for the max_dist
+    """
+    # scale the distance down to [0.0, 1.0], then multiply it by 2 for easing
+    pd2 = 2 * (raw_dist - min_dist) / (max_dist - min_dist)
+    # perform a quadratic ease-in-out on pd2
+    r = (pd2 ** 2) / 2 if pd2 < 1 else 1 - (((2 - pd2) ** 2) / 2)
+    # use this to ease between 1000 and 0
+    return int(1000 * (1 - r))
+
+
+def score(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.
+
+    Returns (score, distance in km)
+    """
+    dist_km = haversine.haversine(target, guess)
+
+    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

+ 0 - 0
server/urban-centers-non-usa.csv → server/data/urban-centers-non-usa.csv


+ 0 - 0
server/urban-centers-usa.csv → server/data/urban-centers-usa.csv


+ 0 - 135
server/db.py

@@ -1,135 +0,0 @@
-import uuid
-
-from flask_sqlalchemy import SQLAlchemy
-
-db = SQLAlchemy()
-session = db.session
-
-
-class Game(db.Model):
-    game_id = db.Column(db.String, primary_key=True)
-    timer = db.Column(db.Integer)
-    rounds = db.Column(db.Integer)
-    linked_game = db.Column(db.String)
-    only_america = db.Column(db.Boolean)
-    gen_method = db.Column(db.String)
-    rule_set = db.Column(db.String)
-    coordinates = db.relationship("Coordinate", lazy=True, order_by="Coordinate.round_number")
-    players = db.relationship("Player", lazy=True, backref="game")
-
-    @staticmethod
-    def create(timer, rounds, only_america, gen_method, rule_set, coords):
-        game_id = str(uuid.uuid4())
-        while Game.query.get(game_id) is not None:
-            # basically impossible collision, but let's be safe
-            game_id = str(uuid.uuid4())
-
-        new_game = Game(
-            game_id=game_id,
-            timer=timer,
-            rounds=rounds,
-            only_america=only_america,
-            gen_method=gen_method,
-            rule_set=rule_set,
-        )
-        db.session.add(new_game)
-
-        for (round_num, (lat, lng)) in enumerate(coords):
-            coord = Coordinate(
-                game_id=game_id,
-                round_number=round_num+1,
-                latitude=lat,
-                longitude=lng
-            )
-            db.session.add(coord)
-
-        db.session.commit()
-
-        return new_game
-
-    def join(self, player_name):
-        p = Player(
-            game_id=self.game_id,
-            player_name=player_name
-        )
-        db.session.add(p)
-        db.session.commit()
-
-        return p
-
-    def link(self, linked_game):
-        self.linked_game = linked_game
-        db.session.commit()
-
-
-class Player(db.Model):
-    player_id = db.Column(db.Integer, primary_key=True, autoincrement=True)
-    game_id = db.Column(db.String, db.ForeignKey("game.game_id"))
-    player_name = db.Column(db.String)
-    guesses = db.relationship("Guess", lazy=True, order_by="Guess.round_number")
-
-    def get_total_score(self):
-        return sum(g.round_score or 0 for g in self.guesses)
-
-    def get_current_round(self):
-        if len(self.guesses) == 0:
-            return 1
-        next_round = self.guesses[-1].round_number + 1
-        if next_round <= self.game.rounds:
-            return next_round
-        return None
-
-    def add_guess(self, round_num, lat, lng, score, remaining):
-        g = Guess(
-            player_id=self.player_id,
-            round_number=round_num,
-            latitude=lat,
-            longitude=lng,
-            round_score=score,
-            time_remaining=remaining,
-        )
-        db.session.add(g)
-        first = FirstSubmission.query.get((self.game_id, round_num))
-        if first is None:
-            fs = FirstSubmission(
-                game_id=self.game_id,
-                round_number=round_num,
-                player_name=self.player_name,
-            )
-            db.session.add(fs)
-        db.session.commit()
-
-    def add_timeout(self, round_num):
-        self.add_guess(round_num, -200, -200, None, 0)
-
-    def get_last_round_time_remaining(self):
-        if len(self.guesses) == 0:
-            return None
-        return self.guesses[-1].time_remaining
-
-    def timeout_remaining_rounds(self):
-        round_num = self.get_current_round()
-        if round_num is not None:
-            for r in range(round_num, self.game.rounds + 1):
-                self.add_timeout(r)
-
-
-class Coordinate(db.Model):
-    game_id = db.Column(db.String, db.ForeignKey("game.game_id"), primary_key=True)
-    round_number = db.Column(db.Integer, primary_key=True, autoincrement=False)
-    latitude = db.Column(db.Float)
-    longitude = db.Column(db.Float)
-
-
-class Guess(db.Model):
-    player_id = db.Column(db.String, db.ForeignKey("player.player_id"), primary_key=True)
-    round_number = db.Column(db.Integer, primary_key=True, autoincrement=False)
-    latitude = db.Column(db.Float)
-    longitude = db.Column(db.Float)
-    round_score = db.Column(db.Integer)
-    time_remaining = db.Column(db.Float)
-
-class FirstSubmission(db.Model):
-    game_id = db.Column(db.String, primary_key=True)
-    round_number = db.Column(db.Integer, primary_key=True, autoincrement=False)
-    player_name = db.Column(db.String)

+ 0 - 42
server/extra_api.py

@@ -1,42 +0,0 @@
-from flask import Blueprint, abort, request, jsonify
-
-import lib
-from sources import sources
-
-extra = Blueprint("extra", __name__)
-
-
-@extra.route("score", methods=["POST"])
-def check_score():
-    js = request.get_json()
-    p1 = js.get("point1", None)
-    p2 = js.get("point2", None)
-    if p1 is None or p2 is None:
-        abort(400)
-
-    try:
-        lat1 = float(p1.get("lat", None))
-        lng1 = float(p1.get("lng", None))
-        lat2 = float(p2.get("lat", None))
-        lng2 = float(p2.get("lng", None))
-    except ValueError:
-        abort(400)
-
-    score, distance = lib.score((lat1, lng1), (lat2, lng2))
-    return jsonify({
-        "score": score,
-        "distance": distance,
-    })
-
-
-@extra.route("caches", methods=["GET"])
-def get_cached_points():
-    return jsonify({
-        "caches": [
-            {
-                "generationMethod": gm,
-                "onlyAmerica": oa,
-                "size": len(ps.stock)
-            } for ((gm, oa), ps) in sources.items()
-        ]
-    })

+ 0 - 235
server/game_api.py

@@ -1,235 +0,0 @@
-from flask import Blueprint, abort, request, jsonify
-
-import db
-import lib
-from sources import sources, restock_all
-
-restock_all()
-
-game = Blueprint("game", __name__)
-
-
-def require_game(game_id):
-    g = db.Game.query.get(game_id)
-    if g is None:
-        abort(404)
-    return g
-
-
-def require_player(game_id):
-    auth = request.headers.get("Authorization", type=str)
-    if auth is None:
-        abort(401)
-    try:
-        pid = int(auth.split(maxsplit=1)[-1])
-    except ValueError:
-        abort(401)
-    player = db.Player.query.get(pid)
-    if player is None:
-        abort(404)
-    return player
-
-
-@game.route("", methods=["PUT"])
-def create_game():
-    js = request.get_json()
-    if js is None:
-        abort(400)
-
-    timer = js.get("timer", None)
-    if not isinstance(timer, int) or timer <= 0:
-        abort(400)
-
-    rounds = js.get("rounds", None)
-    if not isinstance(rounds, int) or rounds <= 0:
-        abort(400)
-
-    only_america = js.get("onlyAmerica", False)
-    if not isinstance(only_america, bool):
-        abort(400)
-
-    gen_method = js.get("generationMethod", "MAPCRUNCH")
-    if not isinstance(gen_method, str):
-        abort(400)
-
-    rule_set = js.get("ruleSet", "NORMAL")
-    if not isinstance(rule_set, str):
-        abort(400)
-
-    src = sources.get((gen_method, only_america), None)
-    if src is None:
-        abort(400)
-    
-    coords = src.get_points(n=rounds)
-    new_game = db.Game.create(timer, rounds, only_america, gen_method, rule_set, coords)
-
-    return jsonify({"gameId": new_game.game_id})
-
-
-@game.route("/<game_id>/config")
-def game_config(game_id):
-    g = require_game(game_id)
-    return jsonify({
-        "timer": g.timer,
-        "rounds": g.rounds,
-        "onlyAmerica": g.only_america,
-        "generationMethod": g.gen_method,
-        "ruleSet": g.rule_set,
-    })
-
-
-@game.route("/<game_id>/coords")
-def coords(game_id):
-    g = require_game(game_id)
-    return jsonify({
-        str(c.round_number): {
-            "lat": c.latitude,
-            "lng": c.longitude,
-        } for c in g.coordinates
-    })
-
-
-@game.route("/<game_id>/players")
-def players(game_id):
-    g = require_game(game_id)
-    return jsonify({
-        "players": [{
-            "name": p.player_name,
-            "currentRound": p.get_current_round(),
-            "totalScore": p.get_total_score(),
-            "guesses": {
-                str(g.round_number): {
-                    "lat": g.latitude,
-                    "lng": g.longitude,
-                    "score": g.round_score,
-                    "timeRemaining": g.time_remaining,
-                } for g in p.guesses
-            },
-        } for p in g.players]
-    })
-
-
-@game.route("/<game_id>/linked", methods=["GET", "POST"])
-def link_game(game_id):
-    g = require_game(game_id)
-    if request.method == "GET":
-        return jsonify({"linkedGame": g.linked_game})
-
-    js = request.get_json()
-    if js is None:
-        abort(400)
-
-    link_id = js.get("linkedGame", None)
-    if link_id is None or db.Game.query.get(link_id) is None:
-        abort(401)
-    
-    g.link(link_id)
-    return "", 201
-
-
-@game.route("/<game_id>/round/<int:round_num>/first")
-def first_submitter(game_id, round_num):
-    fs = db.FirstSubmission.query.get((game_id, round_num))
-    if fs is None:
-        abort(404)
-    return jsonify({
-        "first": fs.player_name
-    })
-    
-
-@game.route("/<game_id>/join", methods=["POST"])
-def join(game_id):
-    js = request.get_json()
-    if js is None:
-        abort(400)
-
-    name = js.get("playerName", None)
-    if name is None:
-        abort(400)
-
-    g = require_game(game_id)
-
-    if db.Player.query.filter(db.Player.game_id == game_id, db.Player.player_name == name).first() is not None:
-        abort(409)
-
-    p = g.join(name)
-    return jsonify({
-        "playerId": str(p.player_id)
-    }), 201
-
-
-@game.route("/<game_id>/current")
-def current_round(game_id):
-    g = require_game(game_id)
-    player = require_player(game_id)
-    cur_rnd = player.get_current_round()
-    if cur_rnd is None:
-        coord = None
-    else:
-        lookup = db.Coordinate.query.get((game_id, cur_rnd))
-        cur_rnd = str(cur_rnd)
-        if lookup is None:
-            coord = None
-        else:
-            coord = {
-                "lat": lookup.latitude,
-                "lng": lookup.longitude,
-            }
-    if g.rule_set == "TIMEBANK":
-        timer = player.get_last_round_time_remaining()
-        if timer is None:
-            timer = g.timer * g.rounds
-    else:
-        timer = g.timer
-    return jsonify({
-        "currentRound": cur_rnd,
-        "coord": coord,
-        "timer": int(timer),
-    })
-
-
-@game.route("/<game_id>/guesses/<int:round_num>", methods=["POST"])
-def make_guess(game_id, round_num):
-    g = require_game(game_id)
-    player = require_player(game_id)
-    if round_num != player.get_current_round():
-        abort(409)
-
-    js = request.get_json()
-    if js is None:
-        abort(400)
-
-    timed_out = js.get("timeout", False)
-    if timed_out:
-        player.add_timeout(round_num)
-        if g.rule_set == "TIMEBANK":
-            player.timeout_remaining_rounds()
-        db.session.commit()
-        return jsonify({
-            "score": 0,
-            "totalScore": player.get_total_score(),
-            "distance": None,
-        }), 201
-
-    try:
-        lat = float(js.get("lat", None))
-        lng = float(js.get("lng", None))
-        remaining = int(js.get("timeRemaining", None))
-    except ValueError:
-        abort(400)
-
-    target = db.Coordinate.query.get((game_id, round_num))
-    if target is None:
-        abort(400)
-
-    guess_score, distance = lib.score((target.latitude, target.longitude), (lat, lng))
-    player.add_guess(round_num, lat, lng, guess_score, remaining)
-
-    if remaining <= 0 and g.rule_set == "TIMEBANK":
-        player.timeout_remaining_rounds()
-
-    return jsonify({
-        "score": guess_score,
-        "totalScore": player.get_total_score(),
-        "distance": distance,
-    }), 201

+ 0 - 5
server/gunicorn.conf.py

@@ -1,5 +0,0 @@
-workers = 1
-threads = 4
-worker_tmp_dir = "/dev/shm"
-
-bind = "0.0.0.0:5000"

+ 0 - 285
server/lib.py

@@ -1,285 +0,0 @@
-import json
-import math
-import random
-import threading
-import collections
-import time
-
-import requests
-import haversine
-
-# Google API key, with access to Street View Static API
-google_api_key = "AIzaSyAqjCYR6Szph0X0H_iD6O1HenFhL9jySOo"
-metadata_url = "https://maps.googleapis.com/maps/api/streetview/metadata"
-mapcrunch_url = "http://www.mapcrunch.com/_r/"
-rsv_url = "https://randomstreetview.com/data"
-urban_centers_usa = []
-urban_centers_non_usa = []
-with open("./urban-centers-usa.csv") as infile:
-    for line in infile:
-        lat, lng = line.split(",")
-        urban_centers_usa.append((float(lat.strip()), float(lng.strip())))
-with open("./urban-centers-non-usa.csv") as infile:
-    for line in infile:
-        lat, lng = line.split(",")
-        urban_centers_non_usa.append((float(lat.strip()), float(lng.strip())))
-
-
-def point_has_streetview(lat, lng):
-    """
-    Returns True if the streetview metadata endpoint says a given point has
-    data available, and False otherwise.
-
-    This function calls the streetview metadata endpoint - there is no quota consumed.
-    """
-    params = {
-        "key": google_api_key,
-        "location": f"{lat},{lng}",
-    }
-    js = requests.get(metadata_url, params=params).json()
-    return js["status"] == "OK"
-
-
-def generate_coord(max_retries=100, only_america=False):
-    """
-    Returns (latitude, longitude) of usable coord (where google has data).
-    This function will attempt at most max_retries calls to map crunch to fetch
-    candidate points, and will exit as soon as a suitable candidate is found.
-    If no suitable candidate is found in this allotted number of retries, None is
-    returned.
-
-    This function calls the streetview metadata endpoint - there is no quota consumed.
-    """
-    mc_url = mapcrunch_url + ("?c=21" if only_america else "")
-    for _ in range(max_retries):
-        points_res = requests.get(mc_url).text
-        points_js = json.loads(points_res.strip("while(1); "))
-        if "c=" not in mc_url:
-            mc_url += f"?c={points_js['country']}" # lock to the first country randomed
-        for lat, lng in points_js["points"]:
-            if point_has_streetview(lat, lng):
-                return (lat, lng)
-
-
-def call_random_street_view(only_america=False):
-    """
-    Returns an array of (some number of) tuples, each being (latitude, longitude).
-    All points will be valid streetview coordinates. There is no guarantee as to the
-    length of this array (it may be empty), but it will never be None.
-
-    This function calls the streetview metadata endpoint - there is no quota consumed.
-    """
-    rsv_js = requests.post(rsv_url, data={"country": "us" if only_america else "all"}).json()
-    if not rsv_js["success"]:
-        return []
-    
-    return [
-        (point["lat"], point["lng"])
-        for point in rsv_js["locations"]
-        if point_has_streetview(point["lat"], point["lng"])
-    ]
-
-
-def random_street_view_generator(only_america=False):
-    """
-    Returns a generator which will lazily use call_random_street_view to generate new
-    street view points.
-
-    The returned generator calls the streetview metadata endpoint - there is no quota consumed.
-    """
-    points = []
-    while True:
-        if len(points) == 0: 
-            points = call_random_street_view(only_america=only_america)
-        else:
-            yield points.pop()
-
-
-def urban_coord(max_retries=10, retries_per_point=30, max_dist_km=25, usa_chance=0.1):
-    """
-    Returns (latitude, longitude) of usable coord (where google has data) that is near
-    a known urban center. Points will be at most max_dist_km kilometers away. This function will
-    generate at most retries_per_point points around an urban center, and will try at most
-    max_retries urban centers. If none of the generated points have street view data, 
-    this will return None. Otherwise, it will exit as soon as suitable point is found.
-
-    This function calls the streetview metadata endpoint - there is no quota consumed.
-    """
-
-    src = urban_centers_usa if random.random() <= usa_chance else urban_centers_non_usa
-
-    for _ in range(max_retries):
-        # logic adapted from https://stackoverflow.com/a/7835325
-        # start in a city
-        (city_lat, city_lng) = random.choice(src)
-        city_lat_rad = math.radians(city_lat)
-        sin_lat = math.sin(city_lat_rad)
-        cos_lat = math.cos(city_lat_rad)
-        city_lng_rad = math.radians(city_lng)
-        for _ in range(retries_per_point):
-            # turn a random direction, and go random distance
-            dist_km = random.random() * max_dist_km
-            angle_rad = random.random() * 2 * math.pi
-            d_over_radius = dist_km / mean_earth_radius_km
-            sin_dor = math.sin(d_over_radius)
-            cos_dor = math.cos(d_over_radius)
-            pt_lat_rad = math.asin(sin_lat * cos_dor + cos_lat * sin_dor * math.cos(angle_rad))
-            pt_lng_rad = city_lng_rad + math.atan2(math.sin(angle_rad) * sin_dor * cos_lat, cos_dor - sin_lat * math.sin(pt_lat_rad))
-            pt_lat = math.degrees(pt_lat_rad)
-            pt_lng = math.degrees(pt_lng_rad)
-            if point_has_streetview(pt_lat, pt_lng):
-                return (pt_lat, pt_lng)
-
-
-class PointSource:
-    def __init__(self, stock_target):
-        self.stock = collections.deque()
-        self.stock_target = stock_target
-
-    def _restock_impl(self, n):
-        """
-        Returns a list of new points to add to the stock.
-        Implementations of this method should try to return at least n points for performance.
-        """
-        raise NotImplementedError("Subclasses must implement this")
-    
-    def restock(self, n=None):
-        n = n if n is not None else self.stock_target - len(self.stock)
-        if n > 0:
-            pts = self._restock_impl(n)
-            self.stock.extend(pts)
-            diff = n - len(pts)
-            if diff > 0:
-                # if implementations of _restock_impl are well behaved, this will
-                # never actually need to recurse to finish the job.
-                self.restock(n=diff)
-
-    def get_points(self, n=1):
-        if len(self.stock) >= n:
-            pts = []
-            for _ in range(n):
-                pts.append(self.stock.popleft())
-            threading.Thread(target=self.restock).start()
-            return pts
-        self.restock(n=n)
-        # this is safe as long as restock does actually add enough new points.
-        # unless this object is being rapidly drained by another thread,
-        # this will recur at most once.
-        return self.get_points(n=n)
-
-
-class MapCrunchPointSource(PointSource):
-    def __init__(self, stock_target=20, max_retries=100, only_america=False):
-        super().__init__(stock_target=stock_target)
-        self.max_retries = max_retries
-        self.only_america = only_america
-
-    def _restock_impl(self, n):
-        points = []
-        while len(points) < n:
-            pt = generate_coord(
-                max_retries=self.max_retries,
-                only_america=self.only_america
-            )
-            if pt is not None:
-                points.append(pt)
-        return points
-
-
-class RSVPointSource(PointSource):
-    def __init__(self, stock_target=20, only_america=False):
-        super().__init__(stock_target=stock_target)
-        self.only_america = only_america
-    
-    def _restock_impl(self, n):
-        points = []
-        while len(points) < n:
-            points.extend(call_random_street_view(only_america=self.only_america))
-        return points
-
-
-class UrbanPointSource(PointSource):
-    def __init__(self, stock_target=20, max_retries=10, retries_per_point=30, max_dist_km=25, usa_chance=0.1):
-        super().__init__(stock_target=stock_target)
-        self.max_retries = max_retries
-        self.retries_per_point = retries_per_point
-        self.max_dist_km = max_dist_km
-        self.usa_chance = usa_chance
-    
-    def _restock_impl(self, n):
-        points = []
-        while len(points) < n:
-            pt = urban_coord(
-                max_retries=self.max_retries,
-                retries_per_point=self.retries_per_point,
-                max_dist_km=self.max_dist_km,
-                usa_chance=self.usa_chance
-            )
-            if pt is not None:
-                points.append(pt)
-        return points
-
-
-mean_earth_radius_km = (6378 + 6357) / 2
-
-# if you're more than 1/4 of the Earth's circumfrence away, you get 0
-max_dist_km = (math.pi * mean_earth_radius_km) / 2 # this is about 10,000 km
-
-# if you're within 1/16 of the Earth's circumfrence away, you get at least 1000 points
-quarter_of_max_km = max_dist_km / 4 # this is about 2,500 km
-
-# https://www.wolframalpha.com/input/?i=sqrt%28%28%28land+mass+of+earth%29+%2F+7%29%29+%2F+pi%29+in+kilometers
-# this is the average "radius" of a continent
-# within this radius, you get at least 2000 points
-avg_continental_rad_km = 1468.0
-
-# somewhat arbitrarily, if you're within 1000 km, you get at least 3000 points
-one_thousand = 1000.0
-
-# https://www.wolframalpha.com/input/?i=sqrt%28%28%28land+mass+of+earth%29+%2F+%28number+of+countries+on+earth%29%29+%2F+pi%29+in+kilometers
-# this is the average "radius" of a country
-# within this radius, you get at least 4000 points
-avg_country_rad_km = 479.7
-
-# if you're within 150m, you get a perfect score of 5000
-min_dist_km = 0.15  
-
-
-def score_within(raw_dist, min_dist, max_dist):
-    """
-    Gives a score between 0 and 1000, with 1000 for the min_dist and 0 for the max_dist
-    """
-    # scale the distance down to [0.0, 1.0], then multiply it by 2 for easing
-    pd2 = 2 * (raw_dist - min_dist) / (max_dist - min_dist)
-    # perform a quadratic ease-in-out on pd2
-    r = (pd2 ** 2) / 2 if pd2 < 1 else 1 - (((2 - pd2) ** 2) / 2)
-    # use this to ease between 1000 and 0
-    return int(1000 * (1 - r))
-
-
-def score(target, guess):
-    """
-    Takes in two (latitude, longitude) pairs and produces an int score.
-    Score is in the (inclusive) range [0, 5000]
-    Higher scores are closer.
-
-    Returns (score, distance in km)
-    """
-    dist_km = haversine.haversine(target, guess)
-
-    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

+ 17 - 15
server/requirements.txt

@@ -1,16 +1,18 @@
-certifi==2019.6.16
+certifi==2020.4.5.1
 chardet==3.0.4
-Click==7.0
-Flask==1.1.1
-Flask-Cors==3.0.8
-Flask-SQLAlchemy==2.4.0
-haversine==2.1.2
-idna==2.8
-itsdangerous==1.1.0
-Jinja2==2.10.1
-MarkupSafe==1.1.1
-requests==2.22.0
-six==1.12.0
-SQLAlchemy==1.3.7
-urllib3==1.25.3
-Werkzeug==0.15.5
+click==7.1.2
+fastapi==0.54.2
+fastapi-camelcase==1.0.1
+h11==0.9.0
+haversine==2.2.0
+httptools==0.1.1
+idna==2.9
+pydantic==1.5.1
+pyhumps==1.3.1
+requests==2.23.0
+SQLAlchemy==1.3.17
+starlette==0.13.2
+urllib3==1.25.9
+uvicorn==0.11.5
+uvloop==0.14.0
+websockets==8.1

+ 0 - 31
server/scripts/process.py

@@ -1,31 +0,0 @@
-#!/usr/bin/env python3
-
-import sys
-import csv
-
-"""
-Strip unneeded data from world cities database.
-
-https://simplemaps.com/data/world-cities
-"""
-
-us_cities = []
-other_cities = []
-
-with open(sys.argv[1]) as infile:
-  reader = csv.reader(infile, quotechar='"')
-  next(reader) # skip header
-  for row in reader:
-    pt = (row[2], row[3])
-    if row[4] == "United States":
-      us_cities.append(pt)
-    else:
-      other_cities.append(pt)
-
-with open(sys.argv[2], "w") as outfile:
-  for (lat, lng) in us_cities:
-    outfile.write(f"{lat}, {lng}\n")
-
-with open(sys.argv[3], "w") as outfile:
-  for (lat, lng) in other_cities:
-    outfile.write(f"{lat}, {lng}\n")

+ 0 - 34
server/sources.py

@@ -1,34 +0,0 @@
-import threading
-
-import lib
-
-stock_target = 20
-# (gen_method, only_america): lib.PointSource
-sources = {
-    ("MAPCRUNCH", False): lib.MapCrunchPointSource(stock_target=stock_target, max_retries=1000, only_america=False),
-    ("MAPCRUNCH", True): lib.MapCrunchPointSource(stock_target=stock_target, max_retries=1000, only_america=True),
-    ("RANDOMSTREETVIEW", False): lib.RSVPointSource(stock_target=stock_target, only_america=False),
-    ("RANDOMSTREETVIEW", True): lib.RSVPointSource(stock_target=stock_target, only_america=True),
-    ("URBAN", False): lib.UrbanPointSource(
-        stock_target=stock_target,
-        max_retries=100,
-        retries_per_point=30,
-        max_dist_km=25,
-        usa_chance=0.1
-    ),
-    ("URBAN", True): lib.UrbanPointSource(
-        stock_target=stock_target,
-        max_retries=100,
-        retries_per_point=30,
-        max_dist_km=25,
-        usa_chance=1.0
-    )
-}
-
-
-def restock_all():
-  """
-  Restock all the configured sources above
-  """
-  for src in sources.values():
-      threading.Thread(target=src.restock).start()