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 "_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 %} + +
+ + + + + {% for name in game.names %} + + {% endfor %} + + + + {% for row in rows %} + + + {% for cell in row.cells %} + + {% endfor %} + + {% else %} + + + {% for name in game.names %} + + {% endfor %} + + {% endfor %} + + + + + {% for name in game.names %} + + {% endfor %} + + +
Kolo{{ name }}
{{ row.round }} + {%- if cell.value is none -%} + · + {%- elif cell.reset -%} + 0vynulováno + {%- else -%} + {{ cell.value|cislo }} + {%- endif -%} +
1·
Celkem + {{ totals[name]|cislo }} + {%- if streaks[name] %} {{ '✕' * streaks[name] }}{% endif %} +
+
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 %} +
+
+ +
+ {{ qr }} +

Zapisuje se z telefonu{{ address }}

+
+
+ +
{% 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 %} +
+
+
+

Hra {{ game_id }}

+

Kostky

+
+

do {{ game.final_score|cislo }} · kolo {{ game.round_number }}

+
+ + {% 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 %} + + + + + + {% endfor %} +
{{ rank }}.{{ name }} + {{ total|cislo }} + {%- if streaks[name] %} {{ '✕' * streaks[name] }}{% endif %} +
+ +
+
+ +
+ {% 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 %} +
+
+
+

Počitadlo

+

Kostky

+
+
+ + {% if request.args.get('chyba') %} +

{{ request.args.get('chyba') }}

+ {% endif %} + + {% if running %} +
+ Rozehraná hra {{ running }} čeká. + Pokračovat +
+ {% endif %} + +
+

Kdo hraje

+

Ťukej jména v pořadí, jak sedíte kolem stolu.

+ +
+ {% for player in players %} + + {% endfor %} +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ + +{% 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)