feat(lobby): align canonical round flow for issue 287
Some checks failed
CI / test-and-quality (push) Failing after 10s
CI / test-and-quality (pull_request) Failing after 10s

This commit is contained in:
2026-03-16 01:00:07 +00:00
parent a2c60749f8
commit ab08dc2b6d
3 changed files with 120 additions and 46 deletions

View File

@@ -7,8 +7,8 @@
| `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar | | `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_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 | | 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 | | Første canonical state-read efter resolved reveal (`session_detail`, og idempotent `GET /scoreboard` hvis state allerede er resolved) | `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}/next` | `scoreboard` | `lie` | Increment round counter, kopierer seneste `RoundConfig`, vælger/låser næste spørgsmål i samme kategori og broadcaster `phase.lie_started` |
| `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard | | `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard |
## Flow-log (happy path) ## Flow-log (happy path)
@@ -17,5 +17,6 @@
2. Server vælger straks spørgsmål og går i `lie`. 2. Server vælger straks spørgsmål og går i `lie`.
3. Spillere sender løgne; sidste submission auto-advancer til `guess`. 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. 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. 5. Næste canonical state-read promoverer resolved reveal til `scoreboard`; state findes uden separat debug-knap.
6. Host kan nu kun vælge `next round` eller `finish game`. 6. Host kan nu kun vælge `next round` eller `finish game`.
7. `next round` starter næste runde direkte i `lie` med nyt konkret spørgsmål; ingen mellem-hop tilbage til `lobby`.

View File

@@ -19,6 +19,7 @@ from fupogfakta.models import (
Question, Question,
RoundConfig, RoundConfig,
RoundQuestion, RoundQuestion,
ScoreEvent,
) )
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
@@ -947,6 +948,33 @@ class RevealRoundFlowTests(TestCase):
self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL) self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL)
self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9) self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9)
self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3) self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3)
self.category = Category.objects.create(name="Reveal", slug="reveal", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Hvad er Danmarks hovedstad?",
correct_answer="København",
is_active=True,
)
self.next_question = Question.objects.create(
category=self.category,
prompt="Hvad er Sveriges hovedstad?",
correct_answer="Stockholm",
is_active=True,
)
self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category)
self.round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
ScoreEvent.objects.create(
session=self.session,
player=self.player_one,
delta=5,
reason="guess_correct",
meta={"round_question_id": self.round_question.id},
)
@patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views.sync_broadcast_phase_event")
def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event): def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event):
@@ -1062,9 +1090,10 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase") self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase")
@patch("lobby.views.sync_broadcast_phase_event") @patch("lobby.views.sync_broadcast_phase_event")
def test_host_can_start_next_round_from_scoreboard(self, _mock_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.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
mock_sync_broadcast_phase_event.reset_mock()
response = self.client.post( response = self.client.post(
reverse( reverse(
@@ -1075,12 +1104,24 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
self.assertEqual(payload["session"]["status"], GameSession.Status.LOBBY) self.assertEqual(payload["session"]["status"], GameSession.Status.LIE)
self.assertEqual(payload["session"]["current_round"], 2) self.assertEqual(payload["session"]["current_round"], 2)
self.assertEqual(payload["round"]["category"]["slug"], self.category.slug)
self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt)
self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds)
self.session.refresh_from_db() self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LOBBY) self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2) self.assertEqual(self.session.current_round, 2)
self.assertTrue(
RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists()
)
self.assertTrue(
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
)
mock_sync_broadcast_phase_event.assert_called_once()
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")
def test_start_next_round_requires_host(self): def test_start_next_round_requires_host(self):
self.session.status = GameSession.Status.SCOREBOARD self.session.status = GameSession.Status.SCOREBOARD

View File

@@ -155,6 +155,20 @@ def _select_round_question(session: GameSession, round_config: RoundConfig) -> R
def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"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,
}
def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
deduped_answers = list(round_question.mixed_answers or []) deduped_answers = list(round_question.mixed_answers or [])
if deduped_answers: if deduped_answers:
@@ -532,20 +546,12 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
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) lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
sync_broadcast_phase_event( sync_broadcast_phase_event(
session.code, session.code,
"phase.lie_started", "phase.lie_started",
{ lie_started_payload,
"round_number": session.current_round,
"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,
},
) )
return JsonResponse( return JsonResponse(
@@ -567,7 +573,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
"prompt": round_question.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_started_payload["lie_deadline_at"],
}, },
"config": { "config": {
"lie_seconds": round_config.lie_seconds, "lie_seconds": round_config.lie_seconds,
@@ -1026,37 +1032,18 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id: if session.host_id != request.user.id:
return api_error(request, code="host_only_view_scoreboard", status=403) return api_error(request, code="host_only_view_scoreboard", status=403)
with transaction.atomic(): session = _maybe_promote_reveal_to_scoreboard(session)
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}:
return api_error(request, code="scoreboard_invalid_phase", status=400) return api_error(request, code="scoreboard_invalid_phase", status=400)
promoted_to_scoreboard = locked_session.status == GameSession.Status.REVEAL leaderboard = _build_leaderboard(session)
if promoted_to_scoreboard:
locked_session.status = GameSession.Status.SCOREBOARD
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
if promoted_to_scoreboard:
sync_broadcast_phase_event(
session.code,
"phase.scoreboard",
{"leaderboard": list(leaderboard), "current_round": locked_session.current_round},
)
return JsonResponse( return JsonResponse(
{ {
"session": { "session": {
"code": session.code, "code": session.code,
"status": locked_session.status, "status": session.status,
"current_round": locked_session.current_round, "current_round": session.current_round,
}, },
"leaderboard": leaderboard, "leaderboard": leaderboard,
} }
@@ -1081,18 +1068,63 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
if locked_session.status != GameSession.Status.SCOREBOARD: if locked_session.status != GameSession.Status.SCOREBOARD:
return api_error(request, code="next_round_invalid_phase", status=400) return api_error(request, code="next_round_invalid_phase", status=400)
previous_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
if previous_round_config is None:
return api_error(request, code="round_config_missing", status=400)
locked_session.current_round += 1 locked_session.current_round += 1
locked_session.status = GameSession.Status.LOBBY next_round_config = RoundConfig.objects.create(
session=locked_session,
number=locked_session.current_round,
category=previous_round_config.category,
lie_seconds=previous_round_config.lie_seconds,
guess_seconds=previous_round_config.guess_seconds,
points_correct=previous_round_config.points_correct,
points_bluff=previous_round_config.points_bluff,
)
try:
round_question = _select_round_question(locked_session, next_round_config)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["current_round", "status"]) locked_session.save(update_fields=["current_round", "status"])
lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question)
sync_broadcast_phase_event(
locked_session.code,
"phase.lie_started",
lie_started_payload,
)
return JsonResponse( return JsonResponse(
{ {
"session": { "session": {
"code": session.code, "code": locked_session.code,
"status": GameSession.Status.LOBBY, "status": locked_session.status,
"current_round": locked_session.current_round, "current_round": locked_session.current_round,
} },
"round": {
"number": next_round_config.number,
"category": {
"slug": next_round_config.category.slug,
"name": next_round_config.category.name,
},
},
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": next_round_config.lie_seconds,
},
} }
) )