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
361 lines
11 KiB
Python
361 lines
11 KiB
Python
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, GameChip, Entry, SheetState, Category, GameMember, User, Role
|
|
from ..security import get_session_user_id
|
|
|
|
router = APIRouter(prefix="/games", tags=["games"])
|
|
|
|
|
|
def require_user(req: Request, db: Session):
|
|
uid = get_session_user_id(req)
|
|
if not uid:
|
|
raise HTTPException(status_code=401, detail="not logged in")
|
|
return uid
|
|
|
|
|
|
def stable_order(seed: int, user_id: str, entry_id: str) -> str:
|
|
s = f"{seed}:{user_id}:{entry_id}".encode()
|
|
return hashlib.sha256(s).hexdigest()
|
|
|
|
|
|
CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
|
|
|
|
|
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:
|
|
return
|
|
db.add(GameMember(game_id=game_id, user_id=user_id))
|
|
db.commit()
|
|
|
|
|
|
def require_game_member(db: Session, game_id: str, user_id: str) -> Game:
|
|
g = db.query(Game).filter(Game.id == game_id).first()
|
|
if not g:
|
|
raise HTTPException(404, "game not found")
|
|
|
|
mem = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
|
|
if not mem:
|
|
raise HTTPException(403, "not a member of this game")
|
|
return g
|
|
|
|
|
|
@router.post("")
|
|
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
|
uid = require_user(req, db)
|
|
|
|
name = data.get("name") or "Neues Spiel"
|
|
seed = random.randint(1, 2_000_000_000)
|
|
|
|
# unique code
|
|
code = gen_code()
|
|
while db.query(Game).filter(Game.code == code).first():
|
|
code = gen_code()
|
|
|
|
g = Game(host_user_id=uid, name=name, seed=seed, code=code, winner_user_id=None)
|
|
db.add(g)
|
|
db.commit()
|
|
|
|
# creator joins automatically
|
|
ensure_member(db, g.id, uid)
|
|
|
|
return {"id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
|
|
|
|
|
|
@router.post("/join")
|
|
def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
|
uid = require_user(req, db)
|
|
code = (data.get("code") or "").strip().upper()
|
|
if not code:
|
|
raise HTTPException(400, "code required")
|
|
|
|
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}
|
|
|
|
|
|
@router.get("")
|
|
def list_games(req: Request, db: Session = Depends(get_db)):
|
|
uid = require_user(req, db)
|
|
|
|
# list games where user is member
|
|
q = (
|
|
db.query(Game)
|
|
.join(GameMember, GameMember.game_id == Game.id)
|
|
.filter(GameMember.user_id == uid)
|
|
.order_by(Game.created_at.desc())
|
|
)
|
|
games = q.all()
|
|
|
|
# winner email (optional)
|
|
out = []
|
|
for g in games:
|
|
winner_email = None
|
|
if g.winner_user_id:
|
|
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
|
winner_email = wu.email if wu else None
|
|
out.append(
|
|
{
|
|
"id": g.id,
|
|
"name": g.name,
|
|
"seed": g.seed,
|
|
"code": g.code,
|
|
"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
|
|
|
|
|
|
@router.get("/{game_id}")
|
|
def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
|
|
uid = require_user(req, db)
|
|
g = require_game_member(db, game_id, uid)
|
|
|
|
winner_email = None
|
|
winner_display_name = None
|
|
|
|
if g.winner_user_id:
|
|
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
|
if wu:
|
|
winner_email = wu.email
|
|
winner_display_name = wu.display_name
|
|
|
|
return {
|
|
"id": g.id,
|
|
"name": g.name,
|
|
"code": g.code,
|
|
"host_user_id": g.host_user_id,
|
|
"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)
|
|
_g = require_game_member(db, game_id, uid)
|
|
|
|
# return only "user" role (admin excluded)
|
|
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()
|
|
)
|
|
return [{"id": u.id, "email": u.email, "display_name": u.display_name} for u in members]
|
|
|
|
|
|
@router.patch("/{game_id}/winner")
|
|
def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)):
|
|
uid = require_user(req, db)
|
|
g = require_game_member(db, game_id, uid)
|
|
|
|
# only host can set winner
|
|
if g.host_user_id != uid:
|
|
raise HTTPException(403, "only host can set winner")
|
|
|
|
winner_user_id = data.get("winner_user_id")
|
|
if winner_user_id is None:
|
|
g.winner_user_id = None
|
|
db.add(g)
|
|
db.commit()
|
|
return {"ok": True, "winner_user_id": None}
|
|
|
|
# must be a member AND role=user
|
|
member = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == winner_user_id).first()
|
|
if not member:
|
|
raise HTTPException(400, "winner must be a member of the game")
|
|
|
|
u = db.query(User).filter(User.id == winner_user_id).first()
|
|
if not u or u.role != Role.user.value or u.disabled:
|
|
raise HTTPException(400, "invalid winner")
|
|
|
|
g.winner_user_id = winner_user_id
|
|
db.add(g)
|
|
db.commit()
|
|
return {"ok": True, "winner_user_id": g.winner_user_id}
|
|
|
|
|
|
@router.get("/{game_id}/sheet")
|
|
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
|
uid = require_user(req, db)
|
|
g = require_game_member(db, game_id, uid)
|
|
|
|
entries = db.query(Entry).all()
|
|
states = (
|
|
db.query(SheetState)
|
|
.filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid)
|
|
.all()
|
|
)
|
|
state_map = {st.entry_id: st for st in states}
|
|
|
|
out = {"suspect": [], "item": [], "location": []}
|
|
for e in entries:
|
|
st = state_map.get(e.id)
|
|
item = {
|
|
"entry_id": e.id,
|
|
"label": e.label,
|
|
"status": st.status if st else 0,
|
|
"note_tag": st.note_tag if st else None,
|
|
"chip": st.chip if st else None, # NEW
|
|
"order": stable_order(g.seed, uid, e.id),
|
|
}
|
|
out[e.category].append(item)
|
|
|
|
for k in out:
|
|
out[k].sort(key=lambda x: x["order"])
|
|
for i in out[k]:
|
|
del i["order"]
|
|
|
|
return out
|
|
|
|
|
|
@router.patch("/{game_id}/sheet/{entry_id}")
|
|
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
|
|
uid = require_user(req, db)
|
|
g = require_game_member(db, game_id, uid)
|
|
|
|
status = data.get("status")
|
|
note_tag = data.get("note_tag")
|
|
chip = data.get("chip")
|
|
|
|
if note_tag not in (None, "i", "m", "s"):
|
|
raise HTTPException(400, "invalid note_tag")
|
|
if status is not None and status not in (0, 1, 2, 3):
|
|
raise HTTPException(400, "invalid status")
|
|
|
|
if chip is not None:
|
|
chip = (chip or "").strip().upper()
|
|
if chip == "":
|
|
chip = None
|
|
if chip is not None:
|
|
if len(chip) > 8:
|
|
raise HTTPException(400, "invalid chip")
|
|
|
|
st = (
|
|
db.query(SheetState)
|
|
.filter(
|
|
SheetState.game_id == g.id,
|
|
SheetState.owner_user_id == uid,
|
|
SheetState.entry_id == entry_id,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
if not st:
|
|
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip=None)
|
|
db.add(st)
|
|
|
|
if status is not None:
|
|
st.status = status
|
|
if "note_tag" in data:
|
|
st.note_tag = note_tag
|
|
|
|
# wenn note_tag zurück auf null -> chip auch löschen
|
|
if note_tag is None:
|
|
st.chip = None
|
|
|
|
# chip nur speichern wenn note_tag "s" ist (ansonsten löschen wir es)
|
|
if "chip" in data:
|
|
if st.note_tag == "s":
|
|
st.chip = chip
|
|
else:
|
|
st.chip = None
|
|
|
|
db.commit()
|
|
return {"ok": True}
|
|
|