1189 lines
40 KiB
Python
1189 lines
40 KiB
Python
import json
|
|
import random
|
|
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 fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
|
from fupogfakta.payloads import (
|
|
build_leaderboard as _build_leaderboard,
|
|
build_lie_started_payload as _build_lie_started_payload,
|
|
build_reveal_payload as _build_reveal_payload,
|
|
)
|
|
from fupogfakta.services import (
|
|
get_current_round_question as _get_current_round_question,
|
|
prepare_mixed_answers as _prepare_mixed_answers,
|
|
resolve_scores as _resolve_scores,
|
|
select_round_question as _select_round_question,
|
|
)
|
|
from realtime.broadcast import sync_broadcast_phase_event
|
|
|
|
from .i18n import api_error
|
|
|
|
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
SESSION_CODE_LENGTH = 6
|
|
MAX_CODE_GENERATION_ATTEMPTS = 20
|
|
JOINABLE_STATUSES = {
|
|
GameSession.Status.LOBBY,
|
|
GameSession.Status.LIE,
|
|
GameSession.Status.GUESS,
|
|
GameSession.Status.REVEAL,
|
|
GameSession.Status.SCOREBOARD,
|
|
}
|
|
|
|
|
|
|
|
|
|
def _json_body(request: HttpRequest) -> dict:
|
|
if not request.body:
|
|
return {}
|
|
|
|
try:
|
|
return json.loads(request.body)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
|
|
def _generate_session_code() -> str:
|
|
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
|
|
|
|
|
|
def _normalize_session_code(code: str) -> str:
|
|
return code.strip().upper()
|
|
|
|
|
|
def _create_unique_session_code() -> str:
|
|
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
|
|
code = _generate_session_code()
|
|
if not GameSession.objects.filter(code=code).exists():
|
|
return code
|
|
|
|
raise RuntimeError("Could not generate unique session code")
|
|
|
|
|
|
def _build_start_next_round_response(
|
|
session: GameSession,
|
|
round_config: RoundConfig,
|
|
round_question: RoundQuestion,
|
|
) -> JsonResponse:
|
|
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
|
|
return JsonResponse(
|
|
{
|
|
"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_finish_game_response(session: GameSession) -> JsonResponse:
|
|
leaderboard = _build_leaderboard(session)
|
|
winner = leaderboard[0] if leaderboard else None
|
|
return JsonResponse(
|
|
{
|
|
"session": {
|
|
"code": session.code,
|
|
"status": GameSession.Status.FINISHED,
|
|
"current_round": session.current_round,
|
|
},
|
|
"winner": winner,
|
|
"leaderboard": leaderboard,
|
|
}
|
|
)
|
|
|
|
|
|
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
|
if session.status != GameSession.Status.REVEAL:
|
|
return session
|
|
|
|
current_round_question = _get_current_round_question(session)
|
|
if current_round_question is None:
|
|
return session
|
|
|
|
players_count = Player.objects.filter(session=session).count()
|
|
guess_count = Guess.objects.filter(round_question=current_round_question).count()
|
|
has_score_events = ScoreEvent.objects.filter(
|
|
session=session,
|
|
meta__round_question_id=current_round_question.id,
|
|
).exists()
|
|
reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count)
|
|
if not reveal_is_resolved:
|
|
return session
|
|
|
|
with transaction.atomic():
|
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
|
if locked_session.status != GameSession.Status.REVEAL:
|
|
return locked_session
|
|
locked_session.status = GameSession.Status.SCOREBOARD
|
|
locked_session.save(update_fields=["status"])
|
|
|
|
leaderboard = _build_leaderboard(session)
|
|
sync_broadcast_phase_event(
|
|
session.code,
|
|
"phase.scoreboard",
|
|
{"leaderboard": list(leaderboard), "current_round": session.current_round},
|
|
)
|
|
session.refresh_from_db(fields=["status"])
|
|
return session
|
|
|
|
|
|
|
|
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": False,
|
|
"can_mix_answers": False,
|
|
"can_calculate_scores": False,
|
|
"can_reveal_scoreboard": False,
|
|
"can_start_next_round": in_scoreboard,
|
|
"can_finish_game": in_scoreboard,
|
|
},
|
|
"player": {
|
|
"can_join": status in JOINABLE_STATUSES,
|
|
"can_submit_lie": in_lie and has_round_question,
|
|
"can_submit_guess": in_guess and has_round_question,
|
|
"can_view_final_result": in_finished,
|
|
},
|
|
}
|
|
|
|
|
|
@require_POST
|
|
@login_required
|
|
def create_session(request: HttpRequest) -> JsonResponse:
|
|
code = _create_unique_session_code()
|
|
session = GameSession.objects.create(host=request.user, code=code)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"session": {
|
|
"code": session.code,
|
|
"status": session.status,
|
|
"host_id": session.host_id,
|
|
"current_round": session.current_round,
|
|
}
|
|
},
|
|
status=201,
|
|
)
|
|
|
|
|
|
@require_POST
|
|
def join_session(request: HttpRequest) -> JsonResponse:
|
|
payload = _json_body(request)
|
|
|
|
code = _normalize_session_code(str(payload.get("code", "")))
|
|
nickname = str(payload.get("nickname", "")).strip()
|
|
|
|
if not code:
|
|
return api_error(
|
|
request,
|
|
code="session_code_required",
|
|
status=400,
|
|
)
|
|
|
|
if len(nickname) < 2 or len(nickname) > 40:
|
|
return api_error(
|
|
request,
|
|
code="nickname_invalid",
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
session = GameSession.objects.get(code=code)
|
|
except GameSession.DoesNotExist:
|
|
return api_error(
|
|
request,
|
|
code="session_not_found",
|
|
status=404,
|
|
)
|
|
|
|
if session.status not in JOINABLE_STATUSES:
|
|
return api_error(
|
|
request,
|
|
code="session_not_joinable",
|
|
status=400,
|
|
)
|
|
|
|
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
|
|
return api_error(
|
|
request,
|
|
code="nickname_taken",
|
|
status=409,
|
|
)
|
|
|
|
player = Player.objects.create(session=session, nickname=nickname)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"player": {
|
|
"id": player.id,
|
|
"nickname": player.nickname,
|
|
"session_token": player.session_token,
|
|
"score": player.score,
|
|
},
|
|
"session": {
|
|
"code": session.code,
|
|
"status": session.status,
|
|
},
|
|
},
|
|
status=201,
|
|
)
|
|
|
|
|
|
@require_GET
|
|
def session_detail(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,
|
|
)
|
|
|
|
players = list(
|
|
session.players.order_by("nickname").values(
|
|
"id",
|
|
"nickname",
|
|
"score",
|
|
"is_connected",
|
|
)
|
|
)
|
|
|
|
session = _maybe_promote_reveal_to_scoreboard(session)
|
|
current_round_question = _get_current_round_question(session)
|
|
|
|
round_question_payload = None
|
|
if current_round_question:
|
|
round_question_payload = {
|
|
"id": current_round_question.id,
|
|
"round_number": current_round_question.round_number,
|
|
"prompt": current_round_question.question.prompt,
|
|
"shown_at": current_round_question.shown_at.isoformat(),
|
|
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
|
|
}
|
|
|
|
phase_view_model = _build_phase_view_model(
|
|
session,
|
|
players_count=len(players),
|
|
has_round_question=bool(current_round_question),
|
|
)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"session": {
|
|
"code": session.code,
|
|
"status": session.status,
|
|
"host_id": session.host_id,
|
|
"current_round": session.current_round,
|
|
"players_count": len(players),
|
|
},
|
|
"players": players,
|
|
"round_question": round_question_payload,
|
|
"reveal": _build_reveal_payload(current_round_question)
|
|
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
|
|
else None,
|
|
"scoreboard": _build_leaderboard(session)
|
|
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
|
|
else None,
|
|
"phase_view_model": phase_view_model,
|
|
}
|
|
)
|
|
|
|
|
|
@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,
|
|
)
|
|
|
|
if session.status != GameSession.Status.LOBBY:
|
|
return api_error(
|
|
request,
|
|
code="round_start_invalid_phase",
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
category = Category.objects.get(slug=category_slug, is_active=True)
|
|
except Category.DoesNotExist:
|
|
return api_error(
|
|
request,
|
|
code="category_not_found",
|
|
status=404,
|
|
)
|
|
|
|
if not Question.objects.filter(category=category, is_active=True).exists():
|
|
return api_error(
|
|
request,
|
|
code="category_has_no_questions",
|
|
status=400,
|
|
)
|
|
|
|
with transaction.atomic():
|
|
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
|
if session.status != GameSession.Status.LOBBY:
|
|
return api_error(
|
|
request,
|
|
code="round_start_invalid_phase",
|
|
status=400,
|
|
)
|
|
|
|
if RoundConfig.objects.filter(session=session, number=session.current_round).exists():
|
|
return api_error(
|
|
request,
|
|
code="round_already_configured",
|
|
status=409,
|
|
)
|
|
|
|
round_config = RoundConfig(
|
|
session=session,
|
|
number=session.current_round,
|
|
category=category,
|
|
)
|
|
|
|
try:
|
|
round_question = _select_round_question(session, round_config)
|
|
except ValueError as exc:
|
|
return api_error(request, code=str(exc), status=400)
|
|
|
|
round_config.save()
|
|
session.status = GameSession.Status.LIE
|
|
session.save(update_fields=["status"])
|
|
|
|
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
|
|
|
|
sync_broadcast_phase_event(
|
|
session.code,
|
|
"phase.lie_started",
|
|
lie_started_payload,
|
|
)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"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,
|
|
},
|
|
},
|
|
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,
|
|
)
|
|
|
|
if session.status != GameSession.Status.LIE:
|
|
return api_error(
|
|
request,
|
|
code="show_question_invalid_phase",
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
|
|
except RoundConfig.DoesNotExist:
|
|
return api_error(
|
|
request,
|
|
code="round_config_missing",
|
|
status=400,
|
|
)
|
|
|
|
existing_round_question = _get_current_round_question(session)
|
|
if existing_round_question is not None:
|
|
round_question = existing_round_question
|
|
else:
|
|
try:
|
|
round_question = _select_round_question(session, round_config)
|
|
except ValueError as exc:
|
|
return api_error(request, code=str(exc), status=400)
|
|
|
|
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
|
|
|
sync_broadcast_phase_event(
|
|
session.code,
|
|
"phase.question_shown",
|
|
{
|
|
"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,
|
|
},
|
|
)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"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.isoformat(),
|
|
},
|
|
"config": {
|
|
"lie_seconds": round_config.lie_seconds,
|
|
},
|
|
},
|
|
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": t} for t 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)
|
|
|
|
allowed_answers = {
|
|
round_question.correct_answer.strip().casefold(),
|
|
*(
|
|
text.strip().casefold()
|
|
for text in round_question.lies.values_list("text", flat=True)
|
|
if 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)
|
|
|
|
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)
|
|
|
|
session = _maybe_promote_reveal_to_scoreboard(session)
|
|
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
|
|
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
|
|
|
leaderboard = _build_leaderboard(session)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"session": {
|
|
"code": session.code,
|
|
"status": session.status,
|
|
"current_round": session.current_round,
|
|
},
|
|
"leaderboard": leaderboard,
|
|
}
|
|
)
|
|
|
|
|
|
@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)
|
|
|
|
should_broadcast = False
|
|
with transaction.atomic():
|
|
locked_session = GameSession.objects.select_for_update().select_related("host").get(pk=session.pk)
|
|
next_round_config = None
|
|
round_question = None
|
|
|
|
if locked_session.status == GameSession.Status.SCOREBOARD:
|
|
previous_round_config = RoundConfig.objects.filter(
|
|
session=locked_session,
|
|
number=locked_session.current_round,
|
|
).select_related("category").first()
|
|
if previous_round_config is None:
|
|
return api_error(request, code="round_config_missing", status=400)
|
|
|
|
next_round_number = locked_session.current_round + 1
|
|
next_round_config = RoundConfig(
|
|
session=locked_session,
|
|
number=next_round_number,
|
|
category=previous_round_config.category,
|
|
lie_seconds=previous_round_config.lie_seconds,
|
|
guess_seconds=previous_round_config.guess_seconds,
|
|
points_correct=previous_round_config.points_correct,
|
|
points_bluff=previous_round_config.points_bluff,
|
|
)
|
|
locked_session.current_round = next_round_number
|
|
|
|
try:
|
|
round_question = _select_round_question(locked_session, next_round_config)
|
|
except ValueError as exc:
|
|
return api_error(request, code=str(exc), status=400)
|
|
|
|
next_round_config.save()
|
|
locked_session.status = GameSession.Status.LIE
|
|
locked_session.save(update_fields=["current_round", "status"])
|
|
should_broadcast = True
|
|
elif locked_session.status == GameSession.Status.LIE:
|
|
if locked_session.current_round <= 1:
|
|
return api_error(request, code="next_round_invalid_phase", status=400)
|
|
|
|
next_round_config = RoundConfig.objects.filter(
|
|
session=locked_session,
|
|
number=locked_session.current_round,
|
|
).select_related("category").first()
|
|
round_question = _get_current_round_question(locked_session)
|
|
if next_round_config is None or round_question is None:
|
|
return api_error(request, code="next_round_invalid_phase", status=400)
|
|
else:
|
|
return api_error(request, code="next_round_invalid_phase", status=400)
|
|
|
|
if should_broadcast:
|
|
lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question)
|
|
sync_broadcast_phase_event(
|
|
locked_session.code,
|
|
"phase.lie_started",
|
|
lie_started_payload,
|
|
)
|
|
|
|
return _build_start_next_round_response(locked_session, next_round_config, round_question)
|
|
|
|
@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)
|
|
|
|
should_broadcast = False
|
|
with transaction.atomic():
|
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
|
if locked_session.status == GameSession.Status.SCOREBOARD:
|
|
locked_session.status = GameSession.Status.FINISHED
|
|
locked_session.save(update_fields=["status"])
|
|
should_broadcast = True
|
|
elif locked_session.status != GameSession.Status.FINISHED:
|
|
return api_error(request, code="finish_game_invalid_phase", status=400)
|
|
|
|
if should_broadcast:
|
|
leaderboard = _build_leaderboard(locked_session)
|
|
winner = leaderboard[0] if leaderboard else None
|
|
sync_broadcast_phase_event(
|
|
locked_session.code,
|
|
"phase.game_over",
|
|
{"winner": winner, "leaderboard": list(leaderboard)},
|
|
)
|
|
|
|
return _build_finish_game_response(locked_session)
|
|
|
|
|
|
@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)
|
|
|
|
guesses = list(round_question.guesses.select_related("player"))
|
|
if not guesses:
|
|
return api_error(request, code="no_guesses_submitted", status=400)
|
|
|
|
bluff_counts = {}
|
|
for guess in guesses:
|
|
if guess.fooled_player_id:
|
|
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
|
|
|
|
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 = []
|
|
|
|
for guess in guesses:
|
|
if guess.is_correct:
|
|
guess.player.score += round_config.points_correct
|
|
guess.player.save(update_fields=["score"])
|
|
score_events.append(
|
|
ScoreEvent(
|
|
session=session,
|
|
player=guess.player,
|
|
delta=round_config.points_correct,
|
|
reason="guess_correct",
|
|
meta={"round_question_id": round_question.id, "guess_id": guess.id},
|
|
)
|
|
)
|
|
|
|
for player_id, fooled_count in bluff_counts.items():
|
|
delta = fooled_count * round_config.points_bluff
|
|
player = Player.objects.get(pk=player_id, session=session)
|
|
player.score += delta
|
|
player.save(update_fields=["score"])
|
|
score_events.append(
|
|
ScoreEvent(
|
|
session=session,
|
|
player=player,
|
|
delta=delta,
|
|
reason="bluff_success",
|
|
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
|
|
)
|
|
)
|
|
|
|
ScoreEvent.objects.bulk_create(score_events)
|
|
|
|
locked_session.status = GameSession.Status.REVEAL
|
|
locked_session.save(update_fields=["status"])
|
|
|
|
leaderboard = list(
|
|
Player.objects.filter(session=session)
|
|
.order_by("-score", "nickname")
|
|
.values("id", "nickname", "score")
|
|
)
|
|
|
|
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),
|
|
"events_created": len(score_events),
|
|
"leaderboard": leaderboard,
|
|
}
|
|
)
|