Webové počitadlo na LAN s historií her #2

Merged
david-spacil merged 7 commits from agents/claude/web-a-historie into main 2026-08-20 21:24:57 +02:00
26 changed files with 2889 additions and 113 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
__pycache__/
.venv/
*.db
.pytest_cache/
+102
View File
@@ -0,0 +1,102 @@
# Počitadlo v kostkách
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
zápisník** a hry se pamatují mezi večery.
Hráči se napříč hrami poznávají podle jména — Adam ze středečního večera je
ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla.
## Spuštění
```bash
uv run web.py
```
To je celé. Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará
sám — není co instalovat ani aktivovat.
Server vypíše všechny adresy, na kterých je dostupný:
```
Počitadlo je dostupné na:
→ http://10.186.234.182:8000 místní síť
http://100.91.0.24:8000 přes Tailscale
http://172.17.0.1:8000 virtuální síť
```
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další.
| Proměnná | K čemu je | Výchozí |
|---|---|---|
| `DICE_DB` | soubor s databází | `dice.db` |
| `DICE_PORT` | port | `8000` |
| `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu |
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
může zapisovat.
## Když se telefon nepřipojí
Adresy se hledají při každém načtení tabule, takže přechod z domácí WiFi na
hotspot a zpátky si tabule pohlídá sama a načte se znovu.
Zbývají tři důvody, proč se telefon nedostane na notebook:
- **Telefon je na mobilních datech**, notebook na WiFi. Různé sítě, nepotkají
se. Připoj telefon na stejnou WiFi, nebo notebook na hotspot telefonu —
to funguje taky.
- **Zapnutý hotspot na telefonu shodil jeho WiFi.** Většina Androidů to dělá.
Pak je řešením připojit na ten hotspot i notebook.
- **VPN nebo Tailscale exit node na telefonu** posílá veškerý provoz mimo
místní síť. V Tailscale to řeší přepínač „Allow local network access";
případně použij tailnet adresu notebooku, ta funguje odkudkoli.
## Pravidla
Skóre se zadává ručně po tazích, hází se doopravdy. Tři nulové tahy za sebou
vynulují hráči skóre; indikátor `x` / `xx` ukazuje, jak blízko k tomu je.
Vítěz se vyhlašuje až po dokončeném kole, aby měli všichni stejný počet tahů —
při remíze na prvním místě se hraje dál.
## Co kde je
| Soubor | Role |
|---|---|
| `core.py` | pravidla hry, žádný vstup ani výstup |
| `net.py` | hledání adres, na kterých je počitadlo dostupné |
| `storage.py` | SQLite: hráči, hry, tahy |
| `stats.py` | síň slávy a kariérní statistiky |
| `web.py` | Flask aplikace |
| `dice.py` | totéž v terminálu, bez ukládání |
Terminálová verze zůstává funkční jako záloha:
```bash
uv run dice.py
```
## Bez uv
Když `uv` po ruce není, jde to postaru:
```bash
python -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python web.py
```
Na Fedoře stačí i systémové balíčky, pak se nemusí řešit vůbec nic:
```bash
sudo dnf install python3-flask python3-qrcode python3-tabulate
python3 web.py
```
## Testy
```bash
uv run --with-requirements requirements.txt --no-project pytest
```
Nebo z připraveného prostředí prostě `pytest`.
+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
+51 -113
View File
@@ -1,30 +1,34 @@
import sys
from collections import Counter
# /// script
# requires-python = ">=3.11"
# dependencies = ["tabulate"]
# ///
"""Počitadlo skóre v terminálu.
Pravidla žijí v `core.Game`, tenhle modul je jen vstup a výstup. Hraje se
v paměti, nic se neukládá — na historii je webová aplikace (`web.py`).
"""
from os import name, system
from tabulate import tabulate
hraci = {}
score = {}
pointer = ""
first = ""
final_score = 10000
from core import FINAL_SCORE, Game
table_header = ["Hráč", "Skóre"]
def clear() -> None:
_ = system('cls') if name == 'nt' else system('clear')
def char_create() -> bool:
global pointer
global first
global final_score
def char_create() -> tuple[list[str], int]:
"""Zeptá se na hráče a cílové skóre."""
hraci: list[str] = []
while True:
inp: str = input("Zadejte jméno prvního hráče: ")
if inp:
hraci[inp] = []
score[inp] = 0
pointer = inp
first = inp
hraci.append(inp)
clear()
print(f"Hráč/ka {inp} přidán(a).")
break
@@ -40,12 +44,13 @@ def char_create() -> bool:
print("Je potřeba zadávat unikátní jména.")
else:
clear()
hraci[inp] = []
score[inp] = 0
hraci.append(inp)
print(f"Hráč/ka {inp} přidán(a).")
else:
break
final_score: int = FINAL_SCORE
while True:
inp = input("Zadejte finální skóre (nechte prázdné pro výchozích 10 000): ")
if inp != "":
@@ -59,103 +64,39 @@ def char_create() -> bool:
else:
break
return True
return hraci, final_score
def next_player() -> None:
global pointer
keys_iter = iter(hraci)
for key in keys_iter:
if key == pointer:
nxt_key = next(keys_iter, first)
break
pointer = nxt_key
table_header = ["Hráč", "Skóre"]
def table():
def table(game: Game) -> str:
hraci_tab = []
for h, h_val in score.items():
hrac = h
if pointer == h:
hrac = "> "+h
hraci_tab.append([hrac, h_val])
for hrac, hodnota in game.totals().items():
if game.current_player == hrac:
hrac = "> " + hrac
hraci_tab.append([hrac, hodnota])
return tabulate(hraci_tab, headers=table_header, tablefmt="fancy_grid")
def add_score(hrac, hodnota):
if hodnota == 0:
last_two = hraci[hrac][-2:]
if len(last_two) > 1:
last_two_sum = sum(last_two)
if last_two_sum == 0:
hodnota = -sum(hraci[hrac])
clear()
input("Po třech nulových hodech bylo vaše skóre vynulováno.")
hraci[hrac].append(hodnota)
score[hrac] = sum(hraci[hrac])
def to_sorted_tuple(d: dict[str, int], s: bool = True) -> tuple[list[tuple[int, str, int]],
dict[str, int]]:
L: list[tuple[int, str, int]] = []
if s:
d = dict(sorted(d.items(), key=lambda x: x[1], reverse=True))
for i, (k, v) in enumerate(d.items()):
L.append((i+1, k, v))
return L, d
def check_win() -> str:
over_limit: dict[str, int] = {}
for p in score:
if score[p] >= final_score:
over_limit[p] = score[p]
winner: str = ""
if over_limit:
L, d = to_sorted_tuple(over_limit)
count: Counter = Counter(list(d.values()))
for c in count:
if count[c] == 1:
winner = L[0][1]
break
return winner
def win(winner: str, vitezne_skore: int) -> None:
results, d = to_sorted_tuple(score)
def win(game: Game, winner: str) -> None:
headers = ["Pořadí", "Hráč", "Skóre"]
vysledky = tabulate(results, headers=headers, tablefmt="fancy_grid")
vysledky = tabulate(game.standings(), headers=headers, tablefmt="fancy_grid")
print(f"Vítězem se stává {winner} s {vitezne_skore} body!")
print(f"Vítězem se stává {winner} s {game.totals()[winner]} body!")
print("Díky za hru a zase příště.\n")
print(vysledky)
def game():
def play(game: Game) -> None:
while True:
clear()
print(table())
print(table(game))
strikes: str = ""
pointer = game.current_player
strikes: str = "x" * game.zero_streak(pointer)
if len(hraci[pointer]) > 0:
if hraci[pointer][-1] == 0:
strikes = "x"
if len(hraci[pointer]) > 1 and hraci[pointer][-2] == 0:
strikes += "x"
else:
strikes = ""
inp = input(f"Zadejte skóre hráče {pointer} ({score[pointer]}{strikes}/{final_score}): ")
inp = input(f"Zadejte skóre hráče {pointer} "
f"({game.totals()[pointer]}{strikes}/{game.final_score}): ")
try:
inp_int = int(inp)
except ValueError:
@@ -163,30 +104,27 @@ def game():
input("Zadejte prosím platnou číselnou hodnotu.")
continue
add_score(pointer, inp_int)
next_player()
result = game.add_score(inp_int)
if pointer == first:
winner: str = check_win()
if winner:
clear()
win(winner, score[winner])
break
if result.was_reset:
clear()
input("Po třech nulových hodech bylo vaše skóre vynulováno.")
if result.winner:
clear()
win(game, result.winner)
break
def main() -> None:
clear()
if char_create():
clear()
print("Vytvoření hráči:", ", ".join(list(hraci.keys())))
else:
print("Nastala chyba při vytváření uživatelů.")
sys.exit()
hraci, final_score = char_create()
clear()
print("Vytvoření hráči:", ", ".join(hraci))
input("Stiskněte libovolnou klávesu pro pokračování. ")
game()
play(Game(hraci, final_score))
if __name__ == "__main__":
+151
View File
@@ -0,0 +1,151 @@
"""Zjištění adres, na kterých je počitadlo dostupné.
Telefon a notebook se musí potkat na stejné síti a která to je, se během
večera mění — domácí WiFi, hotspot z telefonu, tailnet. Proto se adresy
hledají za běhu, hledá se jich víc a řadí se podle toho, jak pravděpodobně
povedou k cíli. Vybrat si může člověk; my jen nabídneme.
Nic se tu nezakazuje podle jména konkrétního rozhraní — rozhoduje rozsah
adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované.
"""
import ipaddress
import os
import socket
from dataclasses import dataclass
# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT).
TAILNET = ipaddress.ip_network("100.64.0.0/10")
# Obvyklé předpony virtuálních rozhraní. Adresy z nich nezahazujeme, jen je
# řadíme nakonec — dostat se přes ně k počitadlu jde jen výjimečně.
VIRTUAL = ("docker", "br-", "virbr", "veth", "vmnet", "vboxnet", "tun", "tap")
KIND_LABELS = {
"lan": "místní síť",
"tailnet": "přes Tailscale",
"public": "veřejná adresa",
"virtual": "virtuální síť",
}
ORDER = {"lan": 0, "tailnet": 1, "public": 2, "virtual": 3}
@dataclass
class Address:
ip: str
interface: str
kind: str
primary: bool = False
@property
def label(self) -> str:
return KIND_LABELS.get(self.kind, self.kind)
def url(self, port: int) -> str:
return f"http://{self.ip}:{port}"
def classify(ip: str, interface: str) -> str | None:
"""Typ adresy podle jejího rozsahu. None znamená nepoužitelná."""
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return None
if addr.is_loopback or addr.is_link_local or addr.is_unspecified:
return None
if addr in TAILNET:
return "tailnet"
if interface.startswith(VIRTUAL):
return "virtual"
return "lan" if addr.is_private else "public"
def rank(found: list[tuple[str, str]], primary: str | None) -> list[Address]:
"""Seřadí nalezené dvojice (rozhraní, adresa).
První je adresa, přes kterou vede výchozí trasa — na té síti je telefon
nejspíš taky. Zbytek podle typu. Čistá funkce, ať jde otestovat i to,
co zrovna není v systému.
"""
seen: dict[str, Address] = {}
for interface, ip in found:
kind = classify(ip, interface)
if kind is None or ip in seen:
continue
seen[ip] = Address(ip=ip, interface=interface, kind=kind,
primary=(ip == primary))
return sorted(seen.values(),
key=lambda a: (not a.primary, ORDER.get(a.kind, 9), a.ip))
def default_route_address() -> str | None:
"""Adresa rozhraní, kudy vede výchozí trasa.
Připojení UDP socketu nic neposílá, jen si vyžádá směrování.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("10.255.255.255", 1))
return sock.getsockname()[0]
except OSError:
return None
finally:
sock.close()
def _from_interfaces() -> list[tuple[str, str]]:
"""Adresy všech rozhraní. Linux, jen standardní knihovna."""
try:
import fcntl
import struct
except ImportError:
return []
siocgifaddr = 0x8915
found = []
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
for _index, name in socket.if_nameindex():
try:
packed = struct.pack("256s", name.encode()[:15])
info = fcntl.ioctl(sock.fileno(), siocgifaddr, packed)
found.append((name, socket.inet_ntoa(info[20:24])))
except OSError:
continue # rozhraní bez IPv4 adresy
return found
def _from_hostname() -> list[tuple[str, str]]:
"""Záloha pro systémy, kde předchozí cesta nefunguje."""
try:
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
except OSError:
return []
return [("", info[4][0]) for info in infos]
def addresses() -> list[Address]:
"""Kde všude je počitadlo k dispozici, od nejpravděpodobnější adresy.
`DICE_HOST` má poslední slovo — když je nastavená, platí jen ona.
"""
forced = os.environ.get("DICE_HOST")
if forced:
return [Address(ip=forced, interface="", kind="lan", primary=True)]
primary = default_route_address()
found = _from_interfaces() or _from_hostname()
if primary and primary not in [ip for _name, ip in found]:
found.append(("", primary))
return rank(found, primary)
+4
View File
@@ -0,0 +1,4 @@
[pytest]
# Testy sahají na moduly v kořeni repa, ne na nainstalovaný balíček.
pythonpath = .
testpaths = tests
+4
View File
@@ -0,0 +1,4 @@
flask
qrcode
tabulate
pytest
+495
View File
@@ -0,0 +1,495 @@
/* Počitadlo v kostkách.
Vizuálně stojí na tom, co nahrazuje: čtverečkovaný papír a dvě propisky —
modrá zapisuje, červená škrtá. Zvýrazňovač ukazuje, kdo je na tahu. */
:root {
color-scheme: light;
--paper: #FBFAF6;
--grid: rgba(27, 59, 111, 0.06);
--rule: #C3D0E2;
--ink: #1B3B6F;
--ink-soft: #6C7C9B;
--red: #CB2B25;
--marker: #FFE7A0;
--font-voice: Georgia, "Times New Roman", serif;
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-num: ui-monospace, "SF Mono", "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
--step: 1.5rem;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: var(--step);
min-height: 100vh;
color: var(--ink);
font-family: var(--font-ui);
background-color: var(--paper);
background-image:
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 24px 24px;
}
a { color: var(--ink); }
/* --- hlavička ------------------------------------------------------------ */
.masthead {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--step);
flex-wrap: wrap;
border-bottom: 2px solid var(--ink);
padding-bottom: 0.6rem;
margin-bottom: var(--step);
}
.masthead h1 {
font-family: var(--font-voice);
font-size: clamp(1.6rem, 4vw, 2.4rem);
font-weight: 400;
letter-spacing: -0.01em;
margin: 0;
}
.eyebrow {
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-soft);
margin: 0 0 0.2rem;
}
.meta {
font-family: var(--font-num);
font-size: 0.85rem;
color: var(--ink-soft);
}
/* --- zápisník ------------------------------------------------------------ */
.sheet-wrap { overflow-x: auto; }
.sheet {
border-collapse: collapse;
width: 100%;
background: #FFFFFF;
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
}
.sheet th,
.sheet td {
border: 1px solid var(--rule);
padding: 0.7rem 1.2rem;
text-align: right;
white-space: nowrap;
}
.sheet thead th {
font-family: var(--font-ui);
font-size: 0.9rem;
letter-spacing: 0.1em;
text-transform: uppercase;
font-weight: 600;
color: var(--ink-soft);
border-bottom-width: 2px;
border-bottom-color: var(--ink);
}
.sheet .label {
text-align: left;
color: var(--ink-soft);
font-size: 0.85rem;
width: 1%; /* sloupec s číslem kola se smrskne na obsah */
white-space: nowrap;
}
.sheet tbody td { font-size: 1.5rem; }
.sheet .empty { color: var(--rule); }
/* Zvýrazňovač na sloupci hráče, který je na tahu. Nerovnoměrný, jako tah fixem. */
.marked {
background: linear-gradient(
180deg,
rgba(255, 231, 160, 0.35) 0%,
rgba(255, 231, 160, 0.9) 18%,
rgba(255, 231, 160, 0.75) 82%,
rgba(255, 231, 160, 0.3) 100%
);
}
/* Škrtnuté zápisy před vynulováním. */
.struck {
color: var(--ink-soft);
text-decoration: line-through;
text-decoration-color: var(--red);
text-decoration-thickness: 2px;
}
.wiped { color: var(--red); }
.wiped small {
display: block;
font-family: var(--font-ui);
font-size: 0.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sheet .totals td {
border-bottom: 2px solid var(--ink);
font-size: 2.1rem;
font-weight: 700;
}
.sheet .totals .label { font-weight: 400; font-size: 0.85rem; }
.sheet .more td {
text-align: left;
color: var(--ink-soft);
font-size: 0.85rem;
font-family: var(--font-ui);
}
/* --- nuly za sebou ------------------------------------------------------- */
.ticks {
color: var(--red);
font-size: 0.9em;
letter-spacing: 0.15em;
}
.warning {
color: var(--red);
font-size: 0.85rem;
margin: 0.6rem 0 0;
}
/* --- zadávací pohled ----------------------------------------------------- */
.phone { max-width: 30rem; margin: 0 auto; }
.turn {
border: 2px solid var(--ink);
background: var(--paper);
padding: 1.2rem;
margin-bottom: var(--step);
}
.turn h2 {
font-family: var(--font-voice);
font-size: clamp(2rem, 9vw, 3rem);
font-weight: 400;
margin: 0.1rem 0 0.4rem;
line-height: 1.05;
}
.progress {
font-family: var(--font-num);
font-size: 1.1rem;
color: var(--ink-soft);
margin: 0;
}
.progress strong { color: var(--ink); font-size: 1.35rem; }
input[type="number"] {
width: 100%;
font-family: var(--font-num);
font-size: 1.6rem;
padding: 0.7rem 0.8rem;
color: var(--ink);
background: transparent;
border: 1px solid var(--rule);
border-radius: 0;
}
input[type="text"] {
width: 100%;
font-family: var(--font-ui);
font-size: 1.1rem;
padding: 0.7rem 0.8rem;
color: var(--ink);
background: transparent;
border: 1px solid var(--rule);
border-radius: 0;
}
input:focus-visible,
button:focus-visible,
a:focus-visible {
outline: 3px solid var(--red);
outline-offset: 2px;
}
.field { margin-bottom: 0.8rem; }
.field label {
display: block;
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
margin-bottom: 0.35rem;
}
button {
font-family: var(--font-ui);
font-size: 1rem;
cursor: pointer;
border-radius: 0;
}
.btn {
width: 100%;
padding: 0.9rem;
border: 2px solid var(--ink);
background: var(--ink);
color: var(--paper);
font-weight: 600;
letter-spacing: 0.02em;
}
.btn:hover { background: #16305c; }
.btn-quiet {
background: transparent;
color: var(--ink);
border: 1px solid var(--rule);
font-weight: 400;
}
.btn-quiet:hover { border-color: var(--ink); }
.btn-danger { color: var(--red); border-color: var(--red); }
.actions {
display: flex;
gap: 0.6rem;
margin-top: var(--step);
}
.actions form { flex: 1; }
.actions button { width: 100%; padding: 0.75rem 0.5rem; }
.standings {
width: 100%;
border-collapse: collapse;
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
}
.standings td { padding: 0.45rem 0; border-bottom: 1px solid var(--rule); }
.standings td:last-child { text-align: right; font-weight: 600; }
.standings .rank { color: var(--ink-soft); width: 2.5rem; }
.standings .self { font-family: var(--font-ui); }
.error {
border-left: 3px solid var(--red);
color: var(--red);
padding: 0.5rem 0.8rem;
margin-bottom: var(--step);
font-size: 0.9rem;
}
/* --- výběr hráčů --------------------------------------------------------- */
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.8rem;
}
.chip {
font-family: var(--font-ui);
font-size: 1rem;
padding: 0.7rem 1rem;
border: 1px solid var(--rule);
background: transparent;
color: var(--ink);
display: inline-flex;
align-items: baseline;
gap: 0.5rem;
}
.chip[aria-pressed="true"] {
border-color: var(--ink);
background: var(--marker);
font-weight: 600;
}
.chip .seat {
font-family: var(--font-num);
font-size: 0.75rem;
color: var(--ink-soft);
}
.chip[aria-pressed="true"] .seat { color: var(--ink); }
.hint {
color: var(--ink-soft);
font-size: 0.85rem;
margin: 0 0 var(--step);
}
.add-player { display: flex; gap: 0.5rem; }
.add-player input { flex: 1; }
.add-player button { padding: 0 1rem; border: 1px solid var(--ink); background: transparent; }
.notice {
border: 1px solid var(--rule);
border-left: 3px solid var(--ink);
padding: 0.8rem 1rem;
margin-bottom: var(--step);
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
/* --- tabule -------------------------------------------------------------- */
.board { max-width: 64rem; margin: 0 auto; }
.join {
display: flex;
align-items: center;
gap: 0.9rem;
}
.join .address {
font-family: var(--font-num);
font-size: 0.95rem;
line-height: 1.4;
}
.join .address span {
display: block;
font-family: var(--font-ui);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
}
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
.idle {
text-align: center;
padding-top: 12vh;
}
.idle .join { justify-content: center; margin-top: var(--step); }
.idle .qr { width: 9rem; height: 9rem; }
/* --- vyhlášení ----------------------------------------------------------- */
.win {
border: 2px solid var(--ink);
padding: 1.4rem var(--step);
margin-bottom: var(--step);
background: var(--marker);
}
.win h2 {
font-family: var(--font-voice);
font-size: clamp(1.8rem, 5vw, 3rem);
font-weight: 400;
margin: 0.2rem 0 0.4rem;
}
.win p { margin: 0; color: var(--ink-soft); }
/* --- odkazy dole --------------------------------------------------------- */
.footer {
margin-top: calc(var(--step) * 2);
padding-top: 0.8rem;
border-top: 1px solid var(--rule);
font-size: 0.85rem;
color: var(--ink-soft);
display: flex;
gap: 1.2rem;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
/* --- síň slávy ----------------------------------------------------------- */
.records {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
margin-bottom: calc(var(--step) * 2);
}
.record {
background: #FFFFFF;
padding: 1.2rem 1.4rem;
}
.record .number {
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
font-size: 2.6rem;
margin: 0.2rem 0 0.3rem;
line-height: 1;
}
.record .number small {
font-family: var(--font-ui);
font-size: 0.9rem;
color: var(--ink-soft);
}
.record .who {
font-size: 0.85rem;
color: var(--ink-soft);
margin: 0;
}
.sheet .name {
font-family: var(--font-ui);
font-size: 1rem;
color: var(--ink);
}
/* --- další cesty k počitadlu --------------------------------------------- */
.alt {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
font-size: 0.8rem;
color: var(--ink-soft);
}
.alt li { white-space: nowrap; }
.alt em {
font-family: var(--font-ui);
font-style: normal;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.address code {
font-family: var(--font-num);
font-size: 0.85em;
color: var(--ink);
}
+124
View File
@@ -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
+289
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
{# data-address si hlídá tabule: když se notebook přesune na jinou síť,
je v QR kódu stará adresa a stránka se sama načte znovu. #}
<p class="eyebrow">Zápisník · hra {{ game_id }}</p>
<h1>{% if winner %}Konec hry{% else %}Na tahu {{ current }}{% endif %}</h1>
<p class="meta">do {{ game.final_score|cislo }} bodů · kolo {{ game.round_number }}</p>
+22
View File
@@ -0,0 +1,22 @@
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
{% if primary %}
<div class="join">
{{ target|qr }}
<div class="address">
<span>Zapisuje se z telefonu</span>
{{ target }}
{% if others %}
<ul class="alt">
{% for address in others %}
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% else %}
<div class="join">
<p class="address"><span>Adresu se nepodařilo zjistit</span>
Spusť server s <code>DICE_HOST=&lt;adresa&gt;</code>.</p>
</div>
{% endif %}
+3
View File
@@ -0,0 +1,3 @@
{# Fragment pro tabuli: obě části, které se během hry mění. #}
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
<div id="sheet">{% include "_sheet.html" %}</div>
+66
View File
@@ -0,0 +1,66 @@
{% if winner %}
<div class="win">
<p class="eyebrow">Konec hry</p>
<h2>{{ winner }}</h2>
<p>Vyhrává s {{ totals[winner]|cislo }} body z cílových {{ game.final_score|cislo }}.</p>
</div>
{% endif %}
<div class="sheet-wrap">
<table class="sheet">
<thead>
<tr>
<th class="label">Kolo</th>
{% for name in game.names %}
<th class="{{ 'marked' if name == current }}">{{ name }}</th>
{% endfor %}
</tr>
</thead>
{# Součty patří nahoru: po padesáti kolech by je jinak nikdo neviděl. #}
<tbody class="totals">
<tr>
<td class="label">Celkem</td>
{% for name in game.names %}
<td class="{{ 'marked' if name == current }}">
{{ totals[name]|cislo }}
{%- if streaks[name] %} <span class="ticks">{{ '✕' * streaks[name] }}</span>{% endif %}
</td>
{% endfor %}
</tr>
</tbody>
<tbody>
{% for row in rows %}
<tr>
<td class="label">{{ row.round }}</td>
{% for cell in row.cells %}
<td class="{{ 'marked' if game.names[loop.index0] == current }}
{{ 'struck' if cell.struck }} {{ 'wiped' if cell.reset }}">
{%- if cell.value is none -%}
<span class="empty">·</span>
{%- elif cell.reset -%}
0<small>vynulováno</small>
{%- else -%}
{{ cell.value|cislo }}
{%- endif -%}
</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td class="label">1</td>
{% for name in game.names %}
<td class="{{ 'marked' if name == current }}"><span class="empty">·</span></td>
{% endfor %}
</tr>
{% endfor %}
{% if hidden %}
<tr class="more">
<td class="label" colspan="{{ game.names|length + 1 }}">… a dalších {{ hidden|kola }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Kostky{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Zápisník — hra {{ game_id }}{% endblock %}
{% block body %}
<div class="board">
<header class="masthead">
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
{% include "_join.html" %}
</header>
<div id="sheet">{% include "_sheet.html" %}</div>
</div>
<script>
// Tabule se doptává serveru; není co udržovat na klientovi.
const url = "{{ url_for('board_panel', game_id=game_id) }}";
setInterval(async () => {
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return;
const fresh = new DOMParser().parseFromString(await response.text(), "text/html");
// Notebook se přesunul na jinou síť — adresa v QR kódu už neplatí.
const head = fresh.getElementById("head");
if (head && head.dataset.address !== document.getElementById("head").dataset.address) {
location.reload();
return;
}
for (const id of ["head", "sheet"]) {
const updated = fresh.getElementById(id);
if (updated) document.getElementById(id).replaceWith(updated);
}
} catch (error) {
// Server je pryč — zkusíme to za dvě vteřiny znovu.
}
}, 2000);
</script>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Zápisník{% endblock %}
{% block body %}
<div class="board idle">
<p class="eyebrow">Zápisník</p>
<h1>Zatím se nehraje</h1>
<p class="hint">Naskenuj kód telefonem a založ hru.</p>
{% include "_join.html" %}
</div>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{% if winner %}Konec hry{% else %}Na tahu {{ game.current_player }}{% endif %}{% endblock %}
{% block body %}
<div class="phone">
<header class="masthead">
<div>
<p class="eyebrow">Hra {{ game_id }}</p>
<h1>Kostky</h1>
</div>
<p class="meta">do {{ game.final_score|cislo }} · kolo {{ game.round_number }}</p>
</header>
{% if chyba %}
<p class="error">{{ chyba }}</p>
{% endif %}
{% if winner %}
<div class="win">
<p class="eyebrow">Konec hry</p>
<h2>{{ winner }}</h2>
<p>Vyhrává s {{ totals[winner]|cislo }} body z cílových {{ game.final_score|cislo }}.</p>
</div>
{% else %}
<div class="turn">
<p class="eyebrow">Na tahu</p>
<h2>{{ game.current_player }}</h2>
<p class="progress">
<strong>{{ totals[game.current_player]|cislo }}</strong> / {{ game.final_score|cislo }}
{%- if streaks[game.current_player] %}
<span class="ticks">{{ '✕' * streaks[game.current_player] }}</span>
{%- endif %}
</p>
{% if streaks[game.current_player] == 2 %}
<p class="warning">Ještě jedna nula a skóre jde pryč.</p>
{% endif %}
<form method="post" action="{{ url_for('add_turn', game_id=game_id) }}" style="margin-top: 1.2rem">
<div class="field">
<label for="value">Skóre za tah</label>
<input type="number" name="value" id="value" inputmode="numeric" autofocus required>
</div>
<button class="btn" type="submit">Zapsat</button>
</form>
</div>
{% endif %}
<p class="eyebrow">Průběžné pořadí</p>
<table class="standings">
{% for rank, name, total in standings %}
<tr>
<td class="rank">{{ rank }}.</td>
<td class="self">{{ name }}</td>
<td>
{{ total|cislo }}
{%- if streaks[name] %} <span class="ticks">{{ '✕' * streaks[name] }}</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
<div class="actions">
<form method="post" action="{{ url_for('undo', game_id=game_id) }}">
<button class="btn-quiet" type="submit" {{ 'disabled' if not game.turns }}>Vrátit poslední zápis</button>
</form>
{% if not winner %}
<form method="post" action="{{ url_for('abandon', game_id=game_id) }}">
<button class="btn-quiet btn-danger" type="submit">Ukončit hru</button>
</form>
{% endif %}
</div>
<p class="footer">
<a href="{{ url_for('board', game_id=game_id) }}">Zápisník</a>
<a href="{{ url_for('hall') }}">Síň slávy</a>
<a href="{{ url_for('index') }}">Nová hra</a>
</p>
</div>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}Nová hra{% endblock %}
{% block body %}
<div class="phone">
<header class="masthead">
<div>
<p class="eyebrow">Počitadlo</p>
<h1>Kostky</h1>
</div>
</header>
{% if request.args.get('chyba') %}
<p class="error">{{ request.args.get('chyba') }}</p>
{% endif %}
{% if running %}
<div class="notice">
<span>Rozehraná hra {{ running }} čeká.</span>
<a href="{{ url_for('game_view', game_id=running) }}">Pokračovat</a>
</div>
{% endif %}
<form method="post" action="{{ url_for('new_game') }}">
<p class="eyebrow">Kdo hraje</p>
<p class="hint">Ťukej jména v pořadí, jak sedíte kolem stolu.</p>
<div class="chips" id="chips">
{% for player in players %}
<button type="button" class="chip" aria-pressed="false" data-name="{{ player.name }}">
{{ player.name }}<span class="seat"></span>
</button>
{% endfor %}
</div>
<div class="add-player">
<input type="text" id="new-name" placeholder="Nový hráč" autocomplete="off">
<button type="button" id="add">Přidat</button>
</div>
<div class="field" style="margin-top: 1.5rem">
<label for="final">Cílové skóre</label>
<input type="number" name="final_score" id="final" value="{{ final_score }}" inputmode="numeric">
</div>
<div id="chosen"></div>
<button class="btn" type="submit">Začít hru</button>
</form>
<p class="footer">
<a href="{{ url_for('board_redirect') }}">Zápisník</a>
<a href="{{ url_for('hall') }}">Síň slávy</a>
</p>
</div>
<script>
const chips = document.getElementById("chips");
const chosen = [];
function render() {
for (const chip of chips.querySelectorAll(".chip")) {
const seat = chosen.indexOf(chip.dataset.name);
chip.setAttribute("aria-pressed", seat > -1);
chip.querySelector(".seat").textContent = seat > -1 ? seat + 1 : "";
}
// Pořadí odeslaných polí je pořadí u stolu.
document.getElementById("chosen").replaceChildren(...chosen.map(name => {
const field = document.createElement("input");
field.type = "hidden";
field.name = "player";
field.value = name;
return field;
}));
}
chips.addEventListener("click", event => {
const chip = event.target.closest(".chip");
if (!chip) return;
const seat = chosen.indexOf(chip.dataset.name);
if (seat > -1) chosen.splice(seat, 1);
else chosen.push(chip.dataset.name);
render();
});
const field = document.getElementById("new-name");
function addPlayer() {
const name = field.value.trim();
if (!name) return;
if (!chips.querySelector(`[data-name="${CSS.escape(name)}"]`)) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "chip";
chip.dataset.name = name;
chip.append(name, Object.assign(document.createElement("span"), { className: "seat" }));
chips.append(chip);
}
if (!chosen.includes(name)) chosen.push(name);
field.value = "";
render();
}
document.getElementById("add").addEventListener("click", addPlayer);
field.addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); addPlayer(); }
});
</script>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}Síň slávy{% endblock %}
{% block body %}
<div class="board">
<header class="masthead">
<div>
<p class="eyebrow">Počitadlo</p>
<h1>Síň slávy</h1>
</div>
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a></p>
</header>
{% if records.best_turn %}
<div class="records">
<article class="record">
<p class="eyebrow">Nejvyšší tah</p>
<p class="number">{{ records.best_turn.value|cislo }}</p>
<p class="who">{{ records.best_turn.name }} · hra {{ records.best_turn.game_id }}
· {{ records.best_turn.at|datum }}</p>
</article>
{% if records.best_score %}
<article class="record">
<p class="eyebrow">Nejvyšší konečné skóre</p>
<p class="number">{{ records.best_score.total|cislo }}</p>
<p class="who">{{ records.best_score.name }} · hra {{ records.best_score.game_id }}
· {{ records.best_score.finished_at|datum }}</p>
</article>
{% endif %}
{% if records.fastest %}
<article class="record">
<p class="eyebrow">Nejrychlejší výhra</p>
<p class="number">{{ records.fastest.rounds }}<small> kol</small></p>
<p class="who">{{ records.fastest.name }} · hra {{ records.fastest.game_id }}
· {{ records.fastest.finished_at|datum }}</p>
</article>
{% endif %}
</div>
{% else %}
<p class="hint">Zatím se neodehrál ani jeden tah. Rekordy přibudou po první hře.</p>
{% endif %}
{% if careers %}
<p class="eyebrow">Kariéra</p>
<p class="hint">Počítají se jen dohrané hry.</p>
<div class="sheet-wrap">
<table class="sheet">
<thead>
<tr>
<th class="label">Hráč</th>
<th>Her</th>
<th>Výher</th>
<th>Úspěšnost</th>
<th>Průměrné pořadí</th>
<th>Průměr na tah</th>
<th>Vynulování</th>
</tr>
</thead>
<tbody>
{% for player in careers %}
<tr>
<td class="label name">{{ player.name }}</td>
<td>{{ player.games }}</td>
<td>{{ player.wins }}</td>
<td>{{ (player.share * 100)|round|int }} %</td>
<td>{{ '%.1f'|format(player.placing) }}</td>
<td>{{ player.per_turn|round|int|cislo }}</td>
<td class="{{ 'wiped' if player.wipeouts }}">{{ player.wipeouts }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if games %}
<p class="eyebrow" style="margin-top: 2.5rem">Odehrané hry</p>
<div class="sheet-wrap">
<table class="sheet">
<thead>
<tr>
<th class="label">Hra</th>
<th class="label">Datum</th>
<th>Hráčů</th>
<th>Kol</th>
<th>Cíl</th>
<th class="label">Výsledek</th>
</tr>
</thead>
<tbody>
{% for game in games %}
<tr>
<td class="label">{{ game.id }}</td>
<td class="label">{{ game.started_at|datum }}</td>
<td>{{ game.players }}</td>
<td>{{ (game.turns / game.players)|round(0, 'ceil')|int }}</td>
<td>{{ game.final_score|cislo }}</td>
<td class="label name">
{%- if game.winner_name %}Vyhrává {{ game.winner_name }}
{%- elif game.status == 'probíhá' %}<a href="{{ url_for('game_view', game_id=game.id) }}">Pokračovat</a>
{%- else %}{{ game.status }}{% endif -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endblock %}
+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"])
+92
View File
@@ -0,0 +1,92 @@
import pytest
import net
@pytest.mark.parametrize("ip, interface, expected", [
("127.0.0.1", "lo", None), # loopback
("169.254.3.7", "wlp44s0", None), # link-local, adresa bez DHCP
("0.0.0.0", "eth0", None),
("nesmysl", "eth0", None),
("192.168.1.20", "wlp44s0", "lan"),
("10.186.234.182", "wlp44s0", "lan"), # hotspot z telefonu
("172.20.10.3", "en0", "lan"), # hotspot z iPhonu
("100.91.0.24", "tailscale0", "tailnet"),
("172.17.0.1", "docker0", "virtual"),
("192.168.122.1", "virbr0", "virtual"),
("10.0.0.5", "br-2f1a798d", "virtual"),
("93.184.216.34", "eth0", "public"),
])
def test_klasifikace_podle_rozsahu(ip, interface, expected):
assert net.classify(ip, interface) == expected
def test_tailnet_pozna_i_jine_rozhrani():
"""Rozhoduje rozsah adresy, ne jméno rozhraní."""
assert net.classify("100.64.0.1", "eth0") == "tailnet"
assert net.classify("100.127.255.254", "") == "tailnet"
assert net.classify("100.128.0.1", "eth0") == "public" # už mimo rozsah
def test_poradi_adres():
found = [
("docker0", "172.17.0.1"),
("tailscale0", "100.91.0.24"),
("lo", "127.0.0.1"),
("wlp44s0", "10.186.234.182"),
("eth0", "192.168.1.20"),
]
ranked = net.rank(found, primary="10.186.234.182")
assert [a.ip for a in ranked] == [
"10.186.234.182", # výchozí trasa
"192.168.1.20", # ostatní místní sítě
"100.91.0.24", # tailnet funguje i napříč sítěmi
"172.17.0.1", # virtuální až nakonec
]
assert ranked[0].primary
assert not ranked[1].primary
def test_bez_vychozi_trasy_se_poradi_drzi_typu():
found = [("docker0", "172.17.0.1"), ("wlp44s0", "192.168.1.20")]
ranked = net.rank(found, primary=None)
assert [a.ip for a in ranked] == ["192.168.1.20", "172.17.0.1"]
assert not any(a.primary for a in ranked)
def test_duplicity_se_slucuji():
found = [("wlp44s0", "10.0.0.5"), ("", "10.0.0.5")]
assert len(net.rank(found, primary="10.0.0.5")) == 1
def test_zadna_pouzitelna_adresa():
assert net.rank([("lo", "127.0.0.1")], primary=None) == []
def test_dice_host_ma_posledni_slovo(monkeypatch):
monkeypatch.setenv("DICE_HOST", "192.168.5.5")
found = net.addresses()
assert [a.ip for a in found] == ["192.168.5.5"]
assert found[0].primary
def test_url_a_popisek():
address = net.Address(ip="10.0.0.5", interface="wlp44s0", kind="tailnet")
assert address.url(8000) == "http://10.0.0.5:8000"
assert address.label == "přes Tailscale"
def test_v_systemu_se_neco_najde():
"""Na stroji s libovolnou sítí musí vyjít aspoň jedna adresa."""
found = net.addresses()
assert all(net.classify(a.ip, a.interface) for a in found)
assert len({a.ip for a in found}) == len(found)
+100
View File
@@ -0,0 +1,100 @@
import pytest
import stats
import storage
@pytest.fixture
def conn(tmp_path):
connection = storage.connect(tmp_path / "test.db")
yield connection
connection.close()
def play(conn, names, final_score, values, finish=True):
game_id = storage.create_game(conn, names, final_score)
game = storage.load_game(conn, game_id)
for value in values:
storage.add_turn(conn, game_id, game.add_score(value).turn)
if finish and game.winner:
storage.finish_game(conn, game_id, game.winner)
return game_id
def by_name(table):
return {row["name"]: row for row in table}
def test_prazdna_sin_slavy(conn):
found = stats.records(conn)
assert found["best_turn"] is None
assert found["best_score"] is None
assert found["fastest"] is None
assert stats.careers(conn) == []
def test_rekordy(conn):
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
found = stats.records(conn)
assert found["best_turn"]["name"] == "Adam"
assert found["best_turn"]["value"] == 600
assert found["best_score"]["name"] == "Adam"
assert found["best_score"]["total"] == 1100
assert found["fastest"]["name"] == "Adam"
assert found["fastest"]["rounds"] == 2
def test_kariera_pocita_vyhry_a_uspesnost(conn):
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
play(conn, ["Adam", "Eva"], 1000, [100, 600, 200, 500])
table = by_name(stats.careers(conn))
assert table["Adam"]["games"] == 2
assert table["Adam"]["wins"] == 1
assert table["Adam"]["share"] == 0.5
assert table["Eva"]["wins"] == 1
def test_nedohrane_hry_se_nepocitaji(conn):
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
play(conn, ["Adam", "Eva"], 5000, [100, 100], finish=False)
table = by_name(stats.careers(conn))
assert table["Adam"]["games"] == 1
def test_vynulovani_se_pocita(conn):
# Eva vyhraje, Adam se cestou třikrát vynuluje.
play(conn, ["Adam", "Eva"], 1000, [300, 400, 0, 300, 0, 200, 0, 500])
table = by_name(stats.careers(conn))
assert table["Adam"]["wipeouts"] == 1
assert table["Eva"]["wipeouts"] == 0
def test_prumerne_poradi(conn):
play(conn, ["Adam", "Eva", "Petr"], 1000, [600, 300, 100, 500, 200, 50])
table = by_name(stats.careers(conn))
assert table["Adam"]["placing"] == 1
assert table["Eva"]["placing"] == 2
assert table["Petr"]["placing"] == 3
def test_soucty_v_sql_respektuji_vynulovani(conn):
"""SQL musí počítat celkové skóre stejně jako core.Game.totals()."""
game_id = play(conn, ["Adam", "Eva"], 1000, [300, 100, 0, 100, 0, 100, 0, 900])
game = storage.load_game(conn, game_id)
best = stats.records(conn)["best_score"]
assert game.totals()["Adam"] == 0
assert best["name"] == "Eva"
assert best["total"] == game.totals()["Eva"]
+134
View File
@@ -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, " ")
+238
View File
@@ -0,0 +1,238 @@
import pytest
import storage
import web
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "test.db")
monkeypatch.setenv("DICE_HOST", "192.168.0.10")
web.app.config["TESTING"] = True
with web.app.test_client() as client:
yield client
def start_game(client, *names, final_score=1000):
response = client.post("/game", data={"player": list(names),
"final_score": final_score})
assert response.status_code == 302
return int(response.headers["Location"].rsplit("/", 1)[1])
def score(client, game_id, value):
return client.post(f"/game/{game_id}/turn", data={"value": value})
def text(response):
return response.get_data(as_text=True)
def test_prazdne_pocitadlo_nabizi_novou_hru(client):
page = text(client.get("/"))
assert "Kdo hraje" in page
assert "Začít hru" in page
def test_zalozeni_hry_a_zapis_tahu(client):
game_id = start_game(client, "Adam", "Eva")
page = text(client.get(f"/game/{game_id}"))
assert "Na tahu" in page
assert "Adam" in page
score(client, game_id, 450)
page = text(client.get(f"/game/{game_id}"))
assert "Eva" in page
assert "450" in page
def test_znama_jmena_se_nabidnou_v_dalsi_hre(client):
start_game(client, "Adam", "Eva")
page = text(client.get("/"))
assert 'data-name="Adam"' in page
assert 'data-name="Eva"' in page
def test_rozehrana_hra_se_nabidne_k_pokracovani(client):
game_id = start_game(client, "Adam", "Eva")
page = text(client.get("/"))
assert "Rozehraná hra" in page
assert f"/game/{game_id}" in page
def test_neplatne_skore_neprojde(client):
game_id = start_game(client, "Adam", "Eva")
response = score(client, game_id, "hodně")
assert "chyba" in response.headers["Location"]
assert "Zadej skóre jako číslo." in text(client.get(response.headers["Location"]))
def test_vynulovani_se_v_zapisniku_vyznaci(client):
game_id = start_game(client, "Adam")
for value in (300, 0, 0, 0):
score(client, game_id, value)
panel = text(client.get(f"/board/{game_id}/panel"))
assert "vynulováno" in panel
assert "struck" in panel # škrtnutý zápis před vynulováním
def test_vraceni_posledniho_zapisu(client):
game_id = start_game(client, "Adam", "Eva")
score(client, game_id, 450)
score(client, game_id, 200)
client.post(f"/game/{game_id}/undo")
page = text(client.get(f"/game/{game_id}"))
assert "Eva" in page
assert "200" not in page
def test_varovani_pred_treti_nulou(client):
game_id = start_game(client, "Adam")
score(client, game_id, 300)
score(client, game_id, 0)
score(client, game_id, 0)
assert "Ještě jedna nula" in text(client.get(f"/game/{game_id}"))
def test_dohrani_hry_vyhlasi_viteze(client):
game_id = start_game(client, "Adam", "Eva", final_score=500)
score(client, game_id, 500)
score(client, game_id, 100)
page = text(client.get(f"/game/{game_id}"))
assert "Vyhrává" in page
assert "Konec hry" in page
assert storage.running_game(storage.connect(storage.DB_PATH)) is None
def test_do_dohrane_hry_uz_nejde_zapsat(client):
game_id = start_game(client, "Adam", "Eva", final_score=500)
score(client, game_id, 500)
score(client, game_id, 100)
score(client, game_id, 999)
conn = storage.connect(storage.DB_PATH)
assert storage.load_game(conn, game_id).totals()["Adam"] == 500
def test_remiza_hru_neukonci(client):
game_id = start_game(client, "Adam", "Eva", final_score=500)
score(client, game_id, 500)
score(client, game_id, 500)
page = text(client.get(f"/game/{game_id}"))
assert "Na tahu" in page
assert "Konec hry" not in page
def test_ukonceni_hry(client):
game_id = start_game(client, "Adam", "Eva")
client.post(f"/game/{game_id}/abandon")
assert "Rozehraná hra" not in text(client.get("/"))
def test_tabule_ukazuje_qr_a_adresu(client):
game_id = start_game(client, "Adam", "Eva")
page = text(client.get(f"/board/{game_id}"))
assert "<svg class=\"qr\"" in page
assert "http://192.168.0.10:8000" in page
def test_tabule_bez_hry(client):
page = text(client.get("/board"))
assert "Zatím se nehraje" in page
def test_tabule_presmeruje_na_rozehranou_hru(client):
game_id = start_game(client, "Adam", "Eva")
response = client.get("/board")
assert response.headers["Location"].endswith(f"/board/{game_id}")
def test_neexistujici_hra(client):
assert client.get("/game/999").status_code == 404
def test_sin_slavy(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("/stats"))
assert "Nejvyšší tah" in page
assert "Kariéra" in page
assert "Vyhrává Adam" in page
def test_sin_slavy_bez_her(client):
assert "Rekordy přibudou po první hře" in text(client.get("/stats"))
def test_zapisnik_zacina_nejnovejsim_kolem(client):
game_id = start_game(client, "Adam", final_score=99999)
for value in (111, 222, 333):
score(client, game_id, value)
panel = text(client.get(f"/board/{game_id}/panel"))
assert panel.index("333") < panel.index("222") < panel.index("111")
def test_soucty_jsou_nad_koly(client):
game_id = start_game(client, "Adam", final_score=99999)
score(client, game_id, 450)
panel = text(client.get(f"/board/{game_id}/panel"))
assert panel.index("Celkem") < panel.index(">450<")
def test_tabule_useka_starou_historii(client):
game_id = start_game(client, "Adam", final_score=999999)
for value in range(1, 31):
score(client, game_id, value * 10)
panel = text(client.get(f"/board/{game_id}/panel"))
assert "a dalších 15 kol" in panel
assert "300" in panel # nejnovější kolo tam je
assert ">150<" not in panel # patnácté odspodu už ne
def test_kratka_hra_nic_neuseka(client):
game_id = start_game(client, "Adam", final_score=99999)
score(client, game_id, 100)
assert "a dalších" not in text(client.get(f"/board/{game_id}/panel"))
@pytest.mark.parametrize("count, expected", [
(1, "1 kolo"), (2, "2 kola"), (4, "4 kola"), (5, "5 kol"), (22, "22 kol"),
])
def test_sklonovani_kol(count, expected):
assert web.kola(count) == expected
+301
View File
@@ -0,0 +1,301 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["flask", "qrcode"]
# ///
"""Webové počitadlo pro domácí síť.
Telefon zapisuje skóre (`/game/<id>`), notebook ukazuje zápisník (`/board`).
Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step.
"""
import os
import socket
import qrcode
from flask import (Flask, abort, g, redirect, render_template, request,
url_for)
from markupsafe import Markup
import net
import stats
import storage
from core import FINAL_SCORE, Game, GameOver
app = Flask(__name__)
PORT = int(os.environ.get("DICE_PORT", 8000))
# Kolik kol tabule ukáže. Zbytek historie je na hráčích, ne na tabuli —
# ta má ukazovat stav, ne archiv.
BOARD_ROUNDS = 15
# --- připojení k databázi ---------------------------------------------------
def db():
if "db" not in g:
g.db = storage.connect()
return g.db
@app.teardown_appcontext
def close_db(_exception):
conn = g.pop("db", None)
if conn is not None:
conn.close()
# --- adresa a QR kód --------------------------------------------------------
def primary_ip() -> str:
"""Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
found = net.addresses()
return found[0].ip if found else ""
def links(path: str = "/") -> dict:
"""Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
Hledá se při každém načtení — notebook se během večera může přesunout
z domácí WiFi na hotspot a zpátky.
"""
found = net.addresses()
primary = found[0] if found else None
return {
"primary": primary,
"others": found[1:],
"port": PORT,
"target": primary.url(PORT) + path if primary else "",
}
def qr_svg(data: str) -> Markup:
"""QR jako SVG složené ze čtverečků — bez Pillow a bez práce s obrázky."""
code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data)
code.make(fit=True)
matrix = code.get_matrix()
size = len(matrix)
rects = [
f'<rect x="{x}" y="{y}" width="1" height="1"/>'
for y, row in enumerate(matrix)
for x, dark in enumerate(row) if dark
]
return Markup(
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
f'aria-label="QR kód s adresou pro telefon" '
f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
)
@app.template_filter("datum")
def datum(value: str) -> str:
"""Z ISO textu udělá 12. 8. 2026."""
if not value:
return ""
den = value[:10].split("-")
return f"{int(den[2])}. {int(den[1])}. {den[0]}"
@app.template_filter("qr")
def qr_filter(data: str) -> Markup:
return qr_svg(data) if data else Markup("")
@app.template_filter("kola")
def kola(count: int) -> str:
if count == 1:
return "1 kolo"
if 2 <= count <= 4:
return f"{count} kola"
return f"{count} kol"
@app.template_filter("cislo")
def cislo(value: int) -> str:
"""Tisíce se v češtině oddělují mezerou."""
return f"{value:,}".replace(",", " ")
# --- zápisník ---------------------------------------------------------------
def scoresheet(game: Game) -> tuple[list[dict], int]:
"""Řádky zápisníku po kolech, od nejnovějšího.
Naposledy odehrané kolo patří nahoru — po padesáti kolech by jinak bylo
to podstatné mimo obrazovku. Vrací i počet kol, která se nevešla.
Tahy zapsané před posledním vynulováním se přeškrtnou — přesně tak, jak
by se škrtaly na papíře.
"""
wiped_in_round = {}
for turn in game.turns:
if turn.reset:
wiped_in_round[turn.player] = turn.round
rows = []
for number, by_player in game.rounds():
cells = []
for name in game.names:
turn = by_player[name]
cells.append({
"value": None if turn is None else turn.value,
"reset": turn is not None and turn.reset,
"struck": turn is not None and turn.round < wiped_in_round.get(name, 0),
})
rows.append({"round": number, "cells": cells})
rows.reverse()
hidden = max(0, len(rows) - BOARD_ROUNDS)
return rows[:BOARD_ROUNDS], hidden
def view(game: Game, game_id: int, row) -> dict:
"""Společný podklad pro zápisník i pro zadávací pohled."""
totals = game.totals()
winner = row["winner_name"] if row["status"] == storage.STATUS_FINISHED else ""
rows, hidden = scoresheet(game)
return {
"game": game,
"game_id": game_id,
"row": row,
"totals": totals,
"rows": rows,
"hidden": hidden,
"streaks": {name: game.zero_streak(name) for name in game.names},
"standings": game.standings(),
"winner": winner,
"current": "" if winner else game.current_player,
"primary_ip": primary_ip(),
}
def load(game_id: int) -> tuple[Game, "storage.sqlite3.Row"]:
row = storage.game_row(db(), game_id)
if row is None:
abort(404)
return storage.load_game(db(), game_id), row
# --- routy ------------------------------------------------------------------
@app.route("/")
def index():
return render_template(
"index.html",
players=storage.known_players(db()),
running=storage.running_game(db()),
final_score=FINAL_SCORE,
)
@app.post("/game")
def new_game():
names = [n for n in request.form.getlist("player") if n.strip()]
if not names:
return redirect(url_for("index", chyba="Vyber aspoň jednoho hráče."))
try:
final_score = int(request.form.get("final_score") or FINAL_SCORE)
except ValueError:
return redirect(url_for("index", chyba="Cílové skóre musí být číslo."))
try:
game_id = storage.create_game(db(), names, final_score)
except ValueError as exc:
return redirect(url_for("index", chyba=str(exc)))
return redirect(url_for("game_view", game_id=game_id))
@app.route("/game/<int:game_id>")
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/<int:game_id>/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/<int:game_id>/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/<int:game_id>/abandon")
def abandon(game_id: int):
storage.abandon_game(db(), game_id)
return redirect(url_for("index"))
@app.route("/board")
def board_redirect():
game_id = storage.running_game(db())
if game_id:
return redirect(url_for("board", game_id=game_id))
return render_template("board_idle.html", **links(url_for("index")))
@app.route("/board/<int:game_id>")
def board(game_id: int):
game, row = load(game_id)
return render_template("board.html",
**links(url_for("game_view", game_id=game_id)),
**view(game, game_id, row))
@app.route("/board/<int:game_id>/panel")
def board_panel(game_id: int):
"""Fragment, na který se tabule doptává každé dvě vteřiny."""
game, row = load(game_id)
return render_template("_panel.html", **view(game, game_id, row))
@app.route("/stats")
def hall():
conn = db()
return render_template("stats.html",
records=stats.records(conn),
careers=stats.careers(conn),
games=storage.recent_games(conn))
if __name__ == "__main__":
found = net.addresses()
if found:
print("Počitadlo je dostupné na:")
for address in found:
mark = "→" if address.primary else " "
print(f" {mark} {address.url(PORT):30} {address.label}")
print(f"\nTabule na notebook: {found[0].url(PORT)}/board")
else:
print("Adresu se nepodařilo zjistit. Spusť server s DICE_HOST=<adresa>.")
app.run(host="0.0.0.0", port=PORT, threaded=True)