from datetime import timedelta from typing import Literal from .models import GameSession, Player, Question, RoundConfig, RoundQuestion SessionViewerRole = Literal["host", "player", "public"] NON_HOST_PROMPT_PHASES = { GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED, } HOST_PHASE_THEMES = { GameSession.Status.LOBBY: "host-atrium", GameSession.Status.LIE: "host-spotlight", GameSession.Status.GUESS: "host-signal", GameSession.Status.REVEAL: "host-verdict", GameSession.Status.SCOREBOARD: "host-podium", GameSession.Status.FINISHED: "host-finale", } HOST_PHASE_ORNAMENTS = { GameSession.Status.LOBBY: "atrium-banner", GameSession.Status.LIE: "spotlight-beam", GameSession.Status.GUESS: "signal-grid", GameSession.Status.REVEAL: "verdict-wave", GameSession.Status.SCOREBOARD: "podium-ribbon", GameSession.Status.FINISHED: "finale-burst", } PLAYER_IDENTITY_TONES = ( "ember", "lagoon", "gold", "sage", "coral", ) PLAYER_IDENTITY_ICONS = ( "spark", "wave", "comet", "leaf", "crown", ) PLAYER_PHASE_THEMES = { "join": "player-boarding", "lobby": "player-ready", "waiting": "player-holding", "lie": "player-ink", "guess": "player-choices", "reveal": "player-ripple", "result": "player-pennant", } PLAYER_PHASE_ORNAMENTS = { "join": "boarding-pass", "lobby": "ready-lantern", "waiting": "holding-ring", "lie": "ink-trace", "guess": "choice-grid", "reveal": "ripple-flare", "result": "pennant-stack", } def build_player_ref(player: Player | None) -> dict | None: if player is None: return None return { "player_id": player.id, "nickname": player.nickname, } def _player_identity_token(nickname: str, join_order: int) -> str: initial = nickname.strip()[:1].upper() or "P" return f"{initial}{join_order}" def build_player_identity_payload(player: Player, *, join_order: int) -> dict: return { "token": _player_identity_token(player.nickname, join_order), "tone": PLAYER_IDENTITY_TONES[(join_order - 1) % len(PLAYER_IDENTITY_TONES)], "icon": PLAYER_IDENTITY_ICONS[(join_order - 1) % len(PLAYER_IDENTITY_ICONS)], } def build_session_players_payload(session: GameSession) -> list[dict]: joined_players = list(session.players.order_by("created_at", "id")) identities_by_id = { player.id: build_player_identity_payload(player, join_order=index) for index, player in enumerate(joined_players, start=1) } return [ { "id": player.id, "nickname": player.nickname, "score": player.score, "is_connected": player.is_connected, "identity": identities_by_id[player.id], } for player in sorted(joined_players, key=lambda entry: (entry.nickname.casefold(), entry.id)) ] def _can_view_round_prompt(session: GameSession, viewer_role: SessionViewerRole) -> bool: return viewer_role == "host" or session.status in NON_HOST_PROMPT_PHASES def build_round_question_payload( round_question: RoundQuestion | None, *, session: GameSession, viewer_role: SessionViewerRole, ) -> dict | None: if round_question is None: return None return { "id": round_question.id, "round_number": round_question.round_number, "prompt": round_question.question.prompt if _can_view_round_prompt(session, viewer_role) else None, "shown_at": round_question.shown_at.isoformat(), "answers": [{"text": text} for text in (round_question.mixed_answers or [])], } def build_reveal_payload( round_question: RoundQuestion | None, *, session: GameSession, viewer_role: SessionViewerRole, ) -> dict | None: if round_question is None: return None lies = [ { **build_player_ref(lie.player), "text": lie.text, "created_at": lie.created_at.isoformat(), } for lie in round_question.lies.select_related("player").order_by("created_at", "id") ] guesses = [] for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"): guess_payload = { **build_player_ref(guess.player), "selected_text": guess.selected_text, "is_correct": guess.is_correct, "created_at": guess.created_at.isoformat(), "fooled_player_id": guess.fooled_player_id, } if guess.fooled_player is not None: guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname guesses.append(guess_payload) return { "round_question_id": round_question.id, "round_number": round_question.round_number, "prompt": round_question.question.prompt if _can_view_round_prompt(session, viewer_role) else None, "correct_answer": round_question.correct_answer, "lies": lies, "guesses": guesses, } def build_voice_cues_payload( voice_cues: dict | None, *, session: GameSession, viewer_role: SessionViewerRole, ) -> dict | None: if voice_cues is None: return None if viewer_role == "host": return voice_cues # Keep non-host payloads role-correct: players can still receive generic intro/phase # metadata later if needed, but prompt-bearing cues stay presenter-only until reveal. return { **voice_cues, "question_prompt": None, "question_reveal": voice_cues.get("question_reveal") if session.status in NON_HOST_PROMPT_PHASES else None, } def build_leaderboard(session: GameSession) -> list[dict]: return list( Player.objects.filter(session=session) .order_by("-score", "nickname") .values("id", "nickname", "score") ) def build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict: lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) return { "round_number": session.current_round, "category": {"slug": round_config.category.slug, "name": round_config.category.name}, "round_question_id": round_question.id, "prompt": round_question.question.prompt, "shown_at": round_question.shown_at.isoformat(), "lie_deadline_at": lie_deadline_at.isoformat(), "lie_seconds": round_config.lie_seconds, } def _wait_cue_keys() -> tuple[str, str]: return "host.presenter_scene_cue_wait_label", "host.presenter_scene_cue_wait_body" def _resolve_authored_scene_ornament( session: GameSession, current_round_question: RoundQuestion | None, ) -> str | None: if session.status not in { GameSession.Status.LIE, GameSession.Status.GUESS, GameSession.Status.REVEAL, }: return None if current_round_question is None: return None authored_ornament = current_round_question.question.scene_ornament if authored_ornament in Question.SceneOrnament.values: return authored_ornament return None def _build_host_phase_display_payload( session: GameSession, phase_view_model: dict, *, current_round_question: RoundQuestion | None = None, ) -> dict: phase = session.status host = phase_view_model["host"] cue_label_key, cue_body_key = _wait_cue_keys() if phase == GameSession.Status.FINISHED: cue_label_key = "host.presenter_scene_cue_finished_label" cue_body_key = "host.presenter_scene_cue_finished_body" title_key = "host.presenter_scene_title_finished" body_key = "host.presenter_scene_body_finished" elif phase == GameSession.Status.LOBBY: if host["can_start_round"]: cue_label_key = "host.presenter_scene_cue_start_label" cue_body_key = "host.presenter_scene_cue_start_body" title_key = "host.presenter_scene_title_lobby" body_key = "host.presenter_scene_body_lobby" elif phase == GameSession.Status.GUESS: if host["can_calculate_scores"]: cue_label_key = "host.presenter_scene_cue_reveal_label" cue_body_key = "host.presenter_scene_cue_reveal_body" title_key = "host.presenter_scene_title_guess" body_key = "host.presenter_scene_body_guess" elif phase == GameSession.Status.REVEAL: if host["can_reveal_scoreboard"]: cue_label_key = "host.presenter_scene_cue_scoreboard_label" cue_body_key = "host.presenter_scene_cue_scoreboard_body" title_key = "host.presenter_scene_title_reveal" body_key = "host.presenter_scene_body_reveal" elif phase == GameSession.Status.SCOREBOARD: if host["can_start_next_round"] or host["can_finish_game"]: cue_label_key = "host.presenter_scene_cue_close_label" cue_body_key = "host.presenter_scene_cue_close_body" title_key = "host.presenter_scene_title_scoreboard" body_key = "host.presenter_scene_body_scoreboard" else: if host["can_mix_answers"]: cue_label_key = "host.presenter_scene_cue_mix_label" cue_body_key = "host.presenter_scene_cue_mix_body" elif host["can_show_question"]: cue_label_key = "host.presenter_scene_cue_show_label" cue_body_key = "host.presenter_scene_cue_show_body" title_key = "host.presenter_scene_title" body_key = "host.presenter_scene_body_lie" return { "theme": HOST_PHASE_THEMES.get(phase, HOST_PHASE_THEMES[GameSession.Status.LIE]), "ornament": _resolve_authored_scene_ornament(session, current_round_question) or HOST_PHASE_ORNAMENTS.get(phase, HOST_PHASE_ORNAMENTS[GameSession.Status.LIE]), "title_key": title_key, "body_key": body_key, "cue_label_key": cue_label_key, "cue_body_key": cue_body_key, } def _build_player_phase_display_payload( session: GameSession, phase_view_model: dict, viewer_role: SessionViewerRole, *, current_round_question: RoundQuestion | None = None, ) -> dict: phase = session.status player = phase_view_model["player"] authored_ornament = _resolve_authored_scene_ornament(session, current_round_question) if viewer_role != "player" and player["can_join"]: return { "theme": PLAYER_PHASE_THEMES["join"], "ornament": PLAYER_PHASE_ORNAMENTS["join"], "title_key": "player.player_scene_title_join", "body_key": "player.player_scene_body_join", "cue_label_key": "player.player_scene_cue_join_label", "cue_body_key": "player.player_scene_cue_join_body", } if player["can_submit_lie"]: return { "theme": PLAYER_PHASE_THEMES["lie"], "ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["lie"], "title_key": "player.submit_lie", "body_key": "player.phase_summary_lie", "cue_label_key": "player.active_scene_cue_lie_label", "cue_body_key": "player.active_scene_cue_lie_body", } if player["can_submit_guess"]: return { "theme": PLAYER_PHASE_THEMES["guess"], "ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["guess"], "title_key": "player.submit_guess", "body_key": "player.phase_summary_guess", "cue_label_key": "player.active_scene_cue_guess_label", "cue_body_key": "player.active_scene_cue_guess_body", } if phase == GameSession.Status.REVEAL: return { "theme": PLAYER_PHASE_THEMES["reveal"], "ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["reveal"], "title_key": "player.reveal_title", "body_key": "player.phase_summary_reveal", "cue_label_key": "player.active_scene_cue_reveal_label", "cue_body_key": "player.active_scene_cue_reveal_body", } if phase in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} or player["can_view_final_result"]: is_finished = phase == GameSession.Status.FINISHED or player["can_view_final_result"] return { "theme": PLAYER_PHASE_THEMES["result"], "ornament": PLAYER_PHASE_ORNAMENTS["result"], "title_key": "player.final_leaderboard" if is_finished else "player.scoreboard_title", "body_key": "player.phase_summary_finished" if is_finished else "player.phase_summary_scoreboard", "cue_label_key": "player.active_scene_cue_result_label", "cue_body_key": "player.active_scene_cue_result_body", } if phase == GameSession.Status.LOBBY: return { "theme": PLAYER_PHASE_THEMES["lobby"], "ornament": PLAYER_PHASE_ORNAMENTS["lobby"], "title_key": "player.player_scene_title_lobby", "body_key": "player.player_scene_body_lobby", "cue_label_key": "player.player_scene_cue_lobby_label", "cue_body_key": "player.player_scene_cue_lobby_body", } waiting_title_key = { GameSession.Status.LIE: "player.player_scene_title_waiting_lie", GameSession.Status.GUESS: "player.player_scene_title_waiting_guess", }.get(phase, "player.player_scene_title_waiting") return { "theme": PLAYER_PHASE_THEMES["waiting"], "ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["waiting"], "title_key": waiting_title_key, "body_key": "player.player_scene_body_waiting", "cue_label_key": "player.player_scene_cue_waiting_label", "cue_body_key": "player.player_scene_cue_waiting_body", } def build_phase_display_payload( session: GameSession, *, viewer_role: SessionViewerRole, phase_view_model: dict, current_round_question: RoundQuestion | None = None, ) -> dict: if viewer_role == "host": return _build_host_phase_display_payload( session, phase_view_model, current_round_question=current_round_question, ) return _build_player_phase_display_payload( session, phase_view_model, viewer_role, current_round_question=current_round_question, ) def build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict: status = session.status in_lobby = status == GameSession.Status.LOBBY in_lie = status == GameSession.Status.LIE in_guess = status == GameSession.Status.GUESS in_scoreboard = status == GameSession.Status.SCOREBOARD in_finished = status == GameSession.Status.FINISHED min_players_reached = players_count >= 3 max_players_allowed = players_count <= 5 return { "status": status, "current_phase": status, "round_number": session.current_round, "players_count": players_count, "constraints": { "min_players_to_start": 3, "max_players_mvp": 5, "min_players_reached": min_players_reached, "max_players_allowed": max_players_allowed, }, "readiness": { "question_ready": has_round_question, "scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}, "can_advance_to_next_round": in_scoreboard, }, "host": { "can_start_round": in_lobby and min_players_reached and max_players_allowed, "can_show_question": in_lie and has_round_question, "can_mix_answers": (in_lie or in_guess) and has_round_question, "can_calculate_scores": in_guess and has_round_question, "can_reveal_scoreboard": status == GameSession.Status.REVEAL, "can_start_next_round": in_scoreboard, "can_finish_game": in_scoreboard, }, "player": { "can_join": status in { GameSession.Status.LOBBY, GameSession.Status.LIE, GameSession.Status.GUESS, GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, }, "can_submit_lie": in_lie and has_round_question, "can_submit_guess": in_guess and has_round_question, "can_view_final_result": in_finished, }, } def build_session_detail_gameplay_payload( session: GameSession, *, current_round_question: RoundQuestion | None, players_count: int, viewer_role: SessionViewerRole, voice_cues: dict | None = None, ) -> dict: phase_view_model = build_phase_view_model( session, players_count=players_count, has_round_question=bool(current_round_question), ) return { "round_question": build_round_question_payload( current_round_question, session=session, viewer_role=viewer_role, ), "reveal": build_reveal_payload( current_round_question, session=session, viewer_role=viewer_role, ) if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question else None, "scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"] if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} else None, "voice_cues": build_voice_cues_payload( voice_cues, session=session, viewer_role=viewer_role, ), "phase_view_model": phase_view_model, "phase_display": build_phase_display_payload( session, viewer_role=viewer_role, phase_view_model=phase_view_model, current_round_question=current_round_question, ), } def build_start_round_response( session: GameSession, round_config: RoundConfig, round_question: RoundQuestion, ) -> dict: lie_started_payload = build_lie_started_payload(session, round_config, round_question) return { "session": { "code": session.code, "status": session.status, "current_round": session.current_round, }, "round": { "number": round_config.number, "category": { "slug": round_config.category.slug, "name": round_config.category.name, }, }, "round_question": { "id": round_question.id, "prompt": round_question.question.prompt, "round_number": round_question.round_number, "shown_at": round_question.shown_at.isoformat(), "lie_deadline_at": lie_started_payload["lie_deadline_at"], }, "config": { "lie_seconds": round_config.lie_seconds, }, } def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict: return { "round_question_id": round_question.id, "prompt": round_question.question.prompt, "shown_at": round_question.shown_at.isoformat(), "lie_deadline_at": lie_deadline_at, "lie_seconds": lie_seconds, } def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict: return { "round_question": { "id": round_question.id, "prompt": round_question.question.prompt, "round_number": round_question.round_number, "shown_at": round_question.shown_at.isoformat(), "lie_deadline_at": lie_deadline_at, }, "config": { "lie_seconds": lie_seconds, }, } def build_start_next_round_response( session: GameSession, round_config: RoundConfig, round_question: RoundQuestion, ) -> dict: return build_start_round_response(session, round_config, round_question) def build_start_next_round_phase_event( session: GameSession, round_config: RoundConfig, round_question: RoundQuestion, ) -> dict: return { "name": "phase.lie_started", "payload": build_lie_started_payload(session, round_config, round_question), } def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict: return { "name": "phase.scoreboard", "payload": { "leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session), "current_round": session.current_round, }, } def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict: return { "session": { "code": session.code, "status": session.status, "current_round": session.current_round, }, "leaderboard": leaderboard, } def build_finish_game_phase_event(session: GameSession) -> dict: leaderboard = build_leaderboard(session) winner = leaderboard[0] if leaderboard else None return { "name": "phase.game_over", "payload": {"winner": winner, "leaderboard": leaderboard}, } def build_finish_game_response(session: GameSession) -> dict: finish_event = build_finish_game_phase_event(session) return { "session": { "code": session.code, "status": GameSession.Status.FINISHED, "current_round": session.current_round, }, "winner": finish_event["payload"]["winner"], "leaderboard": finish_event["payload"]["leaderboard"], }