45 Commits

Author SHA1 Message Date
21e390d200 test: tighten pr320 lobby ownership guard
All checks were successful
CI / test-and-quality (push) Successful in 4m6s
CI / test-and-quality (pull_request) Successful in 4m8s
2026-03-18 06:44:54 +01:00
df9b6d192c chore: refresh i18n parity artifact
All checks were successful
CI / test-and-quality (push) Successful in 4m6s
CI / test-and-quality (pull_request) Successful in 4m6s
2026-03-18 05:19:53 +00:00
702f130de2 test(lobby): lock issue-310 transition ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 4m14s
CI / test-and-quality (pull_request) Successful in 4m15s
2026-03-18 05:00:48 +00:00
92f2cda83a test(lobby): lock scoreboard next-round bootstrap target
All checks were successful
CI / test-and-quality (push) Successful in 4m3s
CI / test-and-quality (pull_request) Successful in 4m5s
2026-03-18 04:36:20 +00:00
d080f05661 fix(ci): retain lobby payload ownership export
All checks were successful
CI / test-and-quality (push) Successful in 4m0s
CI / test-and-quality (pull_request) Successful in 4m2s
2026-03-18 04:17:17 +00:00
e246bd648f fix(gameplay): scope next-round selection to target round
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-18 03:54:25 +00:00
06e4ccac61 fix(lobby): restore scoreboard payload import
Some checks failed
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s
2026-03-18 02:55:30 +00:00
3c9214178e fix(ci): remove stale scoreboard payload import
Some checks failed
CI / test-and-quality (pull_request) Failing after 3m34s
CI / test-and-quality (push) Failing after 3m35s
2026-03-18 02:33:36 +00:00
feddd910eb fix: restore reveal payload import in submit_guess
Some checks failed
CI / test-and-quality (push) Failing after 12s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-18 02:12:11 +00:00
dd615796f4 refactor(payloads): delegate session detail gameplay payload
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-18 01:33:47 +00:00
d2cdf16322 test(lobby): lock repaired stale next-round replay
All checks were successful
CI / test-and-quality (push) Successful in 4m0s
CI / test-and-quality (pull_request) Successful in 4m1s
2026-03-18 00:46:51 +00:00
101c3f9c26 fix(gameplay): repair stale next-round question drift
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m55s
CI / test-and-quality (push) Successful in 3m56s
2026-03-17 23:25:02 +00:00
65eb5685f7 test(lobby): lock scoreboard ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 3m57s
CI / test-and-quality (pull_request) Successful in 3m58s
2026-03-17 23:07:22 +00:00
8a70645fda test(lobby): lock session detail payload delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m53s
CI / test-and-quality (pull_request) Successful in 3m54s
2026-03-17 22:25:03 +00:00
2cd8d940f9 test(lobby): lock session detail ownership boundary
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m55s
CI / test-and-quality (push) Successful in 3m57s
2026-03-17 21:22:39 +00:00
72bc5997ff test(gameplay): keep lobby delegation checks in lobby suite
All checks were successful
CI / test-and-quality (push) Successful in 4m3s
CI / test-and-quality (pull_request) Successful in 4m5s
2026-03-17 21:03:50 +00:00
c9e64bc8a8 test(gameplay): lock lobby delegation for host transitions (#310)
All checks were successful
CI / test-and-quality (push) Successful in 4m4s
CI / test-and-quality (pull_request) Successful in 4m4s
2026-03-17 20:48:39 +00:00
1c7f1e7c53 fix(ci): satisfy PR #320 lobby lint contract
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m56s
CI / test-and-quality (push) Successful in 3m56s
2026-03-17 20:06:12 +00:00
03850b5ed5 refactor(gameplay): extract start/show transitions from lobby views
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-17 19:44:13 +00:00
16c9cf6b57 refactor(gameplay): extract round start payload builders
All checks were successful
CI / test-and-quality (push) Successful in 3m51s
CI / test-and-quality (pull_request) Successful in 3m53s
2026-03-17 19:15:44 +00:00
c45f04f9f1 refactor(gameplay): extract round question payload builder
All checks were successful
CI / test-and-quality (push) Successful in 3m41s
CI / test-and-quality (pull_request) Successful in 3m42s
2026-03-17 18:55:28 +00:00
319038555a refactor(gameplay): move phase view model into cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m42s
CI / test-and-quality (push) Successful in 3m44s
2026-03-17 18:29:11 +00:00
e318711148 test(gameplay): lock refreshed next-round deadline contract
All checks were successful
CI / test-and-quality (push) Successful in 3m47s
CI / test-and-quality (pull_request) Successful in 3m48s
2026-03-17 17:44:34 +00:00
a9c6e4fd79 test(lobby): lock host transition ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m44s
2026-03-17 17:25:22 +00:00
7eb3507934 fix(gameplay): refresh stale next-round bootstrap config
All checks were successful
CI / test-and-quality (push) Successful in 3m39s
CI / test-and-quality (pull_request) Successful in 3m40s
2026-03-17 17:06:59 +00:00
dfa197b33b refactor(gameplay): keep host transition payloads in cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m37s
CI / test-and-quality (push) Successful in 3m38s
2026-03-17 16:06:46 +00:00
fefc5ecd56 test(lobby): lock refreshed deadline for reused bootstrap round
All checks were successful
CI / test-and-quality (push) Successful in 3m40s
CI / test-and-quality (pull_request) Successful in 3m40s
2026-03-17 15:42:00 +00:00
94f940e5d8 refactor(gameplay): delegate host transition events from service
All checks were successful
CI / test-and-quality (push) Successful in 3m35s
CI / test-and-quality (pull_request) Successful in 3m35s
2026-03-17 13:43:44 +00:00
a102a72a77 fix(gameplay): refresh reused bootstrap lie timer
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m27s
CI / test-and-quality (push) Successful in 3m27s
2026-03-17 13:21:52 +00:00
d272e35a79 refactor(gameplay): keep host transition events in payload layer
All checks were successful
CI / test-and-quality (push) Successful in 3m28s
CI / test-and-quality (pull_request) Successful in 3m28s
2026-03-17 13:04:58 +00:00
8a07433f11 refactor(gameplay): move transition event composition into service
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m36s
CI / test-and-quality (push) Successful in 3m37s
2026-03-17 11:58:39 +00:00
9baade0105 test(gameplay): lock lobby replay side-effect delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m43s
CI / test-and-quality (pull_request) Successful in 3m44s
2026-03-17 11:35:19 +00:00
35e2d09ee3 test(gameplay): lock lobby host-transition delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m34s
CI / test-and-quality (pull_request) Successful in 3m34s
2026-03-17 10:55:41 +00:00
a916da12a7 refactor: move scoreboard promotion out of lobby view
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m26s
CI / test-and-quality (push) Successful in 3m28s
2026-03-17 10:41:09 +00:00
7f20cb3bf9 refactor(gameplay): move scoreboard phase events into cartridge payloads
All checks were successful
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 3m27s
2026-03-17 10:13:41 +00:00
f736f4f74e refactor(gameplay): move scoreboard transitions into cartridge service
All checks were successful
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m28s
2026-03-17 09:29:02 +00:00
8247787404 refactor(gameplay): move transition payload builders to cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m30s
CI / test-and-quality (push) Successful in 3m31s
2026-03-17 09:08:14 +00:00
6722be43d4 merge(main): resolve PR #320 scoreboard transition conflict
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m24s
CI / test-and-quality (push) Successful in 3m26s
2026-03-17 08:45:55 +00:00
212549373b fix(gameplay): gate next-round replay on scoreboard exit marker
All checks were successful
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 3m26s
2026-03-17 08:25:57 +00:00
47659ed673 test(gameplay): guard extracted lobby helper wiring
All checks were successful
CI / test-and-quality (push) Successful in 3m26s
CI / test-and-quality (pull_request) Successful in 3m26s
2026-03-17 07:43:49 +00:00
root
c8750af4d8 fix(gameplay): restore extracted helper imports
All checks were successful
CI / test-and-quality (push) Successful in 3m29s
CI / test-and-quality (pull_request) Successful in 3m29s
2026-03-17 07:24:50 +00:00
44e480931b fix(gameplay): gate next-round replay on prior scoreboard exit
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-17 07:05:56 +00:00
1839b30e0a merge(main): resolve PR #320 gameplay conflicts
Some checks failed
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s
2026-03-17 06:44:21 +00:00
542d326615 fix(gameplay): gate next-round replay on prior transition
All checks were successful
CI / test-and-quality (push) Successful in 3m24s
CI / test-and-quality (pull_request) Successful in 3m27s
2026-03-17 06:21:00 +00:00
d36d256daf fix(gameplay): make scoreboard host exits idempotent
All checks were successful
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m45s
2026-03-17 05:41:13 +00:00
9 changed files with 1550 additions and 431 deletions

View 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.

View File

@@ -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),
),
]

View File

@@ -83,6 +83,7 @@ class RoundConfig(models.Model):
points_bluff = models.IntegerField(default=2)
lie_seconds = models.PositiveIntegerField(default=45)
guess_seconds = models.PositiveIntegerField(default=30)
started_from_scoreboard = models.BooleanField(default=False)
class Meta:
unique_together = (("session", "number"),)

View File

@@ -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:
if round_question is 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_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"],
}

View File

@@ -1,11 +1,59 @@
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 (
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
RoundQuestion.objects.filter(session=session, round_number=round_number)
.select_related("question")
.order_by("-id")
.first()
@@ -13,9 +61,37 @@ def get_current_round_question(session: GameSession) -> RoundQuestion | None:
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:
def get_current_round_question(session: GameSession) -> RoundQuestion | None:
return get_round_question(session, session.current_round)
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
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")
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(
session=session,
round_number=session.current_round,
round_number=target_round_number,
question=question,
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(
session: GameSession,
round_question: RoundQuestion,

View File

@@ -1,11 +1,27 @@
from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model
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.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
from fupogfakta.payloads import (
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()
@@ -63,6 +79,232 @@ class FupOgFaktaExtractionSliceTests(TestCase):
round_question.refresh_from_db()
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):
round_question = RoundQuestion.objects.create(
session=self.session,
@@ -117,11 +359,52 @@ class FupOgFaktaExtractionSliceTests(TestCase):
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)
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["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)
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"])

View File

@@ -1,3 +1,4 @@
import inspect
import json
import tempfile
from datetime import timedelta
@@ -10,6 +11,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from fupogfakta import payloads as gameplay_payloads, services as gameplay_services
from fupogfakta.models import (
Category,
GameSession,
@@ -21,11 +23,343 @@ from fupogfakta.models import (
RoundQuestion,
ScoreEvent,
)
from lobby import views as lobby_views
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
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):
def setUp(self):
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()["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):
self.client.login(username="host", password="secret123")
@@ -1289,6 +1623,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")
@@ -1348,7 +1701,12 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
self.assertTrue(
RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists()
RoundConfig.objects.filter(
session=self.session,
number=2,
category=self.category,
started_from_scoreboard=True,
).exists()
)
self.assertTrue(
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[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):
self.client.login(username="host_reveal", password="secret123")
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(
session=self.session,
round_number=2,
question=self.next_question,
correct_answer=self.next_question.correct_answer,
shown_at=stale_shown_at,
mixed_answers=["Stale truth", "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()
self.assertEqual(self.session.status, GameSession.Status.LIE)
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.lies.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()
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["round_question"]["id"], stale_round_question.id)
self.assertEqual(detail_payload["round_question"]["answers"], [])
self.assertIsNone(detail_payload["reveal"])
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):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])

View File

@@ -1,29 +1,39 @@
import json
import random
from datetime import timedelta
import json
import random
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction
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 GameSession, Guess, LieAnswer, Player, 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,
build_scoreboard_phase_event as _build_scoreboard_phase_event,
build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload,
)
from fupogfakta.services import (
finish_game as _finish_game,
get_current_round_question as _get_current_round_question,
prepare_mixed_answers as _prepare_mixed_answers,
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
resolve_scores as _resolve_scores,
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 .i18n import api_error
_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (
_select_round_question,
_build_scoreboard_phase_event,
)
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6
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:
if session.status != GameSession.Status.REVEAL:
return 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,
)
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
@@ -268,21 +194,10 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
session = _maybe_promote_reveal_to_scoreboard(session)
current_round_question = _get_current_round_question(session)
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(
gameplay_payload = _build_session_detail_gameplay_payload(
session,
current_round_question=current_round_question,
players_count=len(players),
has_round_question=bool(current_round_question),
)
return JsonResponse(
@@ -295,14 +210,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"players_count": len(players),
},
"players": players,
"round_question": round_question_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,
**gameplay_payload,
}
)
@@ -338,95 +246,23 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
status=403,
)
if session.status != GameSession.Status.LOBBY:
return api_error(
request,
code="round_start_invalid_phase",
status=400,
)
try:
category = Category.objects.get(slug=category_slug, is_active=True)
except Category.DoesNotExist:
return api_error(
request,
code="category_not_found",
status=404,
)
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)
transition = _start_round(session, category_slug)
except ValueError as exc:
error_code = str(exc)
error_status = {
"category_not_found": 404,
"round_already_configured": 409,
}.get(error_code, 400)
return api_error(request, code=error_code, status=error_status)
sync_broadcast_phase_event(
session.code,
"phase.lie_started",
lie_started_payload,
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return JsonResponse(
{
"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,
)
return JsonResponse(transition.response_payload, status=201)
@require_POST
@@ -450,60 +286,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
status=403,
)
if session.status != GameSession.Status.LIE:
return api_error(
request,
code="show_question_invalid_phase",
status=400,
)
try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
return api_error(
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)
transition = _show_question(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
sync_broadcast_phase_event(
session.code,
"phase.question_shown",
{
"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,
},
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return JsonResponse(
{
"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,
)
return JsonResponse(transition.response_payload, status=201)
@require_POST
@@ -902,22 +696,18 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id:
return api_error(request, code="host_only_view_scoreboard", status=403)
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}:
return api_error(request, code="scoreboard_invalid_phase", status=400)
leaderboard = _build_leaderboard(session)
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
)
return JsonResponse(transition.response_payload)
@require_POST
@@ -933,74 +723,19 @@ 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)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.SCOREBOARD:
return api_error(request, code="next_round_invalid_phase", status=400)
try:
transition = _start_next_round(session)
except ValueError as exc:
return api_error(request, code=str(exc), 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 transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
locked_session.current_round = next_round_number
try:
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,
},
}
)
return JsonResponse(transition.response_payload)
@require_POST
@login_required
@@ -1015,40 +750,19 @@ 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)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.SCOREBOARD:
return api_error(request, code="finish_game_invalid_phase", status=400)
try:
transition = _finish_game(session)
except ValueError as exc:
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
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 JsonResponse(transition.response_payload)
@require_POST

View File

@@ -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.",
"source_of_truth": {
"catalog": "shared/i18n/lobby.json",
"catalog_sha256": "e3ed39f2fa25622c01b450bd14fd4da5fc7f96c0d9635bb819f73cae14203beb",
"catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb",
"source_paths": [
"lobby/views.py",
"frontend/src/spa/vertical-slice.ts",
@@ -24,28 +24,7 @@
},
"parity": {
"status": "pass",
"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"
],
"django_backend_error_codes_used_by_mvp": [],
"angular_frontend_error_fallback_keys_used_by_mvp": [
"join_failed",
"session_code_required",
@@ -158,36 +137,8 @@
"player.submit_lie",
"player.title"
],
"backend_codes_mapped_to_frontend_error_keys": {
"category_has_no_questions": "start_round_failed",
"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"
],
"backend_codes_mapped_to_frontend_error_keys": {},
"unique_frontend_error_keys_reached_from_django": [],
"blocking_issues": {
"missing_backend_codes": [],
"missing_backend_translations": [],
@@ -201,11 +152,6 @@
"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.",
"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"
}
]
}