Author SHA1 Message Date
gitea-actionsandClaude Opus 5 cf3c167e01 k binárce i kontrolní součet
Věší se jako druhá příloha ve formátu sha256sum, počítaná nad jménem, pod
kterým se soubor stahuje — aby se dala ověřit rovnou přes
sha256sum -c, bez přejmenovávání.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:44:40 +02:00
7 changed files with 33 additions and 198 deletions

No files matched your search

+3 -3
View File
@@ -1,4 +1,4 @@
# Dice Counter # Počitadlo v kostkách
Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje Doprovod k fyzickým kostkám: **telefon zapisuje skóre, notebook ukazuje
zápisník** a hry se pamatují mezi večery. zápisník** a hry se pamatují mezi večery.
@@ -37,8 +37,8 @@ Počitadlo je dostupné na:
``` ```
Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule Na notebooku otevři `/board`, naskenuj QR kód telefonem a hraj. Tabule
nabízí i zbylé adresy — když jedna nefunguje, klikni na jinou a QR kód se zobrazuje i zbylé adresy — když jedna nefunguje, zkus další. Zastavuje se
přepne na ni. Zastavuje se `Ctrl+C`. `Ctrl+C`.
| Proměnná | K čemu je | Výchozí | | Proměnná | K čemu je | Výchozí |
|---|---|---| |---|---|---|
+1 -21
View File
@@ -373,7 +373,7 @@ button {
line-height: 1.4; line-height: 1.4;
} }
.join .address .what { .join .address span {
display: block; display: block;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 0.7rem; font-size: 0.7rem;
@@ -382,8 +382,6 @@ button {
color: var(--ink-soft); color: var(--ink-soft);
} }
.join .address .url { display: block; }
.qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); } .qr { width: 5.5rem; height: 5.5rem; fill: var(--ink); }
.idle { .idle {
@@ -482,24 +480,6 @@ button {
.alt li { white-space: nowrap; } .alt li { white-space: nowrap; }
/* Adresa je tlačítko, ale nemá vypadat jako tlačítko — jen dát najevo,
že se na ni dá kliknout. */
.alt button {
font: inherit;
color: inherit;
background: none;
border: 0;
border-bottom: 1px dotted var(--rule);
padding: 0;
cursor: pointer;
}
.alt button:hover,
.alt button:focus-visible {
color: var(--ink);
border-bottom-color: currentColor;
}
.alt em { .alt em {
font-family: var(--font-ui); font-family: var(--font-ui);
font-style: normal; font-style: normal;
+9 -45
View File
@@ -1,58 +1,22 @@
{# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může {# Kudy se na počitadlo dostane telefon. Nabídneme víc cest, vybrat si může člověk. #}
člověk — QR kódy jsou vykreslené všechny, přepíná se jen ten viditelný. #} {% if primary %}
{% if choices %}
<div class="join"> <div class="join">
<div class="qr-stack"> {{ target|qr }}
{% for choice in choices %}
<div class="qr-slot" data-ip="{{ choice.ip }}"{% if not loop.first %} hidden{% endif %}>
{{ choice.url|qr }}
</div>
{% endfor %}
</div>
<div class="address"> <div class="address">
<span class="what">Zapisuje se z telefonu</span> <span>Zapisuje se z telefonu</span>
<span class="url">{{ choices[0].url }}</span> {{ target }}
{% if others %}
{% if choices|length > 1 %}
<ul class="alt"> <ul class="alt">
{% for choice in choices %} {% for address in others %}
<li data-ip="{{ choice.ip }}"{% if loop.first %} hidden{% endif %}> <li>{{ address.ip }}:{{ port }} <em>{{ address.label }}</em></li>
<button type="button" data-ip="{{ choice.ip }}" data-url="{{ choice.url }}"
aria-label="Ukázat QR kód pro {{ choice.url }}">
{{ choice.ip }}:{{ port }} <em>{{ choice.label }}</em>
</button>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script>
// Klik na jinou adresu jen prohodí, který QR kód a který řádek je vidět.
// Vybraná adresa ze seznamu zmizí a ta předchozí se v něm objeví, takže
// se dá kroužit dokola.
{
const join = document.currentScript.previousElementSibling;
const shown = join.querySelector(".address .url");
for (const button of join.querySelectorAll(".alt button")) {
button.addEventListener("click", () => {
for (const slot of join.querySelectorAll(".qr-slot")) {
slot.hidden = slot.dataset.ip !== button.dataset.ip;
}
for (const item of join.querySelectorAll(".alt li")) {
item.hidden = item.dataset.ip === button.dataset.ip;
}
shown.textContent = button.dataset.url;
});
}
}
</script>
{% else %} {% else %}
<div class="join"> <div class="join">
<p class="address"><span class="what">Adresu se nepodařilo zjistit</span> <p class="address"><span>Adresu se nepodařilo zjistit</span>
Spusť server s <code>DICE_HOST=&lt;adresa&gt;</code>.</p> Spusť server s <code>DICE_HOST=&lt;adresa&gt;</code>.</p>
</div> </div>
{% endif %} {% endif %}
+1 -1
View File
@@ -1,3 +1,3 @@
{# Fragment pro tabuli: obě části, které se během hry mění. #} {# Fragment pro tabuli: obě části, které se během hry mění. #}
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div> <div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
<div id="sheet">{% include "_sheet.html" %}</div> <div id="sheet">{% include "_sheet.html" %}</div>
+1 -1
View File
@@ -4,7 +4,7 @@
{% block body %} {% block body %}
<div class="board"> <div class="board">
<header class="masthead"> <header class="masthead">
<div id="head" data-address="{{ address_key }}">{% include "_head.html" %}</div> <div id="head" data-address="{{ primary_ip }}">{% include "_head.html" %}</div>
{% include "_join.html" %} {% include "_join.html" %}
</header> </header>
-90
View File
@@ -1,8 +1,5 @@
import re
import pytest import pytest
import net
import storage import storage
import web import web
@@ -239,90 +236,3 @@ def test_kratka_hra_nic_neuseka(client):
]) ])
def test_sklonovani_kol(count, expected): def test_sklonovani_kol(count, expected):
assert web.kola(count) == expected assert web.kola(count) == expected
def offer(monkeypatch, *ips):
"""Server nabídne přesně tyhle adresy, v tomhle pořadí."""
found = [net.Address(ip=ip, interface="test", kind="lan", primary=not i)
for i, ip in enumerate(ips)]
monkeypatch.setattr(web.net, "addresses", lambda: found)
def test_tabule_nabidne_qr_ke_kazde_adrese(client, monkeypatch):
"""Kódy jsou vykreslené předem, přepíná se jen ten viditelný."""
offer(monkeypatch, "192.168.0.10", "100.91.0.24", "172.17.0.1")
game_id = start_game(client, "Adam", "Eva")
page = text(client.get(f"/board/{game_id}"))
assert page.count('<svg class="qr"') == 3
for ip in ("192.168.0.10", "100.91.0.24", "172.17.0.1"):
assert f'data-url="http://{ip}:8000/game/{game_id}"' in page
def test_vybrana_adresa_ze_seznamu_zmizi(client, monkeypatch):
"""Velkým písmem i v seznamu zároveň by byla dvakrát."""
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
page = text(client.get("/board"))
assert '<li data-ip="192.168.0.10" hidden>' in page
assert '<li data-ip="100.91.0.24">' in page
def test_jedina_adresa_nema_co_prepinat(client, monkeypatch):
offer(monkeypatch, "192.168.0.10")
page = text(client.get("/board"))
assert '<ul class="alt">' not in page
assert page.count('<svg class="qr"') == 1
def test_otisk_hlida_vsechny_adresy(client, monkeypatch):
"""Tabule se načte znovu i když se změní jen ta druhá v pořadí —
nabídnuté QR kódy už by neplatily."""
game_id = start_game(client, "Adam", "Eva")
offer(monkeypatch, "192.168.0.10", "100.91.0.24")
before = text(client.get(f"/board/{game_id}"))
offer(monkeypatch, "192.168.0.10", "100.91.0.99")
after = text(client.get(f"/board/{game_id}"))
assert 'data-address="192.168.0.10,100.91.0.24"' in before
assert 'data-address="192.168.0.10,100.91.0.99"' in after
@pytest.mark.parametrize("row, expected", [
([], []),
([False, False], []),
([True], [(0, 1)]),
([True, True], [(0, 2)]),
([False, True, True], [(1, 2)]),
([True, True, False, True], [(0, 2), (3, 1)]),
])
def test_souvisle_useky(row, expected):
assert web.runs(row) == expected
def test_qr_obdelniky_kryji_stejne_moduly():
"""Slučování čtverečků do širších obdélníků nesmí kód změnit."""
import qrcode
data = "http://192.168.0.10:8000/"
code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data)
code.make(fit=True)
matrix = code.get_matrix()
svg = str(web.qr_svg(data))
painted = set()
for x, y, width in re.findall(r'<rect x="(\d+)" y="(\d+)" width="(\d+)"', svg):
painted.update((int(x) + step, int(y)) for step in range(int(width)))
expected = {(x, y) for y, row in enumerate(matrix)
for x, dark in enumerate(row) if dark}
assert painted == expected
assert svg.count("<rect") < len(expected)
+18 -37
View File
@@ -16,7 +16,7 @@ from pathlib import Path
import qrcode import qrcode
from flask import (Flask, abort, g, redirect, render_template, request, from flask import (Flask, abort, g, redirect, render_template, request,
url_for) url_for)
from markupsafe import Markup, escape from markupsafe import Markup
import net import net
import stats import stats
@@ -61,50 +61,31 @@ def close_db(_exception):
# --- adresa a QR kód -------------------------------------------------------- # --- adresa a QR kód --------------------------------------------------------
def address_key() -> str: def primary_ip() -> str:
"""Otisk všech adres. Tabule ho hlídá: jakmile se změní, načte se znovu, """Adresa, na kterou tabule ukazuje. Prázdno, když se nedá zjistit."""
protože nabídnuté adresy i QR kódy už neplatí.""" found = net.addresses()
return ",".join(address.ip for address in net.addresses()) return found[0].ip if found else ""
def links(path: str = "/") -> dict: def links(path: str = "/") -> dict:
"""Adresy, na kterých je počitadlo dostupné, i s cíli pro QR kódy. """Adresy, na kterých je počitadlo dostupné, i s cílem pro QR kód.
Hledá se při každém načtení — notebook se během večera může přesunout 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ší, z domácí WiFi na hotspot a zpátky.
zbytek si člověk může na tabuli přepnout.
""" """
found = net.addresses()
primary = found[0] if found else None
return { return {
"choices": [ "primary": primary,
{"ip": address.ip, "label": address.label, "others": found[1:],
"url": address.url(PORT) + path}
for address in net.addresses()
],
"port": PORT, "port": PORT,
"target": primary.url(PORT) + path if primary else "",
} }
def runs(row: list[bool]) -> list[tuple[int, int]]:
"""Souvislé tmavé úseky řádku jako (začátek, délka).
Jeden široký obdélník místo řady čtverečků. Na tabuli visí QR kód pro
každou adresu zvlášť, takže se to na velikosti stránky pozná.
"""
found = []
start = None
for x, dark in enumerate([*row, False]):
if dark and start is None:
start = x
elif not dark and start is not None:
found.append((start, x - start))
start = None
return found
def qr_svg(data: str) -> Markup: def qr_svg(data: str) -> Markup:
"""QR jako SVG složené z obdélníků — bez Pillow a bez práce s obrázky.""" """QR jako SVG složené ze čtverečků — bez Pillow a bez práce s obrázky."""
code = qrcode.QRCode(box_size=1, border=2) code = qrcode.QRCode(box_size=1, border=2)
code.add_data(data) code.add_data(data)
code.make(fit=True) code.make(fit=True)
@@ -112,14 +93,14 @@ def qr_svg(data: str) -> Markup:
size = len(matrix) size = len(matrix)
rects = [ rects = [
f'<rect x="{x}" y="{y}" width="{width}" height="1"/>' f'<rect x="{x}" y="{y}" width="1" height="1"/>'
for y, row in enumerate(matrix) for y, row in enumerate(matrix)
for x, width in runs(row) for x, dark in enumerate(row) if dark
] ]
return Markup( return Markup(
f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" ' f'<svg class="qr" viewBox="0 0 {size} {size}" role="img" '
f'aria-label="QR kód s adresou {escape(data)}" ' f'aria-label="QR kód s adresou pro telefon" '
f'shape-rendering="crispEdges">{"".join(rects)}</svg>' f'shape-rendering="crispEdges">{"".join(rects)}</svg>'
) )
@@ -203,7 +184,7 @@ def view(game: Game, game_id: int, row) -> dict:
"standings": game.standings(), "standings": game.standings(),
"winner": winner, "winner": winner,
"current": "" if winner else game.current_player, "current": "" if winner else game.current_player,
"address_key": address_key(), "primary_ip": primary_ip(),
} }