Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
298919d71b | ||
|
|
84db0c0380 | ||
|
|
ca97857ca0 | ||
|
|
eeb6e0bc1c | ||
|
|
42f454f754 | ||
|
|
fa66635b95 | ||
|
|
2a03474af8 | ||
|
|
b4864981ad | ||
|
|
8aa54dc7a0 | ||
|
|
cb39036724 | ||
|
|
fcfa0e489b | ||
|
|
f33104b5c2 |
No files matched your search
@@ -0,0 +1,115 @@
|
|||||||
|
# Postaví binárku a pověsí ji na release.
|
||||||
|
#
|
||||||
|
# Runner běží v host módu, takže tu není kontejner ani hotové akce
|
||||||
|
# z marketplace — nic se nespoléhá na to, co má hostitel nainstalováno.
|
||||||
|
# O starou glibc se stará build.sh přes samostatný CPython od uv.
|
||||||
|
name: binárka
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux:
|
||||||
|
runs-on: homelab
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: objdump
|
||||||
|
run: |
|
||||||
|
# Jediné, co musí být na hostiteli — PyInstaller jím hledá, na
|
||||||
|
# kterých knihovnách binárka visí. Zbytek si obstará uv.
|
||||||
|
command -v objdump >/dev/null && exit 0
|
||||||
|
sudo=""; [ "$(id -u)" -ne 0 ] && sudo=sudo
|
||||||
|
if command -v apt-get >/dev/null; then
|
||||||
|
$sudo apt-get update -qq
|
||||||
|
$sudo apt-get install -y -qq binutils
|
||||||
|
elif command -v dnf >/dev/null; then
|
||||||
|
$sudo dnf install -y -q binutils
|
||||||
|
else
|
||||||
|
echo "Nainstaluj binutils, PyInstaller bez objdumpu nestaví." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Zdrojáky
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote remove origin 2>/dev/null || true
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git config http.extraheader "Authorization: token ${TOKEN}"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_REF}"
|
||||||
|
git config --unset http.extraheader
|
||||||
|
git checkout -qf FETCH_HEAD
|
||||||
|
git clean -qfd
|
||||||
|
|
||||||
|
- name: uv
|
||||||
|
run: |
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
uv --version
|
||||||
|
|
||||||
|
- name: Testy
|
||||||
|
run: uv run --with-requirements requirements.txt --no-project pytest -q
|
||||||
|
|
||||||
|
- name: Binárka
|
||||||
|
run: ./build.sh
|
||||||
|
|
||||||
|
- name: Zkouška
|
||||||
|
run: |
|
||||||
|
XDG_DATA_HOME=/tmp/zkouska DICE_PORT=8321 DICE_HOST=127.0.0.1 \
|
||||||
|
./dist/kostky > /tmp/server.log 2>&1 &
|
||||||
|
server=$!
|
||||||
|
trap 'kill $server 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
for _ in $(seq 30); do
|
||||||
|
curl -sf -o /dev/null http://127.0.0.1:8321/ && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -sf -o /dev/null http://127.0.0.1:8321/
|
||||||
|
curl -sf -o /dev/null http://127.0.0.1:8321/static/style.css
|
||||||
|
curl -sf -o /dev/null http://127.0.0.1:8321/board
|
||||||
|
test -f /tmp/zkouska/kostky/dice.db
|
||||||
|
cat /tmp/server.log
|
||||||
|
|
||||||
|
- name: Na release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
ASSET: kostky-linux-x86_64
|
||||||
|
run: |
|
||||||
|
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
auth="Authorization: token ${TOKEN}"
|
||||||
|
|
||||||
|
# Release u tagu být může, ale nemusí — dá se založit i ručně.
|
||||||
|
id=$(curl -s -H "$auth" "$api/releases/tags/${GITHUB_REF_NAME}" \
|
||||||
|
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' || true)
|
||||||
|
|
||||||
|
if [ -z "$id" ]; then
|
||||||
|
id=$(curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" \
|
||||||
|
"$api/releases" \
|
||||||
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Součet se počítá nad jménem, pod kterým se soubor stahuje, aby
|
||||||
|
# šel ověřit rovnou: sha256sum -c kostky-linux-x86_64.sha256
|
||||||
|
cp dist/kostky "$ASSET"
|
||||||
|
sha256sum "$ASSET" | tee "$ASSET.sha256"
|
||||||
|
|
||||||
|
for file in "$ASSET" "$ASSET.sha256"; do
|
||||||
|
# Aby se dal build zopakovat, stará příloha stejného jména jde pryč.
|
||||||
|
curl -s -H "$auth" "$api/releases/$id/assets" \
|
||||||
|
| python3 -c 'import json,sys; [print(a["id"]) for a in json.load(sys.stdin) if a["name"] == sys.argv[1]]' "$file" \
|
||||||
|
| while read -r old; do
|
||||||
|
curl -sf -X DELETE -H "$auth" "$api/releases/$id/assets/$old"
|
||||||
|
done
|
||||||
|
|
||||||
|
curl -sf -o /dev/null -X POST -H "$auth" -F "attachment=@$file" \
|
||||||
|
"$api/releases/$id/assets?name=$file"
|
||||||
|
echo "$file pověšen na ${GITHUB_REF_NAME}"
|
||||||
|
done
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Testy nad každým pull requestem a nad main.
|
||||||
|
#
|
||||||
|
# Runner běží v host módu, takže tu není kontejner ani hotové akce
|
||||||
|
# z marketplace. Pythony obstará uv, na hostiteli nemusí být žádný.
|
||||||
|
name: testy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: testy-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pytest:
|
||||||
|
runs-on: homelab
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
# Runner sdílí pracovní adresář mezi variantami, takže po jedné.
|
||||||
|
max-parallel: 1
|
||||||
|
matrix:
|
||||||
|
python: ["3.11", "3.12", "3.13", "3.14"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Zdrojáky
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git init -q .
|
||||||
|
git remote remove origin 2>/dev/null || true
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git config http.extraheader "Authorization: token ${TOKEN}"
|
||||||
|
git fetch -q --depth 1 origin "${GITHUB_REF}"
|
||||||
|
git config --unset http.extraheader
|
||||||
|
git checkout -qf FETCH_HEAD
|
||||||
|
git clean -qfd
|
||||||
|
|
||||||
|
- name: uv
|
||||||
|
run: |
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
uv python install "${{ matrix.python }}"
|
||||||
|
|
||||||
|
- name: Testy
|
||||||
|
run: |
|
||||||
|
uv run --managed-python --python "${{ matrix.python }}" \
|
||||||
|
--with-requirements requirements.txt --no-project \
|
||||||
|
pytest -q
|
||||||
|
|
||||||
|
- name: Terminálová verze
|
||||||
|
run: |
|
||||||
|
# Pytest sahá na core.py, ne na prompty. Tohle projde celou partii
|
||||||
|
# tak, jak ji odehraje člověk: dvě nuly, třetí vynuluje, výhra.
|
||||||
|
out=$(printf 'A\nB\n\n1000\n\n200\n10\n0\n10\n0\n10\n0\n\n1000\n' \
|
||||||
|
| TERM=dumb uv run --managed-python --python "${{ matrix.python }}" dice.py)
|
||||||
|
|
||||||
|
for expected in "200xx" "Po třech nulových hodech" "Vítězem se stává B"; do
|
||||||
|
printf '%s' "$out" | grep -qF "$expected" || {
|
||||||
|
echo "chybí: $expected"
|
||||||
|
printf '%s\n' "$out"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
echo "terminálová verze v pořádku"
|
||||||
@@ -2,3 +2,6 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
*.db
|
*.db
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.build/
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Počitadlo v kostkách
|
# Dice Counter
|
||||||
|
|
||||||
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
|
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
|
||||||
zápisník** a hry se pamatují mezi večery.
|
zápisník** a hry se pamatují mezi večery.
|
||||||
@@ -8,12 +8,24 @@ ten samý Adam jako z minulého měsíce. Žádné účty, žádná hesla.
|
|||||||
|
|
||||||
## Spuštění
|
## Spuštění
|
||||||
|
|
||||||
|
Stáhni binárku z [releases](https://gitea.spacilovi.eu/david-spacil/dice-counter/releases)
|
||||||
|
a spusť ji:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x kostky
|
||||||
|
./kostky
|
||||||
|
```
|
||||||
|
|
||||||
|
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř. Zatím jen pro Linux
|
||||||
|
na x86-64; postavené proti glibc 2.17, takže jede prakticky všude.
|
||||||
|
|
||||||
|
Ze zdrojáků to je stejně krátké:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run web.py
|
uv run web.py
|
||||||
```
|
```
|
||||||
|
|
||||||
To je celé. Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará
|
Závislosti si skript nese v hlavičce (PEP 723) a `uv` je obstará sám.
|
||||||
sám — není co instalovat ani aktivovat.
|
|
||||||
|
|
||||||
Server vypíše všechny adresy, na kterých je dostupný:
|
Server vypíše všechny adresy, na kterých je dostupný:
|
||||||
|
|
||||||
@@ -25,14 +37,33 @@ Počitadlo je dostupné na:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
|
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
|
||||||
zobrazuje i zbylé adresy — když jedna nefunguje, zkus další.
|
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í |
|
| Proměnná | K čemu je | Výchozí |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DICE_DB` | soubor s databází | `dice.db` |
|
| `DICE_DB` | soubor s databází | podle způsobu spuštění, viz níž |
|
||||||
| `DICE_PORT` | port | `8000` |
|
| `DICE_PORT` | port | `8000` |
|
||||||
| `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu |
|
| `DICE_HOST` | pevná adresa; vypne hledání | hledá se za běhu |
|
||||||
|
|
||||||
|
## Kde jsou data
|
||||||
|
|
||||||
|
Jeden soubor SQLite. Založí se sám při prvním načtení stránky, cestu k němu
|
||||||
|
server vypíše při startu.
|
||||||
|
|
||||||
|
| Spuštěno | Databáze |
|
||||||
|
|---|---|
|
||||||
|
| binárkou | `~/.local/share/kostky/dice.db` |
|
||||||
|
| ze zdrojáků | `dice.db` v pracovním adresáři |
|
||||||
|
|
||||||
|
Binárka se při každém spuštění rozbaluje do dočasného adresáře a pouští se
|
||||||
|
odkudkoli, takže relativní cesta by databázi rozsypala po disku — proto
|
||||||
|
napevno domovský adresář. Ze zdrojáků zůstává relativní, ať se dá mít víc
|
||||||
|
sad vedle sebe.
|
||||||
|
|
||||||
|
Zálohovat i stěhovat jde prostým zkopírováním souboru; `DICE_DB` si ho
|
||||||
|
najde kdekoli.
|
||||||
|
|
||||||
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
|
Počítá se s domácí sítí — appka nemá přihlašování a kdokoli na stejné WiFi
|
||||||
může zapisovat.
|
může zapisovat.
|
||||||
|
|
||||||
@@ -69,6 +100,7 @@ při remíze na prvním místě se hraje dál.
|
|||||||
| `stats.py` | síň slávy a kariérní statistiky |
|
| `stats.py` | síň slávy a kariérní statistiky |
|
||||||
| `web.py` | Flask aplikace |
|
| `web.py` | Flask aplikace |
|
||||||
| `dice.py` | totéž v terminálu, bez ukládání |
|
| `dice.py` | totéž v terminálu, bez ukládání |
|
||||||
|
| `build.sh`, `kostky.spec` | stavba binárky |
|
||||||
|
|
||||||
Terminálová verze zůstává funkční jako záloha:
|
Terminálová verze zůstává funkční jako záloha:
|
||||||
|
|
||||||
@@ -93,6 +125,37 @@ sudo dnf install python3-flask python3-qrcode python3-tabulate
|
|||||||
python3 web.py
|
python3 web.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Vlastní binárka
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Staví se proti samostatnému CPythonu od `uv`, ne proti systémovému. Ten je
|
||||||
|
slinkovaný s glibc 2.17; postavené proti Pythonu z Fedory 44 by to chtělo
|
||||||
|
glibc 2.43 a nešlo by spustit skoro nikde — glibc drží zpětnou kompatibilitu,
|
||||||
|
ne dopřednou. Nic z hostitele se do binárky nedostane, takže kontejner k tomu
|
||||||
|
potřeba není. Výsledek je v `dist/kostky`.
|
||||||
|
|
||||||
|
Terminálová verze součástí binárky není; ta se pouští ze zdrojáků.
|
||||||
|
|
||||||
|
Releasy si tohle dělají samy. Otagovaný commit spustí
|
||||||
|
`.gitea/workflows/binarka.yml`, ta binárku postaví, projede testy, zkusí
|
||||||
|
ji nastartovat a pověsí ji na release jako `kostky-linux-x86_64`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0 && git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Vedle binárky se věší i `kostky-linux-x86_64.sha256`, takže se stažený
|
||||||
|
soubor dá ověřit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c kostky-linux-x86_64.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Stejná stavba běží i nad každým pull requestem, jen se nikam nevěší.
|
||||||
|
|
||||||
## Testy
|
## Testy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -100,3 +163,7 @@ uv run --with-requirements requirements.txt --no-project pytest
|
|||||||
```
|
```
|
||||||
|
|
||||||
Nebo z připraveného prostředí prostě `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á.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Postaví jednosouborovou binárku pro Linux do dist/kostky.
|
||||||
|
#
|
||||||
|
# Staví se proti samostatnému CPythonu od uv, ne proti systémovému. Ten je
|
||||||
|
# slinkovaný s glibc 2.17, takže binárka jede i na letitých systémech.
|
||||||
|
# Postavená proti Pythonu z Fedory 44 by chtěla glibc 2.43 a nešla by
|
||||||
|
# spustit skoro nikde — glibc drží zpětnou kompatibilitu, ne dopřednou.
|
||||||
|
# Nic z hostitele se do ní nedostane, takže kontejner k tomu není potřeba.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION=${VERSION:-3.11}
|
||||||
|
BUILD=${BUILD:-.build}
|
||||||
|
|
||||||
|
uv python install "$VERSION"
|
||||||
|
uv venv --quiet --managed-python --python "$VERSION" "$BUILD"
|
||||||
|
uv pip install --quiet --python "$BUILD/bin/python" -r requirements.txt pyinstaller
|
||||||
|
|
||||||
|
"$BUILD/bin/pyinstaller" --clean --noconfirm \
|
||||||
|
--distpath dist --workpath "$BUILD/work" kostky.spec
|
||||||
|
|
||||||
|
echo
|
||||||
|
ls -lh dist/kostky
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
"""Jedna binárka se vším: Python, Flask, šablony i styly.
|
||||||
|
|
||||||
|
Staví se přes `./build.sh`, ne ručně — ta se stará o to, aby výsledek jel
|
||||||
|
i na starších systémech, než je ten tvůj.
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = Analysis(
|
||||||
|
["web.py"],
|
||||||
|
pathex=["."],
|
||||||
|
datas=[("templates", "templates"), ("static", "static")],
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(analysis.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
analysis.scripts,
|
||||||
|
analysis.binaries,
|
||||||
|
analysis.datas,
|
||||||
|
name="kostky",
|
||||||
|
console=True,
|
||||||
|
strip=True,
|
||||||
|
upx=False,
|
||||||
|
)
|
||||||
+21
-1
@@ -373,7 +373,7 @@ button {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.join .address span {
|
.join .address .what {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -382,6 +382,8 @@ button {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.join .address .url { display: block; }
|
||||||
|
|
||||||
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
|
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
|
||||||
|
|
||||||
.idle {
|
.idle {
|
||||||
@@ -480,6 +482,24 @@ button {
|
|||||||
|
|
||||||
.alt li { white-space: nowrap; }
|
.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 {
|
.alt em {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|||||||
+22
-2
@@ -10,12 +10,30 @@ Součty se nikde neukládají; stav hry se rekonstruuje načtením tahů do
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from core import FINAL_SCORE, Game, Turn
|
from core import FINAL_SCORE, Game, Turn
|
||||||
|
|
||||||
DB_PATH = Path(os.environ.get("DICE_DB", "dice.db"))
|
def default_db() -> Path:
|
||||||
|
"""Kam se ukládá databáze.
|
||||||
|
|
||||||
|
Ze zdrojáků do pracovního adresáře — dá se tak mít víc rozehraných
|
||||||
|
sad vedle sebe a je vidět, s čím se pracuje. Z binárky do datového
|
||||||
|
adresáře podle XDG: binárka se spouští odkudkoli a rozbaluje se do
|
||||||
|
dočasného adresáře, takže relativní cesta by databázi rozsypala po disku.
|
||||||
|
"""
|
||||||
|
chosen = os.environ.get("DICE_DB")
|
||||||
|
if chosen:
|
||||||
|
return Path(chosen)
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
share = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
|
||||||
|
return Path(share) / "kostky" / "dice.db"
|
||||||
|
return Path("dice.db")
|
||||||
|
|
||||||
|
|
||||||
|
DB_PATH = default_db()
|
||||||
|
|
||||||
STATUS_RUNNING = "probíhá"
|
STATUS_RUNNING = "probíhá"
|
||||||
STATUS_FINISHED = "dohráno"
|
STATUS_FINISHED = "dohráno"
|
||||||
@@ -60,7 +78,9 @@ CREATE INDEX IF NOT EXISTS turns_game ON turns(game_id, id);
|
|||||||
|
|
||||||
|
|
||||||
def connect(path: Path | str | None = None) -> sqlite3.Connection:
|
def connect(path: Path | str | None = None) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(path or DB_PATH)
|
target = Path(path or DB_PATH)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(target)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
|
|||||||
+45
-9
@@ -1,22 +1,58 @@
|
|||||||
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
|
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může
|
||||||
{% if primary %}
|
člověk — QR kódy jsou vykreslené všechny, přepíná se jen ten viditelný. #}
|
||||||
|
{% if choices %}
|
||||||
<div class="join">
|
<div class="join">
|
||||||
{{ target|qr }}
|
<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">
|
<div class="address">
|
||||||
<span>Zapisuje se z telefonu</span>
|
<span class="what">Zapisuje se z telefonu</span>
|
||||||
{{ target }}
|
<span class="url">{{ choices[0].url }}</span>
|
||||||
{% if others %}
|
|
||||||
|
{% if choices|length > 1 %}
|
||||||
<ul class="alt">
|
<ul class="alt">
|
||||||
{% for address in others %}
|
{% for choice in choices %}
|
||||||
<li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></li>
|
<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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% else %}
|
||||||
<div class="join">
|
<div class="join">
|
||||||
<p class="address"><span>Adresu se nepodařilo zjistit</span>
|
<p class="address"><span class="what">Adresu se nepodařilo zjistit</span>
|
||||||
Spusť server s <code>DICE_HOST=<adresa></code>.</p>
|
Spusť server s <code>DICE_HOST=<adresa></code>.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{# Fragment pro tabuli: obě části, které se během hry mění. #}
|
{# Fragment pro tabuli: obě části, které se během hry mění. #}
|
||||||
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
|
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
|
||||||
<div id="sheet">{% include "_sheet.html" %}</div>
|
<div id="sheet">{% include "_sheet.html" %}</div>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="board">
|
<div class="board">
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
|
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
|
||||||
{% include "_join.html" %}
|
{% include "_join.html" %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import storage
|
import storage
|
||||||
@@ -132,3 +134,34 @@ def test_hrac_nesmi_sedet_u_stolu_dvakrat(conn):
|
|||||||
def test_prazdne_jmeno(conn):
|
def test_prazdne_jmeno(conn):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
storage.get_or_create_player(conn, " ")
|
storage.get_or_create_player(conn, " ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_umisteni_databaze_ze_zdrojaku(monkeypatch):
|
||||||
|
"""Ze zdrojáků se ukládá do pracovního adresáře."""
|
||||||
|
monkeypatch.delenv("DICE_DB", raising=False)
|
||||||
|
monkeypatch.delattr(storage.sys, "frozen", raising=False)
|
||||||
|
|
||||||
|
assert storage.default_db() == Path("dice.db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_umisteni_databaze_z_binarky(monkeypatch, tmp_path):
|
||||||
|
"""Binárka se rozbaluje do dočasného adresáře, databáze tam nesmí."""
|
||||||
|
monkeypatch.delenv("DICE_DB", raising=False)
|
||||||
|
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
|
||||||
|
|
||||||
|
assert storage.default_db() == tmp_path / "kostky" / "dice.db"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dice_db_prebije_oboje(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(storage.sys, "frozen", True, raising=False)
|
||||||
|
monkeypatch.setenv("DICE_DB", str(tmp_path / "jinde.db"))
|
||||||
|
|
||||||
|
assert storage.default_db() == tmp_path / "jinde.db"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_zalozi_chybejici_adresar(tmp_path):
|
||||||
|
conn = storage.connect(tmp_path / "novy" / "adresar" / "dice.db")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert (tmp_path / "novy" / "adresar" / "dice.db").exists()
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import net
|
||||||
import storage
|
import storage
|
||||||
import web
|
import web
|
||||||
|
|
||||||
@@ -236,3 +239,90 @@ def test_kratka_hra_nic_neuseka(client):
|
|||||||
])
|
])
|
||||||
def test_sklonovani_kol(count, expected):
|
def test_sklonovani_kol(count, expected):
|
||||||
assert web.kola(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)
|
||||||
@@ -10,18 +10,32 @@ Server-rendered HTML, formuláře přes POST/redirect/GET, žádný build step.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import qrcode
|
import qrcode
|
||||||
from flask import (Flask, abort, g, redirect, render_template, request,
|
from flask import (Flask, abort, g, redirect, render_template, request,
|
||||||
url_for)
|
url_for)
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
import net
|
import net
|
||||||
import stats
|
import stats
|
||||||
import storage
|
import storage
|
||||||
from core import FINAL_SCORE, Game, GameOver
|
from core import FINAL_SCORE, Game, GameOver
|
||||||
|
|
||||||
app = Flask(__name__)
|
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))
|
PORT = int(os.environ.get("DICE_PORT", 8000))
|
||||||
|
|
||||||
@@ -47,31 +61,50 @@ def close_db(_exception):
|
|||||||
|
|
||||||
# --- adresa a QR kód --------------------------------------------------------
|
# --- adresa a QR kód --------------------------------------------------------
|
||||||
|
|
||||||
def primary_ip() -> str:
|
def address_key() -> str:
|
||||||
"""Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
|
"""Otisk všech adres. Tabule ho hlídá: jakmile se změní, načte se znovu,
|
||||||
found = net.addresses()
|
protože nabídnuté adresy i QR kódy už neplatí."""
|
||||||
return found[0].ip if found else ""
|
return ",".join(address.ip for address in net.addresses())
|
||||||
|
|
||||||
|
|
||||||
def links(path: str = "/") -> dict:
|
def links(path: str = "/") -> dict:
|
||||||
"""Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
|
"""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
|
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.
|
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.
|
||||||
"""
|
"""
|
||||||
found = net.addresses()
|
|
||||||
primary = found[0] if found else None
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"primary": primary,
|
"choices": [
|
||||||
"others": found[1:],
|
{"ip": address.ip, "label": address.label,
|
||||||
|
"url": address.url(PORT) + path}
|
||||||
|
for address in net.addresses()
|
||||||
|
],
|
||||||
"port": PORT,
|
"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:
|
def qr_svg(data: str) -> Markup:
|
||||||
"""QR jako SVG složené ze čtverečků — bez Pillow a bez práce s obrázky."""
|
"""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 = qrcode.QRCode(box_size=1, border=2)
|
||||||
code.add_data(data)
|
code.add_data(data)
|
||||||
code.make(fit=True)
|
code.make(fit=True)
|
||||||
@@ -79,14 +112,14 @@ def qr_svg(data: str) -> Markup:
|
|||||||
size = len(matrix)
|
size = len(matrix)
|
||||||
|
|
||||||
rects = [
|
rects = [
|
||||||
f'<rect x="{x}" y="{y}" width="1" height="1"/>'
|
f'<rect x="{x}" y="{y}" width="{width}" height="1"/>'
|
||||||
for y, row in enumerate(matrix)
|
for y, row in enumerate(matrix)
|
||||||
for x, dark in enumerate(row) if dark
|
for x, width in runs(row)
|
||||||
]
|
]
|
||||||
|
|
||||||
return Markup(
|
return Markup(
|
||||||
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
|
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
|
||||||
f'aria-label="QR kód s adresou pro telefon" '
|
f'aria-label="QR kód s adresou {escape(data)}" '
|
||||||
f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
|
f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -170,7 +203,7 @@ def view(game: Game, game_id: int, row) -> dict:
|
|||||||
"standings": game.standings(),
|
"standings": game.standings(),
|
||||||
"winner": winner,
|
"winner": winner,
|
||||||
"current": "" if winner else game.current_player,
|
"current": "" if winner else game.current_player,
|
||||||
"primary_ip": primary_ip(),
|
"address_key": address_key(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -298,4 +331,6 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
print("Adresu se nepodařilo zjistit. Spusť server s DICE_HOST=<adresa>.")
|
print("Adresu se nepodařilo zjistit. Spusť server s DICE_HOST=<adresa>.")
|
||||||
|
|
||||||
|
print(f"\nDatabáze: {storage.DB_PATH.resolve()}")
|
||||||
|
|
||||||
app.run(host="0.0.0.0", port=PORT, threaded=True)
|
app.run(host="0.0.0.0", port=PORT, threaded=True)
|
||||||
Reference in new issue
Block a user