diff --git a/README.md b/README.md new file mode 100644 index 0000000..97ac8b8 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Počitadlo v kostkách + +Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje +zápisník** a hry se pamatují mezi večery. + +Hráči se napříč hrami poznávají podle jména — Adam ze středečního večera je +ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla. + +## Spuštění + +```bash +python -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python web.py +``` + +Server vypíše adresu, na které poslouchá. Na notebooku otevři `/board`, +naskenuj QR kód telefonem a hraj. + +| Proměnná | K čemu je | Výchozí | +|---|---|---| +| `DICE_DB` | soubor s databází | `dice.db` | +| `DICE_PORT` | port | `8000` | +| `DICE_HOST` | adresa v QR kódu, když ji nemá hledat sám | zjistí se za běhu | + +Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi +může zapisovat. + +## Pravidla + +Skóre se zadává ručně po tazích, hází se doopravdy. Tři nulové tahy za sebou +vynulují hráči skóre; indikátor `x` / `xx` ukazuje, jak blízko k tomu je. +Vítěz se vyhlašuje až po dokončeném kole, aby měli všichni stejný počet tahů — +při remíze na prvním místě se hraje dál. + +## Co kde je + +| Soubor | Role | +|---|---| +| `core.py` | pravidla hry, žádný vstup ani výstup | +| `storage.py` | SQLite: hráči, hry, tahy | +| `stats.py` | síň slávy a kariérní statistiky | +| `web.py` | Flask aplikace | +| `dice.py` | totéž v terminálu, bez ukládání | + +Terminálová verze zůstává funkční jako záloha: + +```bash +.venv/bin/python dice.py +``` + +## Testy + +```bash +.venv/bin/python -m pytest tests/ -v +``` diff --git a/static/style.css b/static/style.css index 73a565f..c1f467b 100644 --- a/static/style.css +++ b/static/style.css @@ -419,3 +419,45 @@ button { @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } + +/* --- síň slávy ----------------------------------------------------------- */ + +.records { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1px; + background: var(--rule); + border: 1px solid var(--rule); + margin-bottom: calc(var(--step) * 2); +} + +.record { + background: #FFFFFF; + padding: 1.2rem 1.4rem; +} + +.record .number { + font-family: var(--font-num); + font-variant-numeric: tabular-nums; + font-size: 2.6rem; + margin: 0.2rem 0 0.3rem; + line-height: 1; +} + +.record .number small { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ink-soft); +} + +.record .who { + font-size: 0.85rem; + color: var(--ink-soft); + margin: 0; +} + +.sheet .name { + font-family: var(--font-ui); + font-size: 1rem; + color: var(--ink); +} diff --git a/stats.py b/stats.py new file mode 100644 index 0000000..f405a49 --- /dev/null +++ b/stats.py @@ -0,0 +1,124 @@ +"""Statistiky napříč hrami. + +Dvě různé obrazovky, protože odpovídají na jiné otázky: síň slávy sbírá +rekordy, kariéra ukazuje, kdo je dlouhodobě dobrý. + +Vědomě tu není celkový součet bodů napříč hrami — cílové skóre je u každé hry +jiné, hry nejsou srovnatelné a taková metrika by odměňovala jen toho, kdo hrál +nejvíc. +""" + +import sqlite3 + +import storage + +# Celkové skóre hráče ve hře: součet tahů po posledním vynulování. +# Stejné pravidlo jako v core.Game.totals(), jen vyjádřené v SQL. +TOTALS = """ +WITH last_reset AS ( + SELECT game_id, player_id, MAX(id) AS reset_id + FROM turns + WHERE reset = 1 + GROUP BY game_id, player_id +), +totals AS ( + SELECT gp.game_id, + gp.player_id, + COALESCE(( + SELECT SUM(t.value) + FROM turns t + LEFT JOIN last_reset lr + ON lr.game_id = t.game_id AND lr.player_id = t.player_id + WHERE t.game_id = gp.game_id + AND t.player_id = gp.player_id + AND (lr.reset_id IS NULL OR t.id > lr.reset_id) + ), 0) AS total + FROM game_players gp +) +""" + + +def records(conn: sqlite3.Connection) -> dict: + """Síň slávy — tři rekordy, které jde překonat.""" + best_turn = conn.execute(""" + SELECT p.name, t.value, t.game_id, t.at + FROM turns t + JOIN players p ON p.id = t.player_id + ORDER BY t.value DESC, t.id ASC + LIMIT 1 + """).fetchone() + + best_score = conn.execute(TOTALS + """ + SELECT p.name, totals.total, g.id AS game_id, g.finished_at + FROM totals + JOIN games g ON g.id = totals.game_id + JOIN players p ON p.id = totals.player_id + WHERE g.status = ? + ORDER BY totals.total DESC + LIMIT 1 + """, (storage.STATUS_FINISHED,)).fetchone() + + fastest = conn.execute(""" + SELECT p.name, g.id AS game_id, MAX(t.round) AS rounds, g.finished_at + FROM games g + JOIN turns t ON t.game_id = g.id + JOIN players p ON p.id = g.winner_id + WHERE g.status = ? + GROUP BY g.id + ORDER BY rounds ASC, g.id ASC + LIMIT 1 + """, (storage.STATUS_FINISHED,)).fetchone() + + return {"best_turn": best_turn, "best_score": best_score, "fastest": fastest} + + +def careers(conn: sqlite3.Connection) -> list[dict]: + """Kdo je dlouhodobě dobrý. Počítají se jen dohrané hry.""" + placings = conn.execute(TOTALS + """ + , ranked AS ( + SELECT totals.game_id, + totals.player_id, + RANK() OVER (PARTITION BY totals.game_id + ORDER BY totals.total DESC) AS placing + FROM totals + JOIN games g ON g.id = totals.game_id + WHERE g.status = ? + ) + SELECT r.player_id, + p.name, + COUNT(*) AS games, + SUM(CASE WHEN g.winner_id = r.player_id THEN 1 ELSE 0 END) AS wins, + AVG(r.placing) AS placing + FROM ranked r + JOIN players p ON p.id = r.player_id + JOIN games g ON g.id = r.game_id + GROUP BY r.player_id + """, (storage.STATUS_FINISHED,)).fetchall() + + turn_stats = conn.execute(""" + SELECT t.player_id, + AVG(t.value) AS per_turn, + SUM(t.reset) AS wipeouts + FROM turns t + JOIN games g ON g.id = t.game_id + WHERE g.status = ? + GROUP BY t.player_id + """, (storage.STATUS_FINISHED,)).fetchall() + + by_player = {row["player_id"]: row for row in turn_stats} + + table = [] + for row in placings: + turns = by_player.get(row["player_id"]) + table.append({ + "name": row["name"], + "games": row["games"], + "wins": row["wins"], + "share": row["wins"] / row["games"] if row["games"] else 0, + "placing": row["placing"], + "per_turn": turns["per_turn"] if turns else 0, + "wipeouts": turns["wipeouts"] if turns else 0, + }) + + table.sort(key=lambda r: (-r["share"], r["placing"], -r["games"])) + return table diff --git a/templates/game.html b/templates/game.html index 6287e2c..c484238 100644 --- a/templates/game.html +++ b/templates/game.html @@ -72,6 +72,7 @@ diff --git a/templates/index.html b/templates/index.html index cb0a96f..9ef6be7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -47,7 +47,10 @@ - +