feat(lobby): canonicalize round phase ownership
This commit is contained in:
21
docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md
Normal file
21
docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Issue #287 — Canonical round-flow backend artifact
|
||||||
|
|
||||||
|
## State-transition matrix
|
||||||
|
|
||||||
|
| Trigger | From | To | Server-owned effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar |
|
||||||
|
| Sidste gyldige `submit_lie` for aktivt spørgsmål | `lie` | `guess` | Dedupe/shuffle `correct_answer + lies`, persisterer `mixed_answers`, broadcaster `phase.guess_started` |
|
||||||
|
| Sidste gyldige `submit_guess` for aktivt spørgsmål | `guess` | `reveal` | Beregner score deterministisk, persisterer `ScoreEvent` + opdaterede `Player.score`, returnerer canonical reveal payload |
|
||||||
|
| Første canonical state-read efter resolved reveal (`session_detail`) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness |
|
||||||
|
| `POST /lobby/sessions/{code}/next` | `scoreboard` | `lobby` | Increment round counter |
|
||||||
|
| `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard |
|
||||||
|
|
||||||
|
## Flow-log (happy path)
|
||||||
|
|
||||||
|
1. Host starter runde med kategori.
|
||||||
|
2. Server vælger straks spørgsmål og går i `lie`.
|
||||||
|
3. Spillere sender løgne; sidste submission auto-advancer til `guess`.
|
||||||
|
4. Spillere sender gæt; sidste submission auto-advancer til `reveal` og scorer runden.
|
||||||
|
5. Næste `session_detail`-read promoverer resolved reveal til `scoreboard` uden host-knap.
|
||||||
|
6. Host kan nu kun vælge `next round` eller `finish game`.
|
||||||
@@ -70,12 +70,9 @@ class Command(BaseCommand):
|
|||||||
if start_res.status_code != 201:
|
if start_res.status_code != 201:
|
||||||
raise CommandError(f"start_round failed: {start_res.status_code}")
|
raise CommandError(f"start_round failed: {start_res.status_code}")
|
||||||
|
|
||||||
show_res = host_client.post(f"/lobby/sessions/{code}/questions/show", content_type="application/json")
|
round_question_id = start_res.json()["round_question"]["id"]
|
||||||
if show_res.status_code != 201:
|
|
||||||
raise CommandError(f"show_question failed: {show_res.status_code}")
|
|
||||||
|
|
||||||
round_question_id = show_res.json()["round_question"]["id"]
|
|
||||||
|
|
||||||
|
answers = []
|
||||||
for player in players:
|
for player in players:
|
||||||
nick = player["nickname"]
|
nick = player["nickname"]
|
||||||
lie_res = Client().post(
|
lie_res = Client().post(
|
||||||
@@ -91,17 +88,17 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
if lie_res.status_code != 201:
|
if lie_res.status_code != 201:
|
||||||
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
|
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
|
||||||
|
if lie_res.json().get("answers"):
|
||||||
|
answers = lie_res.json()["answers"]
|
||||||
|
|
||||||
mix_res = host_client.post(
|
|
||||||
f"/lobby/sessions/{code}/questions/{round_question_id}/answers/mix",
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
if mix_res.status_code != 200:
|
|
||||||
raise CommandError(f"mix_answers failed: {mix_res.status_code}")
|
|
||||||
|
|
||||||
answers = mix_res.json().get("answers", [])
|
|
||||||
if not answers:
|
if not answers:
|
||||||
raise CommandError("mix_answers returned empty answers")
|
detail_res = host_client.get(f"/lobby/sessions/{code}")
|
||||||
|
if detail_res.status_code != 200:
|
||||||
|
raise CommandError(f"session_detail after lies failed: {detail_res.status_code}")
|
||||||
|
answers = detail_res.json().get("round_question", {}).get("answers", [])
|
||||||
|
|
||||||
|
if not answers:
|
||||||
|
raise CommandError("canonical lie->guess transition returned empty answers")
|
||||||
|
|
||||||
for player in players:
|
for player in players:
|
||||||
nick = player["nickname"]
|
nick = player["nickname"]
|
||||||
@@ -120,16 +117,11 @@ class Command(BaseCommand):
|
|||||||
if guess_res.status_code != 201:
|
if guess_res.status_code != 201:
|
||||||
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
|
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
|
||||||
|
|
||||||
calc_res = host_client.post(
|
detail_res = host_client.get(f"/lobby/sessions/{code}")
|
||||||
f"/lobby/sessions/{code}/questions/{round_question_id}/scores/calculate",
|
if detail_res.status_code != 200:
|
||||||
content_type="application/json",
|
raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}")
|
||||||
)
|
if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD:
|
||||||
if calc_res.status_code != 200:
|
raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard")
|
||||||
raise CommandError(f"calculate_scores failed: {calc_res.status_code}")
|
|
||||||
|
|
||||||
board_res = host_client.get(f"/lobby/sessions/{code}/scoreboard")
|
|
||||||
if board_res.status_code != 200:
|
|
||||||
raise CommandError(f"reveal_scoreboard failed: {board_res.status_code}")
|
|
||||||
|
|
||||||
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
|
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
|
||||||
if finish_res.status_code != 200:
|
if finish_res.status_code != 200:
|
||||||
@@ -148,12 +140,10 @@ class Command(BaseCommand):
|
|||||||
"create_session",
|
"create_session",
|
||||||
"join_players",
|
"join_players",
|
||||||
"start_round",
|
"start_round",
|
||||||
"show_question",
|
|
||||||
"submit_lies",
|
"submit_lies",
|
||||||
"mix_answers",
|
"auto_guess_transition",
|
||||||
"submit_guesses",
|
"submit_guesses",
|
||||||
"calculate_scores",
|
"auto_reveal_to_scoreboard",
|
||||||
"reveal_scoreboard",
|
|
||||||
"finish_game",
|
"finish_game",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,11 +204,14 @@ class StartRoundTests(TestCase):
|
|||||||
self.assertEqual(body["session"]["status"], GameSession.Status.LIE)
|
self.assertEqual(body["session"]["status"], GameSession.Status.LIE)
|
||||||
self.assertEqual(body["round"]["number"], 1)
|
self.assertEqual(body["round"]["number"], 1)
|
||||||
self.assertEqual(body["round"]["category"]["slug"], self.category.slug)
|
self.assertEqual(body["round"]["category"]["slug"], self.category.slug)
|
||||||
|
self.assertEqual(body["round_question"]["prompt"], "Hvilket år faldt muren?")
|
||||||
|
self.assertIn("lie_deadline_at", body["round_question"])
|
||||||
|
|
||||||
self.session.refresh_from_db()
|
self.session.refresh_from_db()
|
||||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
||||||
self.assertEqual(round_config.category, self.category)
|
self.assertEqual(round_config.category, self.category)
|
||||||
|
self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
|
||||||
|
|
||||||
def test_host_start_round_uses_normalized_session_code_from_path(self):
|
def test_host_start_round_uses_normalized_session_code_from_path(self):
|
||||||
self.client.login(username="host", password="secret123")
|
self.client.login(username="host", password="secret123")
|
||||||
@@ -715,6 +718,76 @@ class GuessSubmissionTests(TestCase):
|
|||||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalRoundFlowTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.host = User.objects.create_user(username="host_canonical", password="secret123")
|
||||||
|
self.session = GameSession.objects.create(host=self.host, code="CN2871")
|
||||||
|
self.category = Category.objects.create(name="Kanon", slug="kanon", is_active=True)
|
||||||
|
self.question = Question.objects.create(
|
||||||
|
category=self.category,
|
||||||
|
prompt="Hvem skrev Hamlet?",
|
||||||
|
correct_answer="Shakespeare",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.players = [
|
||||||
|
Player.objects.create(session=self.session, nickname="Luna"),
|
||||||
|
Player.objects.create(session=self.session, nickname="Mads"),
|
||||||
|
Player.objects.create(session=self.session, nickname="Nora"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_canonical_round_flow_auto_advances_from_start_to_scoreboard(self):
|
||||||
|
self.client.login(username="host_canonical", password="secret123")
|
||||||
|
|
||||||
|
start_response = self.client.post(
|
||||||
|
reverse("lobby:start_round", kwargs={"code": self.session.code}),
|
||||||
|
data={"category_slug": self.category.slug},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(start_response.status_code, 201)
|
||||||
|
round_question_id = start_response.json()["round_question"]["id"]
|
||||||
|
self.assertEqual(start_response.json()["session"]["status"], GameSession.Status.LIE)
|
||||||
|
|
||||||
|
lie_responses = []
|
||||||
|
for index, player in enumerate(self.players, start=1):
|
||||||
|
lie_responses.append(
|
||||||
|
self.client.post(
|
||||||
|
reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": round_question_id}),
|
||||||
|
data={"player_id": player.id, "session_token": player.session_token, "text": f"Løgn {index}"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(all(response.status_code == 201 for response in lie_responses))
|
||||||
|
self.assertEqual(lie_responses[-1].json()["session"]["status"], GameSession.Status.GUESS)
|
||||||
|
self.assertTrue(lie_responses[-1].json()["phase_transition"]["auto_advanced"])
|
||||||
|
self.assertGreaterEqual(len(lie_responses[-1].json()["answers"]), 2)
|
||||||
|
|
||||||
|
guess_targets = ["Shakespeare", "Løgn 1", "Shakespeare"]
|
||||||
|
guess_responses = []
|
||||||
|
for player, selected_text in zip(self.players, guess_targets, strict=True):
|
||||||
|
guess_responses.append(
|
||||||
|
self.client.post(
|
||||||
|
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": round_question_id}),
|
||||||
|
data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(all(response.status_code == 201 for response in guess_responses))
|
||||||
|
self.assertEqual(guess_responses[-1].json()["session"]["status"], GameSession.Status.REVEAL)
|
||||||
|
self.assertTrue(guess_responses[-1].json()["phase_transition"]["auto_advanced"])
|
||||||
|
self.assertIsNotNone(guess_responses[-1].json()["reveal"])
|
||||||
|
|
||||||
|
detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||||
|
self.assertEqual(detail_response.status_code, 200)
|
||||||
|
payload = detail_response.json()
|
||||||
|
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
|
||||||
|
self.assertEqual(payload["phase_view_model"]["current_phase"], GameSession.Status.SCOREBOARD)
|
||||||
|
self.assertTrue(payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
||||||
|
self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"])
|
||||||
|
self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare")
|
||||||
|
|
||||||
|
|
||||||
class ScoreCalculationTests(TestCase):
|
class ScoreCalculationTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.host = User.objects.create_user(username="host_score", password="secret123")
|
self.host = User.objects.create_user(username="host_score", password="secret123")
|
||||||
@@ -1502,8 +1575,8 @@ class SessionDetailRoundQuestionTests(TestCase):
|
|||||||
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
|
||||||
self.assertEqual(reveal_payload["reveal"], scoreboard_payload["reveal"])
|
self.assertEqual(reveal_payload["reveal"], scoreboard_payload["reveal"])
|
||||||
self.assertTrue(reveal_payload["phase_view_model"]["host"]["can_reveal_scoreboard"])
|
self.assertTrue(reveal_payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
||||||
self.assertFalse(scoreboard_payload["phase_view_model"]["host"]["can_reveal_scoreboard"])
|
self.assertTrue(scoreboard_payload["phase_view_model"]["readiness"]["scoreboard_ready"])
|
||||||
self.assertFalse(reveal_payload["phase_view_model"]["host"]["can_start_next_round"])
|
self.assertFalse(reveal_payload["phase_view_model"]["host"]["can_start_next_round"])
|
||||||
self.assertTrue(scoreboard_payload["phase_view_model"]["host"]["can_start_next_round"])
|
self.assertTrue(scoreboard_payload["phase_view_model"]["host"]["can_start_next_round"])
|
||||||
|
|
||||||
@@ -1530,7 +1603,10 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
|||||||
self.assertTrue(phase["constraints"]["min_players_reached"])
|
self.assertTrue(phase["constraints"]["min_players_reached"])
|
||||||
self.assertTrue(phase["constraints"]["max_players_allowed"])
|
self.assertTrue(phase["constraints"]["max_players_allowed"])
|
||||||
self.assertTrue(phase["host"]["can_start_round"])
|
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_show_question"])
|
||||||
|
self.assertFalse(phase["readiness"]["question_ready"])
|
||||||
|
self.assertFalse(phase["readiness"]["scoreboard_ready"])
|
||||||
self.assertTrue(phase["player"]["can_join"])
|
self.assertTrue(phase["player"]["can_join"])
|
||||||
self.assertFalse(phase["player"]["can_submit_lie"])
|
self.assertFalse(phase["player"]["can_submit_lie"])
|
||||||
self.assertFalse(phase["player"]["can_submit_guess"])
|
self.assertFalse(phase["player"]["can_submit_guess"])
|
||||||
@@ -1555,7 +1631,8 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
|||||||
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
lie_phase = lie_payload["phase_view_model"]
|
lie_phase = lie_payload["phase_view_model"]
|
||||||
self.assertFalse(lie_phase["host"]["can_show_question"])
|
self.assertFalse(lie_phase["host"]["can_show_question"])
|
||||||
self.assertTrue(lie_phase["host"]["can_mix_answers"])
|
self.assertFalse(lie_phase["host"]["can_mix_answers"])
|
||||||
|
self.assertTrue(lie_phase["readiness"]["question_ready"])
|
||||||
self.assertTrue(lie_phase["player"]["can_submit_lie"])
|
self.assertTrue(lie_phase["player"]["can_submit_lie"])
|
||||||
self.assertFalse(lie_phase["player"]["can_submit_guess"])
|
self.assertFalse(lie_phase["player"]["can_submit_guess"])
|
||||||
|
|
||||||
@@ -1563,8 +1640,9 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
|||||||
self.session.save(update_fields=["status"])
|
self.session.save(update_fields=["status"])
|
||||||
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
guess_phase = guess_payload["phase_view_model"]
|
guess_phase = guess_payload["phase_view_model"]
|
||||||
self.assertTrue(guess_phase["host"]["can_mix_answers"])
|
self.assertFalse(guess_phase["host"]["can_mix_answers"])
|
||||||
self.assertTrue(guess_phase["host"]["can_calculate_scores"])
|
self.assertFalse(guess_phase["host"]["can_calculate_scores"])
|
||||||
|
self.assertFalse(guess_phase["readiness"]["scoreboard_ready"])
|
||||||
self.assertFalse(guess_phase["player"]["can_submit_lie"])
|
self.assertFalse(guess_phase["player"]["can_submit_lie"])
|
||||||
self.assertTrue(guess_phase["player"]["can_submit_guess"])
|
self.assertTrue(guess_phase["player"]["can_submit_guess"])
|
||||||
|
|
||||||
@@ -1573,7 +1651,8 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
|||||||
self.session.save(update_fields=["status"])
|
self.session.save(update_fields=["status"])
|
||||||
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
reveal_phase = reveal_payload["phase_view_model"]
|
reveal_phase = reveal_payload["phase_view_model"]
|
||||||
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
|
self.assertFalse(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_start_next_round"])
|
||||||
self.assertFalse(reveal_phase["host"]["can_finish_game"])
|
self.assertFalse(reveal_phase["host"]["can_finish_game"])
|
||||||
self.assertFalse(reveal_phase["player"]["can_view_final_result"])
|
self.assertFalse(reveal_phase["player"]["can_view_final_result"])
|
||||||
@@ -1583,6 +1662,7 @@ class SessionDetailPhaseViewModelTests(TestCase):
|
|||||||
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
scoreboard_phase = scoreboard_payload["phase_view_model"]
|
scoreboard_phase = scoreboard_payload["phase_view_model"]
|
||||||
self.assertFalse(scoreboard_phase["host"]["can_reveal_scoreboard"])
|
self.assertFalse(scoreboard_phase["host"]["can_reveal_scoreboard"])
|
||||||
|
self.assertTrue(scoreboard_phase["readiness"]["scoreboard_ready"])
|
||||||
self.assertTrue(scoreboard_phase["host"]["can_start_next_round"])
|
self.assertTrue(scoreboard_phase["host"]["can_start_next_round"])
|
||||||
self.assertTrue(scoreboard_phase["host"]["can_finish_game"])
|
self.assertTrue(scoreboard_phase["host"]["can_finish_game"])
|
||||||
self.assertFalse(scoreboard_phase["player"]["can_view_final_result"])
|
self.assertFalse(scoreboard_phase["player"]["can_view_final_result"])
|
||||||
@@ -1620,12 +1700,10 @@ class SmokeStagingCommandTests(TestCase):
|
|||||||
"create_session",
|
"create_session",
|
||||||
"join_players",
|
"join_players",
|
||||||
"start_round",
|
"start_round",
|
||||||
"show_question",
|
|
||||||
"submit_lies",
|
"submit_lies",
|
||||||
"mix_answers",
|
"auto_guess_transition",
|
||||||
"submit_guesses",
|
"submit_guesses",
|
||||||
"calculate_scores",
|
"auto_reveal_to_scoreboard",
|
||||||
"reveal_scoreboard",
|
|
||||||
"finish_game",
|
"finish_game",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
343
lobby/views.py
343
lobby/views.py
@@ -112,6 +112,152 @@ def _build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _build_leaderboard(session: GameSession) -> list[dict]:
|
||||||
|
return list(
|
||||||
|
Player.objects.filter(session=session)
|
||||||
|
.order_by("-score", "nickname")
|
||||||
|
.values("id", "nickname", "score")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_round_question(session: GameSession) -> RoundQuestion | None:
|
||||||
|
return (
|
||||||
|
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
|
||||||
|
.select_related("question")
|
||||||
|
.order_by("-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion:
|
||||||
|
existing_round_question = _get_current_round_question(session)
|
||||||
|
if existing_round_question is not None:
|
||||||
|
return existing_round_question
|
||||||
|
|
||||||
|
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
|
||||||
|
available_questions = Question.objects.filter(
|
||||||
|
category=round_config.category,
|
||||||
|
is_active=True,
|
||||||
|
).exclude(pk__in=used_question_ids)
|
||||||
|
|
||||||
|
if not available_questions.exists():
|
||||||
|
raise ValueError("no_available_questions")
|
||||||
|
|
||||||
|
question = random.choice(list(available_questions))
|
||||||
|
return RoundQuestion.objects.create(
|
||||||
|
session=session,
|
||||||
|
round_number=session.current_round,
|
||||||
|
question=question,
|
||||||
|
correct_answer=question.correct_answer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
|
||||||
|
deduped_answers = list(round_question.mixed_answers or [])
|
||||||
|
if deduped_answers:
|
||||||
|
return deduped_answers
|
||||||
|
|
||||||
|
lie_texts = list(round_question.lies.values_list("text", flat=True))
|
||||||
|
seen = set()
|
||||||
|
for text in [round_question.correct_answer, *lie_texts]:
|
||||||
|
normalized = text.strip().casefold()
|
||||||
|
if not normalized or normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
deduped_answers.append(text.strip())
|
||||||
|
|
||||||
|
if len(deduped_answers) < 2:
|
||||||
|
raise ValueError("not_enough_answers_to_mix")
|
||||||
|
|
||||||
|
random.shuffle(deduped_answers)
|
||||||
|
round_question.mixed_answers = deduped_answers
|
||||||
|
round_question.save(update_fields=["mixed_answers"])
|
||||||
|
return deduped_answers
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]:
|
||||||
|
guesses = list(round_question.guesses.select_related("player"))
|
||||||
|
if not guesses:
|
||||||
|
raise ValueError("no_guesses_submitted")
|
||||||
|
|
||||||
|
bluff_counts: dict[int, int] = {}
|
||||||
|
for guess in guesses:
|
||||||
|
if guess.fooled_player_id:
|
||||||
|
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
|
||||||
|
|
||||||
|
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)
|
||||||
|
return score_events, _build_leaderboard(session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
||||||
|
if session.status != GameSession.Status.REVEAL:
|
||||||
|
return session
|
||||||
|
|
||||||
|
current_round_question = _get_current_round_question(session)
|
||||||
|
if current_round_question is None:
|
||||||
|
return session
|
||||||
|
|
||||||
|
has_round_scores = ScoreEvent.objects.filter(
|
||||||
|
session=session,
|
||||||
|
meta__round_question_id=current_round_question.id,
|
||||||
|
).exists()
|
||||||
|
if not has_round_scores:
|
||||||
|
return session
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
|
if locked_session.status != GameSession.Status.REVEAL:
|
||||||
|
return locked_session
|
||||||
|
locked_session.status = GameSession.Status.SCOREBOARD
|
||||||
|
locked_session.save(update_fields=["status"])
|
||||||
|
|
||||||
|
leaderboard = _build_leaderboard(session)
|
||||||
|
sync_broadcast_phase_event(
|
||||||
|
session.code,
|
||||||
|
"phase.scoreboard",
|
||||||
|
{"leaderboard": list(leaderboard), "current_round": session.current_round},
|
||||||
|
)
|
||||||
|
session.refresh_from_db(fields=["status"])
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
|
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
|
||||||
status = session.status
|
status = session.status
|
||||||
in_lobby = status == GameSession.Status.LOBBY
|
in_lobby = status == GameSession.Status.LOBBY
|
||||||
@@ -126,6 +272,7 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"status": status,
|
"status": status,
|
||||||
|
"current_phase": status,
|
||||||
"round_number": session.current_round,
|
"round_number": session.current_round,
|
||||||
"players_count": players_count,
|
"players_count": players_count,
|
||||||
"constraints": {
|
"constraints": {
|
||||||
@@ -134,12 +281,17 @@ def _build_phase_view_model(session: GameSession, *, players_count: int, has_rou
|
|||||||
"min_players_reached": min_players_reached,
|
"min_players_reached": min_players_reached,
|
||||||
"max_players_allowed": max_players_allowed,
|
"max_players_allowed": max_players_allowed,
|
||||||
},
|
},
|
||||||
|
"readiness": {
|
||||||
|
"question_ready": has_round_question,
|
||||||
|
"scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED},
|
||||||
|
"can_advance_to_next_round": in_scoreboard,
|
||||||
|
},
|
||||||
"host": {
|
"host": {
|
||||||
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
|
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
|
||||||
"can_show_question": in_lie and not has_round_question,
|
"can_show_question": False,
|
||||||
"can_mix_answers": in_lie or in_guess,
|
"can_mix_answers": False,
|
||||||
"can_calculate_scores": in_guess,
|
"can_calculate_scores": False,
|
||||||
"can_reveal_scoreboard": in_reveal,
|
"can_reveal_scoreboard": False,
|
||||||
"can_start_next_round": in_scoreboard,
|
"can_start_next_round": in_scoreboard,
|
||||||
"can_finish_game": in_scoreboard,
|
"can_finish_game": in_scoreboard,
|
||||||
},
|
},
|
||||||
@@ -256,12 +408,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
current_round_question = (
|
session = _maybe_promote_reveal_to_scoreboard(session)
|
||||||
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
|
current_round_question = _get_current_round_question(session)
|
||||||
.select_related("question")
|
|
||||||
.order_by("-id")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
round_question_payload = None
|
round_question_payload = None
|
||||||
if current_round_question:
|
if current_round_question:
|
||||||
@@ -293,6 +441,9 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
"reveal": _build_reveal_payload(current_round_question)
|
"reveal": _build_reveal_payload(current_round_question)
|
||||||
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
|
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
|
||||||
else None,
|
else None,
|
||||||
|
"scoreboard": _build_leaderboard(session)
|
||||||
|
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
|
||||||
|
else None,
|
||||||
"phase_view_model": phase_view_model,
|
"phase_view_model": phase_view_model,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -373,15 +524,26 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
status=409,
|
status=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
round_question = _select_round_question(session, round_config)
|
||||||
|
except ValueError as exc:
|
||||||
|
return api_error(request, code=str(exc), status=400)
|
||||||
|
|
||||||
session.status = GameSession.Status.LIE
|
session.status = GameSession.Status.LIE
|
||||||
session.save(update_fields=["status"])
|
session.save(update_fields=["status"])
|
||||||
|
|
||||||
|
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
sync_broadcast_phase_event(
|
||||||
session.code,
|
session.code,
|
||||||
"phase.lie_started",
|
"phase.lie_started",
|
||||||
{
|
{
|
||||||
"round_number": session.current_round,
|
"round_number": session.current_round,
|
||||||
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
|
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
|
||||||
|
"round_question_id": round_question.id,
|
||||||
|
"prompt": round_question.question.prompt,
|
||||||
|
"shown_at": round_question.shown_at.isoformat(),
|
||||||
|
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||||
"lie_seconds": round_config.lie_seconds,
|
"lie_seconds": round_config.lie_seconds,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -400,6 +562,16 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
"name": round_config.category.name,
|
"name": round_config.category.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"round_question": {
|
||||||
|
"id": round_question.id,
|
||||||
|
"prompt": round_question.question.prompt,
|
||||||
|
"round_number": round_question.round_number,
|
||||||
|
"shown_at": round_question.shown_at.isoformat(),
|
||||||
|
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"lie_seconds": round_config.lie_seconds,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
status=201,
|
status=201,
|
||||||
)
|
)
|
||||||
@@ -442,33 +614,14 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
|
existing_round_question = _get_current_round_question(session)
|
||||||
return api_error(
|
if existing_round_question is not None:
|
||||||
request,
|
round_question = existing_round_question
|
||||||
code="question_already_shown",
|
else:
|
||||||
status=409,
|
try:
|
||||||
)
|
round_question = _select_round_question(session, round_config)
|
||||||
|
except ValueError as exc:
|
||||||
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
|
return api_error(request, code=str(exc), status=400)
|
||||||
available_questions = Question.objects.filter(
|
|
||||||
category=round_config.category,
|
|
||||||
is_active=True,
|
|
||||||
).exclude(pk__in=used_question_ids)
|
|
||||||
|
|
||||||
if not available_questions.exists():
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="no_available_questions",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
question = random.choice(list(available_questions))
|
|
||||||
round_question = RoundQuestion.objects.create(
|
|
||||||
session=session,
|
|
||||||
round_number=session.current_round,
|
|
||||||
question=question,
|
|
||||||
correct_answer=question.correct_answer,
|
|
||||||
)
|
|
||||||
|
|
||||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||||
|
|
||||||
@@ -477,7 +630,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
"phase.question_shown",
|
"phase.question_shown",
|
||||||
{
|
{
|
||||||
"round_question_id": round_question.id,
|
"round_question_id": round_question.id,
|
||||||
"prompt": question.prompt,
|
"prompt": round_question.question.prompt,
|
||||||
"shown_at": round_question.shown_at.isoformat(),
|
"shown_at": round_question.shown_at.isoformat(),
|
||||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||||
"lie_seconds": round_config.lie_seconds,
|
"lie_seconds": round_config.lie_seconds,
|
||||||
@@ -488,7 +641,7 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
{
|
{
|
||||||
"round_question": {
|
"round_question": {
|
||||||
"id": round_question.id,
|
"id": round_question.id,
|
||||||
"prompt": question.prompt,
|
"prompt": round_question.question.prompt,
|
||||||
"round_number": round_question.round_number,
|
"round_number": round_question.round_number,
|
||||||
"shown_at": round_question.shown_at.isoformat(),
|
"shown_at": round_question.shown_at.isoformat(),
|
||||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||||
@@ -558,6 +711,31 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
return api_error(request, code="lie_already_submitted", status=409)
|
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(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"lie": {
|
"lie": {
|
||||||
@@ -570,6 +748,18 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
|
|||||||
"window": {
|
"window": {
|
||||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
"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,
|
status=201,
|
||||||
)
|
)
|
||||||
@@ -626,27 +816,10 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
|
|||||||
|
|
||||||
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
|
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
|
||||||
|
|
||||||
deduped_answers = list(locked_round_question.mixed_answers or [])
|
try:
|
||||||
if not deduped_answers:
|
deduped_answers = _prepare_mixed_answers(locked_round_question)
|
||||||
lie_texts = list(locked_round_question.lies.values_list("text", flat=True))
|
except ValueError as exc:
|
||||||
seen = set()
|
return api_error(request, code=str(exc), status=400)
|
||||||
for text in [locked_round_question.correct_answer, *lie_texts]:
|
|
||||||
normalized = text.strip().casefold()
|
|
||||||
if not normalized or normalized in seen:
|
|
||||||
continue
|
|
||||||
seen.add(normalized)
|
|
||||||
deduped_answers.append(text.strip())
|
|
||||||
|
|
||||||
if len(deduped_answers) < 2:
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="not_enough_answers_to_mix",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
random.shuffle(deduped_answers)
|
|
||||||
locked_round_question.mixed_answers = deduped_answers
|
|
||||||
locked_round_question.save(update_fields=["mixed_answers"])
|
|
||||||
|
|
||||||
if locked_session.status == GameSession.Status.LIE:
|
if locked_session.status == GameSession.Status.LIE:
|
||||||
locked_session.status = GameSession.Status.GUESS
|
locked_session.status = GameSession.Status.GUESS
|
||||||
@@ -769,6 +942,43 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
return api_error(request, code="guess_already_submitted", status=409)
|
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:
|
||||||
|
already_calculated = ScoreEvent.objects.filter(
|
||||||
|
session=session,
|
||||||
|
meta__round_question_id=round_question.id,
|
||||||
|
).exists()
|
||||||
|
if not already_calculated:
|
||||||
|
score_events, leaderboard = _resolve_scores(session, round_question, round_config)
|
||||||
|
else:
|
||||||
|
score_events = list(
|
||||||
|
ScoreEvent.objects.filter(session=session, meta__round_question_id=round_question.id).select_related("player")
|
||||||
|
)
|
||||||
|
leaderboard = _build_leaderboard(session)
|
||||||
|
|
||||||
|
session.status = GameSession.Status.REVEAL
|
||||||
|
session.save(update_fields=["status"])
|
||||||
|
session_status = session.status
|
||||||
|
reveal_payload = _build_reveal_payload(round_question)
|
||||||
|
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(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"guess": {
|
"guess": {
|
||||||
@@ -783,6 +993,19 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
|
|||||||
"window": {
|
"window": {
|
||||||
"guess_deadline_at": guess_deadline_at.isoformat(),
|
"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,
|
status=201,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user