19 Commits
Author SHA1 Message Date
david-spacil 298919d71b Merge pull request 'QR kód se dá přepnout na jinou adresu' (#5) from agents/claude/vyber-adresy into main
testy / pytest (3.11) (push) Successful in 0s
testy / pytest (3.12) (push) Successful in 1s
testy / pytest (3.13) (push) Successful in 1s
testy / pytest (3.14) (push) Successful in 1s
binárka / linux (push) Successful in 11s
Reviewed-on: #5
2026-08-21 19:13:05 +02:00
gitea-actionsandClaude Opus 5 84db0c0380 QR kód se dá přepnout na jinou adresu
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 1s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 0s
Tabule dosud zbylé adresy jen vypsala, ale naskenovat se dala pouze ta
první. Teď se na ně dá kliknout: vybraná se přesune nahoru velkým písmem,
QR kód se přepne na ni a ta předchozí se objeví v seznamu, takže se dá
kroužit dokola.

Kódy jsou vykreslené všechny předem a přepíná se jen ten viditelný —
žádné doptávání serveru, žádná QR knihovna na klientovi. Aby to stránku
nenafouklo, skládá se SVG ze souvislých úseků místo jednotlivých
čtverečků; tabule se třemi adresami má 23 kB.

Otisk v data-address zahrnuje všechny adresy, ne jen tu první. Když se
změní kterákoli z nich, nabídka i QR kódy jsou k ničemu a tabule se
načte znovu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:03:35 +02:00
david-spacil ca97857ca0 Aktualizovat README.md
testy / pytest (3.11) (push) Successful in 1s
testy / pytest (3.12) (push) Successful in 1s
testy / pytest (3.13) (push) Successful in 0s
testy / pytest (3.14) (push) Successful in 1s
2026-08-21 17:50:15 +02:00
david-spacil eeb6e0bc1c Merge pull request 'Kontrolní součet k binárce' (#4) from agents/claude/kontrolni-soucet into main
testy / pytest (3.11) (push) Successful in 0s
testy / pytest (3.12) (push) Successful in 1s
testy / pytest (3.13) (push) Successful in 1s
testy / pytest (3.14) (push) Successful in 1s
binárka / linux (push) Successful in 11s
Reviewed-on: #4
2026-08-21 17:48:53 +02:00
gitea-actionsandClaude Opus 5 42f454f754 k binárce i kontrolní součet
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 0s
testy / pytest (3.12) (pull_request) Successful in 1s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 1s
Věší se jako druhá příloha ve formátu sha256sum, počítaná nad jménem, pod
kterým se soubor stahuje — aby se dala ověřit rovnou přes
sha256sum -c, bez přejmenovávání.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:45:00 +02:00
david-spacil fa66635b95 Merge pull request 'Binárka ke stažení a release z tagu' (#3) from agents/claude/binarka-a-release into main
testy / pytest (3.11) (push) Successful in 0s
testy / pytest (3.12) (push) Successful in 0s
testy / pytest (3.13) (push) Successful in 1s
testy / pytest (3.14) (push) Successful in 1s
Reviewed-on: #3
2026-08-21 17:40:07 +02:00
gitea-actionsandClaude Opus 5 2a03474af8 testy nad pull requesty a nad main
binárka / linux (pull_request) Successful in 10s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 2s
testy / pytest (3.13) (pull_request) Successful in 2s
testy / pytest (3.14) (pull_request) Successful in 3s
Matice Python 3.11 až 3.14 — u projektu, který deklaruje >=3.11, se hodí
vědět, že to na všech čtyřech opravdu jede. Pythony obstará uv, na
hostiteli nemusí být žádný.

K pytestu ještě skriptovaná partie v terminálové verzi. Pytest sahá na
core.py, prompty v dice.py nikdo netestoval; tohle projde dvě nuly, třetí
s vynulováním a výhru tak, jak to odehraje člověk.

Runner sdílí pracovní adresář mezi variantami matice, proto max-parallel 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:36:01 +02:00
gitea-actionsandClaude Opus 5 b4864981ad runner potřebuje binutils, doinstaluje si je sám
binárka / linux (pull_request) Successful in 14s
PyInstaller hledá objdumpem, na kterých knihovnách binárka visí. Runner
v host módu ho nemá — je to jediné, co musí být na hostiteli, zbytek si
obstará uv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:24:37 +02:00
gitea-actionsandClaude Opus 5 8aa54dc7a0 stavba bez kontejneru, proti Pythonu od uv
binárka / linux (pull_request) Failing after 4s
Runner běží v host módu, bez dockeru, takže se v něm kontejner na AlmaLinux
spustit nedá. Ukázalo se ale, že kontejner potřeba není: stačí stavět proti
samostatnému CPythonu od uv. Ten je slinkovaný s glibc 2.17 a nic
z hostitele se do binárky nedostane — změřeno objdumpem přes všechno, co
se z ní rozbaluje. Výsledek je tedy přenositelnější než přes AlmaLinux 8
(2.28) a build.sh je o polovinu kratší.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:23:09 +02:00
gitea-actionsandClaude Opus 5 cb39036724 release se staví sám z tagu
binárka / linux (pull_request) Failing after 0s
Otagovaný commit postaví binárku na runneru, projede testy, zkusí ji
nastartovat a pověsí ji na release. Nad pull requesty běží totéž bez
posledního kroku, ať se rozbitá stavba pozná dřív než na tagu.

Staví se v kontejneru na AlmaLinux 8 ze stejného důvodu jako build.sh —
kvůli glibc. Záměrně bez hotových akcí z marketplace: na vlastním runneru
tím odpadá starost, jestli je v kontejneru node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:18:39 +02:00
gitea-actionsandClaude Opus 5 fcfa0e489b binárka ke stažení a jasné místo pro databázi
Stáhni a spusť: jednosouborová binárka pro Linux, uvnitř Python, Flask,
šablony i styly. Staví se ./build.sh v kontejneru na AlmaLinux 8, protože
binárka slinkovaná proti glibc z Fedory 44 by nešla spustit nikde se
starším systémem.

Databáze tím dostává dvě různá místa. Ze zdrojáků zůstává dice.db
v pracovním adresáři, ať se dá mít víc sad vedle sebe. Z binárky jde do
~/.local/share/kostky/ — binárka se rozbaluje do dočasného adresáře
a pouští se odkudkoli, takže relativní cesta by databázi rozsypala po
disku. DICE_DB přebije obojí.

Server teď cestu k databázi vypisuje při startu, ať se na ni nemusí ptát.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:17:09 +02:00
david-spacil f33104b5c2 Merge pull request 'Webové počitadlo na LAN s historií her' (#2) from agents/claude/web-a-historie into main
Reviewed-on: #2
2026-08-20 21:24:56 +02:00
gitea-actionsandClaude Opus 5 374e4209c5 spuštění jedním příkazem: uv run web.py
Trojice venv/pip/python byla zbytečná ceremonie pro appku, která se pouští
jednou za herní večer.

- web.py i dice.py si nesou závislosti v hlavičce podle PEP 723, takže
  je uv obstará sám a není co instalovat ani aktivovat
- pytest.ini staví kořen repa na pythonpath, takže testy jdou spustit
  i mimo připravené prostředí
- README nabízí uv jako hlavní cestu, venv a systémové balíčky jako
  záložní

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:00:29 +02:00
gitea-actionsandClaude Opus 5 5052a48611 tabule nabídne všechny adresy, ne jednu
Detekce vracela jedinou adresu podle výchozí trasy. Když ta nevede tam,
kam je připojený telefon, QR kód míří do prázdna a nezbývá než sahat do
konfigurace. Při ladění se ukázalo i to, že záložní větev může vrátit
127.0.0.1, tedy QR kód na localhost.

- net.py hledá adresy všech rozhraní přes if_nameindex a ioctl, se zálohou
  přes getaddrinfo; DICE_HOST má poslední slovo
- řadí se podle rozsahu adresy a obecného typu rozhraní, ne podle seznamu
  konkrétních jmen: výchozí trasa, místní sítě, tailnet, virtuální sítě
- loopback a link-local vypadnou, nic dalšího se nezahazuje — o tom, co
  funguje, rozhoduje člověk u stolu
- tailnet adresu vědomě necháváme: funguje i napříč sítěmi
- adresy se hledají při každém načtení; když se notebook přesune na jinou
  síť, tabule to pozná podle data-address a načte se znovu
- když se nenajde nic, tabule řekne, co s tím, místo QR kódu naslepo
- 20 testů nad klasifikací a řazením, nezávislých na aktuální síti
- README doplněn o řešení potíží s připojením

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:52:32 +02:00
gitea-actionsandClaude Opus 5 cd10db1a72 tabule: nejnovější kolo nahoře a součty nad historií
Po sto kolech byla tabule k ničemu: na obrazovku se vešla kola 1–14
a řádek se součty skončil asi 4 500 pixelů pod okrajem. Tabule tedy
ukazovala nejstarší historii a nic z toho, co se právě děje.

- kola se řadí od nejnovějšího, poslední zápis je vždy nahoře
- součty se přesunuly z patičky nad historii, hned pod jména hráčů
- tabule ukazuje posledních 15 kol, zbytek shrne řádka "... a dalších
  85 kol"; má ukazovat stav, ne archiv, a nepřekresluje se pak zbytečně
  každé dvě vteřiny přes tři sta buněk
- skloňování kol podle češtiny (1 kolo / 2 kola / 5 kol)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:37:44 +02:00
gitea-actionsandClaude Opus 5 d7c63cf98b stats: síň slávy a kariérní statistiky
- rekordy: nejvyšší tah, nejvyšší konečné skóre, nejrychlejší výhra
- kariéra: počet her, výher, úspěšnost, průměrné pořadí, průměr na tah
  a kolikrát koho postihlo vynulování
- do statistik jdou jen dohrané hry, nedohrané a opuštěné se ignorují
- celkové skóre počítá SQL stejným pravidlem jako core.Game.totals(),
  tedy součet tahů po posledním vynulování; testem ověřeno, že se obě
  cesty shodují
- vědomě chybí celkový součet bodů napříč hrami — cílové skóre je u každé
  hry jiné, metrika by odměňovala jen toho, kdo hrál nejvíc
- README se spuštěním a přehledem modulů

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:04:50 +02:00
gitea-actionsandClaude Opus 5 bcbbede258 web: zadávání z telefonu a zápisník na notebooku
Flask, server-rendered HTML, formuláře přes POST/redirect/GET.
Žádný build step, žádný JS framework.

- / zakládá hru výběrem známých hráčů v pořadí u stolu
- /game/<id> je zadávací pohled: kdo je na tahu, skóre, x/xx,
  průběžné pořadí, vrácení posledního zápisu
- /board/<id> je zápisník po kolech; tahy před vynulováním se škrtnou
  červeně, sloupec hráče na tahu drží zvýrazňovač
- tabule se doptává serveru každé dvě vteřiny a vyměňuje jen hlavičku
  a zápisník, QR s adresou zůstává mimo obnovovanou část
- adresa pro telefon se hledá za běhu, QR se skládá ze čtverečků
  přímo do SVG, takže není potřeba Pillow
- 16 funkčních testů přes Flask test client

Vizuálně to stojí na tom, co appka nahrazuje: čtverečkovaný papír,
modrá propiska zapisuje, červená škrtá, zvýrazňovač drží tah.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:01:08 +02:00
gitea-actionsandClaude Opus 5 a45a82ae0e storage: SQLite s arkádovou identitou hráčů
Ručně psané SQL nad pěti tabulkami, žádné ORM.

- hry ukazují na players.id, nikdy na jméno, takže se přejmenování
  propíše i do starých her
- párování hráčů napříč hrami přes normalizovaný klíč (trim, casefold),
  plus slučování pro případ překlepu
- zapisuje se každý tah hned, ne až na konci — rozehraná hra se po pádu
  serveru najde podle stavu a dá se v ní pokračovat
- součty se neukládají nikde, stav hry se rekonstruuje načtením tahů
- časy jako ISO text, sqlite3 vlastní adaptéry pro datetime zavrhl

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:47:08 +02:00
gitea-actionsandClaude Opus 5 4743ea74b9 core: vytažení pravidel z CLI do Game třídy
Pravidla byla propletená se vstupem a výstupem (add_score se uprostřed
zastavovalo na input()), takže nešla testovat ani použít z webu.

- core.Game drží stav a pravidla, nic nevypisuje a na nic se neptá
- součty jsou vždy odvozené z tahů, díky tomu je undo() jen zahození
  posledního tahu — funguje i pro vracení vynulování
- vynulování se ukládá jako hodnota 0 s příznakem reset místo záporného
  tahu, aby do historie nepadala čísla, která nikdo nehodil
- zero_streak() je jediný zdroj pravdy pro pravidlo i pro indikátor x/xx
  a počítá se od posledního vynulování
- pořadí na tahu plyne z počtu zápisů, globální pointer zmizel
- dice.py zůstává navenek stejné, jen deleguje na Game
- 15 testů nad pravidly

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:45:11 +02:00
30 changed files with 3426 additions and 114 deletions

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
# Postaví binárku a pověsí ji na release.
#
# Runner běží v host módu, takže tu není kontejner ani hotové akce
# z marketplace — nic se nespoléhá na to, co má hostitel nainstalováno.
# O starou glibc se stará build.sh přes samostatný CPython od uv.
name: binárka
on:
push:
tags: ["v*"]
pull_request:
workflow_dispatch:
jobs:
linux:
runs-on: homelab
steps:
- name: objdump
run: |
# Jediné, co musí být na hostiteli — PyInstaller jím hledá, na
# kterých knihovnách binárka visí. Zbytek si obstará uv.
command -v objdump >/dev/null && exit 0
sudo=""; [ "$(id -u)" -ne 0 ] && sudo=sudo
if command -v apt-get >/dev/null; then
$sudo apt-get update -qq
$sudo apt-get install -y -qq binutils
elif command -v dnf >/dev/null; then
$sudo dnf install -y -q binutils
else
echo "Nainstaluj binutils, PyInstaller bez objdumpu nestaví." >&2
exit 1
fi
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: uv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
uv --version
- name: Testy
run: uv run --with-requirements requirements.txt --no-project pytest -q
- name: Binárka
run: ./build.sh
- name: Zkouška
run: |
XDG_DATA_HOME=/tmp/zkouska DICE_PORT=8321 DICE_HOST=127.0.0.1 \
./dist/kostky > /tmp/server.log 2>&1 &
server=$!
trap 'kill $server 2>/dev/null || true' EXIT
for _ in $(seq 30); do
curl -sf -o /dev/null http://127.0.0.1:8321/ && break
sleep 1
done
curl -sf -o /dev/null http://127.0.0.1:8321/
curl -sf -o /dev/null http://127.0.0.1:8321/static/style.css
curl -sf -o /dev/null http://127.0.0.1:8321/board
test -f /tmp/zkouska/kostky/dice.db
cat /tmp/server.log
- name: Na release
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: kostky-linux-x86_64
run: |
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
auth="Authorization: token ${TOKEN}"
# Release u tagu být může, ale nemusí — dá se založit i ručně.
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' || true)
if [ -z "$id" ]; then
id=$(curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
"$api/releases" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
fi
# Součet se počítá nad jménem, pod kterým se soubor stahuje, aby
# šel ověřit rovnou: sha256sum -c kostky-linux-x86_64.sha256
cp dist/kostky "$ASSET"
sha256sum "$ASSET" | tee "$ASSET.sha256"
for file in "$ASSET" "$ASSET.sha256"; do
# Aby se dal build zopakovat, stará příloha stejného jména jde pryč.
curl -s -H "$auth" "$api/releases/$id/assets" \
| python3 -c 'import json,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == sys.argv[1]]' "$file" \
| while read -r old; do
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
done
curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@$file" \
"$api/releases/$id/assets?name=$file"
echo "$file pověšen na ${GITHUB_REF_NAME}"
done
+69
View File
@@ -0,0 +1,69 @@
# Testy nad každým pull requestem a nad main.
#
# Runner běží v host módu, takže tu není kontejner ani hotové akce
# z marketplace. Pythony obstará uv, na hostiteli nemusí být žádný.
name: testy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: testy-${{ github.ref }}
cancel-in-progress: true
jobs:
pytest:
runs-on: homelab
strategy:
fail-fast: false
# Runner sdílí pracovní adresář mezi variantami, takže po jedné.
max-parallel: 1
matrix:
python: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: uv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
uv python install "${{ matrix.python }}"
- name: Testy
run: |
uv run --managed-python --python "${{ matrix.python }}" \
--with-requirements requirements.txt --no-project \
pytest -q
- name: Terminálová verze
run: |
# Pytest sahá na core.py, ne na prompty. Tohle projde celou partii
# tak, jak ji odehraje člověk: dvě nuly, třetí vynuluje, výhra.
out=$(printf 'A\nB\n\n1000\n\n200\n10\n0\n10\n0\n10\n0\n\n1000\n' \
| TERM=dumb uv run --managed-python --python "${{ matrix.python }}" dice.py)
for expected in "200xx" "Po třech nulových hodech" "Vítězem se stává B"; do
printf '%s' "$out" | grep -qF "$expected" || {
echo "chybí: $expected"
printf '%s\n' "$out"
exit 1
}
done
echo "terminálová verze v pořádku"
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
.venv/
*.db
.pytest_cache/
dist/
build/
.build/
+169
View File
@@ -0,0 +1,169 @@
# Dice Counter
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í
Stáhni binárku z [releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases)
a spusť ji:
```bash
chmod +x kostky
./kostky
```
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř. Zatím jen pro Linux
na x86-64; postavené proti glibc 2.17, takže jede prakticky všude.
Ze zdrojáků to je stejně krátké:
```bash
uv run web.py
```
Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará sám.
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
nabízí i zbylé adresy — když jedna nefunguje, klikni na jinou a QR kód se
přepne na ni. Zastavuje se `Ctrl+C`.
| Proměnná | K čemu je | Výchozí |
|---|---|---|
| `DICE_DB` | soubor s databází | podle způsobu spuštění, viz níž |
| `DICE_PORT` | port | `8000` |
| `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu |
## Kde jsou data
Jeden soubor SQLite. Založí se sám při prvním načtení stránky, cestu k němu
server vypíše při startu.
| Spuštěno | Databáze |
|---|---|
| binárkou | `~/.local/share/kostky/dice.db` |
| ze zdrojáků | `dice.db` v pracovním adresáři |
Binárka se při každém spuštění rozbaluje do dočasného adresáře a pouští se
odkudkoli, takže relativní cesta by databázi rozsypala po disku — proto
napevno domovský adresář. Ze zdrojáků zůstává relativní, ať se dá mít víc
sad vedle sebe.
Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho
najde kdekoli.
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í |
| `build.sh`, `kostky.spec` | stavba binárky |
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
```
## Vlastní binárka
```bash
./build.sh
```
Staví se proti samostatnému CPythonu od `uv`, ne proti systémovému. Ten je
slinkovaný s glibc 2.17; postavené proti Pythonu z Fedory 44 by to chtělo
glibc 2.43 a nešlo by spustit skoro nikde — glibc drží zpětnou kompatibilitu,
ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu
potřeba není. Výsledek je v `dist/kostky`.
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
Releasy si tohle dělají samy. Otagovaný commit spustí
`.gitea/workflows/binarka.yml`, ta binárku postaví, projede testy, zkusí
ji nastartovat a pověsí ji na release jako `kostky-linux-x86_64`:
```bash
git tag v1.0.0 && git push origin v1.0.0
```
Vedle binárky se věší i `kostky-linux-x86_64.sha256`, takže se stažený
soubor dá ověřit:
```bash
sha256sum -c kostky-linux-x86_64.sha256
```
Stejná stavba běží i nad každým pull requestem, jen se nikam nevěší.
## Testy
```bash
uv run --with-requirements requirements.txt --no-project pytest
```
Nebo z připraveného prostředí prostě `pytest`.
Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` —
84 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie
v terminálové verzi, na kterou pytest nesahá.
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Postaví jednosouborovou binárku pro Linux do dist/kostky.
#
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Ten je
# slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech.
# Postavená proti Pythonu z Fedory 44 by chtěla glibc 2.43 a nešla by
# spustit skoro nikde — glibc drží zpětnou kompatibilitu, ne dopřednou.
# Nic z hostitele se do ní nedostane, takže kontejner k tomu není potřeba.
set -euo pipefail
VERSION=${VERSION:-3.11}
BUILD=${BUILD:-.build}
uv python install "$VERSION"
uv venv --quiet --managed-python --python "$VERSION" "$BUILD"
uv pip install --quiet --python "$BUILD/bin/python" -r requirements.txt pyinstaller
"$BUILD/bin/pyinstaller" --clean --noconfirm \
--distpath dist --workpath "$BUILD/work" kostky.spec
echo
ls -lh dist/kostky
+163
View File
@@ -0,0 +1,163 @@
"""Pravidla hry v kostky.
Modul záměrně nic nevypisuje, na nic se neptá a nic neukládá — drží jen stav
hry a pravidla nad ním. Díky tomu ho může sdílet CLI i webová aplikace a dá se
testovat bez simulování vstupu.
"""
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime
FINAL_SCORE: int = 10000
# Kolik nulových tahů po sobě hráči vynuluje skóre.
ZEROS_TO_RESET: int = 3
@dataclass
class Turn:
"""Jeden zápis skóre.
Vynulování po třech nulách se ukládá jako hodnota 0 s příznakem `reset`,
ne jako záporný tah — do historie tak nepadají čísla, která nikdo nehodil,
a statistiky nad tahy zůstávají pravdivé.
"""
player: str
round: int
value: int
reset: bool = False
at: datetime = field(default_factory=datetime.now)
@dataclass
class TurnResult:
"""Co se stalo po zapsání tahu."""
turn: Turn
was_reset: bool
winner: str = ""
class GameOver(Exception):
"""Zápis do hry, která už má vítěze."""
class Game:
def __init__(self, names: list[str], final_score: int = FINAL_SCORE,
turns: list[Turn] | None = None) -> None:
if not names:
raise ValueError("Hra potřebuje alespoň jednoho hráče.")
if len(set(names)) != len(names):
raise ValueError("Jména hráčů musí být unikátní.")
self.names: list[str] = list(names)
self.final_score: int = final_score
self.turns: list[Turn] = list(turns) if turns else []
@property
def first(self) -> str:
return self.names[0]
@property
def current_player(self) -> str:
"""Hráči se střídají dokola, takže tah plyne z počtu zápisů."""
return self.names[len(self.turns) % len(self.names)]
@property
def round_number(self) -> int:
return len(self.turns) // len(self.names) + 1
@property
def round_complete(self) -> bool:
"""True, když všichni odehráli stejný počet tahů."""
return bool(self.turns) and len(self.turns) % len(self.names) == 0
@property
def winner(self) -> str:
"""Vítěz se vyhlašuje jen po dokončeném kole, ne uprostřed."""
return self.check_win() if self.round_complete else ""
@property
def finished(self) -> bool:
return bool(self.winner)
def turns_of(self, player: str) -> list[Turn]:
return [t for t in self.turns if t.player == player]
def totals(self) -> dict[str, int]:
"""Součty se nikdy neukládají, vždy se počítají z tahů."""
result = {name: 0 for name in self.names}
for turn in self.turns:
if turn.reset:
result[turn.player] = 0
else:
result[turn.player] += turn.value
return result
def zero_streak(self, player: str) -> int:
"""Počet nul za sebou od posledního vynulování.
Živí pravidlo i indikátor "x"/"xx". Počítání se na vynulovacím tahu
zastaví, takže po vynulování začíná hráč zase od nuly.
"""
streak = 0
for turn in reversed(self.turns_of(player)):
if turn.reset or turn.value != 0:
break
streak += 1
return streak
def add_score(self, value: int) -> TurnResult:
if self.finished:
raise GameOver("Hra už má vítěze.")
player = self.current_player
reset = value == 0 and self.zero_streak(player) >= ZEROS_TO_RESET - 1
turn = Turn(player=player, round=self.round_number, value=value, reset=reset)
self.turns.append(turn)
return TurnResult(turn=turn, was_reset=reset, winner=self.winner)
def undo(self) -> Turn | None:
"""Vrátí poslední zápis. Dá se opakovat.
Protože jsou součty odvozené, stačí tah zahodit — platí to i pro
vracení vynulování.
"""
return self.turns.pop() if self.turns else None
def check_win(self) -> str:
"""Jméno vítěze, nebo prázdno když se hraje dál.
Při remíze na prvním místě vítěz není a hraje se dál.
"""
totals = self.totals()
over_limit = {n: v for n, v in totals.items() if v >= self.final_score}
if not over_limit:
return ""
ranked = sorted(over_limit.items(), key=lambda x: x[1], reverse=True)
counts = Counter(value for _, value in ranked)
top_name, top_value = ranked[0]
return top_name if counts[top_value] == 1 else ""
def standings(self) -> list[tuple[int, str, int]]:
"""Pořadí, jméno a skóre — seřazeno od nejlepšího."""
totals = self.totals()
ranked = sorted(totals.items(), key=lambda x: x[1], reverse=True)
return [(i + 1, name, value) for i, (name, value) in enumerate(ranked)]
def rounds(self) -> list[tuple[int, dict[str, Turn | None]]]:
"""Zápisník po kolech pro tabuli: [(číslo kola, {hráč: tah nebo None})]."""
result: list[tuple[int, dict[str, Turn | None]]] = []
for index in range(0, len(self.turns), len(self.names)):
chunk = self.turns[index:index + len(self.names)]
by_player: dict[str, Turn | None] = {name: None for name in self.names}
for turn in chunk:
by_player[turn.player] = turn
result.append((index // len(self.names) + 1, by_player))
return result
+52 -114
View File
@@ -1,30 +1,34 @@
import sys
from collections import Counter
# /// script
# requires-python = ">=3.11"
# dependencies = ["tabulate"]
# ///
"""Počitadlo skóre v terminálu.
Pravidla žijí v `core.Game`, tenhle modul je jen vstup a výstup. Hraje se
v paměti, nic se neukládá — na historii je webová aplikace (`web.py`).
"""
from os import name, system
from tabulate import tabulate
hraci = {}
score = {}
pointer = ""
first = ""
final_score = 10000
from core import FINAL_SCORE, Game
table_header = ["Hráč", "Skóre"]
def clear() -> None:
_ = system('cls') if name == 'nt' else system('clear')
_ = 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__":
+26
View File
@@ -0,0 +1,26 @@
# -*- mode: python ; coding: utf-8 -*-
"""Jedna binárka se vším: Python, Flask, šablony i styly.
Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek jel
i na starších systémech, než je ten tvůj.
"""
analysis = Analysis(
["web.py"],
pathex=["."],
datas=[("templates", "templates"), ("static", "static")],
noarchive=False,
)
pyz = PYZ(analysis.pure)
exe = EXE(
pyz,
analysis.scripts,
analysis.binaries,
analysis.datas,
name="kostky",
console=True,
strip=True,
upx=False,
)
+151
View File
@@ -0,0 +1,151 @@
"""Zjištění adres, na kterých je počitadlo dostupné.
Telefon a notebook se musí potkat na stejné síti a která to je, se během
večera mění — domácí WiFi, hotspot z telefonu, tailnet. Proto se adresy
hledají za běhu, hledá se jich víc a řadí se podle toho, jak pravděpodobně
povedou k cíli. Vybrat si může člověk; my jen nabídneme.
Nic se tu nezakazuje podle jména konkrétního rozhraní — rozhoduje rozsah
adresy a obecný typ rozhraní, ne to, co má kdo zrovna nainstalované.
"""
import ipaddress
import os
import socket
from dataclasses import dataclass
# Rozsah, ze kterého adresy přiděluje Tailscale (RFC 6598, CGNAT).
TAILNET = ipaddress.ip_network("100.64.0.0/10")
# Obvyklé předpony virtuálních rozhraní. Adresy z nich nezahazujeme, jen je
# řadíme nakonec — dostat se přes ně k počitadlu jde jen výjimečně.
VIRTUAL = ("docker", "br-", "virbr", "veth", "vmnet", "vboxnet", "tun", "tap")
KIND_LABELS = {
"lan": "místní síť",
"tailnet": "přes Tailscale",
"public": "veřejná adresa",
"virtual": "virtuální síť",
}
ORDER = {"lan": 0, "tailnet": 1, "public": 2, "virtual": 3}
@dataclass
class Address:
ip: str
interface: str
kind: str
primary: bool = False
@property
def label(self) -> str:
return KIND_LABELS.get(self.kind, self.kind)
def url(self, port: int) -> str:
return f"http://{self.ip}:{port}"
def classify(ip: str, interface: str) -> str | None:
"""Typ adresy podle jejího rozsahu. None znamená nepoužitelná."""
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return None
if addr.is_loopback or addr.is_link_local or addr.is_unspecified:
return None
if addr in TAILNET:
return "tailnet"
if interface.startswith(VIRTUAL):
return "virtual"
return "lan" if addr.is_private else "public"
def rank(found: list[tuple[str, str]], primary: str | None) -> list[Address]:
"""Seřadí nalezené dvojice (rozhraní, adresa).
První je adresa, přes kterou vede výchozí trasa — na té síti je telefon
nejspíš taky. Zbytek podle typu. Čistá funkce, ať jde otestovat i to,
co zrovna není v systému.
"""
seen: dict[str, Address] = {}
for interface, ip in found:
kind = classify(ip, interface)
if kind is None or ip in seen:
continue
seen[ip] = Address(ip=ip, interface=interface, kind=kind,
primary=(ip == primary))
return sorted(seen.values(),
key=lambda a: (not a.primary, ORDER.get(a.kind, 9), a.ip))
def default_route_address() -> str | None:
"""Adresa rozhraní, kudy vede výchozí trasa.
Připojení UDP socketu nic neposílá, jen si vyžádá směrování.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("10.255.255.255", 1))
return sock.getsockname()[0]
except OSError:
return None
finally:
sock.close()
def _from_interfaces() -> list[tuple[str, str]]:
"""Adresy všech rozhraní. Linux, jen standardní knihovna."""
try:
import fcntl
import struct
except ImportError:
return []
siocgifaddr = 0x8915
found = []
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
for _index, name in socket.if_nameindex():
try:
packed = struct.pack("256s", name.encode()[:15])
info = fcntl.ioctl(sock.fileno(), siocgifaddr, packed)
found.append((name, socket.inet_ntoa(info[20:24])))
except OSError:
continue # rozhraní bez IPv4 adresy
return found
def _from_hostname() -> list[tuple[str, str]]:
"""Záloha pro systémy, kde předchozí cesta nefunguje."""
try:
infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
except OSError:
return []
return [("", info[4][0]) for info in infos]
def addresses() -> list[Address]:
"""Kde všude je počitadlo k dispozici, od nejpravděpodobnější adresy.
`DICE_HOST` má poslední slovo — když je nastavená, platí jen ona.
"""
forced = os.environ.get("DICE_HOST")
if forced:
return [Address(ip=forced, interface="", kind="lan", primary=True)]
primary = default_route_address()
found = _from_interfaces() or _from_hostname()
if primary and primary not in [ip for _name, ip in found]:
found.append(("", primary))
return rank(found, primary)
+4
View File
@@ -0,0 +1,4 @@
[pytest]
# Testy sahají na moduly v kořeni repa, ne na nainstalovaný balíček.
pythonpath = .
testpaths = tests
+4
View File
@@ -0,0 +1,4 @@
flask
qrcode
tabulate
pytest
+515
View File
@@ -0,0 +1,515 @@
/* 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 .what {
display: block;
font-family: var(--font-ui);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
}
.join .address .url { display: block; }
.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; }
/* Adresa je tlačítko, ale nemá vypadat jako tlačítko — jen dát najevo,
že se na ni dá kliknout. */
.alt button {
font: inherit;
color: inherit;
background: none;
border: 0;
border-bottom: 1px dotted var(--rule);
padding: 0;
cursor: pointer;
}
.alt button:hover,
.alt button:focus-visible {
color: var(--ink);
border-bottom-color: currentColor;
}
.alt em {
font-family: var(--font-ui);
font-style: normal;
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.address code {
font-family: var(--font-num);
font-size: 0.85em;
color: var(--ink);
}
+124
View File
@@ -0,0 +1,124 @@
"""Statistiky napříč hrami.
Dvě různé obrazovky, protože odpovídají na jiné otázky: síň slávy sbírá
rekordy, kariéra ukazuje, kdo je dlouhodobě dobrý.
Vědomě tu není celkový součet bodů napříč hrami — cílové skóre je u každé hry
jiné, hry nejsou srovnatelné a taková metrika by odměňovala jen toho, kdo hrál
nejvíc.
"""
import sqlite3
import storage
# Celkové skóre hráče ve hře: součet tahů po posledním vynulování.
# Stejné pravidlo jako v core.Game.totals(), jen vyjádřené v SQL.
TOTALS = """
WITH last_reset AS (
SELECT game_id, player_id, MAX(id) AS reset_id
FROM turns
WHERE reset = 1
GROUP BY game_id, player_id
),
totals AS (
SELECT gp.game_id,
gp.player_id,
COALESCE((
SELECT SUM(t.value)
FROM turns t
LEFT JOIN last_reset lr
ON lr.game_id = t.game_id AND lr.player_id = t.player_id
WHERE t.game_id = gp.game_id
AND t.player_id = gp.player_id
AND (lr.reset_id IS NULL OR t.id > lr.reset_id)
), 0) AS total
FROM game_players gp
)
"""
def records(conn: sqlite3.Connection) -> dict:
"""Síň slávy — tři rekordy, které jde překonat."""
best_turn = conn.execute("""
SELECT p.name, t.value, t.game_id, t.at
FROM turns t
JOIN players p ON p.id = t.player_id
ORDER BY t.value DESC, t.id ASC
LIMIT 1
""").fetchone()
best_score = conn.execute(TOTALS + """
SELECT p.name, totals.total, g.id AS game_id, g.finished_at
FROM totals
JOIN games g ON g.id = totals.game_id
JOIN players p ON p.id = totals.player_id
WHERE g.status = ?
ORDER BY totals.total DESC
LIMIT 1
""", (storage.STATUS_FINISHED,)).fetchone()
fastest = conn.execute("""
SELECT p.name, g.id AS game_id, MAX(t.round) AS rounds, g.finished_at
FROM games g
JOIN turns t ON t.game_id = g.id
JOIN players p ON p.id = g.winner_id
WHERE g.status = ?
GROUP BY g.id
ORDER BY rounds ASC, g.id ASC
LIMIT 1
""", (storage.STATUS_FINISHED,)).fetchone()
return {"best_turn": best_turn, "best_score": best_score, "fastest": fastest}
def careers(conn: sqlite3.Connection) -> list[dict]:
"""Kdo je dlouhodobě dobrý. Počítají se jen dohrané hry."""
placings = conn.execute(TOTALS + """
, ranked AS (
SELECT totals.game_id,
totals.player_id,
RANK() OVER (PARTITION BY totals.game_id
ORDER BY totals.total DESC) AS placing
FROM totals
JOIN games g ON g.id = totals.game_id
WHERE g.status = ?
)
SELECT r.player_id,
p.name,
COUNT(*) AS games,
SUM(CASE WHEN g.winner_id = r.player_id THEN 1 ELSE 0 END) AS wins,
AVG(r.placing) AS placing
FROM ranked r
JOIN players p ON p.id = r.player_id
JOIN games g ON g.id = r.game_id
GROUP BY r.player_id
""", (storage.STATUS_FINISHED,)).fetchall()
turn_stats = conn.execute("""
SELECT t.player_id,
AVG(t.value) AS per_turn,
SUM(t.reset) AS wipeouts
FROM turns t
JOIN games g ON g.id = t.game_id
WHERE g.status = ?
GROUP BY t.player_id
""", (storage.STATUS_FINISHED,)).fetchall()
by_player = {row["player_id"]: row for row in turn_stats}
table = []
for row in placings:
turns = by_player.get(row["player_id"])
table.append({
"name": row["name"],
"games": row["games"],
"wins": row["wins"],
"share": row["wins"] / row["games"] if row["games"] else 0,
"placing": row["placing"],
"per_turn": turns["per_turn"] if turns else 0,
"wipeouts": turns["wipeouts"] if turns else 0,
})
table.sort(key=lambda r: (-r["share"], r["placing"], -r["games"]))
return table
+309
View File
@@ -0,0 +1,309 @@
"""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
import sys
from datetime import datetime
from pathlib import Path
from core import FINAL_SCORE, Game, Turn
def default_db() -> Path:
"""Kam se ukládá databáze.
Ze zdrojáků do pracovního adresáře — dá se tak mít víc rozehraných
sad vedle sebe a je vidět, s čím se pracuje. Z binárky do datového
adresáře podle XDG: binárka se spouští odkudkoli a rozbaluje se do
dočasného adresáře, takže relativní cesta by databázi rozsypala po disku.
"""
chosen = os.environ.get("DICE_DB")
if chosen:
return Path(chosen)
if getattr(sys, "frozen", False):
share = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
return Path(share) / "kostky" / "dice.db"
return Path("dice.db")
DB_PATH = default_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:
target = Path(path or DB_PATH)
target.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(target)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA)
return conn
def now() -> str:
"""Časy ukládáme jako ISO text — sqlite3 vlastní adaptéry pro datetime
mezitím zavrhl."""
return datetime.now().isoformat(timespec="seconds")
# --- hráči ------------------------------------------------------------------
def normalize(name: str) -> str:
"""Klíč pro párování jmen napříč hrami: Adam, adam i " Adam " je týž hráč."""
return re.sub(r"\s+", " ", name).strip().casefold()
def get_or_create_player(conn: sqlite3.Connection, name: str) -> int:
name = re.sub(r"\s+", " ", name).strip()
if not name:
raise ValueError("Jméno nesmí být prázdné.")
key = normalize(name)
row = conn.execute("SELECT id FROM players WHERE key = ?", (key,)).fetchone()
if row:
return row["id"]
cursor = conn.execute(
"INSERT INTO players (name, key, created_at) VALUES (?, ?, ?)",
(name, key, now()),
)
conn.commit()
return cursor.lastrowid
def known_players(conn: sqlite3.Connection) -> list[sqlite3.Row]:
"""Známí hráči k odklikání, naposledy hrající první."""
return conn.execute("""
SELECT p.id, p.name, MAX(t.at) AS last_played
FROM players p
LEFT JOIN turns t ON t.player_id = p.id
GROUP BY p.id
ORDER BY last_played DESC, p.name COLLATE NOCASE
""").fetchall()
def rename_player(conn: sqlite3.Connection, player_id: int, name: str) -> None:
name = re.sub(r"\s+", " ", name).strip()
if not name:
raise ValueError("Jméno nesmí být prázdné.")
conn.execute("UPDATE players SET name = ?, key = ? WHERE id = ?",
(name, normalize(name), player_id))
conn.commit()
def merge_players(conn: sqlite3.Connection, source_id: int, target_id: int) -> None:
"""Sloučí hráče vzniklého překlepem do toho správného."""
if source_id == target_id:
return
# Kdyby oba omylem hráli tutéž hru, druhé sedadlo zahodíme.
conn.execute("""
DELETE FROM game_players
WHERE player_id = ?
AND game_id IN (SELECT game_id FROM game_players WHERE player_id = ?)
""", (source_id, target_id))
conn.execute("UPDATE game_players SET player_id = ? WHERE player_id = ?",
(target_id, source_id))
conn.execute("UPDATE turns SET player_id = ? WHERE player_id = ?",
(target_id, source_id))
conn.execute("UPDATE games SET winner_id = ? WHERE winner_id = ?",
(target_id, source_id))
conn.execute("DELETE FROM players WHERE id = ?", (source_id,))
conn.commit()
# --- hry --------------------------------------------------------------------
def create_game(conn: sqlite3.Connection, names: list[str],
final_score: int = FINAL_SCORE) -> int:
"""Založí hru. Pořadí jmen je pořadí u stolu."""
ids = [get_or_create_player(conn, name) for name in names]
if len(set(ids)) != len(ids):
raise ValueError("Každý hráč může u stolu sedět jen jednou.")
cursor = conn.execute(
"INSERT INTO games (final_score, status, started_at) VALUES (?, ?, ?)",
(final_score, STATUS_RUNNING, now()),
)
game_id = cursor.lastrowid
conn.executemany(
"INSERT INTO game_players (game_id, player_id, seat) VALUES (?, ?, ?)",
[(game_id, player_id, seat) for seat, player_id in enumerate(ids)],
)
conn.commit()
return game_id
def game_row(conn: sqlite3.Connection, game_id: int) -> sqlite3.Row | None:
return conn.execute("""
SELECT g.*, w.name AS winner_name
FROM games g
LEFT JOIN players w ON w.id = g.winner_id
WHERE g.id = ?
""", (game_id,)).fetchone()
def seating(conn: sqlite3.Connection, game_id: int) -> list[sqlite3.Row]:
return conn.execute("""
SELECT p.id, p.name
FROM game_players gp
JOIN players p ON p.id = gp.player_id
WHERE gp.game_id = ?
ORDER BY gp.seat
""", (game_id,)).fetchall()
def load_game(conn: sqlite3.Connection, game_id: int) -> Game:
"""Rekonstruuje `core.Game` z uložených tahů."""
row = game_row(conn, game_id)
if row is None:
raise LookupError(f"Hra {game_id} neexistuje.")
names = [player["name"] for player in seating(conn, game_id)]
turn_rows = conn.execute("""
SELECT p.name, t.round, t.value, t.reset, t.at
FROM turns t
JOIN players p ON p.id = t.player_id
WHERE t.game_id = ?
ORDER BY t.id
""", (game_id,)).fetchall()
turns = [
Turn(player=t["name"], round=t["round"], value=t["value"],
reset=bool(t["reset"]), at=datetime.fromisoformat(t["at"]))
for t in turn_rows
]
return Game(names, final_score=row["final_score"], turns=turns)
def running_game(conn: sqlite3.Connection) -> int | None:
"""Poslední rozehraná hra — díky ní se dá po pádu serveru pokračovat."""
row = conn.execute(
"SELECT id FROM games WHERE status = ? ORDER BY id DESC LIMIT 1",
(STATUS_RUNNING,),
).fetchone()
return row["id"] if row else None
def recent_games(conn: sqlite3.Connection, limit: int = 20) -> list[sqlite3.Row]:
return conn.execute("""
SELECT g.id, g.final_score, g.status, g.started_at, g.finished_at,
w.name AS winner_name,
(SELECT COUNT(*) FROM game_players gp WHERE gp.game_id = g.id) AS players,
(SELECT COUNT(*) FROM turns t WHERE t.game_id = g.id) AS turns
FROM games g
LEFT JOIN players w ON w.id = g.winner_id
ORDER BY g.id DESC
LIMIT ?
""", (limit,)).fetchall()
# --- tahy -------------------------------------------------------------------
def add_turn(conn: sqlite3.Connection, game_id: int, turn: Turn) -> None:
"""Zapisuje se hned po každém tahu, ne až na konci hry."""
row = conn.execute("""
SELECT p.id
FROM game_players gp
JOIN players p ON p.id = gp.player_id
WHERE gp.game_id = ? AND p.name = ?
""", (game_id, turn.player)).fetchone()
if row is None:
raise LookupError(f"Hráč {turn.player} v této hře nehraje.")
conn.execute("""
INSERT INTO turns (game_id, player_id, round, value, reset, at)
VALUES (?, ?, ?, ?, ?, ?)
""", (game_id, row["id"], turn.round, turn.value, int(turn.reset),
turn.at.isoformat(timespec="seconds")))
conn.commit()
def undo_turn(conn: sqlite3.Connection, game_id: int) -> bool:
"""Zahodí poslední zápis. Dá se opakovat."""
row = conn.execute(
"SELECT id FROM turns WHERE game_id = ? ORDER BY id DESC LIMIT 1",
(game_id,),
).fetchone()
if row is None:
return False
conn.execute("DELETE FROM turns WHERE id = ?", (row["id"],))
conn.execute(
"UPDATE games SET status = ?, winner_id = NULL, finished_at = NULL WHERE id = ?",
(STATUS_RUNNING, game_id),
)
conn.commit()
return True
def finish_game(conn: sqlite3.Connection, game_id: int, winner: str) -> None:
row = conn.execute("SELECT id FROM players WHERE key = ?",
(normalize(winner),)).fetchone()
conn.execute(
"UPDATE games SET status = ?, winner_id = ?, finished_at = ? WHERE id = ?",
(STATUS_FINISHED, row["id"] if row else None, now(), game_id),
)
conn.commit()
def abandon_game(conn: sqlite3.Connection, game_id: int) -> None:
"""Nedohrané hry se nepočítají do statistik výher."""
conn.execute(
"UPDATE games SET status = ?, finished_at = ? WHERE id = ?",
(STATUS_ABANDONED, now(), game_id),
)
conn.commit()
+5
View File
@@ -0,0 +1,5 @@
{# data-address si hlídá tabule: když se notebook přesune na jinou síť,
je v QR kódu stará adresa a stránka se sama načte znovu. #}
<p class="eyebrow">Zápisník · hra {{ game_id }}</p>
<h1>{% if winner %}Konec hry{% else %}Na tahu {{ current }}{% endif %}</h1>
<p class="meta">do {{ game.final_score|cislo }} bodů · kolo {{ game.round_number }}</p>
+58
View File
@@ -0,0 +1,58 @@
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může
člověk — QR kódy jsou vykreslené všechny, přepíná se jen ten viditelný. #}
{% if choices %}
<div class="join">
<div class="qr-stack">
{% for choice in choices %}
<div class="qr-slot" data-ip="{{ choice.ip }}"{% if not loop.first %} hidden{% endif %}>
{{ choice.url|qr }}
</div>
{% endfor %}
</div>
<div class="address">
<span class="what">Zapisuje se z telefonu</span>
<span class="url">{{ choices[0].url }}</span>
{% if choices|length > 1 %}
<ul class="alt">
{% for choice in choices %}
<li data-ip="{{ choice.ip }}"{% if loop.first %} hidden{% endif %}>
<button type="button" data-ip="{{ choice.ip }}" data-url="{{ choice.url }}"
aria-label="Ukázat QR kód pro {{ choice.url }}">
{{ choice.ip }}:{{ port }} <em>{{ choice.label }}</em>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
<script>
// Klik na jinou adresu jen prohodí, který QR kód a který řádek je vidět.
// Vybraná adresa ze seznamu zmizí a ta předchozí se v něm objeví, takže
// se dá kroužit dokola.
{
const join = document.currentScript.previousElementSibling;
const shown = join.querySelector(".address .url");
for (const button of join.querySelectorAll(".alt button")) {
button.addEventListener("click", () => {
for (const slot of join.querySelectorAll(".qr-slot")) {
slot.hidden = slot.dataset.ip !== button.dataset.ip;
}
for (const item of join.querySelectorAll(".alt li")) {
item.hidden = item.dataset.ip === button.dataset.ip;
}
shown.textContent = button.dataset.url;
});
}
}
</script>
{% else %}
<div class="join">
<p class="address"><span class="what">Adresu se nepodařilo zjistit</span>
Spusť server s <code>DICE_HOST=&lt;adresa&gt;</code>.</p>
</div>
{% endif %}
+3
View File
@@ -0,0 +1,3 @@
{# Fragment pro tabuli: obě části, které se během hry mění. #}
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
<div id="sheet">{% include "_sheet.html" %}</div>
+66
View File
@@ -0,0 +1,66 @@
{% if winner %}
<div class="win">
<p class="eyebrow">Konec hry</p>
<h2>{{ winner }}</h2>
<p>Vyhrává s {{ totals[winner]|cislo }} body z cílových {{ game.final_score|cislo }}.</p>
</div>
{% endif %}
<div class="sheet-wrap">
<table class="sheet">
<thead>
<tr>
<th class="label">Kolo</th>
{% for name in game.names %}
<th class="{{ 'marked' if name == current }}">{{ name }}</th>
{% endfor %}
</tr>
</thead>
{# Součty patří nahoru: po padesáti kolech by je jinak nikdo neviděl. #}
<tbody class="totals">
<tr>
<td class="label">Celkem</td>
{% for name in game.names %}
<td class="{{ 'marked' if name == current }}">
{{ totals[name]|cislo }}
{%- if streaks[name] %} <span class="ticks">{{ '✕' * streaks[name] }}</span>{% endif %}
</td>
{% endfor %}
</tr>
</tbody>
<tbody>
{% for row in rows %}
<tr>
<td class="label">{{ row.round }}</td>
{% for cell in row.cells %}
<td class="{{ 'marked' if game.names[loop.index0] == current }}
{{ 'struck' if cell.struck }} {{ 'wiped' if cell.reset }}">
{%- if cell.value is none -%}
<span class="empty">·</span>
{%- elif cell.reset -%}
0<small>vynulováno</small>
{%- else -%}
{{ cell.value|cislo }}
{%- endif -%}
</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td class="label">1</td>
{% for name in game.names %}
<td class="{{ 'marked' if name == current }}"><span class="empty">·</span></td>
{% endfor %}
</tr>
{% endfor %}
{% if hidden %}
<tr class="more">
<td class="label" colspan="{{ game.names|length + 1 }}">… a dalších {{ hidden|kola }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Kostky{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Zápisník — hra {{ game_id }}{% endblock %}
{% block body %}
<div class="board">
<header class="masthead">
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
{% include "_join.html" %}
</header>
<div id="sheet">{% include "_sheet.html" %}</div>
</div>
<script>
// Tabule se doptává serveru; není co udržovat na klientovi.
const url = "{{ url_for('board_panel', game_id=game_id) }}";
setInterval(async () => {
try {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) return;
const fresh = new DOMParser().parseFromString(await response.text(), "text/html");
// Notebook se přesunul na jinou síť — adresa v QR kódu už neplatí.
const head = fresh.getElementById("head");
if (head && head.dataset.address !== document.getElementById("head").dataset.address) {
location.reload();
return;
}
for (const id of ["head", "sheet"]) {
const updated = fresh.getElementById(id);
if (updated) document.getElementById(id).replaceWith(updated);
}
} catch (error) {
// Server je pryč — zkusíme to za dvě vteřiny znovu.
}
}, 2000);
</script>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Zápisník{% endblock %}
{% block body %}
<div class="board idle">
<p class="eyebrow">Zápisník</p>
<h1>Zatím se nehraje</h1>
<p class="hint">Naskenuj kód telefonem a založ hru.</p>
{% include "_join.html" %}
</div>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{% if winner %}Konec hry{% else %}Na tahu {{ game.current_player }}{% endif %}{% endblock %}
{% block body %}
<div class="phone">
<header class="masthead">
<div>
<p class="eyebrow">Hra {{ game_id }}</p>
<h1>Kostky</h1>
</div>
<p class="meta">do {{ game.final_score|cislo }} · kolo {{ game.round_number }}</p>
</header>
{% if chyba %}
<p class="error">{{ chyba }}</p>
{% endif %}
{% if winner %}
<div class="win">
<p class="eyebrow">Konec hry</p>
<h2>{{ winner }}</h2>
<p>Vyhrává s {{ totals[winner]|cislo }} body z cílových {{ game.final_score|cislo }}.</p>
</div>
{% else %}
<div class="turn">
<p class="eyebrow">Na tahu</p>
<h2>{{ game.current_player }}</h2>
<p class="progress">
<strong>{{ totals[game.current_player]|cislo }}</strong> / {{ game.final_score|cislo }}
{%- if streaks[game.current_player] %}
<span class="ticks">{{ '✕' * streaks[game.current_player] }}</span>
{%- endif %}
</p>
{% if streaks[game.current_player] == 2 %}
<p class="warning">Ještě jedna nula a skóre jde pryč.</p>
{% endif %}
<form method="post" action="{{ url_for('add_turn', game_id=game_id) }}" style="margin-top: 1.2rem">
<div class="field">
<label for="value">Skóre za tah</label>
<input type="number" name="value" id="value" inputmode="numeric" autofocus required>
</div>
<button class="btn" type="submit">Zapsat</button>
</form>
</div>
{% endif %}
<p class="eyebrow">Průběžné pořadí</p>
<table class="standings">
{% for rank, name, total in standings %}
<tr>
<td class="rank">{{ rank }}.</td>
<td class="self">{{ name }}</td>
<td>
{{ total|cislo }}
{%- if streaks[name] %} <span class="ticks">{{ '✕' * streaks[name] }}</span>{% endif %}
</td>
</tr>
{% endfor %}
</table>
<div class="actions">
<form method="post" action="{{ url_for('undo', game_id=game_id) }}">
<button class="btn-quiet" type="submit" {{ 'disabled' if not game.turns }}>Vrátit poslední zápis</button>
</form>
{% if not winner %}
<form method="post" action="{{ url_for('abandon', game_id=game_id) }}">
<button class="btn-quiet btn-danger" type="submit">Ukončit hru</button>
</form>
{% endif %}
</div>
<p class="footer">
<a href="{{ url_for('board', game_id=game_id) }}">Zápisník</a>
<a href="{{ url_for('hall') }}">Síň slávy</a>
<a href="{{ url_for('index') }}">Nová hra</a>
</p>
</div>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}Nová hra{% endblock %}
{% block body %}
<div class="phone">
<header class="masthead">
<div>
<p class="eyebrow">Počitadlo</p>
<h1>Kostky</h1>
</div>
</header>
{% if request.args.get('chyba') %}
<p class="error">{{ request.args.get('chyba') }}</p>
{% endif %}
{% if running %}
<div class="notice">
<span>Rozehraná hra {{ running }} čeká.</span>
<a href="{{ url_for('game_view', game_id=running) }}">Pokračovat</a>
</div>
{% endif %}
<form method="post" action="{{ url_for('new_game') }}">
<p class="eyebrow">Kdo hraje</p>
<p class="hint">Ťukej jména v pořadí, jak sedíte kolem stolu.</p>
<div class="chips" id="chips">
{% for player in players %}
<button type="button" class="chip" aria-pressed="false" data-name="{{ player.name }}">
{{ player.name }}<span class="seat"></span>
</button>
{% endfor %}
</div>
<div class="add-player">
<input type="text" id="new-name" placeholder="Nový hráč" autocomplete="off">
<button type="button" id="add">Přidat</button>
</div>
<div class="field" style="margin-top: 1.5rem">
<label for="final">Cílové skóre</label>
<input type="number" name="final_score" id="final" value="{{ final_score }}" inputmode="numeric">
</div>
<div id="chosen"></div>
<button class="btn" type="submit">Začít hru</button>
</form>
<p class="footer">
<a href="{{ url_for('board_redirect') }}">Zápisník</a>
<a href="{{ url_for('hall') }}">Síň slávy</a>
</p>
</div>
<script>
const chips = document.getElementById("chips");
const chosen = [];
function render() {
for (const chip of chips.querySelectorAll(".chip")) {
const seat = chosen.indexOf(chip.dataset.name);
chip.setAttribute("aria-pressed", seat > -1);
chip.querySelector(".seat").textContent = seat > -1 ? seat + 1 : "";
}
// Pořadí odeslaných polí je pořadí u stolu.
document.getElementById("chosen").replaceChildren(...chosen.map(name => {
const field = document.createElement("input");
field.type = "hidden";
field.name = "player";
field.value = name;
return field;
}));
}
chips.addEventListener("click", event => {
const chip = event.target.closest(".chip");
if (!chip) return;
const seat = chosen.indexOf(chip.dataset.name);
if (seat > -1) chosen.splice(seat, 1);
else chosen.push(chip.dataset.name);
render();
});
const field = document.getElementById("new-name");
function addPlayer() {
const name = field.value.trim();
if (!name) return;
if (!chips.querySelector(`[data-name="${CSS.escape(name)}"]`)) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "chip";
chip.dataset.name = name;
chip.append(name, Object.assign(document.createElement("span"), { className: "seat" }));
chips.append(chip);
}
if (!chosen.includes(name)) chosen.push(name);
field.value = "";
render();
}
document.getElementById("add").addEventListener("click", addPlayer);
field.addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); addPlayer(); }
});
</script>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}Síň slávy{% endblock %}
{% block body %}
<div class="board">
<header class="masthead">
<div>
<p class="eyebrow">Počitadlo</p>
<h1>Síň slávy</h1>
</div>
<p class="meta"><a href="{{ url_for('index') }}">Nová hra</a></p>
</header>
{% if records.best_turn %}
<div class="records">
<article class="record">
<p class="eyebrow">Nejvyšší tah</p>
<p class="number">{{ records.best_turn.value|cislo }}</p>
<p class="who">{{ records.best_turn.name }} · hra {{ records.best_turn.game_id }}
· {{ records.best_turn.at|datum }}</p>
</article>
{% if records.best_score %}
<article class="record">
<p class="eyebrow">Nejvyšší konečné skóre</p>
<p class="number">{{ records.best_score.total|cislo }}</p>
<p class="who">{{ records.best_score.name }} · hra {{ records.best_score.game_id }}
· {{ records.best_score.finished_at|datum }}</p>
</article>
{% endif %}
{% if records.fastest %}
<article class="record">
<p class="eyebrow">Nejrychlejší výhra</p>
<p class="number">{{ records.fastest.rounds }}<small> kol</small></p>
<p class="who">{{ records.fastest.name }} · hra {{ records.fastest.game_id }}
· {{ records.fastest.finished_at|datum }}</p>
</article>
{% endif %}
</div>
{% else %}
<p class="hint">Zatím se neodehrál ani jeden tah. Rekordy přibudou po první hře.</p>
{% endif %}
{% if careers %}
<p class="eyebrow">Kariéra</p>
<p class="hint">Počítají se jen dohrané hry.</p>
<div class="sheet-wrap">
<table class="sheet">
<thead>
<tr>
<th class="label">Hráč</th>
<th>Her</th>
<th>Výher</th>
<th>Úspěšnost</th>
<th>Průměrné pořadí</th>
<th>Průměr na tah</th>
<th>Vynulování</th>
</tr>
</thead>
<tbody>
{% for player in careers %}
<tr>
<td class="label name">{{ player.name }}</td>
<td>{{ player.games }}</td>
<td>{{ player.wins }}</td>
<td>{{ (player.share * 100)|round|int }} %</td>
<td>{{ '%.1f'|format(player.placing) }}</td>
<td>{{ player.per_turn|round|int|cislo }}</td>
<td class="{{ 'wiped' if player.wipeouts }}">{{ player.wipeouts }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if games %}
<p class="eyebrow" style="margin-top: 2.5rem">Odehrané hry</p>
<div class="sheet-wrap">
<table class="sheet">
<thead>
<tr>
<th class="label">Hra</th>
<th class="label">Datum</th>
<th>Hráčů</th>
<th>Kol</th>
<th>Cíl</th>
<th class="label">Výsledek</th>
</tr>
</thead>
<tbody>
{% for game in games %}
<tr>
<td class="label">{{ game.id }}</td>
<td class="label">{{ game.started_at|datum }}</td>
<td>{{ game.players }}</td>
<td>{{ (game.turns / game.players)|round(0, 'ceil')|int }}</td>
<td>{{ game.final_score|cislo }}</td>
<td class="label name">
{%- if game.winner_name %}Vyhrává {{ game.winner_name }}
{%- elif game.status == 'probíhá' %}<a href="{{ url_for('game_view', game_id=game.id) }}">Pokračovat</a>
{%- else %}{{ game.status }}{% endif -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endblock %}
+174
View File
@@ -0,0 +1,174 @@
import pytest
from core import Game, GameOver
def play(game: Game, *values: int) -> Game:
"""Odehraje tahy po sobě, jak by je zadával zapisovatel."""
for value in values:
game.add_score(value)
return game
def test_tri_nuly_vynuluji_skore():
game = Game(["A", "B"], final_score=1000)
play(game, 200, 10, 0, 10, 0, 10)
assert game.totals()["A"] == 200
result = game.add_score(0)
assert result.was_reset
assert game.totals()["A"] == 0
def test_nenulovy_tah_prerusi_serii():
game = Game(["A", "B"], final_score=1000)
play(game, 0, 10, 0, 10, 50, 10)
result = game.add_score(0)
assert not result.was_reset
assert game.totals()["A"] == 50
def test_po_vynulovani_zacina_serie_znovu():
game = Game(["A"], final_score=1000)
play(game, 100, 0, 0, 0)
assert game.totals()["A"] == 0
# Další dvě nuly ještě vynulovat nesmí — série se počítá od resetu.
assert not game.add_score(0).was_reset
assert not game.add_score(0).was_reset
assert game.add_score(0).was_reset
def test_vynulovani_se_neuklada_jako_zaporny_tah():
game = Game(["A"], final_score=1000)
play(game, 300, 0, 0, 0)
assert [t.value for t in game.turns] == [300, 0, 0, 0]
assert [t.reset for t in game.turns] == [False, False, False, True]
def test_remiza_na_prvnim_miste_hru_nekonci():
game = Game(["A", "B"], final_score=1000)
play(game, 1000, 1000)
assert game.round_complete
assert game.check_win() == ""
assert not game.finished
def test_vitez_az_po_dokoncenem_kole():
game = Game(["A", "B"], final_score=1000)
game.add_score(1000)
# A je přes limit, ale B ještě nehrál.
assert game.totals()["A"] == 1000
assert game.winner == ""
result = game.add_score(10)
assert result.winner == "A"
assert game.finished
def test_do_dohrane_hry_uz_nejde_zapsat():
game = Game(["A", "B"], final_score=1000)
play(game, 1000, 10)
with pytest.raises(GameOver):
game.add_score(50)
def test_undo_vrati_skore_i_hrace_na_tahu():
game = Game(["A", "B"], final_score=1000)
play(game, 100, 50)
assert game.current_player == "A"
game.add_score(70)
assert game.current_player == "B"
assert game.totals()["A"] == 170
game.undo()
assert game.current_player == "A"
assert game.totals()["A"] == 100
def test_undo_pres_vynulovani_obnovi_puvodni_skore():
game = Game(["A"], final_score=1000)
play(game, 400, 0, 0, 0)
assert game.totals()["A"] == 0
game.undo()
assert game.totals()["A"] == 400
assert game.zero_streak("A") == 2
def test_undo_na_prazdne_hre():
game = Game(["A"])
assert game.undo() is None
def test_zero_streak():
game = Game(["A"], final_score=1000)
assert game.zero_streak("A") == 0
game.add_score(0)
assert game.zero_streak("A") == 1
game.add_score(0)
assert game.zero_streak("A") == 2
game.add_score(0)
assert game.zero_streak("A") == 0
def test_poradi_hracu_se_toci_dokola():
game = Game(["A", "B", "C"])
assert [game.current_player for _ in range(1)] == ["A"]
assert game.round_number == 1
play(game, 10, 10, 10)
assert game.current_player == "A"
assert game.round_number == 2
def test_standings_radi_od_nejlepsiho():
game = Game(["A", "B", "C"])
play(game, 10, 300, 50)
assert game.standings() == [(1, "B", 300), (2, "C", 50), (3, "A", 10)]
def test_rounds_dela_zapisnik_po_kolech():
game = Game(["A", "B"])
play(game, 10, 20, 30)
rounds = game.rounds()
assert len(rounds) == 2
assert rounds[0][0] == 1
assert rounds[0][1]["A"].value == 10
assert rounds[0][1]["B"].value == 20
assert rounds[1][1]["A"].value == 30
assert rounds[1][1]["B"] is None
def test_hra_potrebuje_hrace():
with pytest.raises(ValueError):
Game([])
with pytest.raises(ValueError):
Game(["A", "A"])
+92
View File
@@ -0,0 +1,92 @@
import pytest
import net
@pytest.mark.parametrize("ip, interface, expected", [
("127.0.0.1", "lo", None), # loopback
("169.254.3.7", "wlp44s0", None), # link-local, adresa bez DHCP
("0.0.0.0", "eth0", None),
("nesmysl", "eth0", None),
("192.168.1.20", "wlp44s0", "lan"),
("10.186.234.182", "wlp44s0", "lan"), # hotspot z telefonu
("172.20.10.3", "en0", "lan"), # hotspot z iPhonu
("100.91.0.24", "tailscale0", "tailnet"),
("172.17.0.1", "docker0", "virtual"),
("192.168.122.1", "virbr0", "virtual"),
("10.0.0.5", "br-2f1a798d", "virtual"),
("93.184.216.34", "eth0", "public"),
])
def test_klasifikace_podle_rozsahu(ip, interface, expected):
assert net.classify(ip, interface) == expected
def test_tailnet_pozna_i_jine_rozhrani():
"""Rozhoduje rozsah adresy, ne jméno rozhraní."""
assert net.classify("100.64.0.1", "eth0") == "tailnet"
assert net.classify("100.127.255.254", "") == "tailnet"
assert net.classify("100.128.0.1", "eth0") == "public" # už mimo rozsah
def test_poradi_adres():
found = [
("docker0", "172.17.0.1"),
("tailscale0", "100.91.0.24"),
("lo", "127.0.0.1"),
("wlp44s0", "10.186.234.182"),
("eth0", "192.168.1.20"),
]
ranked = net.rank(found, primary="10.186.234.182")
assert [a.ip for a in ranked] == [
"10.186.234.182", # výchozí trasa
"192.168.1.20", # ostatní místní sítě
"100.91.0.24", # tailnet funguje i napříč sítěmi
"172.17.0.1", # virtuální až nakonec
]
assert ranked[0].primary
assert not ranked[1].primary
def test_bez_vychozi_trasy_se_poradi_drzi_typu():
found = [("docker0", "172.17.0.1"), ("wlp44s0", "192.168.1.20")]
ranked = net.rank(found, primary=None)
assert [a.ip for a in ranked] == ["192.168.1.20", "172.17.0.1"]
assert not any(a.primary for a in ranked)
def test_duplicity_se_slucuji():
found = [("wlp44s0", "10.0.0.5"), ("", "10.0.0.5")]
assert len(net.rank(found, primary="10.0.0.5")) == 1
def test_zadna_pouzitelna_adresa():
assert net.rank([("lo", "127.0.0.1")], primary=None) == []
def test_dice_host_ma_posledni_slovo(monkeypatch):
monkeypatch.setenv("DICE_HOST", "192.168.5.5")
found = net.addresses()
assert [a.ip for a in found] == ["192.168.5.5"]
assert found[0].primary
def test_url_a_popisek():
address = net.Address(ip="10.0.0.5", interface="wlp44s0", kind="tailnet")
assert address.url(8000) == "http://10.0.0.5:8000"
assert address.label == "přes Tailscale"
def test_v_systemu_se_neco_najde():
"""Na stroji s libovolnou sítí musí vyjít aspoň jedna adresa."""
found = net.addresses()
assert all(net.classify(a.ip, a.interface) for a in found)
assert len({a.ip for a in found}) == len(found)
+100
View File
@@ -0,0 +1,100 @@
import pytest
import stats
import storage
@pytest.fixture
def conn(tmp_path):
connection = storage.connect(tmp_path / "test.db")
yield connection
connection.close()
def play(conn, names, final_score, values, finish=True):
game_id = storage.create_game(conn, names, final_score)
game = storage.load_game(conn, game_id)
for value in values:
storage.add_turn(conn, game_id, game.add_score(value).turn)
if finish and game.winner:
storage.finish_game(conn, game_id, game.winner)
return game_id
def by_name(table):
return {row["name"]: row for row in table}
def test_prazdna_sin_slavy(conn):
found = stats.records(conn)
assert found["best_turn"] is None
assert found["best_score"] is None
assert found["fastest"] is None
assert stats.careers(conn) == []
def test_rekordy(conn):
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
found = stats.records(conn)
assert found["best_turn"]["name"] == "Adam"
assert found["best_turn"]["value"] == 600
assert found["best_score"]["name"] == "Adam"
assert found["best_score"]["total"] == 1100
assert found["fastest"]["name"] == "Adam"
assert found["fastest"]["rounds"] == 2
def test_kariera_pocita_vyhry_a_uspesnost(conn):
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
play(conn, ["Adam", "Eva"], 1000, [100, 600, 200, 500])
table = by_name(stats.careers(conn))
assert table["Adam"]["games"] == 2
assert table["Adam"]["wins"] == 1
assert table["Adam"]["share"] == 0.5
assert table["Eva"]["wins"] == 1
def test_nedohrane_hry_se_nepocitaji(conn):
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
play(conn, ["Adam", "Eva"], 5000, [100, 100], finish=False)
table = by_name(stats.careers(conn))
assert table["Adam"]["games"] == 1
def test_vynulovani_se_pocita(conn):
# Eva vyhraje, Adam se cestou třikrát vynuluje.
play(conn, ["Adam", "Eva"], 1000, [300, 400, 0, 300, 0, 200, 0, 500])
table = by_name(stats.careers(conn))
assert table["Adam"]["wipeouts"] == 1
assert table["Eva"]["wipeouts"] == 0
def test_prumerne_poradi(conn):
play(conn, ["Adam", "Eva", "Petr"], 1000, [600, 300, 100, 500, 200, 50])
table = by_name(stats.careers(conn))
assert table["Adam"]["placing"] == 1
assert table["Eva"]["placing"] == 2
assert table["Petr"]["placing"] == 3
def test_soucty_v_sql_respektuji_vynulovani(conn):
"""SQL musí počítat celkové skóre stejně jako core.Game.totals()."""
game_id = play(conn, ["Adam", "Eva"], 1000, [300, 100, 0, 100, 0, 100, 0, 900])
game = storage.load_game(conn, game_id)
best = stats.records(conn)["best_score"]
assert game.totals()["Adam"] == 0
assert best["name"] == "Eva"
assert best["total"] == game.totals()["Eva"]
+167
View File
@@ -0,0 +1,167 @@
from pathlib import Path
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, " ")
def test_umisteni_databaze_ze_zdrojaku(monkeypatch):
"""Ze zdrojáků se ukládá do pracovního adresáře."""
monkeypatch.delenv("DICE_DB", raising=False)
monkeypatch.delattr(storage.sys, "frozen", raising=False)
assert storage.default_db() == Path("dice.db")
def test_umisteni_databaze_z_binarky(monkeypatch, tmp_path):
"""Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí."""
monkeypatch.delenv("DICE_DB", raising=False)
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
assert storage.default_db() == tmp_path / "kostky" / "dice.db"
def test_dice_db_prebije_oboje(monkeypatch, tmp_path):
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
monkeypatch.setenv("DICE_DB", str(tmp_path / "jinde.db"))
assert storage.default_db() == tmp_path / "jinde.db"
def test_connect_zalozi_chybejici_adresar(tmp_path):
conn = storage.connect(tmp_path / "novy" / "adresar" / "dice.db")
conn.close()
assert (tmp_path / "novy" / "adresar" / "dice.db").exists()
+328
View File
@@ -0,0 +1,328 @@
import re
import pytest
import net
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
def offer(monkeypatch, *ips):
"""Server nabídne přesně tyhle adresy, v tomhle pořadí."""
found = [net.Address(ip=ip, interface="test", kind="lan", primary=not i)
for i, ip in enumerate(ips)]
monkeypatch.setattr(web.net, "addresses", lambda: found)
def test_tabule_nabidne_qr_ke_kazde_adrese(client, monkeypatch):
"""Kódy jsou vykreslené předem, přepíná se jen ten viditelný."""
offer(monkeypatch, "192.168.0.10", "100.91.0.24", "172.17.0.1")
game_id = start_game(client, "Adam", "Eva")
page = text(client.get(f"/board/{game_id}"))
assert page.count('<svg class="qr"') == 3
for ip in ("192.168.0.10", "100.91.0.24", "172.17.0.1"):
assert f'data-url="http://{ip}:8000/game/{game_id}"' in page
def test_vybrana_adresa_ze_seznamu_zmizi(client, monkeypatch):
"""Velkým písmem i v seznamu zároveň by byla dvakrát."""
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
page = text(client.get("/board"))
assert '<li data-ip="192.168.0.10" hidden>' in page
assert '<li data-ip="100.91.0.24">' in page
def test_jedina_adresa_nema_co_prepinat(client, monkeypatch):
offer(monkeypatch, "192.168.0.10")
page = text(client.get("/board"))
assert '<ul class="alt">' not in page
assert page.count('<svg class="qr"') == 1
def test_otisk_hlida_vsechny_adresy(client, monkeypatch):
"""Tabule se načte znovu i když se změní jen ta druhá v pořadí —
nabídnuté QR kódy už by neplatily."""
game_id = start_game(client, "Adam", "Eva")
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
before = text(client.get(f"/board/{game_id}"))
offer(monkeypatch, "192.168.0.10", "100.91.0.99")
after = text(client.get(f"/board/{game_id}"))
assert 'data-address="192.168.0.10,100.91.0.24"' in before
assert 'data-address="192.168.0.10,100.91.0.99"' in after
@pytest.mark.parametrize("row, expected", [
([], []),
([False, False], []),
([True], [(0, 1)]),
([True, True], [(0, 2)]),
([False, True, True], [(1, 2)]),
([True, True, False, True], [(0, 2), (3, 1)]),
])
def test_souvisle_useky(row, expected):
assert web.runs(row) == expected
def test_qr_obdelniky_kryji_stejne_moduly():
"""Slučování čtverečků do širších obdélníků nesmí kód změnit."""
import qrcode
data = "http://192.168.0.10:8000/"
code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data)
code.make(fit=True)
matrix = code.get_matrix()
svg = str(web.qr_svg(data))
painted = set()
for x, y, width in re.findall(r'<rect x="(\d+)" y="(\d+)" width="(\d+)"', svg):
painted.update((int(x) + step, int(y)) for step in range(int(width)))
expected = {(x, y) for y, row in enumerate(matrix)
for x, dark in enumerate(row) if dark}
assert painted == expected
assert svg.count("<rect") < len(expected)
+336
View File
@@ -0,0 +1,336 @@
# /// 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 sys
from pathlib import Path
import qrcode
from flask import (Flask, abort, g, redirect, render_template, request,
url_for)
from markupsafe import Markup, escape
import net
import stats
import storage
from core import FINAL_SCORE, Game, GameOver
def bundled(folder: str) -> str:
"""Cesta k šablonám a stylům.
Ze zdrojáků leží vedle tohoto souboru. V binárce je PyInstaller rozbalí
do dočasného adresáře, na který ukazuje `sys._MEIPASS`.
"""
root = Path(getattr(sys, "_MEIPASS", Path(__file__).parent))
return str(root / folder)
app = Flask(__name__,
template_folder=bundled("templates"),
static_folder=bundled("static"))
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 address_key() -> str:
"""Otisk všech adres. Tabule ho hlídá: jakmile se změní, načte se znovu,
protože nabídnuté adresy i QR kódy už neplatí."""
return ",".join(address.ip for address in net.addresses())
def links(path: str = "/") -> dict:
"""Adresy, na kterých je počitadlo dostupné, i s cíli pro QR kódy.
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. První v pořadí je ta nejnadějnější,
zbytek si člověk může na tabuli přepnout.
"""
return {
"choices": [
{"ip": address.ip, "label": address.label,
"url": address.url(PORT) + path}
for address in net.addresses()
],
"port": PORT,
}
def runs(row: list[bool]) -> list[tuple[int, int]]:
"""Souvislé tmavé úseky řádku jako (začátek, délka).
Jeden široký obdélník místo řady čtverečků. Na tabuli visí QR kód pro
každou adresu zvlášť, takže se to na velikosti stránky pozná.
"""
found = []
start = None
for x, dark in enumerate([*row, False]):
if dark and start is None:
start = x
elif not dark and start is not None:
found.append((start, x - start))
start = None
return found
def qr_svg(data: str) -> Markup:
"""QR jako SVG složené z obdélní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="{width}" height="1"/>'
for y, row in enumerate(matrix)
for x, width in runs(row)
]
return Markup(
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
f'aria-label="QR kód s adresou {escape(data)}" '
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,
"address_key": address_key(),
}
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>.")
print(f"\nDatabáze: {storage.DB_PATH.resolve()}")
app.run(host="0.0.0.0", port=PORT, threaded=True)