diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5351aa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +.venv/ +*.db +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3803366 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# 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 +uv run web.py +``` + +To je celé. Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará +sám — není co instalovat ani aktivovat. + +Server vypíše všechny adresy, na kterých je dostupný: + +``` +Počitadlo je dostupné na: + → http://10.186.234.182:8000 místní síť + http://100.91.0.24:8000 přes Tailscale + http://172.17.0.1:8000 virtuální síť +``` + +Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule +zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. + +| Proměnná | K čemu je | Výchozí | +|---|---|---| +| `DICE_DB` | soubor s databází | `dice.db` | +| `DICE_PORT` | port | `8000` | +| `DICE_HOST` | pevná adresa; vypne hledání | hledá 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. + +## Když se telefon nepřipojí + +Adresy se hledají při každém načtení tabule, takže přechod z domácí WiFi na +hotspot a zpátky si tabule pohlídá sama a načte se znovu. + +Zbývají tři důvody, proč se telefon nedostane na notebook: + +- **Telefon je na mobilních datech**, notebook na WiFi. Různé sítě, nepotkají + se. Připoj telefon na stejnou WiFi, nebo notebook na hotspot telefonu — + to funguje taky. +- **Zapnutý hotspot na telefonu shodil jeho WiFi.** Většina Androidů to dělá. + Pak je řešením připojit na ten hotspot i notebook. +- **VPN nebo Tailscale exit node na telefonu** posílá veškerý provoz mimo + místní síť. V Tailscale to řeší přepínač „Allow local network access"; + případně použij tailnet adresu notebooku, ta funguje odkudkoli. + +## 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 | +| `net.py` | hledání adres, na kterých je počitadlo dostupné | +| `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 +uv run dice.py +``` + +## Bez uv + +Když `uv` po ruce není, jde to postaru: + +```bash +python -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python web.py +``` + +Na Fedoře stačí i systémové balíčky, pak se nemusí řešit vůbec nic: + +```bash +sudo dnf install python3-flask python3-qrcode python3-tabulate +python3 web.py +``` + +## Testy + +```bash +uv run --with-requirements requirements.txt --no-project pytest +``` + +Nebo z připraveného prostředí prostě `pytest`. diff --git a/core.py b/core.py new file mode 100644 index 0000000..eaf6293 --- /dev/null +++ b/core.py @@ -0,0 +1,163 @@ +"""Pravidla hry v kostky. + +Modul záměrně nic nevypisuje, na nic se neptá a nic neukládá — drží jen stav +hry a pravidla nad ním. Díky tomu ho může sdílet CLI i webová aplikace a dá se +testovat bez simulování vstupu. +""" + +from collections import Counter +from dataclasses import dataclass, field +from datetime import datetime + +FINAL_SCORE: int = 10000 + +# Kolik nulových tahů po sobě hráči vynuluje skóre. +ZEROS_TO_RESET: int = 3 + + +@dataclass +class Turn: + """Jeden zápis skóre. + + Vynulování po třech nulách se ukládá jako hodnota 0 s příznakem `reset`, + ne jako záporný tah — do historie tak nepadají čísla, která nikdo nehodil, + a statistiky nad tahy zůstávají pravdivé. + """ + + player: str + round: int + value: int + reset: bool = False + at: datetime = field(default_factory=datetime.now) + + +@dataclass +class TurnResult: + """Co se stalo po zapsání tahu.""" + + turn: Turn + was_reset: bool + winner: str = "" + + +class GameOver(Exception): + """Zápis do hry, která už má vítěze.""" + + +class Game: + def __init__(self, names: list[str], final_score: int = FINAL_SCORE, + turns: list[Turn] | None = None) -> None: + if not names: + raise ValueError("Hra potřebuje alespoň jednoho hráče.") + if len(set(names)) != len(names): + raise ValueError("Jména hráčů musí být unikátní.") + + self.names: list[str] = list(names) + self.final_score: int = final_score + self.turns: list[Turn] = list(turns) if turns else [] + + @property + def first(self) -> str: + return self.names[0] + + @property + def current_player(self) -> str: + """Hráči se střídají dokola, takže tah plyne z počtu zápisů.""" + return self.names[len(self.turns) % len(self.names)] + + @property + def round_number(self) -> int: + return len(self.turns) // len(self.names) + 1 + + @property + def round_complete(self) -> bool: + """True, když všichni odehráli stejný počet tahů.""" + return bool(self.turns) and len(self.turns) % len(self.names) == 0 + + @property + def winner(self) -> str: + """Vítěz se vyhlašuje jen po dokončeném kole, ne uprostřed.""" + return self.check_win() if self.round_complete else "" + + @property + def finished(self) -> bool: + return bool(self.winner) + + def turns_of(self, player: str) -> list[Turn]: + return [t for t in self.turns if t.player == player] + + def totals(self) -> dict[str, int]: + """Součty se nikdy neukládají, vždy se počítají z tahů.""" + result = {name: 0 for name in self.names} + for turn in self.turns: + if turn.reset: + result[turn.player] = 0 + else: + result[turn.player] += turn.value + return result + + def zero_streak(self, player: str) -> int: + """Počet nul za sebou od posledního vynulování. + + Živí pravidlo i indikátor "x"/"xx". Počítání se na vynulovacím tahu + zastaví, takže po vynulování začíná hráč zase od nuly. + """ + streak = 0 + for turn in reversed(self.turns_of(player)): + if turn.reset or turn.value != 0: + break + streak += 1 + return streak + + def add_score(self, value: int) -> TurnResult: + if self.finished: + raise GameOver("Hra už má vítěze.") + + player = self.current_player + reset = value == 0 and self.zero_streak(player) >= ZEROS_TO_RESET - 1 + + turn = Turn(player=player, round=self.round_number, value=value, reset=reset) + self.turns.append(turn) + + return TurnResult(turn=turn, was_reset=reset, winner=self.winner) + + def undo(self) -> Turn | None: + """Vrátí poslední zápis. Dá se opakovat. + + Protože jsou součty odvozené, stačí tah zahodit — platí to i pro + vracení vynulování. + """ + return self.turns.pop() if self.turns else None + + def check_win(self) -> str: + """Jméno vítěze, nebo prázdno když se hraje dál. + + Při remíze na prvním místě vítěz není a hraje se dál. + """ + totals = self.totals() + over_limit = {n: v for n, v in totals.items() if v >= self.final_score} + if not over_limit: + return "" + + ranked = sorted(over_limit.items(), key=lambda x: x[1], reverse=True) + counts = Counter(value for _, value in ranked) + top_name, top_value = ranked[0] + + return top_name if counts[top_value] == 1 else "" + + def standings(self) -> list[tuple[int, str, int]]: + """Pořadí, jméno a skóre — seřazeno od nejlepšího.""" + totals = self.totals() + ranked = sorted(totals.items(), key=lambda x: x[1], reverse=True) + return [(i + 1, name, value) for i, (name, value) in enumerate(ranked)] + + def rounds(self) -> list[tuple[int, dict[str, Turn | None]]]: + """Zápisník po kolech pro tabuli: [(číslo kola, {hráč: tah nebo None})].""" + result: list[tuple[int, dict[str, Turn | None]]] = [] + for index in range(0, len(self.turns), len(self.names)): + chunk = self.turns[index:index + len(self.names)] + by_player: dict[str, Turn | None] = {name: None for name in self.names} + for turn in chunk: + by_player[turn.player] = turn + result.append((index // len(self.names) + 1, by_player)) + return result diff --git a/dice.py b/dice.py index 02b46c9..19772ea 100644 --- a/dice.py +++ b/dice.py @@ -1,30 +1,34 @@ -import sys -from collections import Counter +# /// script +# requires-python = ">=3.11" +# dependencies = ["tabulate"] +# /// +"""Počitadlo skóre v terminálu. + +Pravidla žijí v `core.Game`, tenhle modul je jen vstup a výstup. Hraje se +v paměti, nic se neukládá — na historii je webová aplikace (`web.py`). +""" + from os import name, system from tabulate import tabulate -hraci = {} -score = {} -pointer = "" -first = "" -final_score = 10000 +from core import FINAL_SCORE, Game + +table_header = ["Hráč", "Skóre"] + def clear() -> None: - _ = system('cls') if name == 'nt' else system('clear') + _ = system('cls') if name == 'nt' else system('clear') -def char_create() -> bool: - global pointer - global first - global final_score + +def char_create() -> tuple[list[str], int]: + """Zeptá se na hráče a cílové skóre.""" + hraci: list[str] = [] while True: inp: str = input("Zadejte jméno prvního hráče: ") if inp: - hraci[inp] = [] - score[inp] = 0 - pointer = inp - first = inp + hraci.append(inp) clear() print(f"Hráč/ka {inp} přidán(a).") break @@ -40,12 +44,13 @@ def char_create() -> bool: print("Je potřeba zadávat unikátní jména.") else: clear() - hraci[inp] = [] - score[inp] = 0 + hraci.append(inp) print(f"Hráč/ka {inp} přidán(a).") else: break + final_score: int = FINAL_SCORE + while True: inp = input("Zadejte finální skóre (nechte prázdné pro výchozích 10 000): ") if inp != "": @@ -59,103 +64,39 @@ def char_create() -> bool: else: break - return True + return hraci, final_score -def next_player() -> None: - global pointer - keys_iter = iter(hraci) - for key in keys_iter: - if key == pointer: - nxt_key = next(keys_iter, first) - break - - pointer = nxt_key - -table_header = ["Hráč", "Skóre"] - -def table(): +def table(game: Game) -> str: hraci_tab = [] - for h, h_val in score.items(): - hrac = h - if pointer == h: - hrac = "> "+h - hraci_tab.append([hrac, h_val]) + for hrac, hodnota in game.totals().items(): + if game.current_player == hrac: + hrac = "> " + hrac + hraci_tab.append([hrac, hodnota]) return tabulate(hraci_tab, headers=table_header, tablefmt="fancy_grid") -def add_score(hrac, hodnota): - if hodnota == 0: - last_two = hraci[hrac][-2:] - if len(last_two) > 1: - last_two_sum = sum(last_two) - if last_two_sum == 0: - hodnota = -sum(hraci[hrac]) - clear() - input("Po třech nulových hodech bylo vaše skóre vynulováno.") - - hraci[hrac].append(hodnota) - score[hrac] = sum(hraci[hrac]) - -def to_sorted_tuple(d: dict[str, int], s: bool = True) -> tuple[list[tuple[int, str, int]], - dict[str, int]]: - L: list[tuple[int, str, int]] = [] - - if s: - d = dict(sorted(d.items(), key=lambda x: x[1], reverse=True)) - - for i, (k, v) in enumerate(d.items()): - L.append((i+1, k, v)) - - return L, d - -def check_win() -> str: - over_limit: dict[str, int] = {} - for p in score: - if score[p] >= final_score: - over_limit[p] = score[p] - - winner: str = "" - - if over_limit: - L, d = to_sorted_tuple(over_limit) - - count: Counter = Counter(list(d.values())) - - for c in count: - if count[c] == 1: - winner = L[0][1] - break - - return winner - -def win(winner: str, vitezne_skore: int) -> None: - results, d = to_sorted_tuple(score) +def win(game: Game, winner: str) -> None: headers = ["Pořadí", "Hráč", "Skóre"] - vysledky = tabulate(results, headers=headers, tablefmt="fancy_grid") + vysledky = tabulate(game.standings(), headers=headers, tablefmt="fancy_grid") - print(f"Vítězem se stává {winner} s {vitezne_skore} body!") + print(f"Vítězem se stává {winner} s {game.totals()[winner]} body!") print("Díky za hru a zase příště.\n") print(vysledky) -def game(): + +def play(game: Game) -> None: while True: clear() - print(table()) + print(table(game)) - strikes: str = "" + pointer = game.current_player + strikes: str = "x" * game.zero_streak(pointer) - if len(hraci[pointer]) > 0: - if hraci[pointer][-1] == 0: - strikes = "x" - if len(hraci[pointer]) > 1 and hraci[pointer][-2] == 0: - strikes += "x" - else: - strikes = "" - - inp = input(f"Zadejte skóre hráče {pointer} ({score[pointer]}{strikes}/{final_score}): ") + inp = input(f"Zadejte skóre hráče {pointer} " + f"({game.totals()[pointer]}{strikes}/{game.final_score}): ") try: inp_int = int(inp) except ValueError: @@ -163,30 +104,27 @@ def game(): input("Zadejte prosím platnou číselnou hodnotu.") continue - add_score(pointer, inp_int) - next_player() + result = game.add_score(inp_int) - if pointer == first: - winner: str = check_win() - if winner: - clear() - win(winner, score[winner]) - break + if result.was_reset: + clear() + input("Po třech nulových hodech bylo vaše skóre vynulováno.") + + if result.winner: + clear() + win(game, result.winner) + break def main() -> None: clear() - if char_create(): - clear() - print("Vytvoření hráči:", ", ".join(list(hraci.keys()))) - else: - print("Nastala chyba při vytváření uživatelů.") - sys.exit() + hraci, final_score = char_create() + clear() + print("Vytvoření hráči:", ", ".join(hraci)) input("Stiskněte libovolnou klávesu pro pokračování. ") - game() - + play(Game(hraci, final_score)) if __name__ == "__main__": diff --git a/net.py b/net.py new file mode 100644 index 0000000..bc001d6 --- /dev/null +++ b/net.py @@ -0,0 +1,151 @@ +"""Zjištění adres, na kterých je počitadlo dostupné. + +Telefon a notebook se musí potkat na stejné síti a která to je, se během +večera mění — domácí WiFi, hotspot z telefonu, tailnet. Proto se adresy +hledají za běhu, hledá se jich víc a řadí se podle toho, jak pravděpodobně +povedou k cíli. Vybrat si může člověk; my jen nabídneme. + +Nic se tu nezakazuje podle jména konkrétního rozhraní — rozhoduje rozsah +adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované. +""" + +import ipaddress +import os +import socket +from dataclasses import dataclass + +# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT). +TAILNET = ipaddress.ip_network("100.64.0.0/10") + +# Obvyklé předpony virtuálních rozhraní. Adresy z nich nezahazujeme, jen je +# řadíme nakonec — dostat se přes ně k počitadlu jde jen výjimečně. +VIRTUAL = ("docker", "br-", "virbr", "veth", "vmnet", "vboxnet", "tun", "tap") + +KIND_LABELS = { + "lan": "místní síť", + "tailnet": "přes Tailscale", + "public": "veřejná adresa", + "virtual": "virtuální síť", +} + +ORDER = {"lan": 0, "tailnet": 1, "public": 2, "virtual": 3} + + +@dataclass +class Address: + ip: str + interface: str + kind: str + primary: bool = False + + @property + def label(self) -> str: + return KIND_LABELS.get(self.kind, self.kind) + + def url(self, port: int) -> str: + return f"http://{self.ip}:{port}" + + +def classify(ip: str, interface: str) -> str | None: + """Typ adresy podle jejího rozsahu. None znamená nepoužitelná.""" + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return None + + if addr.is_loopback or addr.is_link_local or addr.is_unspecified: + return None + + if addr in TAILNET: + return "tailnet" + + if interface.startswith(VIRTUAL): + return "virtual" + + return "lan" if addr.is_private else "public" + + +def rank(found: list[tuple[str, str]], primary: str | None) -> list[Address]: + """Seřadí nalezené dvojice (rozhraní, adresa). + + První je adresa, přes kterou vede výchozí trasa — na té síti je telefon + nejspíš taky. Zbytek podle typu. Čistá funkce, ať jde otestovat i to, + co zrovna není v systému. + """ + seen: dict[str, Address] = {} + + for interface, ip in found: + kind = classify(ip, interface) + if kind is None or ip in seen: + continue + seen[ip] = Address(ip=ip, interface=interface, kind=kind, + primary=(ip == primary)) + + return sorted(seen.values(), + key=lambda a: (not a.primary, ORDER.get(a.kind, 9), a.ip)) + + +def default_route_address() -> str | None: + """Adresa rozhraní, kudy vede výchozí trasa. + + Připojení UDP socketu nic neposílá, jen si vyžádá směrování. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.connect(("10.255.255.255", 1)) + return sock.getsockname()[0] + except OSError: + return None + finally: + sock.close() + + +def _from_interfaces() -> list[tuple[str, str]]: + """Adresy všech rozhraní. Linux, jen standardní knihovna.""" + try: + import fcntl + import struct + except ImportError: + return [] + + siocgifaddr = 0x8915 + found = [] + + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + for _index, name in socket.if_nameindex(): + try: + packed = struct.pack("256s", name.encode()[:15]) + info = fcntl.ioctl(sock.fileno(), siocgifaddr, packed) + found.append((name, socket.inet_ntoa(info[20:24]))) + except OSError: + continue # rozhraní bez IPv4 adresy + + return found + + +def _from_hostname() -> list[tuple[str, str]]: + """Záloha pro systémy, kde předchozí cesta nefunguje.""" + try: + infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET) + except OSError: + return [] + + return [("", info[4][0]) for info in infos] + + +def addresses() -> list[Address]: + """Kde všude je počitadlo k dispozici, od nejpravděpodobnější adresy. + + `DICE_HOST` má poslední slovo — když je nastavená, platí jen ona. + """ + forced = os.environ.get("DICE_HOST") + if forced: + return [Address(ip=forced, interface="", kind="lan", primary=True)] + + primary = default_route_address() + + found = _from_interfaces() or _from_hostname() + if primary and primary not in [ip for _name, ip in found]: + found.append(("", primary)) + + return rank(found, primary) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b2e925e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +# Testy sahají na moduly v kořeni repa, ne na nainstalovaný balíček. +pythonpath = . +testpaths = tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f017b4d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +qrcode +tabulate +pytest diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..ce95cdb --- /dev/null +++ b/static/style.css @@ -0,0 +1,495 @@ +/* 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 .totals td { + border-bottom: 2px solid var(--ink); + font-size: 2.1rem; + font-weight: 700; +} + +.sheet .totals .label { font-weight: 400; font-size: 0.85rem; } + +.sheet .more td { + text-align: left; + color: var(--ink-soft); + font-size: 0.85rem; + font-family: var(--font-ui); +} + +/* --- 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; } +} + +/* --- 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); +} + +/* --- další cesty k počitadlu --------------------------------------------- */ + +.alt { + list-style: none; + margin: 0.4rem 0 0; + padding: 0; + font-size: 0.8rem; + color: var(--ink-soft); +} + +.alt li { white-space: nowrap; } + +.alt em { + font-family: var(--font-ui); + font-style: normal; + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.address code { + font-family: var(--font-num); + font-size: 0.85em; + 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/storage.py b/storage.py new file mode 100644 index 0000000..12723da --- /dev/null +++ b/storage.py @@ -0,0 +1,289 @@ +"""Trvalé úložiště nad SQLite. + +Ručně psané SQL nad pěti tabulkami, žádné ORM. Hry ukazují na `players.id`, +nikdy na jméno — přejmenování hráče se tím propíše i do starých her. + +Součty se nikde neukládají; stav hry se rekonstruuje načtením tahů do +`core.Game`. +""" + +import os +import re +import sqlite3 +from datetime import datetime +from pathlib import Path + +from core import FINAL_SCORE, Game, Turn + +DB_PATH = Path(os.environ.get("DICE_DB", "dice.db")) + +STATUS_RUNNING = "probíhá" +STATUS_FINISHED = "dohráno" +STATUS_ABANDONED = "opuštěno" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS players ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS games ( + id INTEGER PRIMARY KEY, + final_score INTEGER NOT NULL, + status TEXT NOT NULL, + winner_id INTEGER REFERENCES players(id), + started_at TEXT NOT NULL, + finished_at TEXT +); + +CREATE TABLE IF NOT EXISTS game_players ( + game_id INTEGER NOT NULL REFERENCES games(id), + player_id INTEGER NOT NULL REFERENCES players(id), + seat INTEGER NOT NULL, + PRIMARY KEY (game_id, player_id) +); + +CREATE TABLE IF NOT EXISTS turns ( + id INTEGER PRIMARY KEY, + game_id INTEGER NOT NULL REFERENCES games(id), + player_id INTEGER NOT NULL REFERENCES players(id), + round INTEGER NOT NULL, + value INTEGER NOT NULL, + reset INTEGER NOT NULL DEFAULT 0, + at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS turns_game ON turns(game_id, id); +""" + + +def connect(path: Path | str | None = None) -> sqlite3.Connection: + conn = sqlite3.connect(path or DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.executescript(SCHEMA) + return conn + + +def now() -> str: + """Časy ukládáme jako ISO text — sqlite3 vlastní adaptéry pro datetime + mezitím zavrhl.""" + return datetime.now().isoformat(timespec="seconds") + + +# --- hráči ------------------------------------------------------------------ + +def normalize(name: str) -> str: + """Klíč pro párování jmen napříč hrami: Adam, adam i " Adam " je týž hráč.""" + return re.sub(r"\s+", " ", name).strip().casefold() + + +def get_or_create_player(conn: sqlite3.Connection, name: str) -> int: + name = re.sub(r"\s+", " ", name).strip() + if not name: + raise ValueError("Jméno nesmí být prázdné.") + + key = normalize(name) + row = conn.execute("SELECT id FROM players WHERE key = ?", (key,)).fetchone() + if row: + return row["id"] + + cursor = conn.execute( + "INSERT INTO players (name, key, created_at) VALUES (?, ?, ?)", + (name, key, now()), + ) + conn.commit() + return cursor.lastrowid + + +def known_players(conn: sqlite3.Connection) -> list[sqlite3.Row]: + """Známí hráči k odklikání, naposledy hrající první.""" + return conn.execute(""" + SELECT p.id, p.name, MAX(t.at) AS last_played + FROM players p + LEFT JOIN turns t ON t.player_id = p.id + GROUP BY p.id + ORDER BY last_played DESC, p.name COLLATE NOCASE + """).fetchall() + + +def rename_player(conn: sqlite3.Connection, player_id: int, name: str) -> None: + name = re.sub(r"\s+", " ", name).strip() + if not name: + raise ValueError("Jméno nesmí být prázdné.") + + conn.execute("UPDATE players SET name = ?, key = ? WHERE id = ?", + (name, normalize(name), player_id)) + conn.commit() + + +def merge_players(conn: sqlite3.Connection, source_id: int, target_id: int) -> None: + """Sloučí hráče vzniklého překlepem do toho správného.""" + if source_id == target_id: + return + + # Kdyby oba omylem hráli tutéž hru, druhé sedadlo zahodíme. + conn.execute(""" + DELETE FROM game_players + WHERE player_id = ? + AND game_id IN (SELECT game_id FROM game_players WHERE player_id = ?) + """, (source_id, target_id)) + + conn.execute("UPDATE game_players SET player_id = ? WHERE player_id = ?", + (target_id, source_id)) + conn.execute("UPDATE turns SET player_id = ? WHERE player_id = ?", + (target_id, source_id)) + conn.execute("UPDATE games SET winner_id = ? WHERE winner_id = ?", + (target_id, source_id)) + conn.execute("DELETE FROM players WHERE id = ?", (source_id,)) + conn.commit() + + +# --- hry -------------------------------------------------------------------- + +def create_game(conn: sqlite3.Connection, names: list[str], + final_score: int = FINAL_SCORE) -> int: + """Založí hru. Pořadí jmen je pořadí u stolu.""" + ids = [get_or_create_player(conn, name) for name in names] + if len(set(ids)) != len(ids): + raise ValueError("Každý hráč může u stolu sedět jen jednou.") + + cursor = conn.execute( + "INSERT INTO games (final_score, status, started_at) VALUES (?, ?, ?)", + (final_score, STATUS_RUNNING, now()), + ) + game_id = cursor.lastrowid + + conn.executemany( + "INSERT INTO game_players (game_id, player_id, seat) VALUES (?, ?, ?)", + [(game_id, player_id, seat) for seat, player_id in enumerate(ids)], + ) + conn.commit() + return game_id + + +def game_row(conn: sqlite3.Connection, game_id: int) -> sqlite3.Row | None: + return conn.execute(""" + SELECT g.*, w.name AS winner_name + FROM games g + LEFT JOIN players w ON w.id = g.winner_id + WHERE g.id = ? + """, (game_id,)).fetchone() + + +def seating(conn: sqlite3.Connection, game_id: int) -> list[sqlite3.Row]: + return conn.execute(""" + SELECT p.id, p.name + FROM game_players gp + JOIN players p ON p.id = gp.player_id + WHERE gp.game_id = ? + ORDER BY gp.seat + """, (game_id,)).fetchall() + + +def load_game(conn: sqlite3.Connection, game_id: int) -> Game: + """Rekonstruuje `core.Game` z uložených tahů.""" + row = game_row(conn, game_id) + if row is None: + raise LookupError(f"Hra {game_id} neexistuje.") + + names = [player["name"] for player in seating(conn, game_id)] + + turn_rows = conn.execute(""" + SELECT p.name, t.round, t.value, t.reset, t.at + FROM turns t + JOIN players p ON p.id = t.player_id + WHERE t.game_id = ? + ORDER BY t.id + """, (game_id,)).fetchall() + + turns = [ + Turn(player=t["name"], round=t["round"], value=t["value"], + reset=bool(t["reset"]), at=datetime.fromisoformat(t["at"])) + for t in turn_rows + ] + + return Game(names, final_score=row["final_score"], turns=turns) + + +def running_game(conn: sqlite3.Connection) -> int | None: + """Poslední rozehraná hra — díky ní se dá po pádu serveru pokračovat.""" + row = conn.execute( + "SELECT id FROM games WHERE status = ? ORDER BY id DESC LIMIT 1", + (STATUS_RUNNING,), + ).fetchone() + return row["id"] if row else None + + +def recent_games(conn: sqlite3.Connection, limit: int = 20) -> list[sqlite3.Row]: + return conn.execute(""" + SELECT g.id, g.final_score, g.status, g.started_at, g.finished_at, + w.name AS winner_name, + (SELECT COUNT(*) FROM game_players gp WHERE gp.game_id = g.id) AS players, + (SELECT COUNT(*) FROM turns t WHERE t.game_id = g.id) AS turns + FROM games g + LEFT JOIN players w ON w.id = g.winner_id + ORDER BY g.id DESC + LIMIT ? + """, (limit,)).fetchall() + + +# --- tahy ------------------------------------------------------------------- + +def add_turn(conn: sqlite3.Connection, game_id: int, turn: Turn) -> None: + """Zapisuje se hned po každém tahu, ne až na konci hry.""" + row = conn.execute(""" + SELECT p.id + FROM game_players gp + JOIN players p ON p.id = gp.player_id + WHERE gp.game_id = ? AND p.name = ? + """, (game_id, turn.player)).fetchone() + + if row is None: + raise LookupError(f"Hráč {turn.player} v této hře nehraje.") + + conn.execute(""" + INSERT INTO turns (game_id, player_id, round, value, reset, at) + VALUES (?, ?, ?, ?, ?, ?) + """, (game_id, row["id"], turn.round, turn.value, int(turn.reset), + turn.at.isoformat(timespec="seconds"))) + conn.commit() + + +def undo_turn(conn: sqlite3.Connection, game_id: int) -> bool: + """Zahodí poslední zápis. Dá se opakovat.""" + row = conn.execute( + "SELECT id FROM turns WHERE game_id = ? ORDER BY id DESC LIMIT 1", + (game_id,), + ).fetchone() + if row is None: + return False + + conn.execute("DELETE FROM turns WHERE id = ?", (row["id"],)) + conn.execute( + "UPDATE games SET status = ?, winner_id = NULL, finished_at = NULL WHERE id = ?", + (STATUS_RUNNING, game_id), + ) + conn.commit() + return True + + +def finish_game(conn: sqlite3.Connection, game_id: int, winner: str) -> None: + row = conn.execute("SELECT id FROM players WHERE key = ?", + (normalize(winner),)).fetchone() + conn.execute( + "UPDATE games SET status = ?, winner_id = ?, finished_at = ? WHERE id = ?", + (STATUS_FINISHED, row["id"] if row else None, now(), game_id), + ) + conn.commit() + + +def abandon_game(conn: sqlite3.Connection, game_id: int) -> None: + """Nedohrané hry se nepočítají do statistik výher.""" + conn.execute( + "UPDATE games SET status = ?, finished_at = ? WHERE id = ?", + (STATUS_ABANDONED, now(), game_id), + ) + conn.commit() diff --git a/templates/_head.html b/templates/_head.html new file mode 100644 index 0000000..5cbfc21 --- /dev/null +++ b/templates/_head.html @@ -0,0 +1,5 @@ +{# data-address si hlídá tabule: když se notebook přesune na jinou síť, + je v QR kódu stará adresa a stránka se sama načte znovu. #} +

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/_join.html b/templates/_join.html new file mode 100644 index 0000000..a9126c7 --- /dev/null +++ b/templates/_join.html @@ -0,0 +1,22 @@ +{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #} +{% if primary %} +
+ {{ target|qr }} +
+ Zapisuje se z telefonu + {{ target }} + {% if others %} + + {% endif %} +
+
+{% else %} +
+

Adresu se nepodařilo zjistit + Spusť server s DICE_HOST=<adresa>.

+
+{% endif %} diff --git a/templates/_panel.html b/templates/_panel.html new file mode 100644 index 0000000..ef24f89 --- /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..369f61a --- /dev/null +++ b/templates/_sheet.html @@ -0,0 +1,66 @@ +{% 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 %} + + + + {# Součty patří nahoru: po padesáti kolech by je jinak nikdo neviděl. #} + + + + {% for name in game.names %} + + {% endfor %} + + + + + {% for row in rows %} + + + {% for cell in row.cells %} + + {% endfor %} + + {% else %} + + + {% for name in game.names %} + + {% endfor %} + + {% endfor %} + + {% if hidden %} + + + + {% endif %} + +
Kolo{{ name }}
Celkem + {{ totals[name]|cislo }} + {%- if streaks[name] %} {{ '✕' * streaks[name] }}{% endif %} +
{{ row.round }} + {%- if cell.value is none -%} + · + {%- elif cell.reset -%} + 0vynulováno + {%- else -%} + {{ cell.value|cislo }} + {%- endif -%} +
1·
… a dalších {{ hidden|kola }}
+
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..a430dfc --- /dev/null +++ b/templates/board.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Zápisník — hra {{ game_id }}{% endblock %} + +{% block body %} +
+
+ + {% include "_join.html" %} +
+ +
{% include "_sheet.html" %}
+
+ + +{% endblock %} diff --git a/templates/board_idle.html b/templates/board_idle.html new file mode 100644 index 0000000..d318d79 --- /dev/null +++ b/templates/board_idle.html @@ -0,0 +1,11 @@ +{% 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.

+ {% include "_join.html" %} +
+{% endblock %} diff --git a/templates/game.html b/templates/game.html new file mode 100644 index 0000000..c484238 --- /dev/null +++ b/templates/game.html @@ -0,0 +1,79 @@ +{% 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..9ef6be7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,112 @@ +{% 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/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..0f748e2 --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} +{% block title %}Síň slávy{% endblock %} + +{% block body %} +
+
+
+

Počitadlo

+

Síň slávy

+
+

Nová hra

+
+ + {% if records.best_turn %} +
+
+

Nejvyšší tah

+

{{ records.best_turn.value|cislo }}

+

{{ records.best_turn.name }} · hra {{ records.best_turn.game_id }} + · {{ records.best_turn.at|datum }}

+
+ + {% if records.best_score %} +
+

Nejvyšší konečné skóre

+

{{ records.best_score.total|cislo }}

+

{{ records.best_score.name }} · hra {{ records.best_score.game_id }} + · {{ records.best_score.finished_at|datum }}

+
+ {% endif %} + + {% if records.fastest %} +
+

Nejrychlejší výhra

+

{{ records.fastest.rounds }} kol

+

{{ records.fastest.name }} · hra {{ records.fastest.game_id }} + · {{ records.fastest.finished_at|datum }}

+
+ {% endif %} +
+ {% else %} +

Zatím se neodehrál ani jeden tah. Rekordy přibudou po první hře.

+ {% endif %} + + {% if careers %} +

Kariéra

+

Počítají se jen dohrané hry.

+
+ + + + + + + + + + + + + + {% for player in careers %} + + + + + + + + + + {% endfor %} + +
HráčHerVýherÚspěšnostPrůměrné pořadíPrůměr na tahVynulování
{{ player.name }}{{ player.games }}{{ player.wins }}{{ (player.share * 100)|round|int }} %{{ '%.1f'|format(player.placing) }}{{ player.per_turn|round|int|cislo }}{{ player.wipeouts }}
+
+ {% endif %} + + {% if games %} +

Odehrané hry

+
+ + + + + + + + + + + + + {% for game in games %} + + + + + + + + + {% endfor %} + +
HraDatumHráčůKolCílVýsledek
{{ game.id }}{{ game.started_at|datum }}{{ game.players }}{{ (game.turns / game.players)|round(0, 'ceil')|int }}{{ game.final_score|cislo }} + {%- if game.winner_name %}Vyhrává {{ game.winner_name }} + {%- elif game.status == 'probíhá' %}Pokračovat + {%- else %}{{ game.status }}{% endif -%} +
+
+ {% endif %} +
+{% endblock %} diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..e931d16 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,174 @@ +import pytest + +from core import Game, GameOver + + +def play(game: Game, *values: int) -> Game: + """Odehraje tahy po sobě, jak by je zadával zapisovatel.""" + for value in values: + game.add_score(value) + return game + + +def test_tri_nuly_vynuluji_skore(): + game = Game(["A", "B"], final_score=1000) + play(game, 200, 10, 0, 10, 0, 10) + + assert game.totals()["A"] == 200 + + result = game.add_score(0) + + assert result.was_reset + assert game.totals()["A"] == 0 + + +def test_nenulovy_tah_prerusi_serii(): + game = Game(["A", "B"], final_score=1000) + play(game, 0, 10, 0, 10, 50, 10) + + result = game.add_score(0) + + assert not result.was_reset + assert game.totals()["A"] == 50 + + +def test_po_vynulovani_zacina_serie_znovu(): + game = Game(["A"], final_score=1000) + play(game, 100, 0, 0, 0) + + assert game.totals()["A"] == 0 + + # Další dvě nuly ještě vynulovat nesmí — série se počítá od resetu. + assert not game.add_score(0).was_reset + assert not game.add_score(0).was_reset + assert game.add_score(0).was_reset + + +def test_vynulovani_se_neuklada_jako_zaporny_tah(): + game = Game(["A"], final_score=1000) + play(game, 300, 0, 0, 0) + + assert [t.value for t in game.turns] == [300, 0, 0, 0] + assert [t.reset for t in game.turns] == [False, False, False, True] + + +def test_remiza_na_prvnim_miste_hru_nekonci(): + game = Game(["A", "B"], final_score=1000) + play(game, 1000, 1000) + + assert game.round_complete + assert game.check_win() == "" + assert not game.finished + + +def test_vitez_az_po_dokoncenem_kole(): + game = Game(["A", "B"], final_score=1000) + game.add_score(1000) + + # A je přes limit, ale B ještě nehrál. + assert game.totals()["A"] == 1000 + assert game.winner == "" + + result = game.add_score(10) + + assert result.winner == "A" + assert game.finished + + +def test_do_dohrane_hry_uz_nejde_zapsat(): + game = Game(["A", "B"], final_score=1000) + play(game, 1000, 10) + + with pytest.raises(GameOver): + game.add_score(50) + + +def test_undo_vrati_skore_i_hrace_na_tahu(): + game = Game(["A", "B"], final_score=1000) + play(game, 100, 50) + + assert game.current_player == "A" + + game.add_score(70) + + assert game.current_player == "B" + assert game.totals()["A"] == 170 + + game.undo() + + assert game.current_player == "A" + assert game.totals()["A"] == 100 + + +def test_undo_pres_vynulovani_obnovi_puvodni_skore(): + game = Game(["A"], final_score=1000) + play(game, 400, 0, 0, 0) + + assert game.totals()["A"] == 0 + + game.undo() + + assert game.totals()["A"] == 400 + assert game.zero_streak("A") == 2 + + +def test_undo_na_prazdne_hre(): + game = Game(["A"]) + + assert game.undo() is None + + +def test_zero_streak(): + game = Game(["A"], final_score=1000) + + assert game.zero_streak("A") == 0 + + game.add_score(0) + assert game.zero_streak("A") == 1 + + game.add_score(0) + assert game.zero_streak("A") == 2 + + game.add_score(0) + assert game.zero_streak("A") == 0 + + +def test_poradi_hracu_se_toci_dokola(): + game = Game(["A", "B", "C"]) + + assert [game.current_player for _ in range(1)] == ["A"] + assert game.round_number == 1 + + play(game, 10, 10, 10) + + assert game.current_player == "A" + assert game.round_number == 2 + + +def test_standings_radi_od_nejlepsiho(): + game = Game(["A", "B", "C"]) + play(game, 10, 300, 50) + + assert game.standings() == [(1, "B", 300), (2, "C", 50), (3, "A", 10)] + + +def test_rounds_dela_zapisnik_po_kolech(): + game = Game(["A", "B"]) + play(game, 10, 20, 30) + + rounds = game.rounds() + + assert len(rounds) == 2 + assert rounds[0][0] == 1 + assert rounds[0][1]["A"].value == 10 + assert rounds[0][1]["B"].value == 20 + assert rounds[1][1]["A"].value == 30 + assert rounds[1][1]["B"] is None + + +def test_hra_potrebuje_hrace(): + with pytest.raises(ValueError): + Game([]) + + with pytest.raises(ValueError): + Game(["A", "A"]) diff --git a/tests/test_net.py b/tests/test_net.py new file mode 100644 index 0000000..8096162 --- /dev/null +++ b/tests/test_net.py @@ -0,0 +1,92 @@ +import pytest + +import net + + +@pytest.mark.parametrize("ip, interface, expected", [ + ("127.0.0.1", "lo", None), # loopback + ("169.254.3.7", "wlp44s0", None), # link-local, adresa bez DHCP + ("0.0.0.0", "eth0", None), + ("nesmysl", "eth0", None), + ("192.168.1.20", "wlp44s0", "lan"), + ("10.186.234.182", "wlp44s0", "lan"), # hotspot z telefonu + ("172.20.10.3", "en0", "lan"), # hotspot z iPhonu + ("100.91.0.24", "tailscale0", "tailnet"), + ("172.17.0.1", "docker0", "virtual"), + ("192.168.122.1", "virbr0", "virtual"), + ("10.0.0.5", "br-2f1a798d", "virtual"), + ("93.184.216.34", "eth0", "public"), +]) +def test_klasifikace_podle_rozsahu(ip, interface, expected): + assert net.classify(ip, interface) == expected + + +def test_tailnet_pozna_i_jine_rozhrani(): + """Rozhoduje rozsah adresy, ne jméno rozhraní.""" + assert net.classify("100.64.0.1", "eth0") == "tailnet" + assert net.classify("100.127.255.254", "") == "tailnet" + assert net.classify("100.128.0.1", "eth0") == "public" # už mimo rozsah + + +def test_poradi_adres(): + found = [ + ("docker0", "172.17.0.1"), + ("tailscale0", "100.91.0.24"), + ("lo", "127.0.0.1"), + ("wlp44s0", "10.186.234.182"), + ("eth0", "192.168.1.20"), + ] + + ranked = net.rank(found, primary="10.186.234.182") + + assert [a.ip for a in ranked] == [ + "10.186.234.182", # výchozí trasa + "192.168.1.20", # ostatní místní sítě + "100.91.0.24", # tailnet funguje i napříč sítěmi + "172.17.0.1", # virtuální až nakonec + ] + assert ranked[0].primary + assert not ranked[1].primary + + +def test_bez_vychozi_trasy_se_poradi_drzi_typu(): + found = [("docker0", "172.17.0.1"), ("wlp44s0", "192.168.1.20")] + + ranked = net.rank(found, primary=None) + + assert [a.ip for a in ranked] == ["192.168.1.20", "172.17.0.1"] + assert not any(a.primary for a in ranked) + + +def test_duplicity_se_slucuji(): + found = [("wlp44s0", "10.0.0.5"), ("", "10.0.0.5")] + + assert len(net.rank(found, primary="10.0.0.5")) == 1 + + +def test_zadna_pouzitelna_adresa(): + assert net.rank([("lo", "127.0.0.1")], primary=None) == [] + + +def test_dice_host_ma_posledni_slovo(monkeypatch): + monkeypatch.setenv("DICE_HOST", "192.168.5.5") + + found = net.addresses() + + assert [a.ip for a in found] == ["192.168.5.5"] + assert found[0].primary + + +def test_url_a_popisek(): + address = net.Address(ip="10.0.0.5", interface="wlp44s0", kind="tailnet") + + assert address.url(8000) == "http://10.0.0.5:8000" + assert address.label == "přes Tailscale" + + +def test_v_systemu_se_neco_najde(): + """Na stroji s libovolnou sítí musí vyjít aspoň jedna adresa.""" + found = net.addresses() + + assert all(net.classify(a.ip, a.interface) for a in found) + assert len({a.ip for a in found}) == len(found) diff --git a/tests/test_stats.py b/tests/test_stats.py new file mode 100644 index 0000000..40b2377 --- /dev/null +++ b/tests/test_stats.py @@ -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"] diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..af390b6 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,134 @@ +import pytest + +import storage + + +@pytest.fixture +def conn(tmp_path): + connection = storage.connect(tmp_path / "test.db") + yield connection + connection.close() + + +def test_stejne_jmeno_je_stejny_hrac(conn): + """Arkádová identita: Adam z jedné hry je Adam i v další.""" + first = storage.create_game(conn, ["Adam", "Eva"]) + second = storage.create_game(conn, ["adam", "Petr"]) + + adam_first = [p["id"] for p in storage.seating(conn, first) if p["name"] == "Adam"] + adam_second = [p["id"] for p in storage.seating(conn, second)][0] + + assert adam_first[0] == adam_second + + +def test_normalizace_jmen(): + assert storage.normalize(" Adam ") == storage.normalize("adam") + assert storage.normalize("Jan Novák") == storage.normalize("jan novák") + assert storage.normalize("Adam") != storage.normalize("Ádám") + + +def test_prejmenovani_se_propise_do_starych_her(conn): + game_id = storage.create_game(conn, ["Adam", "Eva"]) + adam = storage.seating(conn, game_id)[0]["id"] + + storage.rename_player(conn, adam, "Adam Š.") + + assert [p["name"] for p in storage.seating(conn, game_id)] == ["Adam Š.", "Eva"] + + +def test_slouceni_hracu_po_preklepu(conn): + good = storage.create_game(conn, ["Adam", "Eva"]) + typo = storage.create_game(conn, ["Adm", "Eva"]) + + adam = storage.get_or_create_player(conn, "Adam") + adm = storage.get_or_create_player(conn, "Adm") + + game = storage.load_game(conn, typo) + storage.add_turn(conn, typo, game.add_score(150).turn) + + storage.merge_players(conn, adm, adam) + + assert [p["name"] for p in storage.seating(conn, typo)] == ["Adam", "Eva"] + assert storage.load_game(conn, typo).totals()["Adam"] == 150 + assert [p["id"] for p in storage.known_players(conn)].count(adm) == 0 + assert storage.load_game(conn, good).totals() == {"Adam": 0, "Eva": 0} + + +def test_hra_se_nacte_zpatky_i_s_tahy(conn): + game_id = storage.create_game(conn, ["Adam", "Eva"], final_score=500) + game = storage.load_game(conn, game_id) + + for value in (100, 50, 0, 30): + storage.add_turn(conn, game_id, game.add_score(value).turn) + + loaded = storage.load_game(conn, game_id) + + assert loaded.names == ["Adam", "Eva"] + assert loaded.final_score == 500 + assert loaded.totals() == {"Adam": 100, "Eva": 80} + assert loaded.current_player == "Adam" + assert loaded.round_number == 3 + + +def test_vynulovani_se_uklada_jako_nula_s_priznakem(conn): + game_id = storage.create_game(conn, ["Adam"], final_score=500) + game = storage.load_game(conn, game_id) + + for value in (300, 0, 0, 0): + storage.add_turn(conn, game_id, game.add_score(value).turn) + + rows = conn.execute("SELECT value, reset FROM turns ORDER BY id").fetchall() + + assert [r["value"] for r in rows] == [300, 0, 0, 0] + assert [r["reset"] for r in rows] == [0, 0, 0, 1] + assert storage.load_game(conn, game_id).totals()["Adam"] == 0 + + +def test_rozehrana_hra_se_najde_a_dokonci(conn): + game_id = storage.create_game(conn, ["Adam", "Eva"], final_score=100) + + assert storage.running_game(conn) == game_id + + game = storage.load_game(conn, game_id) + storage.add_turn(conn, game_id, game.add_score(100).turn) + result = game.add_score(10) + storage.add_turn(conn, game_id, result.turn) + storage.finish_game(conn, game_id, result.winner) + + assert storage.running_game(conn) is None + assert storage.game_row(conn, game_id)["status"] == storage.STATUS_FINISHED + assert storage.game_row(conn, game_id)["winner_name"] == "Adam" + + +def test_undo_smaze_tah_a_vrati_hru_do_hry(conn): + game_id = storage.create_game(conn, ["Adam", "Eva"], final_score=100) + game = storage.load_game(conn, game_id) + storage.add_turn(conn, game_id, game.add_score(100).turn) + result = game.add_score(10) + storage.add_turn(conn, game_id, result.turn) + storage.finish_game(conn, game_id, result.winner) + + assert storage.undo_turn(conn, game_id) + + row = storage.game_row(conn, game_id) + assert row["status"] == storage.STATUS_RUNNING + assert row["winner_id"] is None + assert storage.load_game(conn, game_id).current_player == "Eva" + + +def test_opustena_hra(conn): + game_id = storage.create_game(conn, ["Adam", "Eva"]) + storage.abandon_game(conn, game_id) + + assert storage.running_game(conn) is None + assert storage.game_row(conn, game_id)["status"] == storage.STATUS_ABANDONED + + +def test_hrac_nesmi_sedet_u_stolu_dvakrat(conn): + with pytest.raises(ValueError): + storage.create_game(conn, ["Adam", "adam"]) + + +def test_prazdne_jmeno(conn): + with pytest.raises(ValueError): + storage.get_or_create_player(conn, " ") diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..b10660e --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,238 @@ +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 "450<") + + +def test_tabule_useka_starou_historii(client): + game_id = start_game(client, "Adam", final_score=999999) + for value in range(1, 31): + score(client, game_id, value * 10) + + panel = text(client.get(f"/board/{game_id}/panel")) + + assert "a dalších 15 kol" in panel + assert "300" in panel # nejnovější kolo tam je + assert ">150<" not in panel # patnácté odspodu už ne + + +def test_kratka_hra_nic_neuseka(client): + game_id = start_game(client, "Adam", final_score=99999) + score(client, game_id, 100) + + assert "a dalších" not in text(client.get(f"/board/{game_id}/panel")) + + +@pytest.mark.parametrize("count, expected", [ + (1, "1 kolo"), (2, "2 kola"), (4, "4 kola"), (5, "5 kol"), (22, "22 kol"), +]) +def test_sklonovani_kol(count, expected): + assert web.kola(count) == expected diff --git a/web.py b/web.py new file mode 100644 index 0000000..93bba45 --- /dev/null +++ b/web.py @@ -0,0 +1,301 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["flask", "qrcode"] +# /// +"""Webové počitadlo pro domácí síť. + +Telefon zapisuje skóre (`/game/`), 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 net +import stats +import storage +from core import FINAL_SCORE, Game, GameOver + +app = Flask(__name__) + +PORT = int(os.environ.get("DICE_PORT", 8000)) + +# Kolik kol tabule ukáže. Zbytek historie je na hráčích, ne na tabuli — +# ta má ukazovat stav, ne archiv. +BOARD_ROUNDS = 15 + + +# --- 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 primary_ip() -> str: + """Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit.""" + found = net.addresses() + return found[0].ip if found else "" + + +def links(path: str = "/") -> dict: + """Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód. + + Hledá se při každém načtení — notebook se během večera může přesunout + z domácí WiFi na hotspot a zpátky. + """ + found = net.addresses() + primary = found[0] if found else None + + return { + "primary": primary, + "others": found[1:], + "port": PORT, + "target": primary.url(PORT) + path if primary else "", + } + + +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("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("qr") +def qr_filter(data: str) -> Markup: + return qr_svg(data) if data else Markup("") + + +@app.template_filter("kola") +def kola(count: int) -> str: + if count == 1: + return "1 kolo" + if 2 <= count <= 4: + return f"{count} kola" + return f"{count} kol" + + +@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) -> tuple[list[dict], int]: + """Řádky zápisníku po kolech, od nejnovějšího. + + Naposledy odehrané kolo patří nahoru — po padesáti kolech by jinak bylo + to podstatné mimo obrazovku. Vrací i počet kol, která se nevešla. + + 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}) + + rows.reverse() + hidden = max(0, len(rows) - BOARD_ROUNDS) + + return rows[:BOARD_ROUNDS], hidden + + +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 "" + rows, hidden = scoresheet(game) + return { + "game": game, + "game_id": game_id, + "row": row, + "totals": totals, + "rows": rows, + "hidden": hidden, + "streaks": {name: game.zero_streak(name) for name in game.names}, + "standings": game.standings(), + "winner": winner, + "current": "" if winner else game.current_player, + "primary_ip": primary_ip(), + } + + +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", **links(url_for("index"))) + + +@app.route("/board/") +def board(game_id: int): + game, row = load(game_id) + return render_template("board.html", + **links(url_for("game_view", game_id=game_id)), + **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)) + + +@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__": + found = net.addresses() + + if found: + print("Počitadlo je dostupné na:") + for address in found: + mark = "→" if address.primary else " " + print(f" {mark} {address.url(PORT):30} {address.label}") + print(f"\nTabule na notebook: {found[0].url(PORT)}/board") + else: + print("Adresu se nepodařilo zjistit. Spusť server s DICE_HOST=.") + + app.run(host="0.0.0.0", port=PORT, threaded=True)