From 4743ea74b916a5634d5144dcc0d987ce3ce3dd02 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 20 Aug 2026 18:45:11 +0200 Subject: [PATCH] =?UTF-8?q?core:=20vyta=C5=BEen=C3=AD=20pravidel=20z=20CLI?= =?UTF-8?q?=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"])