Big visual overhaul docker compsoe file etc
Some checks failed
CI / test-and-quality (push) Failing after 4m4s

This commit is contained in:
Asger Geel Weirsøe
2026-03-23 14:11:30 +01:00
parent d86941fef8
commit a81bc1250c
92 changed files with 11584 additions and 1686 deletions

17
lobby/http.py Normal file
View 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()

View File

@@ -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"]

View File

@@ -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):

View File

@@ -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"),
]

View File

@@ -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,
}
)