merge(main): resolve PR #320 gameplay conflicts
Some checks failed
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s

This commit is contained in:
2026-03-17 06:44:21 +00:00
13 changed files with 854 additions and 115 deletions

View File

@@ -10,7 +10,7 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
class Command(BaseCommand):
help = "Run minimal staging smoke flow for lobby gameplay"
help = "Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard"
def add_arguments(self, parser):
parser.add_argument(
@@ -18,6 +18,26 @@ class Command(BaseCommand):
help="Optional path to write smoke result artifact as JSON",
)
def _fail(self, step: str, detail: str, payload=None):
message = f"{step} failed: {detail}"
if payload is not None:
message += f" | payload={json.dumps(payload, sort_keys=True)}"
raise CommandError(message)
def _expect_status(self, response, expected_status: int, step: str):
if response.status_code != expected_status:
try:
payload = response.json()
except ValueError:
payload = {"raw": response.content.decode("utf-8", errors="replace")}
self._fail(step, f"expected HTTP {expected_status}, got {response.status_code}", payload)
return response.json()
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
actual_status = payload.get("session", {}).get("status")
if actual_status != expected_status:
self._fail(step, f"expected session.status={expected_status}, got {actual_status}", payload)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
@@ -30,11 +50,14 @@ class Command(BaseCommand):
category.is_active = True
category.save(update_fields=["is_active"])
Question.objects.get_or_create(
question, _ = Question.objects.get_or_create(
category=category,
prompt="Smoke prompt?",
defaults={"correct_answer": "Correct", "is_active": True},
)
if not question.is_active:
question.is_active = True
question.save(update_fields=["is_active"])
User = get_user_model()
host, _ = User.objects.get_or_create(username="smoke-host")
@@ -42,111 +65,254 @@ class Command(BaseCommand):
host.is_staff = True
host.save()
artifact = {
"ok": True,
"command": "python manage.py smoke_staging --artifact <path>",
"generated_at": datetime.now(timezone.utc).isoformat(),
"question": {
"prompt": question.prompt,
"correct_answer": question.correct_answer,
},
"steps": [],
}
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"]
create_payload = self._expect_status(
host_client.post("/lobby/sessions/create", content_type="application/json"),
201,
"create_session",
)
code = create_payload["session"]["code"]
artifact["session_code"] = code
artifact["steps"].append(
{
"step": "create_session",
"session_status": create_payload["session"]["status"],
}
)
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",
join_payload = self._expect_status(
Client().post(
"/lobby/sessions/join",
data=json.dumps({"code": code, "nickname": nickname}),
content_type="application/json",
),
201,
f"join_session[{nickname}]",
)
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",
players.append(join_payload["player"])
artifact["players"] = [player["nickname"] for player in players]
artifact["steps"].append(
{
"step": "join_players",
"players_count": len(players),
}
)
if start_res.status_code != 201:
raise CommandError(f"start_round failed: {start_res.status_code}")
round_question_id = start_res.json()["round_question"]["id"]
start_payload = self._expect_status(
host_client.post(
f"/lobby/sessions/{code}/rounds/start",
data=json.dumps({"category_slug": category.slug}),
content_type="application/json",
),
201,
"start_round",
)
self._expect_session_status(start_payload, GameSession.Status.LIE, "start_round")
round_question_id = start_payload["round_question"]["id"]
artifact["round_question_id"] = round_question_id
artifact["steps"].append(
{
"step": "start_round",
"session_status": start_payload["session"]["status"],
"round_question_id": round_question_id,
}
)
answers = []
lie_transition_payload = None
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}",
}
nickname = player["nickname"]
lie_payload = self._expect_status(
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 {nickname}",
}
),
content_type="application/json",
),
content_type="application/json",
201,
f"submit_lie[{nickname}]",
)
if lie_res.status_code != 201:
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
if lie_res.json().get("answers"):
answers = lie_res.json()["answers"]
if lie_payload.get("answers"):
answers = lie_payload["answers"]
lie_transition_payload = lie_payload
if not answers:
detail_res = host_client.get(f"/lobby/sessions/{code}")
if detail_res.status_code != 200:
raise CommandError(f"session_detail after lies failed: {detail_res.status_code}")
answers = detail_res.json().get("round_question", {}).get("answers", [])
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_lies")
answers = detail_payload.get("round_question", {}).get("answers", [])
self._expect_session_status(detail_payload, GameSession.Status.GUESS, "session_detail_after_lies")
lie_transition_payload = detail_payload
if not answers:
raise CommandError("canonical lie->guess transition returned empty answers")
self._fail("auto_guess_transition", "canonical lie->guess transition 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 not any(answer.get("text") == question.correct_answer for answer in answers):
self._fail("auto_guess_transition", "mixed answers missing correct answer", {"answers": answers})
if len(answers) < len(players) + 1:
self._fail(
"auto_guess_transition",
"mixed answers shorter than expected bluff set",
{"answers": answers, "players_count": len(players)},
)
if guess_res.status_code != 201:
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
detail_res = host_client.get(f"/lobby/sessions/{code}")
if detail_res.status_code != 200:
raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}")
if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD:
raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard")
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, "auto_guess_transition")
artifact["steps"].append(
{
"step": "auto_guess_transition",
"session_status": lie_transition_payload["session"]["status"],
"answers": [answer["text"] for answer in answers],
}
)
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}")
answer_texts = {answer["text"] for answer in answers}
correct_answer = next((answer["text"] for answer in answers if answer.get("text") == question.correct_answer), None)
if correct_answer is None:
self._fail("submit_guesses", "could not resolve correct answer from mixed answers", {"answers": answers})
guess_plan = {
players[0]["nickname"]: "Lie from P2",
players[1]["nickname"]: correct_answer,
players[2]["nickname"]: "Lie from P1",
}
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
if missing_guess_targets:
self._fail(
"submit_guesses",
"expected bluff targets missing from mixed answers",
{"answers": answers, "missing_guess_targets": sorted(missing_guess_targets)},
)
artifact["guess_plan"] = guess_plan
guess_payloads = []
for player in players:
nickname = player["nickname"]
guess_payload = self._expect_status(
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": guess_plan[nickname],
}
),
content_type="application/json",
),
201,
f"submit_guess[{nickname}]",
)
guess_payloads.append(guess_payload)
reveal_payload = guess_payloads[-1]
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, "auto_reveal_transition")
if not reveal_payload.get("phase_transition", {}).get("auto_advanced"):
self._fail("auto_reveal_transition", "expected auto_advanced=true on final guess", reveal_payload)
reveal = reveal_payload.get("reveal")
if not reveal:
self._fail("auto_reveal_transition", "missing canonical reveal payload", reveal_payload)
if reveal.get("correct_answer") != question.correct_answer:
self._fail(
"auto_reveal_transition",
"reveal payload returned wrong correct answer",
{"expected": question.correct_answer, "reveal": reveal},
)
if len(reveal.get("lies", [])) != len(players):
self._fail("auto_reveal_transition", "unexpected lie count in reveal payload", reveal)
if len(reveal.get("guesses", [])) != len(players):
self._fail("auto_reveal_transition", "unexpected guess count in reveal payload", reveal)
fooled_guesses = [guess for guess in reveal["guesses"] if not guess.get("is_correct")]
correct_guesses = [guess for guess in reveal["guesses"] if guess.get("is_correct")]
if len(fooled_guesses) != 2:
self._fail("auto_reveal_transition", "expected exactly two bluff guesses", reveal)
if len(correct_guesses) != 1:
self._fail("auto_reveal_transition", "expected exactly one correct guess", reveal)
if any(guess.get("fooled_player_id") is None for guess in fooled_guesses):
self._fail("auto_reveal_transition", "bluff guesses missing fooled_player_id", reveal)
artifact["steps"].append(
{
"step": "submit_guesses",
"guess_results": [
{
"player_id": payload["guess"]["player_id"],
"selected_text": payload["guess"]["selected_text"],
"is_correct": payload["guess"]["is_correct"],
"fooled_player_id": payload["guess"].get("fooled_player_id"),
}
for payload in guess_payloads
],
}
)
artifact["steps"].append(
{
"step": "auto_reveal_transition",
"session_status": reveal_payload["session"]["status"],
"reveal": {
"correct_answer": reveal["correct_answer"],
"lies_count": len(reveal["lies"]),
"guesses_count": len(reveal["guesses"]),
"fooled_player_ids": sorted(guess["fooled_player_id"] for guess in fooled_guesses),
"correct_guess_player_ids": sorted(guess["player_id"] for guess in correct_guesses),
},
}
)
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_guesses")
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, "auto_scoreboard_transition")
if detail_payload.get("reveal") != reveal:
self._fail("auto_scoreboard_transition", "scoreboard promotion changed canonical reveal payload", detail_payload)
scoreboard = detail_payload.get("scoreboard")
if not scoreboard:
self._fail("auto_scoreboard_transition", "missing scoreboard payload after promotion", detail_payload)
if len(scoreboard) != len(players):
self._fail("auto_scoreboard_transition", "unexpected scoreboard length", detail_payload)
if not detail_payload.get("phase_view_model", {}).get("readiness", {}).get("scoreboard_ready"):
self._fail("auto_scoreboard_transition", "scoreboard_ready=false after promotion", detail_payload)
artifact["steps"].append(
{
"step": "auto_scoreboard_transition",
"session_status": detail_payload["session"]["status"],
"leaderboard": scoreboard,
}
)
finish_payload = self._expect_status(
host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json"),
200,
"finish_game",
)
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, "finish_game")
artifact["steps"].append(
{
"step": "finish_game",
"session_status": finish_payload["session"]["status"],
}
)
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",
"submit_lies",
"auto_guess_transition",
"submit_guesses",
"auto_reveal_to_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")

View File

@@ -867,6 +867,79 @@ class CanonicalRoundFlowTests(TestCase):
},
)
def test_canonical_round_flow_bootstraps_second_round_without_first_round_carry_over(self):
self.client.login(username="host_canonical", password="secret123")
extra_question = Question.objects.create(
category=self.category,
prompt="Hvem malede Mona Lisa?",
correct_answer="Da Vinci",
is_active=True,
)
start_response = self.client.post(
reverse("lobby:start_round", kwargs={"code": self.session.code}),
data={"category_slug": self.category.slug},
content_type="application/json",
)
self.assertEqual(start_response.status_code, 201)
first_round_question_id = start_response.json()["round_question"]["id"]
first_round_prompt = start_response.json()["round_question"]["prompt"]
first_round_correct_answer = RoundQuestion.objects.get(pk=first_round_question_id).correct_answer
second_question = extra_question if first_round_prompt == self.question.prompt else self.question
final_lie_response = None
for index, player in enumerate(self.players, start=1):
lie_response = self.client.post(
reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}),
data={"player_id": player.id, "session_token": player.session_token, "text": f"Første løgn {index}"},
content_type="application/json",
)
self.assertEqual(lie_response.status_code, 201)
final_lie_response = lie_response
self.assertIsNotNone(final_lie_response)
for player, selected_text in zip(
self.players,
[first_round_correct_answer, first_round_correct_answer, first_round_correct_answer],
strict=True,
):
guess_response = self.client.post(
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}),
data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text},
content_type="application/json",
)
self.assertEqual(guess_response.status_code, 201)
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(scoreboard_payload["session"]["status"], GameSession.Status.SCOREBOARD)
self.assertEqual(scoreboard_payload["round_question"]["id"], first_round_question_id)
self.assertIsNotNone(scoreboard_payload["reveal"])
self.assertIsNotNone(scoreboard_payload["scoreboard"])
self.assertGreaterEqual(len(scoreboard_payload["reveal"]["guesses"]), 1)
next_round_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(next_round_response.status_code, 200)
self.assertEqual(next_round_response.json()["session"]["status"], GameSession.Status.LIE)
self.assertEqual(next_round_response.json()["session"]["current_round"], 2)
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
self.assertEqual(detail_payload["session"]["current_round"], 2)
self.assertEqual(detail_payload["phase_view_model"]["current_phase"], GameSession.Status.LIE)
self.assertIsNone(detail_payload["reveal"])
self.assertIsNone(detail_payload["scoreboard"])
self.assertEqual(detail_payload["round_question"]["round_number"], 2)
self.assertNotEqual(detail_payload["round_question"]["id"], first_round_question_id)
self.assertEqual(detail_payload["round_question"]["prompt"], second_question.prompt)
self.assertEqual(detail_payload["round_question"]["answers"], [])
round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2)
self.assertEqual(round_two_question.question, second_question)
self.assertEqual(round_two_question.lies.count(), 0)
self.assertEqual(round_two_question.guesses.count(), 0)
self.assertEqual(round_two_question.mixed_answers, [])
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._resolve_scores")
@patch("lobby.views.GameSession.objects.get")
@@ -1347,6 +1420,45 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1)
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1)
def test_start_next_round_clears_existing_next_round_bootstrap_state(self):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.next_question,
correct_answer=self.next_question.correct_answer,
mixed_answers=["Stale truth", "Stale lie"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie")
Guess.objects.create(
round_question=stale_round_question,
player=self.player_two,
selected_text="Stale truth",
is_correct=True,
)
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.session.refresh_from_db()
stale_round_question.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id)
self.assertEqual(stale_round_question.mixed_answers, [])
self.assertEqual(stale_round_question.lies.count(), 0)
self.assertEqual(stale_round_question.guesses.count(), 0)
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
self.assertEqual(detail_payload["session"]["current_round"], 2)
self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id)
self.assertEqual(detail_payload["round_question"]["answers"], [])
self.assertIsNone(detail_payload["reveal"])
self.assertIsNone(detail_payload["scoreboard"])
def test_start_next_round_requires_host(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
@@ -1968,7 +2080,7 @@ class SmokeStagingCommandTests(TestCase):
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):
def test_smoke_staging_writes_phase_evidence_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))
@@ -1976,24 +2088,40 @@ class SmokeStagingCommandTests(TestCase):
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["command"], "python manage.py smoke_staging --artifact <path>")
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
self.assertIn("generated_at", payload)
self.assertIn("session_code", payload)
self.assertEqual(payload["question"]["correct_answer"], "Correct")
self.assertEqual(payload["guess_plan"]["P2"], "Correct")
step_names = [step["step"] for step in payload["steps"]]
self.assertEqual(
payload["steps"],
step_names,
[
"create_session",
"join_players",
"start_round",
"submit_lies",
"auto_guess_transition",
"submit_guesses",
"auto_reveal_to_scoreboard",
"auto_reveal_transition",
"auto_scoreboard_transition",
"finish_game",
],
)
reveal_step = payload["steps"][5]
self.assertEqual(reveal_step["session_status"], GameSession.Status.REVEAL)
self.assertEqual(reveal_step["reveal"]["correct_answer"], "Correct")
self.assertEqual(reveal_step["reveal"]["lies_count"], 3)
self.assertEqual(reveal_step["reveal"]["guesses_count"], 3)
self.assertEqual(len(reveal_step["reveal"]["fooled_player_ids"]), 2)
self.assertEqual(len(reveal_step["reveal"]["correct_guess_player_ids"]), 1)
scoreboard_step = payload["steps"][6]
self.assertEqual(scoreboard_step["session_status"], GameSession.Status.SCOREBOARD)
self.assertEqual(len(scoreboard_step["leaderboard"]), 3)
class I18nResolverTests(TestCase):
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):

View File

@@ -116,6 +116,132 @@ def _build_finish_game_response(session: GameSession) -> JsonResponse:
)
def _get_current_round_question(session: GameSession) -> RoundQuestion | None:
return (
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
.select_related("question")
.order_by("-id")
.first()
)
def _select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion:
existing_round_question = _get_current_round_question(session)
if existing_round_question is not None:
return existing_round_question
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter(
category=round_config.category,
is_active=True,
).exclude(pk__in=used_question_ids)
if not available_questions.exists():
raise ValueError("no_available_questions")
question = random.choice(list(available_questions))
return RoundQuestion.objects.create(
session=session,
round_number=session.current_round,
question=question,
correct_answer=question.correct_answer,
)
def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
deduped_answers = list(round_question.mixed_answers or [])
if deduped_answers:
return deduped_answers
lie_texts = list(round_question.lies.values_list("text", flat=True))
seen = set()
for text in [round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
raise ValueError("not_enough_answers_to_mix")
random.shuffle(deduped_answers)
round_question.mixed_answers = deduped_answers
round_question.save(update_fields=["mixed_answers"])
return deduped_answers
def _reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion:
Guess.objects.filter(round_question=round_question).delete()
LieAnswer.objects.filter(round_question=round_question).delete()
if round_question.mixed_answers:
round_question.mixed_answers = []
round_question.save(update_fields=["mixed_answers"])
return round_question
def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]:
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
raise ValueError("no_guesses_submitted")
bluff_counts: dict[int, int] = {}
for guess in guesses:
if guess.fooled_player_id:
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
score_events = []
for guess in guesses:
if guess.is_correct:
guess.player.score += round_config.points_correct
guess.player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=guess.player,
delta=round_config.points_correct,
reason="guess_correct",
meta={"round_question_id": round_question.id, "guess_id": guess.id},
)
)
for player_id, fooled_count in bluff_counts.items():
delta = fooled_count * round_config.points_bluff
player = Player.objects.get(pk=player_id, session=session)
player.score += delta
player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=player,
delta=delta,
reason="bluff_success",
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
)
)
ScoreEvent.objects.bulk_create(score_events)
return score_events, _build_leaderboard(session)
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
if session.status != GameSession.Status.REVEAL:
return session
@@ -995,7 +1121,9 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
locked_session.current_round = next_round_number
try:
round_question = _select_round_question(locked_session, next_round_config)
round_question = _reset_round_question_bootstrap_state(
_select_round_question(locked_session, next_round_config)
)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)