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>
164 lines
5.4 KiB
Python
164 lines
5.4 KiB
Python
"""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
|