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
This commit is contained in:
@@ -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:
|
||||
|
||||
+12
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user