Cosmetic and final-basic functionality
At this point it _should_ be just about usable for folks to poke-around in, though ugly as sin.
This commit is contained in:
parent
ac4bdd09b8
commit
e4ea529fbe
41
NOTES.md
41
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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
]
|
||||
|
@ -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}
|
||||
)
|
||||
|
@ -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}
|
||||
)
|
||||
|
16
app/routers/score.py
Normal file
16
app/routers/score.py
Normal file
@ -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)
|
@ -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(
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
)
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
|
92
app/static/css/base.css
Normal file
92
app/static/css/base.css
Normal file
@ -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;
|
||||
}
|
@ -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('<option val="-1">Select Deck...</option>');
|
||||
actual_select.append('<option value="-1">Select Deck...</option>');
|
||||
for (deck of player_decks) {
|
||||
actual_select.append(`<option val="${deck.id}">${deck.name}</option>`);
|
||||
actual_select.append(`<option value="${deck.id}">${deck.name}</option>`);
|
||||
}
|
||||
// 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(
|
||||
$('<option></option>')
|
||||
.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
|
||||
});
|
||||
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'];
|
||||
}
|
@ -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))
|
||||
|
36
app/templates/about.html
Normal file
36
app/templates/about.html
Normal file
@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}EDH ELO - About{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
This app is a score-tracker for an EDH/Commander group. Initial motivation was to provide
|
||||
<a href="https://en.wikipedia.org/wiki/Elo_rating_system"></a> Elo scoring for the decks, but other planned
|
||||
functionality includes:</p>
|
||||
<ul>
|
||||
<li>Authentication - limiting so that only a given player can create their decks, and allowing admins to edit/delete data</li>
|
||||
<li>Comments on games</li>
|
||||
<li>Integration with external services, including Moxfield and Archidekt to ingest decklists, <a href="https://edhrec.com/top/salt">Salt scores</a>, etc.</li>
|
||||
<li>More-nuanced statistics (for decks and players), including recommendations of which decks would be a good match-up,
|
||||
or which feasible match-ups haven't yet occurred</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
(This is only listing user-facing functionality - there are also plenty of technical or QA considerations that I'd
|
||||
like to add! See the <a href="https://github.com/scubbo/edh-elo/blob/placeholder/NOTES.md">GitHub Repo</a> for more
|
||||
details)
|
||||
</p>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{% endblock %}
|
@ -7,13 +7,32 @@
|
||||
src="https://code.jquery.com/jquery-3.7.1.min.js"
|
||||
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
|
||||
crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="/static/css/base.css"/>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<div id="header_main_anchor">
|
||||
<a id="main_anchor" href="/">EDH ELO</a>
|
||||
</div>
|
||||
<div id="topbar">
|
||||
<a class="topbar_item" href="/game/list">Games</a>
|
||||
<a class="topbar_item" href="/deck/list">Decks</a>
|
||||
<a class="topbar_item" href="/player/list">Players</a>
|
||||
</div>
|
||||
<div id="create_game_button">
|
||||
<a id="create_game_link" href="/game/create">Record New Game</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content">{% block content %}{% endblock %}</div>
|
||||
<div id="footer">
|
||||
{% block footer %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a id="about" href="/about">About</a>
|
||||
<span id="credits">Made with Love and Python by <a href="https://fosstodon.org/@scubbo">Scubbo</a></div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
@ -1,13 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Deck - {{ deck.name }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>This is the page for deck {{ deck.name }} with id {{ deck.id }}, owned by {{ owner.name }}</h2>
|
||||
{% if deck.description %}
|
||||
<p>The description of the deck is: {{ deck.description }}</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Deck - {{ deck.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>This is the page for deck {{ deck.name }} with id {{ deck.id }}, owned by <a href="/player/{{ owner.id }}">{{ owner.name }}</a></h2>
|
||||
|
||||
{% if deck.description %}
|
||||
<p>The description of the deck is: {{ deck.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Game history</h2>
|
||||
{% if game_history %}
|
||||
(TODO - extract a translation-from-deckid-to-names method)
|
||||
(Or...just link them as a relationship/ForeignKey)
|
||||
<table>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Participants</th>
|
||||
<th>Result</th>
|
||||
<th>ELO Score</th>
|
||||
</tr>
|
||||
{% for entry in game_history %}
|
||||
<tr>
|
||||
<td><a href="/game/{{ entry.game.id }}">{{ entry.game.date.strftime('%Y-%m-%d') }}</a></td>
|
||||
<td>
|
||||
{% for participant_id in range(6) %}
|
||||
{% set deck_id = entry.game['deck_id_' ~ (participant_id+1)] %}
|
||||
{% if deck_id is not none %}
|
||||
<a href="/deck/{{ deck_id }}">{{ deck_id }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}</td>
|
||||
<td>{{ "Win" if entry.game.winning_deck_id == deck.id else "Loss" }}</td>
|
||||
<td>{{ entry.score|int }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>This Deck has not played any games</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
@ -1,24 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Decks</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Decks</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Deck Name</th>
|
||||
<th>Owner</th>
|
||||
</tr>
|
||||
{% for deck in decks %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/deck/{{ deck.id }}">{{ deck.name }}</a>
|
||||
</td>
|
||||
<td>{{ deck.owner.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Decks{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Decks</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Deck Name</th>
|
||||
<th>Owner</th>
|
||||
</tr>
|
||||
{% for deck in decks %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/deck/{{ deck.id }}">{{ deck.name }}</a>
|
||||
</td>
|
||||
<td>{{ deck.owner.name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
@ -33,5 +33,22 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<label for="winning_player_id">Winning Player:</label>
|
||||
<select id="winning_player_id" name="winning_player_id">
|
||||
<option disabled selected value> -- select an option -- </option>
|
||||
</select><br/>
|
||||
|
||||
<label for="win_type_id">Win Type:</label>
|
||||
<select id="win_type_id" name="win_type_id">
|
||||
{% for win_type in win_types %}
|
||||
<option value="{{ win_type.id }}">{{ win_type.name }}</option>
|
||||
{% endfor %}
|
||||
</select><br/>
|
||||
<label for="number_of_turns">Number Of Turns:</label><input type="number" name="number_of_turns" id="number_of_turns"/><br/>
|
||||
<label for="first_player_out_turn">Turn First Player Out:</label><input type="number" name="first_player_out_turn" id="first_player_out_turn"/><br/>
|
||||
|
||||
<label for="description">Description:</label>
|
||||
<input type="textarea" id="description" name="description"/>
|
||||
|
||||
<input type="button" id="submit" value="Submit"/>
|
||||
{% endblock %}
|
||||
|
@ -1,13 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Game - {{ game.id }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>This is the page for game with id {{ game.id }}, played on date {{ game.date }}</h2>
|
||||
{% if game.description %}
|
||||
<p>The description of the game is: {{ game.description }}</p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Game - {{ game.id }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>This is the page for game with id {{ game.id }}, played on date {{ game.date.strftime('%Y-%m-%d') }}</h2>
|
||||
<h2>Participants</h2>
|
||||
<ul>
|
||||
{% for deck in game_deck_names %}
|
||||
<li><a href="/deck/{{ deck.id }}">{{ deck.owner }} ({{ deck.name }})</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if game.description %}
|
||||
<p>The description of the game is: {{ game.description }}</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -1,28 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Games</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Games</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Decks</th>
|
||||
<th>Winning Deck</th>
|
||||
</tr>
|
||||
{% for game in games %}
|
||||
<tr>
|
||||
<td><a href="/game/{{ game.id }}">{{ game.date }}</a></td>
|
||||
<td>
|
||||
{{ game_names[game.id] | join(", ") }}
|
||||
</td>
|
||||
<td>
|
||||
{{ decks_by_id[game.winning_deck_id].name }}
|
||||
</td>
|
||||
</tr>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Games{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Games</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Decks</th>
|
||||
<th>Winning Deck</th>
|
||||
</tr>
|
||||
{% for game in games %}
|
||||
<tr>
|
||||
<td><a href="/game/{{ game.id }}">{{ game.date.strftime('%Y-%m-%d') }}</a></td>
|
||||
<td>
|
||||
<ul>
|
||||
{% for deck in game_names[game.id] %}
|
||||
<li><a href="/deck/{{ deck.id }}">{{ deck.owner }} ({{ deck.name }})</a></li>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
{{ decks_by_id[game.winning_deck_id].name }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
@ -3,8 +3,9 @@
|
||||
{% block title %}EDH ELO{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Welcome to EDH ELO!</p>
|
||||
<p>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</p>
|
||||
{% endblock %}
|
@ -1,10 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Player - {{ player.name }}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>This is the page for player {{ player.name }} who has id {{ player.id }}</h2>
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Player - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>This is the page for player {{ player.name }} who has id {{ player.id }}</h2>
|
||||
|
||||
{% if decks %}
|
||||
<h2>Decks</h2>
|
||||
<ul>
|
||||
{% for deck in decks %}
|
||||
<li><a href="/deck/{{ deck.id }}">{{ deck.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
@ -1,20 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Players</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Players</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
{% for player in players %}
|
||||
<tr>
|
||||
<td><a href="/player/{{ player.id }}">{{ player.name }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Players{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Players</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
{% for player in players %}
|
||||
<tr>
|
||||
<td><a href="/player/{{ player.id }}">{{ player.name }}</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
@ -3,6 +3,7 @@
|
||||
{% block title %}Seeding from files{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -32,4 +33,12 @@
|
||||
<input type="submit">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
<hr/>
|
||||
<div>
|
||||
<form action="/api/seed/all_in_one" method="post" enctype="multipart/form-data">
|
||||
<label for="file">Upload All-in-One</label>
|
||||
<input type="file" id="file" name="file" accept=".csv"/>
|
||||
<input type="submit">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
77
seed-data/all-in-one.csv
Normal file
77
seed-data/all-in-one.csv
Normal file
@ -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"
|
|
@ -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!
|
|
@ -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"
|
||||
|
69
tests/sql/test_crud.py
Normal file
69
tests/sql/test_crud.py
Normal file
@ -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
|
@ -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 <a href="/player/1">jim</a>""" 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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user