core: vytažení pravidel z CLI do Game třídy

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 <noreply@anthropic.com>
This commit is contained in:
gitea-actionsandClaude Opus 5 committed 2026-08-20 18:45:11 +02:00
1 parent 5b109c2418
commit 4743ea74b9
5 files changed
+393 -114

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
__pycache__/
.venv/
*.db
.pytest_cache/
+163
View File
@@ -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
+48 -114
View File
@@ -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__":
+4
View File
@@ -0,0 +1,4 @@
flask
qrcode
tabulate
pytest
+174
View File
@@ -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"])