diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..73a565f
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,421 @@
+/* Počitadlo v kostkách.
+ Vizuálně stojí na tom, co nahrazuje: čtverečkovaný papír a dvě propisky —
+ modrá zapisuje, červená škrtá. Zvýrazňovač ukazuje, kdo je na tahu. */
+
+:root {
+ color-scheme: light;
+
+ --paper: #FBFAF6;
+ --grid: rgba(27, 59, 111, 0.06);
+ --rule: #C3D0E2;
+ --ink: #1B3B6F;
+ --ink-soft: #6C7C9B;
+ --red: #CB2B25;
+ --marker: #FFE7A0;
+
+ --font-voice: Georgia, "Times New Roman", serif;
+ --font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-num: ui-monospace, "SF Mono", "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
+
+ --step: 1.5rem;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ padding: var(--step);
+ min-height: 100vh;
+ color: var(--ink);
+ font-family: var(--font-ui);
+ background-color: var(--paper);
+ background-image:
+ linear-gradient(var(--grid) 1px, transparent 1px),
+ linear-gradient(90deg, var(--grid) 1px, transparent 1px);
+ background-size: 24px 24px;
+}
+
+a { color: var(--ink); }
+
+/* --- hlavička ------------------------------------------------------------ */
+
+.masthead {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--step);
+ flex-wrap: wrap;
+ border-bottom: 2px solid var(--ink);
+ padding-bottom: 0.6rem;
+ margin-bottom: var(--step);
+}
+
+.masthead h1 {
+ font-family: var(--font-voice);
+ font-size: clamp(1.6rem, 4vw, 2.4rem);
+ font-weight: 400;
+ letter-spacing: -0.01em;
+ margin: 0;
+}
+
+.eyebrow {
+ font-size: 0.72rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+ margin: 0 0 0.2rem;
+}
+
+.meta {
+ font-family: var(--font-num);
+ font-size: 0.85rem;
+ color: var(--ink-soft);
+}
+
+/* --- zápisník ------------------------------------------------------------ */
+
+.sheet-wrap { overflow-x: auto; }
+
+.sheet {
+ border-collapse: collapse;
+ width: 100%;
+ background: #FFFFFF;
+ font-family: var(--font-num);
+ font-variant-numeric: tabular-nums;
+}
+
+.sheet th,
+.sheet td {
+ border: 1px solid var(--rule);
+ padding: 0.7rem 1.2rem;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.sheet thead th {
+ font-family: var(--font-ui);
+ font-size: 0.9rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ font-weight: 600;
+ color: var(--ink-soft);
+ border-bottom-width: 2px;
+ border-bottom-color: var(--ink);
+}
+
+.sheet .label {
+ text-align: left;
+ color: var(--ink-soft);
+ font-size: 0.85rem;
+ width: 1%; /* sloupec s číslem kola se smrskne na obsah */
+ white-space: nowrap;
+}
+
+.sheet tbody td { font-size: 1.5rem; }
+
+.sheet .empty { color: var(--rule); }
+
+/* Zvýrazňovač na sloupci hráče, který je na tahu. Nerovnoměrný, jako tah fixem. */
+.marked {
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 231, 160, 0.35) 0%,
+ rgba(255, 231, 160, 0.9) 18%,
+ rgba(255, 231, 160, 0.75) 82%,
+ rgba(255, 231, 160, 0.3) 100%
+ );
+}
+
+/* Škrtnuté zápisy před vynulováním. */
+.struck {
+ color: var(--ink-soft);
+ text-decoration: line-through;
+ text-decoration-color: var(--red);
+ text-decoration-thickness: 2px;
+}
+
+.wiped { color: var(--red); }
+
+.wiped small {
+ display: block;
+ font-family: var(--font-ui);
+ font-size: 0.6rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.sheet tfoot td {
+ border-top: 2px solid var(--ink);
+ font-size: 2.1rem;
+ font-weight: 700;
+ padding-top: 0.7rem;
+}
+
+.sheet tfoot .label { font-weight: 400; }
+
+/* --- nuly za sebou ------------------------------------------------------- */
+
+.ticks {
+ color: var(--red);
+ font-size: 0.9em;
+ letter-spacing: 0.15em;
+}
+
+.warning {
+ color: var(--red);
+ font-size: 0.85rem;
+ margin: 0.6rem 0 0;
+}
+
+/* --- zadávací pohled ----------------------------------------------------- */
+
+.phone { max-width: 30rem; margin: 0 auto; }
+
+.turn {
+ border: 2px solid var(--ink);
+ background: var(--paper);
+ padding: 1.2rem;
+ margin-bottom: var(--step);
+}
+
+.turn h2 {
+ font-family: var(--font-voice);
+ font-size: clamp(2rem, 9vw, 3rem);
+ font-weight: 400;
+ margin: 0.1rem 0 0.4rem;
+ line-height: 1.05;
+}
+
+.progress {
+ font-family: var(--font-num);
+ font-size: 1.1rem;
+ color: var(--ink-soft);
+ margin: 0;
+}
+
+.progress strong { color: var(--ink); font-size: 1.35rem; }
+
+input[type="number"] {
+ width: 100%;
+ font-family: var(--font-num);
+ font-size: 1.6rem;
+ padding: 0.7rem 0.8rem;
+ color: var(--ink);
+ background: transparent;
+ border: 1px solid var(--rule);
+ border-radius: 0;
+}
+
+input[type="text"] {
+ width: 100%;
+ font-family: var(--font-ui);
+ font-size: 1.1rem;
+ padding: 0.7rem 0.8rem;
+ color: var(--ink);
+ background: transparent;
+ border: 1px solid var(--rule);
+ border-radius: 0;
+}
+
+input:focus-visible,
+button:focus-visible,
+a:focus-visible {
+ outline: 3px solid var(--red);
+ outline-offset: 2px;
+}
+
+.field { margin-bottom: 0.8rem; }
+
+.field label {
+ display: block;
+ font-size: 0.75rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+ margin-bottom: 0.35rem;
+}
+
+button {
+ font-family: var(--font-ui);
+ font-size: 1rem;
+ cursor: pointer;
+ border-radius: 0;
+}
+
+.btn {
+ width: 100%;
+ padding: 0.9rem;
+ border: 2px solid var(--ink);
+ background: var(--ink);
+ color: var(--paper);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+.btn:hover { background: #16305c; }
+
+.btn-quiet {
+ background: transparent;
+ color: var(--ink);
+ border: 1px solid var(--rule);
+ font-weight: 400;
+}
+
+.btn-quiet:hover { border-color: var(--ink); }
+
+.btn-danger { color: var(--red); border-color: var(--red); }
+
+.actions {
+ display: flex;
+ gap: 0.6rem;
+ margin-top: var(--step);
+}
+
+.actions form { flex: 1; }
+.actions button { width: 100%; padding: 0.75rem 0.5rem; }
+
+.standings {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--font-num);
+ font-variant-numeric: tabular-nums;
+}
+
+.standings td { padding: 0.45rem 0; border-bottom: 1px solid var(--rule); }
+.standings td:last-child { text-align: right; font-weight: 600; }
+.standings .rank { color: var(--ink-soft); width: 2.5rem; }
+.standings .self { font-family: var(--font-ui); }
+
+.error {
+ border-left: 3px solid var(--red);
+ color: var(--red);
+ padding: 0.5rem 0.8rem;
+ margin-bottom: var(--step);
+ font-size: 0.9rem;
+}
+
+/* --- výběr hráčů --------------------------------------------------------- */
+
+.chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 0.8rem;
+}
+
+.chip {
+ font-family: var(--font-ui);
+ font-size: 1rem;
+ padding: 0.7rem 1rem;
+ border: 1px solid var(--rule);
+ background: transparent;
+ color: var(--ink);
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.5rem;
+}
+
+.chip[aria-pressed="true"] {
+ border-color: var(--ink);
+ background: var(--marker);
+ font-weight: 600;
+}
+
+.chip .seat {
+ font-family: var(--font-num);
+ font-size: 0.75rem;
+ color: var(--ink-soft);
+}
+
+.chip[aria-pressed="true"] .seat { color: var(--ink); }
+
+.hint {
+ color: var(--ink-soft);
+ font-size: 0.85rem;
+ margin: 0 0 var(--step);
+}
+
+.add-player { display: flex; gap: 0.5rem; }
+.add-player input { flex: 1; }
+.add-player button { padding: 0 1rem; border: 1px solid var(--ink); background: transparent; }
+
+.notice {
+ border: 1px solid var(--rule);
+ border-left: 3px solid var(--ink);
+ padding: 0.8rem 1rem;
+ margin-bottom: var(--step);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+/* --- tabule -------------------------------------------------------------- */
+
+.board { max-width: 64rem; margin: 0 auto; }
+
+.join {
+ display: flex;
+ align-items: center;
+ gap: 0.9rem;
+}
+
+.join .address {
+ font-family: var(--font-num);
+ font-size: 0.95rem;
+ line-height: 1.4;
+}
+
+.join .address span {
+ display: block;
+ font-family: var(--font-ui);
+ font-size: 0.7rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
+
+.idle {
+ text-align: center;
+ padding-top: 12vh;
+}
+
+.idle .join { justify-content: center; margin-top: var(--step); }
+.idle .qr { width: 9rem; height: 9rem; }
+
+/* --- vyhlášení ----------------------------------------------------------- */
+
+.win {
+ border: 2px solid var(--ink);
+ padding: 1.4rem var(--step);
+ margin-bottom: var(--step);
+ background: var(--marker);
+}
+
+.win h2 {
+ font-family: var(--font-voice);
+ font-size: clamp(1.8rem, 5vw, 3rem);
+ font-weight: 400;
+ margin: 0.2rem 0 0.4rem;
+}
+
+.win p { margin: 0; color: var(--ink-soft); }
+
+/* --- odkazy dole --------------------------------------------------------- */
+
+.footer {
+ margin-top: calc(var(--step) * 2);
+ padding-top: 0.8rem;
+ border-top: 1px solid var(--rule);
+ font-size: 0.85rem;
+ color: var(--ink-soft);
+ display: flex;
+ gap: 1.2rem;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * { transition: none !important; animation: none !important; }
+}
diff --git a/templates/_head.html b/templates/_head.html
new file mode 100644
index 0000000..902d6c8
--- /dev/null
+++ b/templates/_head.html
@@ -0,0 +1,3 @@
+
Zápisník · hra {{ game_id }}
+{% if winner %}Konec hry{% else %}Na tahu {{ current }}{% endif %}
+do {{ game.final_score|cislo }} bodů · kolo {{ game.round_number }}
diff --git a/templates/_panel.html b/templates/_panel.html
new file mode 100644
index 0000000..e8c1cd5
--- /dev/null
+++ b/templates/_panel.html
@@ -0,0 +1,3 @@
+{# Fragment pro tabuli: obě části, které se během hry mění. #}
+{% include "_head.html" %}
+{% include "_sheet.html" %}
diff --git a/templates/_sheet.html b/templates/_sheet.html
new file mode 100644
index 0000000..9b2294c
--- /dev/null
+++ b/templates/_sheet.html
@@ -0,0 +1,57 @@
+{% if winner %}
+
+
Konec hry
+
{{ winner }}
+
Vyhrává s {{ totals[winner]|cislo }} body z cílových {{ game.final_score|cislo }}.
+
+{% endif %}
+
+
+
+
+
+ Kolo
+ {% for name in game.names %}
+ {{ name }}
+ {% endfor %}
+
+
+
+ {% for row in rows %}
+
+ {{ row.round }}
+ {% for cell in row.cells %}
+
+ {%- if cell.value is none -%}
+ ·
+ {%- elif cell.reset -%}
+ 0vynulováno
+ {%- else -%}
+ {{ cell.value|cislo }}
+ {%- endif -%}
+
+ {% endfor %}
+
+ {% else %}
+
+ 1
+ {% for name in game.names %}
+ ·
+ {% endfor %}
+
+ {% endfor %}
+
+
+
+ Celkem
+ {% for name in game.names %}
+
+ {{ totals[name]|cislo }}
+ {%- if streaks[name] %} {{ '✕' * streaks[name] }} {% endif %}
+
+ {% endfor %}
+
+
+
+
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..5d8cdb6
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ {% block title %}Kostky{% endblock %}
+
+
+
+{% block body %}{% endblock %}
+
+
diff --git a/templates/board.html b/templates/board.html
new file mode 100644
index 0000000..73850b4
--- /dev/null
+++ b/templates/board.html
@@ -0,0 +1,37 @@
+{% extends "base.html" %}
+{% block title %}Zápisník — hra {{ game_id }}{% endblock %}
+
+{% block body %}
+
+
+
+
{% include "_sheet.html" %}
+
+
+
+{% endblock %}
diff --git a/templates/board_idle.html b/templates/board_idle.html
new file mode 100644
index 0000000..8350948
--- /dev/null
+++ b/templates/board_idle.html
@@ -0,0 +1,14 @@
+{% extends "base.html" %}
+{% block title %}Zápisník{% endblock %}
+
+{% block body %}
+
+
Zápisník
+
Zatím se nehraje
+
Naskenuj kód telefonem a založ hru.
+
+ {{ qr }}
+
Adresa počitadla {{ address }}
+
+
+{% endblock %}
diff --git a/templates/game.html b/templates/game.html
new file mode 100644
index 0000000..6287e2c
--- /dev/null
+++ b/templates/game.html
@@ -0,0 +1,78 @@
+{% extends "base.html" %}
+{% block title %}{% if winner %}Konec hry{% else %}Na tahu {{ game.current_player }}{% endif %}{% endblock %}
+
+{% block body %}
+
+
+
+ {% if chyba %}
+
{{ chyba }}
+ {% endif %}
+
+ {% if winner %}
+
+
Konec hry
+
{{ winner }}
+
Vyhrává s {{ totals[winner]|cislo }} body z cílových {{ game.final_score|cislo }}.
+
+ {% else %}
+
+
Na tahu
+
{{ game.current_player }}
+
+ {{ totals[game.current_player]|cislo }} / {{ game.final_score|cislo }}
+ {%- if streaks[game.current_player] %}
+ {{ '✕' * streaks[game.current_player] }}
+ {%- endif %}
+
+ {% if streaks[game.current_player] == 2 %}
+
Ještě jedna nula a skóre jde pryč.
+ {% endif %}
+
+
+
+ {% endif %}
+
+
Průběžné pořadí
+
+ {% for rank, name, total in standings %}
+
+ {{ rank }}.
+ {{ name }}
+
+ {{ total|cislo }}
+ {%- if streaks[name] %} {{ '✕' * streaks[name] }} {% endif %}
+
+
+ {% endfor %}
+
+
+
+
+ {% if not winner %}
+
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..cb0a96f
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,109 @@
+{% extends "base.html" %}
+{% block title %}Nová hra{% endblock %}
+
+{% block body %}
+
+
+
+ {% if request.args.get('chyba') %}
+
{{ request.args.get('chyba') }}
+ {% endif %}
+
+ {% if running %}
+
+
Rozehraná hra {{ running }} čeká.
+
Pokračovat
+
+ {% endif %}
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/tests/test_web.py b/tests/test_web.py
new file mode 100644
index 0000000..8e7ee68
--- /dev/null
+++ b/tests/test_web.py
@@ -0,0 +1,177 @@
+import pytest
+
+import storage
+import web
+
+
+@pytest.fixture
+def client(tmp_path, monkeypatch):
+ monkeypatch.setattr(storage, "DB_PATH", tmp_path / "test.db")
+ monkeypatch.setenv("DICE_HOST", "192.168.0.10")
+ web.app.config["TESTING"] = True
+ with web.app.test_client() as client:
+ yield client
+
+
+def start_game(client, *names, final_score=1000):
+ response = client.post("/game", data={"player": list(names),
+ "final_score": final_score})
+ assert response.status_code == 302
+ return int(response.headers["Location"].rsplit("/", 1)[1])
+
+
+def score(client, game_id, value):
+ return client.post(f"/game/{game_id}/turn", data={"value": value})
+
+
+def text(response):
+ return response.get_data(as_text=True)
+
+
+def test_prazdne_pocitadlo_nabizi_novou_hru(client):
+ page = text(client.get("/"))
+
+ assert "Kdo hraje" in page
+ assert "Začít hru" in page
+
+
+def test_zalozeni_hry_a_zapis_tahu(client):
+ game_id = start_game(client, "Adam", "Eva")
+
+ page = text(client.get(f"/game/{game_id}"))
+ assert "Na tahu" in page
+ assert "Adam" in page
+
+ score(client, game_id, 450)
+
+ page = text(client.get(f"/game/{game_id}"))
+ assert "Eva" in page
+ assert "450" in page
+
+
+def test_znama_jmena_se_nabidnou_v_dalsi_hre(client):
+ start_game(client, "Adam", "Eva")
+
+ page = text(client.get("/"))
+
+ assert 'data-name="Adam"' in page
+ assert 'data-name="Eva"' in page
+
+
+def test_rozehrana_hra_se_nabidne_k_pokracovani(client):
+ game_id = start_game(client, "Adam", "Eva")
+
+ page = text(client.get("/"))
+
+ assert "Rozehraná hra" in page
+ assert f"/game/{game_id}" in page
+
+
+def test_neplatne_skore_neprojde(client):
+ game_id = start_game(client, "Adam", "Eva")
+
+ response = score(client, game_id, "hodně")
+
+ assert "chyba" in response.headers["Location"]
+ assert "Zadej skóre jako číslo." in text(client.get(response.headers["Location"]))
+
+
+def test_vynulovani_se_v_zapisniku_vyznaci(client):
+ game_id = start_game(client, "Adam")
+ for value in (300, 0, 0, 0):
+ score(client, game_id, value)
+
+ panel = text(client.get(f"/board/{game_id}/panel"))
+
+ assert "vynulováno" in panel
+ assert "struck" in panel # škrtnutý zápis před vynulováním
+
+
+def test_vraceni_posledniho_zapisu(client):
+ game_id = start_game(client, "Adam", "Eva")
+ score(client, game_id, 450)
+ score(client, game_id, 200)
+
+ client.post(f"/game/{game_id}/undo")
+ page = text(client.get(f"/game/{game_id}"))
+
+ assert "Eva" in page
+ assert "200" not in page
+
+
+def test_varovani_pred_treti_nulou(client):
+ game_id = start_game(client, "Adam")
+ score(client, game_id, 300)
+ score(client, game_id, 0)
+ score(client, game_id, 0)
+
+ assert "Ještě jedna nula" in text(client.get(f"/game/{game_id}"))
+
+
+def test_dohrani_hry_vyhlasi_viteze(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(f"/game/{game_id}"))
+ assert "Vyhrává" in page
+ assert "Konec hry" in page
+
+ assert storage.running_game(storage.connect(storage.DB_PATH)) is None
+
+
+def test_do_dohrane_hry_uz_nejde_zapsat(client):
+ game_id = start_game(client, "Adam", "Eva", final_score=500)
+ score(client, game_id, 500)
+ score(client, game_id, 100)
+
+ score(client, game_id, 999)
+
+ conn = storage.connect(storage.DB_PATH)
+ assert storage.load_game(conn, game_id).totals()["Adam"] == 500
+
+
+def test_remiza_hru_neukonci(client):
+ game_id = start_game(client, "Adam", "Eva", final_score=500)
+ score(client, game_id, 500)
+ score(client, game_id, 500)
+
+ page = text(client.get(f"/game/{game_id}"))
+
+ assert "Na tahu" in page
+ assert "Konec hry" not in page
+
+
+def test_ukonceni_hry(client):
+ game_id = start_game(client, "Adam", "Eva")
+
+ client.post(f"/game/{game_id}/abandon")
+
+ assert "Rozehraná hra" not in text(client.get("/"))
+
+
+def test_tabule_ukazuje_qr_a_adresu(client):
+ game_id = start_game(client, "Adam", "Eva")
+
+ page = text(client.get(f"/board/{game_id}"))
+
+ assert "`), notebook ukazuje zápisník (`/board`).
+Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step.
+"""
+
+import os
+import socket
+
+import qrcode
+from flask import (Flask, abort, g, redirect, render_template, request,
+ url_for)
+from markupsafe import Markup
+
+import storage
+from core import FINAL_SCORE, Game, GameOver
+
+app = Flask(__name__)
+
+PORT = int(os.environ.get("DICE_PORT", 8000))
+
+
+# --- připojení k databázi ---------------------------------------------------
+
+def db():
+ if "db" not in g:
+ g.db = storage.connect()
+ return g.db
+
+
+@app.teardown_appcontext
+def close_db(_exception):
+ conn = g.pop("db", None)
+ if conn is not None:
+ conn.close()
+
+
+# --- adresa a QR kód --------------------------------------------------------
+
+def lan_address() -> str:
+ """Adresa, na kterou se dá z telefonu připojit.
+
+ IP notebooku se podle DHCP mění, proto ji hledáme za běhu. Připojení
+ UDP socketu nic neposílá, jen si vyžádá směrování k výchozí bráně.
+ """
+ host = os.environ.get("DICE_HOST")
+ if not host:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ sock.connect(("10.255.255.255", 1))
+ host = sock.getsockname()[0]
+ except OSError:
+ host = socket.gethostbyname(socket.gethostname())
+ finally:
+ sock.close()
+ return f"http://{host}:{PORT}"
+
+
+def qr_svg(data: str) -> Markup:
+ """QR jako SVG složené ze čtverečků — bez Pillow a bez práce s obrázky."""
+ code = qrcode.QRCode(box_size=1, border=2)
+ code.add_data(data)
+ code.make(fit=True)
+ matrix = code.get_matrix()
+ size = len(matrix)
+
+ rects = [
+ f' '
+ for y, row in enumerate(matrix)
+ for x, dark in enumerate(row) if dark
+ ]
+
+ return Markup(
+ f'{"".join(rects)} '
+ )
+
+
+@app.template_filter("cislo")
+def cislo(value: int) -> str:
+ """Tisíce se v češtině oddělují mezerou."""
+ return f"{value:,}".replace(",", " ")
+
+
+# --- zápisník ---------------------------------------------------------------
+
+def scoresheet(game: Game) -> list[dict]:
+ """Řádky zápisníku po kolech.
+
+ Tahy zapsané před posledním vynulováním se přeškrtnou — přesně tak, jak
+ by se škrtaly na papíře.
+ """
+ wiped_in_round = {}
+ for turn in game.turns:
+ if turn.reset:
+ wiped_in_round[turn.player] = turn.round
+
+ rows = []
+ for number, by_player in game.rounds():
+ cells = []
+ for name in game.names:
+ turn = by_player[name]
+ cells.append({
+ "value": None if turn is None else turn.value,
+ "reset": turn is not None and turn.reset,
+ "struck": turn is not None and turn.round < wiped_in_round.get(name, 0),
+ })
+ rows.append({"round": number, "cells": cells})
+
+ return rows
+
+
+def view(game: Game, game_id: int, row) -> dict:
+ """Společný podklad pro zápisník i pro zadávací pohled."""
+ totals = game.totals()
+ winner = row["winner_name"] if row["status"] == storage.STATUS_FINISHED else ""
+ return {
+ "game": game,
+ "game_id": game_id,
+ "row": row,
+ "totals": totals,
+ "rows": scoresheet(game),
+ "streaks": {name: game.zero_streak(name) for name in game.names},
+ "standings": game.standings(),
+ "winner": winner,
+ "current": "" if winner else game.current_player,
+ }
+
+
+def load(game_id: int) -> tuple[Game, "storage.sqlite3.Row"]:
+ row = storage.game_row(db(), game_id)
+ if row is None:
+ abort(404)
+ return storage.load_game(db(), game_id), row
+
+
+# --- routy ------------------------------------------------------------------
+
+@app.route("/")
+def index():
+ return render_template(
+ "index.html",
+ players=storage.known_players(db()),
+ running=storage.running_game(db()),
+ final_score=FINAL_SCORE,
+ )
+
+
+@app.post("/game")
+def new_game():
+ names = [n for n in request.form.getlist("player") if n.strip()]
+ if not names:
+ return redirect(url_for("index", chyba="Vyber aspoň jednoho hráče."))
+
+ try:
+ final_score = int(request.form.get("final_score") or FINAL_SCORE)
+ except ValueError:
+ return redirect(url_for("index", chyba="Cílové skóre musí být číslo."))
+
+ try:
+ game_id = storage.create_game(db(), names, final_score)
+ except ValueError as exc:
+ return redirect(url_for("index", chyba=str(exc)))
+
+ return redirect(url_for("game_view", game_id=game_id))
+
+
+@app.route("/game/")
+def game_view(game_id: int):
+ game, row = load(game_id)
+ return render_template("game.html", chyba=request.args.get("chyba", ""),
+ **view(game, game_id, row))
+
+
+@app.post("/game//turn")
+def add_turn(game_id: int):
+ game, row = load(game_id)
+
+ try:
+ value = int(request.form.get("value", ""))
+ except ValueError:
+ return redirect(url_for("game_view", game_id=game_id,
+ chyba="Zadej skóre jako číslo."))
+
+ try:
+ result = game.add_score(value)
+ except GameOver:
+ return redirect(url_for("game_view", game_id=game_id))
+
+ storage.add_turn(db(), game_id, result.turn)
+ if result.winner:
+ storage.finish_game(db(), game_id, result.winner)
+
+ return redirect(url_for("game_view", game_id=game_id))
+
+
+@app.post("/game//undo")
+def undo(game_id: int):
+ storage.undo_turn(db(), game_id)
+ return redirect(url_for("game_view", game_id=game_id))
+
+
+@app.post("/game//abandon")
+def abandon(game_id: int):
+ storage.abandon_game(db(), game_id)
+ return redirect(url_for("index"))
+
+
+@app.route("/board")
+def board_redirect():
+ game_id = storage.running_game(db())
+ if game_id:
+ return redirect(url_for("board", game_id=game_id))
+
+ return render_template("board_idle.html", address=lan_address(),
+ qr=qr_svg(lan_address()))
+
+
+@app.route("/board/")
+def board(game_id: int):
+ game, row = load(game_id)
+ address = lan_address() + url_for("game_view", game_id=game_id)
+ return render_template("board.html", address=address, qr=qr_svg(address),
+ **view(game, game_id, row))
+
+
+@app.route("/board//panel")
+def board_panel(game_id: int):
+ """Fragment, na který se tabule doptává každé dvě vteřiny."""
+ game, row = load(game_id)
+ return render_template("_panel.html", **view(game, game_id, row))
+
+
+if __name__ == "__main__":
+ print(f"Počitadlo běží na {lan_address()}")
+ print(f"Tabule: {lan_address()}/board")
+ app.run(host="0.0.0.0", port=PORT, threaded=True)