Author SHA1 Message Date
gitea-actionsandClaude Opus 5 85b6618952 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:12:25 +02:00
11 changed files with 59 additions and 422 deletions

No files matched your search

-115
View File
@@ -1,115 +0,0 @@
# Postaví binárku a pověsí ji na release.
#
# Runner běží v host módu, takže tu není kontejner ani hotové akce
# z marketplace — nic se nespoléhá na to, co má hostitel nainstalováno.
# O starou glibc se stará build.sh přes samostatný CPython od uv.
name: binárka
on:
push:
tags: ["v*"]
pull_request:
workflow_dispatch:
jobs:
linux:
runs-on: homelab
steps:
- name: objdump
run: |
# Jediné, co musí být na hostiteli — PyInstaller jím hledá, na
# kterých knihovnách binárka visí. Zbytek si obstará uv.
command -v objdump >/dev/null && exit 0
sudo=""; [ "$(id -u)" -ne 0 ] && sudo=sudo
if command -v apt-get >/dev/null; then
$sudo apt-get update -qq
$sudo apt-get install -y -qq binutils
elif command -v dnf >/dev/null; then
$sudo dnf install -y -q binutils
else
echo "Nainstaluj binutils, PyInstaller bez objdumpu nestaví." >&2
exit 1
fi
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: uv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
uv --version
- name: Testy
run: uv run --with-requirements requirements.txt --no-project pytest -q
- name: Binárka
run: ./build.sh
- name: Zkouška
run: |
XDG_DATA_HOME=/tmp/zkouska DICE_PORT=8321 DICE_HOST=127.0.0.1 \
./dist/kostky > /tmp/server.log 2>&1 &
server=$!
trap 'kill $server 2>/dev/null || true' EXIT
for _ in $(seq 30); do
curl -sf -o /dev/null http://127.0.0.1:8321/ && break
sleep 1
done
curl -sf -o /dev/null http://127.0.0.1:8321/
curl -sf -o /dev/null http://127.0.0.1:8321/static/style.css
curl -sf -o /dev/null http://127.0.0.1:8321/board
test -f /tmp/zkouska/kostky/dice.db
cat /tmp/server.log
- name: Na release
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: kostky-linux-x86_64
run: |
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
auth="Authorization: token ${TOKEN}"
# Release u tagu být může, ale nemusí — dá se založit i ručně.
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' || true)
if [ -z "$id" ]; then
id=$(curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
"$api/releases" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
fi
# Součet se počítá nad jménem, pod kterým se soubor stahuje, aby
# šel ověřit rovnou: sha256sum -c kostky-linux-x86_64.sha256
cp dist/kostky "$ASSET"
sha256sum "$ASSET" | tee "$ASSET.sha256"
for file in "$ASSET" "$ASSET.sha256"; do
# Aby se dal build zopakovat, stará příloha stejného jména jde pryč.
curl -s -H "$auth" "$api/releases/$id/assets" \
| python3 -c 'import json,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == sys.argv[1]]' "$file" \
| while read -r old; do
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
done
curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@$file" \
"$api/releases/$id/assets?name=$file"
echo "$file pověšen na ${GITHUB_REF_NAME}"
done
-69
View File
@@ -1,69 +0,0 @@
# Testy nad každým pull requestem a nad main.
#
# Runner běží v host módu, takže tu není kontejner ani hotové akce
# z marketplace. Pythony obstará uv, na hostiteli nemusí být žádný.
name: testy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: testy-${{ github.ref }}
cancel-in-progress: true
jobs:
pytest:
runs-on: homelab
strategy:
fail-fast: false
# Runner sdílí pracovní adresář mezi variantami, takže po jedné.
max-parallel: 1
matrix:
python: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Zdrojáky
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git init -q .
git remote remove origin 2>/dev/null || true
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git config http.extraheader "Authorization: token ${TOKEN}"
git fetch -q --depth 1 origin "${GITHUB_REF}"
git config --unset http.extraheader
git checkout -qf FETCH_HEAD
git clean -qfd
- name: uv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
uv python install "${{ matrix.python }}"
- name: Testy
run: |
uv run --managed-python --python "${{ matrix.python }}" \
--with-requirements requirements.txt --no-project \
pytest -q
- name: Terminálová verze
run: |
# Pytest sahá na core.py, ne na prompty. Tohle projde celou partii
# tak, jak ji odehraje člověk: dvě nuly, třetí vynuluje, výhra.
out=$(printf 'A\nB\n\n1000\n\n200\n10\n0\n10\n0\n10\n0\n\n1000\n' \
| TERM=dumb uv run --managed-python --python "${{ matrix.python }}" dice.py)
for expected in "200xx" "Po třech nulových hodech" "Vítězem se stává B"; do
printf '%s' "$out" | grep -qF "$expected" || {
echo "chybí: $expected"
printf '%s\n' "$out"
exit 1
}
done
echo "terminálová verze v pořádku"
-1
View File
@@ -4,4 +4,3 @@ __pycache__/
.pytest_cache/
dist/
build/
.build/
+9 -30
View File
@@ -1,4 +1,4 @@
# Dice Counter
# Počitadlo v kostkách
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
zápisník** a hry se pamatují mezi večery.
@@ -17,7 +17,8 @@ chmod +x kostky
```
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř. Zatím jen pro Linux
na x86-64; postavené proti glibc 2.17, takže jede prakticky všude.
na x86-64; postavené proti glibc 2.28, takže jede na všem od RHEL 8,
Debianu 10 a Ubuntu 20.04 výš.
Ze zdrojáků to je stejně krátké:
@@ -37,8 +38,8 @@ Počitadlo je dostupné na:
```
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`.
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se
`Ctrl+C`.
| Proměnná | K čemu je | Výchozí |
|---|---|---|
@@ -131,31 +132,13 @@ python3 web.py
./build.sh
```
Staví se proti samostatnému CPythonu od `uv`, ne proti systémovému. Ten je
slinkovaný s glibc 2.17; postavené proti Pythonu z Fedory 44 by to chtělo
glibc 2.43 a nešlo by spustit skoro nikde — glibc drží zpětnou kompatibilitu,
ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu
potřeba není. Výsledek je v `dist/kostky`.
Staví se v kontejneru (podman nebo docker) na AlmaLinux 8, ne na hostiteli.
Binárka slinkovaná proti glibc z Fedory 44 by nešla spustit nikde se starším
systémem — glibc drží zpětnou kompatibilitu, ne dopřednou. Výsledek je
v `dist/kostky`.
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
Releasy si tohle dělají samy. Otagovaný commit spustí
`.gitea/workflows/binarka.yml`, ta binárku postaví, projede testy, zkusí
ji nastartovat a pověsí ji na release jako `kostky-linux-x86_64`:
```bash
git tag v1.0.0 && git push origin v1.0.0
```
Vedle binárky se věší i `kostky-linux-x86_64.sha256`, takže se stažený
soubor dá ověřit:
```bash
sha256sum -c kostky-linux-x86_64.sha256
```
Stejná stavba běží i nad každým pull requestem, jen se nikam nevěší.
## Testy
```bash
@@ -163,7 +146,3 @@ uv run --with-requirements requirements.txt --no-project pytest
```
Nebo z připraveného prostředí prostě `pytest`.
Nad každým pull requestem a nad `main` běží `.gitea/workflows/testy.yml` —
84 testů na Pythonu 3.11, 3.12, 3.13 i 3.14, a k tomu skriptovaná partie
v terminálové verzi, na kterou pytest nesahá.
+20 -12
View File
@@ -1,22 +1,30 @@
#!/usr/bin/env bash
# Postaví jednosouborovou binárku pro Linux do dist/kostky.
#
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Ten je
# slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech.
# Postavená proti Pythonu z Fedory 44 by chtěla glibc 2.43 a nešla by
# spustit skoro nikde — glibc drží zpětnou kompatibilitu, ne dopřednou.
# Nic z hostitele se do ní nedostane, takže kontejner k tomu není potřeba.
# Staví se v kontejneru s glibc 2.28 (AlmaLinux 8), ne na hostiteli. Binárka
# slinkovaná proti glibc z Fedory 44 by nešla spustit nikde se starším
# systémem — glibc drží zpětnou kompatibilitu, ne dopřednou. Postavené na
# staré glibc poběží i na nové.
set -euo pipefail
VERSION=${VERSION:-3.11}
BUILD=${BUILD:-.build}
IMAGE=${IMAGE:-docker.io/library/almalinux:8}
PYTHON=${PYTHON:-python3.11}
ENGINE=${ENGINE:-$(command -v podman || command -v docker)}
uv python install "$VERSION"
uv venv --quiet --managed-python --python "$VERSION" "$BUILD"
uv pip install --quiet --python "$BUILD/bin/python" -r requirements.txt pyinstaller
# Rootless podman mapuje roota v kontejneru na tebe, docker ne — tam by
# dist/ zůstalo cizí.
FIXUP=":"
[[ "$ENGINE" == *docker ]] && FIXUP="chown -R $(id -u):$(id -g) dist"
"$BUILD/bin/pyinstaller" --clean --noconfirm \
--distpath dist --workpath "$BUILD/work" kostky.spec
"$ENGINE" run --rm -v "$PWD:/src:z" -w /src "$IMAGE" bash -euc "
dnf install -y -q python3.11 binutils
$PYTHON -m venv /tmp/build
/tmp/build/bin/pip install --quiet --upgrade pip
/tmp/build/bin/pip install --quiet flask qrcode pyinstaller
/tmp/build/bin/pyinstaller --clean --noconfirm \
--distpath dist --workpath /tmp/work kostky.spec
$FIXUP
"
echo
ls -lh dist/kostky
+1 -21
View File
@@ -373,7 +373,7 @@ button {
line-height: 1.4;
}
.join .address .what {
.join .address span {
display: block;
font-family: var(--font-ui);
font-size: 0.7rem;
@@ -382,8 +382,6 @@ button {
color: var(--ink-soft);
}
.join .address .url { display: block; }
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
.idle {
@@ -482,24 +480,6 @@ button {
.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;
+9 -45
View File
@@ -1,58 +1,22 @@
{# 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 %}
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
{% if primary %}
<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>
{{ target|qr }}
<div class="address">
<span class="what">Zapisuje se z telefonu</span>
<span class="url">{{ choices[0].url }}</span>
{% if choices|length > 1 %}
<span>Zapisuje se z telefonu</span>
{{ target }}
{% if others %}
<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>
{% for address in others %}
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></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>
<p class="address"><span>Adresu se nepodařilo zjistit</span>
Spusť server s <code>DICE_HOST=&lt;adresa&gt;</code>.</p>
</div>
{% endif %}
+1 -1
View File
@@ -1,3 +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="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
<div id="sheet">{% include "_sheet.html" %}</div>
+1 -1
View File
@@ -4,7 +4,7 @@
{% block body %}
<div class="board">
<header class="masthead">
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
{% include "_join.html" %}
</header>
-90
View File
@@ -1,8 +1,5 @@
import re
import pytest
import net
import storage
import web
@@ -239,90 +236,3 @@ def test_kratka_hra_nic_neuseka(client):
])
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)
+18 -37
View File
@@ -16,7 +16,7 @@ from pathlib import Path
import qrcode
from flask import (Flask, abort, g, redirect, render_template, request,
url_for)
from markupsafe import Markup, escape
from markupsafe import Markup
import net
import stats
@@ -61,50 +61,31 @@ def close_db(_exception):
# --- 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 primary_ip() -> str:
"""Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
found = net.addresses()
return found[0].ip if found else ""
def links(path: str = "/") -> dict:
"""Adresy, na kterých je počitadlo dostupné, i s cíli pro QR kódy.
"""Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
Hledá se při každém načtení — notebook se během večera může přesunout
z domácí WiFi na hotspot a zpátky. První v pořadí je ta nejnadějnější,
zbytek si člověk může na tabuli přepnout.
z domácí WiFi na hotspot a zpátky.
"""
found = net.addresses()
primary = found[0] if found else None
return {
"choices": [
{"ip": address.ip, "label": address.label,
"url": address.url(PORT) + path}
for address in net.addresses()
],
"primary": primary,
"others": found[1:],
"port": PORT,
"target": primary.url(PORT) + path if primary else "",
}
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."""
"""QR jako SVG složené ze čtverečků — bez Pillow a bez práce s obrázky."""
code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data)
code.make(fit=True)
@@ -112,14 +93,14 @@ def qr_svg(data: str) -> Markup:
size = len(matrix)
rects = [
f'<rect x="{x}" y="{y}" width="{width}" height="1"/>'
f'<rect x="{x}" y="{y}" width="1" height="1"/>'
for y, row in enumerate(matrix)
for x, width in runs(row)
for x, dark in enumerate(row) if dark
]
return Markup(
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
f'aria-label="QR kód s adresou {escape(data)}" '
f'aria-label="QR kód s adresou pro telefon" '
f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
)
@@ -203,7 +184,7 @@ def view(game: Game, game_id: int, row) -> dict:
"standings": game.standings(),
"winner": winner,
"current": "" if winner else game.current_player,
"address_key": address_key(),
"primary_ip": primary_ip(),
}