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:
Jack Jackson 2024-04-22 22:59:18 -07:00
parent ac4bdd09b8
commit e4ea529fbe
32 changed files with 937 additions and 184 deletions

View File

@ -10,35 +10,23 @@
- [X] Swagger API - [X] Swagger API
- [ ] Local development tool to clear/seed database - [ ] Local development tool to clear/seed database
- [X] CRUD APIs for Games - [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.). - 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 - [ ] 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`) - [ ] Authentication (will need to link `user` table to `player`)
... ...
- [ ] Helm chart including an initContainer to create the database if it doesn't exist already - [ ] 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!) - [ ] GroupId (i.e. so I can host other people's data? That's _probably_ a YAGNI - see if there's demand!)
...
- [ ] Comments/chats on Games?
# 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
# Database Migrations # 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)?~~ * ~~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 * Fixed by using FastApi instead of Flask
* How to abstract out the standard-definitions of `routers/*` and `sql/crud.py`? * 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

View File

@ -1,35 +1,36 @@
from typing import Iterable from typing import Iterable, List
K_FACTOR = 10 K_FACTOR = 10.0
BETA = 200 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) expectations = _expectations(ratings)
return [ return [
rating float(rating)
+ (K_FACTOR * ((1 if winning_player_idx == idx else 0) - expectations[idx])) + (K_FACTOR * ((1.0 if winning_player_idx == idx else 0.0) - expectations[idx]))
for idx, rating in enumerate(ratings) for idx, rating in enumerate(ratings)
] ]
def _expectations(ratings: Iterable[int]) -> Iterable[int]: def _expectations(ratings: List[float]) -> List[float]:
return [ return [
_calculate_expectation(rating, ratings[:idx] + ratings[idx + 1 :]) _calculate_expectation(rating, ratings[:idx] + ratings[idx + 1 :])
for idx, rating in enumerate(ratings) 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( return sum(
[_pairwise_expectation(rating, other_rating) for other_rating in other_ratings] [_pairwise_expectation(rating, other_rating) for other_rating in other_ratings]
) / (float(len(other_ratings) + 1) * len(other_ratings) / 2) ) / (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` Gives the expected score of `rating` against `other_rating`
""" """
diff = float(other_rating) - float(rating) diff = float(other_rating) - float(rating)
f_factor = 2 * BETA # rating disparity 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

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import base, decks, games, players, seed from . import base, decks, games, players, score, seed
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
html_router = APIRouter() html_router = APIRouter()
@ -8,6 +8,7 @@ html_router = APIRouter()
api_router.include_router(decks.api_router) api_router.include_router(decks.api_router)
api_router.include_router(players.api_router) api_router.include_router(players.api_router)
api_router.include_router(games.api_router) api_router.include_router(games.api_router)
api_router.include_router(score.api_router)
api_router.include_router(seed.api_router) api_router.include_router(seed.api_router)
html_router.include_router(decks.html_router) html_router.include_router(decks.html_router)

View File

@ -14,3 +14,8 @@ def main(request: Request, db=Depends(get_db)):
return jinja_templates.TemplateResponse( return jinja_templates.TemplateResponse(
request, "/main.html", {"games": _jsonify(games)} 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")

View File

@ -1,8 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session 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 import crud, schemas
from ..sql.database import get_db from ..sql.database import get_db
@ -75,8 +78,43 @@ def decks_html(request: Request, db=Depends(get_db)):
@html_router.get("/{deck_id}") @html_router.get("/{deck_id}")
def deck_html(request: Request, deck_id: str, db=Depends(get_db)): def deck_html(request: Request, deck_id: str, db=Depends(get_db)):
deck_info = read_deck(deck_id, db) deck_info = read_deck(deck_id, db)
deck_score_history = _build_deck_score_history(deck_id, db)
return jinja_templates.TemplateResponse( return jinja_templates.TemplateResponse(
request, request,
"decks/detail.html", "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
]

View File

@ -1,4 +1,5 @@
import json import json
import logging
from functional import seq from functional import seq
from typing import List, Mapping from typing import List, Mapping
@ -10,15 +11,18 @@ from sqlalchemy.orm import Session
from app.routers.decks import list_decks from app.routers.decks import list_decks
from app.sql import models from app.sql import models
from .players import list_players from .players import list_players
from ..templates import jinja_templates from ..elo import rerank
from ..sql import crud, schemas from ..sql import crud, schemas
from ..sql.database import get_db from ..sql.database import get_db
from ..templates import jinja_templates
api_router = APIRouter(prefix="/game", tags=["game"]) api_router = APIRouter(prefix="/game", tags=["game"])
html_router = APIRouter( html_router = APIRouter(
prefix="/game", include_in_schema=False, default_response_class=HTMLResponse prefix="/game", include_in_schema=False, default_response_class=HTMLResponse
) )
LOGGER = logging.getLogger(__name__)
######## ########
# API Routes # API Routes
######## ########
@ -26,7 +30,36 @@ html_router = APIRouter(
@api_router.post("/", response_model=schemas.Game, status_code=201) @api_router.post("/", response_model=schemas.Game, status_code=201)
def create_game(game: schemas.GameCreate, db: Session = Depends(get_db)): 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]) @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) @html_router.get("/create", response_class=HTMLResponse)
def game_create_html(request: Request, db=Depends(get_db)): def game_create_html(request: Request, db=Depends(get_db)):
players = list_players(db=db) players = list_players(db=db)
win_types = db.query(models.WinType).all()
return jinja_templates.TemplateResponse( return jinja_templates.TemplateResponse(
request, request,
"games/create.html", "games/create.html",
@ -72,6 +106,7 @@ def game_create_html(request: Request, db=Depends(get_db)):
for player in players 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") @html_router.get("/list")
def games_html(request: Request, db=Depends(get_db)): def games_html(request: Request, db=Depends(get_db)):
games = list_games(db=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} 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} game_names = {game.id: _build_game_deck_names(game, decks_by_id) for game in games}
return jinja_templates.TemplateResponse( return jinja_templates.TemplateResponse(
@ -100,14 +137,19 @@ def _build_game_deck_names(
.map(lambda key: getattr(game, key)) .map(lambda key: getattr(game, key))
.filter(lambda x: x) .filter(lambda x: x)
.map(lambda deck_id: decks_by_id[deck_id]) .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 # This must be after the static-path routes, lest it take priority over them
@html_router.get("/{game_id}") @html_router.get("/{game_id}")
def game_html(request: Request, game_id: str, db=Depends(get_db)): 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( return jinja_templates.TemplateResponse(
request, "games/detail.html", {"game": game_info} request, "games/detail.html", {"game": game, "game_deck_names": game_deck_names}
) )

View File

@ -2,6 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.sql import models
from ..templates import jinja_templates from ..templates import jinja_templates
from ..sql import crud, schemas from ..sql import crud, schemas
from ..sql.database import get_db 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}") @html_router.get("/{player_id}")
def player_html(request: Request, player_id: str, db=Depends(get_db)): def player_html(request: Request, player_id: str, db=Depends(get_db)):
player_info = read_player(player_id, 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( 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
View 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)

View File

@ -1,13 +1,19 @@
import csv import csv
import datetime import datetime
import logging import logging
from collections import defaultdict
from fastapi import APIRouter, Depends, Request, UploadFile from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .games import create_game
from ..templates import jinja_templates from ..templates import jinja_templates
from ..sql import crud, schemas from ..sql import crud, schemas
from ..sql.database import get_db from ..sql.database import get_db
from ..sql.models import Format, WinType
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -78,6 +84,103 @@ def seed_games(file: UploadFile, db: Session = Depends(get_db)):
return "OK!" 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("/") @html_router.get("/")
def main(request: Request, db=Depends(get_db)): def main(request: Request, db=Depends(get_db)):
return jinja_templates.TemplateResponse( return jinja_templates.TemplateResponse(

View File

@ -6,10 +6,17 @@ def prime_database():
db = SessionLocal() db = SessionLocal()
win_types = db.query(models.WinType).all() win_types = db.query(models.WinType).all()
if not win_types: if not win_types:
db.add(models.WinType(name="Combat Damage")) db.add(models.WinType(name="combat damage"))
db.add(models.WinType(name="Commander Damage")) db.add(models.WinType(name="21+ commander"))
db.add(models.WinType(name="Direct Damage")) db.add(models.WinType(name="aristocrats/burn"))
db.add(models.WinType(name="Poison")) db.add(models.WinType(name="poison"))
db.add(models.WinType(name="Decking")) db.add(models.WinType(name="quality of life concede"))
db.add(models.WinType(name="other")) 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() db.commit()

View File

@ -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.query(models.Game).filter(models.Game.id == game_id).delete()
db.commit() db.commit()
return "", 204 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()
)

View File

@ -1,5 +1,7 @@
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String from typing import List
from sqlalchemy.orm import relationship
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import relationship, Mapped
from .database import Base from .database import Base
@ -25,7 +27,14 @@ class Deck(Base):
class WinType(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) id = Column(Integer, primary_key=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
@ -45,5 +54,17 @@ class Game(Base):
winning_deck_id = Column(Integer, ForeignKey("decks.id"), nullable=False) winning_deck_id = Column(Integer, ForeignKey("decks.id"), nullable=False)
number_of_turns = Column(Integer, nullable=False) number_of_turns = Column(Integer, nullable=False)
first_player_out_turn = 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) 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))

View File

@ -59,6 +59,7 @@ class GameBase(BaseModel):
number_of_turns: int number_of_turns: int
first_player_out_turn: int first_player_out_turn: int
win_type_id: int win_type_id: int
format_id: int
description: str description: str
@ -70,3 +71,12 @@ class Game(GameBase):
id: int id: int
model_config = {"from_attributes": True} 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
View 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;
}

View File

@ -21,6 +21,7 @@ function change_player(eventData) {
console.log(deck_select); console.log(deck_select);
const target_val = parseInt(target.val()); const target_val = parseInt(target.val());
// Populate the per-player deck-choices
if (target_val == -1) { if (target_val == -1) {
deck_select.hide(); deck_select.hide();
} else { } else {
@ -40,28 +41,70 @@ function change_player(eventData) {
player_decks = player_deck_data[target_val.toString()]; player_decks = player_deck_data[target_val.toString()];
actual_select = $(deck_select[0]); actual_select = $(deck_select[0]);
actual_select.empty(); 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) { 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 // Just in case it's been previously hidden
actual_select.show(); actual_select.show();
} }
}
function initialize_dropdowns() { // Update the "winning player" dropdown
console.log('TODO - initialize dropdowns'); $('#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() { $(document).ready(function() {
$('#number_of_players').on("change", change_num_players) $('#number_of_players').on("change", change_num_players)
$('.player_select').on("change", change_player) $('.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() $.ajax({
// TODO - initialize dropdowns 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: function getDeckForPlayerId(player_id) {
// * Check that Players are unique 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'];
}

View File

@ -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) # 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.templating import Jinja2Templates
from fastapi.encoders import jsonable_encoder
jinja_templates = Jinja2Templates(directory="app/templates") 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) # (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) # (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): def _jsonify(o):
if hasattr(o, "__dict__"): return dumps(jsonable_encoder(o))
return {k: v for (k, v) in o.__dict__.items() if k != "_sa_instance_state"}
else:
return [_jsonify(e) for e in o]

36
app/templates/about.html Normal file
View 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 %}

View File

@ -7,13 +7,32 @@
src="https://code.jquery.com/jquery-3.7.1.min.js" src="https://code.jquery.com/jquery-3.7.1.min.js"
integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo="
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<link rel="stylesheet" href="/static/css/base.css"/>
{% endblock %} {% endblock %}
</head> </head>
<body> <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="content">{% block content %}{% endblock %}</div>
<div id="footer"> <div id="footer">
{% block footer %} {% block footer %}
{% endblock %} {% endblock %}
</div> </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> </body>
</html> </html>

View File

@ -1,13 +1,45 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Deck - {{ deck.name }}{% endblock %}
<meta charset="UTF-8">
<title>Deck - {{ deck.name }}</title> {% block head %}
</head> {{ super() }}
<body> {% endblock %}
<h2>This is the page for deck {{ deck.name }} with id {{ deck.id }}, owned by {{ owner.name }}</h2>
{% if deck.description %} {% block content %}
<p>The description of the deck is: {{ deck.description }}</p> <h2>This is the page for deck {{ deck.name }} with id {{ deck.id }}, owned by <a href="/player/{{ owner.id }}">{{ owner.name }}</a></h2>
{% endif %}
</body> {% if deck.description %}
</html> <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 %}

View File

@ -1,24 +1,25 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Decks{% endblock %}
<meta charset="UTF-8">
<title>Decks</title> {% block head %}
</head> {{ super() }}
<body> {% endblock %}
<h1>Decks</h1>
<table> {% block content %}
<tr> <h1>Decks</h1>
<th>Deck Name</th> <table>
<th>Owner</th> <tr>
</tr> <th>Deck Name</th>
{% for deck in decks %} <th>Owner</th>
<tr> </tr>
<td> {% for deck in decks %}
<a href="/deck/{{ deck.id }}">{{ deck.name }}</a> <tr>
</td> <td>
<td>{{ deck.owner.name }}</td> <a href="/deck/{{ deck.id }}">{{ deck.name }}</a>
</tr> </td>
{% endfor %} <td>{{ deck.owner.name }}</td>
</table> </tr>
</body> {% endfor %}
</html> </table>
{% endblock %}

View File

@ -33,5 +33,22 @@
</div> </div>
{% endfor %} {% 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"/> <input type="button" id="submit" value="Submit"/>
{% endblock %} {% endblock %}

View File

@ -1,13 +1,20 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Game - {{ game.id }}{% endblock %}
<meta charset="UTF-8">
<title>Game - {{ game.id }}</title> {% block head %}
</head> {{ super() }}
<body> {% endblock %}
<h2>This is the page for game with id {{ game.id }}, played on date {{ game.date }}</h2>
{% if game.description %} {% block content %}
<p>The description of the game is: {{ game.description }}</p> <h2>This is the page for game with id {{ game.id }}, played on date {{ game.date.strftime('%Y-%m-%d') }}</h2>
{% endif %} <h2>Participants</h2>
</body> <ul>
</html> {% 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 %}

View File

@ -1,28 +1,33 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Games{% endblock %}
<meta charset="UTF-8">
<title>Games</title> {% block head %}
</head> {{ super() }}
<body> {% endblock %}
<h1>Games</h1>
<table> {% block content %}
<tr> <h1>Games</h1>
<th>Date</th> <table>
<th>Decks</th> <tr>
<th>Winning Deck</th> <th>Date</th>
</tr> <th>Decks</th>
{% for game in games %} <th>Winning Deck</th>
<tr> </tr>
<td><a href="/game/{{ game.id }}">{{ game.date }}</a></td> {% for game in games %}
<td> <tr>
{{ game_names[game.id] | join(", ") }} <td><a href="/game/{{ game.id }}">{{ game.date.strftime('%Y-%m-%d') }}</a></td>
</td> <td>
<td> <ul>
{{ decks_by_id[game.winning_deck_id].name }} {% for deck in game_names[game.id] %}
</td> <li><a href="/deck/{{ deck.id }}">{{ deck.owner }} ({{ deck.name }})</a></li>
</tr>
{% endfor %} {% endfor %}
</table> </ul>
</body> </td>
</html> <td>
{{ decks_by_id[game.winning_deck_id].name }}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}

View File

@ -3,8 +3,9 @@
{% block title %}EDH ELO{% endblock %} {% block title %}EDH ELO{% endblock %}
{% block head %} {% block head %}
{{ super() }}
{% endblock %} {% endblock %}
{% block content %} {% 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 %} {% endblock %}

View File

@ -1,10 +1,20 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Player - {{ player.name }}{% endblock %}
<meta charset="UTF-8">
<title>Player - {{ player.name }}</title> {% block head %}
</head> {{ super() }}
<body> {% endblock %}
<h2>This is the page for player {{ player.name }} who has id {{ player.id }}</h2>
</body> {% block content %}
</html> <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 %}

View File

@ -1,20 +1,21 @@
<!DOCTYPE html> {% extends "base.html" %}
<html lang="en">
<head> {% block title %}Players{% endblock %}
<meta charset="UTF-8">
<title>Players</title> {% block head %}
</head> {{ super() }}
<body> {% endblock %}
<h1>Players</h1>
<table> {% block content %}
<tr> <h1>Players</h1>
<th>Name</th> <table>
</tr> <tr>
{% for player in players %} <th>Name</th>
<tr> </tr>
<td><a href="/player/{{ player.id }}">{{ player.name }}</a></td> {% for player in players %}
</tr> <tr>
{% endfor %} <td><a href="/player/{{ player.id }}">{{ player.name }}</a></td>
</table> </tr>
</body> {% endfor %}
</html> </table>
{% endblock %}

View File

@ -3,6 +3,7 @@
{% block title %}Seeding from files{% endblock %} {% block title %}Seeding from files{% endblock %}
{% block head %} {% block head %}
{{ super() }}
{% endblock %} {% endblock %}
@ -32,4 +33,12 @@
<input type="submit">Upload</button> <input type="submit">Upload</button>
</form> </form>
</div> </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 %} {% endblock %}

77
seed-data/all-in-one.csv Normal file
View 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 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
2 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
3 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!
4 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
5 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)
6 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
7 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
8 1/23/24 Ryan Goose Mother Patrick Maarika Terence Wilson/Cultist Patrick Maarika 8 7 21+ commander FFA Maarika + Runes of the Deus
9 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
10 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
11 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
12 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)
13 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
14 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
15 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
16 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
17 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
18 2/20/24 Terence Wilson/Cultist Ryan Gitrog Patrick Tekuthal Terence Wilson/Cultist 15 12 combat damage FFA Weatherlight and Ormendahl collect many tithes
19 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)
20 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!
21 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
22 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
23 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
24 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
25 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
26 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
27 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)
28 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)
29 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)
30 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
31 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
32 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
33 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
34 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
35 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)
36 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
37 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
38 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
39 4/10/24 Terence Ishai/Tana Ryan Don Andres Patrick Tekuthal Terence Ishai/Tana 9 9 combat damage FFA Assemble the Legion + Felidar Retreat
40 4/10/24 Patrick Tekuthal Terence Ishai/Tana Ryan Don Andres Patrick Tekuthal 10 10 poison FFA Don Andres cast Eternal Dominion
41 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
42 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
43 4/16/24 Terence Kefnet the Mindful Patrick Dihada Ryan Emiel Terence Kefnet the Mindful 13 12 21+ commander FFA Kefnet w/ Empyrial Plate
44 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
45 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
46 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
47 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
48 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
49 4/23/24 Patrick Ashad Terence Phabine Ryan Zaxara Terence Phabine 8 7 combat damage FFA Adeline, Port Razer, Halana and Alena into Phabine
50 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)
51 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
52 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
53 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
54 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
55 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
56 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!)
57 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
58 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)
59 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
60 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
61 5/15/24 Patrick Niv-Mizzet Terence Emry Ryan Omnath Ryan Omnath 8 8 aristocrats/burn FFA Nissa + Ancient Greenwarden into Crackle With Power
62 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
63 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
64 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
65 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
66 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
67 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
68 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
69 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
70 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
71 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
72 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+
73 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
74 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
75 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
76 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
77 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

View File

@ -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 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 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!
1 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
2 1 2024-01-13 1 2 3 4 2 15 12 1 3-4 board wipes; Biotransference
3 2 2024-01-13 5 3 23 4 6 3 12 10 1 Possessed Portal scary!

View File

@ -2,9 +2,57 @@ import os
import pathlib import pathlib
import pytest import pytest
from typing import Callable
from fastapi.testclient import TestClient 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(): def prime_database():
# Start afresh! # Start afresh!
database_dir = "database" database_dir = "database"

69
tests/sql/test_crud.py Normal file
View 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

View File

@ -8,7 +8,7 @@ from app import app
client = TestClient(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") response = _json_get(test_client, "/player/1")
assert response.status_code == 404 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") response_1 = _json_get(client, "/player/1")
assert response_1.json()["name"] == "jason" assert response_1.json()["name"] == "jason"
# Cleanup def cleanup():
# TODO - put this in a finally clause (or similar as provided by pytest) delete_response = _json_delete(client, "/player/1")
delete_response = _json_delete(client, "/player/1") assert delete_response.status_code == 204
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") not_found_response = _json_get(test_client, "/deck/1")
assert not_found_response.status_code == 404 assert not_found_response.status_code == 404
@ -52,11 +53,13 @@ def test_add_and_retrieve_deck(test_client: TestClient):
# Very basic HTML testing # Very basic HTML testing
html_response = test_client.get(f"/deck/{deck_id}") 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 def success_cleanup():
delete_response = _json_delete(test_client, f"/deck/{deck_id}") delete_response = _json_delete(test_client, f"/deck/{deck_id}")
assert delete_response.status_code == 204 assert delete_response.status_code == 204
cleanups.add_success(success_cleanup)
def _json_get(c: TestClient, path: str) -> httpx.Response: def _json_get(c: TestClient, path: str) -> httpx.Response: