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 %} +
{% block content %}{% endblock %}
+ + \ No newline at end of file diff --git a/app/templates/decks/detail.html b/app/templates/decks/detail.html index de7d8db..c90d20f 100644 --- a/app/templates/decks/detail.html +++ b/app/templates/decks/detail.html @@ -1,13 +1,45 @@ - - - - - Deck - {{ deck.name }} - - -

This is the page for deck {{ deck.name }} with id {{ deck.id }}, owned by {{ owner.name }}

- {% if deck.description %} -

The description of the deck is: {{ deck.description }}

- {% endif %} - - +{% extends "base.html" %} + +{% block title %}Deck - {{ deck.name }}{% endblock %} + +{% block head %} +{{ super() }} +{% endblock %} + +{% block content %} +

This is the page for deck {{ deck.name }} with id {{ deck.id }}, owned by {{ owner.name }}

+ +{% if deck.description %} +

The description of the deck is: {{ deck.description }}

+{% endif %} + +

Game history

+{% if game_history %} +(TODO - extract a translation-from-deckid-to-names method) +(Or...just link them as a relationship/ForeignKey) + + + + + + + + {% for entry in game_history %} + + + + + + + {% endfor %} +
DateParticipantsResultELO Score
{{ entry.game.date.strftime('%Y-%m-%d') }} + {% for participant_id in range(6) %} + {% set deck_id = entry.game['deck_id_' ~ (participant_id+1)] %} + {% if deck_id is not none %} + {{ deck_id }} + {% endif %} + {% endfor %}{{ "Win" if entry.game.winning_deck_id == deck.id else "Loss" }}{{ entry.score|int }}
+{% else %} +

This Deck has not played any games

+{% endif %} +{% endblock %} diff --git a/app/templates/decks/list.html b/app/templates/decks/list.html index 8a13fba..13b1432 100644 --- a/app/templates/decks/list.html +++ b/app/templates/decks/list.html @@ -1,24 +1,25 @@ - - - - - Decks - - -

Decks

- - - - - - {% for deck in decks %} - - - - - {% endfor %} -
Deck NameOwner
- {{ deck.name }} - {{ deck.owner.name }}
- - +{% extends "base.html" %} + +{% block title %}Decks{% endblock %} + +{% block head %} +{{ super() }} +{% endblock %} + +{% block content %} +

Decks

+ + + + + + {% for deck in decks %} + + + + + {% endfor %} +
Deck NameOwner
+ {{ deck.name }} + {{ deck.owner.name }}
+{% endblock %} diff --git a/app/templates/games/create.html b/app/templates/games/create.html index 7d173b9..0e9940a 100644 --- a/app/templates/games/create.html +++ b/app/templates/games/create.html @@ -33,5 +33,22 @@ {% endfor %} + +
+ + +
+
+
+ + + + {% endblock %} diff --git a/app/templates/games/detail.html b/app/templates/games/detail.html index 968a2fa..79b42f6 100644 --- a/app/templates/games/detail.html +++ b/app/templates/games/detail.html @@ -1,13 +1,20 @@ - - - - - Game - {{ game.id }} - - -

This is the page for game with id {{ game.id }}, played on date {{ game.date }}

- {% if game.description %} -

The description of the game is: {{ game.description }}

- {% endif %} - - +{% extends "base.html" %} + +{% block title %}Game - {{ game.id }}{% endblock %} + +{% block head %} +{{ super() }} +{% endblock %} + +{% block content %} +

This is the page for game with id {{ game.id }}, played on date {{ game.date.strftime('%Y-%m-%d') }}

+

Participants

+ +{% if game.description %} +

The description of the game is: {{ game.description }}

+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/games/list.html b/app/templates/games/list.html index 9b1e5a6..ea02a7d 100644 --- a/app/templates/games/list.html +++ b/app/templates/games/list.html @@ -1,28 +1,33 @@ - - - - - Games - - -

Games

- - - - - - - {% for game in games %} - - - - - +{% extends "base.html" %} + +{% block title %}Games{% endblock %} + +{% block head %} +{{ super() }} +{% endblock %} + +{% block content %} +

Games

+
DateDecksWinning Deck
{{ game.date }} - {{ game_names[game.id] | join(", ") }} - - {{ decks_by_id[game.winning_deck_id].name }} -
+ + + + + +{% for game in games %} + + +
DateDecksWinning Deck
{{ game.date.strftime('%Y-%m-%d') }} +
- - + + + + {{ decks_by_id[game.winning_deck_id].name }} + + +{% endfor %} + +{% endblock %} diff --git a/app/templates/main.html b/app/templates/main.html index 521d08a..4f6b942 100644 --- a/app/templates/main.html +++ b/app/templates/main.html @@ -3,8 +3,9 @@ {% block title %}EDH ELO{% endblock %} {% block head %} +{{ super() }} {% endblock %} {% block content %} -

Welcome to EDH ELO!

+

Welcome to EDH ELO! Click "Games" above to see the list of Games, or "Record New Game" in the top-right to record a game

{% endblock %} \ No newline at end of file diff --git a/app/templates/players/detail.html b/app/templates/players/detail.html index a9c7aaa..c1e023d 100644 --- a/app/templates/players/detail.html +++ b/app/templates/players/detail.html @@ -1,10 +1,20 @@ - - - - - Player - {{ player.name }} - - -

This is the page for player {{ player.name }} who has id {{ player.id }}

- - \ No newline at end of file +{% extends "base.html" %} + +{% block title %}Player - {{ player.name }}{% endblock %} + +{% block head %} +{{ super() }} +{% endblock %} + +{% block content %} +

This is the page for player {{ player.name }} who has id {{ player.id }}

+ +{% if decks %} +

Decks

+ +{% endif %} +{% endblock %} diff --git a/app/templates/players/list.html b/app/templates/players/list.html index 2de9a08..0952a35 100644 --- a/app/templates/players/list.html +++ b/app/templates/players/list.html @@ -1,20 +1,21 @@ - - - - - Players - - -

Players

- - - - - {% for player in players %} - - - - {% endfor %} -
Name
{{ player.name }}
- - +{% extends "base.html" %} + +{% block title %}Players{% endblock %} + +{% block head %} +{{ super() }} +{% endblock %} + +{% block content %} +

Players

+ + + + + {% for player in players %} + + + + {% endfor %} +
Name
{{ player.name }}
+{% endblock %} diff --git a/app/templates/seed.html b/app/templates/seed.html index e5120f1..b8b22f4 100644 --- a/app/templates/seed.html +++ b/app/templates/seed.html @@ -3,6 +3,7 @@ {% block title %}Seeding from files{% endblock %} {% block head %} +{{ super() }} {% endblock %} @@ -32,4 +33,12 @@ Upload +
+
+
+ + + Upload +
+
{% endblock %} \ No newline at end of file diff --git a/seed-data/all-in-one.csv b/seed-data/all-in-one.csv new file mode 100644 index 0000000..e24ac4b --- /dev/null +++ b/seed-data/all-in-one.csv @@ -0,0 +1,77 @@ +Date,Player 1,Deck 1,Player 2,Deck 2,Player 3,Deck 3,Player 4,Deck 4,Player 5,Deck 5,Player 6,Deck 6,Winning Player,Winning Deck,# turns,turn 1st player out,Type of win,Format,Notes +1/13/24,Evan,Kelsien the Plague,Terence,Ravos/Rebbec,Patrick,Duke Ulder Ravengard,Jeff,Mondrak,,,,,Terence,Ravos/Rebbec,15,12,combat damage,FFA,3-4 board wipes; Biotransference +1/13/24,Terence,Wernog/Cecily,Patrick,Duke Ulder Ravengard,Ryan,Don Andres,Jeff,Mondrak,Evan,Jasmine Boreal of the Seven,,,Patrick,Duke Ulder Ravengard,12,10,combat damage,FFA,Possessed Portal scary! +1/13/24,Jeff,Go-Shintai of Life's Origin,Evan,Kethis the Hidden Hand,Terence,Rograkh/Silas ninjas,Patrick,Tekuthal,Ryan,Gitrog,,,Patrick,Tekuthal,8,8,poison,FFA,Flux Channeler + Tekuthal +1/13/24,Patrick,Tekuthal,Jeff,Illuna,Evan,"Atraxa, Praetor's Voice",Terence,Muldrotha,,,,,Patrick,Tekuthal,20,5,combat damage,FFA,3 Hullbreaker Horrors simultaneously (Evan forfeited ~t5; game took ~2hrs) +1/17/24,Patrick,Duke Ulder Ravengard,Ryan,Grist,Terence,Ravos/Rebbec,,,,,,,Patrick,Duke Ulder Ravengard,12,12,combat damage,FFA,"Eternal Wanderer wipe, Angel Serenity + Sun Titan followup" +1/17/24,Terence,Ravos/Rebbec,Patrick,Duke Ulder Ravengard,Ryan,Grist,,,,,,,Terence,Ravos/Rebbec,13,11,combat damage,FFA,Verge Rangers + Conjurer's Mantle +1/23/24,Ryan,Goose Mother,Patrick,Maarika,Terence,Wilson/Cultist,,,,,,,Patrick,Maarika,8,7,21+ commander,FFA,Maarika + Runes of the Deus +1/23/24,Ryan,Goose Mother,Patrick,Maarika,Terence,Wilson/Cultist,,,,,,,Terence,Wilson/Cultist,10,8,combat damage,FFA,Scepter of Celebration brings an army +1/23/24,Terence,Abdel Adrian/Far Traveler,Ryan,Obeka,Patrick,Laelia,,,,,,,Patrick,Laelia,9,8,21+ commander,FFA,Nalfeshnee copying Storm's Wrath cleared the way; Ryan mana screwed +1/23/24,Terence,Jan Jansen,Ryan,Don Andres,Patrick,Laelia,,,,,,,Terence,Jan Jansen,10,10,aristocrats/burn,FFA,Mirkwood Bats + Thornbite Staff ftw; Ryan mana screwed +1/30/24,Patrick,Urza,Terence,"Me, the Immortal",Ryan,Gitrog,,,,,,,Ryan,Gitrog,8,7,combat damage,FFA,Mending of Dominaria + Lotus Cobra (10 lands) +1/30/24,Patrick,Kiora,Terence,Raffine,Ryan,Kozilek,,,,,,,Patrick,Kiora,11,9,combat damage,FFA,"Raffine does all the work, then Kederekt Leviathan cleans up" +2/4/24,Ajit,Jhoira of the Ghitu,Brandon,Anikthea,Patrick,Oops all Kayas,,,,,,,Brandon,Anikthea,12,11,combat damage,FFA,Unopposed enchantress with card draw and Nature's Will +2/4/24,Brandon,Silvar/Trynn,Patrick,Oops all Kayas,Ryan,"Purphoros, God of the Forge",Ajit,Jhoira of the Ghitu,,,,,Brandon,Silvar/Trynn,12,10,aristocrats/burn,FFA,Bastion of Remembrance overcame Emrakul + It That Betrays +2/4/24,Ajit,Brago,Brandon,Marneus Calgar,Patrick,Laelia,Ryan,Gitrog,,,,,Ryan,Gitrog,15,12,combat damage,FFA,Gitrog draws a zillion cards and closes w/ Multani +2/4/24,Terence,Slimefoot and Squee,Patrick,Ayara,Ryan,Omnath,,,,,,,Ryan,Omnath,8,8,combat damage,FFA,"Vorinclex, Voice of Hunger enables Crackle with Power (x=4) to overcome Army of the Damned" +2/20/24,Terence,Wilson/Cultist,Ryan,Gitrog,Patrick,Tekuthal,,,,,,,Terence,Wilson/Cultist,15,12,combat damage,FFA,Weatherlight and Ormendahl collect many tithes +2/20/24,Ryan,Myrel,Patrick,Tekuthal,Terence,Ravos/Rebbec,,,,,,,Patrick,Tekuthal,10,9,combat damage,FFA,Arcbound Crusher got chonky (w/ Sword of Truth & Justice) +2/20/24,Terence,Ravos/Rebbec,Ryan,Myrel,Patrick,Tekuthal,,,,,,,Terence,Ravos/Rebbec,10,9,combat damage,FFA,Ryan's Coat of Arms + Terence's Door of Destinies and Haunted One = math! +2/25/24,Patrick,Elmar storm,Jack J,Gale/Scion of Halaster,Terence,Kefnet the Mindful,Jeff,Mondrak,Ryan,Don Andres,,,Jeff,Mondrak,12,9,combat damage,FFA,Elesh Norn is a good finisher; Ryan achievement unlocked: control all other commanders at once +2/25/24,Terence,Rograkh/Silas ninjas,Jeff,Illuna,Ryan,Emiel,Patrick,Myrkul planeswalkers,Jack J,Syr Ginger,,,Terence,Rograkh/Silas ninjas,16,15,combat damage,FFA,Yuriko + ninja informant = overwhelming card advantage +2/25/24,Ryan,Omnath,Patrick,Rafiq,Terence,Yoshimaru/Reyhan,Jeff,Go-Shintai of Life's Origin,,,,,Jeff,Go-Shintai of Life's Origin,8,5,combat damage,FFA,Shrine of tapping stuff down bought just enough time +2/28/24,Terence,Dargo/Nadier,Ryan,Pantlaza,Patrick,Niv-Mizzet,,,,,,,Ryan,Pantlaza,10,9,combat damage,FFA,Hasty dinosaurs post-wrath with a Savage Order for Gishath chonks life totals +2/28/24,Ryan,Gitrog,Patrick,Niv-Mizzet,Terence,Dargo/Nadier,,,,,,,Patrick,Niv-Mizzet,9,9,combat damage,FFA,Faeburrow Elder enables quick acceleration; Case of the Shattered Pact is quick damage +2/28/24,Ryan,Gitrog,Patrick,Niv-Mizzet,Terence,Dargo/Nadier,,,,,,,Terence,Dargo/Nadier,8,5,combat damage,FFA,Dargo with Jeska 1-shot Ryan (and earned 27 impulse draws via Fire Giant's Fury - into Burnt Offering); Skull Storm precisely lethal +2/28/24,Ryan,"Liesa, Shroud of Dusk",Patrick,Duke Ulder Ravengard,Terence,Malcolm/Ich-Tekik,,,,,,,Patrick,Duke Ulder Ravengard,13,11,combat damage,FFA,Stealing Angel of Destiny and giving myriad 1-shot Ryan (while earning 60 life) +3/10/24,stranger,"Krenko, Tin Street Kingpin",Patrick,Tekuthal,stranger,Gishath,stranger,Ur-Dragon,,,,,stranger,Ur-Dragon,7,7,combat damage,FFA,"Master Warcraft with Atarka, Miirym, and 2 Ur-Dragons; poor threat analysis (2 friends didn't target each other)" +3/10/24,stranger,Gishath,stranger,Ur-Dragon,stranger,Korvold,Patrick,Dihada,,,,,Patrick,Dihada,10,8,combat damage,FFA,Heroes' Podium + Day of Destiny stacks quickly; Malik gets around Lightning Greaves (same 2 friends) +3/10/24,stranger,"Lazav, the Multifarious",stranger,"Mirri, Weatherlight Duelist",Patrick,Duke Ulder Ravengard,,Anzrag,,,,,Patrick,Duke Ulder Ravengard,11,9,combat damage,FFA,Lazav cast an honest Eater of Days to block Anzrag buffed by Xenagos; Knight-Captain of Eos perma-fogged Anzrag to victory +3/10/24,stranger,"Brimaz, Blight of Oreskos",stranger,"The Master, Multiplied",stranger,"Lonis, Cryptozoologist",stranger,"Mirri, Weatherlight Duelist",Patrick,Tekuthal,,,stranger,"The Master, Multiplied",7,7,combat damage,FFA,unchecked Master Multiplied with ramp = 13 Masters attacking everyone; Brimaz + Tekuthal both mana screwed +3/10/24,stranger,"Brimaz, Blight of Oreskos",stranger,"The Master, Multiplied",stranger,"Lonis, Cryptozoologist",stranger,"Mirri, Weatherlight Duelist",Patrick,Tekuthal,,,stranger,"Brimaz, Blight of Oreskos",12,12,quality of life concede,FFA,Several board wipes + removal with life totals largely intact; dealt with Koma; Myojin of Seeing Winds drew 13 and could keep going but conceded +3/15/24,Terence,Atraxa win-cons,Ryan,Emiel,Patrick,Duke Ulder Ravengard,,,,,,,Patrick,Duke Ulder Ravengard,16,13,combat damage,FFA,Ghostway + wrath to slow down Emiel+Seedborn +3/15/24,Patrick,Niv-Mizzet,Terence,Yidris dredge,Ryan,Pantlaza,,,,,,,Terence,Yidris dredge,10,9,combat damage,FFA,"Living End claims another victim, even when scripted a turn ahead" +3/15/24,Ryan,Pantlaza,Patrick,Niv-Mizzet,Terence,Yidris dredge,,,,,,,Ryan,Pantlaza,12,12,combat damage,FFA,Ghalta discover trigger into Portal to Phyrexia (with Skullspore Nexus) +3/15/24,Ryan,Reyav,Patrick,Laelia,Terence,Yidris dredge,,,,,,,Terence,Yidris dredge,9,7,combat damage,FFA,Echoing Equation on Hogaak takes out Patrick; Narcomoeba attacking was the final damage to Ryan +3/30/24,Jack F,"Brenard, Ginger Sculptor",Terence,Ravos/Rebbec,Patrick,Duke Ulder Ravengard,Jeff,Mondrak,Brandon,The Wise Mothman,,,Jeff,Mondrak,11,8,combat damage,FFA,Anointed Procession + (destroyed: Cathar's Crusade + Starlight Spectacular) with multiple wraths overcame a LARGE Mothman and Brenard's army +3/30/24,Ajit,Brago,Patrick,Laelia,Jeff,Go-Shintai of Life's Origin,Brandon,"Liberty Prime, Recharged",Jack F,"Astor, Bearer of Blades",,,Ajit,Brago,14,9,combat damage,FFA,"Brago + Medomai turns ft. 21/21 Astor (RIP Patrick), 22/22 Danitha; 31/31 Laelia; Goblin Welder loops of Synth-Reflector Mages; 7+ shrines threatening w/ Zur" +4/10/24,Terence,Ishai/Tana,Ryan,Don Andres,Patrick,Tekuthal,,,,,,,Terence,Ishai/Tana,9,9,combat damage,FFA,Assemble the Legion + Felidar Retreat +4/10/24,Patrick,Tekuthal,Terence,Ishai/Tana,Ryan,Don Andres,,,,,,,Patrick,Tekuthal,10,10,poison,FFA,Don Andres cast Eternal Dominion +4/16/24,Ryan,Goose Mother,Terence,"Vorinclex, Monstrous Raider",Patrick,Maarika,,,,,,,Terence,"Vorinclex, Monstrous Raider",10,9,21+ commander,FFA,Vorinclex + Bone Saws backed up by hexproof +4/16/24,Terence,Kefnet the Mindful,Patrick,Maarika,Ryan,Goose Mother,,,,,,,Terence,Kefnet the Mindful,10,9,21+ commander,FFA,Kefnet wears a Robe of the Archmagi and survives Phasing of Zhalfir; Ryan Mana Drained a t4 Harmonize into a large Goose +4/16/24,Terence,Kefnet the Mindful,Patrick,Dihada,Ryan,Emiel,,,,,,,Terence,Kefnet the Mindful,13,12,21+ commander,FFA,Kefnet w/ Empyrial Plate +4/20/24,Jack J,Syr Ginger,Patrick,Kamahl/Prava,Terence,Atraxa win-cons,,,,,,,Patrick,Kamahl/Prava,9,8,combat damage,FFA,Inspiring Leader + both commanders +4/20/24,Terence,Atraxa win-cons,Jack J,Syr Ginger,Ryan,Emiel,Patrick,Kamahl/Prava,,,,,Jack J,Syr Ginger,15,12,combat damage,FFA,Ghalta/Mavren made 20 vampires (w/ Doubling Season); Millenium Calendar hit 50; Spine of Ish Sah recurred each turn +4/20/24,Ryan,Gitrog,Patrick,Laelia,Terence,Amber/Veteran Soldier,Jack J,Ghyson Kharn,,,,,Patrick,Laelia,10,9,21+ commander,FFA,"Throne of Eldraine into Etali snowballed, then Laelia cascaded for +50/+50. Slicer *nearly* took Laelia out. Terence Generous Gifted Ryan's only land" +4/20/24,Terence,Vial Smasher/Sidar Kondo,Ryan,"Liesa, Shroud of Dusk",Patrick,Dihada,,,,,,,Ryan,"Liesa, Shroud of Dusk",11,10,combat damage,FFA,Austere Command +4/20/24,Patrick,Dihada,Terence,Vial Smasher/Sidar Kondo,Ryan,"Liesa, Shroud of Dusk",,,,,,,Terence,Vial Smasher/Sidar Kondo,10,8,combat damage,FFA,Authority of the Consul +4/23/24,Patrick,Ashad,Terence,Phabine,Ryan,Zaxara,,,,,,,Terence,Phabine,8,7,combat damage,FFA,"Adeline, Port Razer, Halana and Alena into Phabine" +4/23/24,Ryan,Zaxara,Patrick,Ashad,Terence,Jon Irenicus,,,,,,,Ryan,Zaxara,9,7,combat damage,FFA,Genesis Wave for 7; Exponential Growth for 3; Villainous Wealth for 19; Biomass Mutation for 32 (477 trample damage) +4/23/24,Ryan,Reyav,Patrick,Gwenna,Terence,Yidris dredge,,,,,,,Patrick,Gwenna,11,7,combat damage,FFA,Living Death wiped out Reyav early; Ram Through post Zopandrel for lethal +4/23/24,Ryan,Gitrog,Patrick,Rafiq,Terence,Jan Jansen,,,,,,,Terence,Jan Jansen,9,8,aristocrats/burn,FFA,Mirkwood Bats + Thornbite Staff + Mayhem Devil + Jan Jansen ftw; Rafiq flooded after a terrifying start +5/1/24,Brandon,"Gavi, Nest Warden",Ryan,Ziatora,Patrick,Ashad,Terence,"Izzet (Jori En, Ruin Diver)",,,,,Terence,"Izzet (Jori En, Ruin Diver)",9,8,combat damage,FFA,"Arcane Bombardment, Stormkiln Artist, Haughty Djinn should've been stopped sooner. Djinn Illuminatus replicated Lightning Bolt x8 to take out Ashad" +5/1/24,Patrick,Ashad,Terence,"Izzet (Bilbo, Retired Burglar)",Brandon,"Dogmeat, Ever Loyal",Ryan,Ziatora,,,,,Patrick,Ashad,11,6,combat damage,FFA,Ziatora+GE-Rhonas+Zopandrel KO'ed Brandon on t6; Ashad and 8 Mishra's Self-Replicators went wide enough +5/7/24,Terence,Tevesh Szat/Kraum,Ryan,Ur-Dragon,Patrick,Kamahl/Prava,,,,,,,Ryan,Ur-Dragon,11,10,combat damage,FFA,"Elminster's Simulacrum (copied) created 2 dragons and a Kamahl, but didn't live to untap" +5/7/24,Terence,Wernog/Cecily,Ryan,Ur-Dragon,Patrick,Kamahl/Prava,,,,,,,Terence,Wernog/Cecily,11,11,alt win-con,FFA,Hellkite Tyrant win-con enabled by Storm the Vault and Wernog flickers (equalled 32 life!) +5/7/24,Terence,Ravos/Rebbec,Ryan,Zaxara,Patrick,Kamahl/Prava,,,,,,,Terence,Ravos/Rebbec,11,10,combat damage,FFA,"Rammas Echor, Door of Destinies, Metallic Mimic, Hero of Bladehold took out Ryan 2 turns before Simic Ascendancy, then Anduril, Narsil Reforged + Biotransference closed it out after a mana-screwed Patrick dropped 8 lands (Harvest Season) w/ Felidar Retreat and Doubling Season" +5/15/24,Ryan,"Tinybones, the Pickpocket",Patrick,"Smeagol, Helpful Guide",Terence,Atraxa win-cons,,,,,,,Terence,Atraxa win-cons,10,10,alt win-con,FFA,Accidental Millennium Calendar win; Smeagol milled both opponents out but forgot Calendar would win on upkeep (and had artifact removal in hand) +5/15/24,Patrick,"Smeagol, Helpful Guide",Terence,Atraxa win-cons,Ryan,"Tinybones, the Pickpocket",,,,,,,Patrick,"Smeagol, Helpful Guide",11,10,combat damage,FFA,Awakening Zone triggered Smeagol every turn; Rampaging Baloths + Invasion of Lorwyn closed out +5/15/24,Terence,Emry,Ryan,Omnath,Patrick,Niv-Mizzet,,,,,,,Terence,Emry,9,9,combat damage,FFA,"T3 Kappa Cannoneer survived 2 board wipes, then Echo Storm created 4 more" +5/15/24,Patrick,Niv-Mizzet,Terence,Emry,Ryan,Omnath,,,,,,,Ryan,Omnath,8,8,aristocrats/burn,FFA,Nissa + Ancient Greenwarden into Crackle With Power +5/18/24,Jack J,Syr Ginger,Patrick,Maarika,Jack F,"Brenard, Ginger Sculptor",Brandon,"Dogmeat, Ever Loyal",,,,,Brandon,"Dogmeat, Ever Loyal",9,6,combat damage,FFA,31-power Dogmeat (Strong Back+Mantle of the Ancients) archenemy'ed the table +5/18/24,Patrick,Maarika,Jack F,Jeskai (The War Doctor/Clara Oswald (blue)),Brandon,"Caesar, Legion's Emperor",Jack J,Ghyson Kharn,,,,,Patrick,Maarika,12,7,combat damage,FFA,Maarika/Tergrid stole Fervent Charge and Iroas to lethal Brandon +5/18/24,Jack F,Jeskai (Kate Stewart),Brandon,"Morska, Undersea Sleuth",Jack J,Ghyson Kharn,Patrick,Kamahl/Prava,,,,,Jack J,Ghyson Kharn,7,7,aristocrats/burn,FFA,"Niv-Mizzet, Ghyson Kharn, Ophidian Eye" +5/18/24,Jack F,"Astor, Bearer of Blades",Brandon,Commodore Guff,Ryan,Don Andres,Jack J,Jon Irenicus,Patrick,Kamahl/Prava,,,Jack F,"Astor, Bearer of Blades",15,9,combat damage,FFA,Lae'Zel and 5 planeswalkers (unanswered for 5 turns) played archenemy until Holy Day and 2 Bruenor swings to the face +5/21/24,Terence,Wilson/Cultist,Patrick,Tekuthal,Ryan,Emiel,,,,,,,Terence,Wilson/Cultist,16,14,combat damage,FFA,Wilson's card-advantage engines never stopped; Jace TMS ult'ed (after a 5*2 proliferate turn) on Emiel; Emiel's White Dragon tapped down Tekuthal's blockers +5/21/24,Ryan,Toxrill,Terence,Rigo,Patrick,Jared [Jegantha],,,,,,,Terence,Rigo,10,8,poison,FFA,"Norn's Decree: incentivized Jared to only attack Toxrill (which halted the 1/1 swarm), then was lethal to a first strike + normal strike would-be attack" +5/21/24,Terence,Vial Smasher/Sidar Kondo,Patrick,Ayara,Ryan,Gitrog,,,,,,,Terence,Vial Smasher/Sidar Kondo,9,8,combat damage,FFA,"Vial Smasher literally only hit Ayara (~20 damage); Ayara jumped from 2 to 20 (taking out mana-screwed Gitrog) w/ Gary, but unblockable + Mercadia's Downfall was exactly lethal" +5/25/24,Jeff,Mondrak,Terence,Viconia/Cultist,Patrick,Kiora,,,,,,,Terence,Viconia/Cultist,9,9,aristocrats/burn,FFA,Living Death: Ayara + Abhorrent Overlord devotion 32 insta lethal +5/25/24,Patrick,Kiora,Jeff,Mondrak,Terence,Viconia/Cultist,,,,,,,Terence,Viconia/Cultist,16,15,combat damage,FFA,Slow one: multiple board bounces and one wipe; bestowed Nighthowler for 19 closed it out +5/26/24,stranger,Jeska/Vial Smasher,stranger,Riku of Many Paths,stranger,Sovereign Okinec Ahau,Patrick,Rafiq,,,,,Patrick,Rafiq,10,5,21+ commander,FFA,"T3 Rafiq w/ mom protection took out Riku, then rebuilt post board wipe (mom survived) and 3/3 commander lethal" +5/26/24,Patrick,Ayara,stranger,"Kellan, the Fae-Blooded",stranger,Sovereign Okinec Ahau,,,,,,,stranger,"Kellan, the Fae-Blooded",13,8,combat damage,FFA,(opted not to play Bolas' Citadel b/c had just won previous game); Kellan won due to fog effect stopping Sovereign from hitting for 100+ +5/26/24,stranger,The Swarmlord,stranger,Tergrid,Patrick,Ashad,stranger,"Rocco, Street Chef",,,,,stranger,"Rocco, Street Chef",10,9,combat damage,FFA,Rocco and Tergrid emerged as heavyweights; Rocco Final Showdown'ed in response to Tergrid's Myojin of Night's Reach; Tergrid had to leave and Rocco cleaned up fast +6/2/24,Brandon,Sliver Gravemother,Ajit,Brago,Terence,Umori,Jeff,Go-Shintai of Life's Origin,Patrick,"Smeagol, Helpful Guide",,,Terence,Umori,12,8,combat damage,Star,"Smeagol was a punching bag; Aetherspouts stopped a lethal Rumbleweed, but Sanctum of Stone Fangs inadvertently took out Brandon before Ajit could be taken out" +6/2/24,Patrick,"Smeagol, Helpful Guide",Brandon,Abaddon,Ajit,Jhoira of the Ghitu,Terence,Borborygmos Enraged,Jeff,Mondrak,,,Patrick,"Smeagol, Helpful Guide",6,6,mill,Star,Naturally drew the mill combo +6/2/24,Terence,Borborygmos Enraged,Jeff,Mondrak,Patrick,Duke Ulder Ravengard,Brandon,"Morska, Undersea Sleuth",Ajit,Jhoira of the Ghitu,,,Terence,Borborygmos Enraged,10,8,combat damage,Star,"Windshaper Planetar rerouted 21 Mondrak damage from Brandon to Terence; Mondrak's Starlight Spectacular (would've won if we'd done math precombat to play 1 more creature) was short of killing Brandon so took out Patrick; Morska's Kappa Cannoneer ended Jeff, leaving Terence last one standing" +6/2/24,Brandon,"Atraxa, Grand Unifier",Patrick,Talion,Jeff,Illuna,Ajit,Kadena,Terence,Rograkh/Silas ninjas,,,Patrick,Talion,11,9,combat damage,Star,"Kadena early Tempt for Discovery and stole Talion; Atraxa early Breach the Multiverse (Mommy Norn, Oko, Apex Altisaur, Elspeth Knight Errant) became archenemy; loads of interaction; eventually Talion+Sakashima + Sheoldred closed the door" \ No newline at end of file diff --git a/seed-data/games.csv b/seed-data/games.csv index 6e43484..97946aa 100644 --- a/seed-data/games.csv +++ b/seed-data/games.csv @@ -1,2 +1,3 @@ id,date,deck_id_1,deck_id_2,deck_id_3,deck_id_4,deck_id_5,deck_id_6,winning_deck_id,number_of_turns,first_player_out_turn,win_type_id,description 1,2024-01-13,1,2,3,4,,,2,15,12,1,3-4 board wipes; Biotransference +2,2024-01-13,5,3,23,4,6,,3,12,10,1,Possessed Portal scary! \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 71df00b..6e3870a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,57 @@ import os import pathlib import pytest +from typing import Callable + from fastapi.testclient import TestClient +# https://stackoverflow.com/questions/69281822/how-to-only-run-a-pytest-fixture-cleanup-on-test-error-or-failure, +# Though syntax appears to have changed +# https://docs.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + # execute all other hooks to obtain the report object + outcome = yield + + # set a report attribute for each phase of a call, which can + # be "setup", "call", "teardown" + + # TODO - we may care about more than just a binary result! + # (i.e. a skipped test is neither passed nor failed...probably?) + setattr( + item, + "rep_" + outcome.get_result().when + "_passed", + outcome.get_result().passed, + ) + + +class Cleanups(object): + def __init__(self): + self.success_cleanups = [] + self.failure_cleanups = [] + + def add_success(self, success_cleanup: Callable[[], None]): + self.success_cleanups.append(success_cleanup) + + def add_failure(self, failure_cleanup: Callable[[], None]): + self.failure_cleanups.append(failure_cleanup) + + +@pytest.fixture +def cleanups(request): + cleanups = Cleanups() + yield cleanups + + if request.node.rep_call_passed: + cleanups = cleanups.success_cleanups + else: + cleanups = cleanups.failure_cleanups + if cleanups: + for cleanup in cleanups[::-1]: # Apply in reverse order + cleanup() + + def prime_database(): # Start afresh! database_dir = "database" diff --git a/tests/sql/test_crud.py b/tests/sql/test_crud.py new file mode 100644 index 0000000..1fed5c0 --- /dev/null +++ b/tests/sql/test_crud.py @@ -0,0 +1,69 @@ +import pathlib +import pytest +import random +import string + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.sql.models import Base +from app.sql import crud +from app.sql import schemas + + +def test_create_player(isolated_database): + # No risk of interference because the fixture has `scope="function"`, + # so creates a new database for every invocation! + _test_create_and_retrieve(isolated_database, "Timmy") + # Note there's no need to cleanup this database because the fixture takes care of it for us! + + +def test_parallel(isolated_database): + _test_create_and_retrieve(isolated_database, "Johnny") + + +def test_more_parallelization(isolated_database): + _test_create_and_retrieve(isolated_database, "Spike") + + +def _test_create_and_retrieve(db, name: str): + empty_get_player = crud.get_player_by_id(db, 1) + assert empty_get_player is None + + create_response = crud.create_player(db, schemas.PlayerCreate(name=name)) + assert create_response.id == 1 + assert create_response.name == name + + get_player = crud.get_player_by_id(db, 1) + assert get_player.name == name + + +@pytest.fixture(scope="function") +def isolated_database(request, cleanups): + database_dir = "database" + db_dir_path = pathlib.Path(database_dir) + if not db_dir_path.exists(): + db_dir_path.mkdir() + db_dir_path.chmod(0o777) + + isolated_db_name = f"isolated_database_{''.join([random.choice(string.ascii_lowercase) for _ in range(5)])}.db" + isolated_db_path = db_dir_path.joinpath(isolated_db_name) + + engine = create_engine(f"sqlite:///{isolated_db_path.absolute()}") + + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.create_all(bind=engine) + + def success_cleanup(): + isolated_db_path.unlink() + + def failure_cleanup(): + print( + f"Isolated database {isolated_db_path.absolute()}, used in test `{request.node.name}` at path `{request.node.path.absolute()}`, has been preserved for debugging" + ) + + cleanups.add_success(success_cleanup) + cleanups.add_failure(failure_cleanup) + + yield SessionLocal() + # yield isolated_db_path diff --git a/tests/test_fresh_db_tests.py b/tests/test_fresh_db_tests.py index 7ea5676..ab65a2e 100644 --- a/tests/test_fresh_db_tests.py +++ b/tests/test_fresh_db_tests.py @@ -8,7 +8,7 @@ from app import app client = TestClient(app) -def test_add_and_retrieve_player(test_client: TestClient): +def test_add_and_retrieve_player(test_client: TestClient, cleanups): response = _json_get(test_client, "/player/1") assert response.status_code == 404 @@ -18,13 +18,14 @@ def test_add_and_retrieve_player(test_client: TestClient): response_1 = _json_get(client, "/player/1") assert response_1.json()["name"] == "jason" - # Cleanup - # TODO - put this in a finally clause (or similar as provided by pytest) - delete_response = _json_delete(client, "/player/1") - assert delete_response.status_code == 204 + def cleanup(): + delete_response = _json_delete(client, "/player/1") + assert delete_response.status_code == 204 + + cleanups.add_success(cleanup) -def test_add_and_retrieve_deck(test_client: TestClient): +def test_add_and_retrieve_deck(test_client: TestClient, cleanups): not_found_response = _json_get(test_client, "/deck/1") assert not_found_response.status_code == 404 @@ -52,11 +53,13 @@ def test_add_and_retrieve_deck(test_client: TestClient): # Very basic HTML testing html_response = test_client.get(f"/deck/{deck_id}") - assert "owned by jim" in html_response.text + assert """owned by jim""" in html_response.text - # Cleanup - delete_response = _json_delete(test_client, f"/deck/{deck_id}") - assert delete_response.status_code == 204 + def success_cleanup(): + delete_response = _json_delete(test_client, f"/deck/{deck_id}") + assert delete_response.status_code == 204 + + cleanups.add_success(success_cleanup) def _json_get(c: TestClient, path: str) -> httpx.Response: