stats: síň slávy a kariérní statistiky
- rekordy: nejvyšší tah, nejvyšší konečné skóre, nejrychlejší výhra - kariéra: počet her, výher, úspěšnost, průměrné pořadí, průměr na tah a kolikrát koho postihlo vynulování - do statistik jdou jen dohrané hry, nedohrané a opuštěné se ignorují - celkové skóre počítá SQL stejným pravidlem jako core.Game.totals(), tedy součet tahů po posledním vynulování; testem ověřeno, že se obě cesty shodují - vědomě chybí celkový součet bodů napříč hrami — cílové skóre je u každé hry jiné, metrika by odměňovala jen toho, kdo hrál nejvíc - README se spuštěním a přehledem modulů Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bcbbede258
commit
d7c63cf98b
9 files changed
+474
-1
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -72,6 +72,7 @@
|
||||
|
||||
<p class="footer">
|
||||
<a href="{{ url_for('board', game_id=game_id) }}">Zápisník</a>
|
||||
<a href="{{ url_for('hall') }}">Síň slávy</a>
|
||||
<a href="{{ url_for('index') }}">Nová hra</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
<button class="btn" type="submit">Začít hru</button>
|
||||
</form>
|
||||
|
||||
<p class="footer"><a href="{{ url_for('board_redirect') }}">Zápisník</a></p>
|
||||
<p class="footer">
|
||||
<a href="{{ url_for('board_redirect') }}">Zápisník</a>
|
||||
<a href="{{ url_for('hall') }}">Síň slávy</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Síň slávy{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="board">
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<p class="eyebrow">Počitadlo</p>
|
||||
<h1>Síň slávy</h1>
|
||||
</div>
|
||||
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a></p>
|
||||
</header>
|
||||
|
||||
{% if records.best_turn %}
|
||||
<div class="records">
|
||||
<article class="record">
|
||||
<p class="eyebrow">Nejvyšší tah</p>
|
||||
<p class="number">{{ records.best_turn.value|cislo }}</p>
|
||||
<p class="who">{{ records.best_turn.name }} · hra {{ records.best_turn.game_id }}
|
||||
· {{ records.best_turn.at|datum }}</p>
|
||||
</article>
|
||||
|
||||
{% if records.best_score %}
|
||||
<article class="record">
|
||||
<p class="eyebrow">Nejvyšší konečné skóre</p>
|
||||
<p class="number">{{ records.best_score.total|cislo }}</p>
|
||||
<p class="who">{{ records.best_score.name }} · hra {{ records.best_score.game_id }}
|
||||
· {{ records.best_score.finished_at|datum }}</p>
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
{% if records.fastest %}
|
||||
<article class="record">
|
||||
<p class="eyebrow">Nejrychlejší výhra</p>
|
||||
<p class="number">{{ records.fastest.rounds }}<small> kol</small></p>
|
||||
<p class="who">{{ records.fastest.name }} · hra {{ records.fastest.game_id }}
|
||||
· {{ records.fastest.finished_at|datum }}</p>
|
||||
</article>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Zatím se neodehrál ani jeden tah. Rekordy přibudou po první hře.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if careers %}
|
||||
<p class="eyebrow">Kariéra</p>
|
||||
<p class="hint">Počítají se jen dohrané hry.</p>
|
||||
<div class="sheet-wrap">
|
||||
<table class="sheet">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="label">Hráč</th>
|
||||
<th>Her</th>
|
||||
<th>Výher</th>
|
||||
<th>Úspěšnost</th>
|
||||
<th>Průměrné pořadí</th>
|
||||
<th>Průměr na tah</th>
|
||||
<th>Vynulování</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player in careers %}
|
||||
<tr>
|
||||
<td class="label name">{{ player.name }}</td>
|
||||
<td>{{ player.games }}</td>
|
||||
<td>{{ player.wins }}</td>
|
||||
<td>{{ (player.share * 100)|round|int }} %</td>
|
||||
<td>{{ '%.1f'|format(player.placing) }}</td>
|
||||
<td>{{ player.per_turn|round|int|cislo }}</td>
|
||||
<td class="{{ 'wiped' if player.wipeouts }}">{{ player.wipeouts }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if games %}
|
||||
<p class="eyebrow" style="margin-top: 2.5rem">Odehrané hry</p>
|
||||
<div class="sheet-wrap">
|
||||
<table class="sheet">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="label">Hra</th>
|
||||
<th class="label">Datum</th>
|
||||
<th>Hráčů</th>
|
||||
<th>Kol</th>
|
||||
<th>Cíl</th>
|
||||
<th class="label">Výsledek</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for game in games %}
|
||||
<tr>
|
||||
<td class="label">{{ game.id }}</td>
|
||||
<td class="label">{{ game.started_at|datum }}</td>
|
||||
<td>{{ game.players }}</td>
|
||||
<td>{{ (game.turns / game.players)|round(0, 'ceil')|int }}</td>
|
||||
<td>{{ game.final_score|cislo }}</td>
|
||||
<td class="label name">
|
||||
{%- if game.winner_name %}Vyhrává {{ game.winner_name }}
|
||||
{%- elif game.status == 'probíhá' %}<a href="{{ url_for('game_view', game_id=game.id) }}">Pokračovat</a>
|
||||
{%- else %}{{ game.status }}{% endif -%}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
|
||||
import stats
|
||||
import storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(tmp_path):
|
||||
connection = storage.connect(tmp_path / "test.db")
|
||||
yield connection
|
||||
connection.close()
|
||||
|
||||
|
||||
def play(conn, names, final_score, values, finish=True):
|
||||
game_id = storage.create_game(conn, names, final_score)
|
||||
game = storage.load_game(conn, game_id)
|
||||
for value in values:
|
||||
storage.add_turn(conn, game_id, game.add_score(value).turn)
|
||||
if finish and game.winner:
|
||||
storage.finish_game(conn, game_id, game.winner)
|
||||
return game_id
|
||||
|
||||
|
||||
def by_name(table):
|
||||
return {row["name"]: row for row in table}
|
||||
|
||||
|
||||
def test_prazdna_sin_slavy(conn):
|
||||
found = stats.records(conn)
|
||||
|
||||
assert found["best_turn"] is None
|
||||
assert found["best_score"] is None
|
||||
assert found["fastest"] is None
|
||||
assert stats.careers(conn) == []
|
||||
|
||||
|
||||
def test_rekordy(conn):
|
||||
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
|
||||
|
||||
found = stats.records(conn)
|
||||
|
||||
assert found["best_turn"]["name"] == "Adam"
|
||||
assert found["best_turn"]["value"] == 600
|
||||
assert found["best_score"]["name"] == "Adam"
|
||||
assert found["best_score"]["total"] == 1100
|
||||
assert found["fastest"]["name"] == "Adam"
|
||||
assert found["fastest"]["rounds"] == 2
|
||||
|
||||
|
||||
def test_kariera_pocita_vyhry_a_uspesnost(conn):
|
||||
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
|
||||
play(conn, ["Adam", "Eva"], 1000, [100, 600, 200, 500])
|
||||
|
||||
table = by_name(stats.careers(conn))
|
||||
|
||||
assert table["Adam"]["games"] == 2
|
||||
assert table["Adam"]["wins"] == 1
|
||||
assert table["Adam"]["share"] == 0.5
|
||||
assert table["Eva"]["wins"] == 1
|
||||
|
||||
|
||||
def test_nedohrane_hry_se_nepocitaji(conn):
|
||||
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
|
||||
play(conn, ["Adam", "Eva"], 5000, [100, 100], finish=False)
|
||||
|
||||
table = by_name(stats.careers(conn))
|
||||
|
||||
assert table["Adam"]["games"] == 1
|
||||
|
||||
|
||||
def test_vynulovani_se_pocita(conn):
|
||||
# Eva vyhraje, Adam se cestou třikrát vynuluje.
|
||||
play(conn, ["Adam", "Eva"], 1000, [300, 400, 0, 300, 0, 200, 0, 500])
|
||||
|
||||
table = by_name(stats.careers(conn))
|
||||
|
||||
assert table["Adam"]["wipeouts"] == 1
|
||||
assert table["Eva"]["wipeouts"] == 0
|
||||
|
||||
|
||||
def test_prumerne_poradi(conn):
|
||||
play(conn, ["Adam", "Eva", "Petr"], 1000, [600, 300, 100, 500, 200, 50])
|
||||
|
||||
table = by_name(stats.careers(conn))
|
||||
|
||||
assert table["Adam"]["placing"] == 1
|
||||
assert table["Eva"]["placing"] == 2
|
||||
assert table["Petr"]["placing"] == 3
|
||||
|
||||
|
||||
def test_soucty_v_sql_respektuji_vynulovani(conn):
|
||||
"""SQL musí počítat celkové skóre stejně jako core.Game.totals()."""
|
||||
game_id = play(conn, ["Adam", "Eva"], 1000, [300, 100, 0, 100, 0, 100, 0, 900])
|
||||
|
||||
game = storage.load_game(conn, game_id)
|
||||
best = stats.records(conn)["best_score"]
|
||||
|
||||
assert game.totals()["Adam"] == 0
|
||||
assert best["name"] == "Eva"
|
||||
assert best["total"] == game.totals()["Eva"]
|
||||
@@ -175,3 +175,19 @@ def test_tabule_presmeruje_na_rozehranou_hru(client):
|
||||
|
||||
def test_neexistujici_hra(client):
|
||||
assert client.get("/game/999").status_code == 404
|
||||
|
||||
|
||||
def test_sin_slavy(client):
|
||||
game_id = start_game(client, "Adam", "Eva", final_score=500)
|
||||
score(client, game_id, 500)
|
||||
score(client, game_id, 100)
|
||||
|
||||
page = text(client.get("/stats"))
|
||||
|
||||
assert "Nejvyšší tah" in page
|
||||
assert "Kariéra" in page
|
||||
assert "Vyhrává Adam" in page
|
||||
|
||||
|
||||
def test_sin_slavy_bez_her(client):
|
||||
assert "Rekordy přibudou po první hře" in text(client.get("/stats"))
|
||||
@@ -12,6 +12,7 @@ from flask import (Flask, abort, g, redirect, render_template, request,
|
||||
url_for)
|
||||
from markupsafe import Markup
|
||||
|
||||
import stats
|
||||
import storage
|
||||
from core import FINAL_SCORE, Game, GameOver
|
||||
|
||||
@@ -77,6 +78,15 @@ def qr_svg(data: str) -> Markup:
|
||||
)
|
||||
|
||||
|
||||
@app.template_filter("datum")
|
||||
def datum(value: str) -> str:
|
||||
"""Z ISO textu udělá 12. 8. 2026."""
|
||||
if not value:
|
||||
return ""
|
||||
den = value[:10].split("-")
|
||||
return f"{int(den[2])}. {int(den[1])}. {den[0]}"
|
||||
|
||||
|
||||
@app.template_filter("cislo")
|
||||
def cislo(value: int) -> str:
|
||||
"""Tisíce se v češtině oddělují mezerou."""
|
||||
@@ -232,6 +242,15 @@ def board_panel(game_id: int):
|
||||
return render_template("_panel.html", **view(game, game_id, row))
|
||||
|
||||
|
||||
@app.route("/stats")
|
||||
def hall():
|
||||
conn = db()
|
||||
return render_template("stats.html",
|
||||
records=stats.records(conn),
|
||||
careers=stats.careers(conn),
|
||||
games=storage.recent_games(conn))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Počitadlo běží na {lan_address()}")
|
||||
print(f"Tabule: {lan_address()}/board")
|
||||
|
||||
Reference in new issue
Block a user