40 Commits
Author SHA1 Message Date
david-spacil fc2df8d86e Merge pull request 'Nasazení na server: Proxmox LXC a Docker' (#13) from agents/claude/nasazeni into main
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 0s
binárka / linux (push) Successful in 11s
Reviewed-on: #13
2026-08-22 21:32:13 +02:00
gitea-actions 83654fe6b5 Zapnout kontejneru nesting
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 0s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 1s
Debian 13 veze systemd 257 a ten v neprivilegovaném LXC bez nestingu
nedostane, co potřebuje — Proxmox na to při startu sám upozorňuje hláškou
"Systemd 257 detected. You may need to enable nesting."

Appka i tak nastartovala, ale spoléhat se na to nemá cenu. Stejnou výchozí
hodnotu mají i community-scripts a sami varují, že moderní distribuce se
systemd nesting potřebují.
2026-08-22 21:23:51 +02:00
gitea-actions 861470da55 Šablonu vybírat podle architektury uzlu
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 2s
pveam nabízí amd64 i arm64 vedle sebe a výběr přes `sort -V | tail -1` padl
na tu abecedně poslední — tedy arm64 i na x86 uzlu. Šablona, která na uzlu
už ležela, se pak nenašla a skript začal stahovat 120 MB nepoužitelného
archivu.

Architektura se teď bere z dpkg --print-architecture a nejdřív se kouká, co
na uzlu už je; stahuje se, až když tam nic nesedí. Neúspěšné stažení navíc
skript zastaví, místo aby se šlo zakládat kontejner ze šablony, která není.

Ke stejné příležitosti Ctrl+C: bez trapu zabil jen rozdělaný podproces
a skript pokračoval dál — v hlášení od uživatele je vidět, jak po přerušeném
stahování vesele oznámil hotovou šablonu a pustil se do pct create.
2026-08-22 21:21:09 +02:00
gitea-actions 746916a755 Jeden příkaz místo dvou stažených souborů
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 0s
testy / pytest (3.13) (pull_request) Successful in 1s
testy / pytest (3.14) (pull_request) Successful in 1s
Instalace teď vypadá tak, jak se u community-scripts čeká:

    bash -c "$(curl -fsSL .../deploy/pve-kostky.sh)"

Dva soubory to neuměly. Přes rouru z curlu není na disku hostitele žádný
soubor, který by šel poslat do kontejneru, takže se instalátor přesunul
dovnitř skriptu jako text v heredocu. Duplicita tím nevzniká — vypsat se dá
zpátky přes `bash pve-kostky.sh instalator`, když ho chce někdo pustit
v kontejneru, který si založil sám.

Argumenty se rourou předávají mizerně, tak jde všechno i proměnnou:
CTID=123 MODE=update. Ze staženého souboru funguje i pozičně.

Ke stejné příležitosti: úložiště se přestalo hádat. local-lvm na ZFS
instalacích neexistuje a pct create by spadlo až po půlce práce; teď se
vybere první aktivní, a když se nějaké vnutí, ověří se, že vůbec existuje.
2026-08-22 19:22:53 +02:00
gitea-actions eaa9c12ebc Nasazení na server: Proxmox LXC a Docker
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
Aby se to dalo nechat běžet pořád, ne jen spouštět večer na notebooku.

pve-kostky.sh se pouští na uzlu Proxmoxu, založí kontejner a předá řízení
lxc-install.sh, který uvnitř stáhne vydanou binárku, ověří kontrolní součet
a zapíše službu. Ty dva jsou oddělené schválně: instalátor o Proxmoxu nic
neví, takže se dá pustit i v kontejneru, který sis založil sám. Podoba je
odkoukaná od community-scripts.org, ale nic z jejich frameworku se nestahuje
— jejich build.func si instalační skript hledá natvrdo ve vlastním
repozitáři, takže mimo něj nefunguje.

Docker se staví ze zdrojáků, ne z binárky: image pak jde sestavit pro obě
architektury a nezávisí na tom, jestli release proběhl. Zamčené závislosti
z minula dělají sestavení opakovatelné.

Compose používá síť hostitele, a to je podstatné. V bridge režimu vidí
kontejner uvnitř adresu 172.17.0.2, tabule ji poctivě nabídne a udělá na ni
QR kód — jenže z telefonu je nedosažitelná. Ověřeno, ne odhadnuto; kdo bridge
potřebuje, musí nastavit DICE_HOST.

Aplikace sama nepotřebovala změnit nic. DICE_DB, DICE_PORT a DICE_HOST
pokrývají obojí a v LXC má kontejner vlastní adresu v LAN, takže hledání
adres i QR kód fungují beze změny.
2026-08-22 19:12:57 +02:00
david-spacil 923fc03581 Merge pull request 'Infrastrukturní úklid: zamčené závislosti, verze schématu i binárky' (#12) from agents/claude/infrastruktura 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: #12
2026-08-22 18:42:28 +02:00
gitea-actions 3c3805362f Srovnat počet testů v README
binárka / linux (pull_request) Successful in 11s
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 2s
testy / pytest (3.14) (pull_request) Successful in 0s
2026-08-22 18:32:27 +02:00
gitea-actions 023b743c76 Stavět načisto, ať projde i druhá stavba za sebou
uv nad existujícím prostředím skončí chybou a chce --clear. Domácí runner si
pracovní adresář mezi běhy drží a git clean -fd na .build nesáhne, protože je
v .gitignore — takže druhá stavba ve stejném adresáři spadla dřív, než se
stihlo cokoli postavit.
2026-08-22 18:32:16 +02:00
gitea-actions a655bfe6f3 Stažení databáze ze síně slávy
Dokud se hrálo ze zdrojáků, ležel dice.db v pracovním adresáři a záloha byla
otázka zkopírování. Z binárky se schovává do systémového datového adresáře,
který na Windows běžný člověk nenajde — takže se teď dá stáhnout přes
prohlížeč.

VACUUM INTO udělá konzistentní kopii i uprostřed zápisu, na rozdíl od
prostého přečtení souboru. Kopie se přečte do paměti a hned zahodí, ať
nezůstane ležet v dočasném adresáři, když si ji někdo nestáhne celou.
2026-08-22 18:30:49 +02:00
gitea-actions 1a2053ff66 Přidat linuxovou binárku pro ARM64
GitHub dává veřejným repozitářům běhouny ubuntu-24.04-arm zadarmo, takže je
to jeden řádek do matice. Hodí se pro Raspberry Pi nebo jinou malou pořád
zapnutou krabičku, což je rozumnější místo, kde tohle nechat běžet, než
notebook, který se každý večer zapíná.

x86-64 zůstává doma. Tam jde stavět proti staré glibc a když GitHub vypadne,
release má pořád tu platformu, na které to reálně poběží. ARM je výjimka
prostě proto, že doma na něm není na čem postavit.
2026-08-22 18:29:29 +02:00
gitea-actions 33e8582079 Obsluhovat to waitressem, ne vývojovým serverem Flasku
Flask pod pěkně naformátovanou úvodní hlášku vysypal červené varování, že
tohle není produkční server. Waitress je čistě pythonní, takže ho PyInstaller
zabalí bez řečí, a osm vláken pobere telefony u stolu i tabuli, která se
doptává každé dvě vteřiny.

Cestou se ukázalo, že hláška se nikdy nedostala do přesměrovaného výstupu:
mimo konzoli drží Python výstup v bloku a pouští ho, až program skončí —
jenže server neskončí. Zkouška binárky u Gitey si logovala do souboru a pak
ho vypisovala, takže celou dobu tiskla prázdno. Řeší to řádkové bufferování
v console.utf8(), kam to patří vedle té kódové stránky.
2026-08-22 18:29:00 +02:00
gitea-actions e3d56f4c22 Binárka má vědět, která je
Venku jsou čtyři soubory na release, tři systémy a k tomu zdrojáky. Když
někdo napíše, že mu něco nefunguje, není jak zjistit, co vlastně pustil.

Verzi zapisuje build.sh do verze.txt, kostky.spec ji přibalí a version.py ji
z rozbalené binárky přečte. Ze zdrojáků má přednost git describe, který ví
i o commitech nad tagem a o rozdělané práci. V CI se na tagu vnucuje
proměnnou, protože runner klonuje jeden commit bez značek.

Hlásí se přes --version, v úvodní hlášce serveru a v patičce každé stránky.
Zkouška v obou workflow ověří, že se do binárky opravdu dostala.
2026-08-22 18:26:32 +02:00
gitea-actions 4ffd7d3c9d Verzovat schéma databáze
Dokud to běželo jen doma, šlo dice.db v nejhorším smazat. Teď jsou venku
binárky, které si zakládají databázi v %LOCALAPPDATA% a v Library, a ta data
patří cizím lidem — první sloupec, který přibude, na nich prostě nebude.

Verze se drží v PRAGMA user_version. Existující databáze mají nulu a plné
schéma zároveň, což vychází: SCHEMA je samé IF NOT EXISTS, takže se na nich
jen orazítkuje a nic se neztratí.

Databázi z novější verze aplikace radši odmítneme otevřít. Srozumitelná
hláška je lepší než tiše rozbitá data, ke kterým neexistuje záloha.
2026-08-22 18:23:38 +02:00
gitea-actions 3af8fe22ef Zamknout závislosti
Bez zamčených verzí si každá stavba stáhne to, co je zrovna nejnovější:
binárka z dneška a binárka z příštího jara obsahují něco jiného a není jak
zjistit co. Zároveň může vydání kterékoli knihovny rozbít build, aniž by se
sáhlo na řádku kódu — a nejspíš zrovna ve chvíli, kdy se tagne release.

Volné seznamy jsou nově v `.in`, zamčené v `.txt`. Rozděleno na tři: běh,
testy a stavba. Pytest se tak přestane instalovat do prostředí, kde se
staví binárka, a PyInstaller má konečně taky zamčenou verzi — ta na podobu
výsledku sahá ze všech nejvíc.

Zůstává, že se verze píšou i v hlavičce PEP 723, jinak by přestalo fungovat
`uv run web.py`. Aby se ta dvě místa nerozešla, hlídá je test.
2026-08-22 18:22:35 +02:00
david-spacil e0413b6adf Merge pull request 'Čeština na výpisu nesmí shodit server' (#11) from agents/claude/cestina-na-windows into main
testy / pytest (3.11) (push) Successful in 1s
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: #11
2026-08-22 16:17:10 +02:00
gitea-actionsandClaude Opus 5 5821385dc8 Čeština na výpisu nesmí shodit server
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 0s
testy / pytest (3.13) (pull_request) Successful in 0s
testy / pytest (3.14) (pull_request) Successful in 1s
Windowsová binárka spadla dřív, než stihla vypsat adresu:

  print("Počitadlo je dostupné na:")
  UnicodeEncodeError: 'charmap' codec can't encode character 'č'

Windows sahá po historické kódové stránce všude, kde výstup není konzole —
přesměrovaný do souboru, puštěný ze skriptu, ve frontě CI. První háček nad
takovým proudem shodí celý program.

Oba vstupní body si teď výstup přepnou na UTF-8. Na konzoli i na ostatních
systémech je to prázdná operace, protože tam je UTF-8 dávno výchozí.
Pojistka errors="replace" je pro zbytek případů: rozsypaný háček je pořád
lepší než spadlý server.

Chytila to zkouška po stavbě, ne uživatel. Test to reprodukuje přes
TextIOWrapper s cp1252, takže se to bez opravy neprojde ani na Linuxu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:12:16 +02:00
david-spacil 0fd8d072ec Merge pull request 'Opravit značky akcí a zrušený Intel běhoun' (#10) from agents/claude/oprava-znacek into main
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
Reviewed-on: #10
2026-08-22 16:07:11 +02:00
gitea-actionsandClaude Opus 5 d4b979e627 Opravit značky akcí a zrušený Intel běhoun
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
První ostrý běh na GitHubu spadl hned v přípravě jobu:

  Unable to resolve action `astral-sh/setup-uv@v10`

Vydání v10.0.1 existuje, plovoucí značka v10 ne — setup-uv je přestal
vydávat po v7. Obě akce se teď píšou přesnou verzí; u třetí strany je to
stejně poctivější, protože je vidět, co se doopravdy pouští.

Druhá věc: macos-13 GitHub zrušil, takže ten job jen visel ve frontě.
Intel se staví na macos-15-intel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:57:11 +02:00
david-spacil 68a9f1d19f Merge pull request 'Binárky pro Windows a macOS' (#9) from agents/claude/binarky-win-mac into main
testy / pytest (3.11) (push) Successful in 1s
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: #9
2026-08-22 13:46:55 +02:00
gitea-actionsandClaude Opus 5 a7621bb01b Binárky pro Windows a macOS přes GitHub Actions
binárka / linux (pull_request) Successful in 11s
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
PyInstaller neumí křížovou kompilaci — binárku pro každý systém musí
postavit ten systém. Vlastní runner pro Windows a hlavně pro macOS by byl
nepoměr, tak si tyhle stroje půjčujeme od GitHub Actions.

GitHub je jen půjčená dílna, ne druhý domov projektu: repozitář se tam
z Gitey zrcadlí, hotové soubory se posílají zpátky na zdejší release přes
Gitea API a stránka s releasy zůstává jedna. Linux se tam schválně
nestaví; doma to jde proti glibc 2.17 a je to zároveň pojistka, kdyby
GitHub vypadl.

build.sh je společný pro všechny tři systémy, liší se jen tím, že Windows
dává spustitelné soubory venvu do Scripts/ místo bin/. Ořezání symbolů
zůstává jen Linuxu: na macOS by rozbilo podpis, který si PyInstaller sám
přidává a bez kterého se binárka na Apple Silicon vůbec nespustí.

Zkouška po stavbě je stejná jako u linuxové: nastartovat, stáhnout si
stránku i styly a ověřit, že se založila databáze.

Nepodepsané binárky si Gatekeeper ani SmartScreen nenechají líbit, tak je
v README napsané, co s tím — certifikáty za tisíce ročně by na počitadlo
kostek byly nesmysl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:13:57 +02:00
gitea-actionsandClaude Opus 5 26338bfa29 Adresy a data podle systému, ne podle Linuxu
Než začneme rozdávat binárky pro macOS a Windows, musí se na nich chovat
rozumně to, co dosud počítalo s Linuxem.

Adresy rozhraní se hledaly linuxovým ioctl. Na Windows chybí fcntl, na
macOS to číslo znamená něco jiného, takže obojí propadlo až na poslední
zálohu přes hostname — a ta na macOS vrátí jednu adresu. Tabule by tam
nabídla jedinou volbu, přesně to, co jsme minule opravovali. Přibyla
mezizáloha přes ifconfig; spouští se jen tam, kde ioctl nic nenašel,
takže na Linuxu se neplatí nic. Parsování je zvlášť jako čistá funkce,
ať jde otestovat i bez macOS pod rukama.

Databáze z binárky mířila do XDG adresáře na všech systémech. Na Windows
by to znamenalo C:\Users\...\.local\share\, což tam nikdo nečeká. Teď se
volí podle systému: LOCALAPPDATA, Library/Application Support, XDG.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:13:41 +02:00
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
45 changed files with 4925 additions and 113 deletions

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
# Do image patří jen to, co appka za běhu potřebuje.
.git/
.github/
.gitea/
.venv/
.build/
.pytest_cache/
__pycache__/
dist/
tests/
deploy/
*.db
*.spec
build.sh
dice.py
verze.txt
README.md
requirements*.in
requirements-dev.txt
requirements-build.txt
+122
View File
@@ -0,0 +1,122 @@
# 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-dev.txt --no-project pytest -q
- name: Binárka
run: |
# Runner klonuje jeden commit bez značek, takže si git describe
# nemá o co opřít — na tagu mu verzi řekneme rovnou.
if [ "$GITHUB_REF_TYPE" = tag ]; then
export VERSION="$GITHUB_REF_NAME"
fi
./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
./dist/kostky --version
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-dev.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"
+142
View File
@@ -0,0 +1,142 @@
# Binárky pro Windows, macOS a Linux na ARM.
#
# PyInstaller neumí křížovou kompilaci: binárku pro každý systém musí postavit
# ten systém. Kupovat kvůli tomu Mac nebo krmit Windows VM by bylo přehnané,
# tak si tyhle stroje půjčujeme od GitHub Actions.
#
# GitHub je tu jen půjčená dílna, ne druhý domov projektu: repozitář se sem
# z Gitey zrcadlí a hotové soubory se posílají zpátky na tamní release přes
# Gitea API. Jedna stránka s releasy, ta původní.
#
# Linux na x86-64 se tu schválně nestaví — o ten se stará vlastní runner
# (.gitea/workflows/binarka.yml). Když GitHub vypadne, release má pořád tu
# platformu, na které to reálně poběží. ARM doma není na čem postavit, takže
# ten jediný linuxový sem patří.
name: binárky
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
GITEA: https://gitea.spacilovi.eu
GITEA_REPO: david-spacil/dice-counter
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
asset: kostky-windows-x86_64.exe
built: dist/kostky.exe
- os: macos-15-intel # Intel; macos-13 GitHub zrušil
asset: kostky-macos-x86_64
built: dist/kostky
- os: macos-latest # Apple Silicon
asset: kostky-macos-arm64
built: dist/kostky
- os: ubuntu-24.04-arm # pro Raspberry Pi a spol.
asset: kostky-linux-arm64
built: dist/kostky
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
# Přesné verze, ne plovoucí `v7`. Ta u setup-uv nevede tam, kam by
# člověk čekal — hlavní značky se přestaly vydávat po v7, takže `v10`
# neexistuje, i když vydání v10.0.1 ano.
- uses: actions/checkout@v7.0.1
- uses: astral-sh/setup-uv@v10.0.1
- name: Testy
run: uv run --with-requirements requirements-dev.txt --no-project pytest -q
- name: Binárka
run: |
# Na tagu je verze jistá. Jinde je repozitář naklonovaný mělce
# a build.sh si vystačí s tím, co z gitu vyčte.
if [ "$GITHUB_REF_TYPE" = tag ]; then
export VERSION="$GITHUB_REF_NAME"
fi
./build.sh
- name: Zkouška
run: |
# Binárka se musí umět rozbalit, najít šablony a začít poslouchat.
# Adresa se vnucuje, ať zkouška nezávisí na síti běhounu.
export DICE_HOST=127.0.0.1 DICE_PORT=8123 DICE_DB=zkouska/dice.db
"${{ matrix.built }}" &
for _ in $(seq 30); do
curl -sf -o /dev/null "http://127.0.0.1:8123/" && break
sleep 1
done
curl -sf -o /dev/null "http://127.0.0.1:8123/" || {
echo "Server se nerozeběhl." >&2; exit 1; }
curl -sf -o /dev/null "http://127.0.0.1:8123/static/style.css"
curl -sf "http://127.0.0.1:8123/board" | grep -q "127.0.0.1:8123"
test -f zkouska/dice.db
# Verze se musí do binárky opravdu dostat, ne se hlásit jako neznámá.
"${{ matrix.built }}" --version
! "${{ matrix.built }}" --version | grep -q "neznámá"
echo "Binárka jede."
- name: Na release
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
ASSET: ${{ matrix.asset }}
run: |
api="$GITEA/api/v1/repos/$GITEA_REPO"
auth="Authorization: token $TOKEN"
json() { uv run --no-project python -c "$@"; }
# Release u tagu být může, ale nemusí — dá se založit i ručně a
# sahají na něj čtyři buildy naráz, tak se po neúspěchu ptáme znovu.
for _ in 1 2 3; do
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
| json 'import json,sys; print(json.load(sys.stdin).get("id",""))')
[ -n "$id" ] && break
curl -s -o /dev/null -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
"$api/releases"
done
[ -n "$id" ] || { echo "Release ${GITHUB_REF_NAME} se nepodařilo najít." >&2; exit 1; }
# macOS zná jen shasum, Git Bash na Windows jen sha256sum. Formát
# výsledku je stejný, takže se dá ověřit kterýmkoli z nich.
if command -v sha256sum >/dev/null; then
soucet() { sha256sum "$@"; }
else
soucet() { shasum -a 256 "$@"; }
fi
# Součet se počítá nad jménem, pod kterým se soubor stahuje, aby
# šel ověřit rovnou: shasum -a 256 -c kostky-macos-arm64.sha256
cp "${{ matrix.built }}" "$ASSET"
soucet "$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" \
| json '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
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
.venv/
*.db
.pytest_cache/
dist/
build/
.build/
verze.txt
+350
View File
@@ -0,0 +1,350 @@
# 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 pro svůj systém z
[releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases):
| Systém | Soubor |
|---|---|
| Linux (x86-64) | `kostky-linux-x86_64` |
| Linux (ARM64) | `kostky-linux-arm64` |
| macOS (Apple Silicon) | `kostky-macos-arm64` |
| macOS (Intel) | `kostky-macos-x86_64` |
| Windows (x86-64) | `kostky-windows-x86_64.exe` |
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř.
Na Linuxu a macOS:
```bash
chmod +x kostky-*
./kostky-linux-x86_64
```
Na Windows stačí na `.exe` poklepat.
Linuxová binárka je postavená proti glibc 2.17, takže jede prakticky všude.
### Než to poprvé pustíš
Binárky nejsou podepsané — podpisové certifikáty stojí tisíce ročně a na
počitadlo kostek by to byl nesmysl. Systémy si toho všimnou:
- **macOS** stažený soubor označí za karanténní a odmítne ho spustit. Buď
značku sundej (`xattr -dr com.apple.quarantine kostky-macos-arm64`), nebo
soubor stáhni rovnou z terminálu přes `curl -LO` — tudy se karanténa
nenastavuje.
- **Windows** ukáže modré okno SmartScreenu. *Další informace* →
*Přesto spustit*. Občas si postěžuje i antivirus; u zabalených Python
programů je to běžný falešný poplach. Při prvním spuštění se ještě zeptá
brána firewall — bez povolení do místní sítě se telefon nepřipojí.
Kdo tomu nechce věřit, ověří si stažený soubor podle přiloženého součtu
(níž) nebo si ho postaví sám — `./build.sh`, zdrojáky jsou tady celé.
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ý:
```
Kostky v1.1.0
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íť
```
Kterou verzi máš, řekne i `kostky --version`; visí taky v patičce každé
stránky. Když něco nefunguje, je to první věc, na kterou se zeptám.
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 na Linuxu | `~/.local/share/kostky/dice.db` |
| binárkou na macOS | `~/Library/Application Support/kostky/dice.db` |
| binárkou na Windows | `%LOCALAPPDATA%\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. Kdo ho hledat nechce — a z binárky na Windows je zalezlý —
si ho stáhne ze síně slávy odkazem **Stáhnout databázi** (`/export`). Kopie
se dělá přes `VACUUM INTO`, takže je použitelná i uprostřed rozehrané hry.
Schéma má verzi uloženou v `PRAGMA user_version` a `storage.migrate()` si
databázi při každém připojení dorovná. Starší soubory z verzí, kdy se verze
schématu ještě nepsala, se poznají podle nuly a orazítkují se. Databázi
založenou novější verzí počitadla appka odmítne otevřít, místo aby ji tiše
rozbila.
Obsluhuje to `waitress` — čistě pythonní WSGI server, který se zabalí do
binárky stejně snadno jako zbytek. Vestavěný server Flasku by pod úvodní
hlášku vysypal varování, že takhle se to nemá, a měl by pravdu.
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
může zapisovat.
## Nasazení na server
Kdo nechce každý večer spouštět binárku na notebooku, může to nechat běžet
pořád. Obojí níž počítá **jen s domácí sítí** — appka nemá přihlašování
a ani jedno nasazení ji nijak nezabezpečuje. Do internetu to nepatří.
### Proxmox (LXC)
Jeden příkaz v terminálu uzlu Proxmoxu, jako root. Založí kontejner,
nastaví ho, stáhne poslední vydanou binárku, ověří kontrolní součet a zapne
službu:
```bash
bash -c "$(curl -fsSL https://gitea.spacilovi.eu/david-spacil/dice-counter/raw/branch/main/deploy/pve-kostky.sh)"
```
Na konci vypíše adresu, na které počitadlo poslouchá. Výchozí nastavení je
1 jádro, 512 MB, 4 GB disku a Debian 13; úložiště si najde samo. Přebíjí se
proměnnými před příkazem:
```bash
MEMORY=1024 STORAGE=local-zfs PORT=8080 bash -c "$(curl -fsSL ...)"
```
| Proměnná | Výchozí |
|---|---|
| `CTID` | první volné číslo |
| `CT_HOSTNAME` | `kostky` |
| `CORES`, `MEMORY`, `DISK` | `1`, `512`, `4` |
| `STORAGE` | první aktivní úložiště pro kontejnery |
| `TEMPLATE_STORAGE`, `BRIDGE` | `local`, `vmbr0` |
| `PORT` | `8000` |
| `UNPRIVILEGED`, `OSVERSION` | `1`, `13` |
| `NESTING` | `1` — systemd v Debianu 13 ho potřebuje |
| `VERSION` | poslední vydání |
Aktualizace na novější vydání je ten samý příkaz s číslem kontejneru:
```bash
CTID=123 MODE=update bash -c "$(curl -fsSL ...)"
```
Podoba je odkoukaná od [community-scripts.org](https://community-scripts.org),
ale nic z jejich frameworku se nestahuje — jejich `build.func` si instalační
skript hledá natvrdo ve vlastním repozitáři, takže mimo něj nefunguje.
Instalátor, který běží uvnitř kontejneru, je ve skriptu vložený jako text —
přes rouru z curlu na disku hostitele žádný druhý soubor není. O Proxmoxu nic
neví, takže se dá použít i v kontejneru, který sis založil sám:
```bash
bash pve-kostky.sh instalator > install.sh
```
Je idempotentní; druhé spuštění jen vymění binárku za nejnovější.
### Docker
```bash
cd deploy && docker compose up -d
```
Staví se ze zdrojáků, takže image jde sestavit pro amd64 i arm64 a nezávisí
na tom, jestli release pro danou architekturu proběhl. Databáze leží
v pojmenovaném svazku, appka běží pod nerootovým uživatelem.
**Pozor na síť.** Compose schválně používá `network_mode: host`. V bridge
režimu kontejner uvnitř vidí adresu jako `172.17.0.2`, tabule ji poctivě
nabídne a vygeneruje na ni QR kód — jenže z telefonu je nedosažitelná.
Kdo bridge potřebuje, musí nastavit `DICE_HOST` na adresu hostitele v LAN.
| | LXC | Docker |
|---|---|---|
| Odkud | vydaná binárka z releasu | zdrojáky |
| Databáze | `/var/lib/kostky/dice.db` | svazek `kostky-data` |
| Aktualizace | `pve-kostky.sh update <ctid>` | `docker compose build --pull` |
| Adresa a QR | funguje samo | potřeba síť hostitele |
## 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í |
| `console.py` | aby čeština prošla i windowsovou konzolí |
| `version.py` | která verze to je — z gitu, nebo z binárky |
| `build.sh`, `kostky.spec` | stavba binárky |
| `.gitea/workflows/` | testy a linuxová binárka doma |
| `.github/workflows/` | binárky pro Windows a macOS |
| `deploy/` | nasazení na server: Proxmox LXC a Docker |
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-waitress
python3 web.py
```
## Závislosti
Verze jsou zamčené, aby binárka postavená dnes a za půl roku obsahovala
totéž. Volné seznamy jsou v `.in`, zamčené v `.txt`:
| Soubor | K čemu |
|---|---|
| `requirements.in` → `.txt` | běh aplikace |
| `requirements-dev.in` → `.txt` | + pytest, pro testy |
| `requirements-build.in` → `.txt` | + PyInstaller, pro stavbu binárky |
Aktualizace je vědomý krok, ne vedlejší efekt toho, že něco vyšlo na PyPI:
```bash
for f in requirements requirements-dev requirements-build; do
uv pip compile "$f.in" -o "$f.txt"
done
```
Verze se píšou ještě jednou v hlavičce PEP 723 na začátku `web.py` a
`dice.py`, aby fungovalo `uv run web.py`. Že se ty dva zápisy shodují, hlídá
`tests/test_zavislosti.py` — po každém přegenerování je potřeba hlavičky
srovnat.
## Vlastní binárka
```bash
./build.sh
```
Verzi si `build.sh` vezme z `git describe`, nebo se dá vnutit přes
`VERSION=v1.2.3 ./build.sh`. Zapíše ji do `verze.txt`, `kostky.spec` ji
přibalí a binárka ji pak umí ohlásit i na cizím počítači, kde žádný git není.
Verzi Pythonu, proti kterému se staví, přebíjí `PYTHON_VERSION`.
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/`.
Stejný `build.sh` staví i na macOS a na Windows (v Git Bash) — jen tam bez
té starosti o glibc. Křížem to nejde: PyInstaller balí do výsledku interpret
a knihovny toho systému, na kterém běží, takže **binárku pro každý systém
musí postavit ten systém.**
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
### Jak vznikají releasy
Otagovaný commit spustí stavbu na obou stranách:
| Kde | Co staví | Workflow |
|---|---|---|
| vlastní runner u Gitey | Linux x86-64 | `.gitea/workflows/binarka.yml` |
| GitHub Actions | Windows, macOS ×2, Linux ARM64 | `.github/workflows/binarky.yml` |
```bash
git tag v1.0.0 && git push origin v1.0.0
```
GitHub je tu jen půjčená dílna. Repozitář se tam z Gitey zrcadlí
([mirror](https://github.com/david-spacil/dice-counter)), postavené soubory
se posílají zpátky na zdejší release přes Gitea API a projekt má pořád jednu
stránku s releasy — tuhle. Linux na x86-64 se na GitHubu schválně nestaví;
doma to jde proti staré glibc, a když GitHub vypadne, release má aspoň tu
platformu, na které to reálně poběží. ARM64 je výjimka — doma na něm není
na čem stavět, a hodí se pro Raspberry Pi nebo jinou malou pořád zapnutou
krabičku.
Každá stavba nejdřív projede testy a zkusí hotovou binárku nastartovat, než
ji kamkoli pověsí. Vedle každé visí i `.sha256`, takže se stažený soubor dá
ověřit:
```bash
sha256sum -c kostky-linux-x86_64.sha256 # na macOS: shasum -a 256 -c
```
Nad pull requesty se Linux staví taky, 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` —
118 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
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Postaví jednosouborovou binárku do dist/ — pro systém, na kterém běží.
#
# PyInstaller neumí křížovou kompilaci: binárku pro každý systém musí postavit
# ten systém. Linux se staví doma na vlastním runneru, Windows a macOS na
# půjčených strojích GitHub Actions (.github/workflows/binarky.yml).
#
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Na Linuxu
# 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
PYTHON_VERSION=${PYTHON_VERSION:-3.11}
BUILD=${BUILD:-.build}
# Verze, kterou binárka ohlásí přes --version. V CI ji na tagu vnutíme
# proměnnou, protože tam je repozitář naklonovaný na jeden commit a git
# describe nemá o co se opřít.
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo neznámá)}
printf '%s\n' "$VERSION" > verze.txt
echo "Verze: $VERSION"
uv python install "$PYTHON_VERSION"
# --clear, protože runner si pracovní adresář mezi běhy drží a uv nad
# existujícím prostředím jinak skončí chybou. Stavíme načisto.
uv venv --quiet --clear --managed-python --python "$PYTHON_VERSION" "$BUILD"
# Windows dává spustitelné soubory venvu do Scripts/, zbytek světa do bin/.
python="$BUILD/bin/python"
pyinstaller="$BUILD/bin/pyinstaller"
if [ -d "$BUILD/Scripts" ]; then
python="$BUILD/Scripts/python.exe"
pyinstaller="$BUILD/Scripts/pyinstaller.exe"
fi
uv pip install --quiet --python "$python" -r requirements-build.txt
"$pyinstaller" --clean --noconfirm \
--distpath dist --workpath "$BUILD/work" kostky.spec
echo
ls -lh dist/
+27
View File
@@ -0,0 +1,27 @@
"""Aby výstup prošel i tam, kde ho nikdo nečeká na konzoli."""
import sys
def utf8() -> None:
"""Přepne standardní výstup na UTF-8 a na řádkové bufferování.
Windows sahá po historické kódové stránce (cp1252, cp852) všude, kde
výstup není konzole — přesměrovaný do souboru, puštěný ze skriptu, ve
frontě CI. První háček nad takovým proudem shodí celý program, takže
binárka umřela dřív, než stihla vypsat adresu, na které poslouchá.
Na konzoli i na ostatních systémech je UTF-8 dávno výchozí a tohle je
tam prázdná operace. `errors="replace"` je pojistka pro zbylé případy:
rozsypaný háček je pořád lepší než spadlý server.
Řádkové bufferování řeší druhou půlku téhož problému. Mimo konzoli
Python drží výstup v bloku a pouští ho, až se naplní nebo až program
skončí — jenže server neskončí, ten běží. Kdo si ho pustí na pozadí
s výpisem do souboru, našel by tam prázdno; přesně to dělá zkouška
binárky v CI. Pár řádek hlášky za to bufferovat nemá cenu.
"""
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace",
line_buffering=True)
+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
+47
View File
@@ -0,0 +1,47 @@
# Počitadlo kostek jako kontejner.
#
# Staví se ze zdrojáků, ne z vydané binárky: image jde pak sestavit pro
# amd64 i arm64 a nezávisí na tom, jestli pro danou architekturu release
# proběhl. Závislosti jsou zamčené v requirements.txt, takže je sestavení
# opakovatelné.
#
# docker build -f deploy/Dockerfile -t kostky .
#
# Sestavuje se z kořene repozitáře, ne z deploy/.
FROM python:3.11-slim
# Verze se jinak nemá kde vzít — v image není git ani zabalená binárka.
ARG VERSION=neznámá
RUN useradd --system --uid 1000 --home-dir /app --shell /usr/sbin/nologin kostky
WORKDIR /app
# Závislosti zvlášť a dřív než kód, ať se vrstva s pipem přestavuje jen tehdy,
# když se opravdu změnily.
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY console.py core.py net.py stats.py storage.py version.py web.py ./
COPY templates/ templates/
COPY static/ static/
RUN echo "$VERSION" > verze.txt
# Adresář pro databázi musí vzniknout už tady a patřit správnému uživateli —
# svazek, který Docker založí sám, by jinak patřil rootovi a appka běžící pod
# kostky by do něj nezapsala.
RUN install -d -o kostky -g kostky /data
VOLUME ["/data"]
ENV DICE_DB=/data/dice.db \
DICE_PORT=8000
EXPOSE 8000
USER kostky
# Bez curlu, ten ve slim image není. Stačí, že stránka odpoví — sahá i do
# databáze, takže se tím kontroluje i ta.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ.get('DICE_PORT','8000') + '/')"
CMD ["python", "web.py"]
+45
View File
@@ -0,0 +1,45 @@
# Počitadlo kostek v Dockeru.
#
# cd deploy && docker compose up -d
#
# Počítá se s domácí sítí. Appka nemá přihlašování, tohle ji nijak
# nezabezpečuje a do internetu nepatří.
services:
kostky:
build:
context: ..
dockerfile: deploy/Dockerfile
args:
VERSION: ${VERSION:-vlastní sestavení}
image: kostky:latest
container_name: kostky
restart: unless-stopped
# Python jako PID 1 nesklízí osiřelé procesy a hůř reaguje na signály.
init: true
# Síť hostitele schválně. V bridge režimu kontejner uvnitř vidí adresu
# jako 172.17.0.2, tabule ji poctivě nabídne a vygeneruje na ni QR kód —
# jenže z telefonu je nedosažitelná. Se sítí hostitele appka najde tu
# adresu, na kterou se telefon opravdu dovolá.
#
# Kdo bridge potřebuje, ať zakomentuje řádek níž, odkomentuje ports
# a nastaví DICE_HOST na adresu hostitele v LAN:
#
# ports:
# - "8000:8000"
# environment:
# DICE_HOST: 192.168.1.10
network_mode: host
environment:
DICE_PORT: 8000
DICE_DB: /data/dice.db
volumes:
- kostky-data:/data
volumes:
# Zálohu si stáhneš rovnou z appky přes /export, takže se sem nemusí lézt.
kostky-data:
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env bash
# Založí na Proxmoxu LXC kontejner s počitadlem kostek.
#
# Pouští se v terminálu uzlu Proxmoxu jako root, jedním příkazem:
#
# bash -c "$(curl -fsSL https://gitea.spacilovi.eu/david-spacil/dice-counter/raw/branch/main/deploy/pve-kostky.sh)"
#
# Aktualizace stávajícího kontejneru na novější vydání:
#
# CTID=123 MODE=update bash -c "$(curl -fsSL .../pve-kostky.sh)"
#
# Ze staženého souboru jde i obojí postaru: `bash pve-kostky.sh` a
# `bash pve-kostky.sh update 123`.
#
# Podoba je odkoukaná od community-scripts.org, ale nic z jejich frameworku
# se tu nestahuje — jejich build.func si instalační skript hledá natvrdo ve
# vlastním repozitáři, takže mimo něj nefunguje.
#
# Počítá se s domácí sítí. Appka nemá přihlašování a tenhle skript ji nijak
# nezabezpečuje; do internetu ji nepouštěj.
set -euo pipefail
APP="Kostky"
GITEA="${GITEA:-https://gitea.spacilovi.eu}"
REPO="${REPO:-david-spacil/dice-counter}"
# Výchozí hodnoty. Všechny se dají přebít proměnnou prostředí, třeba:
# MEMORY=1024 STORAGE=local-zfs bash -c "$(curl -fsSL ...)"
CT_HOSTNAME="${CT_HOSTNAME:-kostky}"
CORES="${CORES:-1}"
MEMORY="${MEMORY:-512}"
DISK="${DISK:-4}"
STORAGE="${STORAGE:-}"
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
BRIDGE="${BRIDGE:-vmbr0}"
PORT="${PORT:-8000}"
UNPRIVILEGED="${UNPRIVILEGED:-1}"
OSVERSION="${OSVERSION:-13}"
NESTING="${NESTING:-1}"
VERSION="${VERSION:-}" # prázdné = poslední vydání
# --- výpisy ------------------------------------------------------------------
# Bez tohohle Ctrl+C zabije jen rozdělaný podproces a skript jede vesele dál
# — třeba rovnou zakládat kontejner, na který se čekat nemá.
trap 'echo; echo -e "\e[1;31m ✗\e[0m Přerušeno." >&2; exit 130' INT TERM
msg() { echo -e "\e[1;34m →\e[0m $*"; }
ok() { echo -e "\e[1;32m ✓\e[0m $*"; }
die() { echo -e "\e[1;31m ✗\e[0m $*" >&2; exit 1; }
header() {
echo
echo -e "\e[1;36m $APP\e[0m — počitadlo kostek do LXC"
echo -e " \e[2m$GITEA/$REPO\e[0m"
echo
}
# --- instalátor, který poběží uvnitř kontejneru ------------------------------
#
# Je tu jako text schválně. Skript se pouští i rourou z curlu, kdy na disku
# hostitele žádný soubor není a nebylo by co poslat dovnitř. Uvozovky kolem
# 'INSTALL' jsou podstatné: nic z toho se tady nesmí rozvinout, všechny
# proměnné patří až tomu, co poběží v kontejneru.
#
# Vypsat se dá i samostatně: `bash pve-kostky.sh instalator > install.sh`,
# což se hodí, když chceš appku dostat do kontejneru, který sis založil sám.
installer() {
cat <<'INSTALL'
#!/usr/bin/env bash
# Instalace počitadla uvnitř kontejneru. O Proxmoxu nic neví, takže se dá
# pustit v jakémkoli Debianu. Je idempotentní — druhé spuštění jen vymění
# binárku za nejnovější.
set -euo pipefail
GITEA="${GITEA:-https://gitea.spacilovi.eu}"
REPO="${REPO:-david-spacil/dice-counter}"
PORT="${PORT:-8000}"
VERSION="${VERSION:-}"
USER_NAME=kostky
HOME_DIR=/opt/kostky
DATA_DIR=/var/lib/kostky
msg() { echo -e "\e[1;34m →\e[0m $*"; }
ok() { echo -e "\e[1;32m ✓\e[0m $*"; }
die() { echo -e "\e[1;31m ✗\e[0m $*" >&2; exit 1; }
case "$(dpkg --print-architecture)" in
amd64) ASSET=kostky-linux-x86_64 ;;
arm64) ASSET=kostky-linux-arm64 ;;
*) die "Pro architekturu $(dpkg --print-architecture) binárka není." ;;
esac
msg "Doinstalovávám, co je potřeba ke stažení"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates >/dev/null
ok "Základ je hotový"
if [ -z "$VERSION" ]; then
msg "Zjišťuji poslední vydanou verzi"
# Jen tag; adresa přílohy se z něj složí, takže tu nepotřebujeme jq.
VERSION=$(curl -fsSL "$GITEA/api/v1/repos/$REPO/releases/latest" \
| grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -1 | cut -d'"' -f4)
[ -n "$VERSION" ] || die "Nepodařilo se zjistit poslední verzi z $GITEA."
fi
ok "Instaluje se $VERSION ($ASSET)"
BASE="$GITEA/$REPO/releases/download/$VERSION"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
msg "Stahuji binárku"
curl -fsSL -o "$TMP/$ASSET" "$BASE/$ASSET" \
|| die "$ASSET pro $VERSION na releasu není. Vydání pro tuhle architekturu možná ještě neproběhlo."
curl -fsSL -o "$TMP/$ASSET.sha256" "$BASE/$ASSET.sha256" \
|| die "Chybí kontrolní součet — radši nic neinstaluju."
msg "Ověřuji kontrolní součet"
( cd "$TMP" && sha256sum -c "$ASSET.sha256" >/dev/null ) \
|| die "Kontrolní součet nesedí. Stažený soubor zahazuju."
ok "Součet sedí"
id -u "$USER_NAME" >/dev/null 2>&1 \
|| useradd --system --home-dir "$HOME_DIR" --shell /usr/sbin/nologin "$USER_NAME"
install -d -o root -g root -m 755 "$HOME_DIR"
# Zastavit se dá jen to, co běží — při první instalaci služba ještě není.
if systemctl is-active --quiet kostky; then
systemctl stop kostky
fi
install -o root -g root -m 755 "$TMP/$ASSET" "$HOME_DIR/kostky"
echo "$VERSION" > "$HOME_DIR/verze"
ok "Binárka je na místě"
msg "Zapisuji službu"
cat > /etc/systemd/system/kostky.service <<EOF
[Unit]
Description=Počitadlo kostek
Documentation=$GITEA/$REPO
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$USER_NAME
Environment=DICE_DB=$DATA_DIR/dice.db
Environment=DICE_PORT=$PORT
ExecStart=$HOME_DIR/kostky
Restart=on-failure
RestartSec=5
# Databáze je jediné, co appka potřebuje mít zapisovatelné. StateDirectory ji
# založí a předá správnému uživateli, takže se tu nemusí nic chownovat.
StateDirectory=kostky
ReadWritePaths=$DATA_DIR
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
# Binárka se při každém startu rozbaluje do /tmp; tady do vlastního, ne do
# sdíleného.
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --quiet --now kostky
ok "Služba běží"
msg "Čekám, až začne odpovídat"
for _ in $(seq 30); do
curl -fsS -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null && break
sleep 1
done
curl -fsS -o /dev/null "http://127.0.0.1:$PORT/" \
|| die "Služba nastartovala, ale neodpovídá. Mrkni na journalctl -u kostky."
# Verzi bereme ze souboru, ne z binárky. Starší vydání --version neumí
# a místo výpisu by nastartovala server, který by tenhle skript zavěsil.
ok "Počitadlo $(cat "$HOME_DIR/verze") odpovídá na portu $PORT"
INSTALL
}
# --- společné části ----------------------------------------------------------
install_into() {
local ctid="$1" tmp
tmp=$(mktemp)
installer > "$tmp"
msg "Posílám instalátor do kontejneru $ctid"
pct push "$ctid" "$tmp" /root/kostky-install.sh --perms 0755
rm -f "$tmp"
pct exec "$ctid" -- env PORT="$PORT" GITEA="$GITEA" REPO="$REPO" \
VERSION="$VERSION" bash /root/kostky-install.sh
}
address_of() {
pct exec "$1" -- hostname -I 2>/dev/null | awk '{print $1}'
}
hotovo() {
local ctid="$1" ip jmeno
ip=$(address_of "$ctid")
jmeno=$(pct config "$ctid" | awk -F': ' '/^hostname:/ {print $2}')
echo
ok "Hotovo. Kontejner $ctid ($jmeno) běží."
echo
echo -e " Zápisník na notebook: \e[1;32mhttp://${ip}:${PORT}/board\e[0m"
echo -e " Zadávání z telefonu: \e[1;32mhttp://${ip}:${PORT}/\e[0m"
echo
echo -e " \e[2mDatabáze: /var/lib/kostky/dice.db (uvnitř kontejneru)"
echo -e " Log: pct exec $ctid -- journalctl -u kostky -f"
echo -e " Konzole: pct enter $ctid"
echo -e " Zrušit: pct stop $ctid && pct destroy $ctid\e[0m"
echo
}
# --- co se má vlastně dělat --------------------------------------------------
#
# Rourou z curlu se argumenty předávají mizerně, tak jde všechno i proměnnou:
# MODE=update CTID=123. Ze souboru funguje i `bash pve-kostky.sh update 123`.
MODE="${MODE:-${1:-create}}"
[ "$MODE" = "update" ] && CTID="${CTID:-${2:-}}"
if [ "$MODE" = "instalator" ]; then
installer
exit 0
fi
command -v pct >/dev/null || die "Tohle patří na uzel Proxmoxu — pct tu není."
[ "$(id -u)" -eq 0 ] || die "Spusť to jako root."
# --- aktualizace -------------------------------------------------------------
if [ "$MODE" = "update" ]; then
[ -n "${CTID:-}" ] || die "Řekni který: CTID=123 MODE=update ..."
pct status "$CTID" >/dev/null 2>&1 || die "Kontejner $CTID neexistuje."
header
if [ "$(pct status "$CTID")" != "status: running" ]; then
msg "Startuji kontejner"
pct start "$CTID"
sleep 5
fi
install_into "$CTID"
hotovo "$CTID"
exit 0
fi
# --- nový kontejner ----------------------------------------------------------
header
# Úložiště se nehádá. local-lvm na ZFS instalacích neexistuje a pct create by
# spadlo až po půlce práce, tak se radši zeptáme systému, co tu opravdu je.
if [ -z "$STORAGE" ]; then
STORAGE=$(pvesm status -content rootdir 2>/dev/null \
| awk 'NR>1 && $3=="active" {print $1; exit}')
[ -n "$STORAGE" ] || die "Nenašel jsem úložiště pro kontejnery. Zadej STORAGE=<jméno>; co máš, ukáže pvesm status."
msg "Úložiště: $STORAGE (nalezeno automaticky)"
else
pvesm status -content rootdir 2>/dev/null | awk 'NR>1 {print $1}' | grep -qx "$STORAGE" \
|| die "Úložiště $STORAGE pro kontejnery nesedí. Co máš, ukáže: pvesm status -content rootdir"
msg "Úložiště: $STORAGE"
fi
CTID="${CTID:-$(pvesh get /cluster/nextid)}"
msg "Kontejner dostane číslo $CTID"
# Architektura uzlu, ne první šablona v seznamu. pveam nabízí amd64 i arm64
# vedle sebe a bez tohohle filtru by výběr padl na tu abecedně poslední.
ARCH=$(dpkg --print-architecture)
SABLONA="debian-${OSVERSION}-standard[^[:space:]]*_${ARCH}\.tar\.[a-z]*"
msg "Hledám šablonu Debianu $OSVERSION pro $ARCH"
# Nejdřív co už na uzlu leží — stahovat 120 MB znovu je zbytečné.
TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -o "$SABLONA" | sort -V | tail -1)
if [ -n "$TEMPLATE" ]; then
ok "Šablona $TEMPLATE (už je na uzlu)"
else
pveam update >/dev/null 2>&1 || true
TEMPLATE=$(pveam available --section system 2>/dev/null | grep -o "$SABLONA" | sort -V | tail -1)
[ -n "$TEMPLATE" ] || die "Šablona pro Debian $OSVERSION a $ARCH není k dispozici."
msg "Stahuji $TEMPLATE (asi 120 MB, chvíli to trvá)"
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" >/dev/null \
|| die "Stažení šablony selhalo."
ok "Šablona $TEMPLATE"
fi
# Nesting: Debian 13 veze systemd 257 a ten v neprivilegovaném kontejneru bez
# něj nedostane, co potřebuje — Proxmox na to při startu sám upozorňuje.
# Stejnou výchozí hodnotu mají i community-scripts.
msg "Zakládám kontejner"
pct create "$CTID" "$TEMPLATE_STORAGE:vztmpl/$TEMPLATE" \
--hostname "$CT_HOSTNAME" \
--cores "$CORES" \
--memory "$MEMORY" \
--swap 256 \
--rootfs "$STORAGE:$DISK" \
--net0 "name=eth0,bridge=$BRIDGE,ip=dhcp" \
--unprivileged "$UNPRIVILEGED" \
--features "nesting=$NESTING" \
--ostype debian \
--onboot 1 \
--description "$APP — $GITEA/$REPO" >/dev/null
ok "Kontejner $CTID založen"
msg "Startuji"
pct start "$CTID"
msg "Čekám na síť"
for _ in $(seq 60); do
if [ -n "$(address_of "$CTID")" ] \
&& pct exec "$CTID" -- getent hosts deb.debian.org >/dev/null 2>&1; then
break
fi
sleep 2
done
[ -n "$(address_of "$CTID")" ] || die "Kontejner nedostal adresu. Sedí bridge $BRIDGE?"
ok "Adresa $(address_of "$CTID")"
install_into "$CTID"
hotovo "$CTID"
+60 -113
View File
@@ -1,30 +1,37 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["tabulate==0.10.0"]
# ///
"""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`).
"""
import sys
from collections import Counter
from os import name, system
from tabulate import tabulate
hraci = {}
score = {}
pointer = ""
first = ""
final_score = 10000
import console
import version
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 +47,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 +67,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,31 +107,34 @@ 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__":
console.utf8()
if {"--version", "-V"} & set(sys.argv[1:]):
print(f"kostky {version.current()}")
sys.exit(0)
main()
+38
View File
@@ -0,0 +1,38 @@
# -*- 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.
"""
import os
import sys
# Verzi zapisuje build.sh těsně před stavbou. Když se pyinstaller pustí
# ručně bez ní, binárka se postaví taky — jen se pak hlásí jako neznámá.
datas = [("templates", "templates"), ("static", "static")]
if os.path.exists("verze.txt"):
datas.append(("verze.txt", "."))
analysis = Analysis(
["web.py"],
pathex=["."],
datas=datas,
noarchive=False,
)
pyz = PYZ(analysis.pure)
exe = EXE(
pyz,
analysis.scripts,
analysis.binaries,
analysis.datas,
name="kostky",
console=True,
# Ořezání symbolů šetří pár megabajtů, ale jen na Linuxu. Na macOS by
# rozbilo podpis, který si PyInstaller sám přidává a bez kterého se
# binárka na Apple Silicon vůbec nespustí; na Windows nedělá nic.
strip=sys.platform == "linux",
upx=False,
)
+190
View File
@@ -0,0 +1,190 @@
"""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 re
import socket
import subprocess
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 parse_ifconfig(text: str) -> list[tuple[str, str]]:
"""Rozhraní a jejich IPv4 adresy z výstupu `ifconfig`.
Oddělená čistá funkce, ať se dá otestovat i bez macOS pod rukama.
"""
found = []
name = ""
for line in text.splitlines():
head = re.match(r"(\S+):", line)
if head:
name = head.group(1)
continue
addr = re.search(r"\binet (\d+\.\d+\.\d+\.\d+)", line)
if addr:
found.append((name, addr.group(1)))
return found
def _from_ifconfig() -> list[tuple[str, str]]:
"""Adresy přes `ifconfig`. Záloha pro macOS, kde ioctl výše nesedí.
Spouští se jen tam, kde předchozí cesta nic nenašla, takže na Linuxu
se neplatí nic. Trvá jednotky milisekund, cachovat to nemá cenu.
"""
try:
done = subprocess.run(["ifconfig", "-a"], capture_output=True,
text=True, timeout=5, check=True)
except (OSError, subprocess.SubprocessError):
return []
return parse_ifconfig(done.stdout)
def _from_hostname() -> list[tuple[str, str]]:
"""Poslední záloha. Na Windows vrátí adresy všech rozhraní, jinde
obvykle jen jednu — jména rozhraní se takhle nedozvíme."""
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_ifconfig() 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 @@
# Co je potřeba ke stavbě binárky. Zamyká se do requirements-build.txt:
# uv pip compile requirements-build.in -o requirements-build.txt
-r requirements.in
pyinstaller
+37
View File
@@ -0,0 +1,37 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements-build.in -o requirements-build.txt --python-version 3.11
altgraph==0.17.5
# via pyinstaller
blinker==1.9.0
# via flask
click==8.4.2
# via flask
flask==3.1.3
# via -r requirements.in
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
markupsafe==3.0.3
# via
# flask
# jinja2
# werkzeug
packaging==26.3
# via
# pyinstaller
# pyinstaller-hooks-contrib
pyinstaller==6.22.2
# via -r requirements-build.in
pyinstaller-hooks-contrib==2026.6
# via pyinstaller
qrcode==8.2
# via -r requirements.in
setuptools==84.0.0
# via pyinstaller
tabulate==0.10.0
# via -r requirements.in
waitress==3.0.2
# via -r requirements.in
werkzeug==3.1.8
# via flask
+4
View File
@@ -0,0 +1,4 @@
# Co je potřeba k vývoji a testům. Zamyká se do requirements-dev.txt:
# uv pip compile requirements-dev.in -o requirements-dev.txt
-r requirements.in
pytest
+35
View File
@@ -0,0 +1,35 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.11
blinker==1.9.0
# via flask
click==8.4.2
# via flask
flask==3.1.3
# via -r requirements.in
iniconfig==2.3.0
# via pytest
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
markupsafe==3.0.3
# via
# flask
# jinja2
# werkzeug
packaging==26.3
# via pytest
pluggy==1.6.0
# via pytest
pygments==2.21.0
# via pytest
pytest==9.1.1
# via -r requirements-dev.in
qrcode==8.2
# via -r requirements.in
tabulate==0.10.0
# via -r requirements.in
waitress==3.0.2
# via -r requirements.in
werkzeug==3.1.8
# via flask
+6
View File
@@ -0,0 +1,6 @@
# Běhové závislosti. Verze se zamykají do requirements.txt přes:
# uv pip compile requirements.in -o requirements.txt
flask
qrcode
tabulate
waitress
+25
View File
@@ -0,0 +1,25 @@
# This file was autogenerated by uv via the following command:
# uv pip compile requirements.in -o requirements.txt --python-version 3.11
blinker==1.9.0
# via flask
click==8.4.2
# via flask
flask==3.1.3
# via -r requirements.in
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via flask
markupsafe==3.0.3
# via
# flask
# jinja2
# werkzeug
qrcode==8.2
# via -r requirements.in
tabulate==0.10.0
# via -r requirements.in
waitress==3.0.2
# via -r requirements.in
werkzeug==3.1.8
# via flask
+523
View File
@@ -0,0 +1,523 @@
/* 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);
}
/* Verze v patičce. Má být k nalezení, když ji někdo hledá, a jinak neplést. */
.verze {
margin: 2rem 0 0.75rem;
text-align: center;
font-size: 0.75rem;
opacity: 0.35;
}
+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
+358
View File
@@ -0,0 +1,358 @@
"""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 data_home() -> Path:
"""Adresář, kam podle zvyklostí systému patří data aplikací."""
if sys.platform == "win32":
local = os.environ.get("LOCALAPPDATA")
return Path(local) if local else Path.home() / "AppData" / "Local"
if sys.platform == "darwin":
return Path.home() / "Library" / "Application Support"
return Path(os.environ.get("XDG_DATA_HOME")
or Path.home() / ".local" / "share")
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 systému: 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):
return data_home() / "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);
"""
# Každý krok posouvá schéma o jedno dál; krok na indexu 0 vede z verze 1 na 2.
# Přidávat jen na konec a nikdy nesahat na ten, který už někomu proběhl —
# databáze leží lidem na discích a zpátky se nedají stáhnout.
MIGRATIONS: list[str] = []
SCHEMA_VERSION = 1 + len(MIGRATIONS)
def migrate(conn: sqlite3.Connection) -> int:
"""Dorovná databázi na aktuální schéma a vrátí její verzi.
Verze se drží v `PRAGMA user_version`, což je celé číslo, které si SQLite
veze v hlavičce souboru a jinak si ho nevšímá.
Nula znamená dvojí: buď je soubor prázdný, nebo pochází z doby, kdy se
verze ještě nezapisovala. Obojí se vyřeší stejně — `SCHEMA` je samé
`IF NOT EXISTS`, takže na hotové databázi neudělá nic a jen se orazítkuje.
"""
version = conn.execute("PRAGMA user_version").fetchone()[0]
if version > SCHEMA_VERSION:
raise RuntimeError(
f"Databáze je ve verzi {version}, tahle aplikace umí "
f"{SCHEMA_VERSION}. Nejspíš ji založila novější verze počitadla; "
f"aktualizuj, ať o data nepřijdeš.")
if version == 0:
conn.executescript(SCHEMA)
version = 1
for step in MIGRATIONS[version - 1:]:
conn.executescript(step)
version += 1
# PRAGMA neumí zástupné otazníky. Číslo sem teče z kódu, ne od uživatele.
conn.execute(f"PRAGMA user_version = {version}")
conn.commit()
return version
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")
migrate(conn)
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>
+13
View File
@@ -0,0 +1,13 @@
<!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 %}
<footer class="verze">kostky {{ verze }}</footer>
</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 %}
+113
View File
@@ -0,0 +1,113 @@
{% 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>
· <a href="{{ url_for('export') }}">Stáhnout databázi</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 %}
+65
View File
@@ -0,0 +1,65 @@
import io
import console
class Proud:
"""Náhrada za výstup, která si pamatuje, na co ji přepnuli."""
def __init__(self):
self.encoding = "cp1252"
self.errors = "strict"
self.line_buffering = False
def reconfigure(self, encoding, errors, line_buffering=False):
self.encoding = encoding
self.errors = errors
self.line_buffering = line_buffering
def test_vystup_se_prepne_na_utf8(monkeypatch):
out, err = Proud(), Proud()
monkeypatch.setattr(console.sys, "stdout", out)
monkeypatch.setattr(console.sys, "stderr", err)
console.utf8()
assert (out.encoding, out.errors) == ("utf-8", "replace")
assert (err.encoding, err.errors) == ("utf-8", "replace")
assert out.line_buffering and err.line_buffering
def test_cestina_projde_i_pres_cp1252(monkeypatch):
"""Přesně to, na čem umřela první windowsová binárka."""
surovy = io.BytesIO()
proud = io.TextIOWrapper(surovy, encoding="cp1252")
monkeypatch.setattr(console.sys, "stdout", proud)
monkeypatch.setattr(console.sys, "stderr", proud)
console.utf8()
print("Počitadlo je dostupné na:", file=console.sys.stdout)
console.sys.stdout.flush()
assert "Počitadlo je dostupné" in surovy.getvalue().decode("utf-8")
def test_proud_bez_reconfigure_se_prezije(monkeypatch):
"""Zabalený výstup reconfigure mít nemusí; padat kvůli tomu nesmíme."""
monkeypatch.setattr(console.sys, "stdout", object())
monkeypatch.setattr(console.sys, "stderr", object())
console.utf8()
def test_hlaska_je_v_souboru_hned(tmp_path, monkeypatch):
"""Server běží dál, takže na vyprázdnění bufferu při konci se čekat nedá."""
cesta = tmp_path / "server.log"
with cesta.open("w", encoding="utf-8") as soubor:
monkeypatch.setattr(console.sys, "stdout", soubor)
monkeypatch.setattr(console.sys, "stderr", soubor)
console.utf8()
print("Počitadlo je dostupné na:", file=console.sys.stdout)
# Bez zavření souboru — přesně jako u běžícího serveru.
assert "Počitadlo je dostupné" in cesta.read_text(encoding="utf-8")
+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"])
+139
View File
@@ -0,0 +1,139 @@
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)
IFCONFIG_MACOS = """\
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
\toptions=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>
\tinet 127.0.0.1 netmask 0xff000000
\tinet6 ::1 prefixlen 128
gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
\tether 3c:22:fb:aa:bb:cc
\tinet6 fe80::14b0:6b1a:9e2f:1a4c%en0 prefixlen 64 secured scopeid 0xb
\tinet 192.168.0.42 netmask 0xffffff00 broadcast 192.168.0.255
\tmedia: autoselect
\tstatus: active
utun4: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1280
\tinet 100.99.98.97 --> 100.99.98.97 netmask 0xff000000
"""
def test_ifconfig_vytahne_rozhrani_a_adresy():
"""macOS na ioctl z Linuxu neslyší, tak se čte výstup ifconfigu."""
assert net.parse_ifconfig(IFCONFIG_MACOS) == [
("lo0", "127.0.0.1"),
("en0", "192.168.0.42"),
("utun4", "100.99.98.97"),
]
def test_ifconfig_si_neplete_inet6_s_inet():
"""`inet6` začíná stejně jako `inet`; IPv6 adresy tudy projít nesmí."""
assert net.parse_ifconfig("en0: flags=1\n\tinet6 fe80::1 prefixlen 64\n") == []
def test_adresy_z_ifconfigu_projdou_celym_retezem(monkeypatch):
"""Bez linuxového ioctl se sáhne po ifconfigu, teprve pak po hostname."""
monkeypatch.delenv("DICE_HOST", raising=False)
monkeypatch.setattr(net, "_from_interfaces", lambda: [])
monkeypatch.setattr(net, "_from_ifconfig",
lambda: net.parse_ifconfig(IFCONFIG_MACOS))
monkeypatch.setattr(net, "_from_hostname", lambda: pytest.fail("moc brzy"))
monkeypatch.setattr(net, "default_route_address", lambda: "192.168.0.42")
found = net.addresses()
assert [a.ip for a in found] == ["192.168.0.42", "100.99.98.97"]
assert found[0].primary and found[0].interface == "en0"
assert found[1].kind == "tailnet"
+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"]
+251
View File
@@ -0,0 +1,251 @@
import sqlite3
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.setattr(storage.sys, "platform", "linux")
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
assert storage.default_db() == tmp_path / "kostky" / "dice.db"
def test_data_home_podle_systemu(monkeypatch, tmp_path):
"""Každý systém má svůj adresář na data; XDG je jen ten linuxový."""
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "AppData" / "Local"))
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
monkeypatch.setattr(storage.sys, "platform", "win32")
assert storage.data_home() == tmp_path / "AppData" / "Local"
monkeypatch.setattr(storage.sys, "platform", "darwin")
assert storage.data_home() == tmp_path / "Library" / "Application Support"
monkeypatch.setattr(storage.sys, "platform", "linux")
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
assert storage.data_home() == tmp_path / ".local" / "share"
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()
# --- verze schématu ---------------------------------------------------------
def verze(conn) -> int:
return conn.execute("PRAGMA user_version").fetchone()[0]
def test_nova_databaze_je_orazitkovana(tmp_path):
conn = storage.connect(tmp_path / "nova.db")
try:
assert verze(conn) == storage.SCHEMA_VERSION
finally:
conn.close()
def test_databaze_z_doby_pred_verzovanim_prezije(tmp_path):
"""Přesně to, co lidem leží na disku z verzí 1.0 a 1.1.
Tabulky má, razítko ne. Nesmí se ani přijít o data, ani spadnout.
"""
path = tmp_path / "stara.db"
stara = sqlite3.connect(path)
stara.executescript(storage.SCHEMA) # bez PRAGMA user_version
stara.execute("INSERT INTO players (name, key, created_at) "
"VALUES ('Adam', 'adam', '2026-01-01T00:00:00')")
stara.commit()
stara.close()
conn = storage.connect(path)
try:
assert verze(conn) == storage.SCHEMA_VERSION
assert [p["name"] for p in storage.known_players(conn)] == ["Adam"]
finally:
conn.close()
def test_migrace_dojede_jen_ty_chybejici(tmp_path, monkeypatch):
"""Podruhé už krok proběhnout nesmí — jinak by ALTER TABLE spadl."""
monkeypatch.setattr(storage, "MIGRATIONS",
["ALTER TABLE players ADD COLUMN barva TEXT;"])
monkeypatch.setattr(storage, "SCHEMA_VERSION", 2)
path = tmp_path / "migrovana.db"
conn = storage.connect(path)
conn.close()
conn = storage.connect(path) # druhé spuštění téže verze
try:
assert verze(conn) == 2
sloupce = [r[1] for r in conn.execute("PRAGMA table_info(players)")]
assert "barva" in sloupce
finally:
conn.close()
def test_databaze_z_novejsi_verze_se_odmitne(tmp_path):
"""Radši srozumitelná hláška než tiše rozbitá data."""
path = tmp_path / "budouci.db"
conn = storage.connect(path)
conn.execute(f"PRAGMA user_version = {storage.SCHEMA_VERSION + 1}")
conn.commit()
conn.close()
with pytest.raises(RuntimeError, match="novější verze"):
storage.connect(path)
+48
View File
@@ -0,0 +1,48 @@
"""Verze se musí poznat i z binárky, kde žádný git není."""
import pytest
import version
@pytest.fixture(autouse=True)
def bez_pameti():
"""`current()` si výsledek pamatuje; mezi testy je potřeba čistý stůl."""
version.current.cache_clear()
yield
version.current.cache_clear()
def test_z_binarky_se_cte_pribaleny_soubor(monkeypatch, tmp_path):
"""V binárce leží verze.txt v dočasném adresáři vedle šablon."""
(tmp_path / version.NAME).write_text("v9.9.9\n", encoding="utf-8")
monkeypatch.setattr(version.sys, "frozen", True, raising=False)
monkeypatch.setattr(version.sys, "_MEIPASS", str(tmp_path), raising=False)
assert version.current() == "v9.9.9"
def test_binarka_se_gitu_nepta(monkeypatch, tmp_path):
"""Na cizím počítači žádný repozitář není a ptát se nemá koho."""
monkeypatch.setattr(version.sys, "frozen", True, raising=False)
monkeypatch.setattr(version.sys, "_MEIPASS", str(tmp_path), raising=False)
monkeypatch.setattr(version, "described",
lambda: pytest.fail("binárka se ptala gitu"))
assert version.current() == version.UNKNOWN
def test_ze_zdrojaku_ma_prednost_git(monkeypatch):
monkeypatch.delattr(version.sys, "frozen", raising=False)
monkeypatch.setattr(version, "described", lambda: "v1.2.3-4-gabcdef")
monkeypatch.setattr(version, "bundled", lambda: "v1.0.0")
assert version.current() == "v1.2.3-4-gabcdef"
def test_bez_gitu_a_bez_souboru_zbyde_neznama(monkeypatch):
monkeypatch.delattr(version.sys, "frozen", raising=False)
monkeypatch.setattr(version, "described", lambda: "")
monkeypatch.setattr(version, "bundled", lambda: "")
assert version.current() == version.UNKNOWN
+361
View File
@@ -0,0 +1,361 @@
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)
# --- záloha -----------------------------------------------------------------
def test_export_vrati_pouzitelnou_databazi(client, tmp_path):
"""Ze staženého souboru musí jít data přečíst zpátky, ne jen že se stáhne."""
game_id = start_game(client, "Adam", "Eva")
score(client, game_id, 150)
response = client.get("/export")
assert response.status_code == 200
assert "attachment" in response.headers["Content-Disposition"]
assert response.headers["Content-Disposition"].endswith(".db")
stazeny = tmp_path / "stazeny.db"
stazeny.write_bytes(response.data)
conn = storage.connect(stazeny)
try:
assert [p["name"] for p in storage.seating(conn, game_id)] == ["Adam", "Eva"]
assert storage.load_game(conn, game_id).totals()["Adam"] == 150
finally:
conn.close()
def test_export_zabere_i_rozehranou_hru(client):
"""Kopie se dělá za běhu, uprostřed nedohrané hry."""
game_id = start_game(client, "Adam")
score(client, game_id, 100)
assert client.get("/export").status_code == 200
assert text(client.get("/stats")).count("Stáhnout databázi") == 1
+60
View File
@@ -0,0 +1,60 @@
"""Závislosti jsou zamčené a všechna místa se shodnou.
Verze se píšou na dvou místech: v `requirements.txt` pro stavbu binárky
a v hlavičce PEP 723 pro `uv run web.py`. Ta dvě místa se dřív nebo později
rozejdou — leda že by to někdo hlídal. Proto tenhle soubor.
"""
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).parent.parent
# Skript a to, co si podle hlavičky nese s sebou.
SCRIPTS = {"web.py": {"flask", "qrcode", "waitress"},
"dice.py": {"tabulate"}}
def pins(name: str) -> dict[str, str]:
"""Zamčené verze z compilovaného requirements souboru."""
text = (ROOT / name).read_text(encoding="utf-8")
return {m.group(1).lower(): m.group(2)
for m in re.finditer(r"^([A-Za-z0-9_.\-]+)==(\S+)", text, re.M)}
def header(script: str) -> dict[str, str]:
"""Závislosti z hlavičky PEP 723 na začátku skriptu."""
text = (ROOT / script).read_text(encoding="utf-8")
line = re.search(r"^# dependencies = \[(.*)\]$", text, re.M)
assert line, f"{script} nemá hlavičku PEP 723"
return {m.group(1).lower(): m.group(2)
for m in re.finditer(r'"([A-Za-z0-9_.\-]+)==(\S+?)"', line.group(1))}
@pytest.mark.parametrize("name", ["requirements.txt", "requirements-dev.txt",
"requirements-build.txt"])
def test_zavislosti_jsou_zamcene(name):
"""Bez zamčených verzí se binárka z března liší od binárky z dubna."""
lines = [line.strip() for line in (ROOT / name).read_text().splitlines()]
volne = [line for line in lines
if line and not line.startswith("#") and "==" not in line]
assert not volne, f"{name} má nezamčené závislosti: {volne}"
@pytest.mark.parametrize("script", sorted(SCRIPTS))
def test_hlavicka_sedi_s_requirements(script):
"""`uv run web.py` musí dostat totéž, co se zabalí do binárky."""
zamcene = pins("requirements.txt")
v_hlavicce = header(script)
assert set(v_hlavicce) == SCRIPTS[script]
for balicek, verze in v_hlavicce.items():
assert balicek in zamcene, f"{balicek} chybí v requirements.txt"
assert verze == zamcene[balicek], (
f"{script}: {balicek}=={verze}, ale zamčeno je "
f"{zamcene[balicek]} — přepiš hlavičku")
+49
View File
@@ -0,0 +1,49 @@
"""Která verze počitadla to vlastně je.
Venku jsou čtyři binárky pro tři systémy a k tomu zdrojáky. Když někdo
napíše, že mu něco nefunguje, je první otázka vždycky stejná — a bez
`--version` na ni nemá jak odpovědět.
"""
import subprocess
import sys
from functools import lru_cache
from pathlib import Path
NAME = "verze.txt"
UNKNOWN = "neznámá"
def bundled() -> str:
"""Verze vypálená do binárky při stavbě.
`build.sh` ji zapíše do souboru, `kostky.spec` ji přibalí. V binárce
leží vedle šablon v dočasném adresáři, na který ukazuje `sys._MEIPASS`.
"""
root = Path(getattr(sys, "_MEIPASS", Path(__file__).parent))
try:
return (root / NAME).read_text(encoding="utf-8").strip()
except OSError:
return ""
def described() -> str:
"""Verze podle gitu. Ze zdrojáků je to přesnější než cokoli zapsaného."""
try:
done = subprocess.run(
["git", "describe", "--tags", "--always", "--dirty"],
cwd=Path(__file__).parent, capture_output=True, text=True,
timeout=5, check=True)
except (OSError, subprocess.SubprocessError):
return ""
return done.stdout.strip()
@lru_cache(maxsize=1)
def current() -> str:
"""Verze pro výpis. Ptát se gitu má smysl jen mimo binárku."""
if getattr(sys, "frozen", False):
return bundled() or UNKNOWN
return described() or bundled() or UNKNOWN
+396
View File
@@ -0,0 +1,396 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["flask==3.1.3", "qrcode==8.2", "waitress==3.0.2"]
# ///
"""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 io
import os
import shutil
import socket
import sys
import tempfile
from datetime import date
from pathlib import Path
import qrcode
from flask import (Flask, abort, g, redirect, render_template, request,
send_file, url_for)
from markupsafe import Markup, escape
from waitress import serve
import console
import net
import stats
import storage
import version
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.context_processor
def verze():
"""Verze do patičky. Když někdo hlásí chybu, tohle je první otázka."""
return {"verze": version.current()}
@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("/export")
def export():
"""Databáze ke stažení.
Dokud se hrálo ze zdrojáků, ležel `dice.db` v pracovním adresáři a záloha
byla otázka zkopírování. Z binárky se schovává do systémového datového
adresáře, který na Windows běžný člověk nenajde — tak ať se dá stáhnout
přes prohlížeč.
`VACUUM INTO` udělá konzistentní kopii i uprostřed zápisu, na rozdíl od
prostého přečtení souboru. Výsledek si přečteme do paměti a kopii hned
zahodíme: databáze počitadla se počítá v kilobajtech a takhle nezůstane
ležet v dočasném adresáři, když si ji někdo nestáhne celou.
"""
conn = db()
conn.commit() # VACUUM nesmí běžet v transakci
docasny = tempfile.mkdtemp()
try:
kopie = Path(docasny) / "dice.db"
conn.execute("VACUUM INTO ?", (str(kopie),))
data = kopie.read_bytes()
finally:
shutil.rmtree(docasny, ignore_errors=True)
return send_file(io.BytesIO(data), mimetype="application/vnd.sqlite3",
as_attachment=True,
download_name=f"kostky-{date.today().isoformat()}.db")
@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))
def announce() -> None:
"""Vypíše, kde všude je počitadlo k mání, a čím je zrovna spuštěné."""
print(f"Kostky {version.current()}\n")
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()}")
if __name__ == "__main__":
console.utf8()
if {"--version", "-V"} & set(sys.argv[1:]):
print(f"kostky {version.current()}")
sys.exit(0)
announce()
# Ne vestavěný server Flasku: ten pod úvodní hlášku vysype červené
# varování, že takhle se to nemá, a má pravdu. Waitress je čistě
# pythonní, takže se zabalí do binárky bez řečí, a osm vláken pobere
# telefony u stolu i tabuli, která se doptává každé dvě vteřiny.
serve(app, host="0.0.0.0", port=PORT, threads=8)