From a7ac55c5982b58a7eaa631ed8322f48c7821dff1 Mon Sep 17 00:00:00 2001 From: nessi Date: Sat, 1 Aug 2026 18:29:02 +0200 Subject: [PATCH] Add game start functionality with automatic player chip generation Implemented host-controlled game start mechanism that generates unique player chips from user names. Added `started_at` timestamp to games and new `game_chips` table to store player identifiers. Chips are created using first name initial plus first two surname letters (e.g., SNE for Sascha Nesterovic) with automatic conflict resolution. Updated frontend to display start button for hosts, show game status, and populate chip selection modal --- README.md | 4 + backend/app/main.py | 8 ++ backend/app/models.py | 13 +++- backend/app/routes/games.py | 86 +++++++++++++++++++++- frontend/src/App.jsx | 21 ++++++ frontend/src/components/ChipModal.jsx | 15 ++-- frontend/src/components/GamePickerCard.jsx | 22 ++++++ frontend/src/constants.js | 1 - 8 files changed, 160 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d0b3480..0877aac 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A small multiplayer web app that acts as a digital note sheet for a Harry Potter - Admin-managed user creation and deactivation - Multiple games per user - Join games using a short join code +- Host-controlled game start with automatic player chips +- Player chips are generated from the first name initial and first two surname letters, e.g. `SNE` for Sascha Nesterovic - Automatic player list with host indication - Personal note sheet for each player and game - Categories for suspects, items, and locations @@ -200,6 +202,8 @@ The setup endpoint is available only while no administrator exists. After the fi - `GET /games/{game_id}` - `GET /games/{game_id}/members` - `PATCH /games/{game_id}/winner` +- `POST /games/{game_id}/start` +- `GET /games/{game_id}/chips` - `GET /games/{game_id}/sheet` - `PATCH /games/{game_id}/sheet/{entry_id}` diff --git a/backend/app/main.py b/backend/app/main.py index 15bc057..b9d2d55 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -136,6 +136,14 @@ Very small, pragmatic auto-migration (no alembic). except Exception: db.rollback() + # started_at: games are open for joining until the host starts them + if not _has_column(db, "games", "started_at"): + try: + db.execute(text("ALTER TABLE games ADD COLUMN started_at DATETIME")) + db.commit() + except Exception: + db.rollback() + # host_user_id (nice to have for "only host can set winner") if not _has_column(db, "games", "host_user_id"): try: diff --git a/backend/app/models.py b/backend/app/models.py index 4c2f286..edb7730 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -54,6 +54,17 @@ class Game(Base): code: Mapped[str] = mapped_column(String, unique=True, index=True) winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) + started_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class GameChip(Base): + __tablename__ = "game_chips" + __table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_chip_user"),) + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True) + user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) + chip: Mapped[str] = mapped_column(String) class GameMember(Base): @@ -86,4 +97,4 @@ class SheetState(Base): note_tag: Mapped[str | None] = mapped_column(String, nullable=True) chip: Mapped[str | None] = mapped_column(String, nullable=True) - \ No newline at end of file + diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py index 0cc7e7c..b41ccf0 100644 --- a/backend/app/routes/games.py +++ b/backend/app/routes/games.py @@ -1,8 +1,12 @@ -import hashlib, random +import hashlib +import random +import re +import unicodedata +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.orm import Session from ..db import get_db -from ..models import Game, Entry, SheetState, Category, GameMember, User, Role +from ..models import Game, GameChip, Entry, SheetState, Category, GameMember, User, Role from ..security import get_session_user_id router = APIRouter(prefix="/games", tags=["games"]) @@ -27,6 +31,25 @@ def gen_code(n=6) -> str: return "".join(random.choice(CODE_ALPHABET) for _ in range(n)) +def make_user_chip(user: User) -> str: + """Create first-name initial + first two surname letters, e.g. SNE.""" + display_name = (user.display_name or "").strip() + if not display_name: + display_name = (user.email or "").split("@", 1)[0].replace(".", " ") + + parts = re.split(r"\s+", display_name) + first = parts[0] if parts else "X" + last = parts[-1] if len(parts) > 1 else first + + def letters(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + return "".join(c for c in normalized if c.isalpha()) + + first_letters = letters(first).upper() or "X" + last_letters = letters(last).upper() or "X" + return (first_letters[0] + last_letters[:2]).ljust(3, "X") + + def ensure_member(db: Session, game_id: str, user_id: str): ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first() if ex: @@ -78,6 +101,8 @@ def join_game(req: Request, data: dict, db: Session = Depends(get_db)): g = db.query(Game).filter(Game.code == code).first() if not g: raise HTTPException(404, "game not found") + if g.started_at: + raise HTTPException(400, "game already started") ensure_member(db, g.id, uid) return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id} @@ -112,6 +137,7 @@ def list_games(req: Request, db: Session = Depends(get_db)): "host_user_id": g.host_user_id, "winner_user_id": g.winner_user_id, "winner_email": winner_email, + "started": bool(g.started_at), } ) return out @@ -139,9 +165,63 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)): "winner_user_id": g.winner_user_id, "winner_email": winner_email, "winner_display_name": winner_display_name, + "started": bool(g.started_at), + "started_at": g.started_at, } +@router.post("/{game_id}/start") +def start_game(req: Request, game_id: str, db: Session = Depends(get_db)): + uid = require_user(req, db) + g = require_game_member(db, game_id, uid) + + if g.host_user_id != uid: + raise HTTPException(403, "only host can start the game") + + if not g.started_at: + members = ( + db.query(User) + .join(GameMember, GameMember.user_id == User.id) + .filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False) + .order_by(User.email.asc()) + .all() + ) + + used = set() + for member in members: + base = make_user_chip(member) + chip = base + suffix = 2 + while chip in used: + chip = f"{base[:2]}{suffix}" + suffix += 1 + used.add(chip) + db.add(GameChip(game_id=game_id, user_id=member.id, chip=chip)) + + g.started_at = datetime.now(timezone.utc) + db.add(g) + db.commit() + + return {"ok": True, "started": True} + + +@router.get("/{game_id}/chips") +def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)): + uid = require_user(req, db) + g = require_game_member(db, game_id, uid) + if not g.started_at: + return [] + + chips = ( + db.query(GameChip, User) + .join(User, User.id == GameChip.user_id) + .filter(GameChip.game_id == game_id) + .order_by(User.display_name.asc(), User.email.asc()) + .all() + ) + return [{"user_id": chip.user_id, "chip": chip.chip, "display_name": user.display_name, "email": user.email} for chip, user in chips] + + @router.get("/{game_id}/members") def list_members(req: Request, game_id: str, db: Session = Depends(get_db)): uid = require_user(req, db) @@ -277,4 +357,4 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi db.commit() return {"ok": True} - \ No newline at end of file + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 07a9771..80a3234 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -50,6 +50,7 @@ export default function App() { // Game meta const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id} const [members, setMembers] = useState([]); + const [gameChips, setGameChips] = useState([]); // Winner selection (host only) const [winnerUserId, setWinnerUserId] = useState(""); @@ -138,6 +139,9 @@ export default function App() { setGameMeta(meta); setWinnerUserId(meta?.winner_user_id || ""); + const chips = meta?.started ? await api(`/games/${gameId}/chips`) : []; + setGameChips(chips || []); + const mem = await api(`/games/${gameId}/members`); setMembers(mem); @@ -327,6 +331,7 @@ export default function App() { setSheet(null); setGameMeta(null); setMembers([]); + setGameChips([]); setWinnerUserId(""); // reset winner celebration on logout @@ -431,6 +436,7 @@ export default function App() { setSheet(null); setGameMeta(null); setMembers([]); + setGameChips([]); setWinnerUserId(""); setPulseId(null); @@ -469,6 +475,12 @@ export default function App() { setGameId(res.id); }; + const startGame = async () => { + if (!gameId || !gameMeta?.host_user_id || me?.id !== gameMeta.host_user_id) return; + await api(`/games/${gameId}/start`, { method: "POST" }); + await loadGameMeta(); + }; + // ===== Winner ===== const saveWinner = async () => { if (!gameId) return; @@ -501,6 +513,10 @@ export default function App() { const next = cycleTag(entry.note_tag); if (next === "s") { + if (!gameMeta?.started || gameChips.length === 0) { + showSnack("Das Spiel muss zuerst gestartet werden."); + return; + } setChipEntry(entry); setChipOpen(true); return; @@ -642,6 +658,10 @@ export default function App() { members={members} me={me} hostUserId={gameMeta?.host_user_id || ""} + isHost={isHost} + started={!!gameMeta?.started} + chipCount={gameChips.length} + onStartGame={startGame} /> {/* Sieger Badge: zwischen Spiel und Verdächtigte Person */} @@ -717,6 +737,7 @@ export default function App() { chipOpen={chipOpen} closeChipModalToDash={closeChipModalToDash} chooseChip={chooseChip} + chips={gameChips} /> Chip auswählen:
- {CHIP_LIST.map((c) => ( - ))}
+ {!chips.length && ( +
+ Das Spiel wurde noch nicht gestartet oder es sind keine Spieler-Chips vorhanden. +
+ )} +
Tipp: Wenn du wieder auf den Notiz-Button klickst, geht’s von s zurück auf —.
diff --git a/frontend/src/components/GamePickerCard.jsx b/frontend/src/components/GamePickerCard.jsx index 37fed88..c2b7e20 100644 --- a/frontend/src/components/GamePickerCard.jsx +++ b/frontend/src/components/GamePickerCard.jsx @@ -10,6 +10,10 @@ export default function GamePickerCard({ members = [], me, hostUserId, + isHost = false, + started = false, + chipCount = 0, + onStartGame, }) { const cur = games.find((x) => x.id === gameId); @@ -85,6 +89,24 @@ export default function GamePickerCard({ )} +
+ {!started ? ( + isHost ? ( + + ) : ( +
+ Warte auf den Host, der das Spiel startet. +
+ ) + ) : ( +
+ ✓ Spiel läuft · {chipCount} Spieler-Chips erstellt +
+ )} +
+ {/* Spieler */} {members?.length > 0 && (
diff --git a/frontend/src/constants.js b/frontend/src/constants.js index a44edd6..ce82014 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -1,2 +1 @@ export const API_BASE = "/api"; -export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"]; \ No newline at end of file