From 4743ea74b916a5634d5144dcc0d987ce3ce3dd02 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 20 Aug 2026 18:45:11 +0200 Subject: [PATCH 1/7] =?UTF-8?q?core:=20vyta=C5=BEen=C3=AD=20pravidel=20z?= =?UTF-8?q?=20CLI=20do=20Game=20t=C5=99=C3=ADdy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pravidla byla propletená se vstupem a výstupem (add_score se uprostřed zastavovalo na input()), takže nešla testovat ani použít z webu. - core.Game drží stav a pravidla, nic nevypisuje a na nic se neptá - součty jsou vždy odvozené z tahů, díky tomu je undo() jen zahození posledního tahu — funguje i pro vracení vynulování - vynulování se ukládá jako hodnota 0 s příznakem reset místo záporného tahu, aby do historie nepadala čísla, která nikdo nehodil - zero_streak() je jediný zdroj pravdy pro pravidlo i pro indikátor x/xx a počítá se od posledního vynulování - pořadí na tahu plyne z počtu zápisů, globální pointer zmizel - dice.py zůstává navenek stejné, jen deleguje na Game - 15 testů nad pravidly Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++ core.py | 163 ++++++++++++++++++++++++++++++++++++++++++ dice.py | 162 +++++++++++++---------------------------- requirements.txt | 4 ++ tests/test_core.py | 174 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 393 insertions(+), 114 deletions(-) create mode 100644 .gitignore create mode 100644 core.py create mode 100644 requirements.txt create mode 100644 tests/test_core.py 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/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..7e3a7d1 100644 --- a/dice.py +++ b/dice.py @@ -1,30 +1,30 @@ -import sys -from collections import Counter +"""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 +40,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 +60,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 +100,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/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/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"]) -- 2.47.3 From a45a82ae0e942dee42803735b1791814e92be5b2 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 20 Aug 2026 18:47:08 +0200 Subject: [PATCH 2/7] =?UTF-8?q?storage:=20SQLite=20s=20ark=C3=A1dovou=20id?= =?UTF-8?q?entitou=20hr=C3=A1=C4=8D=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ručně psané SQL nad pěti tabulkami, žádné ORM. - hry ukazují na players.id, nikdy na jméno, takže se přejmenování propíše i do starých her - párování hráčů napříč hrami přes normalizovaný klíč (trim, casefold), plus slučování pro případ překlepu - zapisuje se každý tah hned, ne až na konci — rozehraná hra se po pádu serveru najde podle stavu a dá se v ní pokračovat - součty se neukládají nikde, stav hry se rekonstruuje načtením tahů - časy jako ISO text, sqlite3 vlastní adaptéry pro datetime zavrhl Co-Authored-By: Claude Opus 5 --- storage.py | 289 ++++++++++++++++++++++++++++++++++++++++++ tests/test_storage.py | 134 ++++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100644 storage.py create mode 100644 tests/test_storage.py 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/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, " ") -- 2.47.3 From bcbbede2581351d0154933455f9a363d0ba9a6ec Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 20 Aug 2026 19:01:08 +0200 Subject: [PATCH 3/7] =?UTF-8?q?web:=20zad=C3=A1v=C3=A1n=C3=AD=20z=20telefo?= =?UTF-8?q?nu=20a=20z=C3=A1pisn=C3=ADk=20na=20notebooku?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flask, server-rendered HTML, formuláře přes POST/redirect/GET. Žádný build step, žádný JS framework. - / zakládá hru výběrem známých hráčů v pořadí u stolu - /game/ je zadávací pohled: kdo je na tahu, skóre, x/xx, průběžné pořadí, vrácení posledního zápisu - /board/ je zápisník po kolech; tahy před vynulováním se škrtnou červeně, sloupec hráče na tahu drží zvýrazňovač - tabule se doptává serveru každé dvě vteřiny a vyměňuje jen hlavičku a zápisník, QR s adresou zůstává mimo obnovovanou část - adresa pro telefon se hledá za běhu, QR se skládá ze čtverečků přímo do SVG, takže není potřeba Pillow - 16 funkčních testů přes Flask test client Vizuálně to stojí na tom, co appka nahrazuje: čtverečkovaný papír, modrá propiska zapisuje, červená škrtá, zvýrazňovač drží tah. Co-Authored-By: Claude Opus 5 --- static/style.css | 421 ++++++++++++++++++++++++++++++++++++++ templates/_head.html | 3 + templates/_panel.html | 3 + templates/_sheet.html | 57 ++++++ templates/base.html | 12 ++ templates/board.html | 37 ++++ templates/board_idle.html | 14 ++ templates/game.html | 78 +++++++ templates/index.html | 109 ++++++++++ tests/test_web.py | 177 ++++++++++++++++ web.py | 238 +++++++++++++++++++++ 11 files changed, 1149 insertions(+) create mode 100644 static/style.css create mode 100644 templates/_head.html create mode 100644 templates/_panel.html create mode 100644 templates/_sheet.html create mode 100644 templates/base.html create mode 100644 templates/board.html create mode 100644 templates/board_idle.html create mode 100644 templates/game.html create mode 100644 templates/index.html create mode 100644 tests/test_web.py create mode 100644 web.py 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) -- 2.47.3 From d7c63cf98bd27797a835c306aa239aa02c01d1c3 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 20 Aug 2026 19:04:50 +0200 Subject: [PATCH 4/7] =?UTF-8?q?stats:=20s=C3=AD=C5=88=20sl=C3=A1vy=20a=20k?= =?UTF-8?q?ari=C3=A9rn=C3=AD=20statistiky?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rekordy: nejvyšší tah, nejvyšší konečné skóre, nejrychlejší výhra - kariéra: počet her, výher, úspěšnost, průměrné pořadí, průměr na tah a kolikrát koho postihlo vynulování - do statistik jdou jen dohrané hry, nedohrané a opuštěné se ignorují - celkové skóre počítá SQL stejným pravidlem jako core.Game.totals(), tedy součet tahů po posledním vynulování; testem ověřeno, že se obě cesty shodují - vědomě chybí celkový součet bodů napříč hrami — cílové skóre je u každé hry jiné, metrika by odměňovala jen toho, kdo hrál nejvíc - README se spuštěním a přehledem modulů Co-Authored-By: Claude Opus 5 --- README.md | 56 +++++++++++++++++++ static/style.css | 42 +++++++++++++++ stats.py | 124 +++++++++++++++++++++++++++++++++++++++++++ templates/game.html | 1 + templates/index.html | 5 +- templates/stats.html | 112 ++++++++++++++++++++++++++++++++++++++ tests/test_stats.py | 100 ++++++++++++++++++++++++++++++++++ tests/test_web.py | 16 ++++++ web.py | 19 +++++++ 9 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100644 stats.py create mode 100644 templates/stats.html create mode 100644 tests/test_stats.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..97ac8b8 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Počitadlo v kostkách + +Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje +zápisník** a hry se pamatují mezi večery. + +Hráči se napříč hrami poznávají podle jména — Adam ze středečního večera je +ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla. + +## Spuštění + +```bash +python -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python web.py +``` + +Server vypíše adresu, na které poslouchá. Na notebooku otevři `/board`, +naskenuj QR kód telefonem a hraj. + +| Proměnná | K čemu je | Výchozí | +|---|---|---| +| `DICE_DB` | soubor s databází | `dice.db` | +| `DICE_PORT` | port | `8000` | +| `DICE_HOST` | adresa v QR kódu, když ji nemá hledat sám | zjistí se za běhu | + +Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi +může zapisovat. + +## Pravidla + +Skóre se zadává ručně po tazích, hází se doopravdy. Tři nulové tahy za sebou +vynulují hráči skóre; indikátor `x` / `xx` ukazuje, jak blízko k tomu je. +Vítěz se vyhlašuje až po dokončeném kole, aby měli všichni stejný počet tahů — +při remíze na prvním místě se hraje dál. + +## Co kde je + +| Soubor | Role | +|---|---| +| `core.py` | pravidla hry, žádný vstup ani výstup | +| `storage.py` | SQLite: hráči, hry, tahy | +| `stats.py` | síň slávy a kariérní statistiky | +| `web.py` | Flask aplikace | +| `dice.py` | totéž v terminálu, bez ukládání | + +Terminálová verze zůstává funkční jako záloha: + +```bash +.venv/bin/python dice.py +``` + +## Testy + +```bash +.venv/bin/python -m pytest tests/ -v +``` diff --git a/static/style.css b/static/style.css index 73a565f..c1f467b 100644 --- a/static/style.css +++ b/static/style.css @@ -419,3 +419,45 @@ button { @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } + +/* --- síň slávy ----------------------------------------------------------- */ + +.records { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1px; + background: var(--rule); + border: 1px solid var(--rule); + margin-bottom: calc(var(--step) * 2); +} + +.record { + background: #FFFFFF; + padding: 1.2rem 1.4rem; +} + +.record .number { + font-family: var(--font-num); + font-variant-numeric: tabular-nums; + font-size: 2.6rem; + margin: 0.2rem 0 0.3rem; + line-height: 1; +} + +.record .number small { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ink-soft); +} + +.record .who { + font-size: 0.85rem; + color: var(--ink-soft); + margin: 0; +} + +.sheet .name { + font-family: var(--font-ui); + font-size: 1rem; + color: var(--ink); +} 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/templates/game.html b/templates/game.html index 6287e2c..c484238 100644 --- a/templates/game.html +++ b/templates/game.html @@ -72,6 +72,7 @@ diff --git a/templates/index.html b/templates/index.html index cb0a96f..9ef6be7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -47,7 +47,10 @@ - +