Compare commits
45 Commits
8c0a561a64
...
dev/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| 21e390d200 | |||
| df9b6d192c | |||
| 702f130de2 | |||
| 92f2cda83a | |||
| d080f05661 | |||
| e246bd648f | |||
| 06e4ccac61 | |||
| 3c9214178e | |||
| feddd910eb | |||
| dd615796f4 | |||
| d2cdf16322 | |||
| 101c3f9c26 | |||
| 65eb5685f7 | |||
| 8a70645fda | |||
| 2cd8d940f9 | |||
| 72bc5997ff | |||
| c9e64bc8a8 | |||
| 1c7f1e7c53 | |||
| 03850b5ed5 | |||
| 16c9cf6b57 | |||
| c45f04f9f1 | |||
| 319038555a | |||
| e318711148 | |||
| a9c6e4fd79 | |||
| 7eb3507934 | |||
| dfa197b33b | |||
| fefc5ecd56 | |||
| 94f940e5d8 | |||
| a102a72a77 | |||
| d272e35a79 | |||
| 8a07433f11 | |||
| 9baade0105 | |||
| 35e2d09ee3 | |||
| a916da12a7 | |||
| 7f20cb3bf9 | |||
| f736f4f74e | |||
| 8247787404 | |||
| 6722be43d4 | |||
| 212549373b | |||
| 47659ed673 | |||
|
|
c8750af4d8 | ||
| 44e480931b | |||
| 1839b30e0a | |||
| 542d326615 | |||
| d36d256daf |
33
docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md
Normal file
33
docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Issue #310 — Host transition idempotency and error catalog
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This artifact hardens the two host-owned scoreboard exits in the canonical gameplay flow:
|
||||||
|
|
||||||
|
- `POST /lobby/sessions/{code}/rounds/next`
|
||||||
|
- `POST /lobby/sessions/{code}/finish`
|
||||||
|
|
||||||
|
The goal is retry-safe host behavior when the scoreboard transition already succeeded server-side but the client retries because of a duplicate click, timeout, or lost response.
|
||||||
|
|
||||||
|
## Transition contract
|
||||||
|
|
||||||
|
| Endpoint | First valid transition | Idempotent replay state | Replay result | Broadcast behavior | Still-invalid states |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard -> lie` | `lie` with persisted current-round bootstrap (`RoundConfig` + `RoundQuestion`) | `200 OK` with the same canonical next-round payload shape | `phase.lie_started` fires only on the first transition | `lobby`, `guess`, `reveal`, `finished` → `next_round_invalid_phase` |
|
||||||
|
| `POST /lobby/sessions/{code}/finish` | `scoreboard -> finished` | `finished` | `200 OK` with the same final leaderboard payload shape | `phase.game_over` fires only on the first transition | `lobby`, `lie`, `guess`, `reveal` → `finish_game_invalid_phase` |
|
||||||
|
|
||||||
|
## Error catalog notes
|
||||||
|
|
||||||
|
No new backend error codes were introduced for this slice.
|
||||||
|
|
||||||
|
The contract change is behavioral:
|
||||||
|
|
||||||
|
- `next_round_invalid_phase` now means the session is in a phase where the scoreboard → next-round transition has **not** already been completed, or the expected bootstrap artifact for the already-started round is missing.
|
||||||
|
- `finish_game_invalid_phase` now means the session is in a phase where the scoreboard → finish transition has **not** already been completed.
|
||||||
|
- Successful replays are returned as normal `200 OK` canonical responses instead of phase errors.
|
||||||
|
|
||||||
|
## Acceptance evidence
|
||||||
|
|
||||||
|
- Repeated `rounds/next` calls after a successful scoreboard exit return the same canonical lie/bootstrap payload without incrementing the round twice.
|
||||||
|
- Repeated `finish` calls after a successful scoreboard exit return the same finished leaderboard payload without rebroadcasting game-over.
|
||||||
|
- Wrong-phase calls outside those replay states still return the existing shared error codes.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-03-17 08:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fupogfakta', '0006_merge_20260315_1249'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='roundconfig',
|
||||||
|
name='started_from_scoreboard',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -83,6 +83,7 @@ class RoundConfig(models.Model):
|
|||||||
points_bluff = models.IntegerField(default=2)
|
points_bluff = models.IntegerField(default=2)
|
||||||
lie_seconds = models.PositiveIntegerField(default=45)
|
lie_seconds = models.PositiveIntegerField(default=45)
|
||||||
guess_seconds = models.PositiveIntegerField(default=30)
|
guess_seconds = models.PositiveIntegerField(default=30)
|
||||||
|
started_from_scoreboard = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (("session", "number"),)
|
unique_together = (("session", "number"),)
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ def build_player_ref(player: Player | None) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_round_question_payload(round_question: RoundQuestion | None) -> dict | None:
|
||||||
|
if round_question is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": round_question.id,
|
||||||
|
"round_number": round_question.round_number,
|
||||||
|
"prompt": round_question.question.prompt,
|
||||||
|
"shown_at": round_question.shown_at.isoformat(),
|
||||||
|
"answers": [{"text": text} for text in (round_question.mixed_answers or [])],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
|
def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
|
||||||
if round_question is None:
|
if round_question is None:
|
||||||
return None
|
return None
|
||||||
@@ -68,3 +81,195 @@ def build_lie_started_payload(session: GameSession, round_config: RoundConfig, r
|
|||||||
"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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
|
||||||
|
status = session.status
|
||||||
|
in_lobby = status == GameSession.Status.LOBBY
|
||||||
|
in_lie = status == GameSession.Status.LIE
|
||||||
|
in_guess = status == GameSession.Status.GUESS
|
||||||
|
in_scoreboard = status == GameSession.Status.SCOREBOARD
|
||||||
|
in_finished = status == GameSession.Status.FINISHED
|
||||||
|
|
||||||
|
min_players_reached = players_count >= 3
|
||||||
|
max_players_allowed = players_count <= 5
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"current_phase": status,
|
||||||
|
"round_number": session.current_round,
|
||||||
|
"players_count": players_count,
|
||||||
|
"constraints": {
|
||||||
|
"min_players_to_start": 3,
|
||||||
|
"max_players_mvp": 5,
|
||||||
|
"min_players_reached": min_players_reached,
|
||||||
|
"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": {
|
||||||
|
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
|
||||||
|
"can_show_question": False,
|
||||||
|
"can_mix_answers": False,
|
||||||
|
"can_calculate_scores": False,
|
||||||
|
"can_reveal_scoreboard": False,
|
||||||
|
"can_start_next_round": in_scoreboard,
|
||||||
|
"can_finish_game": in_scoreboard,
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"can_join": status in {
|
||||||
|
GameSession.Status.LOBBY,
|
||||||
|
GameSession.Status.LIE,
|
||||||
|
GameSession.Status.GUESS,
|
||||||
|
GameSession.Status.REVEAL,
|
||||||
|
GameSession.Status.SCOREBOARD,
|
||||||
|
},
|
||||||
|
"can_submit_lie": in_lie and has_round_question,
|
||||||
|
"can_submit_guess": in_guess and has_round_question,
|
||||||
|
"can_view_final_result": in_finished,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_session_detail_gameplay_payload(
|
||||||
|
session: GameSession,
|
||||||
|
*,
|
||||||
|
current_round_question: RoundQuestion | None,
|
||||||
|
players_count: int,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"round_question": build_round_question_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
|
||||||
|
else None,
|
||||||
|
"scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"]
|
||||||
|
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
|
||||||
|
else None,
|
||||||
|
"phase_view_model": build_phase_view_model(
|
||||||
|
session,
|
||||||
|
players_count=players_count,
|
||||||
|
has_round_question=bool(current_round_question),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_start_round_response(
|
||||||
|
session: GameSession,
|
||||||
|
round_config: RoundConfig,
|
||||||
|
round_question: RoundQuestion,
|
||||||
|
) -> dict:
|
||||||
|
lie_started_payload = build_lie_started_payload(session, round_config, round_question)
|
||||||
|
return {
|
||||||
|
"session": {
|
||||||
|
"code": session.code,
|
||||||
|
"status": session.status,
|
||||||
|
"current_round": session.current_round,
|
||||||
|
},
|
||||||
|
"round": {
|
||||||
|
"number": round_config.number,
|
||||||
|
"category": {
|
||||||
|
"slug": round_config.category.slug,
|
||||||
|
"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_started_payload["lie_deadline_at"],
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"lie_seconds": round_config.lie_seconds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
|
||||||
|
return {
|
||||||
|
"round_question_id": round_question.id,
|
||||||
|
"prompt": round_question.question.prompt,
|
||||||
|
"shown_at": round_question.shown_at.isoformat(),
|
||||||
|
"lie_deadline_at": lie_deadline_at,
|
||||||
|
"lie_seconds": lie_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
|
||||||
|
return {
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"lie_seconds": lie_seconds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_start_next_round_response(
|
||||||
|
session: GameSession,
|
||||||
|
round_config: RoundConfig,
|
||||||
|
round_question: RoundQuestion,
|
||||||
|
) -> dict:
|
||||||
|
return build_start_round_response(session, round_config, round_question)
|
||||||
|
|
||||||
|
|
||||||
|
def build_start_next_round_phase_event(
|
||||||
|
session: GameSession,
|
||||||
|
round_config: RoundConfig,
|
||||||
|
round_question: RoundQuestion,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"name": "phase.lie_started",
|
||||||
|
"payload": build_lie_started_payload(session, round_config, round_question),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"name": "phase.scoreboard",
|
||||||
|
"payload": {
|
||||||
|
"leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session),
|
||||||
|
"current_round": session.current_round,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict:
|
||||||
|
return {
|
||||||
|
"session": {
|
||||||
|
"code": session.code,
|
||||||
|
"status": session.status,
|
||||||
|
"current_round": session.current_round,
|
||||||
|
},
|
||||||
|
"leaderboard": leaderboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_finish_game_phase_event(session: GameSession) -> dict:
|
||||||
|
leaderboard = build_leaderboard(session)
|
||||||
|
winner = leaderboard[0] if leaderboard else None
|
||||||
|
return {
|
||||||
|
"name": "phase.game_over",
|
||||||
|
"payload": {"winner": winner, "leaderboard": leaderboard},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_finish_game_response(session: GameSession) -> dict:
|
||||||
|
finish_event = build_finish_game_phase_event(session)
|
||||||
|
return {
|
||||||
|
"session": {
|
||||||
|
"code": session.code,
|
||||||
|
"status": GameSession.Status.FINISHED,
|
||||||
|
"current_round": session.current_round,
|
||||||
|
},
|
||||||
|
"winner": finish_event["payload"]["winner"],
|
||||||
|
"leaderboard": finish_event["payload"]["leaderboard"],
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,59 @@
|
|||||||
import random
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .models import GameSession, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
||||||
|
from .payloads import (
|
||||||
|
build_finish_game_phase_event,
|
||||||
|
build_finish_game_response,
|
||||||
|
build_lie_started_payload,
|
||||||
|
build_question_shown_payload,
|
||||||
|
build_question_shown_response,
|
||||||
|
build_reveal_scoreboard_response,
|
||||||
|
build_scoreboard_phase_event,
|
||||||
|
build_start_next_round_phase_event,
|
||||||
|
build_start_next_round_response,
|
||||||
|
build_start_round_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_current_round_question(session: GameSession) -> RoundQuestion | None:
|
@dataclass(frozen=True)
|
||||||
|
class RoundTransitionResult:
|
||||||
|
session: GameSession
|
||||||
|
round_config: RoundConfig
|
||||||
|
round_question: RoundQuestion
|
||||||
|
should_broadcast: bool
|
||||||
|
response_payload: dict[str, Any]
|
||||||
|
phase_event_name: str | None = None
|
||||||
|
phase_event_payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FinishGameResult:
|
||||||
|
session: GameSession
|
||||||
|
should_broadcast: bool
|
||||||
|
response_payload: dict[str, Any]
|
||||||
|
phase_event_name: str | None = None
|
||||||
|
phase_event_payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScoreboardTransitionResult:
|
||||||
|
session: GameSession
|
||||||
|
leaderboard: list[dict]
|
||||||
|
should_broadcast: bool
|
||||||
|
response_payload: dict[str, Any] | None = None
|
||||||
|
phase_event_name: str | None = None
|
||||||
|
phase_event_payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_round_question(session: GameSession, round_number: int) -> RoundQuestion | None:
|
||||||
return (
|
return (
|
||||||
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
|
RoundQuestion.objects.filter(session=session, round_number=round_number)
|
||||||
.select_related("question")
|
.select_related("question")
|
||||||
.order_by("-id")
|
.order_by("-id")
|
||||||
.first()
|
.first()
|
||||||
@@ -13,9 +61,37 @@ def get_current_round_question(session: GameSession) -> RoundQuestion | None:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion:
|
def get_current_round_question(session: GameSession) -> RoundQuestion | None:
|
||||||
existing_round_question = get_current_round_question(session)
|
return get_round_question(session, session.current_round)
|
||||||
if existing_round_question is not None:
|
|
||||||
|
|
||||||
|
|
||||||
|
def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion:
|
||||||
|
Guess.objects.filter(round_question=round_question).delete()
|
||||||
|
LieAnswer.objects.filter(round_question=round_question).delete()
|
||||||
|
|
||||||
|
update_fields: list[str] = []
|
||||||
|
if round_question.mixed_answers:
|
||||||
|
round_question.mixed_answers = []
|
||||||
|
update_fields.append("mixed_answers")
|
||||||
|
|
||||||
|
round_question.shown_at = timezone.now()
|
||||||
|
update_fields.append("shown_at")
|
||||||
|
|
||||||
|
round_question.save(update_fields=update_fields)
|
||||||
|
return round_question
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def select_round_question(
|
||||||
|
session: GameSession,
|
||||||
|
round_config: RoundConfig,
|
||||||
|
*,
|
||||||
|
round_number: int | None = None,
|
||||||
|
) -> RoundQuestion:
|
||||||
|
target_round_number = session.current_round if round_number is None else round_number
|
||||||
|
existing_round_question = get_round_question(session, target_round_number)
|
||||||
|
if existing_round_question is not None and existing_round_question.question.category_id == round_config.category_id:
|
||||||
return existing_round_question
|
return existing_round_question
|
||||||
|
|
||||||
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
|
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
|
||||||
@@ -28,9 +104,15 @@ def select_round_question(session: GameSession, round_config: RoundConfig) -> Ro
|
|||||||
raise ValueError("no_available_questions")
|
raise ValueError("no_available_questions")
|
||||||
|
|
||||||
question = random.choice(list(available_questions))
|
question = random.choice(list(available_questions))
|
||||||
|
if existing_round_question is not None:
|
||||||
|
existing_round_question.question = question
|
||||||
|
existing_round_question.correct_answer = question.correct_answer
|
||||||
|
existing_round_question.save(update_fields=["question", "correct_answer"])
|
||||||
|
return existing_round_question
|
||||||
|
|
||||||
return RoundQuestion.objects.create(
|
return RoundQuestion.objects.create(
|
||||||
session=session,
|
session=session,
|
||||||
round_number=session.current_round,
|
round_number=target_round_number,
|
||||||
question=question,
|
question=question,
|
||||||
correct_answer=question.correct_answer,
|
correct_answer=question.correct_answer,
|
||||||
)
|
)
|
||||||
@@ -61,6 +143,288 @@ def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def start_round(session: GameSession, category_slug: str) -> RoundTransitionResult:
|
||||||
|
try:
|
||||||
|
category = Category.objects.get(slug=category_slug, is_active=True)
|
||||||
|
except Category.DoesNotExist:
|
||||||
|
raise ValueError("category_not_found")
|
||||||
|
|
||||||
|
if not Question.objects.filter(category=category, is_active=True).exists():
|
||||||
|
raise ValueError("category_has_no_questions")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
|
if locked_session.status != GameSession.Status.LOBBY:
|
||||||
|
raise ValueError("round_start_invalid_phase")
|
||||||
|
|
||||||
|
if RoundConfig.objects.filter(session=locked_session, number=locked_session.current_round).exists():
|
||||||
|
raise ValueError("round_already_configured")
|
||||||
|
|
||||||
|
round_config = RoundConfig(
|
||||||
|
session=locked_session,
|
||||||
|
number=locked_session.current_round,
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
|
||||||
|
round_question = select_round_question(locked_session, round_config)
|
||||||
|
|
||||||
|
round_config.save()
|
||||||
|
locked_session.status = GameSession.Status.LIE
|
||||||
|
locked_session.save(update_fields=["status"])
|
||||||
|
|
||||||
|
phase_event = {
|
||||||
|
"name": "phase.lie_started",
|
||||||
|
"payload": build_lie_started_payload(locked_session, round_config, round_question),
|
||||||
|
}
|
||||||
|
return RoundTransitionResult(
|
||||||
|
session=locked_session,
|
||||||
|
round_config=round_config,
|
||||||
|
round_question=round_question,
|
||||||
|
should_broadcast=True,
|
||||||
|
response_payload=build_start_round_response(locked_session, round_config, round_question),
|
||||||
|
phase_event_name=phase_event["name"],
|
||||||
|
phase_event_payload=phase_event["payload"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def show_question(session: GameSession) -> RoundTransitionResult:
|
||||||
|
if session.status != GameSession.Status.LIE:
|
||||||
|
raise ValueError("show_question_invalid_phase")
|
||||||
|
|
||||||
|
try:
|
||||||
|
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
|
||||||
|
except RoundConfig.DoesNotExist:
|
||||||
|
raise ValueError("round_config_missing")
|
||||||
|
|
||||||
|
round_question = get_current_round_question(session)
|
||||||
|
if round_question is None:
|
||||||
|
round_question = select_round_question(session, round_config)
|
||||||
|
|
||||||
|
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||||
|
lie_deadline_iso = lie_deadline_at.isoformat()
|
||||||
|
phase_event = {
|
||||||
|
"name": "phase.question_shown",
|
||||||
|
"payload": build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds),
|
||||||
|
}
|
||||||
|
return RoundTransitionResult(
|
||||||
|
session=session,
|
||||||
|
round_config=round_config,
|
||||||
|
round_question=round_question,
|
||||||
|
should_broadcast=True,
|
||||||
|
response_payload=build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds),
|
||||||
|
phase_event_name=phase_event["name"],
|
||||||
|
phase_event_payload=phase_event["payload"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_next_round(session: GameSession) -> RoundTransitionResult:
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
|
next_round_config = None
|
||||||
|
round_question = None
|
||||||
|
should_broadcast = False
|
||||||
|
|
||||||
|
phase_event_name = None
|
||||||
|
phase_event_payload = None
|
||||||
|
|
||||||
|
if locked_session.status == GameSession.Status.SCOREBOARD:
|
||||||
|
previous_round_config = RoundConfig.objects.filter(
|
||||||
|
session=locked_session,
|
||||||
|
number=locked_session.current_round,
|
||||||
|
).select_related("category").first()
|
||||||
|
if previous_round_config is None:
|
||||||
|
raise ValueError("round_config_missing")
|
||||||
|
|
||||||
|
next_round_number = locked_session.current_round + 1
|
||||||
|
next_round_config, _created = RoundConfig.objects.get_or_create(
|
||||||
|
session=locked_session,
|
||||||
|
number=next_round_number,
|
||||||
|
defaults={
|
||||||
|
"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,
|
||||||
|
"started_from_scoreboard": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
round_config_update_fields: list[str] = []
|
||||||
|
if next_round_config.category_id != previous_round_config.category_id:
|
||||||
|
next_round_config.category = previous_round_config.category
|
||||||
|
round_config_update_fields.append("category")
|
||||||
|
if next_round_config.lie_seconds != previous_round_config.lie_seconds:
|
||||||
|
next_round_config.lie_seconds = previous_round_config.lie_seconds
|
||||||
|
round_config_update_fields.append("lie_seconds")
|
||||||
|
if next_round_config.guess_seconds != previous_round_config.guess_seconds:
|
||||||
|
next_round_config.guess_seconds = previous_round_config.guess_seconds
|
||||||
|
round_config_update_fields.append("guess_seconds")
|
||||||
|
if next_round_config.points_correct != previous_round_config.points_correct:
|
||||||
|
next_round_config.points_correct = previous_round_config.points_correct
|
||||||
|
round_config_update_fields.append("points_correct")
|
||||||
|
if next_round_config.points_bluff != previous_round_config.points_bluff:
|
||||||
|
next_round_config.points_bluff = previous_round_config.points_bluff
|
||||||
|
round_config_update_fields.append("points_bluff")
|
||||||
|
if not next_round_config.started_from_scoreboard:
|
||||||
|
next_round_config.started_from_scoreboard = True
|
||||||
|
round_config_update_fields.append("started_from_scoreboard")
|
||||||
|
if round_config_update_fields:
|
||||||
|
next_round_config.save(update_fields=round_config_update_fields)
|
||||||
|
|
||||||
|
locked_session.current_round = next_round_number
|
||||||
|
|
||||||
|
round_question = reset_round_question_bootstrap_state(
|
||||||
|
select_round_question(locked_session, next_round_config, round_number=next_round_number)
|
||||||
|
)
|
||||||
|
|
||||||
|
locked_session.status = GameSession.Status.LIE
|
||||||
|
locked_session.save(update_fields=["current_round", "status"])
|
||||||
|
should_broadcast = True
|
||||||
|
phase_event = build_start_next_round_phase_event(locked_session, next_round_config, round_question)
|
||||||
|
phase_event_name = phase_event["name"]
|
||||||
|
phase_event_payload = phase_event["payload"]
|
||||||
|
elif locked_session.status == GameSession.Status.LIE:
|
||||||
|
if locked_session.current_round <= 1:
|
||||||
|
raise ValueError("next_round_invalid_phase")
|
||||||
|
|
||||||
|
next_round_config = RoundConfig.objects.filter(
|
||||||
|
session=locked_session,
|
||||||
|
number=locked_session.current_round,
|
||||||
|
).select_related("category").first()
|
||||||
|
round_question = get_current_round_question(locked_session)
|
||||||
|
if (
|
||||||
|
next_round_config is None
|
||||||
|
or not next_round_config.started_from_scoreboard
|
||||||
|
or round_question is None
|
||||||
|
):
|
||||||
|
raise ValueError("next_round_invalid_phase")
|
||||||
|
else:
|
||||||
|
raise ValueError("next_round_invalid_phase")
|
||||||
|
|
||||||
|
return RoundTransitionResult(
|
||||||
|
session=locked_session,
|
||||||
|
round_config=next_round_config,
|
||||||
|
round_question=round_question,
|
||||||
|
should_broadcast=should_broadcast,
|
||||||
|
response_payload=build_start_next_round_response(
|
||||||
|
locked_session,
|
||||||
|
next_round_config,
|
||||||
|
round_question,
|
||||||
|
),
|
||||||
|
phase_event_name=phase_event_name,
|
||||||
|
phase_event_payload=phase_event_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def finish_game(session: GameSession) -> FinishGameResult:
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
|
should_broadcast = False
|
||||||
|
phase_event_name = None
|
||||||
|
phase_event_payload = None
|
||||||
|
|
||||||
|
if locked_session.status == GameSession.Status.SCOREBOARD:
|
||||||
|
locked_session.status = GameSession.Status.FINISHED
|
||||||
|
locked_session.save(update_fields=["status"])
|
||||||
|
should_broadcast = True
|
||||||
|
phase_event = build_finish_game_phase_event(locked_session)
|
||||||
|
phase_event_name = phase_event["name"]
|
||||||
|
phase_event_payload = phase_event["payload"]
|
||||||
|
elif locked_session.status != GameSession.Status.FINISHED:
|
||||||
|
raise ValueError("finish_game_invalid_phase")
|
||||||
|
|
||||||
|
return FinishGameResult(
|
||||||
|
session=locked_session,
|
||||||
|
should_broadcast=should_broadcast,
|
||||||
|
response_payload=build_finish_game_response(locked_session),
|
||||||
|
phase_event_name=phase_event_name,
|
||||||
|
phase_event_payload=phase_event_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionResult:
|
||||||
|
if session.status != GameSession.Status.REVEAL:
|
||||||
|
leaderboard = list(
|
||||||
|
Player.objects.filter(session=session)
|
||||||
|
.order_by("-score", "nickname")
|
||||||
|
.values("id", "nickname", "score")
|
||||||
|
)
|
||||||
|
return ScoreboardTransitionResult(
|
||||||
|
session=session,
|
||||||
|
leaderboard=leaderboard,
|
||||||
|
should_broadcast=False,
|
||||||
|
response_payload=build_reveal_scoreboard_response(session, leaderboard),
|
||||||
|
)
|
||||||
|
|
||||||
|
current_round_question = get_current_round_question(session)
|
||||||
|
if current_round_question is None:
|
||||||
|
leaderboard = list(
|
||||||
|
Player.objects.filter(session=session)
|
||||||
|
.order_by("-score", "nickname")
|
||||||
|
.values("id", "nickname", "score")
|
||||||
|
)
|
||||||
|
return ScoreboardTransitionResult(
|
||||||
|
session=session,
|
||||||
|
leaderboard=leaderboard,
|
||||||
|
should_broadcast=False,
|
||||||
|
response_payload=build_reveal_scoreboard_response(session, leaderboard),
|
||||||
|
)
|
||||||
|
|
||||||
|
players_count = Player.objects.filter(session=session).count()
|
||||||
|
guess_count = Guess.objects.filter(round_question=current_round_question).count()
|
||||||
|
has_score_events = ScoreEvent.objects.filter(
|
||||||
|
session=session,
|
||||||
|
meta__round_question_id=current_round_question.id,
|
||||||
|
).exists()
|
||||||
|
reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count)
|
||||||
|
if not reveal_is_resolved:
|
||||||
|
leaderboard = list(
|
||||||
|
Player.objects.filter(session=session)
|
||||||
|
.order_by("-score", "nickname")
|
||||||
|
.values("id", "nickname", "score")
|
||||||
|
)
|
||||||
|
return ScoreboardTransitionResult(
|
||||||
|
session=session,
|
||||||
|
leaderboard=leaderboard,
|
||||||
|
should_broadcast=False,
|
||||||
|
response_payload=build_reveal_scoreboard_response(session, leaderboard),
|
||||||
|
)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||||
|
if locked_session.status != GameSession.Status.REVEAL:
|
||||||
|
scoreboard_session = locked_session
|
||||||
|
should_broadcast = False
|
||||||
|
else:
|
||||||
|
locked_session.status = GameSession.Status.SCOREBOARD
|
||||||
|
locked_session.save(update_fields=["status"])
|
||||||
|
scoreboard_session = locked_session
|
||||||
|
should_broadcast = True
|
||||||
|
|
||||||
|
leaderboard = list(
|
||||||
|
Player.objects.filter(session=scoreboard_session)
|
||||||
|
.order_by("-score", "nickname")
|
||||||
|
.values("id", "nickname", "score")
|
||||||
|
)
|
||||||
|
phase_event_name = None
|
||||||
|
phase_event_payload = None
|
||||||
|
if should_broadcast:
|
||||||
|
phase_event = build_scoreboard_phase_event(scoreboard_session, leaderboard)
|
||||||
|
phase_event_name = phase_event["name"]
|
||||||
|
phase_event_payload = phase_event["payload"]
|
||||||
|
return ScoreboardTransitionResult(
|
||||||
|
session=scoreboard_session,
|
||||||
|
leaderboard=leaderboard,
|
||||||
|
should_broadcast=should_broadcast,
|
||||||
|
response_payload=build_reveal_scoreboard_response(scoreboard_session, leaderboard),
|
||||||
|
phase_event_name=phase_event_name,
|
||||||
|
phase_event_payload=phase_event_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_scores(
|
def resolve_scores(
|
||||||
session: GameSession,
|
session: GameSession,
|
||||||
round_question: RoundQuestion,
|
round_question: RoundQuestion,
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
||||||
from fupogfakta.payloads import build_lie_started_payload, build_reveal_payload
|
from fupogfakta.payloads import (
|
||||||
from fupogfakta.services import get_current_round_question, prepare_mixed_answers, resolve_scores, select_round_question
|
build_lie_started_payload,
|
||||||
|
build_phase_view_model,
|
||||||
|
build_reveal_payload,
|
||||||
|
build_round_question_payload,
|
||||||
|
build_session_detail_gameplay_payload,
|
||||||
|
)
|
||||||
|
from fupogfakta.services import (
|
||||||
|
finish_game,
|
||||||
|
get_current_round_question,
|
||||||
|
prepare_mixed_answers,
|
||||||
|
promote_reveal_to_scoreboard,
|
||||||
|
resolve_scores,
|
||||||
|
select_round_question,
|
||||||
|
start_next_round,
|
||||||
|
)
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -63,6 +79,232 @@ class FupOgFaktaExtractionSliceTests(TestCase):
|
|||||||
round_question.refresh_from_db()
|
round_question.refresh_from_db()
|
||||||
self.assertEqual(round_question.mixed_answers, answers)
|
self.assertEqual(round_question.mixed_answers, answers)
|
||||||
|
|
||||||
|
def test_start_next_round_moves_scoreboard_transition_into_service(self):
|
||||||
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
|
||||||
|
result = start_next_round(self.session)
|
||||||
|
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
self.assertTrue(result.should_broadcast)
|
||||||
|
self.assertEqual(result.session.status, GameSession.Status.LIE)
|
||||||
|
self.assertEqual(result.session.current_round, 2)
|
||||||
|
self.assertEqual(result.round_config.number, 2)
|
||||||
|
self.assertTrue(result.round_config.started_from_scoreboard)
|
||||||
|
self.assertEqual(result.round_question.round_number, 2)
|
||||||
|
|
||||||
|
def test_start_next_round_rejects_plain_lie_without_scoreboard_marker(self):
|
||||||
|
self.session.status = GameSession.Status.LIE
|
||||||
|
self.session.current_round = 2
|
||||||
|
self.session.save(update_fields=["status", "current_round"])
|
||||||
|
RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False)
|
||||||
|
RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=self.question_two,
|
||||||
|
correct_answer=self.question_two.correct_answer,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesMessage(ValueError, "next_round_invalid_phase"):
|
||||||
|
start_next_round(self.session)
|
||||||
|
|
||||||
|
def test_start_next_round_refreshes_shown_at_for_reused_bootstrap_question(self):
|
||||||
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
stale_shown_at = timezone.now() - timedelta(minutes=10)
|
||||||
|
stale_round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=self.question_two,
|
||||||
|
correct_answer=self.question_two.correct_answer,
|
||||||
|
shown_at=stale_shown_at,
|
||||||
|
mixed_answers=["Stale truth", "Stale lie"],
|
||||||
|
)
|
||||||
|
LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Stale lie")
|
||||||
|
Guess.objects.create(
|
||||||
|
round_question=stale_round_question,
|
||||||
|
player=self.bob,
|
||||||
|
selected_text="Stale truth",
|
||||||
|
is_correct=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
before_transition = timezone.now()
|
||||||
|
result = start_next_round(self.session)
|
||||||
|
after_transition = timezone.now()
|
||||||
|
|
||||||
|
stale_round_question.refresh_from_db()
|
||||||
|
self.assertEqual(result.round_question.id, stale_round_question.id)
|
||||||
|
self.assertGreaterEqual(stale_round_question.shown_at, before_transition)
|
||||||
|
self.assertLessEqual(stale_round_question.shown_at, after_transition)
|
||||||
|
self.assertNotEqual(stale_round_question.shown_at, stale_shown_at)
|
||||||
|
self.assertEqual(result.response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat())
|
||||||
|
expected_deadline = stale_round_question.shown_at + timedelta(seconds=result.round_config.lie_seconds)
|
||||||
|
self.assertEqual(result.response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat())
|
||||||
|
self.assertGreater(expected_deadline, before_transition)
|
||||||
|
self.assertEqual(stale_round_question.mixed_answers, [])
|
||||||
|
self.assertEqual(stale_round_question.lies.count(), 0)
|
||||||
|
self.assertEqual(stale_round_question.guesses.count(), 0)
|
||||||
|
|
||||||
|
def test_start_next_round_reuses_existing_bootstrap_round_config_with_fresh_canonical_values(self):
|
||||||
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
stale_category = Category.objects.create(name="Sport", slug="sport", is_active=True)
|
||||||
|
stale_round_config = RoundConfig.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
number=2,
|
||||||
|
category=stale_category,
|
||||||
|
lie_seconds=12,
|
||||||
|
guess_seconds=18,
|
||||||
|
points_correct=9,
|
||||||
|
points_bluff=7,
|
||||||
|
started_from_scoreboard=False,
|
||||||
|
)
|
||||||
|
stale_round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=self.question_two,
|
||||||
|
correct_answer=self.question_two.correct_answer,
|
||||||
|
shown_at=timezone.now() - timedelta(minutes=10),
|
||||||
|
mixed_answers=["Stale truth"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = start_next_round(self.session)
|
||||||
|
|
||||||
|
stale_round_config.refresh_from_db()
|
||||||
|
stale_round_question.refresh_from_db()
|
||||||
|
self.assertEqual(result.round_config.id, stale_round_config.id)
|
||||||
|
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
|
||||||
|
self.assertEqual(stale_round_config.category_id, self.round_config.category_id)
|
||||||
|
self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds)
|
||||||
|
self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds)
|
||||||
|
self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct)
|
||||||
|
self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff)
|
||||||
|
self.assertTrue(stale_round_config.started_from_scoreboard)
|
||||||
|
self.assertEqual(result.round_question.id, stale_round_question.id)
|
||||||
|
self.assertEqual(stale_round_question.mixed_answers, [])
|
||||||
|
|
||||||
|
def test_start_next_round_repairs_reused_bootstrap_question_when_category_drifted(self):
|
||||||
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=1,
|
||||||
|
question=self.question_one,
|
||||||
|
correct_answer=self.question_one.correct_answer,
|
||||||
|
)
|
||||||
|
stale_category = Category.objects.create(name="Sport drift", slug="sport-drift", is_active=True)
|
||||||
|
stale_question = Question.objects.create(
|
||||||
|
category=stale_category,
|
||||||
|
prompt="Hvem vandt EM i 1992?",
|
||||||
|
correct_answer="Danmark",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
stale_round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=stale_question,
|
||||||
|
correct_answer=stale_question.correct_answer,
|
||||||
|
shown_at=timezone.now() - timedelta(minutes=10),
|
||||||
|
mixed_answers=["Stale truth", "Stale lie"],
|
||||||
|
)
|
||||||
|
LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Tyskland")
|
||||||
|
Guess.objects.create(
|
||||||
|
round_question=stale_round_question,
|
||||||
|
player=self.bob,
|
||||||
|
selected_text="Stale truth",
|
||||||
|
is_correct=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = start_next_round(self.session)
|
||||||
|
|
||||||
|
stale_round_question.refresh_from_db()
|
||||||
|
self.assertEqual(result.round_question.id, stale_round_question.id)
|
||||||
|
self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id)
|
||||||
|
self.assertEqual(stale_round_question.question_id, self.question_two.id)
|
||||||
|
self.assertEqual(stale_round_question.correct_answer, self.question_two.correct_answer)
|
||||||
|
self.assertEqual(stale_round_question.mixed_answers, [])
|
||||||
|
self.assertEqual(stale_round_question.lies.count(), 0)
|
||||||
|
self.assertEqual(stale_round_question.guesses.count(), 0)
|
||||||
|
|
||||||
|
def test_start_next_round_does_not_reuse_previous_round_question_when_category_matches(self):
|
||||||
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
previous_round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=1,
|
||||||
|
question=self.question_one,
|
||||||
|
correct_answer=self.question_one.correct_answer,
|
||||||
|
mixed_answers=["1989", "1991"],
|
||||||
|
)
|
||||||
|
LieAnswer.objects.create(round_question=previous_round_question, player=self.alice, text="1991")
|
||||||
|
Guess.objects.create(
|
||||||
|
round_question=previous_round_question,
|
||||||
|
player=self.bob,
|
||||||
|
selected_text="1991",
|
||||||
|
is_correct=False,
|
||||||
|
fooled_player=self.alice,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = start_next_round(self.session)
|
||||||
|
|
||||||
|
previous_round_question.refresh_from_db()
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
self.assertEqual(self.session.current_round, 2)
|
||||||
|
self.assertEqual(result.round_question.round_number, 2)
|
||||||
|
self.assertNotEqual(result.round_question.id, previous_round_question.id)
|
||||||
|
self.assertEqual(result.round_question.question_id, self.question_two.id)
|
||||||
|
self.assertEqual(previous_round_question.round_number, 1)
|
||||||
|
self.assertEqual(previous_round_question.question_id, self.question_one.id)
|
||||||
|
self.assertEqual(previous_round_question.mixed_answers, ["1989", "1991"])
|
||||||
|
self.assertEqual(previous_round_question.lies.count(), 1)
|
||||||
|
self.assertEqual(previous_round_question.guesses.count(), 1)
|
||||||
|
|
||||||
|
def test_finish_game_moves_scoreboard_transition_into_service(self):
|
||||||
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
|
||||||
|
result = finish_game(self.session)
|
||||||
|
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
self.assertTrue(result.should_broadcast)
|
||||||
|
self.assertEqual(result.session.status, GameSession.Status.FINISHED)
|
||||||
|
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
||||||
|
|
||||||
|
def test_promote_reveal_to_scoreboard_moves_transition_into_service(self):
|
||||||
|
round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=1,
|
||||||
|
question=self.question_one,
|
||||||
|
correct_answer=self.question_one.correct_answer,
|
||||||
|
)
|
||||||
|
self.session.status = GameSession.Status.REVEAL
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
|
||||||
|
LieAnswer.objects.create(round_question=round_question, player=self.alice, text="Elbil")
|
||||||
|
Guess.objects.create(
|
||||||
|
round_question=round_question,
|
||||||
|
player=self.bob,
|
||||||
|
selected_text="Elbil",
|
||||||
|
is_correct=False,
|
||||||
|
fooled_player=self.alice,
|
||||||
|
)
|
||||||
|
ScoreEvent.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
player=self.alice,
|
||||||
|
delta=5,
|
||||||
|
reason="bluff_success",
|
||||||
|
meta={"round_question_id": round_question.id},
|
||||||
|
)
|
||||||
|
self.alice.score = 5
|
||||||
|
self.alice.save(update_fields=["score"])
|
||||||
|
|
||||||
|
result = promote_reveal_to_scoreboard(self.session)
|
||||||
|
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
self.assertTrue(result.should_broadcast)
|
||||||
|
self.assertEqual(result.session.status, GameSession.Status.SCOREBOARD)
|
||||||
|
self.assertEqual(result.leaderboard[0]["nickname"], self.alice.nickname)
|
||||||
|
|
||||||
def test_resolve_scores_applies_correct_and_bluff_points(self):
|
def test_resolve_scores_applies_correct_and_bluff_points(self):
|
||||||
round_question = RoundQuestion.objects.create(
|
round_question = RoundQuestion.objects.create(
|
||||||
session=self.session,
|
session=self.session,
|
||||||
@@ -117,11 +359,52 @@ class FupOgFaktaExtractionSliceTests(TestCase):
|
|||||||
fooled_player=self.bob,
|
fooled_player=self.bob,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
round_question_payload = build_round_question_payload(round_question)
|
||||||
lie_payload = build_lie_started_payload(self.session, self.round_config, round_question)
|
lie_payload = build_lie_started_payload(self.session, self.round_config, round_question)
|
||||||
reveal_payload = build_reveal_payload(round_question)
|
reveal_payload = build_reveal_payload(round_question)
|
||||||
|
phase_view_model = build_phase_view_model(
|
||||||
|
self.session,
|
||||||
|
players_count=3,
|
||||||
|
has_round_question=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(round_question_payload["prompt"], self.question_one.prompt)
|
||||||
|
self.assertEqual(round_question_payload["answers"], [])
|
||||||
self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name})
|
self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name})
|
||||||
self.assertEqual(lie_payload["round_question_id"], round_question.id)
|
self.assertEqual(lie_payload["round_question_id"], round_question.id)
|
||||||
self.assertEqual(reveal_payload["correct_answer"], "1989")
|
self.assertEqual(reveal_payload["correct_answer"], "1989")
|
||||||
self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id)
|
self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id)
|
||||||
self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname)
|
self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname)
|
||||||
|
self.assertTrue(phase_view_model["host"]["can_start_round"])
|
||||||
|
self.assertFalse(phase_view_model["host"]["can_finish_game"])
|
||||||
|
|
||||||
|
def test_build_session_detail_gameplay_payload_keeps_session_detail_semantics_in_cartridge(self):
|
||||||
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
|
self.session.save(update_fields=["status"])
|
||||||
|
round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=1,
|
||||||
|
question=self.question_one,
|
||||||
|
correct_answer=self.question_one.correct_answer,
|
||||||
|
)
|
||||||
|
lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991")
|
||||||
|
Guess.objects.create(
|
||||||
|
round_question=round_question,
|
||||||
|
player=self.alice,
|
||||||
|
selected_text="1991",
|
||||||
|
is_correct=False,
|
||||||
|
fooled_player=self.bob,
|
||||||
|
)
|
||||||
|
|
||||||
|
gameplay_payload = build_session_detail_gameplay_payload(
|
||||||
|
self.session,
|
||||||
|
current_round_question=round_question,
|
||||||
|
players_count=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(gameplay_payload["round_question"]["id"], round_question.id)
|
||||||
|
self.assertEqual(gameplay_payload["reveal"]["lies"][0]["player_id"], lie.player_id)
|
||||||
|
self.assertEqual(gameplay_payload["scoreboard"], [{"id": self.alice.id, "nickname": self.alice.nickname, "score": self.alice.score}, {"id": self.bob.id, "nickname": self.bob.nickname, "score": self.bob.score}, {"id": self.clara.id, "nickname": self.clara.nickname, "score": self.clara.score}])
|
||||||
|
self.assertEqual(gameplay_payload["phase_view_model"]["status"], GameSession.Status.SCOREBOARD)
|
||||||
|
self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_start_next_round"])
|
||||||
|
self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_finish_game"])
|
||||||
|
|||||||
561
lobby/tests.py
561
lobby/tests.py
@@ -1,3 +1,4 @@
|
|||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -10,6 +11,7 @@ from django.test import TestCase, override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from fupogfakta import payloads as gameplay_payloads, services as gameplay_services
|
||||||
from fupogfakta.models import (
|
from fupogfakta.models import (
|
||||||
Category,
|
Category,
|
||||||
GameSession,
|
GameSession,
|
||||||
@@ -21,11 +23,343 @@ from fupogfakta.models import (
|
|||||||
RoundQuestion,
|
RoundQuestion,
|
||||||
ScoreEvent,
|
ScoreEvent,
|
||||||
)
|
)
|
||||||
|
from lobby import views as lobby_views
|
||||||
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
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyGameplayExtractionTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.host = User.objects.create_user(username="extract_host", password="secret123")
|
||||||
|
self.client.login(username="extract_host", password="secret123")
|
||||||
|
self.session = GameSession.objects.create(
|
||||||
|
host=self.host,
|
||||||
|
code="EXTR42",
|
||||||
|
status=GameSession.Status.SCOREBOARD,
|
||||||
|
)
|
||||||
|
self.category = Category.objects.create(name="Historie", slug="historie-extract", is_active=True)
|
||||||
|
self.round_config = RoundConfig.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
number=1,
|
||||||
|
category=self.category,
|
||||||
|
)
|
||||||
|
self.question = Question.objects.create(
|
||||||
|
category=self.category,
|
||||||
|
prompt="Hvornår faldt muren?",
|
||||||
|
correct_answer="1989",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lobby_views_use_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._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_start_round_view_source_stays_http_thin(self):
|
||||||
|
source = inspect.getsource(inspect.unwrap(lobby_views.start_round))
|
||||||
|
|
||||||
|
self.assertIn("transition = _start_round(session, category_slug)", source)
|
||||||
|
self.assertNotIn("RoundConfig", source)
|
||||||
|
self.assertNotIn("RoundQuestion", source)
|
||||||
|
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))
|
||||||
|
|
||||||
|
self.assertIn("transition = _show_question(session)", source)
|
||||||
|
self.assertNotIn("RoundConfig", source)
|
||||||
|
self.assertNotIn("RoundQuestion", source)
|
||||||
|
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))
|
||||||
|
|
||||||
|
self.assertIn("transition = _start_next_round(session)", source)
|
||||||
|
self.assertNotIn("RoundConfig", source)
|
||||||
|
self.assertNotIn("RoundQuestion", source)
|
||||||
|
self.assertNotIn("build_start_next_round_response", source)
|
||||||
|
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))
|
||||||
|
|
||||||
|
self.assertIn("transition = _finish_game(session)", source)
|
||||||
|
self.assertNotIn("RoundConfig", source)
|
||||||
|
self.assertNotIn("RoundQuestion", source)
|
||||||
|
self.assertNotIn("build_finish_game_response", source)
|
||||||
|
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))
|
||||||
|
|
||||||
|
self.assertIn("transition = _promote_reveal_to_scoreboard(session)", source)
|
||||||
|
self.assertNotIn("Player.objects.filter(session=session)", source)
|
||||||
|
self.assertNotIn("ScoreEvent.objects.filter", source)
|
||||||
|
self.assertNotIn("build_reveal_scoreboard_response", source)
|
||||||
|
self.assertNotIn("build_scoreboard_phase_event", source)
|
||||||
|
|
||||||
|
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)),
|
||||||
|
}
|
||||||
|
|
||||||
|
forbidden_snippets = (
|
||||||
|
"RoundConfig.objects.get_or_create(",
|
||||||
|
"RoundConfig.objects.create(",
|
||||||
|
"RoundQuestion.objects.create(",
|
||||||
|
"select_round_question(",
|
||||||
|
"reset_round_question_bootstrap_state(",
|
||||||
|
"session.current_round =",
|
||||||
|
"session.status = GameSession.Status.LIE",
|
||||||
|
"session.status = GameSession.Status.SCOREBOARD",
|
||||||
|
"session.status = GameSession.Status.FINISHED",
|
||||||
|
"build_start_next_round_response(",
|
||||||
|
"build_start_next_round_phase_event(",
|
||||||
|
"build_finish_game_response(",
|
||||||
|
"build_finish_game_phase_event(",
|
||||||
|
"build_reveal_scoreboard_response(",
|
||||||
|
"build_scoreboard_phase_event(",
|
||||||
|
"ScoreEvent.objects.filter(",
|
||||||
|
"Player.objects.filter(",
|
||||||
|
)
|
||||||
|
|
||||||
|
for view_name, source in transition_sources.items():
|
||||||
|
for snippet in forbidden_snippets:
|
||||||
|
self.assertNotIn(snippet, source, msg=f"{view_name} leaked gameplay snippet: {snippet}")
|
||||||
|
|
||||||
|
def test_session_detail_view_source_stays_http_thin(self):
|
||||||
|
source = inspect.getsource(inspect.unwrap(lobby_views.session_detail))
|
||||||
|
|
||||||
|
self.assertIn("session = _maybe_promote_reveal_to_scoreboard(session)", source)
|
||||||
|
self.assertIn("current_round_question = _get_current_round_question(session)", source)
|
||||||
|
self.assertIn("gameplay_payload = _build_session_detail_gameplay_payload(", source)
|
||||||
|
self.assertIn("**gameplay_payload", source)
|
||||||
|
self.assertNotIn("build_round_question_payload", source)
|
||||||
|
self.assertNotIn("build_phase_view_model", source)
|
||||||
|
self.assertNotIn("build_reveal_payload", source)
|
||||||
|
self.assertNotIn("build_scoreboard_phase_event(session)[\"payload\"][\"leaderboard\"]", source)
|
||||||
|
self.assertNotIn("lies.select_related", source)
|
||||||
|
self.assertNotIn("guesses.select_related", source)
|
||||||
|
self.assertNotIn("Player.objects.filter(session=session)", source)
|
||||||
|
self.assertNotIn("leaderboard =", source)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("lobby.views.sync_broadcast_phase_event")
|
||||||
|
@patch("lobby.views._start_round")
|
||||||
|
def test_start_round_view_delegates_transition_to_service(
|
||||||
|
self,
|
||||||
|
mock_start_round,
|
||||||
|
mock_sync_broadcast_phase_event,
|
||||||
|
):
|
||||||
|
lobby_session = GameSession.objects.create(host=self.host, code="LOBBY1", status=GameSession.Status.LOBBY)
|
||||||
|
transition = gameplay_services.RoundTransitionResult(
|
||||||
|
session=lobby_session,
|
||||||
|
round_config=self.round_config,
|
||||||
|
round_question=RoundQuestion.objects.create(
|
||||||
|
session=lobby_session,
|
||||||
|
round_number=1,
|
||||||
|
question=self.question,
|
||||||
|
correct_answer=self.question.correct_answer,
|
||||||
|
),
|
||||||
|
should_broadcast=True,
|
||||||
|
response_payload={"ok": True},
|
||||||
|
phase_event_name="phase.lie_started",
|
||||||
|
phase_event_payload={"round_question_id": 123},
|
||||||
|
)
|
||||||
|
mock_start_round.return_value = transition
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("lobby:start_round", kwargs={"code": lobby_session.code}),
|
||||||
|
data=json.dumps({"category_slug": self.category.slug}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
|
mock_start_round.assert_called_once_with(lobby_session, self.category.slug)
|
||||||
|
mock_sync_broadcast_phase_event.assert_called_once_with(
|
||||||
|
lobby_session.code,
|
||||||
|
"phase.lie_started",
|
||||||
|
{"round_question_id": 123},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("lobby.views.sync_broadcast_phase_event")
|
||||||
|
@patch("lobby.views._show_question")
|
||||||
|
def test_show_question_view_delegates_transition_to_service(
|
||||||
|
self,
|
||||||
|
mock_show_question,
|
||||||
|
mock_sync_broadcast_phase_event,
|
||||||
|
):
|
||||||
|
lie_session = GameSession.objects.create(host=self.host, code="LIE123", status=GameSession.Status.LIE)
|
||||||
|
transition = gameplay_services.RoundTransitionResult(
|
||||||
|
session=lie_session,
|
||||||
|
round_config=self.round_config,
|
||||||
|
round_question=RoundQuestion.objects.create(
|
||||||
|
session=lie_session,
|
||||||
|
round_number=1,
|
||||||
|
question=self.question,
|
||||||
|
correct_answer=self.question.correct_answer,
|
||||||
|
),
|
||||||
|
should_broadcast=True,
|
||||||
|
response_payload={"ok": True},
|
||||||
|
phase_event_name="phase.question_shown",
|
||||||
|
phase_event_payload={"round_question_id": 456},
|
||||||
|
)
|
||||||
|
mock_show_question.return_value = transition
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:show_question", kwargs={"code": lie_session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
|
mock_show_question.assert_called_once_with(lie_session)
|
||||||
|
mock_sync_broadcast_phase_event.assert_called_once_with(
|
||||||
|
lie_session.code,
|
||||||
|
"phase.question_shown",
|
||||||
|
{"round_question_id": 456},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("lobby.views.sync_broadcast_phase_event")
|
||||||
|
@patch("lobby.views._start_next_round")
|
||||||
|
def test_start_next_round_view_delegates_transition_to_service(
|
||||||
|
self,
|
||||||
|
mock_start_next_round,
|
||||||
|
mock_sync_broadcast_phase_event,
|
||||||
|
):
|
||||||
|
next_round_config = RoundConfig.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
number=2,
|
||||||
|
category=self.category,
|
||||||
|
started_from_scoreboard=True,
|
||||||
|
)
|
||||||
|
round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=self.question,
|
||||||
|
correct_answer=self.question.correct_answer,
|
||||||
|
)
|
||||||
|
transition = gameplay_services.RoundTransitionResult(
|
||||||
|
session=self.session,
|
||||||
|
round_config=next_round_config,
|
||||||
|
round_question=round_question,
|
||||||
|
should_broadcast=True,
|
||||||
|
response_payload={"ok": True},
|
||||||
|
phase_event_name="phase.lie_started",
|
||||||
|
phase_event_payload={"round_question_id": round_question.id},
|
||||||
|
)
|
||||||
|
mock_start_next_round.return_value = transition
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
|
mock_start_next_round.assert_called_once_with(self.session)
|
||||||
|
mock_sync_broadcast_phase_event.assert_called_once_with(
|
||||||
|
self.session.code,
|
||||||
|
"phase.lie_started",
|
||||||
|
{"round_question_id": round_question.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("lobby.views.sync_broadcast_phase_event")
|
||||||
|
@patch("lobby.views._finish_game")
|
||||||
|
def test_finish_game_view_delegates_transition_to_service(
|
||||||
|
self,
|
||||||
|
mock_finish_game,
|
||||||
|
mock_sync_broadcast_phase_event,
|
||||||
|
):
|
||||||
|
finished_session = GameSession.objects.get(pk=self.session.pk)
|
||||||
|
finished_session.status = GameSession.Status.FINISHED
|
||||||
|
transition = gameplay_services.FinishGameResult(
|
||||||
|
session=finished_session,
|
||||||
|
should_broadcast=True,
|
||||||
|
response_payload={"ok": True},
|
||||||
|
phase_event_name="phase.game_over",
|
||||||
|
phase_event_payload={"winner": None, "leaderboard": []},
|
||||||
|
)
|
||||||
|
mock_finish_game.return_value = transition
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
|
mock_finish_game.assert_called_once_with(self.session)
|
||||||
|
mock_sync_broadcast_phase_event.assert_called_once_with(
|
||||||
|
self.session.code,
|
||||||
|
"phase.game_over",
|
||||||
|
{"winner": None, "leaderboard": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("lobby.views.sync_broadcast_phase_event")
|
||||||
|
@patch("lobby.views._start_next_round")
|
||||||
|
def test_start_next_round_view_skips_broadcast_on_service_replay(
|
||||||
|
self,
|
||||||
|
mock_start_next_round,
|
||||||
|
mock_sync_broadcast_phase_event,
|
||||||
|
):
|
||||||
|
replay_round_config = RoundConfig.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
number=2,
|
||||||
|
category=self.category,
|
||||||
|
started_from_scoreboard=True,
|
||||||
|
)
|
||||||
|
round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=self.question,
|
||||||
|
correct_answer=self.question.correct_answer,
|
||||||
|
)
|
||||||
|
replay_session = GameSession.objects.get(pk=self.session.pk)
|
||||||
|
replay_session.status = GameSession.Status.LIE
|
||||||
|
replay_session.current_round = 2
|
||||||
|
transition = gameplay_services.RoundTransitionResult(
|
||||||
|
session=replay_session,
|
||||||
|
round_config=replay_round_config,
|
||||||
|
round_question=round_question,
|
||||||
|
should_broadcast=False,
|
||||||
|
response_payload={"ok": True},
|
||||||
|
)
|
||||||
|
mock_start_next_round.return_value = transition
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
|
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")
|
||||||
|
def test_finish_game_view_skips_broadcast_on_service_replay(
|
||||||
|
self,
|
||||||
|
mock_finish_game,
|
||||||
|
mock_sync_broadcast_phase_event,
|
||||||
|
):
|
||||||
|
finished_session = GameSession.objects.get(pk=self.session.pk)
|
||||||
|
finished_session.status = GameSession.Status.FINISHED
|
||||||
|
transition = gameplay_services.FinishGameResult(
|
||||||
|
session=finished_session,
|
||||||
|
should_broadcast=False,
|
||||||
|
response_payload={"ok": True},
|
||||||
|
)
|
||||||
|
mock_finish_game.return_value = transition
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
|
mock_finish_game.assert_called_once_with(self.session)
|
||||||
|
mock_sync_broadcast_phase_event.assert_not_called()
|
||||||
|
|
||||||
class LobbyFlowTests(TestCase):
|
class LobbyFlowTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.host = User.objects.create_user(username="host", password="secret123")
|
self.host = User.objects.create_user(username="host", password="secret123")
|
||||||
@@ -308,7 +642,7 @@ class StartRoundTests(TestCase):
|
|||||||
self.assertEqual(response.json()["locale"], "en")
|
self.assertEqual(response.json()["locale"], "en")
|
||||||
self.assertEqual(response.json()["error"], "Only host can start round")
|
self.assertEqual(response.json()["error"], "Only host can start round")
|
||||||
|
|
||||||
@patch("lobby.views._select_round_question", side_effect=ValueError("no_available_questions"))
|
@patch("fupogfakta.services.select_round_question", side_effect=ValueError("no_available_questions"))
|
||||||
def test_start_round_does_not_persist_round_config_when_question_selection_fails(self, _mock_select_round_question):
|
def test_start_round_does_not_persist_round_config_when_question_selection_fails(self, _mock_select_round_question):
|
||||||
self.client.login(username="host", password="secret123")
|
self.client.login(username="host", password="secret123")
|
||||||
|
|
||||||
@@ -1289,6 +1623,25 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
self.session.refresh_from_db()
|
self.session.refresh_from_db()
|
||||||
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
||||||
|
|
||||||
|
@patch("lobby.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}))
|
||||||
|
|
||||||
|
first_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
|
||||||
|
second_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(first_response.status_code, 200)
|
||||||
|
self.assertEqual(second_response.status_code, 200)
|
||||||
|
self.assertEqual(first_response.json(), second_response.json())
|
||||||
|
self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.FINISHED)
|
||||||
|
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
||||||
|
self.assertEqual(mock_sync_broadcast_phase_event.call_count, 2)
|
||||||
|
self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[0].args[1], "phase.scoreboard")
|
||||||
|
self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[1].args[1], "phase.game_over")
|
||||||
|
|
||||||
def test_finish_game_requires_host(self):
|
def test_finish_game_requires_host(self):
|
||||||
self.client.login(username="other_reveal", password="secret123")
|
self.client.login(username="other_reveal", password="secret123")
|
||||||
|
|
||||||
@@ -1348,7 +1701,12 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
self.assertEqual(self.session.current_round, 2)
|
self.assertEqual(self.session.current_round, 2)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists()
|
RoundConfig.objects.filter(
|
||||||
|
session=self.session,
|
||||||
|
number=2,
|
||||||
|
category=self.category,
|
||||||
|
started_from_scoreboard=True,
|
||||||
|
).exists()
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
|
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
|
||||||
@@ -1357,15 +1715,113 @@ 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[0], self.session.code)
|
||||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
||||||
|
|
||||||
|
@patch("lobby.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}))
|
||||||
|
mock_sync_broadcast_phase_event.reset_mock()
|
||||||
|
|
||||||
|
stale_shown_at = timezone.now() - timedelta(minutes=10)
|
||||||
|
current_round_question = RoundQuestion.objects.get(session=self.session, round_number=1)
|
||||||
|
current_round_question.shown_at = stale_shown_at
|
||||||
|
current_round_question.mixed_answers = ["Stale truth", "Stale lie"]
|
||||||
|
current_round_question.save(update_fields=["shown_at", "mixed_answers"])
|
||||||
|
LieAnswer.objects.create(round_question=current_round_question, player=self.player_one, text="Stale lie")
|
||||||
|
Guess.objects.create(
|
||||||
|
round_question=current_round_question,
|
||||||
|
player=self.player_two,
|
||||||
|
selected_text="Stale truth",
|
||||||
|
is_correct=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
current_round_question.refresh_from_db()
|
||||||
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
|
self.assertEqual(self.session.current_round, 2)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["round_question"]["id"], RoundQuestion.objects.get(session=self.session, round_number=2).id)
|
||||||
|
self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt)
|
||||||
|
self.assertEqual(current_round_question.round_number, 1)
|
||||||
|
self.assertEqual(current_round_question.question_id, self.question.id)
|
||||||
|
self.assertEqual(current_round_question.shown_at, stale_shown_at)
|
||||||
|
self.assertEqual(current_round_question.mixed_answers, ["Stale truth", "Stale lie"])
|
||||||
|
self.assertEqual(current_round_question.lies.count(), 1)
|
||||||
|
self.assertEqual(current_round_question.guesses.count(), 1)
|
||||||
|
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
self.assertEqual(detail_payload["round_question"]["id"], payload["round_question"]["id"])
|
||||||
|
self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt)
|
||||||
|
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")
|
||||||
|
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}))
|
||||||
|
mock_sync_broadcast_phase_event.reset_mock()
|
||||||
|
|
||||||
|
first_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
second_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(first_response.status_code, 200)
|
||||||
|
self.assertEqual(second_response.status_code, 200)
|
||||||
|
self.assertEqual(first_response.json(), second_response.json())
|
||||||
|
self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.LIE)
|
||||||
|
self.assertEqual(second_response.json()["session"]["current_round"], 2)
|
||||||
|
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
|
self.assertEqual(self.session.current_round, 2)
|
||||||
|
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
|
||||||
|
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1)
|
||||||
|
mock_sync_broadcast_phase_event.assert_called_once()
|
||||||
|
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
||||||
|
|
||||||
|
def test_start_next_round_rejects_plain_lie_phase_without_prior_scoreboard_transition(self):
|
||||||
|
self.client.login(username="host_reveal", password="secret123")
|
||||||
|
ScoreEvent.objects.filter(session=self.session).delete()
|
||||||
|
self.session.status = GameSession.Status.LIE
|
||||||
|
self.session.current_round = 2
|
||||||
|
self.session.save(update_fields=["status", "current_round"])
|
||||||
|
RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False)
|
||||||
|
RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=self.next_question,
|
||||||
|
correct_answer=self.next_question.correct_answer,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"lobby:start_next_round",
|
||||||
|
kwargs={"code": self.session.code},
|
||||||
|
),
|
||||||
|
HTTP_ACCEPT_LANGUAGE="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.json()["error_code"], "next_round_invalid_phase")
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
|
self.assertEqual(self.session.current_round, 2)
|
||||||
|
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1)
|
||||||
|
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
|
||||||
|
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1)
|
||||||
|
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1)
|
||||||
|
|
||||||
def test_start_next_round_clears_existing_next_round_bootstrap_state(self):
|
def test_start_next_round_clears_existing_next_round_bootstrap_state(self):
|
||||||
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}))
|
||||||
|
|
||||||
|
stale_shown_at = timezone.now() - timedelta(minutes=10)
|
||||||
stale_round_question = RoundQuestion.objects.create(
|
stale_round_question = RoundQuestion.objects.create(
|
||||||
session=self.session,
|
session=self.session,
|
||||||
round_number=2,
|
round_number=2,
|
||||||
question=self.next_question,
|
question=self.next_question,
|
||||||
correct_answer=self.next_question.correct_answer,
|
correct_answer=self.next_question.correct_answer,
|
||||||
|
shown_at=stale_shown_at,
|
||||||
mixed_answers=["Stale truth", "Stale lie"],
|
mixed_answers=["Stale truth", "Stale lie"],
|
||||||
)
|
)
|
||||||
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie")
|
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie")
|
||||||
@@ -1383,19 +1839,118 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
stale_round_question.refresh_from_db()
|
stale_round_question.refresh_from_db()
|
||||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
self.assertEqual(self.session.current_round, 2)
|
self.assertEqual(self.session.current_round, 2)
|
||||||
self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id)
|
response_payload = response.json()
|
||||||
|
self.assertEqual(response_payload["round_question"]["id"], stale_round_question.id)
|
||||||
self.assertEqual(stale_round_question.mixed_answers, [])
|
self.assertEqual(stale_round_question.mixed_answers, [])
|
||||||
self.assertEqual(stale_round_question.lies.count(), 0)
|
self.assertEqual(stale_round_question.lies.count(), 0)
|
||||||
self.assertEqual(stale_round_question.guesses.count(), 0)
|
self.assertEqual(stale_round_question.guesses.count(), 0)
|
||||||
|
self.assertNotEqual(stale_round_question.shown_at, stale_shown_at)
|
||||||
|
self.assertGreater(stale_round_question.shown_at, stale_shown_at)
|
||||||
|
self.assertEqual(response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat())
|
||||||
|
expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds)
|
||||||
|
self.assertEqual(response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat())
|
||||||
|
self.assertGreater(expected_deadline, timezone.now())
|
||||||
|
|
||||||
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
|
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
|
||||||
|
|
||||||
|
def test_start_next_round_reuses_existing_next_round_config_with_refreshed_canonical_values(self):
|
||||||
|
self.client.login(username="host_reveal", password="secret123")
|
||||||
|
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
stale_category = Category.objects.create(name="Sport reveal", slug="sport-reveal", is_active=True)
|
||||||
|
stale_round_config = RoundConfig.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
number=2,
|
||||||
|
category=stale_category,
|
||||||
|
lie_seconds=12,
|
||||||
|
guess_seconds=18,
|
||||||
|
points_correct=9,
|
||||||
|
points_bluff=7,
|
||||||
|
started_from_scoreboard=False,
|
||||||
|
)
|
||||||
|
stale_round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=self.next_question,
|
||||||
|
correct_answer=self.next_question.correct_answer,
|
||||||
|
shown_at=timezone.now() - timedelta(minutes=10),
|
||||||
|
mixed_answers=["Stale truth"],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
stale_round_config.refresh_from_db()
|
||||||
|
stale_round_question.refresh_from_db()
|
||||||
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
|
self.assertEqual(self.session.current_round, 2)
|
||||||
|
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
|
||||||
|
self.assertEqual(stale_round_config.category_id, self.round_config.category_id)
|
||||||
|
self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds)
|
||||||
|
self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds)
|
||||||
|
self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct)
|
||||||
|
self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff)
|
||||||
|
self.assertTrue(stale_round_config.started_from_scoreboard)
|
||||||
|
self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id)
|
||||||
|
self.assertEqual(response.json()["config"]["lie_seconds"], self.round_config.lie_seconds)
|
||||||
|
expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds)
|
||||||
|
self.assertEqual(response.json()["round_question"]["lie_deadline_at"], expected_deadline.isoformat())
|
||||||
|
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
self.assertEqual(detail_payload["session"]["current_round"], 2)
|
self.assertEqual(detail_payload["session"]["current_round"], 2)
|
||||||
self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id)
|
self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id)
|
||||||
self.assertEqual(detail_payload["round_question"]["answers"], [])
|
self.assertEqual(detail_payload["round_question"]["answers"], [])
|
||||||
self.assertIsNone(detail_payload["reveal"])
|
self.assertIsNone(detail_payload["reveal"])
|
||||||
self.assertIsNone(detail_payload["scoreboard"])
|
self.assertIsNone(detail_payload["scoreboard"])
|
||||||
|
|
||||||
|
def test_start_next_round_repairs_reused_bootstrap_question_with_drifted_category(self):
|
||||||
|
self.client.login(username="host_reveal", password="secret123")
|
||||||
|
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
stale_category = Category.objects.create(name="Drift reveal", slug="drift-reveal", is_active=True)
|
||||||
|
stale_question = Question.objects.create(
|
||||||
|
category=stale_category,
|
||||||
|
prompt="Hvem vandt EM i 1992?",
|
||||||
|
correct_answer="Danmark",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
stale_round_question = RoundQuestion.objects.create(
|
||||||
|
session=self.session,
|
||||||
|
round_number=2,
|
||||||
|
question=stale_question,
|
||||||
|
correct_answer=stale_question.correct_answer,
|
||||||
|
shown_at=timezone.now() - timedelta(minutes=10),
|
||||||
|
mixed_answers=["Stale truth", "Stale lie"],
|
||||||
|
)
|
||||||
|
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Tyskland")
|
||||||
|
Guess.objects.create(
|
||||||
|
round_question=stale_round_question,
|
||||||
|
player=self.player_two,
|
||||||
|
selected_text="Stale truth",
|
||||||
|
is_correct=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.session.refresh_from_db()
|
||||||
|
stale_round_question.refresh_from_db()
|
||||||
|
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||||
|
self.assertEqual(self.session.current_round, 2)
|
||||||
|
self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id)
|
||||||
|
self.assertEqual(stale_round_question.question_id, self.next_question.id)
|
||||||
|
self.assertEqual(stale_round_question.correct_answer, self.next_question.correct_answer)
|
||||||
|
self.assertEqual(stale_round_question.mixed_answers, [])
|
||||||
|
self.assertEqual(stale_round_question.lies.count(), 0)
|
||||||
|
self.assertEqual(stale_round_question.guesses.count(), 0)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["round_question"]["id"], stale_round_question.id)
|
||||||
|
self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt)
|
||||||
|
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
|
||||||
|
self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id)
|
||||||
|
self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt)
|
||||||
|
|
||||||
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
|
||||||
self.session.save(update_fields=["status"])
|
self.session.save(update_fields=["status"])
|
||||||
|
|||||||
436
lobby/views.py
436
lobby/views.py
@@ -1,29 +1,39 @@
|
|||||||
import json
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.http import HttpRequest, JsonResponse
|
from django.http import HttpRequest, JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
||||||
from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
|
||||||
from fupogfakta.payloads import (
|
from fupogfakta.payloads import (
|
||||||
build_leaderboard as _build_leaderboard,
|
build_leaderboard as _build_leaderboard,
|
||||||
build_lie_started_payload as _build_lie_started_payload,
|
|
||||||
build_reveal_payload as _build_reveal_payload,
|
build_reveal_payload as _build_reveal_payload,
|
||||||
|
build_scoreboard_phase_event as _build_scoreboard_phase_event,
|
||||||
|
build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload,
|
||||||
)
|
)
|
||||||
from fupogfakta.services import (
|
from fupogfakta.services import (
|
||||||
|
finish_game as _finish_game,
|
||||||
get_current_round_question as _get_current_round_question,
|
get_current_round_question as _get_current_round_question,
|
||||||
prepare_mixed_answers as _prepare_mixed_answers,
|
prepare_mixed_answers as _prepare_mixed_answers,
|
||||||
|
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
|
||||||
resolve_scores as _resolve_scores,
|
resolve_scores as _resolve_scores,
|
||||||
select_round_question as _select_round_question,
|
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 realtime.broadcast import sync_broadcast_phase_event
|
||||||
|
|
||||||
from .i18n import api_error
|
from .i18n import api_error
|
||||||
|
|
||||||
|
_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (
|
||||||
|
_select_round_question,
|
||||||
|
_build_scoreboard_phase_event,
|
||||||
|
)
|
||||||
|
|
||||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
SESSION_CODE_LENGTH = 6
|
SESSION_CODE_LENGTH = 6
|
||||||
MAX_CODE_GENERATION_ATTEMPTS = 20
|
MAX_CODE_GENERATION_ATTEMPTS = 20
|
||||||
@@ -66,100 +76,16 @@ def _create_unique_session_code() -> str:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion:
|
|
||||||
Guess.objects.filter(round_question=round_question).delete()
|
|
||||||
LieAnswer.objects.filter(round_question=round_question).delete()
|
|
||||||
if round_question.mixed_answers:
|
|
||||||
round_question.mixed_answers = []
|
|
||||||
round_question.save(update_fields=["mixed_answers"])
|
|
||||||
return round_question
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
||||||
if session.status != GameSession.Status.REVEAL:
|
transition = _promote_reveal_to_scoreboard(session)
|
||||||
return session
|
if transition.should_broadcast:
|
||||||
|
sync_broadcast_phase_event(
|
||||||
|
transition.session.code,
|
||||||
|
transition.phase_event_name,
|
||||||
|
transition.phase_event_payload,
|
||||||
|
)
|
||||||
|
return transition.session
|
||||||
|
|
||||||
current_round_question = _get_current_round_question(session)
|
|
||||||
if current_round_question is None:
|
|
||||||
return session
|
|
||||||
|
|
||||||
players_count = Player.objects.filter(session=session).count()
|
|
||||||
guess_count = Guess.objects.filter(round_question=current_round_question).count()
|
|
||||||
has_score_events = ScoreEvent.objects.filter(
|
|
||||||
session=session,
|
|
||||||
meta__round_question_id=current_round_question.id,
|
|
||||||
).exists()
|
|
||||||
reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count)
|
|
||||||
if not reveal_is_resolved:
|
|
||||||
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:
|
|
||||||
status = session.status
|
|
||||||
in_lobby = status == GameSession.Status.LOBBY
|
|
||||||
in_lie = status == GameSession.Status.LIE
|
|
||||||
in_guess = status == GameSession.Status.GUESS
|
|
||||||
in_scoreboard = status == GameSession.Status.SCOREBOARD
|
|
||||||
in_finished = status == GameSession.Status.FINISHED
|
|
||||||
|
|
||||||
min_players_reached = players_count >= 3
|
|
||||||
max_players_allowed = players_count <= 5
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": status,
|
|
||||||
"current_phase": status,
|
|
||||||
"round_number": session.current_round,
|
|
||||||
"players_count": players_count,
|
|
||||||
"constraints": {
|
|
||||||
"min_players_to_start": 3,
|
|
||||||
"max_players_mvp": 5,
|
|
||||||
"min_players_reached": min_players_reached,
|
|
||||||
"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": {
|
|
||||||
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
|
|
||||||
"can_show_question": False,
|
|
||||||
"can_mix_answers": False,
|
|
||||||
"can_calculate_scores": False,
|
|
||||||
"can_reveal_scoreboard": False,
|
|
||||||
"can_start_next_round": in_scoreboard,
|
|
||||||
"can_finish_game": in_scoreboard,
|
|
||||||
},
|
|
||||||
"player": {
|
|
||||||
"can_join": status in JOINABLE_STATUSES,
|
|
||||||
"can_submit_lie": in_lie and has_round_question,
|
|
||||||
"can_submit_guess": in_guess and has_round_question,
|
|
||||||
"can_view_final_result": in_finished,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@@ -268,21 +194,10 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
|
|
||||||
session = _maybe_promote_reveal_to_scoreboard(session)
|
session = _maybe_promote_reveal_to_scoreboard(session)
|
||||||
current_round_question = _get_current_round_question(session)
|
current_round_question = _get_current_round_question(session)
|
||||||
|
gameplay_payload = _build_session_detail_gameplay_payload(
|
||||||
round_question_payload = None
|
|
||||||
if current_round_question:
|
|
||||||
round_question_payload = {
|
|
||||||
"id": current_round_question.id,
|
|
||||||
"round_number": current_round_question.round_number,
|
|
||||||
"prompt": current_round_question.question.prompt,
|
|
||||||
"shown_at": current_round_question.shown_at.isoformat(),
|
|
||||||
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
|
|
||||||
}
|
|
||||||
|
|
||||||
phase_view_model = _build_phase_view_model(
|
|
||||||
session,
|
session,
|
||||||
|
current_round_question=current_round_question,
|
||||||
players_count=len(players),
|
players_count=len(players),
|
||||||
has_round_question=bool(current_round_question),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
@@ -295,14 +210,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
"players_count": len(players),
|
"players_count": len(players),
|
||||||
},
|
},
|
||||||
"players": players,
|
"players": players,
|
||||||
"round_question": round_question_payload,
|
**gameplay_payload,
|
||||||
"reveal": _build_reveal_payload(current_round_question)
|
|
||||||
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
|
|
||||||
else None,
|
|
||||||
"scoreboard": _build_leaderboard(session)
|
|
||||||
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
|
|
||||||
else None,
|
|
||||||
"phase_view_model": phase_view_model,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -338,95 +246,23 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
status=403,
|
status=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.status != GameSession.Status.LOBBY:
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="round_start_invalid_phase",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
transition = _start_round(session, category_slug)
|
||||||
except Category.DoesNotExist:
|
except ValueError as exc:
|
||||||
return api_error(
|
error_code = str(exc)
|
||||||
request,
|
error_status = {
|
||||||
code="category_not_found",
|
"category_not_found": 404,
|
||||||
status=404,
|
"round_already_configured": 409,
|
||||||
)
|
}.get(error_code, 400)
|
||||||
|
return api_error(request, code=error_code, status=error_status)
|
||||||
if not Question.objects.filter(category=category, is_active=True).exists():
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="category_has_no_questions",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
|
||||||
if session.status != GameSession.Status.LOBBY:
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="round_start_invalid_phase",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if RoundConfig.objects.filter(session=session, number=session.current_round).exists():
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="round_already_configured",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
|
|
||||||
round_config = RoundConfig(
|
|
||||||
session=session,
|
|
||||||
number=session.current_round,
|
|
||||||
category=category,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
round_question = _select_round_question(session, round_config)
|
|
||||||
except ValueError as exc:
|
|
||||||
return api_error(request, code=str(exc), status=400)
|
|
||||||
|
|
||||||
round_config.save()
|
|
||||||
session.status = GameSession.Status.LIE
|
|
||||||
session.save(update_fields=["status"])
|
|
||||||
|
|
||||||
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
|
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
sync_broadcast_phase_event(
|
||||||
session.code,
|
transition.session.code,
|
||||||
"phase.lie_started",
|
transition.phase_event_name,
|
||||||
lie_started_payload,
|
transition.phase_event_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(transition.response_payload, status=201)
|
||||||
{
|
|
||||||
"session": {
|
|
||||||
"code": session.code,
|
|
||||||
"status": session.status,
|
|
||||||
"current_round": session.current_round,
|
|
||||||
},
|
|
||||||
"round": {
|
|
||||||
"number": round_config.number,
|
|
||||||
"category": {
|
|
||||||
"slug": round_config.category.slug,
|
|
||||||
"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_started_payload["lie_deadline_at"],
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"lie_seconds": round_config.lie_seconds,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status=201,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@@ -450,60 +286,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
status=403,
|
status=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.status != GameSession.Status.LIE:
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="show_question_invalid_phase",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
|
transition = _show_question(session)
|
||||||
except RoundConfig.DoesNotExist:
|
except ValueError as exc:
|
||||||
return api_error(
|
return api_error(request, code=str(exc), status=400)
|
||||||
request,
|
|
||||||
code="round_config_missing",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_round_question = _get_current_round_question(session)
|
|
||||||
if existing_round_question is not None:
|
|
||||||
round_question = existing_round_question
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
round_question = _select_round_question(session, round_config)
|
|
||||||
except ValueError as exc:
|
|
||||||
return api_error(request, code=str(exc), status=400)
|
|
||||||
|
|
||||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
sync_broadcast_phase_event(
|
||||||
session.code,
|
transition.session.code,
|
||||||
"phase.question_shown",
|
transition.phase_event_name,
|
||||||
{
|
transition.phase_event_payload,
|
||||||
"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(transition.response_payload, status=201)
|
||||||
{
|
|
||||||
"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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@@ -902,22 +696,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)
|
||||||
|
|
||||||
session = _maybe_promote_reveal_to_scoreboard(session)
|
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}:
|
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
|
||||||
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
||||||
|
|
||||||
leaderboard = _build_leaderboard(session)
|
return JsonResponse(transition.response_payload)
|
||||||
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"session": {
|
|
||||||
"code": session.code,
|
|
||||||
"status": session.status,
|
|
||||||
"current_round": session.current_round,
|
|
||||||
},
|
|
||||||
"leaderboard": leaderboard,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@@ -933,74 +723,19 @@ def start_next_round(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_start_next_round", status=403)
|
return api_error(request, code="host_only_start_next_round", status=403)
|
||||||
|
|
||||||
with transaction.atomic():
|
try:
|
||||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
transition = _start_next_round(session)
|
||||||
if locked_session.status != GameSession.Status.SCOREBOARD:
|
except ValueError as exc:
|
||||||
return api_error(request, code="next_round_invalid_phase", status=400)
|
return api_error(request, code=str(exc), status=400)
|
||||||
|
|
||||||
previous_round_config = RoundConfig.objects.filter(
|
if transition.should_broadcast:
|
||||||
session=locked_session,
|
sync_broadcast_phase_event(
|
||||||
number=locked_session.current_round,
|
transition.session.code,
|
||||||
).select_related("category").first()
|
transition.phase_event_name,
|
||||||
if previous_round_config is None:
|
transition.phase_event_payload,
|
||||||
return api_error(request, code="round_config_missing", status=400)
|
|
||||||
|
|
||||||
next_round_number = locked_session.current_round + 1
|
|
||||||
next_round_config = RoundConfig(
|
|
||||||
session=locked_session,
|
|
||||||
number=next_round_number,
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
locked_session.current_round = next_round_number
|
|
||||||
|
|
||||||
try:
|
return JsonResponse(transition.response_payload)
|
||||||
round_question = _reset_round_question_bootstrap_state(
|
|
||||||
_select_round_question(locked_session, next_round_config)
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
return api_error(request, code=str(exc), status=400)
|
|
||||||
|
|
||||||
next_round_config.save()
|
|
||||||
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": 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@login_required
|
@login_required
|
||||||
@@ -1015,40 +750,19 @@ def finish_game(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_finish_game", status=403)
|
return api_error(request, code="host_only_finish_game", status=403)
|
||||||
|
|
||||||
with transaction.atomic():
|
try:
|
||||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
transition = _finish_game(session)
|
||||||
if locked_session.status != GameSession.Status.SCOREBOARD:
|
except ValueError as exc:
|
||||||
return api_error(request, code="finish_game_invalid_phase", status=400)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
locked_session.status = GameSession.Status.FINISHED
|
return JsonResponse(transition.response_payload)
|
||||||
locked_session.save(update_fields=["status"])
|
|
||||||
|
|
||||||
leaderboard = list(
|
|
||||||
Player.objects.filter(session=session)
|
|
||||||
.order_by("-score", "nickname")
|
|
||||||
.values("id", "nickname", "score")
|
|
||||||
)
|
|
||||||
|
|
||||||
winner = leaderboard[0] if leaderboard else None
|
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
|
||||||
session.code,
|
|
||||||
"phase.game_over",
|
|
||||||
{"winner": winner, "leaderboard": list(leaderboard)},
|
|
||||||
)
|
|
||||||
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"session": {
|
|
||||||
"code": session.code,
|
|
||||||
"status": GameSession.Status.FINISHED,
|
|
||||||
"current_round": session.current_round,
|
|
||||||
},
|
|
||||||
"winner": winner,
|
|
||||||
"leaderboard": leaderboard,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.",
|
"naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.",
|
||||||
"source_of_truth": {
|
"source_of_truth": {
|
||||||
"catalog": "shared/i18n/lobby.json",
|
"catalog": "shared/i18n/lobby.json",
|
||||||
"catalog_sha256": "e3ed39f2fa25622c01b450bd14fd4da5fc7f96c0d9635bb819f73cae14203beb",
|
"catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb",
|
||||||
"source_paths": [
|
"source_paths": [
|
||||||
"lobby/views.py",
|
"lobby/views.py",
|
||||||
"frontend/src/spa/vertical-slice.ts",
|
"frontend/src/spa/vertical-slice.ts",
|
||||||
@@ -24,28 +24,7 @@
|
|||||||
},
|
},
|
||||||
"parity": {
|
"parity": {
|
||||||
"status": "pass",
|
"status": "pass",
|
||||||
"django_backend_error_codes_used_by_mvp": [
|
"django_backend_error_codes_used_by_mvp": [],
|
||||||
"category_has_no_questions",
|
|
||||||
"category_not_found",
|
|
||||||
"category_slug_required",
|
|
||||||
"host_only_mix_answers",
|
|
||||||
"host_only_show_question",
|
|
||||||
"host_only_start_round",
|
|
||||||
"mix_answers_invalid_phase",
|
|
||||||
"nickname_invalid",
|
|
||||||
"nickname_taken",
|
|
||||||
"no_available_questions",
|
|
||||||
"not_enough_answers_to_mix",
|
|
||||||
"question_already_shown",
|
|
||||||
"round_already_configured",
|
|
||||||
"round_config_missing",
|
|
||||||
"round_question_not_found",
|
|
||||||
"round_start_invalid_phase",
|
|
||||||
"session_code_required",
|
|
||||||
"session_not_found",
|
|
||||||
"session_not_joinable",
|
|
||||||
"show_question_invalid_phase"
|
|
||||||
],
|
|
||||||
"angular_frontend_error_fallback_keys_used_by_mvp": [
|
"angular_frontend_error_fallback_keys_used_by_mvp": [
|
||||||
"join_failed",
|
"join_failed",
|
||||||
"session_code_required",
|
"session_code_required",
|
||||||
@@ -158,36 +137,8 @@
|
|||||||
"player.submit_lie",
|
"player.submit_lie",
|
||||||
"player.title"
|
"player.title"
|
||||||
],
|
],
|
||||||
"backend_codes_mapped_to_frontend_error_keys": {
|
"backend_codes_mapped_to_frontend_error_keys": {},
|
||||||
"category_has_no_questions": "start_round_failed",
|
"unique_frontend_error_keys_reached_from_django": [],
|
||||||
"category_not_found": "start_round_failed",
|
|
||||||
"category_slug_required": "start_round_failed",
|
|
||||||
"host_only_mix_answers": "start_round_failed",
|
|
||||||
"host_only_show_question": "start_round_failed",
|
|
||||||
"host_only_start_round": "start_round_failed",
|
|
||||||
"mix_answers_invalid_phase": "start_round_failed",
|
|
||||||
"nickname_invalid": "nickname_invalid",
|
|
||||||
"nickname_taken": "nickname_taken",
|
|
||||||
"no_available_questions": "start_round_failed",
|
|
||||||
"not_enough_answers_to_mix": "start_round_failed",
|
|
||||||
"question_already_shown": "start_round_failed",
|
|
||||||
"round_already_configured": "start_round_failed",
|
|
||||||
"round_config_missing": "start_round_failed",
|
|
||||||
"round_question_not_found": "start_round_failed",
|
|
||||||
"round_start_invalid_phase": "start_round_failed",
|
|
||||||
"session_code_required": "session_code_required",
|
|
||||||
"session_not_found": "session_not_found",
|
|
||||||
"session_not_joinable": "join_failed",
|
|
||||||
"show_question_invalid_phase": "start_round_failed"
|
|
||||||
},
|
|
||||||
"unique_frontend_error_keys_reached_from_django": [
|
|
||||||
"join_failed",
|
|
||||||
"nickname_invalid",
|
|
||||||
"nickname_taken",
|
|
||||||
"session_code_required",
|
|
||||||
"session_not_found",
|
|
||||||
"start_round_failed"
|
|
||||||
],
|
|
||||||
"blocking_issues": {
|
"blocking_issues": {
|
||||||
"missing_backend_codes": [],
|
"missing_backend_codes": [],
|
||||||
"missing_backend_translations": [],
|
"missing_backend_translations": [],
|
||||||
@@ -201,11 +152,6 @@
|
|||||||
"priority": "need-to-have",
|
"priority": "need-to-have",
|
||||||
"item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.",
|
"item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.",
|
||||||
"evidence": "host_only_action"
|
"evidence": "host_only_action"
|
||||||
},
|
|
||||||
{
|
|
||||||
"priority": "nice-to-have",
|
|
||||||
"item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.",
|
|
||||||
"evidence": "start_round_failed <= category_has_no_questions, category_not_found, category_slug_required, host_only_mix_answers, host_only_show_question, host_only_start_round, mix_answers_invalid_phase, no_available_questions, not_enough_answers_to_mix, question_already_shown, round_already_configured, round_config_missing, round_question_not_found, round_start_invalid_phase, show_question_invalid_phase"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user