15 tasks across 8 batches covering: - Celery infrastructure - GameRun model + GameDriver interface - FupOgFaktaConfig relational presets - LieReaction model, reveal_order, ScoreEvent removal - Full FupOgFaktaDriver with all phase transitions - Platform play/pause/exit endpoints - Fupogfakta lie/guess/react endpoints - Angular frontend game screens rebuild - Cleanup of obsolete manual-advance endpoints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
72 KiB
Fup og Fakta — Game Engine Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement a fully working Fup og Fakta game on top of a pluggable game-platform engine with Celery-driven timers, incremental reveal scoring, emoji reactions, and post-game awards.
Architecture: Platform layer (lobby/) provides GameRun, GameDriver interface, and Celery timer dispatch. Each game cartridge (starting with fupogfakta/) owns its models, config, and phase logic. The Angular frontend gets proper game screens replacing the current developer control panels.
Tech Stack: Django 6.0.2, Celery + Redis, Django Channels (WebSocket already built), Angular 19 standalone components, Vitest.
Reading Before You Start
docs/plans/2026-03-09-fupogfakta-game-engine-design.md— full design, read this firstlobby/views.py— existing REST endpoints (most will be replaced)fupogfakta/models.py— existing modelsrealtime/broadcast.py— sync_broadcast_phase_event helperpartyhub/settings.py— env-driven config pattern used throughout
Run tests before starting: .venv/bin/python manage.py test lobby realtime → must be 78/78 green.
Batch 1 — Celery Infrastructure
No breaking changes. Additive only. Existing tests stay green throughout.
Task 1: Add Celery to dependencies
Files:
- Modify:
requirements.txt
Step 1: Add celery and redis to requirements
celery>=5.3,<6
redis>=5.0,<6
redis is the pure-Python Redis client Celery uses as a broker. (channels-redis uses it too but declares it as a dep — add explicitly for clarity.)
Step 2: Install
.venv/bin/pip install celery>=5.3,<6 redis>=5.0,<6
Expected: installs without errors.
Step 3: Commit
git add requirements.txt
git commit -m "chore: add celery + redis to requirements"
Task 2: Create Celery app
Files:
- Create:
partyhub/celery.py - Modify:
partyhub/__init__.py
Step 1: Create partyhub/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings')
app = Celery('partyhub')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
Step 2: Read partyhub/__init__.py first, then update it
Add at the top (after any existing content):
from .celery import app as celery_app
__all__ = ('celery_app',)
Step 3: Add Celery config to partyhub/settings.py
Add after the CHANNEL_LAYERS block:
CELERY_BROKER_URL = f"redis://{env('CELERY_REDIS_HOST', '127.0.0.1')}:{env('CELERY_REDIS_PORT', '6379')}/1"
CELERY_RESULT_BACKEND = CELERY_BROKER_URL
CELERY_TASK_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TIMEZONE = TIME_ZONE
# In tests: use synchronous task execution (no broker needed)
CELERY_TASK_ALWAYS_EAGER = _testing
CELERY_TASK_EAGER_PROPAGATES = _testing
Step 4: Verify Django check still passes
.venv/bin/python manage.py check
Expected: System check identified no issues (0 silenced).
Step 5: Commit
git add partyhub/celery.py partyhub/__init__.py partyhub/settings.py
git commit -m "feat(platform): add celery app and broker config"
Task 3: Create the timer Celery task (skeleton)
Files:
- Create:
lobby/tasks.py
Step 1: Write failing test
In lobby/tests.py, add at the bottom:
class TimerTaskStaleGuardTest(TestCase):
def test_stale_task_does_nothing_when_run_not_found(self):
from lobby.tasks import handle_timer_expired
# Should not raise — run_id that does not exist is silently ignored
handle_timer_expired(run_id=99999, expected_state="LIE_PHASE")
Step 2: Run test to verify it fails
.venv/bin/python manage.py test lobby.tests.TimerTaskStaleGuardTest --verbosity=2
Expected: ImportError or ModuleNotFoundError for lobby.tasks.
Step 3: Create lobby/tasks.py
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=0)
def handle_timer_expired(self, run_id: int, expected_state: str) -> None:
"""
Fired by Celery when a phase timer expires.
Guards against stale tasks: if the GameRun no longer exists or its
current_state no longer matches expected_state (e.g. after pause/resume),
the task silently exits.
Full driver dispatch is wired in Batch 5 once GameRun model exists.
"""
# Import here to avoid circular imports at module load time
try:
from lobby.models import GameRun # will exist after Batch 2
except ImportError:
logger.warning("handle_timer_expired: GameRun model not yet available (pre-migration)")
return
try:
run = GameRun.objects.select_related('session').get(pk=run_id)
except GameRun.DoesNotExist:
logger.info("handle_timer_expired: run %s not found — stale task, ignoring", run_id)
return
if run.current_state != expected_state:
logger.info(
"handle_timer_expired: run %s state is %r, expected %r — stale task, ignoring",
run_id, run.current_state, expected_state,
)
return
# Full transition logic wired in Batch 5
logger.info("handle_timer_expired: run %s state=%r — TODO: dispatch driver", run_id, expected_state)
Step 4: Run test to verify it passes
.venv/bin/python manage.py test lobby.tests.TimerTaskStaleGuardTest --verbosity=2
Expected: PASS (ImportError path is handled gracefully).
Step 5: Commit
git add lobby/tasks.py lobby/tests.py
git commit -m "feat(platform): add handle_timer_expired celery task skeleton"
Batch 2 — GameRun Model + GameDriver Interface
Task 4: Add game_type field to GameSession and simplify status
Files:
- Modify:
fupogfakta/models.py - Create:
fupogfakta/migrations/0005_gamesession_game_type_status_active.py(auto-generated)
Step 1: Write failing test
In lobby/tests.py, in an appropriate test class or new one:
class GameSessionGameTypeTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='host2', password='pw')
def test_game_session_has_game_type_field(self):
session = GameSession.objects.create(host=self.user, code='GGTYPE')
self.assertEqual(session.game_type, 'fupogfakta')
def test_game_session_status_active_exists(self):
session = GameSession.objects.create(host=self.user, code='ACTSTS')
session.status = GameSession.Status.ACTIVE
session.save()
session.refresh_from_db()
self.assertEqual(session.status, GameSession.Status.ACTIVE)
Step 2: Run test to verify it fails
.venv/bin/python manage.py test lobby.tests.GameSessionGameTypeTest --verbosity=2
Expected: AttributeError: type object 'GameSession' has no attribute 'game_type'
Step 3: Modify fupogfakta/models.py — add game_type and ACTIVE status
In the GameSession class, add game_type field and ACTIVE to the Status enum:
class Status(models.TextChoices):
LOBBY = "lobby", "Lobby"
ACTIVE = "active", "Aktiv" # ADD THIS — game is running (state detail in GameRun)
LIE = "lie", "Løgnfase" # keep for now — removed in Batch 6
GUESS = "guess", "Gættefase" # keep for now
REVEAL = "reveal", "Reveal" # keep for now
FINISHED = "finished", "Afsluttet"
And add the field (after current_round):
game_type = models.CharField(max_length=50, default='fupogfakta')
Step 4: Generate and apply migration
.venv/bin/python manage.py makemigrations fupogfakta --name gamesession_game_type_active_status
.venv/bin/python manage.py migrate
Step 5: Run test to verify it passes
.venv/bin/python manage.py test lobby.tests.GameSessionGameTypeTest --verbosity=2
Expected: PASS.
Step 6: Run full test suite — must stay green
.venv/bin/python manage.py test lobby realtime --verbosity=1
Expected: all passing.
Step 7: Commit
git add fupogfakta/models.py fupogfakta/migrations/ lobby/tests.py
git commit -m "feat(platform): add game_type field and ACTIVE status to GameSession"
Task 5: Create GameDriver interface
Files:
- Create:
lobby/driver.py
Step 1: Write failing test
# lobby/tests.py — add to bottom
class GameDriverInterfaceTest(TestCase):
def test_cannot_instantiate_abstract_driver(self):
from lobby.driver import GameDriver
with self.assertRaises(TypeError):
GameDriver()
def test_phase_result_is_namedtuple(self):
from lobby.driver import PhaseResult
result = PhaseResult(next_state='SOME_STATE', duration_seconds=30, broadcast_payload={'type': 'x'})
self.assertEqual(result.next_state, 'SOME_STATE')
self.assertEqual(result.duration_seconds, 30)
Step 2: Run test to verify it fails
.venv/bin/python manage.py test lobby.tests.GameDriverInterfaceTest --verbosity=2
Expected: ModuleNotFoundError: No module named 'lobby.driver'
Step 3: Create lobby/driver.py
from abc import ABC, abstractmethod
from typing import NamedTuple
class PhaseResult(NamedTuple):
"""Return value from GameDriver phase methods."""
next_state: str
duration_seconds: int | None # None = no timer, wait for manual trigger
broadcast_payload: dict
class GameDriver(ABC):
"""
Abstract base class for game cartridges.
Each game app implements this and registers via GAME_DRIVERS in settings.
The platform (lobby/) calls these methods; game logic never touches
GameRun directly.
"""
game_type: str # must be set on concrete subclass, e.g. "fupogfakta"
@abstractmethod
def on_game_start(self, session, run, config: dict) -> PhaseResult:
"""Called when host presses Play from LOBBY. Returns initial phase."""
@abstractmethod
def on_timer_expired(self, session, run, config: dict) -> PhaseResult:
"""Called when Celery timer fires for run.current_state. Returns next phase."""
@abstractmethod
def on_exit(self, session, run) -> None:
"""
Called when host presses Exit. Must delete all ephemeral game data
(RoundQuestion, LieAnswer, Guess, LieReaction, Player, GameRun).
GameSession row is kept.
"""
def on_pause(self, session, run) -> None:
"""Default: no-op. Override if game needs to pause internal state."""
def on_resume(self, session, run) -> None:
"""Default: no-op. Override if game needs to handle resume."""
def get_driver(game_type: str) -> GameDriver:
"""
Look up and return a registered GameDriver instance by game_type.
Raises KeyError if game_type is not registered.
Import and call this from the platform task/views.
"""
from django.conf import settings
registry: dict[str, str] = getattr(settings, 'GAME_DRIVERS', {})
if game_type not in registry:
raise KeyError(f"No GameDriver registered for game_type={game_type!r}. "
f"Add it to GAME_DRIVERS in settings.py.")
# Lazy import the driver class from dotted path
module_path, class_name = registry[game_type].rsplit('.', 1)
import importlib
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
return cls()
Step 4: Register driver path in partyhub/settings.py
Add after the CELERY block:
GAME_DRIVERS = {
'fupogfakta': 'fupogfakta.driver.FupOgFaktaDriver',
}
Step 5: Run test to verify it passes
.venv/bin/python manage.py test lobby.tests.GameDriverInterfaceTest --verbosity=2
Expected: PASS.
Step 6: Commit
git add lobby/driver.py partyhub/settings.py lobby/tests.py
git commit -m "feat(platform): add GameDriver abstract interface and get_driver registry"
Task 6: Create GameRun model
Files:
- Create:
lobby/models.py(currently empty scaffold) - Create migration
Step 1: Write failing test
# lobby/tests.py
class GameRunModelTest(TestCase):
def setUp(self):
from django.contrib.auth import get_user_model
User = get_user_model()
self.user = User.objects.create_user(username='runhost', password='pw')
self.session = GameSession.objects.create(host=self.user, code='RUNTEST')
def test_create_game_run(self):
from lobby.models import GameRun
run = GameRun.objects.create(
session=self.session,
current_state='LIE_PHASE',
)
self.assertEqual(run.current_state, 'LIE_PHASE')
self.assertFalse(run.is_paused)
self.assertIsNone(run.phase_deadline)
self.assertIsNone(run.celery_task_id)
self.assertEqual(run.state_data, {})
def test_game_run_is_one_to_one_with_session(self):
from lobby.models import GameRun
from django.db import IntegrityError
GameRun.objects.create(session=self.session, current_state='LIE_PHASE')
with self.assertRaises(IntegrityError):
GameRun.objects.create(session=self.session, current_state='GUESS_PHASE')
Step 2: Run test to verify it fails
.venv/bin/python manage.py test lobby.tests.GameRunModelTest --verbosity=2
Expected: fails — GameRun does not exist.
Step 3: Read and update lobby/models.py
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class GameRun(models.Model):
"""
Ephemeral runtime state for an active game session.
Created when host presses Play. Deleted when game ends (exit or finish).
"""
session = models.OneToOneField(
'fupogfakta.GameSession',
on_delete=models.CASCADE,
related_name='run',
)
current_state = models.CharField(max_length=64)
phase_deadline = models.DateTimeField(null=True, blank=True)
is_paused = models.BooleanField(default=False)
paused_remaining_seconds = models.FloatField(null=True, blank=True)
celery_task_id = models.CharField(max_length=255, null=True, blank=True)
# Game-specific snapshot data (config, current question id, etc.)
state_data = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"GameRun({self.session.code}, state={self.current_state})"
Step 4: Generate and apply migration
.venv/bin/python manage.py makemigrations lobby --name add_gamerun
.venv/bin/python manage.py migrate
Step 5: Run test to verify it passes
.venv/bin/python manage.py test lobby.tests.GameRunModelTest --verbosity=2
Expected: PASS.
Step 6: Run full suite
.venv/bin/python manage.py test lobby realtime --verbosity=1
Expected: all green.
Step 7: Commit
git add lobby/models.py lobby/migrations/ lobby/tests.py
git commit -m "feat(platform): add GameRun model"
Batch 3 — Config System
Task 7: BaseGameConfig abstract model + FupOgFaktaConfig
Files:
- Create:
lobby/base_config.py - Modify:
fupogfakta/models.py - Create migration
Step 1: Write failing test
# fupogfakta/tests.py (create if empty)
from django.test import TestCase
from django.contrib.auth import get_user_model
User = get_user_model()
class FupOgFaktaConfigTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='cfghost', password='pw')
def test_system_default_config_exists_after_migration(self):
from fupogfakta.models import FupOgFaktaConfig
default = FupOgFaktaConfig.objects.filter(user=None, is_default=True).first()
# System default is created by a data migration (Task 8)
self.assertIsNotNone(default)
self.assertEqual(default.num_rounds, 3)
self.assertEqual(default.lie_seconds, 45)
def test_user_can_have_multiple_presets(self):
from fupogfakta.models import FupOgFaktaConfig
FupOgFaktaConfig.objects.create(user=self.user, name='Quick', is_default=True)
FupOgFaktaConfig.objects.create(user=self.user, name='Long', is_default=False)
self.assertEqual(FupOgFaktaConfig.objects.filter(user=self.user).count(), 2)
def test_resolve_config_returns_user_default_if_exists(self):
from fupogfakta.config import resolve_config
user_cfg = FupOgFaktaConfig.objects.create(
user=self.user, name='Mine', is_default=True, lie_seconds=20
)
result = resolve_config(self.user)
self.assertEqual(result['lie_seconds'], 20)
def test_resolve_config_falls_back_to_system_default(self):
from fupogfakta.config import resolve_config
# No user preset created
result = resolve_config(self.user)
self.assertEqual(result['lie_seconds'], 45) # system default
Step 2: Run test to verify it fails
.venv/bin/python manage.py test fupogfakta --verbosity=2
Expected: module-level failures.
Step 3: Create lobby/base_config.py
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class BaseGameConfig(models.Model):
"""
Abstract base for per-user game configuration presets.
Each game cartridge extends this with its own concrete fields.
"""
name = models.CharField(max_length=100, default='Default')
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
blank=True,
help_text="null = system default (set in Django admin)",
)
is_default = models.BooleanField(
default=False,
help_text="If True and user=null, this is the system default for this game.",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
Step 4: Add FupOgFaktaConfig to fupogfakta/models.py
At the bottom of fupogfakta/models.py, add:
from lobby.base_config import BaseGameConfig
class FupOgFaktaConfig(BaseGameConfig):
num_rounds = models.PositiveIntegerField(default=3)
questions_per_round = models.PositiveIntegerField(default=3)
min_players = models.PositiveIntegerField(default=2)
max_players = models.PositiveIntegerField(default=8)
lie_seconds = models.PositiveIntegerField(default=45)
guess_seconds = models.PositiveIntegerField(default=30)
reveal_seconds_per_lie = models.PositiveIntegerField(default=8)
scoreboard_recap_seconds = models.PositiveIntegerField(default=10)
awards_display_seconds = models.PositiveIntegerField(default=15)
# Escalating scoring: index 0 = round 1, 1 = round 2, etc.
points_correct = models.JSONField(default=list)
points_bluff = models.JSONField(default=list)
def points_correct_for_round(self, round_index: int) -> int:
defaults = [1500, 3000, 4500]
pts = self.points_correct or defaults
return pts[min(round_index, len(pts) - 1)]
def points_bluff_for_round(self, round_index: int) -> int:
defaults = [500, 1000, 1500]
pts = self.points_bluff or defaults
return pts[min(round_index, len(pts) - 1)]
class Meta:
verbose_name = "Fup og Fakta config"
ordering = ["name"]
def __str__(self):
owner = self.user.username if self.user else "SYSTEM"
return f"{self.name} ({owner})"
Step 5: Create fupogfakta/config.py
from django.contrib.auth import get_user_model
User = get_user_model()
_CONFIG_FIELDS = [
'num_rounds', 'questions_per_round', 'min_players', 'max_players',
'lie_seconds', 'guess_seconds', 'reveal_seconds_per_lie',
'scoreboard_recap_seconds', 'awards_display_seconds',
'points_correct', 'points_bluff',
]
def resolve_config(user) -> dict:
"""
Return config dict for a user starting a fupogfakta session.
Resolution order:
1. User's is_default=True preset (if any)
2. System default (user=None, is_default=True)
3. Model field defaults (hardcoded)
"""
from fupogfakta.models import FupOgFaktaConfig
cfg = (
FupOgFaktaConfig.objects.filter(user=user, is_default=True).first()
or FupOgFaktaConfig.objects.filter(user=None, is_default=True).first()
)
if cfg is None:
# Fall back to model defaults
return _model_defaults()
return {field: getattr(cfg, field) for field in _CONFIG_FIELDS}
def _model_defaults() -> dict:
from fupogfakta.models import FupOgFaktaConfig
tmp = FupOgFaktaConfig()
return {field: getattr(tmp, field) for field in _CONFIG_FIELDS}
Step 6: Generate migration and data migration for system default
.venv/bin/python manage.py makemigrations fupogfakta --name add_fupogfakta_config
Then create the data migration manually:
.venv/bin/python manage.py makemigrations fupogfakta --empty --name seed_system_default_config
Edit the generated file to add:
def seed_default(apps, schema_editor):
FupOgFaktaConfig = apps.get_model('fupogfakta', 'FupOgFaktaConfig')
FupOgFaktaConfig.objects.create(
name='System Default',
user=None,
is_default=True,
num_rounds=3,
questions_per_round=3,
min_players=2,
max_players=8,
lie_seconds=45,
guess_seconds=30,
reveal_seconds_per_lie=8,
scoreboard_recap_seconds=10,
awards_display_seconds=15,
points_correct=[1500, 3000, 4500],
points_bluff=[500, 1000, 1500],
)
class Migration(migrations.Migration):
dependencies = [('fupogfakta', '0005_...')] # previous migration name
operations = [migrations.RunPython(seed_default, migrations.RunPython.noop)]
.venv/bin/python manage.py migrate
Step 7: Register in admin — modify fupogfakta/admin.py
from django.contrib import admin
from .models import Category, Question, FupOgFaktaConfig
admin.site.register(Category)
admin.site.register(Question)
@admin.register(FupOgFaktaConfig)
class FupOgFaktaConfigAdmin(admin.ModelAdmin):
list_display = ['name', 'user', 'is_default', 'num_rounds', 'lie_seconds', 'guess_seconds']
list_filter = ['is_default', 'user']
Step 8: Run tests
.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1
Expected: all green.
Step 9: Commit
git add lobby/base_config.py fupogfakta/models.py fupogfakta/migrations/ \
fupogfakta/config.py fupogfakta/admin.py fupogfakta/tests.py
git commit -m "feat(config): add FupOgFaktaConfig model, resolve_config, and system default seed"
Batch 4 — FupOgFakta Game Models
Task 8: Add LieReaction model + reveal_order to RoundQuestion, remove ScoreEvent
Files:
- Modify:
fupogfakta/models.py - Create migrations
Step 1: Write failing tests
# fupogfakta/tests.py — add
class LieReactionModelTest(TestCase):
def setUp(self):
from django.contrib.auth import get_user_model
User = get_user_model()
host = User.objects.create_user(username='rhost', password='pw')
from fupogfakta.models import GameSession, Player, Category, Question, RoundQuestion, LieAnswer
self.session = GameSession.objects.create(host=host, code='REACT1')
self.player = Player.objects.create(session=self.session, nickname='Alice')
self.other = Player.objects.create(session=self.session, nickname='Bob')
cat = Category.objects.create(name='Test', slug='test')
q = Question.objects.create(category=cat, prompt='Q?', correct_answer='A')
self.rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer='A')
self.lie = LieAnswer.objects.create(round_question=self.rq, player=self.player, text='Fake')
def test_player_can_react_to_lie(self):
from fupogfakta.models import LieReaction
r = LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh')
self.assertEqual(r.reaction, 'laugh')
def test_duplicate_reaction_raises(self):
from fupogfakta.models import LieReaction
from django.db import IntegrityError
LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh')
with self.assertRaises(IntegrityError):
LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh')
def test_round_question_has_reveal_order_field(self):
from fupogfakta.models import RoundQuestion
self.assertIsNone(self.rq.reveal_order)
Step 2: Run test to verify it fails
.venv/bin/python manage.py test fupogfakta.tests.LieReactionModelTest --verbosity=2
Step 3: Update fupogfakta/models.py
Remove the entire ScoreEvent class.
Add reveal_order to RoundQuestion:
reveal_order = models.PositiveIntegerField(null=True, blank=True)
Add LieReaction at the bottom:
class LieReaction(models.Model):
REACTION_CHOICES = [
('laugh', '😂 Laugh'),
('heart', '❤️ Heart'),
('fire', '🔥 Fire'),
('wow', '😮 Wow'),
]
lie = models.ForeignKey(LieAnswer, on_delete=models.CASCADE, related_name='reactions')
player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name='lie_reactions')
reaction = models.CharField(max_length=20, choices=REACTION_CHOICES)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [('lie', 'player', 'reaction')]
ordering = ['created_at']
Step 4: Generate migration
.venv/bin/python manage.py makemigrations fupogfakta --name add_liereaction_reveal_order_remove_scoreevent
.venv/bin/python manage.py migrate
Step 5: Run tests
.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1
Expected: all green (ScoreEvent removal will break calculate_scores in lobby/views.py — fix in next step).
Step 6: Fix lobby/views.py — remove ScoreEvent references
In lobby/views.py, the calculate_scores view imports and uses ScoreEvent. Replace the score-saving logic with direct Player.score updates only (no audit model). Remove ScoreEvent from imports.
The score_events list building and ScoreEvent.objects.bulk_create(score_events) lines should be replaced with just the player.save() calls already present. Remove the score_events list and events_created from the response.
Step 7: Run full test suite
.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1
Expected: all green.
Step 8: Commit
git add fupogfakta/models.py fupogfakta/migrations/ lobby/views.py fupogfakta/tests.py
git commit -m "feat(fupogfakta): add LieReaction + reveal_order, remove ScoreEvent"
Batch 5 — FupOgFakta Game Driver
This is the core game logic. Take it one state at a time.
Task 9: Driver skeleton + LIE_PHASE entry
Files:
- Create:
fupogfakta/driver.py - Create:
fupogfakta/phases.py(phase transition helpers)
Step 1: Write failing test
# fupogfakta/tests.py — add
class FupOgFaktaDriverStartTest(TestCase):
def setUp(self):
from django.contrib.auth import get_user_model
User = get_user_model()
self.host = User.objects.create_user(username='drvhost', password='pw')
from fupogfakta.models import GameSession, Category, Question, FupOgFaktaConfig
from lobby.models import GameRun
self.session = GameSession.objects.create(host=self.host, code='DRVST1')
cat = Category.objects.create(name='DrvCat', slug='drvcat')
Question.objects.create(category=cat, prompt='Q1?', correct_answer='A1')
Question.objects.create(category=cat, prompt='Q2?', correct_answer='A2')
Question.objects.create(category=cat, prompt='Q3?', correct_answer='A3')
self.run = GameRun.objects.create(session=self.session, current_state='LOBBY')
self.config = {
'num_rounds': 1, 'questions_per_round': 1,
'lie_seconds': 30, 'guess_seconds': 20,
'reveal_seconds_per_lie': 5, 'scoreboard_recap_seconds': 5,
'awards_display_seconds': 5,
'points_correct': [1500], 'points_bluff': [500],
'min_players': 2, 'max_players': 8,
'category_slug': 'drvcat',
}
def test_on_game_start_returns_lie_phase(self):
from fupogfakta.driver import FupOgFaktaDriver
driver = FupOgFaktaDriver()
result = driver.on_game_start(self.session, self.run, self.config)
self.assertEqual(result.next_state, 'LIE_PHASE')
self.assertEqual(result.duration_seconds, 30)
self.assertIn('prompt', result.broadcast_payload)
def test_on_game_start_creates_round_question(self):
from fupogfakta.driver import FupOgFaktaDriver
from fupogfakta.models import RoundQuestion
driver = FupOgFaktaDriver()
driver.on_game_start(self.session, self.run, self.config)
self.assertEqual(RoundQuestion.objects.filter(session=self.session).count(), 1)
Step 2: Run test to verify it fails
.venv/bin/python manage.py test fupogfakta.tests.FupOgFaktaDriverStartTest --verbosity=2
Step 3: Create fupogfakta/driver.py
import random
import logging
from lobby.driver import GameDriver, PhaseResult
logger = logging.getLogger(__name__)
class FupOgFaktaDriver(GameDriver):
game_type = 'fupogfakta'
def on_game_start(self, session, run, config: dict) -> PhaseResult:
"""Select first question and enter LIE_PHASE."""
from fupogfakta.phases import enter_lie_phase
return enter_lie_phase(session, run, config, round_number=1, question_number=1)
def on_timer_expired(self, session, run, config: dict) -> PhaseResult:
"""Dispatch to the correct next-state handler based on current_state."""
from fupogfakta import phases
state = run.current_state
state_data = run.state_data
if state == 'LIE_PHASE':
return phases.lie_phase_expired(session, run, config)
if state == 'GUESS_PHASE':
return phases.guess_phase_expired(session, run, config)
if state.startswith('REVEAL_LIE_'):
return phases.reveal_lie_expired(session, run, config)
if state == 'REVEAL_TRUTH':
return phases.reveal_truth_expired(session, run, config)
if state == 'SCOREBOARD_RECAP':
return phases.scoreboard_recap_expired(session, run, config)
if state == 'POST_GAME_AWARDS':
return phases.post_game_awards_expired(session, run, config)
logger.error("FupOgFaktaDriver: unknown state %r for run %s", state, run.pk)
return PhaseResult('FINISHED', None, {'type': 'phase.game_over'})
def on_exit(self, session, run) -> None:
"""Delete all ephemeral game data. Keep GameSession row."""
from fupogfakta.models import RoundQuestion, LieAnswer, Guess, LieReaction, Player
from lobby.models import GameRun
# Cascade deletes handle LieAnswer, Guess, LieReaction via RoundQuestion
RoundQuestion.objects.filter(session=session).delete()
Player.objects.filter(session=session).delete()
GameRun.objects.filter(session=session).delete()
session.status = session.Status.FINISHED
session.save(update_fields=['status'])
Step 4: Create fupogfakta/phases.py
Start with just enter_lie_phase — other phases added in subsequent tasks:
import random
import logging
from lobby.driver import PhaseResult
logger = logging.getLogger(__name__)
def _pick_question(session, config: dict, round_number: int):
"""Pick a random unused question for this session from the configured category."""
from fupogfakta.models import Category, Question, RoundQuestion
category = Category.objects.get(slug=config['category_slug'])
used_ids = RoundQuestion.objects.filter(session=session).values_list('question_id', flat=True)
available = list(
Question.objects.filter(category=category, is_active=True).exclude(pk__in=used_ids)
)
if not available:
raise ValueError(f"No available questions for category {config['category_slug']!r}")
return random.choice(available)
def enter_lie_phase(session, run, config: dict, round_number: int, question_number: int) -> PhaseResult:
"""Select a question, create RoundQuestion, return LIE_PHASE result."""
from fupogfakta.models import RoundQuestion
question = _pick_question(session, config, round_number)
rq = RoundQuestion.objects.create(
session=session,
round_number=round_number,
question=question,
correct_answer=question.correct_answer,
)
run.state_data = {
**run.state_data,
'round_number': round_number,
'question_number': question_number,
'round_question_id': rq.id,
}
run.save(update_fields=['state_data'])
return PhaseResult(
next_state='LIE_PHASE',
duration_seconds=config['lie_seconds'],
broadcast_payload={
'type': 'phase.lie_started',
'round_question_id': rq.id,
'prompt': question.prompt,
'round_number': round_number,
'question_number': question_number,
'lie_seconds': config['lie_seconds'],
},
)
def lie_phase_expired(session, run, config: dict) -> PhaseResult:
"""Timer ran out in LIE_PHASE. Mix answers and enter GUESS_PHASE."""
from fupogfakta.models import RoundQuestion, LieAnswer
rq_id = run.state_data['round_question_id']
rq = RoundQuestion.objects.get(pk=rq_id)
# Mix answers (dedup, shuffle)
lie_texts = list(LieAnswer.objects.filter(round_question=rq).values_list('text', flat=True))
seen = set()
answers = []
for text in [rq.correct_answer, *lie_texts]:
norm = text.strip().casefold()
if norm and norm not in seen:
seen.add(norm)
answers.append(text.strip())
random.shuffle(answers)
rq.mixed_answers = answers
rq.save(update_fields=['mixed_answers'])
return PhaseResult(
next_state='GUESS_PHASE',
duration_seconds=config['guess_seconds'],
broadcast_payload={
'type': 'phase.guess_started',
'round_question_id': rq.id,
'answers': [{'text': t} for t in answers],
'guess_seconds': config['guess_seconds'],
},
)
def guess_phase_expired(session, run, config: dict) -> PhaseResult:
"""Timer ran out in GUESS_PHASE. Compute reveal order, enter first REVEAL_LIE or REVEAL_TRUTH."""
from fupogfakta.models import RoundQuestion, LieAnswer, Guess
rq_id = run.state_data['round_question_id']
rq = RoundQuestion.objects.prefetch_related('lies', 'guesses').get(pk=rq_id)
# Determine which lies had at least one guesser
guessed_lie_ids = set(
Guess.objects.filter(round_question=rq, is_correct=False)
.exclude(fooled_player=None)
.values_list('fooled_player_id', flat=True)
)
# Map: liar player_id → lie
lie_map = {lie.player_id: lie for lie in rq.lies.all()}
reveal_lies = [lie for pid, lie in lie_map.items() if any(
g.fooled_player_id == pid for g in rq.guesses.all()
)]
# Assign reveal_order to each lie being revealed
for i, lie in enumerate(reveal_lies):
lie.reveal_order = i
lie.save(update_fields=['reveal_order'])
run.state_data = {
**run.state_data,
'reveal_lies': [lie.id for lie in reveal_lies],
'reveal_index': 0,
}
run.save(update_fields=['state_data'])
if reveal_lies:
return _reveal_lie_result(rq, reveal_lies[0], config, run.state_data)
else:
return _reveal_truth_result(rq, config)
def reveal_lie_expired(session, run, config: dict) -> PhaseResult:
"""A lie has been shown. Score the liar. Move to next lie or truth."""
from fupogfakta.models import LieAnswer, Guess, Player, RoundQuestion
rq_id = run.state_data['round_question_id']
reveal_lies = run.state_data['reveal_lies']
reveal_index = run.state_data['reveal_index']
round_number = run.state_data['round_number']
# Score the liar for this lie
lie = LieAnswer.objects.get(pk=reveal_lies[reveal_index])
guessers = list(Guess.objects.filter(round_question_id=rq_id, fooled_player=lie.player).select_related('player'))
points_bluff = _points_bluff(config, round_number)
if guessers:
delta = points_bluff * len(guessers)
lie.player.score += delta
lie.player.save(update_fields=['score'])
# Next reveal
next_index = reveal_index + 1
run.state_data = {**run.state_data, 'reveal_index': next_index}
run.save(update_fields=['state_data'])
rq = RoundQuestion.objects.get(pk=rq_id)
if next_index < len(reveal_lies):
next_lie = LieAnswer.objects.get(pk=reveal_lies[next_index])
return _reveal_lie_result(rq, next_lie, config, run.state_data)
else:
return _reveal_truth_result(rq, config)
def reveal_truth_expired(session, run, config: dict) -> PhaseResult:
"""Truth revealed. Score correct guessers. Enter SCOREBOARD_RECAP."""
from fupogfakta.models import Guess, RoundQuestion
rq_id = run.state_data['round_question_id']
round_number = run.state_data['round_number']
rq = RoundQuestion.objects.get(pk=rq_id)
correct_guessers = list(Guess.objects.filter(round_question=rq, is_correct=True).select_related('player'))
points_correct = _points_correct(config, round_number)
for guess in correct_guessers:
guess.player.score += points_correct
guess.player.save(update_fields=['score'])
leaderboard = _leaderboard(session)
return PhaseResult(
next_state='SCOREBOARD_RECAP',
duration_seconds=config['scoreboard_recap_seconds'],
broadcast_payload={
'type': 'phase.scoreboard',
'leaderboard': leaderboard,
'round_number': round_number,
},
)
def scoreboard_recap_expired(session, run, config: dict) -> PhaseResult:
"""Scoreboard shown. Advance to next question, next round, or post-game awards."""
round_number = run.state_data['round_number']
question_number = run.state_data['question_number']
num_rounds = config['num_rounds']
questions_per_round = config['questions_per_round']
if question_number < questions_per_round:
# Next question in same round
return enter_lie_phase(session, run, config, round_number, question_number + 1)
elif round_number < num_rounds:
# Next round
return enter_lie_phase(session, run, config, round_number + 1, 1)
else:
# All rounds done → post-game awards
return _post_game_awards_result(session, config)
def post_game_awards_expired(session, run, config: dict) -> PhaseResult:
"""Awards shown. Game over."""
return PhaseResult(
next_state='FINISHED',
duration_seconds=None,
broadcast_payload={
'type': 'phase.game_over',
'leaderboard': _leaderboard(session),
},
)
# ── helpers ──────────────────────────────────────────────────────────────────
def _points_correct(config: dict, round_number: int) -> int:
pts = config.get('points_correct') or [1500, 3000, 4500]
return pts[min(round_number - 1, len(pts) - 1)]
def _points_bluff(config: dict, round_number: int) -> int:
pts = config.get('points_bluff') or [500, 1000, 1500]
return pts[min(round_number - 1, len(pts) - 1)]
def _leaderboard(session) -> list:
from fupogfakta.models import Player
return list(
Player.objects.filter(session=session)
.order_by('-score', 'nickname')
.values('id', 'nickname', 'score')
)
def _reveal_lie_result(rq, lie, config: dict, state_data: dict) -> PhaseResult:
from fupogfakta.models import Guess
guessers = list(
Guess.objects.filter(round_question=rq, fooled_player=lie.player)
.select_related('player')
.values('player__nickname')
)
return PhaseResult(
next_state=f'REVEAL_LIE_{state_data["reveal_index"]}',
duration_seconds=config['reveal_seconds_per_lie'],
broadcast_payload={
'type': 'phase.reveal_lie',
'lie_text': lie.text,
'author': lie.player.nickname,
'guessers': [g['player__nickname'] for g in guessers],
'guesser_count': len(guessers),
},
)
def _reveal_truth_result(rq, config: dict) -> PhaseResult:
from fupogfakta.models import Guess
correct_guessers = list(
Guess.objects.filter(round_question=rq, is_correct=True)
.select_related('player')
.values('player__nickname')
)
return PhaseResult(
next_state='REVEAL_TRUTH',
duration_seconds=config['reveal_seconds_per_lie'],
broadcast_payload={
'type': 'phase.reveal_truth',
'correct_answer': rq.correct_answer,
'correct_guessers': [g['player__nickname'] for g in correct_guessers],
},
)
def _post_game_awards_result(session, config: dict) -> PhaseResult:
from fupogfakta.models import LieReaction, LieAnswer
from django.db.models import Count
# Most laughs overall per player
top_laugh = (
LieReaction.objects.filter(lie__round_question__session=session, reaction='laugh')
.values('lie__player__nickname')
.annotate(total=Count('id'))
.order_by('-total')
.first()
)
# Most hearts on a single lie
top_heart = (
LieReaction.objects.filter(lie__round_question__session=session, reaction='heart')
.values('lie__player__nickname', 'lie__text')
.annotate(total=Count('id'))
.order_by('-total')
.first()
)
awards = []
if top_laugh:
awards.append({
'award': 'most_hilarious',
'label': 'Most Hilarious Liar 😂',
'winner': top_laugh['lie__player__nickname'],
'count': top_laugh['total'],
})
if top_heart:
awards.append({
'award': 'most_beloved',
'label': 'Most Beloved Lie ❤️',
'winner': top_heart['lie__player__nickname'],
'lie': top_heart['lie__text'],
'count': top_heart['total'],
})
return PhaseResult(
next_state='POST_GAME_AWARDS',
duration_seconds=config['awards_display_seconds'],
broadcast_payload={
'type': 'phase.awards',
'awards': awards,
'leaderboard': _leaderboard(session),
},
)
Step 5: Run tests
.venv/bin/python manage.py test fupogfakta.tests.FupOgFaktaDriverStartTest --verbosity=2
Expected: PASS.
Step 6: Run full suite
.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1
Expected: all green.
Step 7: Commit
git add fupogfakta/driver.py fupogfakta/phases.py fupogfakta/tests.py
git commit -m "feat(fupogfakta): implement game driver and all phase transitions"
Batch 6 — Platform REST Endpoints + Celery Wiring
Task 10: Wire handle_timer_expired to the driver
Files:
- Modify:
lobby/tasks.py
Step 1: Write failing test
# lobby/tests.py — add
class TimerTaskDispatchTest(TestCase):
def setUp(self):
from django.contrib.auth import get_user_model
User = get_user_model()
self.host = User.objects.create_user(username='taskhost', password='pw')
from fupogfakta.models import GameSession, Category, Question, Player
from lobby.models import GameRun
self.session = GameSession.objects.create(host=self.host, code='TASKTS')
self.cat = Category.objects.create(name='TskCat', slug='tskcat')
for i in range(3):
Question.objects.create(category=self.cat, prompt=f'Q{i}', correct_answer=f'A{i}')
Player.objects.create(session=self.session, nickname='P1')
Player.objects.create(session=self.session, nickname='P2')
self.run = GameRun.objects.create(
session=self.session,
current_state='LIE_PHASE',
state_data={
'round_number': 1, 'question_number': 1,
'round_question_id': None, # set below
'config': {
'num_rounds': 1, 'questions_per_round': 1,
'lie_seconds': 30, 'guess_seconds': 20,
'reveal_seconds_per_lie': 5, 'scoreboard_recap_seconds': 5,
'awards_display_seconds': 5,
'points_correct': [1500], 'points_bluff': [500],
'min_players': 2, 'max_players': 8,
'category_slug': 'tskcat',
},
},
)
from fupogfakta.models import RoundQuestion, Question as Q
q = Q.objects.filter(category=self.cat).first()
rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer=q.correct_answer)
self.run.state_data['round_question_id'] = rq.id
self.run.save(update_fields=['state_data'])
self.rq = rq
def test_task_transitions_lie_to_guess(self):
from lobby.tasks import handle_timer_expired
from lobby.models import GameRun
handle_timer_expired(run_id=self.run.pk, expected_state='LIE_PHASE')
self.run.refresh_from_db()
self.assertEqual(self.run.current_state, 'GUESS_PHASE')
def test_stale_task_does_not_transition(self):
from lobby.tasks import handle_timer_expired
from lobby.models import GameRun
handle_timer_expired(run_id=self.run.pk, expected_state='WRONG_STATE')
self.run.refresh_from_db()
self.assertEqual(self.run.current_state, 'LIE_PHASE') # unchanged
Step 2: Update lobby/tasks.py — full implementation
import logging
from celery import shared_task
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
logger = logging.getLogger(__name__)
def _schedule_next(run, duration_seconds: int) -> str:
"""Schedule handle_timer_expired for run after duration_seconds. Returns task id."""
from datetime import timedelta
from django.utils import timezone
eta = timezone.now() + timedelta(seconds=duration_seconds)
result = handle_timer_expired.apply_async(
kwargs={'run_id': run.pk, 'expected_state': run.current_state},
eta=eta,
)
return result.id
def _broadcast(session_code: str, payload: dict) -> None:
channel_layer = get_channel_layer()
group_name = f"game_{session_code.upper()}"
async_to_sync(channel_layer.group_send)(
group_name,
{'type': 'phase_event', 'payload': payload},
)
@shared_task(bind=True, max_retries=0)
def handle_timer_expired(self, run_id: int, expected_state: str) -> None:
from lobby.models import GameRun
from lobby.driver import get_driver
from django.utils import timezone
try:
run = GameRun.objects.select_related('session').get(pk=run_id)
except GameRun.DoesNotExist:
logger.info("handle_timer_expired: run %s not found — stale task", run_id)
return
if run.current_state != expected_state:
logger.info("handle_timer_expired: run %s state mismatch (%r != %r) — stale", run_id, run.current_state, expected_state)
return
if run.is_paused:
logger.info("handle_timer_expired: run %s is paused — ignoring", run_id)
return
config = run.state_data.get('config', {})
driver = get_driver(run.session.game_type)
if run.current_state == 'FINISHED':
# Clean up
driver.on_exit(run.session, run)
return
result = driver.on_timer_expired(run.session, run, config)
if result.next_state == 'FINISHED':
_broadcast(run.session.code, result.broadcast_payload)
driver.on_exit(run.session, run)
return
run.current_state = result.next_state
run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=result.duration_seconds) if result.duration_seconds else None
run.celery_task_id = None
run.save(update_fields=['current_state', 'phase_deadline', 'celery_task_id', 'state_data'])
if result.duration_seconds:
task_id = _schedule_next(run, result.duration_seconds)
run.celery_task_id = task_id
run.save(update_fields=['celery_task_id'])
_broadcast(run.session.code, result.broadcast_payload)
Step 3: Run tests
.venv/bin/python manage.py test lobby.tests.TimerTaskDispatchTest --verbosity=2
Expected: PASS (CELERY_TASK_ALWAYS_EAGER=True in test mode).
Step 4: Commit
git add lobby/tasks.py lobby/tests.py
git commit -m "feat(platform): wire handle_timer_expired to GameDriver dispatch"
Task 11: Platform play/pause/exit endpoints
Files:
- Modify:
lobby/views.py— add play, pause, exit views - Modify:
lobby/urls.py— register routes
Step 1: Write failing tests
# lobby/tests.py — add
class PlayPauseExitTest(TestCase):
def setUp(self):
from django.contrib.auth import get_user_model
User = get_user_model()
self.host = User.objects.create_user(username='ppe_host', password='pw')
self.client.force_login(self.host)
from fupogfakta.models import GameSession, Category, Question, Player
from fupogfakta.models import FupOgFaktaConfig
self.session = GameSession.objects.create(host=self.host, code='PPETEST')
cat = Category.objects.create(name='PPECat', slug='ppecat')
for i in range(3):
Question.objects.create(category=cat, prompt=f'Q{i}', correct_answer=f'A{i}')
Player.objects.create(session=self.session, nickname='P1')
Player.objects.create(session=self.session, nickname='P2')
def test_play_creates_game_run_and_transitions_to_active(self):
from lobby.models import GameRun
resp = self.client.post(f'/lobby/sessions/PPETEST/play',
content_type='application/json',
data={'category_slug': 'ppecat'})
self.assertEqual(resp.status_code, 201)
self.assertTrue(GameRun.objects.filter(session__code='PPETEST').exists())
def test_pause_sets_is_paused(self):
from lobby.models import GameRun
self.client.post(f'/lobby/sessions/PPETEST/play',
content_type='application/json',
data={'category_slug': 'ppecat'})
resp = self.client.post('/lobby/sessions/PPETEST/pause')
self.assertEqual(resp.status_code, 200)
run = GameRun.objects.get(session__code='PPETEST')
self.assertTrue(run.is_paused)
def test_exit_deletes_game_run(self):
from lobby.models import GameRun
self.client.post('/lobby/sessions/PPETEST/play',
content_type='application/json',
data={'category_slug': 'ppecat'})
resp = self.client.post('/lobby/sessions/PPETEST/exit')
self.assertEqual(resp.status_code, 200)
self.assertFalse(GameRun.objects.filter(session__code='PPETEST').exists())
Step 2: Add views to lobby/views.py
Add these three views at the bottom:
@require_POST
@login_required
def play_session(request: HttpRequest, code: str) -> JsonResponse:
"""Start game (or resume if paused)."""
from lobby.models import GameRun
from lobby.driver import get_driver
from lobby.tasks import _schedule_next, _broadcast
from fupogfakta.config import resolve_config
from django.utils import timezone
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({'error': 'Session not found'}, status=404)
if session.host_id != request.user.id:
return JsonResponse({'error': 'Only host can start game'}, status=403)
# Resume from pause
run = GameRun.objects.filter(session=session).first()
if run and run.is_paused:
remaining = run.paused_remaining_seconds or 0
run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=remaining)
run.is_paused = False
run.paused_remaining_seconds = None
run.save(update_fields=['phase_deadline', 'is_paused', 'paused_remaining_seconds'])
task_id = _schedule_next(run, int(remaining))
run.celery_task_id = task_id
run.save(update_fields=['celery_task_id'])
_broadcast(session.code, {'type': 'phase.resumed', 'phase_deadline': run.phase_deadline.isoformat()})
return JsonResponse({'status': 'resumed'})
if run:
return JsonResponse({'error': 'Game already running'}, status=409)
# Fresh start
payload = _json_body(request)
category_slug = str(payload.get('category_slug', '')).strip()
if not category_slug:
return JsonResponse({'error': 'category_slug required'}, status=400)
config = resolve_config(request.user)
config['category_slug'] = category_slug
run = GameRun.objects.create(
session=session,
current_state='LOBBY',
state_data={'config': config},
)
driver = get_driver(session.game_type)
result = driver.on_game_start(session, run, config)
run.current_state = result.next_state
run.state_data['config'] = config
run.save(update_fields=['current_state', 'state_data'])
if result.duration_seconds:
run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=result.duration_seconds)
task_id = _schedule_next(run, result.duration_seconds)
run.celery_task_id = task_id
run.save(update_fields=['phase_deadline', 'celery_task_id'])
session.status = GameSession.Status.ACTIVE
session.save(update_fields=['status'])
_broadcast(session.code, result.broadcast_payload)
return JsonResponse({'status': 'started', 'state': run.current_state}, status=201)
@require_POST
@login_required
def pause_session(request: HttpRequest, code: str) -> JsonResponse:
from lobby.models import GameRun
from lobby.tasks import _broadcast
from django.utils import timezone
from celery.app.control import Control
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({'error': 'Session not found'}, status=404)
if session.host_id != request.user.id:
return JsonResponse({'error': 'Only host can pause'}, status=403)
try:
run = GameRun.objects.get(session=session)
except GameRun.DoesNotExist:
return JsonResponse({'error': 'Game not running'}, status=400)
if run.is_paused:
return JsonResponse({'error': 'Already paused'}, status=400)
# Revoke Celery task
if run.celery_task_id:
from partyhub.celery import app as celery_app
celery_app.control.revoke(run.celery_task_id, terminate=True)
remaining = (run.phase_deadline - timezone.now()).total_seconds() if run.phase_deadline else 0
run.is_paused = True
run.paused_remaining_seconds = max(remaining, 0)
run.celery_task_id = None
run.save(update_fields=['is_paused', 'paused_remaining_seconds', 'celery_task_id'])
_broadcast(session.code, {'type': 'phase.paused', 'remaining_seconds': run.paused_remaining_seconds})
return JsonResponse({'status': 'paused', 'remaining_seconds': run.paused_remaining_seconds})
@require_POST
@login_required
def exit_session(request: HttpRequest, code: str) -> JsonResponse:
from lobby.models import GameRun
from lobby.driver import get_driver
from celery.app.control import Control
session_code = _normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return JsonResponse({'error': 'Session not found'}, status=404)
if session.host_id != request.user.id:
return JsonResponse({'error': 'Only host can exit'}, status=403)
run = GameRun.objects.filter(session=session).first()
if run:
if run.celery_task_id:
from partyhub.celery import app as celery_app
celery_app.control.revoke(run.celery_task_id, terminate=True)
driver = get_driver(session.game_type)
driver.on_exit(session, run)
return JsonResponse({'status': 'exited'})
Step 3: Add routes to lobby/urls.py
path("sessions/<str:code>/play", views.play_session, name="play_session"),
path("sessions/<str:code>/pause", views.pause_session, name="pause_session"),
path("sessions/<str:code>/exit", views.exit_session, name="exit_session"),
Step 4: Run tests
.venv/bin/python manage.py test lobby.tests.PlayPauseExitTest --verbosity=2
Expected: PASS.
Step 5: Run full suite
.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1
Step 6: Commit
git add lobby/views.py lobby/urls.py lobby/tests.py
git commit -m "feat(platform): add play/pause/exit session endpoints"
Task 12: FupOgFakta game-specific endpoints (lie, guess, react)
Files:
- Create:
fupogfakta/views.py - Create:
fupogfakta/urls.py - Modify:
partyhub/urls.py
Step 1: Write failing tests
# fupogfakta/tests.py — add
class SubmitLieViewTest(TestCase):
def setUp(self):
# Create session in LIE_PHASE with a RoundQuestion active
from django.contrib.auth import get_user_model
User = get_user_model()
self.host = User.objects.create_user(username='liehost', password='pw')
from fupogfakta.models import GameSession, Player, Category, Question, RoundQuestion
from lobby.models import GameRun
self.session = GameSession.objects.create(host=self.host, code='LIETEST', status='active')
cat = Category.objects.create(name='LieCat', slug='liecat')
q = Question.objects.create(category=cat, prompt='What is?', correct_answer='Real Answer')
self.rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer='Real Answer')
self.player = Player.objects.create(session=self.session, nickname='Liar')
self.run = GameRun.objects.create(
session=self.session, current_state='LIE_PHASE',
state_data={'round_question_id': self.rq.id, 'config': {'lie_seconds': 45}}
)
def test_submit_lie_succeeds(self):
resp = self.client.post(
'/fupogfakta/LIETEST/lie',
content_type='application/json',
data={'session_token': self.player.session_token, 'text': 'My fake answer'},
)
self.assertEqual(resp.status_code, 201)
def test_submit_correct_answer_as_lie_rejected(self):
resp = self.client.post(
'/fupogfakta/LIETEST/lie',
content_type='application/json',
data={'session_token': self.player.session_token, 'text': 'real answer'},
)
self.assertEqual(resp.status_code, 422)
self.assertIn('lie_matches_correct_answer', resp.json().get('error_code', ''))
Step 2: Create fupogfakta/views.py
import json
from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_POST
from fupogfakta.models import GameSession, Player, RoundQuestion, LieAnswer, Guess, LieReaction
from lobby.models import GameRun
def _json_body(request: HttpRequest) -> dict:
try:
return json.loads(request.body) if request.body else {}
except json.JSONDecodeError:
return {}
def _get_active_run_and_player(code: str, session_token: str):
"""Returns (session, run, player) or raises."""
try:
session = GameSession.objects.get(code=code.upper())
except GameSession.DoesNotExist:
return None, None, None
try:
run = GameRun.objects.get(session=session)
except GameRun.DoesNotExist:
return session, None, None
try:
player = Player.objects.get(session=session, session_token=session_token)
except Player.DoesNotExist:
return session, run, None
return session, run, player
@require_POST
def submit_lie(request: HttpRequest, code: str) -> JsonResponse:
payload = _json_body(request)
session_token = str(payload.get('session_token', '')).strip()
lie_text = str(payload.get('text', '')).strip()
if not session_token:
return JsonResponse({'error': 'session_token required'}, status=400)
if not lie_text or len(lie_text) > 255:
return JsonResponse({'error': 'text must be 1-255 characters'}, status=400)
session, run, player = _get_active_run_and_player(code, session_token)
if not session:
return JsonResponse({'error': 'Session not found'}, status=404)
if not run:
return JsonResponse({'error': 'Game not running'}, status=400)
if not player:
return JsonResponse({'error': 'Invalid session_token'}, status=403)
if run.current_state != 'LIE_PHASE':
return JsonResponse({'error': 'Not in lie phase'}, status=400)
rq_id = run.state_data.get('round_question_id')
try:
rq = RoundQuestion.objects.get(pk=rq_id, session=session)
except RoundQuestion.DoesNotExist:
return JsonResponse({'error': 'No active question'}, status=400)
# Reject if lie matches correct answer (case-insensitive)
if lie_text.strip().casefold() == rq.correct_answer.strip().casefold():
return JsonResponse({
'error': 'Your lie matches the correct answer — try something else!',
'error_code': 'lie_matches_correct_answer',
}, status=422)
from django.db import IntegrityError
try:
lie = LieAnswer.objects.create(round_question=rq, player=player, text=lie_text)
except IntegrityError:
return JsonResponse({'error': 'Lie already submitted'}, status=409)
# Broadcast progress (anonymous count only)
from realtime.broadcast import sync_broadcast_phase_event
total_players = Player.objects.filter(session=session).count()
submitted = LieAnswer.objects.filter(round_question=rq).count()
sync_broadcast_phase_event(session.code, 'phase.lie_progress', {
'submitted': submitted,
'total': total_players,
})
return JsonResponse({'lie_id': lie.id}, status=201)
@require_POST
def submit_guess(request: HttpRequest, code: str) -> JsonResponse:
payload = _json_body(request)
session_token = str(payload.get('session_token', '')).strip()
selected_text = str(payload.get('selected_text', '')).strip()
if not session_token:
return JsonResponse({'error': 'session_token required'}, status=400)
if not selected_text:
return JsonResponse({'error': 'selected_text required'}, status=400)
session, run, player = _get_active_run_and_player(code, session_token)
if not session:
return JsonResponse({'error': 'Session not found'}, status=404)
if not run:
return JsonResponse({'error': 'Game not running'}, status=400)
if not player:
return JsonResponse({'error': 'Invalid session_token'}, status=403)
if run.current_state != 'GUESS_PHASE':
return JsonResponse({'error': 'Not in guess phase'}, status=400)
rq_id = run.state_data.get('round_question_id')
rq = RoundQuestion.objects.get(pk=rq_id, session=session)
allowed = {t.strip().casefold() for t in rq.mixed_answers}
if selected_text.casefold() not in allowed:
return JsonResponse({'error': 'Answer not in this round'}, status=400)
correct_normalized = rq.correct_answer.strip().casefold()
is_correct = selected_text.casefold() == correct_normalized
fooled_player_id = None
if not is_correct:
fooled_player_id = (
LieAnswer.objects.filter(round_question=rq, text__iexact=selected_text)
.values_list('player_id', flat=True)
.first()
)
from django.db import IntegrityError
try:
guess = Guess.objects.create(
round_question=rq,
player=player,
selected_text=selected_text,
is_correct=is_correct,
fooled_player_id=fooled_player_id,
)
except IntegrityError:
return JsonResponse({'error': 'Guess already submitted'}, status=409)
return JsonResponse({'guess_id': guess.id, 'is_correct': is_correct}, status=201)
@require_POST
def submit_reaction(request: HttpRequest, code: str) -> JsonResponse:
payload = _json_body(request)
session_token = str(payload.get('session_token', '')).strip()
lie_id = payload.get('lie_id')
reaction = str(payload.get('reaction', '')).strip()
VALID_REACTIONS = {'laugh', 'heart', 'fire', 'wow'}
if not session_token:
return JsonResponse({'error': 'session_token required'}, status=400)
if reaction not in VALID_REACTIONS:
return JsonResponse({'error': f'reaction must be one of {sorted(VALID_REACTIONS)}'}, status=400)
session, run, player = _get_active_run_and_player(code, session_token)
if not session:
return JsonResponse({'error': 'Session not found'}, status=404)
if not run or run.current_state != 'GUESS_PHASE':
return JsonResponse({'error': 'Reactions only allowed during guess phase'}, status=400)
if not player:
return JsonResponse({'error': 'Invalid session_token'}, status=403)
# Cannot react to own lie
try:
lie = LieAnswer.objects.get(pk=lie_id, round_question__session=session)
except LieAnswer.DoesNotExist:
return JsonResponse({'error': 'Lie not found'}, status=404)
if lie.player_id == player.id:
return JsonResponse({'error': 'Cannot react to your own lie'}, status=400)
from django.db import IntegrityError
try:
LieReaction.objects.create(lie=lie, player=player, reaction=reaction)
except IntegrityError:
# Already reacted with this emoji — idempotent, return ok
pass
return JsonResponse({'status': 'ok'})
Step 3: Create fupogfakta/urls.py
from django.urls import path
from . import views
app_name = 'fupogfakta'
urlpatterns = [
path('<str:code>/lie', views.submit_lie, name='submit_lie'),
path('<str:code>/guess', views.submit_guess, name='submit_guess'),
path('<str:code>/react', views.submit_reaction, name='submit_reaction'),
]
Step 4: Register in partyhub/urls.py
Read the file first, then add:
path('fupogfakta/', include('fupogfakta.urls')),
Step 5: Run tests
.venv/bin/python manage.py test fupogfakta.tests.SubmitLieViewTest --verbosity=2
Expected: PASS.
Step 6: Commit
git add fupogfakta/views.py fupogfakta/urls.py partyhub/urls.py fupogfakta/tests.py
git commit -m "feat(fupogfakta): add lie/guess/react endpoints"
Batch 7 — Frontend Game Screens
The Angular host-shell and player-shell currently work as developer control panels. Replace them with proper game screens that react to WebSocket events.
Task 13: WebSocket state machine in Angular
Files:
- Create:
frontend/angular/src/app/game-state.service.ts
The service opens a WebSocket to ws/game/{sessionCode}/ and maintains reactive state from phase.* events. Both host and player shells subscribe to this service.
// game-state.service.ts
import { Injectable, signal } from '@angular/core';
export type GamePhase =
| 'LOBBY' | 'LIE_PHASE' | 'GUESS_PHASE'
| `REVEAL_LIE_${number}` | 'REVEAL_TRUTH'
| 'SCOREBOARD_RECAP' | 'POST_GAME_AWARDS' | 'FINISHED';
export interface GameState {
phase: GamePhase;
sessionCode: string;
payload: Record<string, unknown>;
}
@Injectable({ providedIn: 'root' })
export class GameStateService {
readonly state = signal<GameState>({ phase: 'LOBBY', sessionCode: '', payload: {} });
private ws: WebSocket | null = null;
connect(sessionCode: string, token: string, role: 'player' | 'host') {
const query = role === 'host' ? 'role=host' : `session_token=${token}`;
this.ws = new WebSocket(`ws://${location.host}/ws/game/${sessionCode}/?${query}`);
this.ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
this.state.set({ phase: msg.type.replace('phase.', '').toUpperCase() as GamePhase, sessionCode, payload: msg });
};
}
disconnect() { this.ws?.close(); }
ping() { this.ws?.send(JSON.stringify({ type: 'ping' })); }
}
Step 1: Write Vitest test
// frontend/angular/src/app/game-state.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { GameStateService } from './game-state.service';
describe('GameStateService', () => {
it('initialises in LOBBY phase', () => {
const svc = TestBed.inject(GameStateService);
expect(svc.state().phase).toBe('LOBBY');
});
});
Step 2: Run test
cd frontend/angular && npm test -- --run 2>&1 | grep -E 'PASS|FAIL|GameState'
Step 3: Rebuild host-shell for real gameplay
Replace host-shell.component.ts content. The host screen shows:
- LOBBY: session code large, player list, "Play" button with category select
- LIE_PHASE: question prompt, lie-submission progress bar (N/total), countdown timer, Pause button
- GUESS_PHASE: answers displayed (no correct answer highlighted), countdown timer, Pause button
- REVEAL_LIE_*: lie text, author revealed, guessers listed, score delta animation
- REVEAL_TRUTH: correct answer, who guessed right, score deltas
- SCOREBOARD_RECAP: full leaderboard
- POST_GAME_AWARDS: award cards
- FINISHED: final leaderboard, "New Game" button
Host makes REST calls only for: POST /lobby/sessions/{code}/play, POST pause, POST exit.
Step 4: Rebuild player-shell for real gameplay
Player screen shows:
- LOBBY: "Waiting for host to start…" with players list
- LIE_PHASE: text input for lie, submit button, countdown — replaced by "Submitted ✓" once sent
- GUESS_PHASE: answer buttons, once selected show emoji reaction buttons for other lies (cannot react to own lie), countdown
- REVEAL_* / SCOREBOARD / AWARDS: display-only, shows own score delta highlighted
- FINISHED: final leaderboard
Player makes REST calls only for: POST /fupogfakta/{code}/lie, POST /fupogfakta/{code}/guess, POST /fupogfakta/{code}/react.
Step 5: Run Angular tests
cd frontend/angular && npm test -- --run 2>&1 | tail -10
Step 6: Commit
git add frontend/angular/src/
git commit -m "feat(frontend): rebuild host and player screens as real game UI"
Batch 8 — Cleanup
Task 14: Remove obsolete lobby/views.py endpoints
The old manual-advance endpoints (start_round, show_question, submit_lie, mix_answers, submit_guess, calculate_scores, reveal_scoreboard, finish_game, start_next_round) are now replaced by the driver + platform endpoints. Remove them from lobby/views.py and lobby/urls.py, then remove or update the tests that covered them.
Run full suite after:
.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1
Commit:
git commit -m "chore: remove obsolete manual-advance lobby endpoints"
Task 15: Update TODO.md
Mark completed items, add new backlog items discovered during implementation:
- Rate limiting on fupogfakta/lie and /guess endpoints
- Session-code brute-force protection on /join
- TTS / read-aloud integration (Fase 4)
git add TODO.md && git commit -m "docs: update TODO after game engine implementation"
Running Order Summary
Batch 1 — Celery infra (Tasks 1-3) no breaking changes
Batch 2 — GameRun + Driver (Tasks 4-6) additive
Batch 3 — Config system (Task 7) additive
Batch 4 — Game models (Task 8) remove ScoreEvent
Batch 5 — FupOgFakta driver (Task 9) new game logic
Batch 6 — Platform endpoints (Tasks 10-12) new REST API
Batch 7 — Frontend (Tasks 13) replace UI
Batch 8 — Cleanup (Tasks 14-15) remove old code
Each batch is independently mergeable. Run .venv/bin/python manage.py test fupogfakta lobby realtime before every commit.