Files
weirsoe-party-protocol/fupogfakta/payloads.py
Asger Geel Weirsøe a81bc1250c
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
Big visual overhaul docker compsoe file etc
2026-03-23 14:11:30 +01:00

619 lines
21 KiB
Python

from datetime import timedelta
from typing import Literal
from .models import GameSession, Player, Question, RoundConfig, RoundQuestion
SessionViewerRole = Literal["host", "player", "public"]
NON_HOST_PROMPT_PHASES = {
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
GameSession.Status.FINISHED,
}
HOST_PHASE_THEMES = {
GameSession.Status.LOBBY: "host-atrium",
GameSession.Status.LIE: "host-spotlight",
GameSession.Status.GUESS: "host-signal",
GameSession.Status.REVEAL: "host-verdict",
GameSession.Status.SCOREBOARD: "host-podium",
GameSession.Status.FINISHED: "host-finale",
}
HOST_PHASE_ORNAMENTS = {
GameSession.Status.LOBBY: "atrium-banner",
GameSession.Status.LIE: "spotlight-beam",
GameSession.Status.GUESS: "signal-grid",
GameSession.Status.REVEAL: "verdict-wave",
GameSession.Status.SCOREBOARD: "podium-ribbon",
GameSession.Status.FINISHED: "finale-burst",
}
PLAYER_IDENTITY_TONES = (
"ember",
"lagoon",
"gold",
"sage",
"coral",
)
PLAYER_IDENTITY_ICONS = (
"spark",
"wave",
"comet",
"leaf",
"crown",
)
PLAYER_PHASE_THEMES = {
"join": "player-boarding",
"lobby": "player-ready",
"waiting": "player-holding",
"lie": "player-ink",
"guess": "player-choices",
"reveal": "player-ripple",
"result": "player-pennant",
}
PLAYER_PHASE_ORNAMENTS = {
"join": "boarding-pass",
"lobby": "ready-lantern",
"waiting": "holding-ring",
"lie": "ink-trace",
"guess": "choice-grid",
"reveal": "ripple-flare",
"result": "pennant-stack",
}
def build_player_ref(player: Player | None) -> dict | None:
if player is None:
return None
return {
"player_id": player.id,
"nickname": player.nickname,
}
def _player_identity_token(nickname: str, join_order: int) -> str:
initial = nickname.strip()[:1].upper() or "P"
return f"{initial}{join_order}"
def build_player_identity_payload(player: Player, *, join_order: int) -> dict:
return {
"token": _player_identity_token(player.nickname, join_order),
"tone": PLAYER_IDENTITY_TONES[(join_order - 1) % len(PLAYER_IDENTITY_TONES)],
"icon": PLAYER_IDENTITY_ICONS[(join_order - 1) % len(PLAYER_IDENTITY_ICONS)],
}
def build_session_players_payload(session: GameSession) -> list[dict]:
joined_players = list(session.players.order_by("created_at", "id"))
identities_by_id = {
player.id: build_player_identity_payload(player, join_order=index)
for index, player in enumerate(joined_players, start=1)
}
return [
{
"id": player.id,
"nickname": player.nickname,
"score": player.score,
"is_connected": player.is_connected,
"identity": identities_by_id[player.id],
}
for player in sorted(joined_players, key=lambda entry: (entry.nickname.casefold(), entry.id))
]
def _can_view_round_prompt(session: GameSession, viewer_role: SessionViewerRole) -> bool:
return viewer_role == "host" or session.status in NON_HOST_PROMPT_PHASES
def build_round_question_payload(
round_question: RoundQuestion | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if round_question is None:
return None
return {
"id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt if _can_view_round_prompt(session, viewer_role) else None,
"shown_at": round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (round_question.mixed_answers or [])],
}
def build_reveal_payload(
round_question: RoundQuestion | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if round_question is None:
return None
lies = [
{
**build_player_ref(lie.player),
"text": lie.text,
"created_at": lie.created_at.isoformat(),
}
for lie in round_question.lies.select_related("player").order_by("created_at", "id")
]
guesses = []
for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
guess_payload = {
**build_player_ref(guess.player),
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"created_at": guess.created_at.isoformat(),
"fooled_player_id": guess.fooled_player_id,
}
if guess.fooled_player is not None:
guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
guesses.append(guess_payload)
return {
"round_question_id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt if _can_view_round_prompt(session, viewer_role) else None,
"correct_answer": round_question.correct_answer,
"lies": lies,
"guesses": guesses,
}
def build_voice_cues_payload(
voice_cues: dict | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if voice_cues is None:
return None
if viewer_role == "host":
return voice_cues
# Keep non-host payloads role-correct: players can still receive generic intro/phase
# metadata later if needed, but prompt-bearing cues stay presenter-only until reveal.
return {
**voice_cues,
"question_prompt": None,
"question_reveal": voice_cues.get("question_reveal") if session.status in NON_HOST_PROMPT_PHASES else None,
}
def build_leaderboard(session: GameSession) -> list[dict]:
return list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
def build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
def _wait_cue_keys() -> tuple[str, str]:
return "host.presenter_scene_cue_wait_label", "host.presenter_scene_cue_wait_body"
def _resolve_authored_scene_ornament(
session: GameSession,
current_round_question: RoundQuestion | None,
) -> str | None:
if session.status not in {
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
}:
return None
if current_round_question is None:
return None
authored_ornament = current_round_question.question.scene_ornament
if authored_ornament in Question.SceneOrnament.values:
return authored_ornament
return None
def _build_host_phase_display_payload(
session: GameSession,
phase_view_model: dict,
*,
current_round_question: RoundQuestion | None = None,
) -> dict:
phase = session.status
host = phase_view_model["host"]
cue_label_key, cue_body_key = _wait_cue_keys()
if phase == GameSession.Status.FINISHED:
cue_label_key = "host.presenter_scene_cue_finished_label"
cue_body_key = "host.presenter_scene_cue_finished_body"
title_key = "host.presenter_scene_title_finished"
body_key = "host.presenter_scene_body_finished"
elif phase == GameSession.Status.LOBBY:
if host["can_start_round"]:
cue_label_key = "host.presenter_scene_cue_start_label"
cue_body_key = "host.presenter_scene_cue_start_body"
title_key = "host.presenter_scene_title_lobby"
body_key = "host.presenter_scene_body_lobby"
elif phase == GameSession.Status.GUESS:
if host["can_calculate_scores"]:
cue_label_key = "host.presenter_scene_cue_reveal_label"
cue_body_key = "host.presenter_scene_cue_reveal_body"
title_key = "host.presenter_scene_title_guess"
body_key = "host.presenter_scene_body_guess"
elif phase == GameSession.Status.REVEAL:
if host["can_reveal_scoreboard"]:
cue_label_key = "host.presenter_scene_cue_scoreboard_label"
cue_body_key = "host.presenter_scene_cue_scoreboard_body"
title_key = "host.presenter_scene_title_reveal"
body_key = "host.presenter_scene_body_reveal"
elif phase == GameSession.Status.SCOREBOARD:
if host["can_start_next_round"] or host["can_finish_game"]:
cue_label_key = "host.presenter_scene_cue_close_label"
cue_body_key = "host.presenter_scene_cue_close_body"
title_key = "host.presenter_scene_title_scoreboard"
body_key = "host.presenter_scene_body_scoreboard"
else:
if host["can_mix_answers"]:
cue_label_key = "host.presenter_scene_cue_mix_label"
cue_body_key = "host.presenter_scene_cue_mix_body"
elif host["can_show_question"]:
cue_label_key = "host.presenter_scene_cue_show_label"
cue_body_key = "host.presenter_scene_cue_show_body"
title_key = "host.presenter_scene_title"
body_key = "host.presenter_scene_body_lie"
return {
"theme": HOST_PHASE_THEMES.get(phase, HOST_PHASE_THEMES[GameSession.Status.LIE]),
"ornament": _resolve_authored_scene_ornament(session, current_round_question)
or HOST_PHASE_ORNAMENTS.get(phase, HOST_PHASE_ORNAMENTS[GameSession.Status.LIE]),
"title_key": title_key,
"body_key": body_key,
"cue_label_key": cue_label_key,
"cue_body_key": cue_body_key,
}
def _build_player_phase_display_payload(
session: GameSession,
phase_view_model: dict,
viewer_role: SessionViewerRole,
*,
current_round_question: RoundQuestion | None = None,
) -> dict:
phase = session.status
player = phase_view_model["player"]
authored_ornament = _resolve_authored_scene_ornament(session, current_round_question)
if viewer_role != "player" and player["can_join"]:
return {
"theme": PLAYER_PHASE_THEMES["join"],
"ornament": PLAYER_PHASE_ORNAMENTS["join"],
"title_key": "player.player_scene_title_join",
"body_key": "player.player_scene_body_join",
"cue_label_key": "player.player_scene_cue_join_label",
"cue_body_key": "player.player_scene_cue_join_body",
}
if player["can_submit_lie"]:
return {
"theme": PLAYER_PHASE_THEMES["lie"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["lie"],
"title_key": "player.submit_lie",
"body_key": "player.phase_summary_lie",
"cue_label_key": "player.active_scene_cue_lie_label",
"cue_body_key": "player.active_scene_cue_lie_body",
}
if player["can_submit_guess"]:
return {
"theme": PLAYER_PHASE_THEMES["guess"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["guess"],
"title_key": "player.submit_guess",
"body_key": "player.phase_summary_guess",
"cue_label_key": "player.active_scene_cue_guess_label",
"cue_body_key": "player.active_scene_cue_guess_body",
}
if phase == GameSession.Status.REVEAL:
return {
"theme": PLAYER_PHASE_THEMES["reveal"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["reveal"],
"title_key": "player.reveal_title",
"body_key": "player.phase_summary_reveal",
"cue_label_key": "player.active_scene_cue_reveal_label",
"cue_body_key": "player.active_scene_cue_reveal_body",
}
if phase in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} or player["can_view_final_result"]:
is_finished = phase == GameSession.Status.FINISHED or player["can_view_final_result"]
return {
"theme": PLAYER_PHASE_THEMES["result"],
"ornament": PLAYER_PHASE_ORNAMENTS["result"],
"title_key": "player.final_leaderboard" if is_finished else "player.scoreboard_title",
"body_key": "player.phase_summary_finished" if is_finished else "player.phase_summary_scoreboard",
"cue_label_key": "player.active_scene_cue_result_label",
"cue_body_key": "player.active_scene_cue_result_body",
}
if phase == GameSession.Status.LOBBY:
return {
"theme": PLAYER_PHASE_THEMES["lobby"],
"ornament": PLAYER_PHASE_ORNAMENTS["lobby"],
"title_key": "player.player_scene_title_lobby",
"body_key": "player.player_scene_body_lobby",
"cue_label_key": "player.player_scene_cue_lobby_label",
"cue_body_key": "player.player_scene_cue_lobby_body",
}
waiting_title_key = {
GameSession.Status.LIE: "player.player_scene_title_waiting_lie",
GameSession.Status.GUESS: "player.player_scene_title_waiting_guess",
}.get(phase, "player.player_scene_title_waiting")
return {
"theme": PLAYER_PHASE_THEMES["waiting"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["waiting"],
"title_key": waiting_title_key,
"body_key": "player.player_scene_body_waiting",
"cue_label_key": "player.player_scene_cue_waiting_label",
"cue_body_key": "player.player_scene_cue_waiting_body",
}
def build_phase_display_payload(
session: GameSession,
*,
viewer_role: SessionViewerRole,
phase_view_model: dict,
current_round_question: RoundQuestion | None = None,
) -> dict:
if viewer_role == "host":
return _build_host_phase_display_payload(
session,
phase_view_model,
current_round_question=current_round_question,
)
return _build_player_phase_display_payload(
session,
phase_view_model,
viewer_role,
current_round_question=current_round_question,
)
def build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_scoreboard = status == GameSession.Status.SCOREBOARD
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"current_phase": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"readiness": {
"question_ready": has_round_question,
"scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED},
"can_advance_to_next_round": in_scoreboard,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": in_lie and has_round_question,
"can_mix_answers": (in_lie or in_guess) and has_round_question,
"can_calculate_scores": in_guess and has_round_question,
"can_reveal_scoreboard": status == GameSession.Status.REVEAL,
"can_start_next_round": in_scoreboard,
"can_finish_game": in_scoreboard,
},
"player": {
"can_join": status in {
GameSession.Status.LOBBY,
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
},
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
def build_session_detail_gameplay_payload(
session: GameSession,
*,
current_round_question: RoundQuestion | None,
players_count: int,
viewer_role: SessionViewerRole,
voice_cues: dict | None = None,
) -> dict:
phase_view_model = build_phase_view_model(
session,
players_count=players_count,
has_round_question=bool(current_round_question),
)
return {
"round_question": build_round_question_payload(
current_round_question,
session=session,
viewer_role=viewer_role,
),
"reveal": build_reveal_payload(
current_round_question,
session=session,
viewer_role=viewer_role,
)
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
else None,
"scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"]
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
else None,
"voice_cues": build_voice_cues_payload(
voice_cues,
session=session,
viewer_role=viewer_role,
),
"phase_view_model": phase_view_model,
"phase_display": build_phase_display_payload(
session,
viewer_role=viewer_role,
phase_view_model=phase_view_model,
current_round_question=current_round_question,
),
}
def build_start_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
lie_started_payload = build_lie_started_payload(session, round_config, round_question)
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"round": {
"number": round_config.number,
"category": {
"slug": round_config.category.slug,
"name": round_config.category.name,
},
},
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
}
def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
"lie_seconds": lie_seconds,
}
def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
},
"config": {
"lie_seconds": lie_seconds,
},
}
def build_start_next_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return build_start_round_response(session, round_config, round_question)
def build_start_next_round_phase_event(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return {
"name": "phase.lie_started",
"payload": build_lie_started_payload(session, round_config, round_question),
}
def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict:
return {
"name": "phase.scoreboard",
"payload": {
"leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session),
"current_round": session.current_round,
},
}
def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict:
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
def build_finish_game_phase_event(session: GameSession) -> dict:
leaderboard = build_leaderboard(session)
winner = leaderboard[0] if leaderboard else None
return {
"name": "phase.game_over",
"payload": {"winner": winner, "leaderboard": leaderboard},
}
def build_finish_game_response(session: GameSession) -> dict:
finish_event = build_finish_game_phase_event(session)
return {
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": finish_event["payload"]["winner"],
"leaderboard": finish_event["payload"]["leaderboard"],
}