From 592c2653314cb6eccccda4f18987290c68ff5a34 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Mon, 16 Mar 2026 18:57:29 +0000 Subject: [PATCH 1/3] docs(architecture): map lobby vs fupogfakta extraction boundary refs #311 #312 --- ...SUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md diff --git a/docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md b/docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md new file mode 100644 index 0000000..9b5b6d2 --- /dev/null +++ b/docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md @@ -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. From 2ee235c6c08761fb7766531a07b1aaf6c160e341 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 05:37:31 +0000 Subject: [PATCH 2/3] refactor(fupogfakta): extract first lobby gameplay slice (#312) --- fupogfakta/payloads.py | 70 +++++++++++++++ fupogfakta/services.py | 115 ++++++++++++++++++++++++ fupogfakta/tests.py | 127 +++++++++++++++++++++++++- lobby/views.py | 196 +++-------------------------------------- 4 files changed, 322 insertions(+), 186 deletions(-) create mode 100644 fupogfakta/payloads.py create mode 100644 fupogfakta/services.py diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py new file mode 100644 index 0000000..9024e6b --- /dev/null +++ b/fupogfakta/payloads.py @@ -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, + } diff --git a/fupogfakta/services.py b/fupogfakta/services.py new file mode 100644 index 0000000..562d2bb --- /dev/null +++ b/fupogfakta/services.py @@ -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 diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 4929020..d64e10c 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -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) diff --git a/lobby/views.py b/lobby/views.py index 31024fa..414b8a0 100644 --- a/lobby/views.py +++ b/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,6 @@ 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 = [ - { - **_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 _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 From 7de843e44bb87f3ff823c949b07c8115ffb26f72 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Tue, 17 Mar 2026 06:21:33 +0000 Subject: [PATCH 3/3] fix(lobby): use extracted fupogfakta helpers --- lobby/views.py | 169 ------------------------------------------------- 1 file changed, 169 deletions(-) diff --git a/lobby/views.py b/lobby/views.py index b2d7e6c..cbd43fc 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -65,132 +65,6 @@ 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 = [ - { - **_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 _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 @@ -204,49 +78,6 @@ def _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> Roun -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)