3cb-data

Unnamed repository; edit this file 'description' to name the repository.
git clone git://git.alexwennerberg.com/3cb-data.git
Log | Files | Refs | README | LICENSE

commit ff630ec7a1242d8de71981fd6818defb8f873f22
parent 40b757f3d9a9f5a36ec245a76b78ad53556c9dd7
Author: Alex Wennerberg <alex@Alexs-MacBook-Air.local>
Date:   Wed, 17 Dec 2025 15:17:51 -0800

App revamp

Diffstat:
M.gitignore | 1+
MREADME.md | 3---
Mapp.py | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mgetdata.py | 223+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mrequirements.txt | 8++++++--
Mschema.sql | 34++++++++++++++++++++--------------
Mtemplates/header.html | 1+
Mtemplates/matches.html | 49+++++++++++++++++++++++++++----------------------
8 files changed, 285 insertions(+), 165 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,3 +1,4 @@ +*.swp *.json allcards.txt *.db diff --git a/README.md b/README.md @@ -5,10 +5,7 @@ Utilities for pulling data from 3 card blind metashape: https://sites.google.com/view/3cb-metashape/home -(Working on Ruby rewrite) - Pull all cards from mtgjson https://mtgjson.com/downloads/all-files/ - wget https://mtgjson.com/api/v5/AtomicCards.json cat AtomicCards.json | jq -r '.data | keys[]' > allcards.txt diff --git a/app.py b/app.py @@ -1,14 +1,46 @@ -from flask import Flask, render_template, request +from flask import Flask, render_template, request, g import sqlite3 from urllib.parse import quote as url_quote +import time +import logging app = Flask(__name__) +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +@app.before_request +def before_request(): + g.start_time = time.time() + +@app.after_request +def after_request(response): + if hasattr(g, 'start_time'): + duration_ms = (time.time() - g.start_time) * 1000 + logger.info(f"{request.method} {request.path} - {response.status_code} - {duration_ms:.2f}ms") + return response + def get_db(): db = sqlite3.connect('3cb.db') db.row_factory = sqlite3.Row + db.set_trace_callback(lambda stmt: logger.debug(f"SQLite: {stmt}")) return db +def execute_query(db, query, params=()): + """Execute a query with logging of query text and execution time.""" + start_time = time.time() + result = db.execute(query, params) + duration_ms = (time.time() - start_time) * 1000 + + # Format query for logging (remove extra whitespace/newlines) + formatted_query = ' '.join(query.split()) + logger.info(f"SQL Query ({duration_ms:.2f}ms): {formatted_query} | Params: {params}") + + return result + @app.route('/') def index(): db = get_db() @@ -21,7 +53,18 @@ def index(): def round_detail(id): db = get_db() round_data = db.execute("select * from round where id = ?", (id,)).fetchone() - matches = db.execute("select * from round_score where round = ? order by final_score desc,prelim_score desc", (id,)).fetchall() + matches = db.execute(""" + select rs.*, + m1.cost as card1_cost, m1.card_type as card1_type, + m2.cost as card2_cost, m2.card_type as card2_type, + m3.cost as card3_cost, m3.card_type as card3_type + from round_score rs + left join mtg m1 on rs.card1 = m1.name + left join mtg m2 on rs.card2 = m2.name + left join mtg m3 on rs.card3 = m3.name + where round = ? + order by final_score desc, prelim_score desc + """, (id,)).fetchall() banned_cards = [row["name"] for row in db.execute("select name from ban").fetchall()] db.close() subtitle = "round " + str(id) @@ -31,7 +74,18 @@ def round_detail(id): def card(): name = request.args.get('name') db = get_db() - matches = db.execute("select * from round_score where card1 = ?1 or card2 = ?1 or card3 = ?1 order by round desc", (name,)).fetchall() + matches = db.execute(""" + select rs.*, + m1.cost as card1_cost, m1.card_type as card1_type, + m2.cost as card2_cost, m2.card_type as card2_type, + m3.cost as card3_cost, m3.card_type as card3_type + from round_score rs + left join mtg m1 on rs.card1 = m1.name + left join mtg m2 on rs.card2 = m2.name + left join mtg m3 on rs.card3 = m3.name + where card1 = ?1 or card2 = ?1 or card3 = ?1 + order by round desc + """, (name,)).fetchall() banned_cards = [row["name"] for row in db.execute("select name from ban").fetchall()] db.close() subtitle = name @@ -41,7 +95,18 @@ def card(): def player(): name = request.args.get('name') db = get_db() - matches = db.execute("select * from round_score where player = ? order by round desc", (name,)).fetchall() + matches = db.execute(""" + select rs.*, + m1.cost as card1_cost, m1.card_type as card1_type, + m2.cost as card2_cost, m2.card_type as card2_type, + m3.cost as card3_cost, m3.card_type as card3_type + from round_score rs + left join mtg m1 on rs.card1 = m1.name + left join mtg m2 on rs.card2 = m2.name + left join mtg m3 on rs.card3 = m3.name + where player = ? + order by round desc + """, (name,)).fetchall() banned_cards = [row["name"] for row in db.execute("select name from ban").fetchall()] db.close() subtitle = "player " + name @@ -52,14 +117,23 @@ def deck(): cards = sorted([request.args.get('c1'), request.args.get('c2'), request.args.get('c3')]) db = get_db() matches = db.execute(""" - select * from round_score where - card1 = ?1 and card2 = ?2 and card3 = ?3 or - card1 = ?1 and card2 = ?3 and card3 = ?2 or - card1 = ?2 and card2 = ?1 and card3 = ?3 or - card1 = ?2 and card2 = ?3 and card3 = ?1 or - card1 = ?3 and card2 = ?1 and card3 = ?2 or - card1 = ?3 and card2 = ?2 and card3 = ?1 - order by round desc""", (cards[0], cards[1], cards[2])).fetchall() + select rs.*, + m1.cost as card1_cost, m1.card_type as card1_type, + m2.cost as card2_cost, m2.card_type as card2_type, + m3.cost as card3_cost, m3.card_type as card3_type + from round_score rs + left join mtg m1 on rs.card1 = m1.name + left join mtg m2 on rs.card2 = m2.name + left join mtg m3 on rs.card3 = m3.name + where + card1 = ?1 and card2 = ?2 and card3 = ?3 or + card1 = ?1 and card2 = ?3 and card3 = ?2 or + card1 = ?2 and card2 = ?1 and card3 = ?3 or + card1 = ?2 and card2 = ?3 and card3 = ?1 or + card1 = ?3 and card2 = ?1 and card3 = ?2 or + card1 = ?3 and card2 = ?2 and card3 = ?1 + order by round desc + """, (cards[0], cards[1], cards[2])).fetchall() banned_cards = [row["name"] for row in db.execute("select name from ban").fetchall()] db.close() subtitle = "; ".join(cards) @@ -112,5 +186,38 @@ def cards(): def url_encode_filter(s): return url_quote(str(s)) +# Template filter for mana symbols using Mana font +@app.template_filter('mana_symbols') +def mana_symbols_filter(s): + if not s or s == 'N/A': + return s + + import re + + # Parse mana symbols like {W}, {U}, {1}, {2/U}, etc. + def replace_symbol(match): + symbol = match.group(1).lower() + + # Handle hybrid mana (e.g., {W/U}, {2/W}) + if '/' in symbol: + parts = symbol.split('/') + # Check if first part is a number (e.g., 2/W for hybrid) + if parts[0].isdigit(): + return f'<i class="ms ms-{parts[1]} ms-cost ms-shadow"></i>' + else: + return f'<i class="ms ms-{parts[0]}{parts[1]} ms-cost ms-shadow"></i>' + # Handle Phyrexian mana (e.g., {W/P}) + elif 'p' in symbol: + color = symbol.replace('/p', '').replace('p', '') + return f'<i class="ms ms-{color} ms-p ms-cost ms-shadow"></i>' + # Regular mana + else: + return f'<i class="ms ms-{symbol} ms-cost ms-shadow"></i>' + + # Replace all mana symbols + result = re.sub(r'\{([^}]+)\}', replace_symbol, s) + + return result + if __name__ == '__main__': app.run(debug=False) diff --git a/getdata.py b/getdata.py @@ -1,26 +1,21 @@ # set GOOG_KEY env variable to a valid api key # all rounds available at https://sites.google.com/view/3cb-metashape/pairings-results/past-results?authuser=0 -# depends on requests and stdlib -# writes to 3cmdata.csv - -import requests, os, csv, re, io, sqlite3, titlecase, string, openpyxl, urllib +import requests, os, csv, re, io, sqlite3, titlecase, string, openpyxl, urllib, json +from datetime import datetime, timedelta from Levenshtein import distance from bs4 import BeautifulSoup con = sqlite3.connect("3cb.db") -allcards = set(open('allcards.txt','r').read().splitlines()) - -# cache -def fileids_in_db(): - return [a[0] for a in con.cursor().execute("select fileid from round").fetchall()] +con.set_trace_callback(print) +allcards= [a[0] for a in con.cursor().execute("select name from mtg").fetchall()] def bans_from_db(): return [a[0] for a in con.cursor().execute("select name from bans").fetchall()] -def main(): - cache = fileids_in_db() +def run_rounds(): + file_ids_in_db = [a[0] for a in con.cursor().execute("select fileid from round").fetchall()] for n, file in enumerate(get_round_fileids()): - if file in cache: + if file in file_ids_in_db: continue if file not in cache and file != "1LGHvTrQz2zhBjz1PhlW61ZYmwRWJyWkEHj-png7ZKdw": # misc file print(f"analyzing round {n+1}...") @@ -58,7 +53,6 @@ def get_file_created_date(fileid): data = res.json() if "createdTime" in data: # Parse ISO format and return YYYY-MM-DD - from datetime import datetime created_time = datetime.fromisoformat(data["createdTime"].replace('Z', '+00:00')) return created_time.strftime('%Y-%m-%d') return None @@ -82,34 +76,10 @@ def update_bans(): cur.execute("insert into ban values (?)", (clean_card(card),)) con.commit() -# not really normalized well -schema = [""" -create table if not exists round ( - id integer primary key, - fileid text not null, - date text -); -""", -""" -create table if not exists deck ( - round integer not null, - player varchar not null, - card1 text, - card2 text, - card3 text -);""",""" -create table if not exists match ( - round integer, - group_name text, - player text, - opp_player text, - score integer -);""",""" -create view if not exists card as - select round,player,card1 as card from deck - union select round,player,card2 as card from deck - union select round,player,card3 as card from deck; -"""] +def load_schema(): + with open('schema.sql', 'r') as f: + schema_sql = f.read() + con.executescript(schema_sql) def save_sheet(cur, sheet, n, final): sheetrows = list(sheet.rows) @@ -185,86 +155,115 @@ def best_guess(card): return card -# data cleaning -def replacement(card): - m = { - "Urborg Tomb Yawgy": "Urborg, Tomb of Yawgmoth", - "Urborg (The One That Makes All Lands Swamps)": "Urborg, Tomb of Yawgmoth", - "Ulamog the Infinite Gyre (Borderless) (Foil)": "Ulamog, the Infinite Gyre", - "That One Wurm That Makes the Three 5/5s When It Dies I Have Done Too Many Scryfall Searches Today Sorry": "TBD", - "Thallid Oh Yeah": "Thallid", - "Tabernacle at Penrall Vale": "The Tabernacle at Pendrell Value", - "Swamp (XLN 270)": "Swamp", - "Swamp (BRB 18)": "Swamp", - "Swamp (8ED #339)": "Swamp", - "Speaker of the Heavens!?!?!?!": "Speaker of the Heavens", - "Restore Balanse (I Have to Misspell This Cuz the Regex Is Buggy Lmao)": "Restore Balance", - "Red Sun's Zenith (Again)": "Red Sun's Zenith", - "Plains (ONS 333)": "Plains", - "Phelps, Exuberant Swimmer (Phelia)": "Phelia, Exuberant Shepherd", - "Phelia, Tail-Wagging Shepherd": "Phelia, Exuberant Shepherd", - "Nesumi Shortfang": "Nezumi Shortfang // Stabwhisker the Odious", - "Mox Pearl (The One Without Metalcraft)": "Mox Pearl", - "Monty Python and the Holy Grail Black Knight (Oathsworn Knight)": "Oathsworn Knight", - "Mayor of Avarbruck": "Mayor of Avabruck // Howlpack Alpha", - "Lion's Eye Diamond Cheatyface": "Lion's Eye Diamond", - "Karaka (Its Listed as Unbanned but the Form Wont Let Me Submit It Idk Its My First Time Lol)": "Karakas", - "Island [KLD #395]": "Island", - "Ink Mothy Nexy": "Inkmoth Nexus", - "Gavel of the Righteous, for I Am Stubborn and Naively Hopeful": "Gavel of the Righteous", - "Gargadon (Neither Greater Nor Lesser)": "Gargadon", - "Forge-Chan": "Chancellor of the Forge", - "Forest!!!!!!": "Forest", - "Forest 🌲": "Forest", - "Forest (Tempest #348)": "Forest", - "Filigree Sylex": "The Filigree Sylex", - "Elspeth Suns Champion Wooooooo": "Elspeth, Sun's Champion", - "Dwarven Hold (Again)": "Dwarven Hold", - "Dreams of Oil and Steel": "Dreams of Steel and Oil", - "Dark Ritual (STA #89)": "Dark Ritual", - "Dark Ritual (BTD 21)": "Dark Ritual", - "Chronomatonton (The 1 Cost 1/1 That Taps to Get Bigger)": "Chronomaton", - "Chancelor of the Tangle (Sic)": "Chancellor of the Tangle", - "Bottomless Depths": "Bottomless Vault", - "Boseiju, Who Destroys Target Artifact, Enchantment, or Nonbasic Land (Who Endures)": "Boseiju, Who Endures", - "Bayou - Not Legal": "Bayou", - "Basic Plains": "Plains", - "Annex-Chan": "Chancellor of the Annex", - "Annex Chan": "Chancellor of the Annex", - "Azorius Chancery First": "Azorius Chancery", - "Azorius Guildgate Anniversary": "Azorius Guildgate", - "Chaplain of Arms": "Chaplain of Alms // Chapel Shieldgeist", - "Fizik, Etherium Mechanic": "Fizik, Etherium Mechanic", - "Forest [LCI #402]": "Forest", - "Inky Mothy Nexy": "Inkmoth Nexus", - "Island (ONE #273)": "Island", - "Kytheon Hero of Akros": "Kytheon, Hero of Akros // Gideon, Battle-Forged", - "Magus of the Mooooooooon": "Magus of the Moon", - "Miku, Divine Diva": "Elspeth Tirel", - "Senu, the Keen-Eyed": "Senu, Keen-Eyed Protector", - "Signaling Roar": "Riling Dawnbreaker // Signaling Roar", - "Simispiriguide": "Simian Spirit Guide", - "Swamp (DOM #258)": "Swamp", - "Witch-Blessed Meadow": "Witch's Cottage", - "Yera and Oski, Weaver and Guide": "Yera and Oski, Weaver and Guide", - "You Guessed It, Magus of the Moooon": "Magus of the Moon", - } - if card in m: - return m[card] - return card +replacement = { + "You Guessed It, Magus of the Moooon": "Magus of the Moon", + "Yera and Oski, Weaver and Guide": "Yera and Oski, Weaver and Guide", + "Yera and Oski, Weaver and Guide": "Arachne, Psionic Weaver", + "Witch-Blessed Meadow": "Witch's Cottage", + "Urborg Tomb Yawgy": "Urborg, Tomb of Yawgmoth", + "Urborg (The One That Makes All Lands Swamps)": "Urborg, Tomb of Yawgmoth", + "Ulamog the Infinite Gyre (Borderless) (Foil)": "Ulamog, the Infinite Gyre", + "That One Wurm That Makes the Three 5/5s When It Dies I Have Done Too Many Scryfall Searches Today Sorry": "TBD", + "Thallid Oh Yeah": "Thallid", + "Tabernacle at Penrall Vale": "The Tabernacle at Pendrell Value", + "Swamp (XLN 270)": "Swamp", + "Swamp (DOM #258)": "Swamp", + "Swamp (BRB 18)": "Swamp", + "Swamp (8ED #339)": "Swamp", + "Speaker of the Heavens!?!?!?!": "Speaker of the Heavens", + "Simispiriguide": "Simian Spirit Guide", + "Signaling Roar": "Riling Dawnbreaker // Signaling Roar", + "Senu, the Keen-Eyed": "Senu, Keen-Eyed Protector", + "Restore Balanse (I Have to Misspell This Cuz the Regex Is Buggy Lmao)": "Restore Balance", + "Red Sun's Zenith (Again)": "Red Sun's Zenith", + "Plains (ONS 333)": "Plains", + "Phelps, Exuberant Swimmer (Phelia)": "Phelia, Exuberant Shepherd", + "Phelia, Tail-Wagging Shepherd": "Phelia, Exuberant Shepherd", + "Nesumi Shortfang": "Nezumi Shortfang // Stabwhisker the Odious", + "Mox Pearl (The One Without Metalcraft)": "Mox Pearl", + "Monty Python and the Holy Grail Black Knight (Oathsworn Knight)": "Oathsworn Knight", + "Miku, Divine Diva": "Elspeth Tirel", + "Mayor of Avarbruck": "Mayor of Avabruck // Howlpack Alpha", + "Magus of the Mooooooooon": "Magus of the Moon", + "Lion's Eye Diamond Cheatyface": "Lion's Eye Diamond", + "Kytheon Hero of Akros": "Kytheon, Hero of Akros // Gideon, Battle-Forged", + "Karaka (Its Listed as Unbanned but the Form Wont Let Me Submit It Idk Its My First Time Lol)": "Karakas", + "Island [KLD #395]": "Island", + "Island (ONE #273)": "Island", + "Inky Mothy Nexy": "Inkmoth Nexus", + "Ink Mothy Nexy": "Inkmoth Nexus", + "Gavel of the Righteous, for I Am Stubborn and Naively Hopeful": "Gavel of the Righteous", + "Gargadon (Neither Greater Nor Lesser)": "Gargadon", + "Forge-Chan": "Chancellor of the Forge", + "Forest!!!!!!": "Forest", + "Forest 🌲": "Forest", + "Forest [LCI #402]": "Forest", + "Forest (Tempest #348)": "Forest", + "Fizik, Etherium Mechanic": "Iron Spider, Stark Upgrade", + "Fizik, Etherium Mechanic": "Fizik, Etherium Mechanic", + "Filigree Sylex": "The Filigree Sylex", + "Elspeth Suns Champion Wooooooo": "Elspeth, Sun's Champion", + "Dwarven Hold (Again)": "Dwarven Hold", + "Dreams of Oil and Steel": "Dreams of Steel and Oil", + "Dark Ritual (STA #89)": "Dark Ritual", + "Dark Ritual (BTD 21)": "Dark Ritual", + "Chronomatonton (The 1 Cost 1/1 That Taps to Get Bigger)": "Chronomaton", + "Chaplain of Arms": "Chaplain of Alms // Chapel Shieldgeist", + "Chancelor of the Tangle (Sic)": "Chancellor of the Tangle", + "Bottomless Depths": "Bottomless Vault", + "Boseiju, Who Destroys Target Artifact, Enchantment, or Nonbasic Land (Who Endures)": "Boseiju, Who Endures", + "Bayou - Not Legal": "Bayou", + "Basic Plains": "Plains", + "Azorius Guildgate Anniversary": "Azorius Guildgate", + "Azorius Chancery First": "Azorius Chancery", + "Annex-Chan": "Chancellor of the Annex", + "Annex Chan": "Chancellor of the Annex", + } def clean_card(card_name): clean = titlecase.titlecase(card_name.strip()) clean = ''.join(filter(lambda x: x in string.printable, clean)) clean = clean.replace("’", "'") - clean = replacement(clean) + clean = replacement.get(clean) or clean if clean not in allcards: return best_guess(clean) return clean +cache_file = "AtomicCards.json" +# update the db with all valid mtg cards +def update_mtg_db(): + updated = None + if os.path.exists(cache_file): + updated = datetime.now() - datetime.fromtimestamp(os.path.getmtime(cache_file)) + if (not updated) or updated > timedelta(days=30): + print("Updating AtomicCards (~100MB)") + cards = requests.get("https://mtgjson.com/api/v5/AtomicCards.json") + cards_data = cards.json() + with open(cache_file, 'w') as f: + json.dump(cards_data, f) + return + +def populate_mtg_table(): + with open(cache_file, 'r') as f: + cards_data = json.load(f) + cur = con.cursor() + cur.execute("delete from mtg") + print("Populating mtg table...") + count = 0 + for card_name, card_variants in cards_data.get('data', {}).items(): + if card_variants: + card = card_variants[0] + name = card.get('name', card_name) + cost = card.get('manaCost', '') + card_type = " ".join(card.get('types', '')) + cur.execute("insert into mtg values (?, ?, ?)", (name, cost, card_type)) + con.commit() + return + if __name__ == "__main__": - for table in schema: - con.execute(table) + load_schema() + update_mtg_db() + populate_mtg_table() update_bans() - main() + run_rounds() diff --git a/requirements.txt b/requirements.txt @@ -1 +1,6 @@ -Flask==2.3.3 -\ No newline at end of file +Flask==2.3.3 +requests +titlecase +Levenshtein +openpyxl +beautifulsoup4 diff --git a/schema.sql b/schema.sql @@ -1,39 +1,45 @@ -CREATE TABLE round ( +-- card name db +create table if not exists mtg ( + name text, + cost text, + card_type text +); +CREATE TABLE if not exists round ( id integer primary key, fileid text not null, date text ); -CREATE TABLE deck ( +CREATE TABLE if not exists deck ( round integer not null, player varchar not null, card1 text, card2 text, card3 text ); -CREATE TABLE match ( +CREATE TABLE if not exists match ( round integer, group_name text, player text, opp_player text, score integer ); -CREATE VIEW card as +CREATE VIEW if not exists card as select round,player,card1 as card from deck union select round,player,card2 as card from deck union select round,player,card3 as card from deck /* card(round,player,card) */; -CREATE INDEX matchidx on match(round); -CREATE INDEX deckidx on deck(round); -CREATE INDEX matchidx2 on match(player); -CREATE INDEX deckidx2 on deck(player); -CREATE VIEW rank as +CREATE INDEX if not exists matchidx on match(round); +CREATE INDEX if not exists deckidx on deck(round); +CREATE INDEX if not exists matchidx2 on match(player); +CREATE INDEX if not exists deckidx2 on deck(player); +CREATE VIEW if not exists rank as select round,player,rank() over (partition by round order by sum(case when match.group_name = 'final' then match.score else null end) desc nulls last, sum(case when match.group_name != 'final' then match.score else null end) desc ) as rank from match group by 1,2 order by 3 desc /* rank(round,player,rank) */; -CREATE VIEW round_score as +CREATE VIEW if not exists round_score as select deck.round, deck.player, card1, card2, @@ -49,7 +55,7 @@ join rank on deck.round = rank.round and deck.player = rank.player group by 1,2 /* round_score(round,player,card1,card2,card3,prelim_group,prelim_score,final_score,rank,num_players) */; -CREATE TABLE ban (name text); -CREATE INDEX c1idx on deck(card1); -CREATE INDEX c2idx on deck(card2); -CREATE INDEX c3idx on deck(card3); +CREATE TABLE if not exists ban (name text); +CREATE INDEX if not exists c1idx on deck(card1); +CREATE INDEX if not exists c2idx on deck(card2); +CREATE INDEX if not exists c3idx on deck(card3); diff --git a/templates/header.html b/templates/header.html @@ -5,6 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="description" content="Data analysis for 3 card blind metashape" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='style.css') }}"> +<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/mana-font@latest/css/mana.min.css"> <title>3 card blind data analysis</title> </head> <body> diff --git a/templates/matches.html b/templates/matches.html @@ -4,25 +4,31 @@ {% if not matches %} <p>This card has never been played, or may be spelled wrong!</p> {% else %} -<div class="flextable"> - {% for match in matches %} - <div class="deck-box" - {% if match.rank == 1 %} style="background:#c5ffc5" {% elif match.final_score %} style="background:#ffffc5"{% endif %}> - <div style="width:160px"> - <b><a href='/player?name={{ match.player }}'>{{ match.player }}</a></b><br> - <a href="/round/{{ match.round }}">Round {{ match.round }}</a><br> - Rank: &nbsp;&nbsp;{{ match.rank }}<br> - Groups{{ match.group_name }}: {{ match.prelim_score }} -{% if match.final_score %}<br> - Finals: {{ match.final_score }} -{% endif %}<br> - <a href="/deck?c1={{ match.card1 }}&c2={{ match.card2 }}&c3={{ match.card3 }}">deck</a><br> -</div> -<div> - {% for card in [match.card1, match.card2, match.card3] %}<div style="position: relative; display: inline-block;"><a href="/card?name={{ card }}"><img alt='{{ card }}' width=146 height=204px loading=lazy src="https://api.scryfall.com/cards/named?exact={{ card|url_encode }}&format=image&version=medium" /></a>{% if card in banned_cards %}<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(255, 0, 0, 0.7); color: white; font-weight: bold; font-size: 20px; padding: 5px 10px; border-radius: 5px;">BANNED</div>{% endif %}</div>{% endfor %} -</div> -</div> -{% endfor %} -</div> +<table style="border-collapse: collapse; width: 100%;"> + <tr> + <th style="border: 1px solid #ddd; padding: 8px;">Player</th> + <th style="border: 1px solid #ddd; padding: 8px;">Round</th> + <th style="border: 1px solid #ddd; padding: 8px;">Rank</th> + <th style="border: 1px solid #ddd; padding: 8px;">Groups</th> + <th style="border: 1px solid #ddd; padding: 8px;">Finals</th> + <th style="border: 1px solid #ddd; padding: 8px;">Card 1</th> + <th style="border: 1px solid #ddd; padding: 8px;">Card 2</th> + <th style="border: 1px solid #ddd; padding: 8px;">Card 3</th> + <th style="border: 1px solid #ddd; padding: 8px;">Deck</th> + </tr> + {% for match in matches %} + <tr {% if match.rank == 1 %}style="background:#c5ffc5"{% elif match.final_score %}style="background:#ffffc5"{% endif %}> + <td style="border: 1px solid #ddd; padding: 8px;"><a href='/player?name={{ match.player }}'>{{ match.player }}</a></td> + <td style="border: 1px solid #ddd; padding: 8px;"><a href="/round/{{ match.round }}">{{ match.round }}</a></td> + <td style="border: 1px solid #ddd; padding: 8px;">{{ match.rank }}</td> + <td style="border: 1px solid #ddd; padding: 8px;">{{ match.prelim_score }}</td> + <td style="border: 1px solid #ddd; padding: 8px;">{{ match.final_score or '' }}</td> + <td style="border: 1px solid #ddd; padding: 8px;"><a href="/card?name={{ match.card1 }}">{{ match.card1 }}</a>{% if match.card1 in banned_cards %} <span style="color: red;">(banned)</span>{% endif %}<br>{{ match.card1_type or 'N/A' }}{% if match.card1_cost %} {{ match.card1_cost|mana_symbols|safe }}{% endif %}</td> + <td style="border: 1px solid #ddd; padding: 8px;"><a href="/card?name={{ match.card2 }}">{{ match.card2 }}</a>{% if match.card2 in banned_cards %} <span style="color: red;">(banned)</span>{% endif %}<br>{{ match.card2_type or 'N/A' }}{% if match.card2_cost %} {{ match.card2_cost|mana_symbols|safe }}{% endif %}</td> + <td style="border: 1px solid #ddd; padding: 8px;"><a href="/card?name={{ match.card3 }}">{{ match.card3 }}</a>{% if match.card3 in banned_cards %} <span style="color: red;">(banned)</span>{% endif %}<br>{{ match.card3_type or 'N/A' }}{% if match.card3_cost %} {{ match.card3_cost|mana_symbols|safe }}{% endif %}</td> + <td style="border: 1px solid #ddd; padding: 8px;"><a href="/deck?c1={{ match.card1 }}&c2={{ match.card2 }}&c3={{ match.card3 }}">deck</a></td> + </tr> + {% endfor %} +</table> {% endif %} -{% include 'footer.html' %} -\ No newline at end of file +{% include 'footer.html' %}