Compare commits
2 Commits
pr-153
...
1662d7d197
| Author | SHA1 | Date | |
|---|---|---|---|
| 1662d7d197 | |||
| 1c78b856fb |
@@ -1,33 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test-and-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install ruff
|
||||
|
||||
- name: Lint
|
||||
run: ruff check lobby
|
||||
|
||||
- name: Tests
|
||||
run: python manage.py test lobby -v 1
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,7 +22,6 @@ media/
|
||||
.env
|
||||
.env.*
|
||||
!.env.test.example
|
||||
!.env.staging.example
|
||||
!.env.prod.example
|
||||
|
||||
# Editors/OS
|
||||
|
||||
25
TODO.md
25
TODO.md
@@ -14,7 +14,7 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
- Deployment: Proxmox LXC (ikke Docker)
|
||||
|
||||
## Midlertidige defaults (kan finjusteres senere)
|
||||
- Spillere: min 3, default max 5 (MVP)
|
||||
- Spillere: min 3, max 12
|
||||
- Løgntid (X): 45 sek
|
||||
- Gættetid (Z): 30 sek
|
||||
- Login: username/password for host/admin
|
||||
@@ -55,12 +55,12 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
### Fase 3 — Spilflow `Fup og Fakta`
|
||||
- [x] Lobby: host opretter session, spillere joiner via kode
|
||||
- [x] Runde starter med kategori
|
||||
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
|
||||
- [x] System blander korrekt svar + løgne
|
||||
- [x] Guessfase: alle gætter inden Z sek
|
||||
- [x] Pointudregning (konfigurerbar pr. runde)
|
||||
- [x] Scoreboard + næste spørgsmål/runde
|
||||
- [x] Slutresultat
|
||||
- [ ] Spørgsmål vises -> alle skriver løgn inden X sek
|
||||
- [ ] System blander korrekt svar + løgne
|
||||
- [ ] Guessfase: alle gætter inden Z sek
|
||||
- [ ] Pointudregning (konfigurerbar pr. runde)
|
||||
- [ ] Scoreboard + næste spørgsmål/runde
|
||||
- [ ] Slutresultat
|
||||
|
||||
### Fase 4 — Voice-acting (platformkrav)
|
||||
- [ ] Definér TTS provider-interface
|
||||
@@ -103,20 +103,9 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
- [ ] Migrations + static + health checks
|
||||
|
||||
### Backlog — Need-to-have / Nice-to-have
|
||||
- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh
|
||||
- [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
|
||||
- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total)
|
||||
- [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge.
|
||||
- [ ] (Need-to-have) Rate limiting på join/submit endpoints
|
||||
- [ ] (Need-to-have) Session-kode brute-force beskyttelse
|
||||
- [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip)
|
||||
- [ ] (Nice-to-have) Runde-tema musik/lyd-cues
|
||||
- [ ] (Nice-to-have) Hurtig onboarding-skærm for nye spillere
|
||||
|
||||
## PO-beslutninger (2026-02-27)
|
||||
- MVP: Første spil (Fup og Fakta) skal være spilbart end-to-end og stabilt.
|
||||
- Realtime: WebSocket er krav i MVP (ingen polling).
|
||||
- Join: kun i LOBBY i MVP (viewers senere).
|
||||
- Sikkerhed: sanitised inputs + server-side validering + fairness logging er hard requirement.
|
||||
- Spillere: default max 5, minimum 3 for start (konfigurerbart).
|
||||
- Se detaljer: coordination/PO_DECISIONS_2026-02-27.md.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# PO decisions — 2026-02-27
|
||||
|
||||
## MVP scope
|
||||
- MVP = første spil (`Fup og Fakta`) er spilbart end-to-end og fungerer stabilt.
|
||||
- Platformen skal fra start være modulariseret så flere spil kan tilføjes senere.
|
||||
|
||||
## Realtime
|
||||
- WebSocket er et krav i MVP (ikke polling).
|
||||
- Redis/RabbitMQ er muligt senere for skalering; Redis kan bruges tidligt til channels layer.
|
||||
|
||||
## Join-regel
|
||||
- Spillere kan kun joine i `LOBBY` i MVP.
|
||||
- Viewers/observers er post-MVP.
|
||||
|
||||
## Sikkerhed / "anti-cheat"
|
||||
- Fokus i MVP: sikre/sanitized user inputs og robust server-side validering.
|
||||
- Hard requirements i første spilbare version:
|
||||
- input sanitation (XSS/angrebsvektorer)
|
||||
- server-side validering af submit/guess-flow
|
||||
- fairness logging
|
||||
|
||||
## Release-klar kriterier
|
||||
1. Stabilitet er prioritet #1.
|
||||
2. Spillerantal er konfigurerbart; default: max 5, minimum 3 for at starte.
|
||||
3. Spilstruktur skal understøtte parametre:
|
||||
- X = spørgsmål i kategori (stor pool)
|
||||
- K = antal spørgsmål udvalgt pr. kategori (K < X)
|
||||
- Z = antal kategorier i pool (stor pool)
|
||||
- N = antal runder pr. spil
|
||||
- M = antal kategorier pr. runde
|
||||
- K og M holdes ens på tværs af runder i MVP
|
||||
|
||||
## Eksempel
|
||||
- 4 runder, 3 kategorier pr. runde, 2 spørgsmål pr. kategori.
|
||||
|
||||
## Prioriteret roadmap
|
||||
- M1: Lås MVP-scope + acceptkriterier
|
||||
- M2: End-to-end round engine
|
||||
- M3: Anti-cheat/sikkerhed enforcement + fairness logging
|
||||
- M4: Stabilitet/UX + release gates
|
||||
- M5: UI/high-performance forbedringer
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updatedAt": "2026-02-27T13:38:35Z",
|
||||
"updatedAt": "2026-02-27T13:15:06Z",
|
||||
"active": [],
|
||||
"queue": []
|
||||
}
|
||||
|
||||
BIN
core_admin/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
core_admin/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core_admin/__pycache__/admin.cpython-312.pyc
Normal file
BIN
core_admin/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core_admin/__pycache__/apps.cpython-312.pyc
Normal file
BIN
core_admin/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core_admin/__pycache__/models.cpython-312.pyc
Normal file
BIN
core_admin/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
core_admin/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
core_admin/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
db.sqlite3
Normal file
BIN
db.sqlite3
Normal file
Binary file not shown.
@@ -1,28 +0,0 @@
|
||||
# Release policy (Issue #23)
|
||||
|
||||
## Formål
|
||||
Sikre at release-tags altid repræsenterer faktisk deployet software.
|
||||
|
||||
## Hård regel
|
||||
- **Ingen release-tag før staging deploy er succesfuld.**
|
||||
- **Ingen release-tag uden changelog-reference.**
|
||||
- **Ingen deploy hvis tester er i gang med smoke-run.**
|
||||
|
||||
## Release-flow
|
||||
1. Bekræft architect-gate (`issue #17`) er release-approved.
|
||||
2. Bekræft tester ikke er aktiv.
|
||||
3. Deploy kandidat til staging (`infra/staging/deploy_staging.sh`).
|
||||
4. Verificér `/healthz` + smoke-resultat.
|
||||
5. Tilføj changelog-entry i `CHANGELOG.md`.
|
||||
6. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
|
||||
|
||||
## Minimum release-notes template
|
||||
```markdown
|
||||
## Changelog
|
||||
- Ref: CHANGELOG.md#<sektion>
|
||||
|
||||
## Deploy
|
||||
- Environment: staging
|
||||
- Status: success
|
||||
- Healthz: ok
|
||||
```
|
||||
@@ -1,43 +0,0 @@
|
||||
# Staging gameplay smoke artifact (Issue #144)
|
||||
|
||||
Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke i #16/#90-sporet uden scope-udvidelse.
|
||||
|
||||
## Guardrails (MVP)
|
||||
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
|
||||
- Kun verifikation af eksisterende flow; ingen nye features/polish.
|
||||
|
||||
## Hvornår bruges artifacten
|
||||
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
|
||||
- Resultatet logges i issue/PR-kommentar med denne skabelon.
|
||||
|
||||
## Evidence template (kopiér i PR/issue-kommentar)
|
||||
```markdown
|
||||
### Staging gameplay smoke evidence
|
||||
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
|
||||
- Environment: staging
|
||||
- Commit/Head SHA: <sha>
|
||||
- Linked scope: #16 #17 #90 #129 #144
|
||||
|
||||
#### Setup
|
||||
- Host authenticated in Django admin: <yes/no>
|
||||
- Active category/questions present: <yes/no>
|
||||
- Participants: host + <N> players
|
||||
|
||||
#### Checks (PASS/FAIL)
|
||||
1. Lobby -> join -> start
|
||||
- Mixed-case + whitespace session code accepted: <pass/fail>
|
||||
2. One full round to scoreboard
|
||||
- submit lie -> mix -> submit guess -> calculate score -> show scoreboard: <pass/fail>
|
||||
3. Next-round + game-end sanity
|
||||
- next round transitions: <pass/fail>
|
||||
- final leaderboard visible: <pass/fail>
|
||||
|
||||
#### Evidence pointers
|
||||
- Command(s): `<exact command(s)>`
|
||||
- UI notes/screenshots/log refs: <short refs>
|
||||
- Result: <PASS/FAIL>
|
||||
- If FAIL: blocker issue link + shortest repro
|
||||
```
|
||||
|
||||
## Anti-stall minimum
|
||||
Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging.
|
||||
@@ -1,18 +0,0 @@
|
||||
# UI smoke (MVP)
|
||||
|
||||
## Forudsætning
|
||||
- Host er logget ind i Django.
|
||||
- Mindst én aktiv kategori med spørgsmål findes.
|
||||
|
||||
## Flow
|
||||
1. Åbn host-siden på /lobby/ui/host og tryk Opret session.
|
||||
2. Åbn player-siden i 3 faner/enheder på /lobby/ui/player.
|
||||
3. Join alle spillere med sessionkode og nickname.
|
||||
4. Host: vælg kategori, Start runde, Vis spørgsmål.
|
||||
5. Spillere: brug round_question_id og submit løgn.
|
||||
6. Host: Mix svar.
|
||||
7. Spillere: submit gæt.
|
||||
8. Host: Beregn score og Vis scoreboard.
|
||||
9. Host: Næste runde eller Afslut spil.
|
||||
|
||||
Resultat: En fuld runde kan køres uden rå API-kald fra terminal.
|
||||
BIN
fupogfakta/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fupogfakta/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fupogfakta/__pycache__/admin.cpython-312.pyc
Normal file
BIN
fupogfakta/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fupogfakta/__pycache__/apps.cpython-312.pyc
Normal file
BIN
fupogfakta/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fupogfakta/__pycache__/models.cpython-312.pyc
Normal file
BIN
fupogfakta/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-27 13:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fupogfakta', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='roundquestion',
|
||||
name='lie_deadline_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='roundquestion',
|
||||
unique_together={('session', 'round_number')},
|
||||
),
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-27 13:32
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("fupogfakta", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="roundquestion",
|
||||
name="shown_at",
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-27 21:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fupogfakta', '0002_roundquestion_shown_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='roundquestion',
|
||||
name='mixed_answers',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-27 22:08
|
||||
|
||||
import fupogfakta.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fupogfakta', '0003_roundquestion_mixed_answers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='player',
|
||||
name='session_token',
|
||||
field=models.CharField(db_index=True, default=fupogfakta.models._generate_player_session_token, max_length=64),
|
||||
),
|
||||
]
|
||||
BIN
fupogfakta/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
BIN
fupogfakta/migrations/__pycache__/0001_initial.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fupogfakta/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fupogfakta/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
@@ -1,15 +1,8 @@
|
||||
import secrets
|
||||
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from django.db import models
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def _generate_player_session_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=120, unique=True)
|
||||
@@ -60,7 +53,6 @@ class GameSession(models.Model):
|
||||
class Player(models.Model):
|
||||
session = models.ForeignKey(GameSession, on_delete=models.CASCADE, related_name="players")
|
||||
nickname = models.CharField(max_length=40)
|
||||
session_token = models.CharField(max_length=64, db_index=True, default=_generate_player_session_token)
|
||||
score = models.IntegerField(default=0)
|
||||
is_connected = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -93,8 +85,10 @@ class RoundQuestion(models.Model):
|
||||
round_number = models.PositiveIntegerField()
|
||||
question = models.ForeignKey(Question, on_delete=models.PROTECT)
|
||||
correct_answer = models.CharField(max_length=255)
|
||||
shown_at = models.DateTimeField(default=timezone.now)
|
||||
mixed_answers = models.JSONField(default=list, blank=True)
|
||||
lie_deadline_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("session", "round_number"),)
|
||||
|
||||
|
||||
class LieAnswer(models.Model):
|
||||
|
||||
@@ -1,2 +1,93 @@
|
||||
from datetime import timedelta
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Category, GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LiePhaseFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
|
||||
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilket år faldt muren?",
|
||||
correct_answer="1989",
|
||||
is_active=True,
|
||||
)
|
||||
RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=self.session.current_round,
|
||||
category=self.category,
|
||||
lie_seconds=20,
|
||||
)
|
||||
self.player = Player.objects.create(session=self.session, nickname="Luna")
|
||||
|
||||
def test_host_can_start_lie_question_with_deadline(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
payload = response.json()["question"]
|
||||
self.assertEqual(payload["round"], 1)
|
||||
self.assertEqual(payload["prompt"], self.question.prompt)
|
||||
self.assertEqual(payload["lie_seconds"], 20)
|
||||
|
||||
round_question = RoundQuestion.objects.get(session=self.session, round_number=1)
|
||||
self.assertIsNotNone(round_question.lie_deadline_at)
|
||||
|
||||
def test_submit_lie_before_deadline_is_accepted(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
lie_deadline_at=timezone.now() + timedelta(seconds=10),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}),
|
||||
data={"player_id": self.player.id, "text": "1991"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(
|
||||
LieAnswer.objects.filter(round_question=round_question, player=self.player, text="1991").exists()
|
||||
)
|
||||
self.assertEqual(response.json()["progress"]["submitted_count"], 1)
|
||||
|
||||
def test_submit_lie_after_deadline_is_rejected(self):
|
||||
RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
lie_deadline_at=timezone.now() - timedelta(seconds=1),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("fupogfakta:submit_lie", kwargs={"code": self.session.code}),
|
||||
data={"player_id": self.player.id, "text": "1991"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Lie timer expired")
|
||||
self.assertEqual(LieAnswer.objects.count(), 0)
|
||||
|
||||
def test_non_host_cannot_start_lie_question(self):
|
||||
User.objects.create_user(username="other", password="secret123")
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
response = self.client.post(reverse("fupogfakta:start_lie_question", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can start lie question")
|
||||
|
||||
10
fupogfakta/urls.py
Normal file
10
fupogfakta/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "fupogfakta"
|
||||
|
||||
urlpatterns = [
|
||||
path("sessions/<str:code>/lie-question/start", views.start_lie_question, name="start_lie_question"),
|
||||
path("sessions/<str:code>/lies", views.submit_lie, name="submit_lie"),
|
||||
]
|
||||
@@ -1,2 +1,176 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
# Create your views here.
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .models import GameSession, LieAnswer, Player, Question, RoundConfig, RoundQuestion
|
||||
|
||||
|
||||
MAX_LIE_LENGTH = 255
|
||||
|
||||
|
||||
def _json_body(request: HttpRequest) -> dict:
|
||||
if not request.body:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _emit_realtime_session_event(session_code: str, event: str, payload: dict) -> None:
|
||||
channel_layer = get_channel_layer()
|
||||
if not channel_layer:
|
||||
return
|
||||
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
f"session_{session_code}",
|
||||
{
|
||||
"type": "session.event",
|
||||
"event": event,
|
||||
"payload": payload,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
# Realtime broadcasting must not fail game flow.
|
||||
return
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def start_lie_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
|
||||
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 lie question"}, status=403)
|
||||
|
||||
if session.status != GameSession.Status.LIE:
|
||||
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
|
||||
|
||||
with transaction.atomic():
|
||||
session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if session.status != GameSession.Status.LIE:
|
||||
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
|
||||
|
||||
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
|
||||
return JsonResponse({"error": "Lie question already started for round"}, status=409)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.select_related("category").get(
|
||||
session=session,
|
||||
number=session.current_round,
|
||||
)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return JsonResponse({"error": "Round config not found"}, status=404)
|
||||
|
||||
question_qs = Question.objects.filter(category=round_config.category, is_active=True)
|
||||
question_ids = list(question_qs.values_list("id", flat=True))
|
||||
if not question_ids:
|
||||
return JsonResponse({"error": "No active question for category"}, status=400)
|
||||
|
||||
question = question_qs.get(pk=random.choice(question_ids))
|
||||
|
||||
lie_deadline_at = timezone.now() + timedelta(seconds=round_config.lie_seconds)
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
lie_deadline_at=lie_deadline_at,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"round": round_question.round_number,
|
||||
"question_id": round_question.id,
|
||||
"prompt": round_question.question.prompt,
|
||||
"lie_deadline_at": round_question.lie_deadline_at.isoformat(),
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
}
|
||||
_emit_realtime_session_event(session.code, "lie_question_started", payload)
|
||||
|
||||
return JsonResponse({"question": payload}, status=201)
|
||||
|
||||
|
||||
@require_POST
|
||||
def submit_lie(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = code.strip().upper()
|
||||
payload = _json_body(request)
|
||||
|
||||
player_id = payload.get("player_id")
|
||||
lie_text = str(payload.get("text", "")).strip()
|
||||
|
||||
if not player_id:
|
||||
return JsonResponse({"error": "player_id is required"}, status=400)
|
||||
|
||||
if len(lie_text) < 2 or len(lie_text) > MAX_LIE_LENGTH:
|
||||
return JsonResponse({"error": "text must be between 2 and 255 characters"}, status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.status != GameSession.Status.LIE:
|
||||
return JsonResponse({"error": "Session is not in lie phase"}, status=400)
|
||||
|
||||
try:
|
||||
player = Player.objects.get(id=player_id, session=session)
|
||||
except Player.DoesNotExist:
|
||||
return JsonResponse({"error": "Player not found in session"}, status=404)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(session=session, round_number=session.current_round)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return JsonResponse({"error": "No active lie question"}, status=404)
|
||||
|
||||
now = timezone.now()
|
||||
if round_question.lie_deadline_at and now > round_question.lie_deadline_at:
|
||||
return JsonResponse({"error": "Lie timer expired"}, status=400)
|
||||
|
||||
lie_answer, _ = LieAnswer.objects.update_or_create(
|
||||
round_question=round_question,
|
||||
player=player,
|
||||
defaults={"text": lie_text},
|
||||
)
|
||||
|
||||
submitted_count = LieAnswer.objects.filter(round_question=round_question).count()
|
||||
players_count = session.players.count()
|
||||
|
||||
event_payload = {
|
||||
"round": session.current_round,
|
||||
"question_id": round_question.id,
|
||||
"player_id": player.id,
|
||||
"submitted_count": submitted_count,
|
||||
"players_count": players_count,
|
||||
"lie_deadline_at": round_question.lie_deadline_at.isoformat() if round_question.lie_deadline_at else None,
|
||||
}
|
||||
_emit_realtime_session_event(session.code, "lie_submitted", event_payload)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"lie": {
|
||||
"id": lie_answer.id,
|
||||
"player_id": player.id,
|
||||
"question_id": round_question.id,
|
||||
},
|
||||
"progress": {
|
||||
"submitted_count": submitted_count,
|
||||
"players_count": players_count,
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
12
infra/env/.env.staging.example
vendored
12
infra/env/.env.staging.example
vendored
@@ -1,12 +0,0 @@
|
||||
DJANGO_SECRET_KEY=change-me-staging
|
||||
DJANGO_DEBUG=false
|
||||
DJANGO_ALLOWED_HOSTS=staging.party.weircon.dk
|
||||
DB_ENGINE=django.db.backends.mysql
|
||||
DB_NAME=wpp_staging
|
||||
DB_USER=wpp_staging
|
||||
DB_PASSWORD=change-me
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
TEST_DB_NAME=
|
||||
CHANNEL_REDIS_HOST=127.0.0.1
|
||||
CHANNEL_REDIS_PORT=6379
|
||||
@@ -1,35 +0,0 @@
|
||||
# DB setup runbook (Issue #21)
|
||||
|
||||
> Credentials ligger i Secrets-repo, ikke i applikationsrepo.
|
||||
|
||||
## Databaser
|
||||
- `wpp_test`
|
||||
- `wpp_staging`
|
||||
- `wpp_prod`
|
||||
|
||||
## Brugere
|
||||
- `wpp_test_user` (least privilege på `wpp_test`)
|
||||
- `wpp_staging_user` (least privilege på `wpp_staging`)
|
||||
- `wpp_prod_user` (least privilege på `wpp_prod`)
|
||||
|
||||
## Secrets placering
|
||||
I Secrets-repo:
|
||||
- `wpp/wpp_test.env`
|
||||
- `wpp/wpp_staging.env`
|
||||
- `wpp/wpp_prod.env`
|
||||
|
||||
Forventede felter:
|
||||
- `DB_HOST`
|
||||
- `DB_PORT`
|
||||
- `DB_NAME`
|
||||
- `DB_USER`
|
||||
- `DB_PASSWORD`
|
||||
|
||||
## Verifikation (eksempel)
|
||||
Kør fra staging-CT eller anden tilladt klient:
|
||||
|
||||
```bash
|
||||
mysql -h <DB_HOST> -u <DB_USER> -p<DB_PASSWORD> -e "SELECT 1" <DB_NAME>
|
||||
```
|
||||
|
||||
Alle forbindelser skal returnere `1`.
|
||||
@@ -1,50 +0,0 @@
|
||||
# Staging runbook (Issue #20)
|
||||
|
||||
## Mål
|
||||
Staging-miljø for WPP i Proxmox LXC, så release-klar kode kan deployes og smoke-testes sikkert.
|
||||
|
||||
## Miljø
|
||||
- LXC: CT 143 (wpp-staging)
|
||||
- App path: /opt/wpp-staging/app
|
||||
- Service: wpp-staging.service
|
||||
- Health endpoint: GET /healthz
|
||||
- Database: MySQL (staging må ikke bruge SQLite, issue #133)
|
||||
|
||||
## Verifikation
|
||||
Kør fra devops-shell med Proxmox-adgang:
|
||||
|
||||
ssh proxmox-lan "sudo -n pct status 143"
|
||||
ssh proxmox-lan "sudo -n pct exec 143 -- systemctl is-active wpp-staging.service"
|
||||
ssh proxmox-lan "sudo -n pct exec 143 -- curl -fsS http://127.0.0.1:8000/healthz"
|
||||
|
||||
Forventet:
|
||||
- CT er running
|
||||
- service er active
|
||||
- healthz returnerer JSON med ok=true
|
||||
|
||||
Smoke-suite skriver nu et gameplay-artifact som JSON under `/opt/wpp-staging/app/artifacts/smoke/` (kan overrides via `ARTIFACT_DIR`/`ARTIFACT_FILE`).
|
||||
|
||||
Efter deploy validerer scriptet, at `DB_ENGINE` ikke er `django.db.backends.sqlite3` før migrations køres.
|
||||
|
||||
Deploy-scriptet bruger en release-candidate mappe og promoverer først til `/opt/wpp-staging/app` efter succesfuld `migrate`. Det reducerer schema/code drift ved afbrudte deploys (issue #130) og understøtter release-readiness gate (issue #90).
|
||||
|
||||
## Deploy (canonical execution context)
|
||||
Deploy skal altid køres via Proxmox host over SSH (ikke fra lokal coder-shell med direkte sudo pct).
|
||||
|
||||
Officiel kommando:
|
||||
|
||||
./infra/staging/deploy_staging.sh [ref]
|
||||
|
||||
Scriptet bruger default PROXMOX_HOST=proxmox-lan og kører sudo -n pct exec på hosten.
|
||||
|
||||
Eksempler:
|
||||
|
||||
./infra/staging/deploy_staging.sh
|
||||
./infra/staging/deploy_staging.sh v0.3.0
|
||||
PROXMOX_HOST=proxmox-prod ./infra/staging/deploy_staging.sh main
|
||||
|
||||
## Policy-kobling
|
||||
Før deploy:
|
||||
1. Bekræft at tester ikke er aktiv (ingen aktiv smoke-run).
|
||||
2. Deploy til staging skal lykkes.
|
||||
3. Først derefter må release-tag oprettes (se docs/RELEASE_POLICY.md).
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CT_ID="${CT_ID:-143}"
|
||||
REF_NAME="${1:-main}"
|
||||
ARCHIVE_URL="https://gitea.weircon.dk/wpp/weirsoe-party-protocol/archive/${REF_NAME}.tar.gz"
|
||||
PROXMOX_HOST="${PROXMOX_HOST:-proxmox-lan}"
|
||||
|
||||
echo "[deploy] host=${PROXMOX_HOST} CT_ID=${CT_ID} REF=${REF_NAME}"
|
||||
|
||||
echo "[deploy] extracting source + installing deps + migrate + restart"
|
||||
ssh "${PROXMOX_HOST}" sudo -n /usr/sbin/pct exec "${CT_ID}" -- bash -s -- "${ARCHIVE_URL}" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
ARCHIVE_URL="$1"
|
||||
RELEASES_DIR=/opt/wpp-staging/releases
|
||||
CANDIDATE_DIR="${RELEASES_DIR}/src"
|
||||
APP_DIR=/opt/wpp-staging/app
|
||||
|
||||
install -d -m 0755 -o wpp -g wpp "${RELEASES_DIR}" "${APP_DIR}"
|
||||
mkdir -p "${CANDIDATE_DIR}"
|
||||
cd "${RELEASES_DIR}"
|
||||
curl -fsSL "${ARCHIVE_URL}" -o app.tar.gz
|
||||
rm -rf "${CANDIDATE_DIR}" && mkdir -p "${CANDIDATE_DIR}"
|
||||
tar -xzf app.tar.gz -C "${CANDIDATE_DIR}" --strip-components=1
|
||||
|
||||
# Ensure deploy artifact copied as root does not leave candidate tree non-writable for wpp.
|
||||
chown -R wpp:wpp "${CANDIDATE_DIR}"
|
||||
# Staging must not run on SQLite (issue #133). Remove bundled sqlite artifact from candidate.
|
||||
rm -f "${CANDIDATE_DIR}/db.sqlite3"
|
||||
cd "${CANDIDATE_DIR}"
|
||||
|
||||
# Load staging env before any manage.py call (issue #133 follow-up).
|
||||
ENV_LOADED=0
|
||||
for ENV_FILE in \
|
||||
/opt/wpp-staging/.env.staging \
|
||||
/opt/wpp-staging/.env \
|
||||
/opt/wpp-staging/env/wpp_staging.env \
|
||||
/opt/wpp-staging/secrets/wpp_staging.env
|
||||
do
|
||||
if [ -f "${ENV_FILE}" ]; then
|
||||
set -a
|
||||
. "${ENV_FILE}"
|
||||
set +a
|
||||
echo "[deploy] loaded staging env: ${ENV_FILE}"
|
||||
ENV_LOADED=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "${ENV_LOADED}" -ne 1 ]; then
|
||||
echo "[deploy] ERROR: no staging env file found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runuser -u wpp -- python3 -m venv .venv
|
||||
runuser -u wpp -- .venv/bin/pip install -U pip >/dev/null
|
||||
runuser -u wpp -- .venv/bin/pip install -r requirements.txt >/dev/null
|
||||
runuser -u wpp -- .venv/bin/python manage.py shell -c "from django.conf import settings; import sys; engine = settings.DATABASES['default']['ENGINE']; print(f'DB_ENGINE={engine}'); sys.exit(0 if engine != 'django.db.backends.sqlite3' else 1)"
|
||||
runuser -u wpp -- .venv/bin/python manage.py migrate --noinput
|
||||
|
||||
# Promote candidate only after migrations succeed (issue #130): avoid code/schema drift after failed deploy.
|
||||
# Use find instead of rm "${APP_DIR}"/* to reliably remove dotfiles between deploys.
|
||||
find "${APP_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
|
||||
cp -a "${CANDIDATE_DIR}"/. "${APP_DIR}/"
|
||||
chown -R wpp:wpp "${APP_DIR}"
|
||||
runuser -u wpp -- test -w "${APP_DIR}"
|
||||
|
||||
systemctl restart wpp-staging.service
|
||||
|
||||
HEALTH_URL="http://127.0.0.1:8000/healthz"
|
||||
MAX_ATTEMPTS=7
|
||||
SLEEP_SECONDS=1
|
||||
ATTEMPT=1
|
||||
|
||||
until curl -fsS "${HEALTH_URL}" >/dev/null; do
|
||||
if [ "${ATTEMPT}" -ge "${MAX_ATTEMPTS}" ]; then
|
||||
echo "[deploy] ERROR: health check failed after ${MAX_ATTEMPTS} attempts: ${HEALTH_URL}" >&2
|
||||
echo "[deploy] service status (wpp-staging.service):" >&2
|
||||
systemctl --no-pager --full status wpp-staging.service >&2 || true
|
||||
echo "[deploy] recent service logs (wpp-staging.service):" >&2
|
||||
journalctl --no-pager -u wpp-staging.service -n 80 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[deploy] health check not ready (attempt ${ATTEMPT}/${MAX_ATTEMPTS}); retrying in ${SLEEP_SECONDS}s"
|
||||
sleep "${SLEEP_SECONDS}"
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
if [ "${SLEEP_SECONDS}" -lt 8 ]; then
|
||||
SLEEP_SECONDS=$((SLEEP_SECONDS * 2))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[deploy] health check passed: ${HEALTH_URL}"
|
||||
REMOTE
|
||||
|
||||
echo "[deploy] OK: staging deploy complete for CT ${CT_ID} (${REF_NAME})"
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
|
||||
APP_DIR="${APP_DIR:-/opt/wpp-staging/app}"
|
||||
ISSUE_ON_FAIL="${ISSUE_ON_FAIL:-1}"
|
||||
|
||||
fail() {
|
||||
local message="$1"
|
||||
echo "[smoke] FAIL: ${message}" >&2
|
||||
|
||||
if [[ "${ISSUE_ON_FAIL}" == "1" ]] && [[ -n "${GITEA_BASE:-}" ]] && [[ -n "${GITEA_REPO:-}" ]] && [[ -n "${GITEA_USER:-}" ]] && [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
python3 - <<PY || true
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
|
||||
base = os.environ["GITEA_BASE"].rstrip("/")
|
||||
repo = os.environ["GITEA_REPO"]
|
||||
user = os.environ["GITEA_USER"]
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
message = os.environ.get("SMOKE_FAIL_MESSAGE", "unknown")
|
||||
when = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
payload = {
|
||||
"title": f"[smoke-fail] staging smoke failed ({when})",
|
||||
"body": (
|
||||
"Automatisk oprettet af smoke-suite.\n\n"
|
||||
f"Fejl: `{message}`\n"
|
||||
"Kontekst: issue #22 (staging smoke-suite)"
|
||||
),
|
||||
"labels": ["smoke-fail", "need-to-have", "staging"],
|
||||
}
|
||||
|
||||
url = f"{base}/api/v1/repos/{repo}/issues"
|
||||
req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST")
|
||||
auth = base64.b64encode(f"{user}:{token}".encode()).decode()
|
||||
req.add_header("Authorization", f"Basic {auth}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req) as r:
|
||||
print(f"[smoke] Created fail issue: HTTP {r.status}")
|
||||
PY
|
||||
fi
|
||||
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "[smoke] healthz check: ${BASE_URL}/healthz"
|
||||
curl -fsS "${BASE_URL}/healthz" >/dev/null || { SMOKE_FAIL_MESSAGE="healthz check failed" fail "healthz check failed"; }
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/etc/wpp/staging.env}"
|
||||
|
||||
run_manage() {
|
||||
local cmd="$1"
|
||||
(
|
||||
cd "${APP_DIR}"
|
||||
if [[ -f "${ENV_FILE}" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
.venv/bin/python manage.py ${cmd}
|
||||
)
|
||||
}
|
||||
|
||||
echo "[smoke] migration consistency check"
|
||||
run_manage "migrate --check --noinput" || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; }
|
||||
|
||||
ARTIFACT_DIR="${ARTIFACT_DIR:-${APP_DIR}/artifacts/smoke}"
|
||||
ARTIFACT_FILE="${ARTIFACT_FILE:-${ARTIFACT_DIR}/smoke-$(date -u +%Y%m%dT%H%M%SZ).json}"
|
||||
|
||||
echo "[smoke] gameplay flow via management command"
|
||||
run_manage "smoke_staging --artifact ${ARTIFACT_FILE}" || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
|
||||
|
||||
echo "[smoke] artifact: ${ARTIFACT_FILE}"
|
||||
echo "[smoke] OK"
|
||||
BIN
lobby/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
lobby/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
lobby/__pycache__/admin.cpython-312.pyc
Normal file
BIN
lobby/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
lobby/__pycache__/apps.cpython-312.pyc
Normal file
BIN
lobby/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
lobby/__pycache__/models.cpython-312.pyc
Normal file
BIN
lobby/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
@@ -1,2 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.test import Client
|
||||
|
||||
from fupogfakta.models import Category, GameSession, Player, Question, RoundQuestion
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run minimal staging smoke flow for lobby gameplay"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--artifact",
|
||||
help="Optional path to write smoke result artifact as JSON",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
GameSession.objects.all().delete()
|
||||
Player.objects.all().delete()
|
||||
RoundQuestion.objects.all().delete()
|
||||
|
||||
category, _ = Category.objects.get_or_create(
|
||||
slug="smoke",
|
||||
defaults={"name": "Smoke", "is_active": True},
|
||||
)
|
||||
category.is_active = True
|
||||
category.save(update_fields=["is_active"])
|
||||
|
||||
Question.objects.get_or_create(
|
||||
category=category,
|
||||
prompt="Smoke prompt?",
|
||||
defaults={"correct_answer": "Correct", "is_active": True},
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
host, _ = User.objects.get_or_create(username="smoke-host")
|
||||
host.set_password("smoke-pass")
|
||||
host.is_staff = True
|
||||
host.save()
|
||||
|
||||
host_client = Client()
|
||||
host_client.force_login(host)
|
||||
|
||||
create_res = host_client.post("/lobby/sessions/create", content_type="application/json")
|
||||
if create_res.status_code != 201:
|
||||
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}")
|
||||
|
||||
code = create_res.json()["session"]["code"]
|
||||
|
||||
players = []
|
||||
for nickname in ["P1", "P2", "P3"]:
|
||||
join_res = Client().post(
|
||||
"/lobby/sessions/join",
|
||||
data=json.dumps({"code": code, "nickname": nickname}),
|
||||
content_type="application/json",
|
||||
)
|
||||
if join_res.status_code != 201:
|
||||
raise CommandError(f"join_session failed for {nickname}: {join_res.status_code}")
|
||||
players.append(join_res.json()["player"])
|
||||
|
||||
start_res = host_client.post(
|
||||
f"/lobby/sessions/{code}/rounds/start",
|
||||
data=json.dumps({"category_slug": category.slug}),
|
||||
content_type="application/json",
|
||||
)
|
||||
if start_res.status_code != 201:
|
||||
raise CommandError(f"start_round failed: {start_res.status_code}")
|
||||
|
||||
show_res = host_client.post(f"/lobby/sessions/{code}/questions/show", content_type="application/json")
|
||||
if show_res.status_code != 201:
|
||||
raise CommandError(f"show_question failed: {show_res.status_code}")
|
||||
|
||||
round_question_id = show_res.json()["round_question"]["id"]
|
||||
|
||||
for player in players:
|
||||
nick = player["nickname"]
|
||||
lie_res = Client().post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
|
||||
data=json.dumps(
|
||||
{
|
||||
"player_id": player["id"],
|
||||
"session_token": player["session_token"],
|
||||
"text": f"Lie from {nick}",
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
if lie_res.status_code != 201:
|
||||
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
|
||||
|
||||
mix_res = host_client.post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/answers/mix",
|
||||
content_type="application/json",
|
||||
)
|
||||
if mix_res.status_code != 200:
|
||||
raise CommandError(f"mix_answers failed: {mix_res.status_code}")
|
||||
|
||||
answers = mix_res.json().get("answers", [])
|
||||
if not answers:
|
||||
raise CommandError("mix_answers returned empty answers")
|
||||
|
||||
for player in players:
|
||||
nick = player["nickname"]
|
||||
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
|
||||
guess_res = Client().post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
|
||||
data=json.dumps(
|
||||
{
|
||||
"player_id": player["id"],
|
||||
"session_token": player["session_token"],
|
||||
"selected_text": selected["text"],
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
if guess_res.status_code != 201:
|
||||
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
|
||||
|
||||
calc_res = host_client.post(
|
||||
f"/lobby/sessions/{code}/questions/{round_question_id}/scores/calculate",
|
||||
content_type="application/json",
|
||||
)
|
||||
if calc_res.status_code != 200:
|
||||
raise CommandError(f"calculate_scores failed: {calc_res.status_code}")
|
||||
|
||||
board_res = host_client.get(f"/lobby/sessions/{code}/scoreboard")
|
||||
if board_res.status_code != 200:
|
||||
raise CommandError(f"reveal_scoreboard failed: {board_res.status_code}")
|
||||
|
||||
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
|
||||
if finish_res.status_code != 200:
|
||||
raise CommandError(f"finish_game failed: {finish_res.status_code}")
|
||||
|
||||
artifact_path = options.get("artifact")
|
||||
if artifact_path:
|
||||
artifact = {
|
||||
"ok": True,
|
||||
"command": "smoke_staging",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"session_code": code,
|
||||
"players": [player["nickname"] for player in players],
|
||||
"round_question_id": round_question_id,
|
||||
"steps": [
|
||||
"create_session",
|
||||
"join_players",
|
||||
"start_round",
|
||||
"show_question",
|
||||
"submit_lies",
|
||||
"mix_answers",
|
||||
"submit_guesses",
|
||||
"calculate_scores",
|
||||
"reveal_scoreboard",
|
||||
"finish_game",
|
||||
],
|
||||
}
|
||||
output_path = Path(artifact_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Smoke flow OK for session {code}"))
|
||||
BIN
lobby/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
lobby/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
@@ -1,2 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Host</title></head>
|
||||
<body>
|
||||
<h1>Host panel (MVP)</h1>
|
||||
<p>Kræver login som host-bruger.</p>
|
||||
<button id="createSessionBtn" onclick="createSession()">1) Opret session</button>
|
||||
<p id="createSessionHint">Opret session er klar.</p>
|
||||
<input id="code" placeholder="Sessionkode">
|
||||
<select id="category">{% for c in categories %}<option value="{{ c.slug }}">{{ c.name }}</option>{% endfor %}</select>
|
||||
<button id="startRoundBtn" onclick="startRound()" disabled>2) Start runde</button>
|
||||
<p id="startRoundHint">Kræver 3-5 spillere i lobbyen.</p>
|
||||
<p id="playerCountStatus">Spillere i session: ukendt</p>
|
||||
<p id="categoryGuardHint">Kategori er kun redigérbar i lobby-fasen.</p>
|
||||
|
||||
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
|
||||
<button id="showQuestionBtn" onclick="showQuestion()" disabled>3) Vis spørgsmål</button>
|
||||
<input id="roundQuestionId" placeholder="Round question id">
|
||||
<p id="roundQuestionGuardHint">Round question-id kan kun redigeres i lie/guess/reveal-faser.</p>
|
||||
<button id="mixAnswersBtn" onclick="mixAnswers()" disabled>4) Mix svar</button>
|
||||
<button id="calcScoresBtn" onclick="calcScores()" disabled>5) Beregn score</button>
|
||||
<button id="showScoreboardBtn" onclick="showScoreboard()" disabled>6) Scoreboard</button>
|
||||
<button id="nextRoundBtn" onclick="nextRound()" disabled>7) Næste runde</button>
|
||||
<button id="finishGameBtn" onclick="finishGame()" disabled>8) Afslut spil</button>
|
||||
<p id="hostActionHint">Angiv sessionkode for at aktivere host-actions.</p>
|
||||
<p id="hostErrorHint">Ingen fejl.</p>
|
||||
<button id="sessionDetailBtn" onclick="sessionDetail()">Session-status</button>
|
||||
<p id="sessionDetailHint">Session-opdatering klar.</p>
|
||||
<button id="autoRefreshToggleBtn" onclick="toggleAutoRefresh()">Auto-refresh: OFF</button>
|
||||
<p id="autoRefreshHint">Auto-refresh er slået fra.</p>
|
||||
<p id="lastRefreshStatus">Session-data ikke opdateret endnu.</p>
|
||||
<pre id="out">Klar.</pre>
|
||||
<script>
|
||||
var currentSessionStatus="";
|
||||
var autoRefreshEnabled=false;
|
||||
var autoRefreshTimer=null;
|
||||
var hostActionInFlight=false;
|
||||
var lastRefreshAtLabel="";
|
||||
var lastRefreshFailed=false;
|
||||
var sessionDetailInFlight=false;
|
||||
function csrf(){var m=document.cookie.match(/csrftoken=([^;]+)/);return m?m[1]:"";}
|
||||
function code(){return document.getElementById("code").value.trim().toUpperCase();}
|
||||
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
||||
|
||||
function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
|
||||
function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
|
||||
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
|
||||
function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggleBtn");var hint=document.getElementById("autoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(autoRefreshEnabled?"ON":"OFF");btn.disabled=hostActionInFlight||sessionDetailInFlight||!code();}if(!hint){return;}if(hostActionInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv host-handling.";return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(!autoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(!code()){hint.textContent="Auto-refresh venter: angiv sessionkode.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for lobby-status.";}
|
||||
function stopAutoRefresh(reason){autoRefreshEnabled=false;if(autoRefreshTimer){clearInterval(autoRefreshTimer);autoRefreshTimer=null;}if(reason){var hint=document.getElementById("autoRefreshHint");if(hint){hint.textContent=reason;}}updateAutoRefreshUi();saveHostContext();}
|
||||
function startAutoRefresh(){if(!code()){updateAutoRefreshUi();return;}autoRefreshEnabled=true;if(autoRefreshTimer){clearInterval(autoRefreshTimer);}autoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updateAutoRefreshUi();saveHostContext();}
|
||||
function toggleAutoRefresh(){if(hostActionInFlight||sessionDetailInFlight||!code()){updateAutoRefreshUi();return;}if(autoRefreshEnabled){stopAutoRefresh();return;}startAutoRefresh();}
|
||||
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
|
||||
function markSessionRefresh(status){if(status>=200&&status<300){lastRefreshAtLabel=formatTimeLabel(new Date());lastRefreshFailed=false;}else{lastRefreshFailed=true;}updateLastRefreshStatus();}
|
||||
function updateLastRefreshStatus(){var el=document.getElementById("lastRefreshStatus");if(!el){return;}if(!lastRefreshAtLabel){el.textContent=lastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(lastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+lastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+lastRefreshAtLabel+".";}
|
||||
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
|
||||
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
|
||||
function updateErrorHint(status,data){var el=document.getElementById("hostErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
||||
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";return;}hint.textContent="Session-opdatering klar.";}
|
||||
|
||||
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
|
||||
function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";}
|
||||
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
|
||||
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}saveHostContext();return d;}
|
||||
function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});}
|
||||
function createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});}
|
||||
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
|
||||
function startRound(){if(document.getElementById("startRoundBtn").disabled){return Promise.resolve({error:"not_enough_players_client_guard"});}return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/start","POST",{category_slug:document.getElementById("category").value});});}
|
||||
function showQuestion(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/show","POST",{});});}
|
||||
function mixAnswers(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/answers/mix","POST",{});});}
|
||||
function calcScores(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/scores/calculate","POST",{});});}
|
||||
function showScoreboard(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/scoreboard","GET",null);});}
|
||||
function nextRound(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/next","POST",{});});}
|
||||
function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});}
|
||||
["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});});
|
||||
|
||||
updatePhaseStatus();syncStartRoundGuard(null);updateHostActionState();updateCreateSessionState();updateSessionDetailState();updateAutoRefreshUi();updateLastRefreshStatus();
|
||||
if(restoreHostContext()){updatePhaseStatus();if(autoRefreshEnabled){startAutoRefresh();}sessionDetail();}else{saveHostContext();}
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -1,122 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="da"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>WPP Player</title>
|
||||
<style>
|
||||
#answerOptions { margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
#answerOptions button { border: 1px solid #999; padding: 6px 10px; border-radius: 8px; background: #f4f4f4; cursor: pointer; }
|
||||
#answerOptions button.active { border-color: #1652f0; background: #dfe9ff; }
|
||||
#guessStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
|
||||
#lieStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
|
||||
#joinStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
|
||||
#contextLockHint { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
|
||||
#phaseStatus { margin: 6px 0 10px; font-size: 0.95rem; color: #333; }
|
||||
#connectionBanner { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px; border: 1px solid #b91c1c; background: #fee2e2; color: #7f1d1d; display: none; }
|
||||
#connectionBanner button { margin-left: 8px; border: 1px solid #991b1b; background: #fff; color: #7f1d1d; border-radius: 6px; padding: 4px 8px; cursor: pointer; }
|
||||
#connectionBanner button[disabled] { opacity: 0.55; cursor: not-allowed; }
|
||||
#lieStatus.locked { color: #0a5f2d; font-weight: 600; }
|
||||
#guessStatus.locked { color: #0a5f2d; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Player panel (MVP)</h1>
|
||||
<input id="code" placeholder="Sessionkode">
|
||||
<input id="nickname" placeholder="Nickname">
|
||||
<button id="joinBtn" onclick="joinSession()">1) Join</button>
|
||||
<p id="joinStatus">Klar til join.</p>
|
||||
<p id="contextLockHint">Kontekst er ikke låst endnu.</p>
|
||||
<input id="playerId" placeholder="Player id">
|
||||
<input id="sessionToken" placeholder="Session token" type="password" readonly>
|
||||
<input id="roundQuestionId" placeholder="Round question id" readonly>
|
||||
<input id="lieText" placeholder="Din løgn">
|
||||
<button id="lieSubmitBtn" onclick="submitLie()">2) Submit løgn</button>
|
||||
<p id="lieStatus">Skriv din løgn.</p>
|
||||
<input id="guessText" placeholder="Dit gæt" readonly>
|
||||
<div id="answerOptions"></div>
|
||||
<p id="guessStatus">Vælg et svar.</p>
|
||||
<button id="guessSubmitBtn" onclick="submitGuess()" disabled>3) Submit gæt</button>
|
||||
<button id="sessionDetailBtn" onclick="sessionDetail()">Opdater session-status</button>
|
||||
<p id="sessionRefreshHint">Session-opdatering klar.</p>
|
||||
<p id="roundContextHint">Runde-kontekst afventer session-opdatering.</p>
|
||||
<button id="playerAutoRefreshToggleBtn" onclick="togglePlayerAutoRefresh()">Auto-refresh: OFF</button>
|
||||
<p id="playerAutoRefreshHint">Auto-refresh er slået fra.</p>
|
||||
<p id="playerLastRefreshStatus">Session-data ikke opdateret endnu.</p>
|
||||
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
|
||||
<p id="playerErrorHint">Ingen fejl.</p>
|
||||
<div id="connectionBanner">Forbindelsen til serveren blev afbrudt.<button id="connectionRetryBtn" type="button" onclick="retryConnection()">Prøv igen</button></div>
|
||||
<pre id="out">Klar.</pre>
|
||||
<script>
|
||||
var availableAnswers=[];
|
||||
var guessSubmitted=false;
|
||||
var lieSubmitted=false;
|
||||
var lieSubmitInFlight=false;
|
||||
var guessSubmitInFlight=false;
|
||||
var PLAYER_CONTEXT_KEY="wppPlayerContext";
|
||||
var joinInFlight=false;
|
||||
var currentSessionStatus="";
|
||||
var playerAutoRefreshEnabled=false;
|
||||
var playerAutoRefreshTimer=null;
|
||||
var sessionDetailInFlight=false;
|
||||
var playerLastRefreshAtLabel="";
|
||||
var playerLastRefreshFailed=false;
|
||||
var connectionLost=false;
|
||||
var connectionRetryInFlight=false;
|
||||
function code(){return document.getElementById("code").value.trim().toUpperCase();}
|
||||
function pid(){return document.getElementById("playerId").value.trim();}
|
||||
function rq(){return document.getElementById("roundQuestionId").value.trim();}
|
||||
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
|
||||
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
|
||||
function savePlayerContext(){try{localStorage.setItem(PLAYER_CONTEXT_KEY,JSON.stringify({code:code(),nickname:document.getElementById("nickname").value.trim(),player_id:pid(),session_token:document.getElementById("sessionToken").value.trim(),round_question_id:rq(),auto_refresh:playerAutoRefreshEnabled}));}catch(_e){}}
|
||||
function loadPlayerContext(){try{var raw=localStorage.getItem(PLAYER_CONTEXT_KEY);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
|
||||
function restorePlayerContext(){var ctx=loadPlayerContext();if(!ctx){return false;}if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.nickname){document.getElementById("nickname").value=ctx.nickname;}if(ctx.player_id){document.getElementById("playerId").value=ctx.player_id;}if(ctx.session_token){document.getElementById("sessionToken").value=ctx.session_token;}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}playerAutoRefreshEnabled=!!ctx.auto_refresh;updatePlayerAutoRefreshUi();return !!(ctx.code&&ctx.player_id&&ctx.session_token);}
|
||||
function hasSubmitContext(){return !!(code()&&pid()&&rq()&&document.getElementById("sessionToken").value.trim());}
|
||||
function hasRoundQuestionContext(){return !!rq();}
|
||||
function isPlayerContextLocked(){return !!(pid()&&document.getElementById("sessionToken").value.trim());}
|
||||
function updateRoundContextHint(){var el=document.getElementById("roundContextHint");if(!el){return;}if(rq()){el.textContent="Runde-kontekst aktiv for spørgsmål #"+rq()+".";return;}el.textContent="Runde-kontekst afventer session-opdatering.";}
|
||||
function resetRoundContextForManualChange(){document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);updateRoundContextHint();}
|
||||
function updateContextLockState(){var locked=isPlayerContextLocked()||joinInFlight;var codeField=document.getElementById("code");var nicknameField=document.getElementById("nickname");var playerIdField=document.getElementById("playerId");var tokenField=document.getElementById("sessionToken");if(codeField){codeField.readOnly=locked;}if(nicknameField){nicknameField.readOnly=locked;}if(playerIdField){playerIdField.readOnly=locked;}if(tokenField){tokenField.readOnly=true;}var hint=document.getElementById("contextLockHint");if(!hint){return;}if(joinInFlight){hint.textContent="Låser kontekst…";return;}if(locked){hint.textContent="Spillerkontekst er låst efter join.";return;}hint.textContent="Kontekst er ikke låst endnu.";}
|
||||
function canAttemptJoin(){return !!(code()&&document.getElementById("nickname").value.trim());}
|
||||
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
|
||||
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
|
||||
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
|
||||
function markPlayerSessionRefresh(status){if(status>=200&&status<300){playerLastRefreshAtLabel=formatTimeLabel(new Date());playerLastRefreshFailed=false;}else{playerLastRefreshFailed=true;}updatePlayerLastRefreshStatus();}
|
||||
function updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
|
||||
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";}
|
||||
function stopPlayerAutoRefresh(reason){playerAutoRefreshEnabled=false;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);playerAutoRefreshTimer=null;}if(reason){var hint=document.getElementById("playerAutoRefreshHint");if(hint){hint.textContent=reason;}}updatePlayerAutoRefreshUi();savePlayerContext();}
|
||||
function startPlayerAutoRefresh(){if(!code()){updatePlayerAutoRefreshUi();return;}playerAutoRefreshEnabled=true;if(playerAutoRefreshTimer){clearInterval(playerAutoRefreshTimer);}playerAutoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail().catch(function(){});},10000);updatePlayerAutoRefreshUi();savePlayerContext();}
|
||||
function togglePlayerAutoRefresh(){if(joinInFlight){updatePlayerAutoRefreshUi();return;}if(playerAutoRefreshEnabled){stopPlayerAutoRefresh();return;}startPlayerAutoRefresh();}
|
||||
function updateConnectionBanner(){var banner=document.getElementById("connectionBanner");var retryBtn=document.getElementById("connectionRetryBtn");if(!banner||!retryBtn){return;}banner.style.display=connectionLost?"block":"none";retryBtn.disabled=connectionRetryInFlight||sessionDetailInFlight||joinInFlight||!code();}
|
||||
function setConnectionLost(isLost){connectionLost=!!isLost;updateConnectionBanner();}
|
||||
function updatePlayerErrorHint(status,data){var el=document.getElementById("playerErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";setConnectionLost(false);return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
|
||||
function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionRefreshHint");var submitInFlight=lieSubmitInFlight||guessSubmitInFlight;if(btn){btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code();}if(!hint){updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(joinInFlight){hint.textContent="Session-opdatering er låst mens join kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(submitInFlight){hint.textContent="Session-opdatering er låst mens submit kører.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updatePlayerAutoRefreshUi();updateConnectionBanner();return;}hint.textContent="Session-opdatering klar.";updatePlayerAutoRefreshUi();updateConnectionBanner();}
|
||||
function updateJoinState(){var btn=document.getElementById("joinBtn");var status=document.getElementById("joinStatus");var joined=!!(pid()&&document.getElementById("sessionToken").value.trim());var canJoin=canAttemptJoin();if(btn){btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin;}if(!status){updateContextLockState();updateConnectionBanner();return;}if(joinInFlight){status.textContent="Joiner…";updateContextLockState();updateConnectionBanner();return;}if(sessionDetailInFlight&&!joined){status.textContent="Afvent aktiv session-opdatering før join.";updateContextLockState();updateConnectionBanner();return;}if(joined){status.textContent="Join gennemført.";updateContextLockState();updateConnectionBanner();return;}if(!canJoin){status.textContent="Udfyld kode og nickname for at join.";updateContextLockState();updateConnectionBanner();return;}status.textContent="Klar til join.";updateContextLockState();updateConnectionBanner();}
|
||||
|
||||
function guessStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppGuess",c,p,q].join(":");}
|
||||
function lieStorageKey(){var c=code();var p=pid();var q=rq();if(!c||!p||!q){return "";}return ["wppLie",c,p,q].join(":");}
|
||||
function persistLieState(text,submitted){var key=lieStorageKey();if(!key){return;}try{localStorage.setItem(key,JSON.stringify({text:text||"",submitted:!!submitted}));}catch(_e){}}
|
||||
function loadLieState(){var key=lieStorageKey();if(!key){return null;}try{var raw=localStorage.getItem(key);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
|
||||
function updateLieSubmitState(){var text=(document.getElementById("lieText").value||"").trim();var btn=document.getElementById("lieSubmitBtn");var input=document.getElementById("lieText");var status=document.getElementById("lieStatus");var hasContext=hasSubmitContext();var inLiePhase=currentSessionStatus==="lie";if(input){input.readOnly=lieSubmitted||lieSubmitInFlight||sessionDetailInFlight||!inLiePhase;}if(btn){btn.disabled=lieSubmitted||lieSubmitInFlight||sessionDetailInFlight||!text||!hasContext||!inLiePhase;}if(status){if(lieSubmitted){status.textContent="Løgn sendt – input er låst.";status.classList.add("locked");}else{status.classList.remove("locked");if(lieSubmitInFlight){status.textContent="Sender løgn…";}else if(sessionDetailInFlight){status.textContent="Afvent aktiv session-opdatering før løgn-submit.";}else if(!hasContext){status.textContent="Join først for at aktivere submit.";}else if(!currentSessionStatus){status.textContent="Opdatér session-status for at validere løgn-fase.";}else if(!inLiePhase){status.textContent="Løgn-input er låst i fase: "+phaseLabel(currentSessionStatus)+".";}else{status.textContent=text?"Løgn klar til afsendelse.":"Skriv din løgn.";}}}}
|
||||
function setLieState(text,submitted){document.getElementById("lieText").value=text||"";if(typeof submitted==="boolean"){lieSubmitted=submitted;}updateLieSubmitState();}
|
||||
function persistGuessState(text,submitted){var key=guessStorageKey();if(!key){return;}try{localStorage.setItem(key,JSON.stringify({selected_text:text||"",submitted:!!submitted}));}catch(_e){}}
|
||||
function loadGuessState(){var key=guessStorageKey();if(!key){return null;}try{var raw=localStorage.getItem(key);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
|
||||
function updateGuessStatus(){var el=document.getElementById("guessStatus");if(!el){return;}var selected=document.getElementById("guessText").value;var hasContext=hasSubmitContext();var hasRoundContext=hasRoundQuestionContext();var inGuessPhase=currentSessionStatus==="guess";if(guessSubmitted){el.textContent="Gæt sendt – valg er låst.";el.classList.add("locked");return;}el.classList.remove("locked");if(guessSubmitInFlight){el.textContent="Sender gæt…";return;}if(sessionDetailInFlight){el.textContent="Afvent aktiv session-opdatering før gæt-submit.";return;}if(!code()||!pid()||!document.getElementById("sessionToken").value.trim()){el.textContent="Join først for at aktivere gæt.";return;}if(!hasRoundContext){el.textContent="Afvent aktivt spørgsmål fra host før du kan gætte.";return;}if(!hasContext){el.textContent="Join først for at aktivere gæt.";return;}if(!currentSessionStatus){el.textContent="Opdatér session-status for at validere gæt-fase.";return;}if(!inGuessPhase){el.textContent="Gæt er låst i fase: "+phaseLabel(currentSessionStatus)+".";return;}el.textContent=selected?"Valgt svar klar til afsendelse.":"Vælg et svar.";}
|
||||
function updateGuessSubmitState(){var selected=document.getElementById("guessText").value;var hasValid=availableAnswers.indexOf(selected)!==-1;var hasContext=hasSubmitContext();var hasRoundContext=hasRoundQuestionContext();var inGuessPhase=currentSessionStatus==="guess";document.getElementById("guessSubmitBtn").disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase;var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight||!hasContext||!hasRoundContext||!inGuessPhase;});updateGuessStatus();}
|
||||
function setGuess(text,submitted){document.getElementById("guessText").value=text||"";if(typeof submitted==="boolean"){guessSubmitted=submitted;}var buttons=document.querySelectorAll("#answerOptions button");buttons.forEach(function(btn){btn.classList.toggle("active",btn.dataset.answer===text);});updateGuessSubmitState();
|
||||
updateJoinState();}
|
||||
function renderAnswerOptions(roundQuestion){var wrap=document.getElementById("answerOptions");wrap.innerHTML="";availableAnswers=[];guessSubmitted=false;setGuess("",false);lieSubmitted=false;setLieState("",false);if(!roundQuestion||!Array.isArray(roundQuestion.answers)){updateGuessSubmitState();updateLieSubmitState();return;}roundQuestion.answers.forEach(function(item){if(!item||!item.text){return;}availableAnswers.push(item.text);var btn=document.createElement("button");btn.type="button";btn.dataset.answer=item.text;btn.textContent=item.text;btn.onclick=function(){if(guessSubmitted){return;}setGuess(item.text,false);persistGuessState(item.text,false);};wrap.appendChild(btn);});var saved=loadGuessState();if(saved&&availableAnswers.indexOf(saved.selected_text)!==-1){setGuess(saved.selected_text,!!saved.submitted);}updateGuessSubmitState();}
|
||||
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.body=JSON.stringify(payload);}try{var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markPlayerSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.player&&d.player.id){document.getElementById("playerId").value=d.player.id;}if(d.player&&d.player.session_token){document.getElementById("sessionToken").value=d.player.session_token;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question){renderAnswerOptions(d.round_question);var savedLie=loadLieState();if(savedLie){setLieState(savedLie.text||"",!!savedLie.submitted);}}else{document.getElementById("roundQuestionId").value="";renderAnswerOptions(null);}if(d.guess&&d.guess.round_question_id){document.getElementById("roundQuestionId").value=d.guess.round_question_id;setGuess(d.guess.selected_text||"",true);persistGuessState(d.guess.selected_text||"",true);}updateRoundContextHint();updatePlayerErrorHint(r.status,d);updatePhaseStatus();updateLieSubmitState();updateGuessSubmitState();if(currentSessionStatus==="finished"&&playerAutoRefreshEnabled){stopPlayerAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updatePlayerAutoRefreshUi();}savePlayerContext();return d;}catch(err){setConnectionLost(true);if((method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path)){markPlayerSessionRefresh(0);}document.getElementById("out").textContent=JSON.stringify({status:0,data:{error:"connection_lost",detail:"Kunne ikke kontakte serveren."}},null,2);document.getElementById("playerErrorHint").textContent="Fejl: Mistede forbindelsen til serveren. Prøv igen.";updateSessionDetailState();throw err;}}
|
||||
function joinSession(){if(joinInFlight){return Promise.resolve({error:"join_in_flight"});}if(!canAttemptJoin()){updateJoinState();return Promise.resolve({error:"missing_join_input"});}if(pid()&&document.getElementById("sessionToken").value.trim()){updateJoinState();return Promise.resolve({error:"already_joined_client"});}joinInFlight=true;updateJoinState();updatePlayerAutoRefreshUi();return api("/lobby/sessions/join","POST",{code:code(),nickname:document.getElementById("nickname").value.trim()}).then(function(d){joinInFlight=false;if(d&&d.player&&d.player.id){updateJoinState();updatePlayerAutoRefreshUi();return d;}updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede – prøv igen.";return d;}).catch(function(err){joinInFlight=false;updateJoinState();updatePlayerAutoRefreshUi();document.getElementById("joinStatus").textContent="Join fejlede – prøv igen.";throw err;});}
|
||||
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
|
||||
function retryConnection(){if(connectionRetryInFlight||!code()){updateConnectionBanner();return Promise.resolve({error:"retry_unavailable"});}connectionRetryInFlight=true;updateConnectionBanner();return sessionDetail().then(function(result){setConnectionLost(false);return result;}).finally(function(){connectionRetryInFlight=false;updateConnectionBanner();});}
|
||||
function submitLie(){if(lieSubmitted){return Promise.resolve({error:"lie_already_submitted_client"});}if(lieSubmitInFlight){return Promise.resolve({error:"lie_submit_in_flight"});}if(!hasSubmitContext()){updateLieSubmitState();return Promise.resolve({error:"missing_submit_context"});}var text=(document.getElementById("lieText").value||"").trim();if(!text){updateLieSubmitState();return Promise.resolve({error:"empty_lie_text"});}lieSubmitInFlight=true;updateLieSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/lies/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,text:text}).then(function(d){if(d&&d.lie&&d.lie.id){lieSubmitted=true;persistLieState(text,true);}return d;}).finally(function(){lieSubmitInFlight=false;updateLieSubmitState();});}
|
||||
document.getElementById("lieText").addEventListener("input",function(){if(!lieSubmitted){updateLieSubmitState();persistLieState(document.getElementById("lieText").value,false);}});updateLieSubmitState();
|
||||
function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_already_submitted_client"});}if(guessSubmitInFlight){return Promise.resolve({error:"guess_submit_in_flight"});}if(!hasSubmitContext()){updateGuessSubmitState();document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Join først for at aktivere gæt"}},null,2);return Promise.resolve({error:"missing_submit_context"});}var selected=document.getElementById("guessText").value;if(availableAnswers.indexOf(selected)===-1){document.getElementById("out").textContent=JSON.stringify({status:400,data:{error:"Vælg et af de viste svarmuligheder"}},null,2);return Promise.resolve({error:"invalid_client_guess"});}guessSubmitInFlight=true;updateGuessSubmitState();return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/guesses/submit","POST",{player_id:parseInt(pid(),10),session_token:document.getElementById("sessionToken").value,selected_text:selected}).finally(function(){guessSubmitInFlight=false;updateGuessSubmitState();});}
|
||||
["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});});
|
||||
updatePhaseStatus();
|
||||
updateGuessSubmitState();
|
||||
updateJoinState();
|
||||
updatePlayerAutoRefreshUi();
|
||||
updatePlayerLastRefreshStatus();
|
||||
updateRoundContextHint();
|
||||
updateConnectionBanner();
|
||||
if(restorePlayerContext()){if(playerAutoRefreshEnabled){startPlayerAutoRefresh();}sessionDetail().catch(function(){});}else{savePlayerContext();}
|
||||
</script>
|
||||
</body></html>
|
||||
863
lobby/tests.py
863
lobby/tests.py
@@ -1,24 +1,8 @@
|
||||
import json
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from fupogfakta.models import (
|
||||
Category,
|
||||
GameSession,
|
||||
Guess,
|
||||
LieAnswer,
|
||||
Player,
|
||||
Question,
|
||||
RoundConfig,
|
||||
RoundQuestion,
|
||||
)
|
||||
from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -59,32 +43,8 @@ class LobbyFlowTests(TestCase):
|
||||
body = response.json()
|
||||
self.assertEqual(body["session"]["code"], "ABCD23")
|
||||
self.assertEqual(body["player"]["nickname"], "Luna")
|
||||
self.assertIn("session_token", body["player"])
|
||||
self.assertTrue(body["player"]["session_token"])
|
||||
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
||||
|
||||
def test_player_can_join_with_trimmed_code(self):
|
||||
session = GameSession.objects.create(host=self.host, code="ABCD23")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": " abcd23 ", "nickname": "Luna"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(Player.objects.filter(session=session, nickname="Luna").exists())
|
||||
|
||||
def test_join_rejects_code_empty_after_trim(self):
|
||||
response = self.client.post(
|
||||
reverse("lobby:join_session"),
|
||||
data={"code": " ", "nickname": "Luna"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Session code is required")
|
||||
|
||||
def test_join_rejects_duplicate_nickname_case_insensitive(self):
|
||||
session = GameSession.objects.create(host=self.host, code="QWER12")
|
||||
Player.objects.create(session=session, nickname="Luna")
|
||||
@@ -156,21 +116,6 @@ class StartRoundTests(TestCase):
|
||||
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
||||
self.assertEqual(round_config.category, self.category)
|
||||
|
||||
def test_host_start_round_uses_normalized_session_code_from_path(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse("lobby:start_round", kwargs={"code": " abcd23 "}),
|
||||
data={"category_slug": self.category.slug},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LIE)
|
||||
round_config = RoundConfig.objects.get(session=self.session, number=1)
|
||||
self.assertEqual(round_config.category, self.category)
|
||||
|
||||
def test_start_round_requires_host(self):
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
@@ -215,809 +160,3 @@ class StartRoundTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Round can only be started from lobby")
|
||||
|
||||
|
||||
class LieSubmissionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
|
||||
self.category = Category.objects.create(name="Geografi", slug="geografi", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvad er hovedstaden i Australien?",
|
||||
correct_answer="Canberra",
|
||||
is_active=True,
|
||||
)
|
||||
RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=1,
|
||||
category=self.category,
|
||||
lie_seconds=45,
|
||||
)
|
||||
self.player = Player.objects.create(session=self.session, nickname="Luna")
|
||||
|
||||
def test_host_can_show_question_and_get_lie_deadline(self):
|
||||
self.client.login(username="host", password="secret123")
|
||||
|
||||
response = self.client.post(reverse("lobby:show_question", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["config"]["lie_seconds"], 45)
|
||||
self.assertIn("lie_deadline_at", payload["round_question"])
|
||||
self.assertTrue(RoundQuestion.objects.filter(session=self.session, round_number=1).exists())
|
||||
|
||||
def test_player_can_submit_lie_before_deadline(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(LieAnswer.objects.filter(round_question=round_question, player=self.player).exists())
|
||||
|
||||
def test_submit_lie_rejects_after_time_window(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
round_question.shown_at = timezone.now() - timedelta(seconds=46)
|
||||
round_question.save(update_fields=["shown_at"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Melbourne"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Lie submission window has closed")
|
||||
|
||||
def test_submit_lie_rejects_duplicate_submission(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
LieAnswer.objects.create(round_question=round_question, player=self.player, text="Perth")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "text": "Brisbane"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
|
||||
|
||||
def test_submit_lie_requires_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
|
||||
def test_submit_lie_rejects_invalid_session_token(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_lie",
|
||||
kwargs={"code": self.session.code, "round_question_id": round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": "invalid-token", "text": "Sydney"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
|
||||
class MixAnswersTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCD23", status=GameSession.Status.LIE)
|
||||
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilken by er Danmarks hovedstad?",
|
||||
correct_answer="København",
|
||||
is_active=True,
|
||||
)
|
||||
RoundConfig.objects.create(session=self.session, number=1, category=self.category)
|
||||
self.round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer="København",
|
||||
)
|
||||
self.player_one = Player.objects.create(session=self.session, nickname="Luna")
|
||||
self.player_two = Player.objects.create(session=self.session, nickname="Mads")
|
||||
|
||||
def test_host_can_mix_answers_and_transition_to_guess(self):
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:mix_answers",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
answer_texts = [entry["text"] for entry in payload["answers"]]
|
||||
self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"})
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS)
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.round_question.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.GUESS)
|
||||
self.assertEqual(self.round_question.mixed_answers, answer_texts)
|
||||
|
||||
def test_mix_answers_requires_host(self):
|
||||
self.client.login(username="other", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:mix_answers",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can mix answers")
|
||||
|
||||
def test_mix_answers_deduplicates_case_insensitive_lies(self):
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="københavn")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Aarhus")
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:mix_answers",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
answer_texts = [entry["text"] for entry in response.json()["answers"]]
|
||||
self.assertEqual(set(answer_texts), {"København", "Aarhus"})
|
||||
|
||||
def test_mix_answers_is_idempotent_after_transition_to_guess(self):
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
first = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
||||
second = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual([entry["text"] for entry in first.json()["answers"]], [entry["text"] for entry in second.json()["answers"]])
|
||||
|
||||
def test_session_detail_returns_persisted_mixed_answers_for_reconnect(self):
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_one, text="Aarhus")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.player_two, text="Odense")
|
||||
|
||||
self.client.login(username="host", password="secret123")
|
||||
mix_response = self.client.post(reverse("lobby:mix_answers", kwargs={"code": self.session.code, "round_question_id": self.round_question.id}))
|
||||
detail_response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(mix_response.status_code, 200)
|
||||
self.assertEqual(detail_response.status_code, 200)
|
||||
self.assertEqual([entry["text"] for entry in mix_response.json()["answers"]], [entry["text"] for entry in detail_response.json()["round_question"]["answers"]])
|
||||
|
||||
|
||||
class GuessSubmissionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_guess", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="GU3551", status=GameSession.Status.GUESS)
|
||||
self.category = Category.objects.create(name="Videnskab", slug="videnskab", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilken planet kaldes den røde planet?",
|
||||
correct_answer="Mars",
|
||||
is_active=True,
|
||||
)
|
||||
self.round_config = RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=1,
|
||||
category=self.category,
|
||||
lie_seconds=45,
|
||||
guess_seconds=30,
|
||||
)
|
||||
self.round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer="Mars",
|
||||
)
|
||||
self.player = Player.objects.create(session=self.session, nickname="Luna")
|
||||
self.liar = Player.objects.create(session=self.session, nickname="Mads")
|
||||
LieAnswer.objects.create(round_question=self.round_question, player=self.liar, text="Jupiter")
|
||||
|
||||
def test_player_can_submit_guess_in_guess_phase(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
payload = response.json()
|
||||
self.assertTrue(payload["guess"]["is_correct"])
|
||||
self.assertIsNone(payload["guess"]["fooled_player_id"])
|
||||
self.assertIn("guess_deadline_at", payload["window"])
|
||||
|
||||
def test_submit_guess_rejects_when_not_in_guess_phase(self):
|
||||
self.session.status = GameSession.Status.LIE
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase")
|
||||
|
||||
def test_submit_guess_rejects_unknown_answer(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Venus"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Selected answer is not part of this round")
|
||||
|
||||
def test_submit_guess_rejects_duplicate_submission(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Jupiter"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"], "Guess already submitted for this player")
|
||||
|
||||
def test_submit_guess_rejects_after_deadline(self):
|
||||
self.round_question.shown_at = timezone.now() - timedelta(seconds=76)
|
||||
self.round_question.save(update_fields=["shown_at"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Guess submission window has closed")
|
||||
|
||||
|
||||
|
||||
def test_submit_guess_requires_session_token(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "session_token is required")
|
||||
|
||||
def test_submit_guess_rejects_invalid_session_token(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:submit_guess",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
),
|
||||
data={"player_id": self.player.id, "session_token": "wrong-token", "selected_text": "Mars"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Invalid player session token")
|
||||
|
||||
|
||||
class ScoreCalculationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_score", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other_score", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="SC0RE1", status=GameSession.Status.GUESS)
|
||||
self.category = Category.objects.create(name="Sport", slug="sport", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvilken sport spiller man i Wimbledon?",
|
||||
correct_answer="Tennis",
|
||||
is_active=True,
|
||||
)
|
||||
RoundConfig.objects.create(
|
||||
session=self.session,
|
||||
number=1,
|
||||
category=self.category,
|
||||
points_correct=5,
|
||||
points_bluff=2,
|
||||
)
|
||||
self.round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer="Tennis",
|
||||
)
|
||||
self.player_one = Player.objects.create(session=self.session, nickname="Luna")
|
||||
self.player_two = Player.objects.create(session=self.session, nickname="Mads")
|
||||
self.player_three = Player.objects.create(session=self.session, nickname="Nora")
|
||||
|
||||
def test_host_can_calculate_scores_and_transition_to_reveal(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
|
||||
Guess.objects.create(
|
||||
round_question=self.round_question,
|
||||
player=self.player_two,
|
||||
selected_text="Padel",
|
||||
is_correct=False,
|
||||
fooled_player=self.player_three,
|
||||
)
|
||||
Guess.objects.create(
|
||||
round_question=self.round_question,
|
||||
player=self.player_three,
|
||||
selected_text="Padel",
|
||||
is_correct=False,
|
||||
fooled_player=self.player_three,
|
||||
)
|
||||
|
||||
self.client.login(username="host_score", password="secret123")
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:calculate_scores",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL)
|
||||
self.assertEqual(payload["events_created"], 2)
|
||||
|
||||
self.player_one.refresh_from_db()
|
||||
self.player_three.refresh_from_db()
|
||||
self.session.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.player_one.score, 5)
|
||||
self.assertEqual(self.player_three.score, 4)
|
||||
self.assertEqual(self.session.status, GameSession.Status.REVEAL)
|
||||
|
||||
def test_calculate_scores_requires_host(self):
|
||||
self.client.login(username="other_score", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:calculate_scores",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can calculate scores")
|
||||
|
||||
def test_calculate_scores_rejects_duplicate_calculation(self):
|
||||
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
|
||||
|
||||
self.client.login(username="host_score", password="secret123")
|
||||
first = self.client.post(
|
||||
reverse(
|
||||
"lobby:calculate_scores",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
second = self.client.post(
|
||||
reverse(
|
||||
"lobby:calculate_scores",
|
||||
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 409)
|
||||
self.assertEqual(second.json()["error"], "Scores already calculated for this round question")
|
||||
|
||||
|
||||
class RevealRoundFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_reveal", password="secret123")
|
||||
self.other_user = User.objects.create_user(username="other_reveal", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="RVL123", status=GameSession.Status.REVEAL)
|
||||
self.player_one = Player.objects.create(session=self.session, nickname="Luna", score=9)
|
||||
self.player_two = Player.objects.create(session=self.session, nickname="Mads", score=3)
|
||||
|
||||
def test_host_can_get_reveal_scoreboard(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"lobby:reveal_scoreboard",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL)
|
||||
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
||||
|
||||
def test_reveal_scoreboard_requires_host(self):
|
||||
self.client.login(username="other_reveal", password="secret123")
|
||||
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"lobby:reveal_scoreboard",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
|
||||
|
||||
def test_host_can_finish_game_from_reveal(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:finish_game",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.FINISHED)
|
||||
self.assertEqual(payload["winner"]["nickname"], "Luna")
|
||||
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
||||
|
||||
def test_finish_game_requires_host(self):
|
||||
self.client.login(username="other_reveal", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:finish_game",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"], "Only host can finish game")
|
||||
|
||||
def test_finish_game_rejects_wrong_phase(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:finish_game",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Game can only be finished from reveal phase")
|
||||
|
||||
def test_host_can_start_next_round_from_reveal(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:start_next_round",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["session"]["status"], GameSession.Status.LOBBY)
|
||||
self.assertEqual(payload["session"]["current_round"], 2)
|
||||
|
||||
self.session.refresh_from_db()
|
||||
self.assertEqual(self.session.status, GameSession.Status.LOBBY)
|
||||
self.assertEqual(self.session.current_round, 2)
|
||||
|
||||
def test_start_next_round_rejects_wrong_phase(self):
|
||||
self.client.login(username="host_reveal", password="secret123")
|
||||
self.session.status = GameSession.Status.GUESS
|
||||
self.session.save(update_fields=["status"])
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"lobby:start_next_round",
|
||||
kwargs={"code": self.session.code},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json()["error"], "Next round can only start from reveal phase")
|
||||
|
||||
class UiScreenTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_ui", password="secret123")
|
||||
|
||||
def test_host_screen_requires_login(self):
|
||||
response = self.client.get(reverse("lobby:host_screen"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_host_screen_renders_for_logged_in_user(self):
|
||||
self.client.login(username="host_ui", password="secret123")
|
||||
response = self.client.get(reverse("lobby:host_screen"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Host panel")
|
||||
self.assertContains(response, "id=\"createSessionBtn\"")
|
||||
self.assertContains(response, "id=\"createSessionHint\"")
|
||||
self.assertContains(response, "saveHostContext")
|
||||
self.assertContains(response, "restoreHostContext")
|
||||
self.assertContains(response, "id=\"showQuestionBtn\"")
|
||||
self.assertContains(response, "id=\"mixAnswersBtn\"")
|
||||
self.assertContains(response, "id=\"hostActionHint\"")
|
||||
self.assertContains(response, "id=\"categoryGuardHint\"")
|
||||
|
||||
self.assertContains(response, "id=\"phaseStatus\"")
|
||||
self.assertContains(response, "updateHostActionState")
|
||||
self.assertContains(response, "phaseLabel")
|
||||
self.assertContains(response, "Opdatér session-status for fasebaserede host-actions.")
|
||||
self.assertContains(response, "Angiv sessionkode for at aktivere host-actions.")
|
||||
self.assertContains(response, "Kategori er kun redigérbar i lobby-fasen.")
|
||||
self.assertContains(response, "Kræver 3-5 spillere i lobbyen.")
|
||||
self.assertContains(response, "For mange spillere: maks 5 i MVP før runde-start.")
|
||||
self.assertContains(response, "Round question-id kan kun redigeres i lie/guess/reveal-faser.")
|
||||
self.assertContains(response, "roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion")
|
||||
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==\"lobby\"")
|
||||
self.assertContains(response, "hostActionInFlight")
|
||||
self.assertContains(response, "withHostActionLock")
|
||||
self.assertContains(response, "updateCreateSessionState")
|
||||
self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight")
|
||||
self.assertContains(response, "Opret session er låst mens en host-handling kører.")
|
||||
self.assertContains(response, "Opret session er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "Handling kører… afvent svar før næste klik.")
|
||||
self.assertContains(response, "Session-data ikke opdateret endnu.")
|
||||
self.assertContains(response, "Sidst opdateret:")
|
||||
self.assertContains(response, "Session-data kan være forældet")
|
||||
self.assertContains(response, "id=\"sessionDetailBtn\"")
|
||||
self.assertContains(response, "id=\"sessionDetailHint\"")
|
||||
self.assertContains(response, "updateSessionDetailState")
|
||||
self.assertContains(response, "sessionDetailInFlight")
|
||||
self.assertContains(response, "session_detail_in_flight")
|
||||
self.assertContains(response, "codeInput.readOnly=sessionDetailInFlight||hostActionInFlight")
|
||||
self.assertContains(response, "id=\"autoRefreshToggleBtn\"")
|
||||
self.assertContains(response, "id=\"autoRefreshHint\"")
|
||||
self.assertContains(response, "btn.disabled=hostActionInFlight||sessionDetailInFlight||!code()")
|
||||
self.assertContains(response, "Auto-refresh-lås: afvent aktiv host-handling.")
|
||||
self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.")
|
||||
self.assertContains(response, "Opdaterer session-status…")
|
||||
self.assertContains(response, "Session-opdatering er låst mens en host-handling kører.")
|
||||
self.assertContains(response, "Angiv sessionkode for at opdatere session-status.")
|
||||
self.assertContains(response, "markSessionRefresh")
|
||||
self.assertContains(response, "updateLastRefreshStatus")
|
||||
self.assertContains(response, "isSessionDetailRead")
|
||||
self.assertContains(response, "showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
|
||||
self.assertContains(response, "Host-actions er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "Round question-id er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "Kategori er låst mens session-opdatering kører.")
|
||||
self.assertContains(response, "categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!==")
|
||||
self.assertContains(response, "hostShellRouteFromPath")
|
||||
self.assertContains(response, "syncHostShellRoute")
|
||||
self.assertContains(response, "Deep-link route guard: omdirigeret")
|
||||
|
||||
def test_host_screen_deeplink_requires_login(self):
|
||||
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_host_screen_deeplink_renders_for_logged_in_user(self):
|
||||
self.client.login(username="host_ui", password="secret123")
|
||||
response = self.client.get(reverse("lobby:host_screen_deeplink", kwargs={"spa_path": "guess"}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Host panel")
|
||||
|
||||
def test_player_screen_is_public(self):
|
||||
response = self.client.get(reverse("lobby:player_screen"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Player panel")
|
||||
self.assertContains(response, "id=\"sessionToken\"")
|
||||
self.assertContains(response, "session_token")
|
||||
self.assertContains(response, "id=\"answerOptions\"")
|
||||
self.assertContains(response, "renderAnswerOptions")
|
||||
self.assertContains(response, "renderAnswerOptions(null)")
|
||||
self.assertContains(response, "availableAnswers")
|
||||
self.assertContains(response, "guessStorageKey")
|
||||
self.assertContains(response, "persistGuessState")
|
||||
self.assertContains(response, "savePlayerContext")
|
||||
self.assertContains(response, "restorePlayerContext")
|
||||
self.assertContains(response, "id=\"lieSubmitBtn\"")
|
||||
self.assertContains(response, "id=\"lieStatus\"")
|
||||
self.assertContains(response, "id=\"phaseStatus\"")
|
||||
self.assertContains(response, "currentSessionStatus")
|
||||
self.assertContains(response, "updatePhaseStatus")
|
||||
self.assertContains(response, "Løgn-input er låst i fase")
|
||||
self.assertContains(response, "Gæt er låst i fase")
|
||||
self.assertContains(response, "Afvent aktivt spørgsmål fra host før du kan gætte.")
|
||||
self.assertContains(response, "persistLieState")
|
||||
self.assertContains(response, "updateLieSubmitState")
|
||||
self.assertContains(response, "hasSubmitContext")
|
||||
self.assertContains(response, "hasRoundQuestionContext")
|
||||
self.assertContains(response, "canAttemptJoin")
|
||||
self.assertContains(response, "missing_join_input")
|
||||
self.assertContains(response, "Udfyld kode og nickname for at join.")
|
||||
self.assertContains(response, "Afvent aktiv session-opdatering før join.")
|
||||
self.assertContains(response, "btn.disabled=joinInFlight||sessionDetailInFlight||joined||!canJoin")
|
||||
self.assertContains(response, "id=\"connectionBanner\"")
|
||||
self.assertContains(response, "id=\"connectionRetryBtn\"")
|
||||
self.assertContains(response, "retryConnection")
|
||||
self.assertContains(response, "setConnectionLost")
|
||||
self.assertContains(response, "connection_lost")
|
||||
self.assertContains(response, "id=\"contextLockHint\"")
|
||||
self.assertContains(response, "updateContextLockState")
|
||||
self.assertContains(response, "Spillerkontekst er låst efter join.")
|
||||
self.assertContains(response, "already_joined_client")
|
||||
self.assertContains(response, "missing_submit_context")
|
||||
self.assertContains(response, "invalid_client_guess")
|
||||
self.assertContains(response, "lieSubmitInFlight")
|
||||
self.assertContains(response, "guessSubmitInFlight")
|
||||
self.assertContains(response, "Sender løgn…")
|
||||
self.assertContains(response, "Sender gæt…")
|
||||
self.assertContains(response, "Afvent aktiv session-opdatering før løgn-submit.")
|
||||
self.assertContains(response, "Afvent aktiv session-opdatering før gæt-submit.")
|
||||
self.assertContains(response, "sessionDetailInFlight||!hasValid||!hasContext||!hasRoundContext||!inGuessPhase")
|
||||
self.assertContains(response, "btn.disabled=guessSubmitted||guessSubmitInFlight||sessionDetailInFlight")
|
||||
self.assertContains(response, "lie_submit_in_flight")
|
||||
self.assertContains(response, "guess_submit_in_flight")
|
||||
self.assertContains(response, "guess_already_submitted_client")
|
||||
self.assertContains(response, "id=\"playerAutoRefreshToggleBtn\"")
|
||||
self.assertContains(response, "id=\"playerAutoRefreshHint\"")
|
||||
self.assertContains(response, "id=\"playerLastRefreshStatus\"")
|
||||
self.assertContains(response, "id=\"sessionDetailBtn\"")
|
||||
self.assertContains(response, "id=\"sessionRefreshHint\"")
|
||||
self.assertContains(response, "id=\"roundContextHint\"")
|
||||
self.assertContains(response, "resetRoundContextForManualChange")
|
||||
self.assertContains(response, "Runde-kontekst afventer session-opdatering.")
|
||||
self.assertContains(response, "togglePlayerAutoRefresh")
|
||||
self.assertContains(response, "btn.disabled=sessionDetailInFlight||joinInFlight||submitInFlight||!code()")
|
||||
self.assertContains(response, "Auto-refresh-lås: afvent aktiv session-opdatering.")
|
||||
self.assertContains(response, "Auto-refresh-lås: afvent aktiv join.")
|
||||
self.assertContains(response, "Auto-refresh kræver sessionkode.")
|
||||
self.assertContains(response, "markPlayerSessionRefresh")
|
||||
self.assertContains(response, "updatePlayerLastRefreshStatus")
|
||||
self.assertContains(response, "updateSessionDetailState")
|
||||
self.assertContains(response, "session_detail_in_flight")
|
||||
self.assertContains(response, "Opdaterer session-status…")
|
||||
self.assertContains(response, "Session-opdatering er låst mens join kører.")
|
||||
self.assertContains(response, "Session-opdatering er låst mens submit kører.")
|
||||
self.assertContains(response, "Session-data ikke opdateret endnu.")
|
||||
self.assertContains(response, "Sidst opdateret:")
|
||||
self.assertContains(response, "Session-data kan være forældet")
|
||||
|
||||
|
||||
class SessionDetailRoundQuestionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.host = User.objects.create_user(username="host_detail", password="secret123")
|
||||
self.session = GameSession.objects.create(host=self.host, code="ABCDE1", status=GameSession.Status.LIE)
|
||||
self.category = Category.objects.create(name="Historie", slug="historie-2", is_active=True)
|
||||
self.question = Question.objects.create(
|
||||
category=self.category,
|
||||
prompt="Hvem opfandt pæren?",
|
||||
correct_answer="Edison",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_session_detail_includes_current_round_question_when_available(self):
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=self.session,
|
||||
round_number=1,
|
||||
question=self.question,
|
||||
correct_answer=self.question.correct_answer,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload["round_question"]["id"], round_question.id)
|
||||
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
|
||||
|
||||
|
||||
class SmokeStagingCommandTests(TestCase):
|
||||
def test_smoke_staging_command_runs_full_flow(self):
|
||||
call_command("smoke_staging")
|
||||
|
||||
session = GameSession.objects.latest("created_at")
|
||||
self.assertEqual(session.status, GameSession.Status.FINISHED)
|
||||
self.assertEqual(Player.objects.filter(session=session).count(), 3)
|
||||
|
||||
def test_smoke_staging_writes_artifact_when_requested(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
artifact_path = Path(tmp_dir) / "smoke.json"
|
||||
call_command("smoke_staging", artifact=str(artifact_path))
|
||||
|
||||
self.assertTrue(artifact_path.exists())
|
||||
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual(payload["command"], "smoke_staging")
|
||||
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
|
||||
self.assertIn("generated_at", payload)
|
||||
self.assertIn("session_code", payload)
|
||||
self.assertEqual(
|
||||
payload["steps"],
|
||||
[
|
||||
"create_session",
|
||||
"join_players",
|
||||
"start_round",
|
||||
"show_question",
|
||||
"submit_lies",
|
||||
"mix_answers",
|
||||
"submit_guesses",
|
||||
"calculate_scores",
|
||||
"reveal_scoreboard",
|
||||
"finish_game",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
|
||||
from fupogfakta.models import Category
|
||||
|
||||
|
||||
@login_required
|
||||
def host_screen(request, spa_path=None):
|
||||
categories = Category.objects.filter(is_active=True).order_by("name")
|
||||
return render(request, "lobby/host_screen.html", {"categories": categories})
|
||||
|
||||
|
||||
def player_screen(request):
|
||||
return render(request, "lobby/player_screen.html")
|
||||
@@ -1,39 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import ui_views, views
|
||||
from . import views
|
||||
|
||||
app_name = "lobby"
|
||||
|
||||
urlpatterns = [
|
||||
path("ui/host", ui_views.host_screen, name="host_screen"),
|
||||
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
|
||||
path("ui/player", ui_views.player_screen, name="player_screen"),
|
||||
path("sessions/create", views.create_session, name="create_session"),
|
||||
path("sessions/join", views.join_session, name="join_session"),
|
||||
path("sessions/<str:code>", views.session_detail, name="session_detail"),
|
||||
path("sessions/<str:code>/rounds/start", views.start_round, name="start_round"),
|
||||
path("sessions/<str:code>/questions/show", views.show_question, name="show_question"),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/lies/submit",
|
||||
views.submit_lie,
|
||||
name="submit_lie",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/answers/mix",
|
||||
views.mix_answers,
|
||||
name="mix_answers",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/guesses/submit",
|
||||
views.submit_guess,
|
||||
name="submit_guess",
|
||||
),
|
||||
path(
|
||||
"sessions/<str:code>/questions/<int:round_question_id>/scores/calculate",
|
||||
views.calculate_scores,
|
||||
name="calculate_scores",
|
||||
),
|
||||
path("sessions/<str:code>/scoreboard", views.reveal_scoreboard, name="reveal_scoreboard"),
|
||||
path("sessions/<str:code>/finish", views.finish_game, name="finish_game"),
|
||||
path("sessions/<str:code>/rounds/next", views.start_next_round, name="start_next_round"),
|
||||
]
|
||||
|
||||
572
lobby/views.py
572
lobby/views.py
@@ -1,24 +1,12 @@
|
||||
import json
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from fupogfakta.models import (
|
||||
Category,
|
||||
GameSession,
|
||||
Guess,
|
||||
LieAnswer,
|
||||
Player,
|
||||
Question,
|
||||
RoundConfig,
|
||||
RoundQuestion,
|
||||
ScoreEvent,
|
||||
)
|
||||
from fupogfakta.models import Category, GameSession, Player, Question, RoundConfig
|
||||
|
||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
SESSION_CODE_LENGTH = 6
|
||||
@@ -45,10 +33,6 @@ def _generate_session_code() -> str:
|
||||
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
|
||||
|
||||
|
||||
def _normalize_session_code(code: str) -> str:
|
||||
return code.strip().upper()
|
||||
|
||||
|
||||
def _create_unique_session_code() -> str:
|
||||
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
|
||||
code = _generate_session_code()
|
||||
@@ -81,7 +65,7 @@ def create_session(request: HttpRequest) -> JsonResponse:
|
||||
def join_session(request: HttpRequest) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
|
||||
code = _normalize_session_code(str(payload.get("code", "")))
|
||||
code = str(payload.get("code", "")).strip().upper()
|
||||
nickname = str(payload.get("nickname", "")).strip()
|
||||
|
||||
if not code:
|
||||
@@ -108,7 +92,6 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
"player": {
|
||||
"id": player.id,
|
||||
"nickname": player.nickname,
|
||||
"session_token": player.session_token,
|
||||
"score": player.score,
|
||||
},
|
||||
"session": {
|
||||
@@ -122,7 +105,7 @@ def join_session(request: HttpRequest) -> JsonResponse:
|
||||
|
||||
@require_GET
|
||||
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
session_code = _normalize_session_code(code)
|
||||
session_code = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -138,23 +121,6 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
)
|
||||
)
|
||||
|
||||
current_round_question = (
|
||||
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
|
||||
.select_related("question")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
round_question_payload = None
|
||||
if current_round_question:
|
||||
round_question_payload = {
|
||||
"id": current_round_question.id,
|
||||
"round_number": current_round_question.round_number,
|
||||
"prompt": current_round_question.question.prompt,
|
||||
"shown_at": current_round_question.shown_at.isoformat(),
|
||||
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
|
||||
}
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
@@ -165,7 +131,6 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
||||
"players_count": len(players),
|
||||
},
|
||||
"players": players,
|
||||
"round_question": round_question_payload,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -179,7 +144,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
if not category_slug:
|
||||
return JsonResponse({"error": "category_slug is required"}, status=400)
|
||||
|
||||
session_code = _normalize_session_code(code)
|
||||
session_code = code.strip().upper()
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
@@ -233,530 +198,3 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
||||
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 show question"}, status=403)
|
||||
|
||||
if session.status != GameSession.Status.LIE:
|
||||
return JsonResponse({"error": "Question can only be shown in lie phase"}, status=400)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return JsonResponse({"error": "Round config missing"}, status=400)
|
||||
|
||||
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
|
||||
return JsonResponse({"error": "Question already shown for this round"}, status=409)
|
||||
|
||||
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():
|
||||
return JsonResponse({"error": "No available questions in category"}, status=400)
|
||||
|
||||
question = random.choice(list(available_questions))
|
||||
round_question = RoundQuestion.objects.create(
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
question=question,
|
||||
correct_answer=question.correct_answer,
|
||||
)
|
||||
|
||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"round_question": {
|
||||
"id": round_question.id,
|
||||
"prompt": question.prompt,
|
||||
"round_number": round_question.round_number,
|
||||
"shown_at": round_question.shown_at.isoformat(),
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
},
|
||||
"config": {
|
||||
"lie_seconds": round_config.lie_seconds,
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
player_id = payload.get("player_id")
|
||||
session_token = str(payload.get("session_token", "")).strip()
|
||||
lie_text = str(payload.get("text", "")).strip()
|
||||
|
||||
if not player_id:
|
||||
return JsonResponse({"error": "player_id is required"}, status=400)
|
||||
|
||||
if not session_token:
|
||||
return JsonResponse({"error": "session_token is required"}, status=400)
|
||||
|
||||
if not lie_text or len(lie_text) > 255:
|
||||
return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.status != GameSession.Status.LIE:
|
||||
return JsonResponse({"error": "Lie submission is only allowed in lie phase"}, status=400)
|
||||
|
||||
try:
|
||||
player = Player.objects.get(pk=player_id, session=session)
|
||||
except Player.DoesNotExist:
|
||||
return JsonResponse({"error": "Player not found in session"}, status=404)
|
||||
|
||||
if player.session_token != session_token:
|
||||
return JsonResponse({"error": "Invalid player session token"}, status=403)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
pk=round_question_id,
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return JsonResponse({"error": "Round question not found"}, status=404)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return JsonResponse({"error": "Round config missing"}, status=400)
|
||||
|
||||
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
|
||||
if timezone.now() > lie_deadline_at:
|
||||
return JsonResponse({"error": "Lie submission window has closed"}, status=400)
|
||||
|
||||
try:
|
||||
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
|
||||
except IntegrityError:
|
||||
return JsonResponse({"error": "Lie already submitted for this player"}, status=409)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"lie": {
|
||||
"id": lie.id,
|
||||
"player_id": player.id,
|
||||
"round_question_id": round_question.id,
|
||||
"text": lie.text,
|
||||
"created_at": lie.created_at.isoformat(),
|
||||
},
|
||||
"window": {
|
||||
"lie_deadline_at": lie_deadline_at.isoformat(),
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
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 mix answers"}, status=403)
|
||||
|
||||
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
|
||||
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
pk=round_question_id,
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return JsonResponse({"error": "Round question not found"}, status=404)
|
||||
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
|
||||
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
|
||||
|
||||
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
|
||||
|
||||
deduped_answers = list(locked_round_question.mixed_answers or [])
|
||||
if not deduped_answers:
|
||||
lie_texts = list(locked_round_question.lies.values_list("text", flat=True))
|
||||
seen = set()
|
||||
for text in [locked_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:
|
||||
return JsonResponse({"error": "Not enough answers to mix"}, status=400)
|
||||
|
||||
random.shuffle(deduped_answers)
|
||||
locked_round_question.mixed_answers = deduped_answers
|
||||
locked_round_question.save(update_fields=["mixed_answers"])
|
||||
|
||||
if locked_session.status == GameSession.Status.LIE:
|
||||
locked_session.status = GameSession.Status.GUESS
|
||||
locked_session.save(update_fields=["status"])
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": GameSession.Status.GUESS,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"round_question": {
|
||||
"id": round_question.id,
|
||||
"round_number": round_question.round_number,
|
||||
},
|
||||
"answers": [{"text": text} for text in deduped_answers],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
payload = _json_body(request)
|
||||
session_code = _normalize_session_code(code)
|
||||
|
||||
player_id = payload.get("player_id")
|
||||
session_token = str(payload.get("session_token", "")).strip()
|
||||
selected_text = str(payload.get("selected_text", "")).strip()
|
||||
|
||||
if not player_id:
|
||||
return JsonResponse({"error": "player_id is required"}, status=400)
|
||||
|
||||
if not session_token:
|
||||
return JsonResponse({"error": "session_token is required"}, status=400)
|
||||
|
||||
if not selected_text or len(selected_text) > 255:
|
||||
return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400)
|
||||
|
||||
try:
|
||||
session = GameSession.objects.get(code=session_code)
|
||||
except GameSession.DoesNotExist:
|
||||
return JsonResponse({"error": "Session not found"}, status=404)
|
||||
|
||||
if session.status != GameSession.Status.GUESS:
|
||||
return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400)
|
||||
|
||||
try:
|
||||
player = Player.objects.get(pk=player_id, session=session)
|
||||
except Player.DoesNotExist:
|
||||
return JsonResponse({"error": "Player not found in session"}, status=404)
|
||||
|
||||
if player.session_token != session_token:
|
||||
return JsonResponse({"error": "Invalid player session token"}, status=403)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
pk=round_question_id,
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return JsonResponse({"error": "Round question not found"}, status=404)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return JsonResponse({"error": "Round config missing"}, status=400)
|
||||
|
||||
guess_deadline_at = round_question.shown_at + timedelta(
|
||||
seconds=round_config.lie_seconds + round_config.guess_seconds
|
||||
)
|
||||
if timezone.now() > guess_deadline_at:
|
||||
return JsonResponse({"error": "Guess submission window has closed"}, status=400)
|
||||
|
||||
allowed_answers = {
|
||||
round_question.correct_answer.strip().casefold(),
|
||||
*(
|
||||
text.strip().casefold()
|
||||
for text in round_question.lies.values_list("text", flat=True)
|
||||
if text.strip()
|
||||
),
|
||||
}
|
||||
|
||||
selected_normalized = selected_text.casefold()
|
||||
if selected_normalized not in allowed_answers:
|
||||
return JsonResponse({"error": "Selected answer is not part of this round"}, status=400)
|
||||
|
||||
correct_normalized = round_question.correct_answer.strip().casefold()
|
||||
fooled_player_id = None
|
||||
if selected_normalized != correct_normalized:
|
||||
fooled_player_id = (
|
||||
round_question.lies.filter(text__iexact=selected_text).values_list("player_id", flat=True).first()
|
||||
)
|
||||
|
||||
try:
|
||||
guess = Guess.objects.create(
|
||||
round_question=round_question,
|
||||
player=player,
|
||||
selected_text=selected_text,
|
||||
is_correct=selected_normalized == correct_normalized,
|
||||
fooled_player_id=fooled_player_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
return JsonResponse({"error": "Guess already submitted for this player"}, status=409)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"guess": {
|
||||
"id": guess.id,
|
||||
"player_id": player.id,
|
||||
"round_question_id": round_question.id,
|
||||
"selected_text": guess.selected_text,
|
||||
"is_correct": guess.is_correct,
|
||||
"fooled_player_id": guess.fooled_player_id,
|
||||
"created_at": guess.created_at.isoformat(),
|
||||
},
|
||||
"window": {
|
||||
"guess_deadline_at": guess_deadline_at.isoformat(),
|
||||
},
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@require_GET
|
||||
@login_required
|
||||
def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
|
||||
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 view scoreboard"}, status=403)
|
||||
|
||||
if session.status != GameSession.Status.REVEAL:
|
||||
return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400)
|
||||
|
||||
leaderboard = list(
|
||||
Player.objects.filter(session=session)
|
||||
.order_by("-score", "nickname")
|
||||
.values("id", "nickname", "score")
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"leaderboard": leaderboard,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
||||
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 next round"}, status=403)
|
||||
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status != GameSession.Status.REVEAL:
|
||||
return JsonResponse({"error": "Next round can only start from reveal phase"}, status=400)
|
||||
|
||||
locked_session.current_round += 1
|
||||
locked_session.status = GameSession.Status.LOBBY
|
||||
locked_session.save(update_fields=["current_round", "status"])
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": GameSession.Status.LOBBY,
|
||||
"current_round": locked_session.current_round,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def finish_game(request: HttpRequest, code: str) -> JsonResponse:
|
||||
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 finish game"}, status=403)
|
||||
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status != GameSession.Status.REVEAL:
|
||||
return JsonResponse({"error": "Game can only be finished from reveal phase"}, status=400)
|
||||
|
||||
locked_session.status = GameSession.Status.FINISHED
|
||||
locked_session.save(update_fields=["status"])
|
||||
|
||||
leaderboard = list(
|
||||
Player.objects.filter(session=session)
|
||||
.order_by("-score", "nickname")
|
||||
.values("id", "nickname", "score")
|
||||
)
|
||||
|
||||
winner = leaderboard[0] if leaderboard else None
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": GameSession.Status.FINISHED,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"winner": winner,
|
||||
"leaderboard": leaderboard,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
|
||||
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 calculate scores"}, status=403)
|
||||
|
||||
already_calculated = ScoreEvent.objects.filter(
|
||||
session=session,
|
||||
meta__round_question_id=round_question_id,
|
||||
).exists()
|
||||
if already_calculated:
|
||||
return JsonResponse({"error": "Scores already calculated for this round question"}, status=409)
|
||||
|
||||
if session.status != GameSession.Status.GUESS:
|
||||
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400)
|
||||
|
||||
try:
|
||||
round_question = RoundQuestion.objects.get(
|
||||
pk=round_question_id,
|
||||
session=session,
|
||||
round_number=session.current_round,
|
||||
)
|
||||
except RoundQuestion.DoesNotExist:
|
||||
return JsonResponse({"error": "Round question not found"}, status=404)
|
||||
|
||||
try:
|
||||
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
|
||||
except RoundConfig.DoesNotExist:
|
||||
return JsonResponse({"error": "Round config missing"}, status=400)
|
||||
|
||||
guesses = list(round_question.guesses.select_related("player"))
|
||||
if not guesses:
|
||||
return JsonResponse({"error": "No guesses submitted for this round question"}, status=400)
|
||||
|
||||
bluff_counts = {}
|
||||
for guess in guesses:
|
||||
if guess.fooled_player_id:
|
||||
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
|
||||
|
||||
with transaction.atomic():
|
||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
||||
if locked_session.status != GameSession.Status.GUESS:
|
||||
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400)
|
||||
|
||||
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)
|
||||
|
||||
locked_session.status = GameSession.Status.REVEAL
|
||||
locked_session.save(update_fields=["status"])
|
||||
|
||||
leaderboard = list(
|
||||
Player.objects.filter(session=session)
|
||||
.order_by("-score", "nickname")
|
||||
.values("id", "nickname", "score")
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"session": {
|
||||
"code": session.code,
|
||||
"status": GameSession.Status.REVEAL,
|
||||
"current_round": session.current_round,
|
||||
},
|
||||
"round_question": {
|
||||
"id": round_question.id,
|
||||
"round_number": round_question.round_number,
|
||||
},
|
||||
"events_created": len(score_events),
|
||||
"leaderboard": leaderboard,
|
||||
}
|
||||
)
|
||||
|
||||
BIN
partyhub/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
partyhub/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
partyhub/__pycache__/settings.cpython-312.pyc
Normal file
BIN
partyhub/__pycache__/settings.cpython-312.pyc
Normal file
Binary file not shown.
BIN
partyhub/__pycache__/urls.cpython-312.pyc
Normal file
BIN
partyhub/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
@@ -11,4 +11,5 @@ urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("healthz", health, name="healthz"),
|
||||
path("lobby/", include("lobby.urls")),
|
||||
path("game/", include("fupogfakta.urls")),
|
||||
]
|
||||
|
||||
BIN
realtime/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
realtime/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
realtime/__pycache__/admin.cpython-312.pyc
Normal file
BIN
realtime/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
realtime/__pycache__/apps.cpython-312.pyc
Normal file
BIN
realtime/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
realtime/__pycache__/models.cpython-312.pyc
Normal file
BIN
realtime/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
realtime/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
realtime/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
voice/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
voice/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
voice/__pycache__/admin.cpython-312.pyc
Normal file
BIN
voice/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
voice/__pycache__/apps.cpython-312.pyc
Normal file
BIN
voice/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
BIN
voice/__pycache__/models.cpython-312.pyc
Normal file
BIN
voice/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
voice/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
voice/migrations/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Reference in New Issue
Block a user