diff --git a/NOTES.md b/NOTES.md index ff31d8f..a12065a 100644 --- a/NOTES.md +++ b/NOTES.md @@ -10,35 +10,23 @@ - [X] Swagger API - [ ] Local development tool to clear/seed database - [X] CRUD APIs for Games -- [ ] HTML Page to Create Game +- [X] HTML Page to Create Game - Mostly done, just need to implement actual submission, (if desired) deck-creation in-page, and non-deck metadata (who won, turn count, etc.). -- [ ] Load Game-history from file +- [X] Load Game-history from file - [ ] Favicon +- [ ] Configuration per-stage (including different welcome screen) +- [X] More content on Home Page +- [X] About Page +- [ ] "Display components" like "a tables of games" that can be inserted into multiple pages + * Oh no, did I just re-invent React? :P + - [ ] Data presentation methods like "translating a list of Deck IDs into Deck Names" ... - [ ] Authentication (will need to link `user` table to `player`) ... - [ ] Helm chart including an initContainer to create the database if it doesn't exist already - [ ] GroupId (i.e. so I can host other people's data? That's _probably_ a YAGNI - see if there's demand!) - - -# Tables - -Tables: -* Decks - * Name - * Description - * Owner - * DecklistId (optional) -* Players (not the same as Users! Can model a Player who is not a User) -* Users - * Standard auth stuff -* Games - * Date - * Location - * DeckIds (array) - * WinningDeckId - * FinalTurnNum - * Notes +... +- [ ] Comments/chats on Games? # Database Migrations @@ -54,3 +42,12 @@ https://www.digitalocean.com/community/tutorials/how-to-add-authentication-to-yo * ~~How to specify the content-type that should be sent with a Swagger request without needing to use `requestBody` (and thus, implying that there _should_ be a body)?~~ * Fixed by using FastApi instead of Flask * How to abstract out the standard-definitions of `routers/*` and `sql/crud.py`? + +# Known bugs/QA issues + +* It's possible to select the same player twice for a game +* Can submit a game in the future + * ELO calculation assumes that games are submitted in chronological order +* "Turn First Player Out" can be more than "Number Of Turns" +* No validation that you've actually selected a deck for a player. +* 404 pages are not friendly diff --git a/app/elo/elo.py b/app/elo/elo.py index f01a53e..5f5b357 100644 --- a/app/elo/elo.py +++ b/app/elo/elo.py @@ -1,35 +1,36 @@ -from typing import Iterable +from typing import Iterable, List -K_FACTOR = 10 +K_FACTOR = 10.0 BETA = 200 -def rerank(ratings: Iterable[int], winning_player_idx: int) -> Iterable[int]: +def rerank(ratings: List[float], winning_player_idx: int) -> Iterable[float]: expectations = _expectations(ratings) return [ - rating - + (K_FACTOR * ((1 if winning_player_idx == idx else 0) - expectations[idx])) + float(rating) + + (K_FACTOR * ((1.0 if winning_player_idx == idx else 0.0) - expectations[idx])) for idx, rating in enumerate(ratings) ] -def _expectations(ratings: Iterable[int]) -> Iterable[int]: +def _expectations(ratings: List[float]) -> List[float]: return [ _calculate_expectation(rating, ratings[:idx] + ratings[idx + 1 :]) for idx, rating in enumerate(ratings) ] -def _calculate_expectation(rating: int, other_ratings: Iterable[int]) -> int: +def _calculate_expectation(rating: float, other_ratings: List[float]) -> float: return sum( [_pairwise_expectation(rating, other_rating) for other_rating in other_ratings] ) / (float(len(other_ratings) + 1) * len(other_ratings) / 2) -def _pairwise_expectation(rating: int, other_rating: int) -> Iterable[int]: +def _pairwise_expectation(rating: float, other_rating: float) -> float: """ Gives the expected score of `rating` against `other_rating` """ diff = float(other_rating) - float(rating) f_factor = 2 * BETA # rating disparity - return 1.0 / (1 + 10 ** (diff / f_factor)) + ret_val = 1.0 / (1 + 10 ** (diff / f_factor)) + return ret_val diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 1e34be5..c063896 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import base, decks, games, players, seed +from . import base, decks, games, players, score, seed api_router = APIRouter(prefix="/api") html_router = APIRouter() @@ -8,6 +8,7 @@ html_router = APIRouter() api_router.include_router(decks.api_router) api_router.include_router(players.api_router) api_router.include_router(games.api_router) +api_router.include_router(score.api_router) api_router.include_router(seed.api_router) html_router.include_router(decks.html_router) diff --git a/app/routers/base.py b/app/routers/base.py index 6a910a3..31c5d36 100644 --- a/app/routers/base.py +++ b/app/routers/base.py @@ -14,3 +14,8 @@ def main(request: Request, db=Depends(get_db)): return jinja_templates.TemplateResponse( request, "/main.html", {"games": _jsonify(games)} ) + + +@html_router.get("/about") +def about(request: Request, db=Depends(get_db)): + return jinja_templates.TemplateResponse(request, "/about.html") diff --git a/app/routers/decks.py b/app/routers/decks.py index dc5e196..0831c94 100644 --- a/app/routers/decks.py +++ b/app/routers/decks.py @@ -1,8 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse +from sqlalchemy import and_, or_ from sqlalchemy.orm import Session -from ..templates import jinja_templates, _jsonify +from app.sql import models + +from ..templates import jinja_templates from ..sql import crud, schemas from ..sql.database import get_db @@ -75,8 +78,43 @@ def decks_html(request: Request, db=Depends(get_db)): @html_router.get("/{deck_id}") def deck_html(request: Request, deck_id: str, db=Depends(get_db)): deck_info = read_deck(deck_id, db) + deck_score_history = _build_deck_score_history(deck_id, db) return jinja_templates.TemplateResponse( request, "decks/detail.html", - {"deck": _jsonify(deck_info), "owner": _jsonify(deck_info.owner)}, + { + "deck": deck_info, + "owner": deck_info.owner, + "game_history": deck_score_history, + }, ) + + +def _build_deck_score_history(deck_id: str, db: Session): + # This is...horrible. + # But I can't find a way to execute a join _in_ SQLAlchemy in such a way that the response is actual objects rather + # than the underlying rows + # (https://stackoverflow.com/questions/78596316/) + games_involving_this_deck = ( + db.query(models.Game) + .filter( + or_(*[getattr(models.Game, f"deck_id_{i+1}") == deck_id for i in range(6)]) + ) + .all() + ) + # Having found the games, then add the score for this deck after that game + return [ + { + "game": game, + "score": db.query(models.EloScore) + .filter( + and_( + models.EloScore.after_game_id == game.id, + models.EloScore.deck_id == deck_id, + ) + ) + .first() + .score, + } + for game in games_involving_this_deck + ] diff --git a/app/routers/games.py b/app/routers/games.py index 1cdfb65..023df39 100644 --- a/app/routers/games.py +++ b/app/routers/games.py @@ -1,4 +1,5 @@ import json +import logging from functional import seq from typing import List, Mapping @@ -10,15 +11,18 @@ from sqlalchemy.orm import Session from app.routers.decks import list_decks from app.sql import models from .players import list_players -from ..templates import jinja_templates +from ..elo import rerank from ..sql import crud, schemas from ..sql.database import get_db +from ..templates import jinja_templates api_router = APIRouter(prefix="/game", tags=["game"]) html_router = APIRouter( prefix="/game", include_in_schema=False, default_response_class=HTMLResponse ) +LOGGER = logging.getLogger(__name__) + ######## # API Routes ######## @@ -26,7 +30,36 @@ html_router = APIRouter( @api_router.post("/", response_model=schemas.Game, status_code=201) def create_game(game: schemas.GameCreate, db: Session = Depends(get_db)): - return crud.create_game(db=db, game=game) + created_game = crud.create_game(db=db, game=game) + + # Update ELO scores + last_score = ( + db.query(models.EloScore).order_by(models.EloScore.after_game_id.desc()).first() + ) + if last_score: + last_scored_game_id = last_score.after_game_id + else: + last_scored_game_id = 0 + if created_game.id != last_scored_game_id + 1: + # TODO - better error reporting? + LOGGER.error( + f"Created a game with id {created_game.id}, which is not after the last-scored-game-id {last_scored_game_id}. ELO calculation paused." + ) + return created_game + + deck_ids = [id for id in [getattr(game, f"deck_id_{n+1}") for n in range(6)] if id] + deck_scores_before_this_game = [ + crud.get_latest_score_for_deck(db, deck_id) for deck_id in deck_ids + ] + new_scores = rerank( + deck_scores_before_this_game, deck_ids.index(game.winning_deck_id) + ) + for score, deck_id in zip(new_scores, deck_ids): + db.add( + models.EloScore(after_game_id=created_game.id, deck_id=deck_id, score=score) + ) + db.commit() + return created_game @api_router.get("/list", response_model=list[schemas.Game]) @@ -55,6 +88,7 @@ def delete_game(game_id: str, db=Depends(get_db)): @html_router.get("/create", response_class=HTMLResponse) def game_create_html(request: Request, db=Depends(get_db)): players = list_players(db=db) + win_types = db.query(models.WinType).all() return jinja_templates.TemplateResponse( request, "games/create.html", @@ -72,6 +106,7 @@ def game_create_html(request: Request, db=Depends(get_db)): for player in players } ), + "win_types": win_types, }, ) @@ -80,7 +115,9 @@ def game_create_html(request: Request, db=Depends(get_db)): @html_router.get("/list") def games_html(request: Request, db=Depends(get_db)): games = list_games(db=db) - decks = list_decks(db=db) + # TODO - a more "data-intensive application" implementation would fetch only the decks involved in the games for + # this page + decks = list_decks(db=db, limit=-1) decks_by_id = {deck.id: deck for deck in decks} game_names = {game.id: _build_game_deck_names(game, decks_by_id) for game in games} return jinja_templates.TemplateResponse( @@ -100,14 +137,19 @@ def _build_game_deck_names( .map(lambda key: getattr(game, key)) .filter(lambda x: x) .map(lambda deck_id: decks_by_id[deck_id]) - .map(lambda deck: deck.name) + .map(lambda deck: {"owner": deck.owner.name, "name": deck.name, "id": deck.id}) ) # This must be after the static-path routes, lest it take priority over them @html_router.get("/{game_id}") def game_html(request: Request, game_id: str, db=Depends(get_db)): - game_info = read_game(game_id, db) + game = read_game(game_id, db) + + decks = list_decks(db=db, limit=-1) + decks_by_id = {deck.id: deck for deck in decks} + game_deck_names = _build_game_deck_names(game, decks_by_id) + return jinja_templates.TemplateResponse( - request, "games/detail.html", {"game": game_info} + request, "games/detail.html", {"game": game, "game_deck_names": game_deck_names} ) diff --git a/app/routers/players.py b/app/routers/players.py index bbb915c..63cf0b1 100644 --- a/app/routers/players.py +++ b/app/routers/players.py @@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session +from app.sql import models + from ..templates import jinja_templates from ..sql import crud, schemas from ..sql.database import get_db @@ -61,6 +63,10 @@ def player_list_html(request: Request, db=Depends(get_db)): @html_router.get("/{player_id}") def player_html(request: Request, player_id: str, db=Depends(get_db)): player_info = read_player(player_id, db) + # TODO - Is it bad practice to pull database-accessing code into this layer? + # On the one hand, it's obviously mixing levels. + # ON the other, feels weird to implement a `get_decks_for_player` method to be used in exactly one place. + decks = db.query(models.Deck).filter(models.Deck.owner_id == player_id).all() return jinja_templates.TemplateResponse( - request, "players/detail.html", {"player": player_info} + request, "players/detail.html", {"player": player_info, "decks": decks} ) diff --git a/app/routers/score.py b/app/routers/score.py new file mode 100644 index 0000000..102f470 --- /dev/null +++ b/app/routers/score.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, Depends + +from ..sql import crud, schemas +from ..sql.database import get_db + +api_router = APIRouter(prefix="/score", tags=["score"]) + + +@api_router.get("/{deck_id}/latest", response_model=schemas.EloScore) +def get_latest_score_for_deck(deck_id: int, db=Depends(get_db)): + return crud.get_latest_score_for_deck(db, deck_id) + + +@api_router.get("/{deck_id}/all", response_model=list[schemas.EloScore]) +def get_all_scores_for_deck(deck_id: int, db=Depends(get_db)): + return crud.get_all_scores_for_deck(db, deck_id) diff --git a/app/routers/seed.py b/app/routers/seed.py index 6eb9972..a47de65 100644 --- a/app/routers/seed.py +++ b/app/routers/seed.py @@ -1,13 +1,19 @@ import csv import datetime import logging + +from collections import defaultdict + + from fastapi import APIRouter, Depends, Request, UploadFile from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session +from .games import create_game from ..templates import jinja_templates from ..sql import crud, schemas from ..sql.database import get_db +from ..sql.models import Format, WinType LOGGER = logging.getLogger(__name__) @@ -78,6 +84,103 @@ def seed_games(file: UploadFile, db: Session = Depends(get_db)): return "OK!" +@api_router.post("/all_in_one") +def all_in_one(file: UploadFile, db: Session = Depends(get_db)): + file_contents = file.file.read().decode("utf-8").split("\n") + reader = csv.DictReader(file_contents, delimiter=",") + + # Mapping from name to set-of-owned-decks + # (Set rather than list so that we can blindly `.add`) + player_decks = defaultdict(set) + # I'm hard-coding seeding of win_cons and formats (in `app/sql/__init__.py`), rather than requiring them to be + # manually seeded - but this would be where we'd track them if we wanted them to be seeded + # win_types = set() + # formats = set() + + for row_idx, row in enumerate(reader): + if not row: + continue + for i in range(6): + player_id = f"Player {i+1}" + if row[player_id]: + player_decks[row[player_id]].add(row[f"Deck {i+1}"]) + # Hack because of missing data in the original spreadsheet... :) + if row_idx == 28 and i == 3: + print("In the suspect row") + player_decks["stranger"].add(row["Deck 4"]) + + # See above + # win_types.add(row['Type of win']) + # formats.add(row['Format']) + + # If we cared about memory efficiency we could have instead made `player_decks` into an extensible data structure + # and added this information in there, but I'm hardly going to be dealing with memory-intensive amounts of + # data in this app. + player_id_lookup = {} + deck_id_lookup = {} + + for player_name, decks in player_decks.items(): + player = crud.create_player( + db=db, player=schemas.PlayerCreate(name=player_name) + ) + LOGGER.info(f"Seeded {player=}") + player_id = player.id + player_id_lookup[player_name] = player_id + for deck_name in decks: + deck = crud.create_deck( + db=db, + deck=schemas.DeckCreate( + name=deck_name, description="", owner_id=player_id + ), + ) + LOGGER.info(f"Seeded {deck=}") + deck_id_lookup[deck_name] = deck.id + + def parse_date(date_string) -> datetime.datetime: + month, day, year = date_string.split("/") + return datetime.datetime.strptime( + f"{year}-{month.rjust(2, '0')}-{day.rjust(2, '0')}", "%y-%m-%d" + ) + + win_types = db.query(WinType).all() + formats = db.query(Format).all() + + # Recreate the reader to consume the rows again. + # (Again, if we _really_ cared about efficiency we could have stored this data on the first pass to avoid a + # retraversal. I suspect that the overhead of O(2*n) vs. O(n) data-reads is going to be insignificant) + # ((Yes, I know that's an abuse of Big-O notation, shut up - you knew what I meant :P )) + reader = csv.DictReader(file_contents, delimiter=",") + for row in reader: + # Note that we intentionally create via the API, not via direct `crud.create_game`, to trigger ELO calculation. + + created_game = create_game( + schemas.GameCreate( + date=parse_date(row["Date"]), + **{ + f"deck_id_{i+1}": deck_id_lookup[row[f"Deck {i+1}"]] + for i in range(6) + if row[f"Deck {i+1}"] + }, + winning_deck_id=deck_id_lookup[row["Winning Deck"]], + number_of_turns=int(row["# turns"]), + first_player_out_turn=row["turn 1st player out"], + win_type_id=[ + win_type.id + for win_type in win_types + if win_type.name == row["Type of win"] + ][0], + format_id=[ + format.id for format in formats if format.name == row["Format"] + ][0], + description=row["Notes"], + ), + db, + ) + LOGGER.info(f"Seeded {created_game=}") + + return "Ok!" + + @html_router.get("/") def main(request: Request, db=Depends(get_db)): return jinja_templates.TemplateResponse( diff --git a/app/sql/__init__.py b/app/sql/__init__.py index 8d1d1d0..79f04b5 100644 --- a/app/sql/__init__.py +++ b/app/sql/__init__.py @@ -6,10 +6,17 @@ def prime_database(): db = SessionLocal() win_types = db.query(models.WinType).all() if not win_types: - db.add(models.WinType(name="Combat Damage")) - db.add(models.WinType(name="Commander Damage")) - db.add(models.WinType(name="Direct Damage")) - db.add(models.WinType(name="Poison")) - db.add(models.WinType(name="Decking")) - db.add(models.WinType(name="other")) + db.add(models.WinType(name="combat damage")) + db.add(models.WinType(name="21+ commander")) + db.add(models.WinType(name="aristocrats/burn")) + db.add(models.WinType(name="poison")) + db.add(models.WinType(name="quality of life concede")) + db.add(models.WinType(name="alt win-con")) + db.add(models.WinType(name="mill")) + db.commit() + + formats = db.query(models.Format).all() + if not formats: + db.add(models.Format(name="FFA")) + db.add(models.Format(name="Star")) db.commit() diff --git a/app/sql/crud.py b/app/sql/crud.py index 2c916ab..fe7614b 100644 --- a/app/sql/crud.py +++ b/app/sql/crud.py @@ -66,3 +66,30 @@ def delete_game_by_id(db: Session, game_id: int): db.query(models.Game).filter(models.Game.id == game_id).delete() db.commit() return "", 204 + + +# Note - I'm _super_ new to FastAPI and have no idea about best practices - I don't know whether it's correct to put +# these "higher-level than basic CRUD, but still database-interacting" methods in `crud.py`, or if they should go +# elsewhere. Feedback welcome! (Lol as if anyone but me is ever going to actually look at this code :P ) + + +def get_latest_score_for_deck(db: Session, deck_id: int): + scores = get_all_scores_for_deck(db, deck_id) + + if scores: + return scores[0].score + else: + # Really we could pick any value as the initial rating for an as-yet-unplayed deck - + # scores are all relative, not absolutely, so any value would be appropriate! + # This was chosen just because it's a nice round number :) + return 1000.0 + + +def get_all_scores_for_deck(db: Session, deck_id: int): + return ( + db.query(models.EloScore) + .join(models.Game) + .filter(models.EloScore.deck_id == deck_id) + .order_by(models.Game.id.desc()) + .all() + ) diff --git a/app/sql/models.py b/app/sql/models.py index c854faa..7de2bc6 100644 --- a/app/sql/models.py +++ b/app/sql/models.py @@ -1,5 +1,7 @@ -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String -from sqlalchemy.orm import relationship +from typing import List + +from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String +from sqlalchemy.orm import relationship, Mapped from .database import Base @@ -25,7 +27,14 @@ class Deck(Base): class WinType(Base): - __tablename__ = "wintypes" + __tablename__ = "win_types" + + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False) + + +class Format(Base): + __tablename__ = "formats" id = Column(Integer, primary_key=True) name = Column(String, nullable=False) @@ -45,5 +54,17 @@ class Game(Base): winning_deck_id = Column(Integer, ForeignKey("decks.id"), nullable=False) number_of_turns = Column(Integer, nullable=False) first_player_out_turn = Column(Integer, nullable=False) - win_type_id = Column(Integer, ForeignKey("wintypes.id"), nullable=False) + win_type_id = Column(Integer, ForeignKey("win_types.id"), nullable=False) + format_id = Column(Integer, ForeignKey("formats.id"), nullable=False) description = Column(String) + elo_scores: Mapped[List["EloScore"]] = relationship() + + +class EloScore(Base): + __tablename__ = "elo_scores" + + id = Column(Integer, primary_key=True) + # This Elo Score was calculated after this game + after_game_id: Mapped[int] = Column(Integer, ForeignKey("games.id")) + deck_id = Column(Integer, ForeignKey("decks.id")) + score = Column(Float(asdecimal=True, decimal_return_scale=3)) diff --git a/app/sql/schemas.py b/app/sql/schemas.py index c24cb58..9344b3d 100644 --- a/app/sql/schemas.py +++ b/app/sql/schemas.py @@ -59,6 +59,7 @@ class GameBase(BaseModel): number_of_turns: int first_player_out_turn: int win_type_id: int + format_id: int description: str @@ -70,3 +71,12 @@ class Game(GameBase): id: int model_config = {"from_attributes": True} + + +# No need for an EloScoreBase because this will never be created via API - it's only ever calculated internally. +class EloScore(BaseModel): + id: int + after_game_id: int + on_date: datetime + deck_id: int + score: float diff --git a/app/static/css/base.css b/app/static/css/base.css new file mode 100644 index 0000000..bddcacb --- /dev/null +++ b/app/static/css/base.css @@ -0,0 +1,92 @@ +body { + margin: 0; +} + +#header { + background-color: #0080ff; + min-height: 40px; + width: 100%; + color: #ddd; + text-shadow: 2px 2px #555; +} + +#header div { + float: left +} + +#header a { + color: inherit; + text-decoration: none; + -moz-transition: all .2s ease-in; + -o-transition: all .2s ease-in; + -webkit-transition: all .2s ease-in; + transition: all .2s ease-in; +} + +#header a { + /* margin: 0; */ + line-height: 40px; +} + +#header a:hover { + color: #00c; +} + +#header #header_main_anchor { + min-height: 40px; + width: 100px; + padding: 0px 20px; + text-align: center; +} + +#header #topbar { + margin-left: 20px; +} + +.topbar_item { + margin: 0px 10px; +} + +#header div#create_game_button { + float: right; + margin-right: 15px; +} + +a#create_game_link { + display:block; + text-shadow: none; + color: #222; + background-color: lightgreen; + height: 25px; + line-height: 27px; + margin-top: 5px; + padding: 2px 10px; + border-radius: 10px; + +} + +footer { + position: fixed; + left: 0; + bottom: 0; + height: 40px; + line-height: 40px; + width: 100%; + background-color: #0007ee; + color: lightgrey; + text-align: center; +} + +footer a { + color: lightgrey; +} + +footer #about { + float: left; + margin-left: 10px; +} + +footer #credits { + float: right; + margin-right: 10px; +} \ No newline at end of file diff --git a/app/static/js/game_create.js b/app/static/js/game_create.js index 4574db5..eb0e069 100644 --- a/app/static/js/game_create.js +++ b/app/static/js/game_create.js @@ -21,6 +21,7 @@ function change_player(eventData) { console.log(deck_select); const target_val = parseInt(target.val()); + // Populate the per-player deck-choices if (target_val == -1) { deck_select.hide(); } else { @@ -40,28 +41,70 @@ function change_player(eventData) { player_decks = player_deck_data[target_val.toString()]; actual_select = $(deck_select[0]); actual_select.empty(); - actual_select.append(''); + actual_select.append(''); for (deck of player_decks) { - actual_select.append(``); + actual_select.append(``); } // Just in case it's been previously hidden actual_select.show(); - } -} -function initialize_dropdowns() { - console.log('TODO - initialize dropdowns'); + // Update the "winning player" dropdown + $('#winning_player_id').empty(); + $('.player_select').each(function () { + if ($(this).val() != -1) { + $('#winning_player_id').append( + $('') + .attr('value', $(this).val()) + .text($(this).children("option:selected").text()) + ) + } + }); + + } $(document).ready(function() { $('#number_of_players').on("change", change_num_players) $('.player_select').on("change", change_player) + + $('#submit').click(function() { + var data = { + 'date': $('#date').val(), + 'number_of_turns': $('#number_of_turns').val(), + 'first_player_out_turn': $('#first_player_out_turn').val(), + 'win_type_id': $('#win_type_id').val(), + 'description': $('#description').val() + } + winning_player_id = $('#winning_player_id option:selected').attr('value'); + data['winning_deck_id'] = getDeckForPlayerId(winning_player_id); + for (i=0; i<$('#number_of_players').val(); i++) { + data['deck_id_' + (i+1)] = $('#div_for_player_' + (i+1) + ' .deck_select option:selected').attr('value'); + } - initialize_dropdowns() - // TODO - initialize dropdowns + $.ajax({ + type: 'POST', + url: '/api/game/', + data: JSON.stringify(data), + contentType: 'application/json', + dataType: 'json', + success: function(data) { + window.location.href = '/game/' + data.id; + } + }); + }); +}); - // TODO - submit logic should: - // * Check that Players are unique -}); \ No newline at end of file +function getDeckForPlayerId(player_id) { + mapped = $('.player_div').map(function() { + return { + 'player_id': $(this).find('.player_select option:selected').attr('value'), + 'deck_id': $(this).find('.deck_select option:selected').attr('value') + } + }) + + filtered = mapped.filter((_, data) => parseInt(parseInt(data['player_id'])) == player_id) + + return filtered[0]['deck_id']; +} \ No newline at end of file diff --git a/app/templates/__init__.py b/app/templates/__init__.py index 40bb4bd..4b67689 100644 --- a/app/templates/__init__.py +++ b/app/templates/__init__.py @@ -5,7 +5,10 @@ # # ImportError: cannot import name 'jinja_templates' from partially initialized module 'app.routers' (most likely due to a circular import) (/Users/scubbo/Code/edh-elo/app/routers/__init__.py) +from json import dumps + from fastapi.templating import Jinja2Templates +from fastapi.encoders import jsonable_encoder jinja_templates = Jinja2Templates(directory="app/templates") @@ -15,8 +18,13 @@ jinja_templates = Jinja2Templates(directory="app/templates") # (Probably not, as we'd still need to explicitly call it - it wouldn't be implicitly called _by_ Flask) # # (Assumes that this will only be passed lists or objects, not primitives) +# def _jsonify(o): +# if hasattr(o, "__dict__"): +# return {k: v for (k, v) in o.__dict__.items() if k != "_sa_instance_state"} +# else: +# return [_jsonify(e) for e in o] + + +# https://fastapi.tiangolo.com/tutorial/encoder/ def _jsonify(o): - if hasattr(o, "__dict__"): - return {k: v for (k, v) in o.__dict__.items() if k != "_sa_instance_state"} - else: - return [_jsonify(e) for e in o] + return dumps(jsonable_encoder(o)) diff --git a/app/templates/about.html b/app/templates/about.html new file mode 100644 index 0000000..82b684f --- /dev/null +++ b/app/templates/about.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}EDH ELO - About{% endblock %} + +{% block head %} +{{ super() }} +{% endblock %} + +{% block content %} +
+ This app is a score-tracker for an EDH/Commander group. Initial motivation was to provide + Elo scoring for the decks, but other planned + functionality includes:
++ (This is only listing user-facing functionality - there are also plenty of technical or QA considerations that I'd + like to add! See the GitHub Repo for more + details) +
+ ++ As you can probably tell, this app is currently in a "pre-alpha" state, with minimal functionality and cosmetic + considerations so as to just get something in front of users. +
++ HUGE thanks to my friend Patrick for getting a jump-start on data-tracking, so that 1. I had some data to work with, + and 2. I had inspiration to keep working on this project. +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 053fd33..9579ec7 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -7,13 +7,32 @@ src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"> + {% endblock %} +