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