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:
gitea-actionsandClaude Opus 5 committed 2026-08-20 19:04:50 +02:00
1 parent bcbbede258
commit d7c63cf98b
9 files changed
+474 -1

No files matched your search

+56
View File
@@ -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
```
+42
View File
@@ -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);
}
+124
View File
@@ -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
+1
View File
@@ -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>
+4 -1
View File
@@ -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>
+112
View File
@@ -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 %}
+100
View File
@@ -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"]
+16
View File
@@ -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"))
+19
View File
@@ -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")