Webové počitadlo na LAN s historií her #2
No files matched your search
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.db
|
||||
.pytest_cache/
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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')
|
||||
_ = 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__":
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
# Testy sahají na moduly v kořeni repa, ne na nainstalovaný balíček.
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,4 @@
|
||||
flask
|
||||
qrcode
|
||||
tabulate
|
||||
pytest
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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=<adresa></code>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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, " ")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user