feat(lobby): align canonical round flow for issue 287
This commit is contained in:
@@ -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 |
|
||||
| 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 |
|
||||
| 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` | `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 |
|
||||
|
||||
## Flow-log (happy path)
|
||||
@@ -17,5 +17,6 @@
|
||||
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.
|
||||
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`.
|
||||
7. `next round` starter næste runde direkte i `lie` med nyt konkret spørgsmål; ingen mellem-hop tilbage til `lobby`.
|
||||
|
||||
@@ -19,6 +19,7 @@ from fupogfakta.models import (
|
||||
Question,
|
||||
RoundConfig,
|
||||
RoundQuestion,
|
||||
ScoreEvent,
|
||||
)
|
||||
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.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.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")
|
||||
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")
|
||||
|
||||
@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.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||
mock_sync_broadcast_phase_event.reset_mock()
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
@@ -1075,12 +1104,24 @@ class RevealRoundFlowTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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["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.assertEqual(self.session.status, GameSession.Status.LOBBY)
|
||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||
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):
|
||||
self.session.status = GameSession.Status.SCOREBOARD
|
||||
|
||||
112
lobby/views.py
112
lobby/views.py
@@ -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]:
|
||||
deduped_answers = list(round_question.mixed_answers or [])
|
||||
if deduped_answers:
|
||||
@@ -532,20 +546,12 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session.status = GameSession.Status.LIE
|
||||
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(
|
||||
session.code,
|
||||
"phase.lie_started",
|
||||
{
|
||||
"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,
|
||||
},
|
||||
lie_started_payload,
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
@@ -567,7 +573,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
"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(),
|
||||
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
|
||||
},
|
||||
"config": {
|
||||
"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:
|
||||
return api_error(request, code="host_only_view_scoreboard", status=403)
|
||||
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status not in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD}:
|
||||
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
||||
|
||||
promoted_to_scoreboard = locked_session.status == GameSession.Status.REVEAL
|
||||
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},
|
||||
)
|
||||
session = _maybe_promote_reveal_to_scoreboard(session)
|
||||
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
|
||||
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
||||
|
||||
leaderboard = _build_leaderboard(session)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": locked_session.status,
|
||||
"current_round": locked_session.current_round,
|
||||
"status": session.status,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"leaderboard": leaderboard,
|
||||
}
|
||||
@@ -1081,18 +1068,63 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if locked_session.status != GameSession.Status.SCOREBOARD:
|
||||
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.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"])
|
||||
|
||||
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(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": GameSession.Status.LOBBY,
|
||||
"code": locked_session.code,
|
||||
"status": locked_session.status,
|
||||
"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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user