Compare commits
3 Commits
251ccfce19
...
d36d256daf
| Author | SHA1 | Date | |
|---|---|---|---|
| d36d256daf | |||
| 2ee235c6c0 | |||
| 592c265331 |
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.
|
||||
202
docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md
Normal file
202
docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Issue #312 — FupOgFakta extraction map for logic currently living in `lobby/`
|
||||
|
||||
Parent: #311
|
||||
Issue: #312
|
||||
|
||||
## Purpose
|
||||
|
||||
This artifact documents the concrete FupOgFakta-specific logic that still lives in `lobby/`, separates it from true platform/session concerns, and names the intended destination ownership before any larger code move happens.
|
||||
|
||||
It is intentionally an inventory + extraction plan only. It does **not** perform the full move.
|
||||
|
||||
## Architectural boundary this map is enforcing
|
||||
|
||||
The target boundary is already described in:
|
||||
|
||||
- `docs/plans/2026-03-09-fupogfakta-game-engine-design.md`
|
||||
- `docs/plans/2026-03-09-fupogfakta-implementation-plan.md`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
|
||||
Those docs consistently describe:
|
||||
|
||||
- `lobby/` as the **platform layer** for session lifecycle, player presence, host ownership, generic game-run orchestration, and transport-facing platform concerns.
|
||||
- `fupogfakta/` as the **game cartridge** that owns question selection rules, round config semantics, lie/guess/reveal/scoreboard flow, answer mixing, scoring, and game-specific response/event payloads.
|
||||
|
||||
In other words:
|
||||
|
||||
- **Platform (`lobby/`)** should know that a session exists and that a game can be started/observed.
|
||||
- **Cartridge (`fupogfakta/`)** should know what a lie is, what a guess is, how answers are mixed, when phases advance, and what payload shape those game phases expose.
|
||||
|
||||
## Summary split
|
||||
|
||||
### Generic platform/session concerns that belong in `lobby/`
|
||||
|
||||
These are not FupOgFakta-specific and should remain platform-owned:
|
||||
|
||||
- Session code parsing/generation:
|
||||
- `lobby/views.py::_generate_session_code`
|
||||
- `lobby/views.py::_normalize_session_code`
|
||||
- `lobby/views.py::_create_unique_session_code`
|
||||
- Generic request parsing:
|
||||
- `lobby/views.py::_json_body`
|
||||
- Session lifecycle and player presence endpoints:
|
||||
- `lobby/views.py::create_session`
|
||||
- `lobby/views.py::join_session`
|
||||
- `lobby/views.py::session_detail` **only for the generic session/player shell part**
|
||||
- Generic ownership / host authorization checks
|
||||
- Generic session detail payload fields:
|
||||
- `session.code`
|
||||
- `session.status`
|
||||
- `session.host_id`
|
||||
- `session.current_round`
|
||||
- `session.players_count`
|
||||
- `players[].id|nickname|score|is_connected`
|
||||
- Generic i18n/error transport helper usage:
|
||||
- `lobby/i18n.py`
|
||||
- `api_error(...)`
|
||||
- Route mounting / namespace ownership in `lobby/urls.py` for platform routes only
|
||||
|
||||
### FupOgFakta-specific logic currently misplaced in `lobby/`
|
||||
|
||||
These items are game-cartridge logic and should move behind `fupogfakta/` ownership:
|
||||
|
||||
- Round question selection by category and previously-used questions
|
||||
- Lie-phase payload construction and lie timer semantics
|
||||
- Mixed-answer preparation for bluff gameplay
|
||||
- Guess correctness / fooled-player detection
|
||||
- Bluff/correct-answer score resolution
|
||||
- Reveal payload construction
|
||||
- Reveal → scoreboard promotion rules
|
||||
- Start round / mix answers / submit lie / submit guess / calculate scores / reveal scoreboard / next round / finish game gameplay endpoints
|
||||
- Phase view-model booleans that encode FupOgFakta rules rather than generic platform readiness
|
||||
|
||||
## Extraction map
|
||||
|
||||
| Source file | Current function / concern | Why it is FupOgFakta-specific | Intended destination / owner |
|
||||
| --- | --- | --- | --- |
|
||||
| `lobby/views.py` | `_build_player_ref(player)` | Helper is only used to shape FupOgFakta reveal payloads; not a generic platform concern today. | `fupogfakta/serializers.py` or `fupogfakta/payloads.py` owned by cartridge. |
|
||||
| `lobby/views.py` | `_build_reveal_payload(round_question)` | Encodes FupOgFakta reveal contract: lies, guesses, fooled-player refs, correct answer, prompt. | `fupogfakta/payloads.py::build_reveal_payload` or equivalent cartridge response builder. |
|
||||
| `lobby/views.py` | `_build_leaderboard(session)` | Current implementation is generic-ish, but used exclusively by FupOgFakta scoreboard/finish flow and coupled to that response shape. | Short term: keep shared helper if multiple games will consume same contract; otherwise move to `fupogfakta/payloads.py` until a true shared scoreboard contract exists. |
|
||||
| `lobby/views.py` | `_get_current_round_question(session)` | Depends on FupOgFakta `RoundQuestion` model and current-round semantics. | `fupogfakta/services/rounds.py` or `fupogfakta/queries.py`. |
|
||||
| `lobby/views.py` | `_select_round_question(session, round_config)` | Implements FupOgFakta question selection rules by category, active questions, and not-yet-used question set. | `fupogfakta/services/rounds.py::select_round_question`. |
|
||||
| `lobby/views.py` | `_build_lie_started_payload(session, round_config, round_question)` | Builds a FupOgFakta event/response contract for lie phase, including category, prompt, lie deadline, round question id. | `fupogfakta/payloads.py::build_lie_started_payload`. |
|
||||
| `lobby/views.py` | `_prepare_mixed_answers(round_question)` | Bluff-answer dedupe and shuffle is core FupOgFakta gameplay logic. | `fupogfakta/services/answers.py::prepare_mixed_answers`. |
|
||||
| `lobby/views.py` | `_resolve_scores(session, round_question, round_config)` | Applies FupOgFakta scoring rules for correct guesses and successful bluffs; depends on `Guess`, `LieAnswer`, `ScoreEvent`, `points_correct`, `points_bluff`. | `fupogfakta/services/scoring.py::resolve_scores`. |
|
||||
| `lobby/views.py` | `_maybe_promote_reveal_to_scoreboard(session)` | Encodes FupOgFakta reveal completion semantics and scoreboard transition trigger. | `fupogfakta/services/phases.py::maybe_promote_reveal_to_scoreboard`. |
|
||||
| `lobby/views.py` | `_build_phase_view_model(session, players_count, has_round_question)` | Most booleans are not platform-generic; they encode FupOgFakta phase names (`lie`, `guess`, `scoreboard`) and MVP constraints (`3-5 players`, round-question readiness, next-round/finish gating). | Split: keep platform-shell fields in `lobby/`; move game-specific readiness/action flags to `fupogfakta/payloads.py::build_phase_view_model` or cartridge driver payload builder. |
|
||||
| `lobby/views.py` | `start_round(request, code)` | Starts FupOgFakta round, binds category, creates `RoundConfig`, selects `RoundQuestion`, transitions to `LIE`, broadcasts `phase.lie_started`. | `fupogfakta/views.py` or cartridge command handler behind a future `GameDriver.on_game_start` / round bootstrap service. |
|
||||
| `lobby/views.py` | `show_question(request, code)` | Emits lie-phase question payload using FupOgFakta `RoundQuestion` and `RoundConfig`. | `fupogfakta/views.py` or remove entirely once canonical driver flow owns the transition. |
|
||||
| `lobby/views.py` | `submit_lie(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: lie validation, deadline semantics, auto-advance to guess phase, `phase.guess_started` payload. | `fupogfakta/views.py::submit_lie` (or cartridge intent handler). |
|
||||
| `lobby/views.py` | `mix_answers(request, code, round_question_id)` | Manual FupOgFakta host action for lie→guess transition and answer mixing. | `fupogfakta/views.py` short term; long term likely deleted in favor of cartridge-driven automatic transition. |
|
||||
| `lobby/views.py` | `submit_guess(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: validates answer choice, resolves correctness/bluff source, auto-calculates scores, transitions to reveal. | `fupogfakta/views.py::submit_guess` plus `fupogfakta/services/scoring.py` and `fupogfakta/services/phases.py`. |
|
||||
| `lobby/views.py` | `reveal_scoreboard(request, code)` | FupOgFakta reveal/scoreboard progression, not a generic platform capability. | `fupogfakta/views.py::reveal_scoreboard` or cartridge phase service. |
|
||||
| `lobby/views.py` | `start_next_round(request, code)` | FupOgFakta next-round bootstrap: copies prior `RoundConfig`, increments round, picks next question, re-enters lie phase. | `fupogfakta/services/rounds.py::start_next_round` plus cartridge-owned endpoint/driver integration. |
|
||||
| `lobby/views.py` | `finish_game(request, code)` | Current finish path is tied to FupOgFakta scoreboard semantics and winner payload. | `fupogfakta/views.py::finish_game` until a truly generic platform finish contract exists. |
|
||||
| `lobby/views.py` | `calculate_scores(request, code, round_question_id)` | Explicit FupOgFakta score resolution endpoint. | `fupogfakta/services/scoring.py` and/or remove when fully absorbed by cartridge phase driver. |
|
||||
| `lobby/urls.py` | Gameplay routes for rounds, lies, guesses, scoreboard, finish | These route names expose FupOgFakta-specific phase/actions from the platform namespace. | Re-home under `fupogfakta/urls.py` or leave mounted under `/lobby/sessions/...` only as a temporary façade delegating to cartridge-owned code. |
|
||||
| `lobby/tests.py` | `StartRoundTests`, `LieSubmissionTests`, `MixAnswersTests`, `GuessSubmissionTests`, `CanonicalRoundFlowTests`, `ScoreCalculationTests`, `RevealRoundFlowTests`, `SessionDetailRoundQuestionTests`, `SessionDetailPhaseViewModelTests`, `SmokeStagingCommandTests` | These test classes verify FupOgFakta game flow rather than platform mechanics. | Move/split into `fupogfakta/tests/` with only session creation/join/platform transport tests left in `lobby/tests.py`. |
|
||||
| `lobby/management/commands/smoke_staging.py` | End-to-end gameplay smoke through lies/guesses/finish | Script executes one concrete game flow and should be cartridge-aware, not platform-owned. | `fupogfakta/management/commands/` or a shared smoke harness that delegates into cartridge-specific scenario runners. |
|
||||
|
||||
## Recommended ownership split by module
|
||||
|
||||
### Keep in `lobby/`
|
||||
|
||||
- Session creation/join and session-code lifecycle
|
||||
- Generic player membership/presence reads
|
||||
- Generic auth/host checks helpers (if extracted from views)
|
||||
- Generic API error/i18n plumbing
|
||||
- Future `GameRun` / driver orchestration, timers, and cartridge dispatch
|
||||
- A slim generic `session_detail` envelope that can embed cartridge payloads under a dedicated game key
|
||||
|
||||
### Move to `fupogfakta/`
|
||||
|
||||
- Round state queries
|
||||
- Question selection
|
||||
- Lie/guess/reveal/scoreboard/finish transition rules
|
||||
- Score calculation
|
||||
- Answer mixing
|
||||
- Gameplay payload/response builders
|
||||
- Gameplay endpoints and tests
|
||||
- Gameplay smoke command
|
||||
|
||||
## Explicit boundary for `session_detail`
|
||||
|
||||
`session_detail` is currently mixed.
|
||||
|
||||
### Generic part that should remain platform-owned
|
||||
|
||||
- Session identity/status metadata
|
||||
- Player list / presence list
|
||||
- Generic host/player capability envelope if it is game-agnostic
|
||||
|
||||
### FupOgFakta part that should move or be delegated
|
||||
|
||||
- `round_question` payload
|
||||
- `reveal` payload
|
||||
- `scoreboard` payload
|
||||
- `phase_view_model` fields keyed to `lie`, `guess`, `scoreboard`, `finished`, `question_ready`, and 3–5-player MVP rules
|
||||
|
||||
A clean future shape would be:
|
||||
|
||||
```json
|
||||
{
|
||||
"session": {"code": "ABC123", "status": "active", "game_type": "fupogfakta"},
|
||||
"players": [...],
|
||||
"game": {
|
||||
"phase": "lie",
|
||||
"payload": {"round_question": {...}, "reveal": null, "scoreboard": null}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That makes `lobby/` the shell and `fupogfakta/` the authority for game-state payloads.
|
||||
|
||||
## Concrete extraction sequence
|
||||
|
||||
1. **Move pure helpers first**
|
||||
- `_get_current_round_question`
|
||||
- `_select_round_question`
|
||||
- `_prepare_mixed_answers`
|
||||
- `_resolve_scores`
|
||||
- `_build_lie_started_payload`
|
||||
- `_build_reveal_payload`
|
||||
2. **Move gameplay endpoints behind cartridge-owned service functions**
|
||||
- `submit_lie`
|
||||
- `submit_guess`
|
||||
- `start_round`
|
||||
- `start_next_round`
|
||||
- `finish_game`
|
||||
- `reveal_scoreboard`
|
||||
- `calculate_scores`
|
||||
3. **Slim `session_detail` into platform envelope + delegated cartridge payload**
|
||||
4. **Move gameplay tests out of `lobby/tests.py`**
|
||||
5. **Optionally leave compatibility routes in `lobby/urls.py` as a façade** until clients are rewired
|
||||
|
||||
## Risks this map is explicitly preventing
|
||||
|
||||
- Moving only models but leaving hidden phase-transition rules in `lobby/views.py`
|
||||
- Treating `session_detail` as platform-generic while it still leaks cartridge payload semantics
|
||||
- Leaving scoreboard/reveal transition logic behind as an undocumented coupling
|
||||
- Splitting tests incorrectly so regressions stay "green" in `lobby/` while FupOgFakta behavior silently drifts
|
||||
|
||||
## Decision
|
||||
|
||||
For #311 / #312, the repository should treat the following as **game-specific and extraction candidates**:
|
||||
|
||||
- round-question selection
|
||||
- lie/guess/reveal/scoreboard/finish transitions
|
||||
- answer mixing
|
||||
- score resolution
|
||||
- reveal/scoreboard payload builders
|
||||
- FupOgFakta-specific session-detail subpayloads
|
||||
- gameplay flow tests and smoke command
|
||||
|
||||
And it should treat the following as **platform-generic**:
|
||||
|
||||
- session identity/lifecycle
|
||||
- player presence/membership
|
||||
- host authorization shell
|
||||
- generic error transport
|
||||
- future game-driver dispatch/orchestration
|
||||
|
||||
That is the explicit `lobby` vs `fupogfakta` boundary this issue needs before code extraction proceeds.
|
||||
70
fupogfakta/payloads.py
Normal file
70
fupogfakta/payloads.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from .models import GameSession, Player, RoundConfig, RoundQuestion
|
||||
|
||||
|
||||
def build_player_ref(player: Player | None) -> dict | None:
|
||||
if player is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"player_id": player.id,
|
||||
"nickname": player.nickname,
|
||||
}
|
||||
|
||||
|
||||
def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
|
||||
if round_question is None:
|
||||
return None
|
||||
|
||||
lies = [
|
||||
{
|
||||
**build_player_ref(lie.player),
|
||||
"text": lie.text,
|
||||
"created_at": lie.created_at.isoformat(),
|
||||
}
|
||||
for lie in round_question.lies.select_related("player").order_by("created_at", "id")
|
||||
]
|
||||
|
||||
guesses = []
|
||||
for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
|
||||
guess_payload = {
|
||||
**build_player_ref(guess.player),
|
||||
"selected_text": guess.selected_text,
|
||||
"is_correct": guess.is_correct,
|
||||
"created_at": guess.created_at.isoformat(),
|
||||
"fooled_player_id": guess.fooled_player_id,
|
||||
}
|
||||
if guess.fooled_player is not None:
|
||||
guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
|
||||
guesses.append(guess_payload)
|
||||
|
||||
return {
|
||||
"round_question_id": round_question.id,
|
||||
"round_number": round_question.round_number,
|
||||
"prompt": round_question.question.prompt,
|
||||
"correct_answer": round_question.correct_answer,
|
||||
"lies": lies,
|
||||
"guesses": guesses,
|
||||
}
|
||||
|
||||
|
||||
def build_leaderboard(session: GameSession) -> list[dict]:
|
||||
return list(
|
||||
Player.objects.filter(session=session)
|
||||
.order_by("-score", "nickname")
|
||||
.values("id", "nickname", "score")
|
||||
)
|
||||
|
||||
|
||||
def build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
|
||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||
return {
|
||||
"round_number": session.current_round,
|
||||
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
|
||||
"round_question_id": round_question.id,
|
||||
"prompt": round_question.question.prompt,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
}
|
||||
115
fupogfakta/services.py
Normal file
115
fupogfakta/services.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import random
|
||||
|
||||
from .models import GameSession, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
|
||||
|
||||
|
||||
def get_current_round_question(session: GameSession) -> RoundQuestion | None:
|
||||
return (
|
||||
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
|
||||
.select_related("question")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
|
||||
def select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion:
|
||||
existing_round_question = get_current_round_question(session)
|
||||
if existing_round_question is not None:
|
||||
return existing_round_question
|
||||
|
||||
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
|
||||
available_questions = Question.objects.filter(
|
||||
category=round_config.category,
|
||||
is_active=True,
|
||||
).exclude(pk__in=used_question_ids)
|
||||
|
||||
if not available_questions.exists():
|
||||
raise ValueError("no_available_questions")
|
||||
|
||||
question = random.choice(list(available_questions))
|
||||
return RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
|
||||
deduped_answers = list(round_question.mixed_answers or [])
|
||||
if deduped_answers:
|
||||
return deduped_answers
|
||||
|
||||
lie_texts = list(round_question.lies.values_list("text", flat=True))
|
||||
seen = set()
|
||||
for text in [round_question.correct_answer, *lie_texts]:
|
||||
normalized = text.strip().casefold()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
deduped_answers.append(text.strip())
|
||||
|
||||
if len(deduped_answers) < 2:
|
||||
raise ValueError("not_enough_answers_to_mix")
|
||||
|
||||
random.shuffle(deduped_answers)
|
||||
round_question.mixed_answers = deduped_answers
|
||||
round_question.save(update_fields=["mixed_answers"])
|
||||
return deduped_answers
|
||||
|
||||
|
||||
|
||||
def resolve_scores(
|
||||
session: GameSession,
|
||||
round_question: RoundQuestion,
|
||||
round_config: RoundConfig,
|
||||
) -> tuple[list[ScoreEvent], list[dict]]:
|
||||
guesses = list(round_question.guesses.select_related("player"))
|
||||
if not guesses:
|
||||
raise ValueError("no_guesses_submitted")
|
||||
|
||||
bluff_counts: dict[int, int] = {}
|
||||
for guess in guesses:
|
||||
if guess.fooled_player_id:
|
||||
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
|
||||
|
||||
score_events = []
|
||||
|
||||
for guess in guesses:
|
||||
if guess.is_correct:
|
||||
guess.player.score += round_config.points_correct
|
||||
guess.player.save(update_fields=["score"])
|
||||
score_events.append(
|
||||
ScoreEvent(
|
||||
session=session,
|
||||
player=guess.player,
|
||||
delta=round_config.points_correct,
|
||||
reason="guess_correct",
|
||||
meta={"round_question_id": round_question.id, "guess_id": guess.id},
|
||||
)
|
||||
)
|
||||
|
||||
for player_id, fooled_count in bluff_counts.items():
|
||||
delta = fooled_count * round_config.points_bluff
|
||||
player = Player.objects.get(pk=player_id, session=session)
|
||||
player.score += delta
|
||||
player.save(update_fields=["score"])
|
||||
score_events.append(
|
||||
ScoreEvent(
|
||||
session=session,
|
||||
player=player,
|
||||
delta=delta,
|
||||
reason="bluff_success",
|
||||
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
|
||||
)
|
||||
)
|
||||
|
||||
ScoreEvent.objects.bulk_create(score_events)
|
||||
leaderboard = list(
|
||||
Player.objects.filter(session=session)
|
||||
.order_by("-score", "nickname")
|
||||
.values("id", "nickname", "score")
|
||||
)
|
||||
return score_events, leaderboard
|
||||
@@ -1,2 +1,127 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
|
||||
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.services import get_current_round_question, prepare_mixed_answers, resolve_scores, select_round_question
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class FupOgFaktaExtractionSliceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCD23")
|
||||
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
|
||||
self.question_one = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvornår faldt muren?",
|
||||
correct_answer="1989",
|
||||
is_active=True,
|
||||
)
|
||||
self.question_two = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvornår kom euroen?",
|
||||
correct_answer="1999",
|
||||
is_active=True,
|
||||
)
|
||||
self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category)
|
||||
self.alice = Player.objects.create(session=self.session, nickname="Alice")
|
||||
self.bob = Player.objects.create(session=self.session, nickname="Bob")
|
||||
self.clara = Player.objects.create(session=self.session, nickname="Clara")
|
||||
|
||||
def test_select_round_question_skips_already_used_questions_for_session(self):
|
||||
RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=99,
|
||||
question=self.question_one,
|
||||
correct_answer=self.question_one.correct_answer,
|
||||
)
|
||||
|
||||
round_question = select_round_question(self.session, self.round_config)
|
||||
|
||||
self.assertEqual(round_question.question, self.question_two)
|
||||
self.assertEqual(get_current_round_question(self.session), round_question)
|
||||
|
||||
def test_prepare_mixed_answers_dedupes_blank_and_case_variants(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question_one,
|
||||
correct_answer="1989",
|
||||
)
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.alice, text=" 1989 ")
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.bob, text="Nitten niogfirs")
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.clara, text=" ")
|
||||
|
||||
with patch("fupogfakta.services.random.shuffle", side_effect=lambda answers: None):
|
||||
answers = prepare_mixed_answers(round_question)
|
||||
|
||||
self.assertEqual(answers, ["1989", "Nitten niogfirs"])
|
||||
round_question.refresh_from_db()
|
||||
self.assertEqual(round_question.mixed_answers, answers)
|
||||
|
||||
def test_resolve_scores_applies_correct_and_bluff_points(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question_one,
|
||||
correct_answer="1989",
|
||||
)
|
||||
Guess.objects.create(
|
||||
round_question=round_question,
|
||||
player=self.alice,
|
||||
selected_text="1989",
|
||||
is_correct=True,
|
||||
)
|
||||
Guess.objects.create(
|
||||
round_question=round_question,
|
||||
player=self.bob,
|
||||
selected_text="Berlin",
|
||||
is_correct=False,
|
||||
fooled_player=self.clara,
|
||||
)
|
||||
Guess.objects.create(
|
||||
round_question=round_question,
|
||||
player=self.clara,
|
||||
selected_text="Berlin",
|
||||
is_correct=False,
|
||||
fooled_player=self.clara,
|
||||
)
|
||||
|
||||
score_events, leaderboard = resolve_scores(self.session, round_question, self.round_config)
|
||||
|
||||
self.assertEqual(len(score_events), 2)
|
||||
self.alice.refresh_from_db()
|
||||
self.clara.refresh_from_db()
|
||||
self.assertEqual(self.alice.score, self.round_config.points_correct)
|
||||
self.assertEqual(self.clara.score, self.round_config.points_bluff * 2)
|
||||
self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2)
|
||||
self.assertEqual([entry["nickname"] for entry in leaderboard], ["Alice", "Clara", "Bob"])
|
||||
|
||||
def test_payload_builders_expose_fupogfakta_round_contract(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question_one,
|
||||
correct_answer="1989",
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
lie_payload = build_lie_started_payload(self.session, self.round_config, round_question)
|
||||
reveal_payload = build_reveal_payload(round_question)
|
||||
|
||||
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(reveal_payload["correct_answer"], "1989")
|
||||
self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id)
|
||||
self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname)
|
||||
|
||||
@@ -1216,6 +1216,25 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.session.refresh_from_db()
|
||||
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):
|
||||
self.client.login(username="other_reveal", password="secret123")
|
||||
|
||||
@@ -1284,6 +1303,29 @@ class RevealRoundFlowTests(TestCase):
|
||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code)
|
||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
||||
|
||||
@patch("lobby.views.sync_broadcast_phase_event")
|
||||
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_requires_host(self):
|
||||
self.session.status = GameSession.Status.SCOREBOARD
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
388
lobby/views.py
388
lobby/views.py
@@ -8,16 +8,17 @@ from django.http import HttpRequest, JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from fupogfakta.models import (
|
||||
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_leaderboard as _build_leaderboard,
|
||||
build_lie_started_payload as _build_lie_started_payload,
|
||||
build_reveal_payload as _build_reveal_payload,
|
||||
)
|
||||
from fupogfakta.services import (
|
||||
get_current_round_question as _get_current_round_question,
|
||||
prepare_mixed_answers as _prepare_mixed_answers,
|
||||
resolve_scores as _resolve_scores,
|
||||
select_round_question as _select_round_question,
|
||||
)
|
||||
from realtime.broadcast import sync_broadcast_phase_event
|
||||
|
||||
@@ -64,181 +65,57 @@ def _create_unique_session_code() -> str:
|
||||
raise RuntimeError("Could not generate unique session code")
|
||||
|
||||
|
||||
def _build_player_ref(player: Player | None) -> dict | None:
|
||||
if player is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"player_id": player.id,
|
||||
"nickname": player.nickname,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
|
||||
if round_question is None:
|
||||
return None
|
||||
|
||||
lies = [
|
||||
def _build_start_next_round_response(
|
||||
session: GameSession,
|
||||
round_config: RoundConfig,
|
||||
round_question: RoundQuestion,
|
||||
) -> JsonResponse:
|
||||
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
|
||||
return JsonResponse(
|
||||
{
|
||||
**_build_player_ref(lie.player),
|
||||
"text": lie.text,
|
||||
"created_at": lie.created_at.isoformat(),
|
||||
"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,
|
||||
},
|
||||
}
|
||||
for lie in round_question.lies.select_related("player").order_by("created_at", "id")
|
||||
]
|
||||
)
|
||||
|
||||
guesses = []
|
||||
for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
|
||||
guess_payload = {
|
||||
**_build_player_ref(guess.player),
|
||||
"selected_text": guess.selected_text,
|
||||
"is_correct": guess.is_correct,
|
||||
"created_at": guess.created_at.isoformat(),
|
||||
"fooled_player_id": guess.fooled_player_id,
|
||||
|
||||
|
||||
def _build_finish_game_response(session: GameSession) -> JsonResponse:
|
||||
leaderboard = _build_leaderboard(session)
|
||||
winner = leaderboard[0] if leaderboard else None
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": GameSession.Status.FINISHED,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"winner": winner,
|
||||
"leaderboard": leaderboard,
|
||||
}
|
||||
if guess.fooled_player is not None:
|
||||
guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
|
||||
guesses.append(guess_payload)
|
||||
|
||||
return {
|
||||
"round_question_id": round_question.id,
|
||||
"round_number": round_question.round_number,
|
||||
"prompt": round_question.question.prompt,
|
||||
"correct_answer": round_question.correct_answer,
|
||||
"lies": lies,
|
||||
"guesses": guesses,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _build_leaderboard(session: GameSession) -> list[dict]:
|
||||
return list(
|
||||
Player.objects.filter(session=session)
|
||||
.order_by("-score", "nickname")
|
||||
.values("id", "nickname", "score")
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _get_current_round_question(session: GameSession) -> RoundQuestion | None:
|
||||
return (
|
||||
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
|
||||
.select_related("question")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion:
|
||||
existing_round_question = _get_current_round_question(session)
|
||||
if existing_round_question is not None:
|
||||
return existing_round_question
|
||||
|
||||
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
|
||||
available_questions = Question.objects.filter(
|
||||
category=round_config.category,
|
||||
is_active=True,
|
||||
).exclude(pk__in=used_question_ids)
|
||||
|
||||
if not available_questions.exists():
|
||||
raise ValueError("no_available_questions")
|
||||
|
||||
question = random.choice(list(available_questions))
|
||||
return RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
|
||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||
return {
|
||||
"round_number": session.current_round,
|
||||
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
|
||||
"round_question_id": round_question.id,
|
||||
"prompt": round_question.question.prompt,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
|
||||
deduped_answers = list(round_question.mixed_answers or [])
|
||||
if deduped_answers:
|
||||
return deduped_answers
|
||||
|
||||
lie_texts = list(round_question.lies.values_list("text", flat=True))
|
||||
seen = set()
|
||||
for text in [round_question.correct_answer, *lie_texts]:
|
||||
normalized = text.strip().casefold()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
deduped_answers.append(text.strip())
|
||||
|
||||
if len(deduped_answers) < 2:
|
||||
raise ValueError("not_enough_answers_to_mix")
|
||||
|
||||
random.shuffle(deduped_answers)
|
||||
round_question.mixed_answers = deduped_answers
|
||||
round_question.save(update_fields=["mixed_answers"])
|
||||
return deduped_answers
|
||||
|
||||
|
||||
|
||||
def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]:
|
||||
guesses = list(round_question.guesses.select_related("player"))
|
||||
if not guesses:
|
||||
raise ValueError("no_guesses_submitted")
|
||||
|
||||
bluff_counts: dict[int, int] = {}
|
||||
for guess in guesses:
|
||||
if guess.fooled_player_id:
|
||||
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
|
||||
|
||||
score_events = []
|
||||
|
||||
for guess in guesses:
|
||||
if guess.is_correct:
|
||||
guess.player.score += round_config.points_correct
|
||||
guess.player.save(update_fields=["score"])
|
||||
score_events.append(
|
||||
ScoreEvent(
|
||||
session=session,
|
||||
player=guess.player,
|
||||
delta=round_config.points_correct,
|
||||
reason="guess_correct",
|
||||
meta={"round_question_id": round_question.id, "guess_id": guess.id},
|
||||
)
|
||||
)
|
||||
|
||||
for player_id, fooled_count in bluff_counts.items():
|
||||
delta = fooled_count * round_config.points_bluff
|
||||
player = Player.objects.get(pk=player_id, session=session)
|
||||
player.score += delta
|
||||
player.save(update_fields=["score"])
|
||||
score_events.append(
|
||||
ScoreEvent(
|
||||
session=session,
|
||||
player=player,
|
||||
delta=delta,
|
||||
reason="bluff_success",
|
||||
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
|
||||
)
|
||||
)
|
||||
|
||||
ScoreEvent.objects.bulk_create(score_events)
|
||||
return score_events, _build_leaderboard(session)
|
||||
|
||||
|
||||
|
||||
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
||||
if session.status != GameSession.Status.REVEAL:
|
||||
return session
|
||||
@@ -1091,72 +968,61 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if session.host_id != request.user.id:
|
||||
return api_error(request, code="host_only_start_next_round", status=403)
|
||||
|
||||
should_broadcast = False
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status != GameSession.Status.SCOREBOARD:
|
||||
locked_session = GameSession.objects.select_for_update().select_related("host").get(pk=session.pk)
|
||||
next_round_config = None
|
||||
round_question = 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:
|
||||
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:
|
||||
round_question = _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"])
|
||||
should_broadcast = True
|
||||
elif locked_session.status == GameSession.Status.LIE:
|
||||
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 round_question is None:
|
||||
return api_error(request, code="next_round_invalid_phase", status=400)
|
||||
else:
|
||||
return api_error(request, code="next_round_invalid_phase", status=400)
|
||||
|
||||
previous_round_config = RoundConfig.objects.filter(
|
||||
session=locked_session,
|
||||
number=locked_session.current_round,
|
||||
).select_related("category").first()
|
||||
if previous_round_config is None:
|
||||
return api_error(request, code="round_config_missing", status=400)
|
||||
|
||||
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,
|
||||
if should_broadcast:
|
||||
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,
|
||||
)
|
||||
locked_session.current_round = next_round_number
|
||||
|
||||
try:
|
||||
round_question = _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,
|
||||
},
|
||||
}
|
||||
)
|
||||
return _build_start_next_round_response(locked_session, next_round_config, round_question)
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
@@ -1171,40 +1037,26 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if session.host_id != request.user.id:
|
||||
return api_error(request, code="host_only_finish_game", status=403)
|
||||
|
||||
should_broadcast = False
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status != GameSession.Status.SCOREBOARD:
|
||||
if locked_session.status == GameSession.Status.SCOREBOARD:
|
||||
locked_session.status = GameSession.Status.FINISHED
|
||||
locked_session.save(update_fields=["status"])
|
||||
should_broadcast = True
|
||||
elif locked_session.status != GameSession.Status.FINISHED:
|
||||
return api_error(request, code="finish_game_invalid_phase", status=400)
|
||||
|
||||
if should_broadcast:
|
||||
leaderboard = _build_leaderboard(locked_session)
|
||||
winner = leaderboard[0] if leaderboard else None
|
||||
sync_broadcast_phase_event(
|
||||
locked_session.code,
|
||||
"phase.game_over",
|
||||
{"winner": winner, "leaderboard": list(leaderboard)},
|
||||
)
|
||||
|
||||
locked_session.status = GameSession.Status.FINISHED
|
||||
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,
|
||||
}
|
||||
)
|
||||
return _build_finish_game_response(locked_session)
|
||||
|
||||
|
||||
@require_POST
|
||||
|
||||
Reference in New Issue
Block a user