from datetime import timedelta from django.contrib.auth.decorators import login_required from django.db import IntegrityError, transaction from django.http import HttpRequest, JsonResponse from django.utils import timezone from django.views.decorators.http import require_GET, require_POST from lobby.http import json_body, normalize_session_code from lobby.i18n import api_error from realtime.broadcast import sync_broadcast_phase_event from .models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent from .payloads import ( build_leaderboard as _build_leaderboard, build_reveal_payload as _build_reveal_payload, ) from .services import ( finish_game as _finish_game, prepare_mixed_answers as _prepare_mixed_answers, promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard, resolve_scores as _resolve_scores, show_question as _show_question, start_next_round as _start_next_round, start_round as _start_round, ) def _broadcast_transition(transition) -> None: if transition.should_broadcast: sync_broadcast_phase_event( transition.session.code, transition.phase_event_name, transition.phase_event_payload, ) def maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession: transition = _promote_reveal_to_scoreboard(session) _broadcast_transition(transition) return transition.session @require_POST @login_required def start_round(request: HttpRequest, code: str) -> JsonResponse: payload = json_body(request) category_slug = str(payload.get('category_slug', '')).strip() if not category_slug: return api_error( request, code='category_slug_required', status=400, ) session_code = normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error( request, code='session_not_found', status=404, ) if session.host_id != request.user.id: return api_error( request, code='host_only_start_round', status=403, ) try: transition = _start_round(session, category_slug) except ValueError as exc: error_code = str(exc) error_status = { 'category_not_found': 404, 'round_already_configured': 409, }.get(error_code, 400) return api_error(request, code=error_code, status=error_status) _broadcast_transition(transition) return JsonResponse(transition.response_payload, status=201) @require_POST @login_required def show_question(request: HttpRequest, code: str) -> JsonResponse: session_code = normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error( request, code='session_not_found', status=404, ) if session.host_id != request.user.id: return api_error( request, code='host_only_show_question', status=403, ) try: transition = _show_question(session) except ValueError as exc: return api_error(request, code=str(exc), status=400) _broadcast_transition(transition) return JsonResponse(transition.response_payload, status=201) @require_POST def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: payload = json_body(request) session_code = normalize_session_code(code) player_id = payload.get('player_id') session_token = str(payload.get('session_token', '')).strip() lie_text = str(payload.get('text', '')).strip() if not player_id: return api_error(request, code='player_id_required', status=400) if not session_token: return api_error(request, code='session_token_required', status=400) if not lie_text or len(lie_text) > 255: return api_error(request, code='lie_text_invalid', status=400) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error(request, code='session_not_found', status=404) if session.status != GameSession.Status.LIE: return api_error(request, code='lie_submission_invalid_phase', status=400) try: player = Player.objects.get(pk=player_id, session=session) except Player.DoesNotExist: return api_error(request, code='player_not_found_in_session', status=404) if player.session_token != session_token: return api_error(request, code='invalid_player_session_token', status=403) try: round_question = RoundQuestion.objects.get( pk=round_question_id, session=session, round_number=session.current_round, ) except RoundQuestion.DoesNotExist: return api_error(request, code='round_question_not_found', status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: return api_error(request, code='round_config_missing', status=400) lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) if timezone.now() > lie_deadline_at: return api_error(request, code='lie_submission_closed', status=400) try: lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text) except IntegrityError: return api_error(request, code='lie_already_submitted', status=409) players_count = Player.objects.filter(session=session).count() lie_count = LieAnswer.objects.filter(round_question=round_question).count() session_status = session.status mixed_answers_payload = None if players_count > 0 and lie_count >= players_count: try: mixed_answers = _prepare_mixed_answers(round_question) except ValueError as exc: return api_error(request, code=str(exc), status=400) session.status = GameSession.Status.GUESS session.save(update_fields=['status']) session_status = session.status mixed_answers_payload = [{'text': text} for text in mixed_answers] sync_broadcast_phase_event( session.code, 'phase.guess_started', { 'round_question_id': round_question.id, 'answers': mixed_answers_payload, 'guess_seconds': round_config.guess_seconds, }, ) return JsonResponse( { 'lie': { 'id': lie.id, 'player_id': player.id, 'round_question_id': round_question.id, 'text': lie.text, 'created_at': lie.created_at.isoformat(), }, 'window': { 'lie_deadline_at': lie_deadline_at.isoformat(), }, 'session': { 'code': session.code, 'status': session_status, 'current_round': session.current_round, }, 'phase_transition': { 'current_phase': session_status, 'lies_submitted': lie_count, 'players_expected': players_count, 'auto_advanced': session_status == GameSession.Status.GUESS, }, 'answers': mixed_answers_payload, }, status=201, ) @require_POST @login_required def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: session_code = normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error( request, code='session_not_found', status=404, ) if session.host_id != request.user.id: return api_error( request, code='host_only_mix_answers', status=403, ) if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: return api_error( request, code='mix_answers_invalid_phase', status=400, ) try: round_question = RoundQuestion.objects.get( pk=round_question_id, session=session, round_number=session.current_round, ) except RoundQuestion.DoesNotExist: return api_error( request, code='round_question_not_found', status=404, ) with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: return api_error( request, code='mix_answers_invalid_phase', status=400, ) locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk) try: deduped_answers = _prepare_mixed_answers(locked_round_question) except ValueError as exc: return api_error(request, code=str(exc), status=400) if locked_session.status == GameSession.Status.LIE: locked_session.status = GameSession.Status.GUESS locked_session.save(update_fields=['status']) try: guess_config = RoundConfig.objects.get(session=session, number=session.current_round) guess_seconds = guess_config.guess_seconds except RoundConfig.DoesNotExist: guess_seconds = None sync_broadcast_phase_event( session.code, 'phase.guess_started', { 'round_question_id': round_question.id, 'answers': [{'text': text} for text in deduped_answers], 'guess_seconds': guess_seconds, }, ) return JsonResponse( { 'session': { 'code': session.code, 'status': GameSession.Status.GUESS, 'current_round': session.current_round, }, 'round_question': { 'id': round_question.id, 'round_number': round_question.round_number, }, 'answers': [{'text': text} for text in deduped_answers], } ) @require_POST def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: payload = json_body(request) session_code = normalize_session_code(code) player_id = payload.get('player_id') session_token = str(payload.get('session_token', '')).strip() selected_text = str(payload.get('selected_text', '')).strip() if not player_id: return api_error(request, code='player_id_required', status=400) if not session_token: return api_error(request, code='session_token_required', status=400) if not selected_text or len(selected_text) > 255: return api_error(request, code='selected_text_invalid', status=400) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error(request, code='session_not_found', status=404) if session.status != GameSession.Status.GUESS: return api_error(request, code='guess_submission_invalid_phase', status=400) try: player = Player.objects.get(pk=player_id, session=session) except Player.DoesNotExist: return api_error(request, code='player_not_found_in_session', status=404) if player.session_token != session_token: return api_error(request, code='invalid_player_session_token', status=403) try: round_question = RoundQuestion.objects.get( pk=round_question_id, session=session, round_number=session.current_round, ) except RoundQuestion.DoesNotExist: return api_error(request, code='round_question_not_found', status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: return api_error(request, code='round_config_missing', status=400) guess_deadline_at = round_question.shown_at + timedelta( seconds=round_config.lie_seconds + round_config.guess_seconds ) if timezone.now() > guess_deadline_at: return api_error(request, code='guess_submission_closed', status=400) mixed_answers = round_question.mixed_answers or _prepare_mixed_answers(round_question) allowed_answers = { text.strip().casefold() for text in mixed_answers if isinstance(text, str) and text.strip() } selected_normalized = selected_text.casefold() if selected_normalized not in allowed_answers: return api_error(request, code='selected_answer_invalid', status=400) correct_normalized = round_question.correct_answer.strip().casefold() fooled_player_id = None if selected_normalized != correct_normalized: fooled_player_id = ( round_question.lies.filter(text__iexact=selected_text).values_list('player_id', flat=True).first() ) try: guess = Guess.objects.create( round_question=round_question, player=player, selected_text=selected_text, is_correct=selected_normalized == correct_normalized, fooled_player_id=fooled_player_id, ) except IntegrityError: return api_error(request, code='guess_already_submitted', status=409) players_count = Player.objects.filter(session=session).count() guess_count = Guess.objects.filter(round_question=round_question).count() session_status = session.status reveal_payload = None leaderboard = None if players_count > 0 and guess_count >= players_count: score_events = [] should_broadcast_scores = False with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status == GameSession.Status.GUESS: already_calculated = ScoreEvent.objects.filter( session=locked_session, meta__round_question_id=round_question.id, ).exists() if not already_calculated: score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config) should_broadcast_scores = True else: score_events = list( ScoreEvent.objects.filter( session=locked_session, meta__round_question_id=round_question.id, ).select_related('player') ) leaderboard = _build_leaderboard(locked_session) locked_session.status = GameSession.Status.REVEAL locked_session.save(update_fields=['status']) elif locked_session.status == GameSession.Status.REVEAL: score_events = list( ScoreEvent.objects.filter( session=locked_session, meta__round_question_id=round_question.id, ).select_related('player') ) leaderboard = _build_leaderboard(locked_session) session_status = locked_session.status reveal_payload = _build_reveal_payload( round_question, session=locked_session, viewer_role='player', ) if should_broadcast_scores: score_deltas = [ {'player_id': ev.player_id, 'delta': ev.delta, 'reason': ev.reason} for ev in score_events ] sync_broadcast_phase_event( session.code, 'phase.scores_calculated', { 'round_question_id': round_question.id, 'score_deltas': score_deltas, 'leaderboard': list(leaderboard), }, ) return JsonResponse( { 'guess': { 'id': guess.id, 'player_id': player.id, 'round_question_id': round_question.id, 'selected_text': guess.selected_text, 'is_correct': guess.is_correct, 'fooled_player_id': guess.fooled_player_id, 'created_at': guess.created_at.isoformat(), }, 'window': { 'guess_deadline_at': guess_deadline_at.isoformat(), }, 'session': { 'code': session.code, 'status': session_status, 'current_round': session.current_round, }, 'phase_transition': { 'current_phase': session_status, 'guesses_submitted': guess_count, 'players_expected': players_count, 'auto_advanced': session_status == GameSession.Status.REVEAL, }, 'reveal': reveal_payload, 'leaderboard': leaderboard, }, status=201, ) @require_GET @login_required def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: session_code = normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error(request, code='session_not_found', status=404) if session.host_id != request.user.id: return api_error(request, code='host_only_view_scoreboard', status=403) transition = _promote_reveal_to_scoreboard(session) _broadcast_transition(transition) session = transition.session if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}: return api_error(request, code='scoreboard_invalid_phase', status=400) return JsonResponse(transition.response_payload) @require_POST @login_required def start_next_round(request: HttpRequest, code: str) -> JsonResponse: session_code = normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error(request, code='session_not_found', status=404) if session.host_id != request.user.id: return api_error(request, code='host_only_start_next_round', status=403) try: transition = _start_next_round(session) except ValueError as exc: return api_error(request, code=str(exc), status=400) _broadcast_transition(transition) return JsonResponse(transition.response_payload) @require_POST @login_required def finish_game(request: HttpRequest, code: str) -> JsonResponse: session_code = normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error(request, code='session_not_found', status=404) if session.host_id != request.user.id: return api_error(request, code='host_only_finish_game', status=403) try: transition = _finish_game(session) except ValueError as exc: return api_error(request, code=str(exc), status=400) _broadcast_transition(transition) return JsonResponse(transition.response_payload) @require_POST @login_required def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse: session_code = normalize_session_code(code) try: session = GameSession.objects.get(code=session_code) except GameSession.DoesNotExist: return api_error(request, code='session_not_found', status=404) if session.host_id != request.user.id: return api_error(request, code='host_only_calculate_scores', status=403) already_calculated = ScoreEvent.objects.filter( session=session, meta__round_question_id=round_question_id, ).exists() if already_calculated: return api_error(request, code='scores_already_calculated', status=409) if session.status != GameSession.Status.GUESS: return api_error(request, code='calculate_scores_invalid_phase', status=400) try: round_question = RoundQuestion.objects.get( pk=round_question_id, session=session, round_number=session.current_round, ) except RoundQuestion.DoesNotExist: return api_error(request, code='round_question_not_found', status=404) try: round_config = RoundConfig.objects.get(session=session, number=round_question.round_number) except RoundConfig.DoesNotExist: return api_error(request, code='round_config_missing', status=400) try: with transaction.atomic(): locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if locked_session.status != GameSession.Status.GUESS: return api_error(request, code='calculate_scores_invalid_phase', status=400) score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config) locked_session.status = GameSession.Status.REVEAL locked_session.save(update_fields=['status']) except ValueError as exc: return api_error(request, code=str(exc), status=400) score_deltas = [ {'player_id': ev.player_id, 'delta': ev.delta, 'reason': ev.reason} for ev in score_events ] sync_broadcast_phase_event( session.code, 'phase.scores_calculated', { 'round_question_id': round_question.id, 'score_deltas': score_deltas, 'leaderboard': list(leaderboard), }, ) return JsonResponse( { 'session': { 'code': session.code, 'status': GameSession.Status.REVEAL, 'current_round': session.current_round, }, 'round_question': { 'id': round_question.id, 'round_number': round_question.round_number, }, 'reveal': _build_reveal_payload( round_question, session=session, viewer_role='host', ), 'events_created': len(score_events), 'leaderboard': leaderboard, } )