Big visual overhaul docker compsoe file etc
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
This commit is contained in:
17
lobby/http.py
Normal file
17
lobby/http.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import json
|
||||
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
def json_body(request: HttpRequest) -> dict:
|
||||
if not request.body:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def normalize_session_code(code: str) -> str:
|
||||
return code.strip().upper()
|
||||
@@ -1,320 +1,3 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from fupogfakta.management.commands.smoke_staging import Command
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.test import Client
|
||||
|
||||
from fupogfakta.models import Category, GameSession, Player, Question, RoundQuestion
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--artifact",
|
||||
help="Optional path to write smoke result artifact as JSON",
|
||||
)
|
||||
|
||||
def _fail(self, step: str, detail: str, payload=None):
|
||||
message = f"{step} failed: {detail}"
|
||||
if payload is not None:
|
||||
message += f" | payload={json.dumps(payload, sort_keys=True)}"
|
||||
raise CommandError(message)
|
||||
|
||||
def _expect_status(self, response, expected_status: int, step: str):
|
||||
if response.status_code != expected_status:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = {"raw": response.content.decode("utf-8", errors="replace")}
|
||||
self._fail(step, f"expected HTTP {expected_status}, got {response.status_code}", payload)
|
||||
return response.json()
|
||||
|
||||
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
|
||||
actual_status = payload.get("session", {}).get("status")
|
||||
if actual_status != expected_status:
|
||||
self._fail(step, f"expected session.status={expected_status}, got {actual_status}", payload)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
GameSession.objects.all().delete()
|
||||
Player.objects.all().delete()
|
||||
RoundQuestion.objects.all().delete()
|
||||
|
||||
category, _ = Category.objects.get_or_create(
|
||||
slug="smoke",
|
||||
defaults={"name": "Smoke", "is_active": True},
|
||||
)
|
||||
category.is_active = True
|
||||
category.save(update_fields=["is_active"])
|
||||
|
||||
question, _ = Question.objects.get_or_create(
|
||||
category=category,
|
||||
prompt="Smoke prompt?",
|
||||
defaults={"correct_answer": "Correct", "is_active": True},
|
||||
)
|
||||
if not question.is_active:
|
||||
question.is_active = True
|
||||
question.save(update_fields=["is_active"])
|
||||
|
||||
User = get_user_model()
|
||||
host, _ = User.objects.get_or_create(username="smoke-host")
|
||||
host.set_password("smoke-pass")
|
||||
host.is_staff = True
|
||||
host.save()
|
||||
|
||||
artifact = {
|
||||
"ok": True,
|
||||
"command": "python manage.py smoke_staging --artifact <path>",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"question": {
|
||||
"prompt": question.prompt,
|
||||
"correct_answer": question.correct_answer,
|
||||
},
|
||||
"steps": [],
|
||||
}
|
||||
|
||||
host_client = Client()
|
||||
host_client.force_login(host)
|
||||
|
||||
create_payload = self._expect_status(
|
||||
host_client.post("/lobby/sessions/create", content_type="application/json"),
|
||||
201,
|
||||
"create_session",
|
||||
)
|
||||
code = create_payload["session"]["code"]
|
||||
artifact["session_code"] = code
|
||||
artifact["steps"].append(
|
||||
{
|
||||
"step": "create_session",
|
||||
"session_status": create_payload["session"]["status"],
|
||||
}
|
||||
)
|
||||
|
||||
players = []
|
||||
for nickname in ["P1", "P2", "P3"]:
|
||||
join_payload = self._expect_status(
|
||||
Client().post(
|
||||
"/lobby/sessions/join",
|
||||
data=json.dumps({"code": code, "nickname": nickname}),
|
||||
content_type="application/json",
|
||||
),
|
||||
201,
|
||||
f"join_session[{nickname}]",
|
||||
)
|
||||
players.append(join_payload["player"])
|
||||
artifact["players"] = [player["nickname"] for player in players]
|
||||
artifact["steps"].append(
|
||||
{
|
||||
"step": "join_players",
|
||||
"players_count": len(players),
|
||||
}
|
||||
)
|
||||
|
||||
start_payload = self._expect_status(
|
||||
host_client.post(
|
||||
f"/lobby/sessions/{code}/rounds/start",
|
||||
data=json.dumps({"category_slug": category.slug}),
|
||||
content_type="application/json",
|
||||
),
|
||||
201,
|
||||
"start_round",
|
||||
)
|
||||
self._expect_session_status(start_payload, GameSession.Status.LIE, "start_round")
|
||||
|
||||
round_question_id = start_payload["round_question"]["id"]
|
||||
artifact["round_question_id"] = round_question_id
|
||||
artifact["steps"].append(
|
||||
{
|
||||
"step": "start_round",
|
||||
"session_status": start_payload["session"]["status"],
|
||||
"round_question_id": round_question_id,
|
||||
}
|
||||
)
|
||||
|
||||
answers = []
|
||||
lie_transition_payload = None
|
||||
for player in players:
|
||||
nickname = player["nickname"]
|
||||
lie_payload = self._expect_status(
|
||||
Client().post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
|
||||
data=json.dumps(
|
||||
{
|
||||
"player_id": player["id"],
|
||||
"session_token": player["session_token"],
|
||||
"text": f"Lie from {nickname}",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
),
|
||||
201,
|
||||
f"submit_lie[{nickname}]",
|
||||
)
|
||||
if lie_payload.get("answers"):
|
||||
answers = lie_payload["answers"]
|
||||
lie_transition_payload = lie_payload
|
||||
|
||||
if not answers:
|
||||
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_lies")
|
||||
answers = detail_payload.get("round_question", {}).get("answers", [])
|
||||
self._expect_session_status(detail_payload, GameSession.Status.GUESS, "session_detail_after_lies")
|
||||
lie_transition_payload = detail_payload
|
||||
|
||||
if not answers:
|
||||
self._fail("auto_guess_transition", "canonical lie->guess transition returned empty answers")
|
||||
|
||||
if not any(answer.get("text") == question.correct_answer for answer in answers):
|
||||
self._fail("auto_guess_transition", "mixed answers missing correct answer", {"answers": answers})
|
||||
if len(answers) < len(players) + 1:
|
||||
self._fail(
|
||||
"auto_guess_transition",
|
||||
"mixed answers shorter than expected bluff set",
|
||||
{"answers": answers, "players_count": len(players)},
|
||||
)
|
||||
|
||||
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, "auto_guess_transition")
|
||||
artifact["steps"].append(
|
||||
{
|
||||
"step": "auto_guess_transition",
|
||||
"session_status": lie_transition_payload["session"]["status"],
|
||||
"answers": [answer["text"] for answer in answers],
|
||||
}
|
||||
)
|
||||
|
||||
answer_texts = {answer["text"] for answer in answers}
|
||||
correct_answer = next((answer["text"] for answer in answers if answer.get("text") == question.correct_answer), None)
|
||||
if correct_answer is None:
|
||||
self._fail("submit_guesses", "could not resolve correct answer from mixed answers", {"answers": answers})
|
||||
|
||||
guess_plan = {
|
||||
players[0]["nickname"]: "Lie from P2",
|
||||
players[1]["nickname"]: correct_answer,
|
||||
players[2]["nickname"]: "Lie from P1",
|
||||
}
|
||||
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
|
||||
if missing_guess_targets:
|
||||
self._fail(
|
||||
"submit_guesses",
|
||||
"expected bluff targets missing from mixed answers",
|
||||
{"answers": answers, "missing_guess_targets": sorted(missing_guess_targets)},
|
||||
)
|
||||
artifact["guess_plan"] = guess_plan
|
||||
|
||||
guess_payloads = []
|
||||
for player in players:
|
||||
nickname = player["nickname"]
|
||||
guess_payload = self._expect_status(
|
||||
Client().post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
|
||||
data=json.dumps(
|
||||
{
|
||||
"player_id": player["id"],
|
||||
"session_token": player["session_token"],
|
||||
"selected_text": guess_plan[nickname],
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
),
|
||||
201,
|
||||
f"submit_guess[{nickname}]",
|
||||
)
|
||||
guess_payloads.append(guess_payload)
|
||||
|
||||
reveal_payload = guess_payloads[-1]
|
||||
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, "auto_reveal_transition")
|
||||
if not reveal_payload.get("phase_transition", {}).get("auto_advanced"):
|
||||
self._fail("auto_reveal_transition", "expected auto_advanced=true on final guess", reveal_payload)
|
||||
reveal = reveal_payload.get("reveal")
|
||||
if not reveal:
|
||||
self._fail("auto_reveal_transition", "missing canonical reveal payload", reveal_payload)
|
||||
if reveal.get("correct_answer") != question.correct_answer:
|
||||
self._fail(
|
||||
"auto_reveal_transition",
|
||||
"reveal payload returned wrong correct answer",
|
||||
{"expected": question.correct_answer, "reveal": reveal},
|
||||
)
|
||||
if len(reveal.get("lies", [])) != len(players):
|
||||
self._fail("auto_reveal_transition", "unexpected lie count in reveal payload", reveal)
|
||||
if len(reveal.get("guesses", [])) != len(players):
|
||||
self._fail("auto_reveal_transition", "unexpected guess count in reveal payload", reveal)
|
||||
|
||||
fooled_guesses = [guess for guess in reveal["guesses"] if not guess.get("is_correct")]
|
||||
correct_guesses = [guess for guess in reveal["guesses"] if guess.get("is_correct")]
|
||||
if len(fooled_guesses) != 2:
|
||||
self._fail("auto_reveal_transition", "expected exactly two bluff guesses", reveal)
|
||||
if len(correct_guesses) != 1:
|
||||
self._fail("auto_reveal_transition", "expected exactly one correct guess", reveal)
|
||||
if any(guess.get("fooled_player_id") is None for guess in fooled_guesses):
|
||||
self._fail("auto_reveal_transition", "bluff guesses missing fooled_player_id", reveal)
|
||||
|
||||
artifact["steps"].append(
|
||||
{
|
||||
"step": "submit_guesses",
|
||||
"guess_results": [
|
||||
{
|
||||
"player_id": payload["guess"]["player_id"],
|
||||
"selected_text": payload["guess"]["selected_text"],
|
||||
"is_correct": payload["guess"]["is_correct"],
|
||||
"fooled_player_id": payload["guess"].get("fooled_player_id"),
|
||||
}
|
||||
for payload in guess_payloads
|
||||
],
|
||||
}
|
||||
)
|
||||
artifact["steps"].append(
|
||||
{
|
||||
"step": "auto_reveal_transition",
|
||||
"session_status": reveal_payload["session"]["status"],
|
||||
"reveal": {
|
||||
"correct_answer": reveal["correct_answer"],
|
||||
"lies_count": len(reveal["lies"]),
|
||||
"guesses_count": len(reveal["guesses"]),
|
||||
"fooled_player_ids": sorted(guess["fooled_player_id"] for guess in fooled_guesses),
|
||||
"correct_guess_player_ids": sorted(guess["player_id"] for guess in correct_guesses),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_guesses")
|
||||
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, "auto_scoreboard_transition")
|
||||
if detail_payload.get("reveal") != reveal:
|
||||
self._fail("auto_scoreboard_transition", "scoreboard promotion changed canonical reveal payload", detail_payload)
|
||||
scoreboard = detail_payload.get("scoreboard")
|
||||
if not scoreboard:
|
||||
self._fail("auto_scoreboard_transition", "missing scoreboard payload after promotion", detail_payload)
|
||||
if len(scoreboard) != len(players):
|
||||
self._fail("auto_scoreboard_transition", "unexpected scoreboard length", detail_payload)
|
||||
if not detail_payload.get("phase_view_model", {}).get("readiness", {}).get("scoreboard_ready"):
|
||||
self._fail("auto_scoreboard_transition", "scoreboard_ready=false after promotion", detail_payload)
|
||||
|
||||
artifact["steps"].append(
|
||||
{
|
||||
"step": "auto_scoreboard_transition",
|
||||
"session_status": detail_payload["session"]["status"],
|
||||
"leaderboard": scoreboard,
|
||||
}
|
||||
)
|
||||
|
||||
finish_payload = self._expect_status(
|
||||
host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json"),
|
||||
200,
|
||||
"finish_game",
|
||||
)
|
||||
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, "finish_game")
|
||||
artifact["steps"].append(
|
||||
{
|
||||
"step": "finish_game",
|
||||
"session_status": finish_payload["session"]["status"],
|
||||
}
|
||||
)
|
||||
|
||||
artifact_path = options.get("artifact")
|
||||
if artifact_path:
|
||||
output_path = Path(artifact_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Smoke flow OK for session {code}"))
|
||||
__all__ = ["Command"]
|
||||
|
||||
514
lobby/tests.py
514
lobby/tests.py
@@ -7,11 +7,12 @@ from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.urls import resolve, reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from fupogfakta import payloads as gameplay_payloads, services as gameplay_services
|
||||
from fupogfakta import payloads as gameplay_payloads, services as gameplay_services, views as gameplay_views
|
||||
from fupogfakta.models import (
|
||||
Category,
|
||||
GameSession,
|
||||
@@ -19,16 +20,146 @@ from fupogfakta.models import (
|
||||
LieAnswer,
|
||||
Player,
|
||||
Question,
|
||||
QuestionLie,
|
||||
RoundConfig,
|
||||
RoundQuestion,
|
||||
ScoreEvent,
|
||||
)
|
||||
from lobby import views as lobby_views
|
||||
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
|
||||
from voice.models import PhaseVoiceLine, QuestionVoiceLine
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def expected_error_message(code: str, locale: str = "en") -> str:
|
||||
return resolve_error_message(key=code, locale=locale)
|
||||
|
||||
|
||||
class LobbyCsrfTokenTests(TestCase):
|
||||
def test_csrf_endpoint_returns_token_and_sets_cookie(self):
|
||||
response = self.client.get(reverse("lobby:csrf_token"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("csrf_token", response.json())
|
||||
self.assertIn("csrftoken", response.cookies)
|
||||
|
||||
|
||||
class LobbyVoiceCueTests(TestCase):
|
||||
def test_session_detail_exposes_multilocale_voice_cues(self):
|
||||
host = User.objects.create_user(username="voice_detail_host", password="secret123")
|
||||
session = GameSession.objects.create(host=host, code="VOICE2", status=GameSession.Status.LIE)
|
||||
category = Category.objects.create(name="Voice detail", slug="voice-detail", is_active=True)
|
||||
question = Question.objects.create(
|
||||
category=category,
|
||||
prompt="Which city is the capital of Denmark?",
|
||||
correct_answer="Copenhagen",
|
||||
is_active=True,
|
||||
)
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=1,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
PhaseVoiceLine.objects.create(game_key="fupogfakta", cue_key="intro", locale="en", text="Custom intro")
|
||||
QuestionVoiceLine.objects.create(
|
||||
question=question,
|
||||
cue_key="question_prompt",
|
||||
locale="da",
|
||||
text="Brugerdefineret sporgsmalslinje",
|
||||
)
|
||||
self.client.login(username="voice_detail_host", password="secret123")
|
||||
|
||||
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["voice_cues"]
|
||||
self.assertEqual(payload["intro"]["translations"]["en"], "Custom intro")
|
||||
self.assertIn("Velkommen til Fup og Fakta", payload["intro"]["translations"]["da"])
|
||||
self.assertEqual(payload["phase"]["cue"], GameSession.Status.LIE)
|
||||
self.assertEqual(payload["question_prompt"]["translations"]["da"], "Brugerdefineret sporgsmalslinje")
|
||||
self.assertIsNone(payload["question_reveal"])
|
||||
self.assertEqual(round_question.question_id, question.id)
|
||||
|
||||
def test_session_detail_exposes_audio_urls_for_custom_voice_lines(self):
|
||||
host = User.objects.create_user(username="voice_audio_host", password="secret123")
|
||||
session = GameSession.objects.create(host=host, code="VOICE3", status=GameSession.Status.LIE)
|
||||
category = Category.objects.create(name="Voice audio", slug="voice-audio", is_active=True)
|
||||
question = Question.objects.create(
|
||||
category=category,
|
||||
prompt="Which city is the capital of Denmark?",
|
||||
correct_answer="Copenhagen",
|
||||
is_active=True,
|
||||
)
|
||||
RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=1,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
self.client.login(username="voice_audio_host", password="secret123")
|
||||
|
||||
with tempfile.TemporaryDirectory() as media_root:
|
||||
with override_settings(MEDIA_ROOT=media_root):
|
||||
PhaseVoiceLine.objects.create(
|
||||
game_key="fupogfakta",
|
||||
cue_key="intro",
|
||||
locale="en",
|
||||
text="Custom intro with audio",
|
||||
audio_file=SimpleUploadedFile("intro-en.mp3", b"fake-mp3-content", content_type="audio/mpeg"),
|
||||
)
|
||||
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()["voice_cues"]
|
||||
self.assertIn("/media/voice/phase/", payload["intro"]["audio_urls"]["en"])
|
||||
self.assertTrue(payload["intro"]["audio_urls"]["en"].endswith("intro-en.mp3"))
|
||||
|
||||
def test_player_session_detail_hides_prompt_bearing_voice_cues(self):
|
||||
host = User.objects.create_user(username="voice_safe_host", password="secret123")
|
||||
session = GameSession.objects.create(host=host, code="VOICE4", status=GameSession.Status.LIE)
|
||||
category = Category.objects.create(name="Voice safe", slug="voice-safe", is_active=True)
|
||||
question = Question.objects.create(
|
||||
category=category,
|
||||
prompt="Which city is the capital of Denmark?",
|
||||
correct_answer="Copenhagen",
|
||||
is_active=True,
|
||||
)
|
||||
RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=1,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
player = Player.objects.create(session=session, nickname="Player one")
|
||||
QuestionVoiceLine.objects.create(
|
||||
question=question,
|
||||
cue_key="question_prompt",
|
||||
locale="en",
|
||||
text="Prompt cue that should stay on the host",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("lobby:session_detail", kwargs={"code": session.code}),
|
||||
data={"session_token": player.session_token},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["viewer_role"], "player")
|
||||
self.assertIsNone(payload["voice_cues"]["question_prompt"])
|
||||
|
||||
|
||||
class AuthRoutesTests(TestCase):
|
||||
def test_login_route_renders_form(self):
|
||||
response = self.client.get("/accounts/login/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'name="username"', html=False)
|
||||
self.assertContains(response, 'name="password"', html=False)
|
||||
|
||||
|
||||
class LobbyGameplayExtractionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="extract_host", password="secret123")
|
||||
@@ -51,21 +182,30 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_lobby_views_use_extracted_gameplay_helpers(self):
|
||||
def test_lobby_session_detail_uses_extracted_gameplay_helpers(self):
|
||||
self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question)
|
||||
self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question)
|
||||
self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers)
|
||||
self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores)
|
||||
self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard)
|
||||
self.assertIs(lobby_views._start_round, gameplay_services.start_round)
|
||||
self.assertIs(lobby_views._show_question, gameplay_services.show_question)
|
||||
self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round)
|
||||
self.assertIs(lobby_views._finish_game, gameplay_services.finish_game)
|
||||
self.assertIs(lobby_views._maybe_promote_reveal_to_scoreboard, gameplay_views.maybe_promote_reveal_to_scoreboard)
|
||||
self.assertIs(lobby_views._build_session_detail_gameplay_payload, gameplay_payloads.build_session_detail_gameplay_payload)
|
||||
self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event)
|
||||
|
||||
def test_public_lobby_gameplay_routes_resolve_to_cartridge_views(self):
|
||||
route_map = {
|
||||
'lobby:start_round': ({'code': self.session.code}, gameplay_views.start_round),
|
||||
'lobby:show_question': ({'code': self.session.code}, gameplay_views.show_question),
|
||||
'lobby:submit_lie': ({'code': self.session.code, 'round_question_id': 1}, gameplay_views.submit_lie),
|
||||
'lobby:mix_answers': ({'code': self.session.code, 'round_question_id': 1}, gameplay_views.mix_answers),
|
||||
'lobby:submit_guess': ({'code': self.session.code, 'round_question_id': 1}, gameplay_views.submit_guess),
|
||||
'lobby:calculate_scores': ({'code': self.session.code, 'round_question_id': 1}, gameplay_views.calculate_scores),
|
||||
'lobby:reveal_scoreboard': ({'code': self.session.code}, gameplay_views.reveal_scoreboard),
|
||||
'lobby:start_next_round': ({'code': self.session.code}, gameplay_views.start_next_round),
|
||||
'lobby:finish_game': ({'code': self.session.code}, gameplay_views.finish_game),
|
||||
}
|
||||
|
||||
for route_name, (kwargs, expected_view) in route_map.items():
|
||||
match = resolve(reverse(route_name, kwargs=kwargs))
|
||||
self.assertIs(inspect.unwrap(match.func), inspect.unwrap(expected_view), msg=f'{route_name} did not resolve to cartridge view')
|
||||
|
||||
def test_start_round_view_source_stays_http_thin(self):
|
||||
source = inspect.getsource(inspect.unwrap(lobby_views.start_round))
|
||||
source = inspect.getsource(inspect.unwrap(gameplay_views.start_round))
|
||||
|
||||
self.assertIn("transition = _start_round(session, category_slug)", source)
|
||||
self.assertNotIn("RoundConfig", source)
|
||||
@@ -73,7 +213,7 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
self.assertNotIn("build_start_round_response", source)
|
||||
|
||||
def test_show_question_view_source_stays_http_thin(self):
|
||||
source = inspect.getsource(inspect.unwrap(lobby_views.show_question))
|
||||
source = inspect.getsource(inspect.unwrap(gameplay_views.show_question))
|
||||
|
||||
self.assertIn("transition = _show_question(session)", source)
|
||||
self.assertNotIn("RoundConfig", source)
|
||||
@@ -81,7 +221,7 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
self.assertNotIn("build_question_shown_response", source)
|
||||
|
||||
def test_start_next_round_view_source_stays_http_thin(self):
|
||||
source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round))
|
||||
source = inspect.getsource(inspect.unwrap(gameplay_views.start_next_round))
|
||||
|
||||
self.assertIn("transition = _start_next_round(session)", source)
|
||||
self.assertNotIn("RoundConfig", source)
|
||||
@@ -90,7 +230,7 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
self.assertNotIn("build_start_next_round_phase_event", source)
|
||||
|
||||
def test_finish_game_view_source_stays_http_thin(self):
|
||||
source = inspect.getsource(inspect.unwrap(lobby_views.finish_game))
|
||||
source = inspect.getsource(inspect.unwrap(gameplay_views.finish_game))
|
||||
|
||||
self.assertIn("transition = _finish_game(session)", source)
|
||||
self.assertNotIn("RoundConfig", source)
|
||||
@@ -99,7 +239,7 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
self.assertNotIn("build_finish_game_phase_event", source)
|
||||
|
||||
def test_reveal_scoreboard_view_source_stays_http_thin(self):
|
||||
source = inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard))
|
||||
source = inspect.getsource(inspect.unwrap(gameplay_views.reveal_scoreboard))
|
||||
|
||||
self.assertIn("transition = _promote_reveal_to_scoreboard(session)", source)
|
||||
self.assertNotIn("Player.objects.filter(session=session)", source)
|
||||
@@ -109,9 +249,9 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
|
||||
def test_issue_310_transition_views_keep_gameplay_logic_out_of_lobby(self):
|
||||
transition_sources = {
|
||||
"reveal_scoreboard": inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)),
|
||||
"start_next_round": inspect.getsource(inspect.unwrap(lobby_views.start_next_round)),
|
||||
"finish_game": inspect.getsource(inspect.unwrap(lobby_views.finish_game)),
|
||||
"reveal_scoreboard": inspect.getsource(inspect.unwrap(gameplay_views.reveal_scoreboard)),
|
||||
"start_next_round": inspect.getsource(inspect.unwrap(gameplay_views.start_next_round)),
|
||||
"finish_game": inspect.getsource(inspect.unwrap(gameplay_views.finish_game)),
|
||||
}
|
||||
|
||||
forbidden_snippets = (
|
||||
@@ -155,8 +295,8 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
self.assertNotIn("leaderboard =", source)
|
||||
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._start_round")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views._start_round")
|
||||
def test_start_round_view_delegates_transition_to_service(
|
||||
self,
|
||||
mock_start_round,
|
||||
@@ -194,8 +334,8 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
{"round_question_id": 123},
|
||||
)
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._show_question")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views._show_question")
|
||||
def test_show_question_view_delegates_transition_to_service(
|
||||
self,
|
||||
mock_show_question,
|
||||
@@ -229,8 +369,8 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
{"round_question_id": 456},
|
||||
)
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._start_next_round")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views._start_next_round")
|
||||
def test_start_next_round_view_delegates_transition_to_service(
|
||||
self,
|
||||
mock_start_next_round,
|
||||
@@ -270,8 +410,8 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
{"round_question_id": round_question.id},
|
||||
)
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._finish_game")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views._finish_game")
|
||||
def test_finish_game_view_delegates_transition_to_service(
|
||||
self,
|
||||
mock_finish_game,
|
||||
@@ -299,8 +439,8 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
{"winner": None, "leaderboard": []},
|
||||
)
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._start_next_round")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views._start_next_round")
|
||||
def test_start_next_round_view_skips_broadcast_on_service_replay(
|
||||
self,
|
||||
mock_start_next_round,
|
||||
@@ -337,8 +477,8 @@ class LobbyGameplayExtractionTests(TestCase):
|
||||
mock_start_next_round.assert_called_once_with(self.session)
|
||||
mock_sync_broadcast_phase_event.assert_not_called()
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._finish_game")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views._finish_game")
|
||||
def test_finish_game_view_skips_broadcast_on_service_replay(
|
||||
self,
|
||||
mock_finish_game,
|
||||
@@ -755,7 +895,7 @@ class LieSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error_code"], "lie_already_submitted")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("lie_already_submitted"))
|
||||
|
||||
def test_submit_lie_requires_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
@@ -777,7 +917,7 @@ class LieSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "session_token_required")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("session_token_required"))
|
||||
|
||||
def test_submit_lie_rejects_invalid_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
@@ -799,7 +939,7 @@ class LieSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("invalid_player_session_token"))
|
||||
|
||||
def test_submit_lie_uses_danish_locale_payload_from_accept_language(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
@@ -822,7 +962,7 @@ class LieSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
||||
self.assertEqual(response.json()["locale"], "da")
|
||||
self.assertEqual(response.json()["error"], "Ugyldigt spiller-session-token")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("invalid_player_session_token", locale="da"))
|
||||
|
||||
class MixAnswersTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -869,6 +1009,24 @@ class MixAnswersTests(TestCase):
|
||||
self.assertEqual(self.session.status, GameSession.Status.GUESS)
|
||||
self.assertEqual(self.round_question.mixed_answers, answer_texts)
|
||||
|
||||
def test_host_can_mix_answers_with_question_fallback_lies_when_players_skip(self):
|
||||
QuestionLie.objects.create(question=self.question, text="Aarhus", sort_order=0)
|
||||
QuestionLie.objects.create(question=self.question, text="Odense", sort_order=1)
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:mix_answers",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
answer_texts = [entry["text"] for entry in payload["answers"]]
|
||||
self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"})
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS)
|
||||
|
||||
def test_mix_answers_requires_host(self):
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
@@ -985,7 +1143,7 @@ class GuessSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "guess_submission_invalid_phase")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("guess_submission_invalid_phase"))
|
||||
|
||||
def test_submit_guess_rejects_unknown_answer(self):
|
||||
response = self.client.post(
|
||||
@@ -1000,7 +1158,26 @@ class GuessSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "selected_answer_invalid")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Selected answer is not part of this round")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("selected_answer_invalid"))
|
||||
|
||||
def test_submit_guess_accepts_question_fallback_lie_from_mixed_answers(self):
|
||||
QuestionLie.objects.create(question=self.question, text="Saturn", sort_order=0)
|
||||
self.round_question.mixed_answers = ["Mars", "Jupiter", "Saturn"]
|
||||
self.round_question.save(update_fields=["mixed_answers"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Saturn"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
payload = response.json()
|
||||
self.assertFalse(payload["guess"]["is_correct"])
|
||||
self.assertIsNone(payload["guess"]["fooled_player_id"])
|
||||
|
||||
def test_submit_guess_rejects_duplicate_submission(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True)
|
||||
@@ -1017,7 +1194,7 @@ class GuessSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error_code"], "guess_already_submitted")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Guess already submitted for this player")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("guess_already_submitted"))
|
||||
|
||||
def test_submit_guess_rejects_after_deadline(self):
|
||||
self.round_question.shown_at = timezone.now() - timedelta(seconds=76)
|
||||
@@ -1052,7 +1229,7 @@ class GuessSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "session_token_required")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("session_token_required"))
|
||||
|
||||
def test_submit_guess_rejects_invalid_session_token(self):
|
||||
response = self.client.post(
|
||||
@@ -1067,7 +1244,7 @@ class GuessSubmissionTests(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("invalid_player_session_token"))
|
||||
|
||||
|
||||
class CanonicalRoundFlowTests(TestCase):
|
||||
@@ -1139,8 +1316,8 @@ class CanonicalRoundFlowTests(TestCase):
|
||||
self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"])
|
||||
self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare")
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._resolve_scores")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views._resolve_scores")
|
||||
def test_session_detail_promotes_zero_score_event_reveal_to_scoreboard(self, mock_resolve_scores, mock_sync_broadcast):
|
||||
self.client.login(username="host_canonical", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
@@ -1274,9 +1451,9 @@ class CanonicalRoundFlowTests(TestCase):
|
||||
self.assertEqual(round_two_question.guesses.count(), 0)
|
||||
self.assertEqual(round_two_question.mixed_answers, [])
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("lobby.views._resolve_scores")
|
||||
@patch("lobby.views.GameSession.objects.get")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views._resolve_scores")
|
||||
@patch("fupogfakta.views.GameSession.objects.get")
|
||||
def test_submit_guess_skips_rescore_when_locked_session_is_already_revealing(
|
||||
self,
|
||||
mock_session_get,
|
||||
@@ -1483,7 +1660,7 @@ class ScoreCalculationTests(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "host_only_calculate_scores")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Only host can calculate scores")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("host_only_calculate_scores"))
|
||||
|
||||
def test_calculate_scores_rejects_duplicate_calculation(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
|
||||
@@ -1506,7 +1683,7 @@ class ScoreCalculationTests(TestCase):
|
||||
self.assertEqual(second.status_code, 409)
|
||||
self.assertEqual(second.json()["error_code"], "scores_already_calculated")
|
||||
self.assertEqual(second.json()["locale"], "en")
|
||||
self.assertEqual(second.json()["error"], "Scores already calculated for this round question")
|
||||
self.assertEqual(second.json()["error"], expected_error_message("scores_already_calculated"))
|
||||
|
||||
|
||||
class RevealRoundFlowTests(TestCase):
|
||||
@@ -1544,7 +1721,7 @@ class RevealRoundFlowTests(TestCase):
|
||||
meta={"round_question_id": self.round_question.id},
|
||||
)
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
|
||||
@@ -1588,7 +1765,7 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("host_only_view_scoreboard"))
|
||||
|
||||
def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self):
|
||||
self.session.status = GameSession.Status.SCOREBOARD
|
||||
@@ -1602,7 +1779,7 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
||||
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
def test_host_can_finish_game_from_scoreboard(self, _mock_sync_broadcast_phase_event):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||
@@ -1623,7 +1800,7 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
def test_finish_game_is_idempotent_after_transition_to_finished(self, mock_sync_broadcast_phase_event):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||
@@ -1656,7 +1833,7 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "host_only_finish_game")
|
||||
self.assertEqual(response.json()["locale"], "da")
|
||||
self.assertEqual(response.json()["error"], "Kun værten kan afslutte spillet")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("host_only_finish_game", locale="da"))
|
||||
|
||||
def test_finish_game_rejects_wrong_phase(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
@@ -1674,9 +1851,9 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error_code"], "finish_game_invalid_phase")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("finish_game_invalid_phase"))
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
def test_host_can_start_next_round_from_scoreboard(self, mock_sync_broadcast_phase_event):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||
@@ -1715,7 +1892,7 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code)
|
||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
def test_start_next_round_bootstraps_new_round_question_instead_of_reusing_current_round(self, mock_sync_broadcast_phase_event):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||
@@ -1756,7 +1933,7 @@ class RevealRoundFlowTests(TestCase):
|
||||
mock_sync_broadcast_phase_event.assert_called_once()
|
||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||
@@ -1966,12 +2143,12 @@ class RevealRoundFlowTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json(), {
|
||||
"error": "Only host can start next round",
|
||||
"error": expected_error_message("host_only_start_next_round"),
|
||||
"error_code": "host_only_start_next_round",
|
||||
"locale": "en",
|
||||
})
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
@patch("fupogfakta.views.sync_broadcast_phase_event")
|
||||
def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self, mock_sync_broadcast_phase_event):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
|
||||
@@ -2047,7 +2224,7 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard")
|
||||
self.assertEqual(response.json()["locale"], "en")
|
||||
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
||||
self.assertEqual(response.json()["error"], expected_error_message("host_only_view_scoreboard"))
|
||||
|
||||
class UiScreenTests(TestCase):
|
||||
def setUp(self):
|
||||
@@ -2337,14 +2514,51 @@ class SessionDetailRoundQuestionTests(TestCase):
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
self.client.login(username="host_detail", password="secret123")
|
||||
|
||||
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["viewer_role"], "host")
|
||||
self.assertEqual(payload["round_question"]["id"], round_question.id)
|
||||
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
|
||||
|
||||
def test_player_session_detail_hides_round_prompt_during_active_play(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
player = Player.objects.create(session=self.session, nickname="Player one")
|
||||
|
||||
response = self.client.get(
|
||||
reverse("lobby:session_detail", kwargs={"code": self.session.code}),
|
||||
data={"session_token": player.session_token},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["viewer_role"], "player")
|
||||
self.assertEqual(payload["round_question"]["id"], round_question.id)
|
||||
self.assertIsNone(payload["round_question"]["prompt"])
|
||||
|
||||
def test_public_session_detail_hides_round_prompt_during_active_play(self):
|
||||
RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["viewer_role"], "public")
|
||||
self.assertIsNone(payload["round_question"]["prompt"])
|
||||
|
||||
def test_session_detail_includes_canonical_reveal_payload_in_reveal_phase(self):
|
||||
self.session.status = GameSession.Status.REVEAL
|
||||
self.session.save(update_fields=["status"])
|
||||
@@ -2495,6 +2709,9 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
self.assertTrue(phase["host"]["can_start_round"])
|
||||
self.assertEqual(phase["current_phase"], GameSession.Status.LOBBY)
|
||||
self.assertFalse(phase["host"]["can_show_question"])
|
||||
self.assertFalse(phase["host"]["can_mix_answers"])
|
||||
self.assertFalse(phase["host"]["can_calculate_scores"])
|
||||
self.assertFalse(phase["host"]["can_reveal_scoreboard"])
|
||||
self.assertFalse(phase["readiness"]["question_ready"])
|
||||
self.assertFalse(phase["readiness"]["scoreboard_ready"])
|
||||
self.assertTrue(phase["player"]["can_join"])
|
||||
@@ -2520,8 +2737,9 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
self.session.save(update_fields=["status"])
|
||||
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
lie_phase = lie_payload["phase_view_model"]
|
||||
self.assertFalse(lie_phase["host"]["can_show_question"])
|
||||
self.assertFalse(lie_phase["host"]["can_mix_answers"])
|
||||
self.assertTrue(lie_phase["host"]["can_show_question"])
|
||||
self.assertTrue(lie_phase["host"]["can_mix_answers"])
|
||||
self.assertFalse(lie_phase["host"]["can_calculate_scores"])
|
||||
self.assertTrue(lie_phase["readiness"]["question_ready"])
|
||||
self.assertTrue(lie_phase["player"]["can_submit_lie"])
|
||||
self.assertFalse(lie_phase["player"]["can_submit_guess"])
|
||||
@@ -2530,8 +2748,9 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
self.session.save(update_fields=["status"])
|
||||
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
guess_phase = guess_payload["phase_view_model"]
|
||||
self.assertFalse(guess_phase["host"]["can_mix_answers"])
|
||||
self.assertFalse(guess_phase["host"]["can_calculate_scores"])
|
||||
self.assertFalse(guess_phase["host"]["can_show_question"])
|
||||
self.assertTrue(guess_phase["host"]["can_mix_answers"])
|
||||
self.assertTrue(guess_phase["host"]["can_calculate_scores"])
|
||||
self.assertFalse(guess_phase["readiness"]["scoreboard_ready"])
|
||||
self.assertFalse(guess_phase["player"]["can_submit_lie"])
|
||||
self.assertTrue(guess_phase["player"]["can_submit_guess"])
|
||||
@@ -2541,7 +2760,7 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
self.session.save(update_fields=["status"])
|
||||
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
reveal_phase = reveal_payload["phase_view_model"]
|
||||
self.assertFalse(reveal_phase["host"]["can_reveal_scoreboard"])
|
||||
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
|
||||
self.assertTrue(reveal_phase["readiness"]["scoreboard_ready"])
|
||||
self.assertFalse(reveal_phase["host"]["can_start_next_round"])
|
||||
self.assertFalse(reveal_phase["host"]["can_finish_game"])
|
||||
@@ -2564,6 +2783,103 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
||||
self.assertFalse(finished_phase["player"]["can_join"])
|
||||
self.assertTrue(finished_phase["player"]["can_view_final_result"])
|
||||
|
||||
def test_session_detail_includes_role_aware_phase_display_contract(self):
|
||||
Player.objects.create(session=self.session, nickname="P1")
|
||||
Player.objects.create(session=self.session, nickname="P2")
|
||||
player = Player.objects.create(session=self.session, nickname="P3")
|
||||
|
||||
public_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
self.assertEqual(public_payload["phase_display"]["theme"], "player-boarding")
|
||||
self.assertEqual(public_payload["phase_display"]["ornament"], "boarding-pass")
|
||||
self.assertEqual(public_payload["phase_display"]["title_key"], "player.player_scene_title_join")
|
||||
self.assertEqual(public_payload["phase_display"]["cue_label_key"], "player.player_scene_cue_join_label")
|
||||
|
||||
self.client.force_login(self.host)
|
||||
host_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
self.assertEqual(host_payload["phase_display"]["theme"], "host-atrium")
|
||||
self.assertEqual(host_payload["phase_display"]["ornament"], "atrium-banner")
|
||||
self.assertEqual(host_payload["phase_display"]["title_key"], "host.presenter_scene_title_lobby")
|
||||
self.assertEqual(host_payload["phase_display"]["cue_label_key"], "host.presenter_scene_cue_start_label")
|
||||
|
||||
self.client.logout()
|
||||
player_payload = self.client.get(
|
||||
reverse("lobby:session_detail", kwargs={"code": self.session.code}),
|
||||
{"session_token": player.session_token},
|
||||
).json()
|
||||
self.assertEqual(player_payload["phase_display"]["theme"], "player-ready")
|
||||
self.assertEqual(player_payload["phase_display"]["ornament"], "ready-lantern")
|
||||
self.assertEqual(player_payload["phase_display"]["title_key"], "player.player_scene_title_lobby")
|
||||
self.assertEqual(player_payload["phase_display"]["cue_label_key"], "player.player_scene_cue_lobby_label")
|
||||
|
||||
def test_session_detail_prefers_authored_question_ornament_for_active_host_and_player_views(self):
|
||||
Player.objects.create(session=self.session, nickname="P1")
|
||||
Player.objects.create(session=self.session, nickname="P2")
|
||||
player = Player.objects.create(session=self.session, nickname="P3")
|
||||
category = Category.objects.create(name="Scene art", slug="scene-art", is_active=True)
|
||||
question = Question.objects.create(
|
||||
category=category,
|
||||
prompt="Which harbor city is the capital of Denmark?",
|
||||
correct_answer="Copenhagen",
|
||||
scene_ornament=Question.SceneOrnament.HARBOR_FLARE,
|
||||
is_active=True,
|
||||
)
|
||||
self.session.status = GameSession.Status.LIE
|
||||
self.session.save(update_fields=["status"])
|
||||
RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
|
||||
public_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
self.assertEqual(public_payload["phase_display"]["ornament"], "boarding-pass")
|
||||
|
||||
self.client.force_login(self.host)
|
||||
host_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
self.assertEqual(host_payload["phase_display"]["ornament"], Question.SceneOrnament.HARBOR_FLARE)
|
||||
|
||||
self.client.logout()
|
||||
player_payload = self.client.get(
|
||||
reverse("lobby:session_detail", kwargs={"code": self.session.code}),
|
||||
{"session_token": player.session_token},
|
||||
).json()
|
||||
self.assertEqual(player_payload["phase_display"]["ornament"], Question.SceneOrnament.HARBOR_FLARE)
|
||||
|
||||
def test_session_detail_includes_stable_player_identity_tokens(self):
|
||||
Player.objects.create(session=self.session, nickname="Zoe")
|
||||
Player.objects.create(session=self.session, nickname="Alice")
|
||||
Player.objects.create(session=self.session, nickname="Mads")
|
||||
|
||||
payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||
|
||||
self.assertEqual(
|
||||
payload["players"],
|
||||
[
|
||||
{
|
||||
"id": payload["players"][0]["id"],
|
||||
"nickname": "Alice",
|
||||
"score": 0,
|
||||
"is_connected": True,
|
||||
"identity": {"token": "A2", "tone": "lagoon", "icon": "wave"},
|
||||
},
|
||||
{
|
||||
"id": payload["players"][1]["id"],
|
||||
"nickname": "Mads",
|
||||
"score": 0,
|
||||
"is_connected": True,
|
||||
"identity": {"token": "M3", "tone": "gold", "icon": "comet"},
|
||||
},
|
||||
{
|
||||
"id": payload["players"][2]["id"],
|
||||
"nickname": "Zoe",
|
||||
"score": 0,
|
||||
"is_connected": True,
|
||||
"identity": {"token": "Z1", "tone": "ember", "icon": "spark"},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
class SmokeStagingCommandTests(TestCase):
|
||||
def test_smoke_staging_command_runs_full_flow(self):
|
||||
call_command("smoke_staging")
|
||||
@@ -2614,6 +2930,74 @@ class SmokeStagingCommandTests(TestCase):
|
||||
self.assertEqual(scoreboard_step["session_status"], GameSession.Status.SCOREBOARD)
|
||||
self.assertEqual(len(scoreboard_step["leaderboard"]), 3)
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=["localhost"])
|
||||
def test_smoke_staging_uses_an_allowed_host_for_internal_clients(self):
|
||||
call_command("smoke_staging")
|
||||
|
||||
session = GameSession.objects.latest("created_at")
|
||||
self.assertEqual(session.status, GameSession.Status.FINISHED)
|
||||
|
||||
|
||||
class BootstrapMvpCommandTests(TestCase):
|
||||
def test_bootstrap_mvp_creates_demo_host_and_seed_questions(self):
|
||||
call_command("bootstrap_mvp")
|
||||
|
||||
host = User.objects.get(username="demo-host")
|
||||
category = Category.objects.get(slug="general")
|
||||
capital_question = Question.objects.get(category=category, prompt="What is the capital of Denmark?")
|
||||
|
||||
self.assertTrue(host.is_staff)
|
||||
self.assertTrue(host.check_password("demo-pass"))
|
||||
self.assertTrue(category.is_active)
|
||||
self.assertEqual(category.questions.filter(is_active=True).count(), 3)
|
||||
self.assertEqual(capital_question.scene_ornament, Question.SceneOrnament.HARBOR_FLARE)
|
||||
self.assertEqual(capital_question.fallback_lies.filter(is_active=True).count(), 5)
|
||||
|
||||
def test_bootstrap_mvp_is_idempotent_and_repairs_drift(self):
|
||||
category = Category.objects.create(name="Old General", slug="general", is_active=False)
|
||||
Question.objects.create(
|
||||
category=category,
|
||||
prompt="What is the capital of Denmark?",
|
||||
correct_answer="Aarhus",
|
||||
scene_ornament=Question.SceneOrnament.AURORA_ARC,
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
call_command(
|
||||
"bootstrap_mvp",
|
||||
username="demo-host",
|
||||
password="demo-pass",
|
||||
category_name="General",
|
||||
)
|
||||
question = Question.objects.get(category=category, prompt="What is the capital of Denmark?")
|
||||
fallback_lie = QuestionLie.objects.get(question=question, text="Aarhus")
|
||||
fallback_lie.is_active = False
|
||||
fallback_lie.sort_order = 99
|
||||
fallback_lie.save(update_fields=["is_active", "sort_order"])
|
||||
call_command(
|
||||
"bootstrap_mvp",
|
||||
username="demo-host",
|
||||
password="demo-pass",
|
||||
category_name="General",
|
||||
)
|
||||
|
||||
host = User.objects.get(username="demo-host")
|
||||
category.refresh_from_db()
|
||||
question = Question.objects.get(category=category, prompt="What is the capital of Denmark?")
|
||||
fallback_lie.refresh_from_db()
|
||||
|
||||
self.assertTrue(host.check_password("demo-pass"))
|
||||
self.assertEqual(User.objects.filter(username="demo-host").count(), 1)
|
||||
self.assertEqual(Category.objects.filter(slug="general").count(), 1)
|
||||
self.assertEqual(category.name, "General")
|
||||
self.assertEqual(question.scene_ornament, Question.SceneOrnament.HARBOR_FLARE)
|
||||
self.assertTrue(category.is_active)
|
||||
self.assertEqual(question.correct_answer, "Copenhagen")
|
||||
self.assertTrue(question.is_active)
|
||||
self.assertTrue(fallback_lie.is_active)
|
||||
self.assertEqual(fallback_lie.sort_order, 0)
|
||||
self.assertEqual(category.questions.count(), 3)
|
||||
|
||||
|
||||
class I18nResolverTests(TestCase):
|
||||
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):
|
||||
|
||||
@@ -1,39 +1,42 @@
|
||||
from django.urls import path
|
||||
|
||||
from fupogfakta import views as gameplay_views
|
||||
|
||||
from . import ui_views, views
|
||||
|
||||
app_name = "lobby"
|
||||
|
||||
urlpatterns = [
|
||||
path("csrf", views.csrf_token, name="csrf_token"),
|
||||
path("ui/host", ui_views.host_screen, name="host_screen"),
|
||||
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
|
||||
path("ui/player", ui_views.player_screen, name="player_screen"),
|
||||
path("sessions/create", views.create_session, name="create_session"),
|
||||
path("sessions/join", views.join_session, name="join_session"),
|
||||
path("sessions/<str:code>", views.session_detail, name="session_detail"),
|
||||
path("sessions/<str:code>/rounds/start", views.start_round, name="start_round"),
|
||||
path("sessions/<str:code>/questions/show", views.show_question, name="show_question"),
|
||||
path("sessions/<str:code>/rounds/start", gameplay_views.start_round, name="start_round"),
|
||||
path("sessions/<str:code>/questions/show", gameplay_views.show_question, name="show_question"),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/lies/submit",
|
||||
views.submit_lie,
|
||||
gameplay_views.submit_lie,
|
||||
name="submit_lie",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/answers/mix",
|
||||
views.mix_answers,
|
||||
gameplay_views.mix_answers,
|
||||
name="mix_answers",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/guesses/submit",
|
||||
views.submit_guess,
|
||||
gameplay_views.submit_guess,
|
||||
name="submit_guess",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/scores/calculate",
|
||||
views.calculate_scores,
|
||||
gameplay_views.calculate_scores,
|
||||
name="calculate_scores",
|
||||
),
|
||||
path("sessions/<str:code>/scoreboard", views.reveal_scoreboard, name="reveal_scoreboard"),
|
||||
path("sessions/<str:code>/finish", views.finish_game, name="finish_game"),
|
||||
path("sessions/<str:code>/rounds/next", views.start_next_round, name="start_next_round"),
|
||||
path("sessions/<str:code>/scoreboard", gameplay_views.reveal_scoreboard, name="reveal_scoreboard"),
|
||||
path("sessions/<str:code>/finish", gameplay_views.finish_game, name="finish_game"),
|
||||
path("sessions/<str:code>/rounds/next", gameplay_views.start_next_round, name="start_next_round"),
|
||||
]
|
||||
|
||||
771
lobby/views.py
771
lobby/views.py
@@ -1,38 +1,21 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import json
|
||||
import random
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.middleware.csrf import get_token
|
||||
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 GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
|
||||
from fupogfakta.models import GameSession, Player
|
||||
from fupogfakta.payloads import (
|
||||
build_leaderboard as _build_leaderboard,
|
||||
build_reveal_payload as _build_reveal_payload,
|
||||
build_scoreboard_phase_event as _build_scoreboard_phase_event,
|
||||
SessionViewerRole,
|
||||
build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload,
|
||||
build_session_players_payload as _build_session_players_payload,
|
||||
)
|
||||
from fupogfakta.services import (
|
||||
finish_game as _finish_game,
|
||||
get_current_round_question as _get_current_round_question,
|
||||
prepare_mixed_answers as _prepare_mixed_answers,
|
||||
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
|
||||
resolve_scores as _resolve_scores,
|
||||
select_round_question as _select_round_question,
|
||||
show_question as _show_question,
|
||||
start_next_round as _start_next_round,
|
||||
start_round as _start_round,
|
||||
)
|
||||
from realtime.broadcast import sync_broadcast_phase_event
|
||||
from .i18n import api_error
|
||||
from fupogfakta.services import get_current_round_question as _get_current_round_question
|
||||
from fupogfakta.views import maybe_promote_reveal_to_scoreboard as _maybe_promote_reveal_to_scoreboard
|
||||
from voice.services import resolve_session_voice_cues
|
||||
|
||||
_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (
|
||||
_select_round_question,
|
||||
_build_scoreboard_phase_event,
|
||||
)
|
||||
from .http import json_body, normalize_session_code
|
||||
from .i18n import api_error
|
||||
|
||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
SESSION_CODE_LENGTH = 6
|
||||
@@ -46,26 +29,9 @@ JOINABLE_STATUSES = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
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()
|
||||
@@ -75,16 +41,9 @@ def _create_unique_session_code() -> str:
|
||||
raise RuntimeError("Could not generate unique session code")
|
||||
|
||||
|
||||
|
||||
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
||||
transition = _promote_reveal_to_scoreboard(session)
|
||||
if transition.should_broadcast:
|
||||
sync_broadcast_phase_event(
|
||||
transition.session.code,
|
||||
transition.phase_event_name,
|
||||
transition.phase_event_payload,
|
||||
)
|
||||
return transition.session
|
||||
@require_GET
|
||||
def csrf_token(request: HttpRequest) -> JsonResponse:
|
||||
return JsonResponse({"csrf_token": get_token(request)})
|
||||
|
||||
|
||||
|
||||
@@ -109,9 +68,9 @@ def create_session(request: HttpRequest) -> JsonResponse:
|
||||
|
||||
@require_POST
|
||||
def join_session(request: HttpRequest) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
payload = json_body(request)
|
||||
|
||||
code = _normalize_session_code(str(payload.get("code", "")))
|
||||
code = normalize_session_code(str(payload.get("code", "")))
|
||||
nickname = str(payload.get("nickname", "")).strip()
|
||||
|
||||
if not code:
|
||||
@@ -170,9 +129,20 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_session_detail_viewer_role(request: HttpRequest, session: GameSession) -> SessionViewerRole:
|
||||
if request.user.is_authenticated and request.user.id == session.host_id:
|
||||
return "host"
|
||||
|
||||
session_token = str(request.GET.get("session_token", "")).strip()
|
||||
if session_token and Player.objects.filter(session=session, session_token=session_token).exists():
|
||||
return "player"
|
||||
|
||||
return "public"
|
||||
|
||||
|
||||
@require_GET
|
||||
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = _normalize_session_code(code)
|
||||
session_code = normalize_session_code(code)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -183,21 +153,17 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
status=404,
|
||||
)
|
||||
|
||||
players = list(
|
||||
session.players.order_by("nickname").values(
|
||||
"id",
|
||||
"nickname",
|
||||
"score",
|
||||
"is_connected",
|
||||
)
|
||||
)
|
||||
players = _build_session_players_payload(session)
|
||||
|
||||
session = _maybe_promote_reveal_to_scoreboard(session)
|
||||
current_round_question = _get_current_round_question(session)
|
||||
viewer_role = _resolve_session_detail_viewer_role(request, session)
|
||||
gameplay_payload = _build_session_detail_gameplay_payload(
|
||||
session,
|
||||
current_round_question=current_round_question,
|
||||
players_count=len(players),
|
||||
viewer_role=viewer_role,
|
||||
voice_cues=resolve_session_voice_cues(session, current_round_question=current_round_question),
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
@@ -209,683 +175,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
"current_round": session.current_round,
|
||||
"players_count": len(players),
|
||||
},
|
||||
"viewer_role": viewer_role,
|
||||
"players": players,
|
||||
**gameplay_payload,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
sync_broadcast_phase_event(
|
||||
transition.session.code,
|
||||
transition.phase_event_name,
|
||||
transition.phase_event_payload,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
sync_broadcast_phase_event(
|
||||
transition.session.code,
|
||||
transition.phase_event_name,
|
||||
transition.phase_event_payload,
|
||||
)
|
||||
|
||||
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": 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)
|
||||
|
||||
transition = _promote_reveal_to_scoreboard(session)
|
||||
if transition.should_broadcast:
|
||||
sync_broadcast_phase_event(
|
||||
transition.session.code,
|
||||
transition.phase_event_name,
|
||||
transition.phase_event_payload,
|
||||
)
|
||||
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)
|
||||
|
||||
if transition.should_broadcast:
|
||||
sync_broadcast_phase_event(
|
||||
transition.session.code,
|
||||
transition.phase_event_name,
|
||||
transition.phase_event_payload,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
if transition.should_broadcast:
|
||||
sync_broadcast_phase_event(
|
||||
transition.session.code,
|
||||
transition.phase_event_name,
|
||||
transition.phase_event_payload,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user