binárka / linux (pull_request) Successful in 11s
image / image (pull_request) Successful in 18s
testy / pytest (3.11) (pull_request) Successful in 1s
testy / pytest (3.12) (pull_request) Successful in 0s
testy / pytest (3.13) (pull_request) Successful in 0s
testy / pytest (3.14) (pull_request) Successful in 1s
Druhý krok po #16, rovnou celý. Dvojkolejnost dávala smysl pro projekt s neznámými uživateli; tady běží jedna instance a ta je přemigrovaná. Pryč je: - deploy/pve-kostky.sh (přesměrování na nový skript) - stěhování ze starého jména v instalátoru včetně bloku STARY_* - záložní jméno přílohy kostky-* v instalátoru - věšení příloh pod dvěma jmény v obou workflow - záloha na ~/.local/share/kostky/dice.db ve storage.py - odstavce o přechodu v README Jediné, co ve zdrojácích zůstalo, je docstring "Pravidla hry v kostky" v core.py — to je česky pojmenovaná hra, ne identifikátor. POZOR na pořadí: instalátor teď hledá jen dice-counter-*, a poslední vydání (v1.2.1) má na releasu pouze kostky-*. Do otagování v1.2.2 tedy `update` ani čerstvá instalace nemají co stáhnout. Merge a tag patří k sobě. Ověřeno v debianím kontejneru: čistá instalace, druhé spuštění za sebou, a vydání bez nových jmen spadne se srozumitelnou hláškou místo toho, aby sáhlo po starém. Binárka postavená lokálně zakládá databázi v …/dice-counter/ a starou vedle sebe ignoruje. 118 testů. Closes #19
359 lines
12 KiB
Python
359 lines
12 KiB
Python
"""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()
|