Compare commits
57
Commits
5b109c2418
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0272f4f70 | ||
|
|
a6c94e239b | ||
|
|
8e6e4a8fe9 | ||
|
|
4e526e8841 | ||
|
|
01dc7a0504 | ||
|
|
182d0a3fdc | ||
|
|
6443a93955 | ||
|
|
7449c01b14 | ||
|
|
5feb3cdd23 | ||
|
|
96f2fc1028 | ||
|
|
826f243af7 | ||
|
|
b1b7d1fad1 | ||
|
|
b6066e2be8 | ||
|
|
9283a87a1e | ||
|
|
0bb2e0efab | ||
|
|
4faddc8b22 | ||
|
|
01d85594b2 | ||
|
|
fc2df8d86e | ||
|
|
83654fe6b5 | ||
|
|
861470da55 | ||
|
|
746916a755 | ||
|
|
eaa9c12ebc | ||
|
|
923fc03581 | ||
|
|
3c3805362f | ||
|
|
023b743c76 | ||
|
|
a655bfe6f3 | ||
|
|
1a2053ff66 | ||
|
|
33e8582079 | ||
|
|
e3d56f4c22 | ||
|
|
4ffd7d3c9d | ||
|
|
3af8fe22ef | ||
|
|
e0413b6adf | ||
|
|
5821385dc8 | ||
|
|
0fd8d072ec | ||
|
|
d4b979e627 | ||
|
|
68a9f1d19f | ||
|
|
a7621bb01b | ||
|
|
26338bfa29 | ||
|
|
298919d71b | ||
|
|
84db0c0380 | ||
|
|
ca97857ca0 | ||
|
|
eeb6e0bc1c | ||
|
|
42f454f754 | ||
|
|
fa66635b95 | ||
|
|
2a03474af8 | ||
|
|
b4864981ad | ||
|
|
8aa54dc7a0 | ||
|
|
cb39036724 | ||
|
|
fcfa0e489b | ||
|
|
f33104b5c2 | ||
|
|
374e4209c5 | ||
|
|
5052a48611 | ||
|
|
cd10db1a72 | ||
|
|
d7c63cf98b | ||
|
|
bcbbede258 | ||
|
|
a45a82ae0e | ||
|
|
4743ea74b9 |
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,127 @@
|
||||
# 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: |
|
||||
# Vlastní adresář pro každý běh. Runner si /tmp mezi běhy drží
|
||||
# a binárka umí sáhnout po databázi ve starém umístění, takže
|
||||
# zbytek po dřívější zkoušce by tuhle poslal vedle.
|
||||
XDG=$(mktemp -d)
|
||||
|
||||
XDG_DATA_HOME="$XDG" DICE_PORT=8321 DICE_HOST=127.0.0.1 \
|
||||
./dist/dice-counter > /tmp/server.log 2>&1 &
|
||||
server=$!
|
||||
trap 'kill $server 2>/dev/null || true; rm -rf "$XDG"' 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 "$XDG/dice-counter/dice.db"
|
||||
./dist/dice-counter --version
|
||||
cat /tmp/server.log
|
||||
|
||||
- name: Na release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: dice-counter-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 dice-counter-linux-x86_64.sha256
|
||||
cp dist/dice-counter "$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,85 @@
|
||||
# Postaví kontejnerový image a zkusí ho spustit. Nikam ho neposílá.
|
||||
#
|
||||
# Dockerfile by se jinak rozbil až ve chvíli, kdy se tagne release — tohle ho
|
||||
# ohlídá nad každým pull requestem.
|
||||
#
|
||||
# Publikuje se jinde: .github/workflows/image.yml. Zdejší runner běží v LXC,
|
||||
# kde nejde zaregistrovat binfmt, takže arm64 by se tu postavit nedalo.
|
||||
# Registrace je vlastnost jádra hostitele a to je produkční stroj, do kterého
|
||||
# se kvůli počitadlu kostek vrtat nebude.
|
||||
#
|
||||
# Staví se nativní amd64 obyčejným `docker build`, žádný buildx ani emulace.
|
||||
name: image
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
ZKOUSKA_PORT: "18000"
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: homelab
|
||||
|
||||
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: Předpoklady
|
||||
run: |
|
||||
command -v docker >/dev/null \
|
||||
|| { echo "Docker na runneru není." >&2; exit 1; }
|
||||
docker version --format '{{.Server.Version}}'
|
||||
|
||||
- name: Stavba
|
||||
run: |
|
||||
docker build -f deploy/Dockerfile \
|
||||
--build-arg VERSION="zkouska-${GITHUB_SHA:0:7}" \
|
||||
-t dice-counter:zkouska .
|
||||
|
||||
- name: Zkouška
|
||||
run: |
|
||||
trap 'docker rm -f dice-counter-zkouska >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
# Port musí být stejný uvnitř i venku: appka poslouchá na DICE_PORT,
|
||||
# takže mapovat ho na jiný by znamenalo mířit do prázdna.
|
||||
# Adresa se vnucuje, ať zkouška nezávisí na síti runneru a dá se
|
||||
# ověřit, co přesně tabule nabídne.
|
||||
docker run -d --name dice-counter-zkouska --init \
|
||||
-p "${ZKOUSKA_PORT}:${ZKOUSKA_PORT}" -e DICE_HOST=127.0.0.1 \
|
||||
-e DICE_PORT="${ZKOUSKA_PORT}" dice-counter:zkouska
|
||||
|
||||
for _ in $(seq 30); do
|
||||
curl -sf -o /dev/null "http://127.0.0.1:${ZKOUSKA_PORT}/" && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
curl -sf -o /dev/null "http://127.0.0.1:${ZKOUSKA_PORT}/" || {
|
||||
echo "Kontejner se nerozeběhl." >&2
|
||||
docker logs dice-counter-zkouska >&2
|
||||
exit 1; }
|
||||
curl -sf -o /dev/null "http://127.0.0.1:${ZKOUSKA_PORT}/static/style.css"
|
||||
curl -sf "http://127.0.0.1:${ZKOUSKA_PORT}/board" | grep -q "127.0.0.1:${ZKOUSKA_PORT}"
|
||||
curl -sf -o /dev/null "http://127.0.0.1:${ZKOUSKA_PORT}/export"
|
||||
|
||||
# Verze se musí do image opravdu dostat, ne se hlásit jako neznámá.
|
||||
docker run --rm dice-counter:zkouska python web.py --version
|
||||
! docker run --rm dice-counter:zkouska python web.py --version | grep -q "neznámá"
|
||||
|
||||
docker logs dice-counter-zkouska
|
||||
echo "Image jede."
|
||||
|
||||
- name: Úklid
|
||||
if: always()
|
||||
run: docker rmi dice-counter:zkouska >/dev/null 2>&1 || true
|
||||
@@ -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"
|
||||
@@ -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: dice-counter-windows-x86_64.exe
|
||||
built: dist/dice-counter.exe
|
||||
- os: macos-15-intel # Intel; macos-13 GitHub zrušil
|
||||
asset: dice-counter-macos-x86_64
|
||||
built: dist/dice-counter
|
||||
- os: macos-latest # Apple Silicon
|
||||
asset: dice-counter-macos-arm64
|
||||
built: dist/dice-counter
|
||||
- os: ubuntu-24.04-arm # pro Raspberry Pi a spol.
|
||||
asset: dice-counter-linux-arm64
|
||||
built: dist/dice-counter
|
||||
|
||||
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 dice-counter-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
|
||||
@@ -0,0 +1,79 @@
|
||||
# Publikuje kontejnerový image do registru balíčků Gitey, pro amd64 i arm64.
|
||||
#
|
||||
# Proč zrovna odsud: domácí runner běží v LXC, kde nejde zaregistrovat binfmt
|
||||
# — je to vlastnost jádra hostitele a ten je produkční. Běhouni GitHubu jsou
|
||||
# plnohodnotné virtuály, kde emulace funguje bez řečí.
|
||||
#
|
||||
# Doma se image přesto staví a zkouší nad každým pull requestem
|
||||
# (.gitea/workflows/image.yml), jen se nikam neposílá. Stejná dělba jako
|
||||
# u binárek: doma se hlídá, tady se vydává.
|
||||
name: image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.spacilovi.eu
|
||||
IMAGE: david-spacil/dice-counter
|
||||
PLATFORMS: linux/amd64,linux/arm64
|
||||
|
||||
jobs:
|
||||
publikace:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Přesná verze, ne plovoucí značka — viz binarky.yml.
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
- name: QEMU pro arm64
|
||||
run: |
|
||||
# Bez hotové akce z marketplace, ať není co udržovat. Běhoun je
|
||||
# virtuál, takže privilegovaný kontejner smí zapisovat do binfmt
|
||||
# a registrace platí pro celé jádro.
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
|
||||
- name: Builder
|
||||
run: |
|
||||
# Výchozí ovladač Dockeru multiplatformní stavbu neumí.
|
||||
docker buildx create --name dice-counter --driver docker-container --use
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
# Bez tohohle by stavba spadla až na konci a hláška by nic neřekla.
|
||||
PLATFORMY=$(docker buildx inspect)
|
||||
echo "$PLATFORMY" | grep -q "linux/arm64" || {
|
||||
echo "Buildkit nevidí linux/arm64, emulace se nezaregistrovala." >&2
|
||||
exit 1; }
|
||||
|
||||
- name: Publikace
|
||||
env:
|
||||
TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
# Uživatel v Gitee, ne na GitHubu. Shodou okolností je to totéž.
|
||||
VLASTNIK: david-spacil
|
||||
run: |
|
||||
# Spouští se to i ručně, a to klidně nad větví. Bez tagu není z čeho
|
||||
# udělat verzi a `:latest` by ukazoval na rozdělanou práci.
|
||||
[ "$GITHUB_REF_TYPE" = tag ] || {
|
||||
echo "Publikovat jde jen z tagu, tohle je ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}." >&2
|
||||
exit 1; }
|
||||
|
||||
# Automatický token na balíčky nestačí ani u Gitey samotné, natož
|
||||
# odsud — musí to být osobní token s oprávněním write:package.
|
||||
[ -n "$TOKEN" ] || { echo "Chybí secret PACKAGE_TOKEN." >&2; exit 1; }
|
||||
echo "$TOKEN" | docker login "$REGISTRY" -u "$VLASTNIK" --password-stdin
|
||||
trap 'docker logout "$REGISTRY" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
# Značka bez počátečního "v": v repozitáři je v1.2.0, u image se
|
||||
# čeká 1.2.0. Verze vypálená do image ale zůstává s "v", ať --version
|
||||
# hlásí totéž co binárky.
|
||||
VERZE="${GITHUB_REF_NAME#v}"
|
||||
|
||||
docker buildx build --platform "$PLATFORMS" --push \
|
||||
-f deploy/Dockerfile \
|
||||
--build-arg VERSION="${GITHUB_REF_NAME}" \
|
||||
-t "$REGISTRY/$IMAGE:$VERZE" \
|
||||
-t "$REGISTRY/$IMAGE:latest" .
|
||||
|
||||
echo "Publikováno: $REGISTRY/$IMAGE:$VERZE a :latest"
|
||||
docker buildx imagetools inspect "$REGISTRY/$IMAGE:$VERZE"
|
||||
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.db
|
||||
.pytest_cache/
|
||||
dist/
|
||||
build/
|
||||
.build/
|
||||
verze.txt
|
||||
@@ -0,0 +1,391 @@
|
||||
# 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) | `dice-counter-linux-x86_64` |
|
||||
| Linux (ARM64) | `dice-counter-linux-arm64` |
|
||||
| macOS (Apple Silicon) | `dice-counter-macos-arm64` |
|
||||
| macOS (Intel) | `dice-counter-macos-x86_64` |
|
||||
| Windows (x86-64) | `dice-counter-windows-x86_64.exe` |
|
||||
|
||||
Nic se neinstaluje — Python, Flask i šablony jsou uvnitř.
|
||||
|
||||
Na Linuxu a macOS:
|
||||
|
||||
```bash
|
||||
chmod +x dice-counter-*
|
||||
./dice-counter-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 dice-counter-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ý:
|
||||
|
||||
```
|
||||
dice-counter v1.2.2
|
||||
|
||||
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 `dice-counter --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/dice-counter/dice.db` |
|
||||
| binárkou na macOS | `~/Library/Application Support/dice-counter/dice.db` |
|
||||
| binárkou na Windows | `%LOCALAPPDATA%\dice-counter\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-dice-counter.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` | `dice-counter` |
|
||||
| `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 |
|
||||
| `AUTOLOGIN` | `1` — konzole ve webu Proxmoxu bez hesla |
|
||||
| `VERSION` | poslední vydání |
|
||||
|
||||
Aktualizace na novější vydání se dá spustit dvěma způsoby. Buď zevnitř
|
||||
kontejneru, kde na to stačí jedno slovo:
|
||||
|
||||
```bash
|
||||
update
|
||||
```
|
||||
|
||||
Nebo z uzlu Proxmoxu tím samým příkazem s číslem kontejneru:
|
||||
|
||||
```bash
|
||||
CTID=123 MODE=update bash -c "$(curl -fsSL ...)"
|
||||
```
|
||||
|
||||
Obojí vymění binárku za poslední vydání a službu restartuje; databáze zůstává.
|
||||
Příkaz `update` si přitom obnoví i sám sebe — instalátor bere z větve `main`,
|
||||
takže jede na nejnovější verzi skriptu, ne na té, se kterou se instalovalo.
|
||||
|
||||
Do konzole kontejneru se dostaneš přímo z webu Proxmoxu, **žádné jméno ani
|
||||
heslo se nezadává** — kontejner root heslo nemá a konzole se přihlašuje sama.
|
||||
Nová práva to nikomu nedává: kdo se dostane do webu Proxmoxu, má root na uzlu
|
||||
tak jako tak. Komu se to nezdá, vypne to přes `AUTOLOGIN=0` a do kontejneru
|
||||
pak leze přes `pct enter <ctid>`.
|
||||
|
||||
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-dice-counter.sh instalator > install.sh
|
||||
```
|
||||
|
||||
Je idempotentní; druhé spuštění jen vymění binárku za nejnovější.
|
||||
|
||||
### Docker
|
||||
|
||||
Stačí jeden soubor, repozitář k tomu potřeba není:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.spacilovi.eu/david-spacil/dice-counter/raw/branch/main/deploy/docker-compose.yaml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Image se stáhne hotový z registru Gitey, pro amd64 i arm64 — Docker si vybere
|
||||
svoji variantu sám. Databáze leží v pojmenovaném svazku, appka běží pod
|
||||
nerootovým uživatelem.
|
||||
|
||||
Aktualizace na novější vydání:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Kdo si ho radši postaví ze zdrojáků, přebije ten stažený stejnou značkou:
|
||||
|
||||
```bash
|
||||
docker build -f deploy/Dockerfile -t gitea.spacilovi.eu/david-spacil/dice-counter:latest .
|
||||
```
|
||||
|
||||
**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 | image z registru |
|
||||
| Databáze | `/var/lib/dice-counter/dice.db` | svazek `dice-counter-data` |
|
||||
| Aktualizace | `update` uvnitř kontejneru | `docker compose 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`, `dice-counter.spec` | stavba binárky |
|
||||
| `.gitea/workflows/` | testy, linuxová binárka a zkouška image 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`, `dice-counter.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` |
|
||||
| GitHub Actions | image amd64 + arm64 | `.github/workflows/image.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 dice-counter-linux-x86_64.sha256 # na macOS: shasum -a 256 -c
|
||||
```
|
||||
|
||||
Nad pull requesty se Linux staví taky, jen se nikam nevěší. Totéž platí pro
|
||||
kontejnerový image — doma se nad každým PR postaví a zkusí nastartovat
|
||||
(`.gitea/workflows/image.yml`), publikuje se ale až z tagu na GitHubu.
|
||||
|
||||
Image se schválně nevydává doma: tamní runner běží v LXC, kde nejde
|
||||
zaregistrovat emulaci pro arm64 — je to vlastnost jádra hostitele a ten je
|
||||
produkční stroj. Běhouni GitHubu jsou plnohodnotné virtuály, kde to funguje
|
||||
bez zásahu do čehokoli.
|
||||
|
||||
## 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á.
|
||||
@@ -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" dice-counter.spec
|
||||
|
||||
echo
|
||||
ls -lh dist/
|
||||
+27
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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 gitea.spacilovi.eu/david-spacil/dice-counter:latest .
|
||||
#
|
||||
# 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á
|
||||
|
||||
# Podle image.source si Gitea připne balíček k repozitáři. Bez toho labelu
|
||||
# image sice existuje, ale visí jen pod uživatelem a záložka Packages
|
||||
# u repozitáře zůstane prázdná.
|
||||
LABEL org.opencontainers.image.source="https://gitea.spacilovi.eu/david-spacil/dice-counter" \
|
||||
org.opencontainers.image.url="https://gitea.spacilovi.eu/david-spacil/dice-counter" \
|
||||
org.opencontainers.image.title="dice-counter" \
|
||||
org.opencontainers.image.description="Počitadlo kostek: telefon zapisuje skóre, notebook ukazuje zápisník" \
|
||||
org.opencontainers.image.version="$VERSION"
|
||||
|
||||
RUN useradd --system --uid 1000 --home-dir /app --shell /usr/sbin/nologin dice-counter
|
||||
|
||||
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
|
||||
# dice-counter by do něj nezapsala.
|
||||
RUN install -d -o dice-counter -g dice-counter /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV DICE_DB=/data/dice.db \
|
||||
DICE_PORT=8000
|
||||
|
||||
EXPOSE 8000
|
||||
USER dice-counter
|
||||
|
||||
# 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"]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Počitadlo kostek v Dockeru.
|
||||
#
|
||||
# curl -fsSLO https://gitea.spacilovi.eu/david-spacil/dice-counter/raw/branch/main/deploy/docker-compose.yaml
|
||||
# docker compose up -d
|
||||
#
|
||||
# Nic dalšího není potřeba — image se stáhne hotový, pro amd64 i arm64.
|
||||
# Kdo si ho chce postavit ze zdrojáků, přebije ten stažený stejnou značkou:
|
||||
#
|
||||
# docker build -f deploy/Dockerfile -t gitea.spacilovi.eu/david-spacil/dice-counter:latest .
|
||||
#
|
||||
# Počítá se s domácí sítí. Appka nemá přihlašování, tohle ji nijak
|
||||
# nezabezpečuje a do internetu nepatří.
|
||||
|
||||
services:
|
||||
dice-counter:
|
||||
image: gitea.spacilovi.eu/david-spacil/dice-counter:latest
|
||||
container_name: dice-counter
|
||||
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:
|
||||
- dice-counter-data:/data
|
||||
|
||||
volumes:
|
||||
# Zálohu si stáhneš rovnou z appky přes /export, takže se sem nemusí lézt.
|
||||
dice-counter-data:
|
||||
Executable
+391
@@ -0,0 +1,391 @@
|
||||
#!/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-dice-counter.sh)"
|
||||
#
|
||||
# Aktualizace stávajícího kontejneru na novější vydání:
|
||||
#
|
||||
# CTID=123 MODE=update bash -c "$(curl -fsSL .../pve-dice-counter.sh)"
|
||||
#
|
||||
# Ze staženého souboru jde i obojí postaru: `bash pve-dice-counter.sh` a
|
||||
# `bash pve-dice-counter.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="dice-counter"
|
||||
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:-dice-counter}"
|
||||
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}"
|
||||
AUTOLOGIN="${AUTOLOGIN:-1}" # konzole ve webu Proxmoxu bez hesla
|
||||
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-dice-counter.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=dice-counter
|
||||
HOME_DIR=/opt/dice-counter
|
||||
DATA_DIR=/var/lib/dice-counter
|
||||
SERVICE=dice-counter
|
||||
|
||||
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=dice-counter-linux-x86_64 ;;
|
||||
arm64) ASSET=dice-counter-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 "$SERVICE"; then
|
||||
systemctl stop "$SERVICE"
|
||||
fi
|
||||
|
||||
install -o root -g root -m 755 "$TMP/$ASSET" "$HOME_DIR/dice-counter"
|
||||
echo "$VERSION" > "$HOME_DIR/verze"
|
||||
ok "Binárka je na místě"
|
||||
|
||||
msg "Zapisuji službu"
|
||||
cat > "/etc/systemd/system/$SERVICE.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/dice-counter
|
||||
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=dice-counter
|
||||
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 "$SERVICE"
|
||||
ok "Služba běží"
|
||||
|
||||
# --- konzole v rozhraní Proxmoxu --------------------------------------------
|
||||
|
||||
if [ "${AUTOLOGIN:-1}" = "1" ]; then
|
||||
msg "Nastavuji konzoli"
|
||||
# Kontejner nemá root heslo, takže by se do konzole ve webu Proxmoxu
|
||||
# nedalo dostat. Autologin to řeší stejně, jako to dělají community-scripts.
|
||||
# Nová práva to nikomu nedává — kdo má web Proxmoxu, má root na uzlu tak
|
||||
# jako tak.
|
||||
mkdir -p /etc/systemd/system/container-getty@1.service.d
|
||||
cat > /etc/systemd/system/container-getty@1.service.d/override.conf <<'GETTY'
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud tty%I 115200,38400,9600 $TERM
|
||||
GETTY
|
||||
systemctl daemon-reload
|
||||
systemctl restart container-getty@1.service 2>/dev/null || true
|
||||
ok "Konzole se přihlásí sama"
|
||||
fi
|
||||
|
||||
# --- příkaz update ----------------------------------------------------------
|
||||
|
||||
# Instalátor si necháme, ať je z čeho aktualizovat. Když se skript pouští
|
||||
# rourou, soubor neexistuje a zůstane ten z minula.
|
||||
#
|
||||
# `-ef` je tu nutnost, ne opatrnost: příkaz `update` si nový instalátor uloží
|
||||
# rovnou sem a odsud ho pustí, takže `$0` a cíl jsou tentýž soubor. `install`
|
||||
# na to řekne "are the same file", skončí chybou a se `set -e` by celý update
|
||||
# spadl dřív, než by stihl cokoli udělat.
|
||||
if [ -f "$0" ] && ! [ "$0" -ef "$HOME_DIR/install.sh" ]; then
|
||||
install -m 755 "$0" "$HOME_DIR/install.sh"
|
||||
fi
|
||||
|
||||
cat > /usr/bin/update <<UPDATE
|
||||
#!/usr/bin/env bash
|
||||
# Aktualizace počitadla na poslední vydání. Stačí napsat: update
|
||||
set -euo pipefail
|
||||
|
||||
# Nejdřív zkusíme obnovit i samotný instalátor, kdyby se mezitím zlepšil.
|
||||
# Bez sítě nebo při změněné adrese se použije ten uložený.
|
||||
NOVY=\$(mktemp)
|
||||
if curl -fsSL "$GITEA/$REPO/raw/branch/main/deploy/pve-dice-counter.sh" -o "\$NOVY" 2>/dev/null \\
|
||||
&& bash "\$NOVY" instalator > "\$NOVY.in" 2>/dev/null \\
|
||||
&& bash -n "\$NOVY.in" 2>/dev/null; then
|
||||
install -m 755 "\$NOVY.in" "$HOME_DIR/install.sh"
|
||||
fi
|
||||
rm -f "\$NOVY" "\$NOVY.in"
|
||||
|
||||
exec env PORT="$PORT" GITEA="$GITEA" REPO="$REPO" bash "$HOME_DIR/install.sh"
|
||||
UPDATE
|
||||
chmod 755 /usr/bin/update
|
||||
ok "Aktualizovat půjde příkazem: update"
|
||||
|
||||
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 $SERVICE."
|
||||
|
||||
# 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/dice-counter-install.sh --perms 0755
|
||||
rm -f "$tmp"
|
||||
|
||||
pct exec "$ctid" -- env PORT="$PORT" GITEA="$GITEA" REPO="$REPO" \
|
||||
VERSION="$VERSION" AUTOLOGIN="$AUTOLOGIN" bash /root/dice-counter-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/dice-counter/dice.db (uvnitř kontejneru)"
|
||||
echo -e " Aktualizace: v konzoli kontejneru napiš update"
|
||||
echo -e " Log: pct exec $ctid -- journalctl -u dice-counter -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-dice-counter.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"
|
||||
@@ -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="dice-counter",
|
||||
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,
|
||||
)
|
||||
@@ -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"dice-counter {version.current()}")
|
||||
sys.exit(0)
|
||||
|
||||
main()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
# Testy sahají na moduly v kořeni repa, ne na nainstalovaný balíček.
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,4 @@
|
||||
# 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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() / "dice-counter" / "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()
|
||||
@@ -0,0 +1,5 @@
|
||||
{# data-address si hlídá tabule: když se notebook přesune na jinou síť,
|
||||
je v QR kódu stará adresa a stránka se sama načte znovu. #}
|
||||
<p class="eyebrow">Zápisník · hra {{ game_id }}</p>
|
||||
<h1>{% if winner %}Konec hry{% else %}Na tahu {{ current }}{% endif %}</h1>
|
||||
<p class="meta">do {{ game.final_score|cislo }} bodů · kolo {{ game.round_number }}</p>
|
||||
@@ -0,0 +1,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=<adresa></code>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{# Fragment pro tabuli: obě části, které se během hry mění. #}
|
||||
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div>
|
||||
<div id="sheet">{% include "_sheet.html" %}</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
{% if winner %}
|
||||
<div class="win">
|
||||
<p class="eyebrow">Konec hry</p>
|
||||
<h2>{{ winner }}</h2>
|
||||
<p>Vyhrává s {{ totals[winner]|cislo }} body z cílových {{ game.final_score|cislo }}.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="sheet-wrap">
|
||||
<table class="sheet">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="label">Kolo</th>
|
||||
{% for name in game.names %}
|
||||
<th class="{{ 'marked' if name == current }}">{{ name }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{# Součty patří nahoru: po padesáti kolech by je jinak nikdo neviděl. #}
|
||||
<tbody class="totals">
|
||||
<tr>
|
||||
<td class="label">Celkem</td>
|
||||
{% for name in game.names %}
|
||||
<td class="{{ 'marked' if name == current }}">
|
||||
{{ totals[name]|cislo }}
|
||||
{%- if streaks[name] %} <span class="ticks">{{ '✕' * streaks[name] }}</span>{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<td class="label">{{ row.round }}</td>
|
||||
{% for cell in row.cells %}
|
||||
<td class="{{ 'marked' if game.names[loop.index0] == current }}
|
||||
{{ 'struck' if cell.struck }} {{ 'wiped' if cell.reset }}">
|
||||
{%- if cell.value is none -%}
|
||||
<span class="empty">·</span>
|
||||
{%- elif cell.reset -%}
|
||||
0<small>vynulováno</small>
|
||||
{%- else -%}
|
||||
{{ cell.value|cislo }}
|
||||
{%- endif -%}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td class="label">1</td>
|
||||
{% for name in game.names %}
|
||||
<td class="{{ 'marked' if name == current }}"><span class="empty">·</span></td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if hidden %}
|
||||
<tr class="more">
|
||||
<td class="label" colspan="{{ game.names|length + 1 }}">… a dalších {{ hidden|kola }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,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">dice-counter {{ verze }}</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Zápisník — hra {{ game_id }}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="board">
|
||||
<header class="masthead">
|
||||
<div id="head" data-address="{{ 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 %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Zápisník{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="board idle">
|
||||
<p class="eyebrow">Zápisník</p>
|
||||
<h1>Zatím se nehraje</h1>
|
||||
<p class="hint">Naskenuj kód telefonem a založ hru.</p>
|
||||
{% include "_join.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if winner %}Konec hry{% else %}Na tahu {{ game.current_player }}{% endif %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="phone">
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<p class="eyebrow">Hra {{ game_id }}</p>
|
||||
<h1>Kostky</h1>
|
||||
</div>
|
||||
<p class="meta">do {{ game.final_score|cislo }} · kolo {{ game.round_number }}</p>
|
||||
</header>
|
||||
|
||||
{% if chyba %}
|
||||
<p class="error">{{ chyba }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if winner %}
|
||||
<div class="win">
|
||||
<p class="eyebrow">Konec hry</p>
|
||||
<h2>{{ winner }}</h2>
|
||||
<p>Vyhrává s {{ totals[winner]|cislo }} body z cílových {{ game.final_score|cislo }}.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="turn">
|
||||
<p class="eyebrow">Na tahu</p>
|
||||
<h2>{{ game.current_player }}</h2>
|
||||
<p class="progress">
|
||||
<strong>{{ totals[game.current_player]|cislo }}</strong> / {{ game.final_score|cislo }}
|
||||
{%- if streaks[game.current_player] %}
|
||||
<span class="ticks">{{ '✕' * streaks[game.current_player] }}</span>
|
||||
{%- endif %}
|
||||
</p>
|
||||
{% if streaks[game.current_player] == 2 %}
|
||||
<p class="warning">Ještě jedna nula a skóre jde pryč.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('add_turn', game_id=game_id) }}" style="margin-top: 1.2rem">
|
||||
<div class="field">
|
||||
<label for="value">Skóre za tah</label>
|
||||
<input type="number" name="value" id="value" inputmode="numeric" autofocus required>
|
||||
</div>
|
||||
<button class="btn" type="submit">Zapsat</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="eyebrow">Průběžné pořadí</p>
|
||||
<table class="standings">
|
||||
{% for rank, name, total in standings %}
|
||||
<tr>
|
||||
<td class="rank">{{ rank }}.</td>
|
||||
<td class="self">{{ name }}</td>
|
||||
<td>
|
||||
{{ total|cislo }}
|
||||
{%- if streaks[name] %} <span class="ticks">{{ '✕' * streaks[name] }}</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<div class="actions">
|
||||
<form method="post" action="{{ url_for('undo', game_id=game_id) }}">
|
||||
<button class="btn-quiet" type="submit" {{ 'disabled' if not game.turns }}>Vrátit poslední zápis</button>
|
||||
</form>
|
||||
{% if not winner %}
|
||||
<form method="post" action="{{ url_for('abandon', game_id=game_id) }}">
|
||||
<button class="btn-quiet btn-danger" type="submit">Ukončit hru</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="footer">
|
||||
<a href="{{ url_for('board', game_id=game_id) }}">Zápisník</a>
|
||||
<a href="{{ url_for('hall') }}">Síň slávy</a>
|
||||
<a href="{{ url_for('index') }}">Nová hra</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Nová hra{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="phone">
|
||||
<header class="masthead">
|
||||
<div>
|
||||
<p class="eyebrow">Počitadlo</p>
|
||||
<h1>Kostky</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if request.args.get('chyba') %}
|
||||
<p class="error">{{ request.args.get('chyba') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if running %}
|
||||
<div class="notice">
|
||||
<span>Rozehraná hra {{ running }} čeká.</span>
|
||||
<a href="{{ url_for('game_view', game_id=running) }}">Pokračovat</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('new_game') }}">
|
||||
<p class="eyebrow">Kdo hraje</p>
|
||||
<p class="hint">Ťukej jména v pořadí, jak sedíte kolem stolu.</p>
|
||||
|
||||
<div class="chips" id="chips">
|
||||
{% for player in players %}
|
||||
<button type="button" class="chip" aria-pressed="false" data-name="{{ player.name }}">
|
||||
{{ player.name }}<span class="seat"></span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="add-player">
|
||||
<input type="text" id="new-name" placeholder="Nový hráč" autocomplete="off">
|
||||
<button type="button" id="add">Přidat</button>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top: 1.5rem">
|
||||
<label for="final">Cílové skóre</label>
|
||||
<input type="number" name="final_score" id="final" value="{{ final_score }}" inputmode="numeric">
|
||||
</div>
|
||||
|
||||
<div id="chosen"></div>
|
||||
<button class="btn" type="submit">Začít hru</button>
|
||||
</form>
|
||||
|
||||
<p class="footer">
|
||||
<a href="{{ url_for('board_redirect') }}">Zápisník</a>
|
||||
<a href="{{ url_for('hall') }}">Síň slávy</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const chips = document.getElementById("chips");
|
||||
const chosen = [];
|
||||
|
||||
function render() {
|
||||
for (const chip of chips.querySelectorAll(".chip")) {
|
||||
const seat = chosen.indexOf(chip.dataset.name);
|
||||
chip.setAttribute("aria-pressed", seat > -1);
|
||||
chip.querySelector(".seat").textContent = seat > -1 ? seat + 1 : "";
|
||||
}
|
||||
|
||||
// Pořadí odeslaných polí je pořadí u stolu.
|
||||
document.getElementById("chosen").replaceChildren(...chosen.map(name => {
|
||||
const field = document.createElement("input");
|
||||
field.type = "hidden";
|
||||
field.name = "player";
|
||||
field.value = name;
|
||||
return field;
|
||||
}));
|
||||
}
|
||||
|
||||
chips.addEventListener("click", event => {
|
||||
const chip = event.target.closest(".chip");
|
||||
if (!chip) return;
|
||||
|
||||
const seat = chosen.indexOf(chip.dataset.name);
|
||||
if (seat > -1) chosen.splice(seat, 1);
|
||||
else chosen.push(chip.dataset.name);
|
||||
render();
|
||||
});
|
||||
|
||||
const field = document.getElementById("new-name");
|
||||
|
||||
function addPlayer() {
|
||||
const name = field.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
if (!chips.querySelector(`[data-name="${CSS.escape(name)}"]`)) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "chip";
|
||||
chip.dataset.name = name;
|
||||
chip.append(name, Object.assign(document.createElement("span"), { className: "seat" }));
|
||||
chips.append(chip);
|
||||
}
|
||||
|
||||
if (!chosen.includes(name)) chosen.push(name);
|
||||
field.value = "";
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById("add").addEventListener("click", addPlayer);
|
||||
field.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter") { event.preventDefault(); addPlayer(); }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,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 %}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,174 @@
|
||||
import pytest
|
||||
|
||||
from core import Game, GameOver
|
||||
|
||||
|
||||
def play(game: Game, *values: int) -> Game:
|
||||
"""Odehraje tahy po sobě, jak by je zadával zapisovatel."""
|
||||
for value in values:
|
||||
game.add_score(value)
|
||||
return game
|
||||
|
||||
|
||||
def test_tri_nuly_vynuluji_skore():
|
||||
game = Game(["A", "B"], final_score=1000)
|
||||
play(game, 200, 10, 0, 10, 0, 10)
|
||||
|
||||
assert game.totals()["A"] == 200
|
||||
|
||||
result = game.add_score(0)
|
||||
|
||||
assert result.was_reset
|
||||
assert game.totals()["A"] == 0
|
||||
|
||||
|
||||
def test_nenulovy_tah_prerusi_serii():
|
||||
game = Game(["A", "B"], final_score=1000)
|
||||
play(game, 0, 10, 0, 10, 50, 10)
|
||||
|
||||
result = game.add_score(0)
|
||||
|
||||
assert not result.was_reset
|
||||
assert game.totals()["A"] == 50
|
||||
|
||||
|
||||
def test_po_vynulovani_zacina_serie_znovu():
|
||||
game = Game(["A"], final_score=1000)
|
||||
play(game, 100, 0, 0, 0)
|
||||
|
||||
assert game.totals()["A"] == 0
|
||||
|
||||
# Další dvě nuly ještě vynulovat nesmí — série se počítá od resetu.
|
||||
assert not game.add_score(0).was_reset
|
||||
assert not game.add_score(0).was_reset
|
||||
assert game.add_score(0).was_reset
|
||||
|
||||
|
||||
def test_vynulovani_se_neuklada_jako_zaporny_tah():
|
||||
game = Game(["A"], final_score=1000)
|
||||
play(game, 300, 0, 0, 0)
|
||||
|
||||
assert [t.value for t in game.turns] == [300, 0, 0, 0]
|
||||
assert [t.reset for t in game.turns] == [False, False, False, True]
|
||||
|
||||
|
||||
def test_remiza_na_prvnim_miste_hru_nekonci():
|
||||
game = Game(["A", "B"], final_score=1000)
|
||||
play(game, 1000, 1000)
|
||||
|
||||
assert game.round_complete
|
||||
assert game.check_win() == ""
|
||||
assert not game.finished
|
||||
|
||||
|
||||
def test_vitez_az_po_dokoncenem_kole():
|
||||
game = Game(["A", "B"], final_score=1000)
|
||||
game.add_score(1000)
|
||||
|
||||
# A je přes limit, ale B ještě nehrál.
|
||||
assert game.totals()["A"] == 1000
|
||||
assert game.winner == ""
|
||||
|
||||
result = game.add_score(10)
|
||||
|
||||
assert result.winner == "A"
|
||||
assert game.finished
|
||||
|
||||
|
||||
def test_do_dohrane_hry_uz_nejde_zapsat():
|
||||
game = Game(["A", "B"], final_score=1000)
|
||||
play(game, 1000, 10)
|
||||
|
||||
with pytest.raises(GameOver):
|
||||
game.add_score(50)
|
||||
|
||||
|
||||
def test_undo_vrati_skore_i_hrace_na_tahu():
|
||||
game = Game(["A", "B"], final_score=1000)
|
||||
play(game, 100, 50)
|
||||
|
||||
assert game.current_player == "A"
|
||||
|
||||
game.add_score(70)
|
||||
|
||||
assert game.current_player == "B"
|
||||
assert game.totals()["A"] == 170
|
||||
|
||||
game.undo()
|
||||
|
||||
assert game.current_player == "A"
|
||||
assert game.totals()["A"] == 100
|
||||
|
||||
|
||||
def test_undo_pres_vynulovani_obnovi_puvodni_skore():
|
||||
game = Game(["A"], final_score=1000)
|
||||
play(game, 400, 0, 0, 0)
|
||||
|
||||
assert game.totals()["A"] == 0
|
||||
|
||||
game.undo()
|
||||
|
||||
assert game.totals()["A"] == 400
|
||||
assert game.zero_streak("A") == 2
|
||||
|
||||
|
||||
def test_undo_na_prazdne_hre():
|
||||
game = Game(["A"])
|
||||
|
||||
assert game.undo() is None
|
||||
|
||||
|
||||
def test_zero_streak():
|
||||
game = Game(["A"], final_score=1000)
|
||||
|
||||
assert game.zero_streak("A") == 0
|
||||
|
||||
game.add_score(0)
|
||||
assert game.zero_streak("A") == 1
|
||||
|
||||
game.add_score(0)
|
||||
assert game.zero_streak("A") == 2
|
||||
|
||||
game.add_score(0)
|
||||
assert game.zero_streak("A") == 0
|
||||
|
||||
|
||||
def test_poradi_hracu_se_toci_dokola():
|
||||
game = Game(["A", "B", "C"])
|
||||
|
||||
assert [game.current_player for _ in range(1)] == ["A"]
|
||||
assert game.round_number == 1
|
||||
|
||||
play(game, 10, 10, 10)
|
||||
|
||||
assert game.current_player == "A"
|
||||
assert game.round_number == 2
|
||||
|
||||
|
||||
def test_standings_radi_od_nejlepsiho():
|
||||
game = Game(["A", "B", "C"])
|
||||
play(game, 10, 300, 50)
|
||||
|
||||
assert game.standings() == [(1, "B", 300), (2, "C", 50), (3, "A", 10)]
|
||||
|
||||
|
||||
def test_rounds_dela_zapisnik_po_kolech():
|
||||
game = Game(["A", "B"])
|
||||
play(game, 10, 20, 30)
|
||||
|
||||
rounds = game.rounds()
|
||||
|
||||
assert len(rounds) == 2
|
||||
assert rounds[0][0] == 1
|
||||
assert rounds[0][1]["A"].value == 10
|
||||
assert rounds[0][1]["B"].value == 20
|
||||
assert rounds[1][1]["A"].value == 30
|
||||
assert rounds[1][1]["B"] is None
|
||||
|
||||
|
||||
def test_hra_potrebuje_hrace():
|
||||
with pytest.raises(ValueError):
|
||||
Game([])
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
Game(["A", "A"])
|
||||
@@ -0,0 +1,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"
|
||||
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
|
||||
import stats
|
||||
import storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn(tmp_path):
|
||||
connection = storage.connect(tmp_path / "test.db")
|
||||
yield connection
|
||||
connection.close()
|
||||
|
||||
|
||||
def play(conn, names, final_score, values, finish=True):
|
||||
game_id = storage.create_game(conn, names, final_score)
|
||||
game = storage.load_game(conn, game_id)
|
||||
for value in values:
|
||||
storage.add_turn(conn, game_id, game.add_score(value).turn)
|
||||
if finish and game.winner:
|
||||
storage.finish_game(conn, game_id, game.winner)
|
||||
return game_id
|
||||
|
||||
|
||||
def by_name(table):
|
||||
return {row["name"]: row for row in table}
|
||||
|
||||
|
||||
def test_prazdna_sin_slavy(conn):
|
||||
found = stats.records(conn)
|
||||
|
||||
assert found["best_turn"] is None
|
||||
assert found["best_score"] is None
|
||||
assert found["fastest"] is None
|
||||
assert stats.careers(conn) == []
|
||||
|
||||
|
||||
def test_rekordy(conn):
|
||||
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
|
||||
|
||||
found = stats.records(conn)
|
||||
|
||||
assert found["best_turn"]["name"] == "Adam"
|
||||
assert found["best_turn"]["value"] == 600
|
||||
assert found["best_score"]["name"] == "Adam"
|
||||
assert found["best_score"]["total"] == 1100
|
||||
assert found["fastest"]["name"] == "Adam"
|
||||
assert found["fastest"]["rounds"] == 2
|
||||
|
||||
|
||||
def test_kariera_pocita_vyhry_a_uspesnost(conn):
|
||||
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
|
||||
play(conn, ["Adam", "Eva"], 1000, [100, 600, 200, 500])
|
||||
|
||||
table = by_name(stats.careers(conn))
|
||||
|
||||
assert table["Adam"]["games"] == 2
|
||||
assert table["Adam"]["wins"] == 1
|
||||
assert table["Adam"]["share"] == 0.5
|
||||
assert table["Eva"]["wins"] == 1
|
||||
|
||||
|
||||
def test_nedohrane_hry_se_nepocitaji(conn):
|
||||
play(conn, ["Adam", "Eva"], 1000, [600, 200, 500, 100])
|
||||
play(conn, ["Adam", "Eva"], 5000, [100, 100], finish=False)
|
||||
|
||||
table = by_name(stats.careers(conn))
|
||||
|
||||
assert table["Adam"]["games"] == 1
|
||||
|
||||
|
||||
def test_vynulovani_se_pocita(conn):
|
||||
# Eva vyhraje, Adam se cestou třikrát vynuluje.
|
||||
play(conn, ["Adam", "Eva"], 1000, [300, 400, 0, 300, 0, 200, 0, 500])
|
||||
|
||||
table = by_name(stats.careers(conn))
|
||||
|
||||
assert table["Adam"]["wipeouts"] == 1
|
||||
assert table["Eva"]["wipeouts"] == 0
|
||||
|
||||
|
||||
def test_prumerne_poradi(conn):
|
||||
play(conn, ["Adam", "Eva", "Petr"], 1000, [600, 300, 100, 500, 200, 50])
|
||||
|
||||
table = by_name(stats.careers(conn))
|
||||
|
||||
assert table["Adam"]["placing"] == 1
|
||||
assert table["Eva"]["placing"] == 2
|
||||
assert table["Petr"]["placing"] == 3
|
||||
|
||||
|
||||
def test_soucty_v_sql_respektuji_vynulovani(conn):
|
||||
"""SQL musí počítat celkové skóre stejně jako core.Game.totals()."""
|
||||
game_id = play(conn, ["Adam", "Eva"], 1000, [300, 100, 0, 100, 0, 100, 0, 900])
|
||||
|
||||
game = storage.load_game(conn, game_id)
|
||||
best = stats.records(conn)["best_score"]
|
||||
|
||||
assert game.totals()["Adam"] == 0
|
||||
assert best["name"] == "Eva"
|
||||
assert best["total"] == game.totals()["Eva"]
|
||||
@@ -0,0 +1,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 / "dice-counter" / "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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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, `dice-counter.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
|
||||
@@ -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"dice-counter-{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"dice-counter {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"dice-counter {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)
|
||||
Reference in new issue
Block a user