Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions custom_components/beatify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
SavePlaylistView,
SwJsView,
RematchGameView,
SetSuddenDeathView,
SongStatsView,
StartGameplayView,
StartGameView,
Expand Down Expand Up @@ -217,6 +218,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.http.register_view(TtsTestView(hass))
hass.http.register_view(StartGameView(hass))
hass.http.register_view(StartGameplayView(hass))
hass.http.register_view(SetSuddenDeathView(hass)) # Issue #827
hass.http.register_view(EndGameView(hass))
hass.http.register_view(
ForceResetView(hass)
Expand Down
3 changes: 3 additions & 0 deletions custom_components/beatify/game/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ class GameStateConfig:

# Mode flags
closest_wins_mode: bool = False
# Issue #827: Sudden Death — last-place player eliminated each round.
# Can be set at game start (wizard) or toggled live from the reveal screen.
sudden_death_mode: bool = False
# Issue #1180: Title & Artist guessing mode. Owned by ChallengeManager;
# listed here for reset symmetry. GameState exposes a delegation property,
# and _apply_config skips manager-delegated names (see field_names()).
Expand Down
8 changes: 8 additions & 0 deletions custom_components/beatify/game/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ class PlayerSession:
# Leaderboard tracking (Story 5.5)
previous_rank: int | None = None # Rank before last update

# Sudden Death tracking (Issue #827) - CUMULATIVE, NOT reset in reset_round()
eliminated: bool = False # True once eliminated; stays out for the rest of the game
eliminated_round: int | None = None # Round number the player was eliminated in

# Final stats tracking (Story 5.6) - CUMULATIVE, NOT reset in reset_round()
best_streak: int = 0 # Highest streak achieved during game
rounds_played: int = 0 # Rounds the player participated in
Expand Down Expand Up @@ -202,6 +206,10 @@ def reset_for_new_game(self) -> None:
# Reset intro mode cumulative tracking (Issue #23)
self.intro_speed_bonuses = 0

# Reset Sudden Death state (Issue #827)
self.eliminated = False
self.eliminated_round = None

# Reset movie bonus cumulative tracking (Issue #28)
self.movie_bonus_total = 0

Expand Down
11 changes: 9 additions & 2 deletions custom_components/beatify/game/player_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ def get_players_state(self) -> list[dict[str, Any]]:
"bet": p.bet,
"steal_used": p.steal_used,
"onboarded": p.onboarded,
# Issue #827: Sudden Death — eliminated players render the
# spectator view and a skull badge on leaderboards.
"eliminated": p.eliminated,
"eliminated_round": p.eliminated_round,
}
for p in self.players.values()
]
Expand All @@ -208,9 +212,12 @@ def all_submitted(self) -> bool:

Uses ``is_active`` rather than the raw ``connected`` flag so a stale
ghost (closed WebSocket not yet cleaned up) can't block early reveal
for the whole room — #928.
for the whole room — #928. Eliminated players (#827) never submit, so
they are excluded from the all-submitted (early reveal) check.
"""
active_players = [p for p in self.players.values() if p.is_active]
active_players = [
p for p in self.players.values() if p.is_active and not p.eliminated
]
if not active_players:
return False
return all(p.submitted for p in active_players)
Expand Down
25 changes: 25 additions & 0 deletions custom_components/beatify/game/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,24 @@ def _superlative_risk_taker(players: list[PlayerSession]) -> dict[str, Any] | No
return _award("risk_taker", "🎲", most[0].name, most[1], "bets")


def _superlative_last_one_standing(
players: list[PlayerSession],
) -> dict[str, Any] | None:
"""Issue #827: the sole survivor of a Sudden Death game.

Awarded only when exactly one player was never eliminated while at least
one other player was — i.e. a Sudden Death game that actually ran to its
1v1 conclusion.
"""
survivors = [p for p in players if not p.eliminated]
eliminated = [p for p in players if p.eliminated]
if len(survivors) != 1 or not eliminated:
return None
return _award(
"last_one_standing", "💀", survivors[0].name, len(eliminated), "eliminated"
)


def _superlative_clutch_player(
players: list[PlayerSession], rounds_played: int
) -> dict[str, Any] | None:
Expand Down Expand Up @@ -646,6 +664,7 @@ def calculate_superlatives(
movie_quiz_enabled: bool = False,
intro_mode_enabled: bool = False,
title_artist_mode_enabled: bool = False,
sudden_death_mode_enabled: bool = False,
) -> list[dict[str, Any]]:
"""Calculate fun awards based on game performance (Story 15.2)."""
if not players:
Expand All @@ -655,6 +674,12 @@ def calculate_superlatives(
# never qualify (their counters aren't tracked), so the TA-native
# awards take their slots near the top of the priority order.
builders = [
# Issue #827: the marquee award for a Sudden Death game leads the reel.
(
_superlative_last_one_standing(players)
if sudden_death_mode_enabled
else None
),
_superlative_speed_demon(players),
_superlative_lucky_streak(players),
_superlative_perfect_pair(players) if title_artist_mode_enabled else None,
Expand Down
14 changes: 14 additions & 0 deletions custom_components/beatify/game/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ def serialize(gs: GameState) -> dict[str, Any] | None:
"intro_mode_enabled": gs.intro_mode_enabled,
# Issue #442: Closest Wins mode
"closest_wins_mode": gs.closest_wins_mode,
# Issue #827: Sudden Death mode (drives wizard chip, player view,
# leaderboard cut-line, admin live toggle)
"sudden_death_mode": gs.sudden_death_mode,
# #1180: Title & Artist guessing mode (player UI renders inputs)
"title_artist_mode": gs.title_artist_mode,
"is_intro_round": gs.is_intro_round,
Expand Down Expand Up @@ -154,6 +157,14 @@ def _add_reveal_state(gs: GameState, state: dict[str, Any]) -> None:
}
# Include reveal-specific player data (guesses, round_score, missed)
state["players"] = GameStateSerializer.get_reveal_players_state(gs)
# Issue #827: Sudden Death — names eliminated *this* round drive the
# TV "OUT" takeover + the admin elimination highlight card.
if gs.sudden_death_mode:
state["eliminated_this_round"] = [
p.name
for p in gs.players.values()
if p.eliminated and p.eliminated_round == gs.round
]
# Leaderboard (Story 5.5)
state["leaderboard"] = gs.get_leaderboard()
# Round analytics (Story 13.3 AC4)
Expand Down Expand Up @@ -269,6 +280,9 @@ def get_reveal_players_state(gs: GameState) -> list[dict[str, Any]]:
"stole_from": p.stole_from,
"was_stolen_by": p.was_stolen_by.copy() if p.was_stolen_by else [],
"steal_available": p.steal_available,
# Issue #827: Sudden Death state
"eliminated": p.eliminated,
"eliminated_round": p.eliminated_round,
}
# Story 20.4: Add artist bonus if challenge is enabled
if gs.artist_challenge_enabled:
Expand Down
68 changes: 68 additions & 0 deletions custom_components/beatify/game/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ def __init__(self, time_fn: Callable[[], float] | None = None) -> None:
# Issue #442: Closest Wins mode
self.closest_wins_mode: bool = False

# Issue #827: Sudden Death mode (last-place player eliminated per round)
self.sudden_death_mode: bool = False

# Issue #477: Admin spectator WebSocket (host without being a player)
self._admin_ws: web.WebSocketResponse | None = None

Expand Down Expand Up @@ -724,6 +727,11 @@ async def _end_round_unlocked(self) -> None:
# Phase 2: scoring pass (year/title-artist), closest-wins, round_results
self._score_round(correct_year)

# Issue #827: Sudden Death — after scoring, eliminate the lowest
# round-scoring survivor (from round 2 on). Runs before REVEAL so the
# elimination is part of the reveal broadcast.
self._apply_sudden_death_elimination()

# Phase 3: highlights, round analytics, persisted song-result stats
await self._record_round_stats(correct_year)

Expand Down Expand Up @@ -751,6 +759,65 @@ async def _end_round_unlocked(self) -> None:
"No round_end callback set - REVEAL state will not be broadcast!"
)

# ------------------------------------------------------------------
# Sudden Death mode (Issue #827)
# ------------------------------------------------------------------

def non_eliminated_players(self) -> list[PlayerSession]:
"""Players still in the game (not yet eliminated). Issue #827."""
return [p for p in self.players.values() if not p.eliminated]

def _apply_sudden_death_elimination(self) -> list[str]:
"""Eliminate the lowest round-scoring survivor(s). Issue #827.

Runs after scoring, from round 2 onward (round 1 never eliminates).
Among non-eliminated players, the one with the lowest *round* score
(this round's delta, not cumulative) is eliminated. A tie for last is
broken by submission speed: the slowest (latest) submitter is out, and
a non-submitter counts as the slowest of all. Returns the names
eliminated this round (empty when nothing happens).

Caller holds ``_score_lock`` (invoked from ``_end_round_unlocked``).
"""
if not self.sudden_death_mode or self.round < 2:
return []

survivors = self.non_eliminated_players()
# Never eliminate the last player standing — the auto-end guard in
# start_round carries a 1-survivor game to END instead.
if len(survivors) <= 1:
return []

min_round_score = min(p.round_score for p in survivors)
tied_for_last = [p for p in survivors if p.round_score == min_round_score]

# Slowest among the tied: a non-submitter (submission_time is None) is
# the slowest of all; otherwise the latest submission_time loses.
loser = max(
tied_for_last,
key=lambda p: (p.submission_time is None, p.submission_time or 0.0),
)
loser.eliminated = True
loser.eliminated_round = self.round
_LOGGER.info(
"Sudden Death: eliminated %s in round %d (round score %d)",
loser.name,
self.round,
loser.round_score,
)
return [loser.name]

def set_sudden_death(self, enabled: bool) -> bool:
"""Toggle Sudden Death mid-game from the reveal screen. Issue #827.

Returns the new state. Turning it ON arms eliminations starting next
round; the current round's results stand. Turning it OFF stops further
cuts but already-eliminated players stay out.
"""
self.sudden_death_mode = bool(enabled)
_LOGGER.info("Sudden Death mode set to %s (live toggle)", self.sudden_death_mode)
return self.sudden_death_mode

def _schedule_reveal_advance(self) -> None:
"""Schedule the REVEAL vote window or auto-advance task (#1272).

Expand Down Expand Up @@ -830,4 +897,5 @@ def calculate_superlatives(self) -> list[dict[str, Any]]:
movie_quiz_enabled=self.movie_quiz_enabled,
intro_mode_enabled=self.intro_mode_enabled,
title_artist_mode_enabled=self.title_artist_mode,
sudden_death_mode_enabled=self.sudden_death_mode,
)
37 changes: 32 additions & 5 deletions custom_components/beatify/game/state_leaderboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ def get_leaderboard(self) -> list[dict[str, Any]]:
"is_admin": player.is_admin,
"rank_change": rank_change,
"connected": player.connected,
# Issue #827: Sudden Death cut-line rendering
"eliminated": player.eliminated,
"eliminated_round": player.eliminated_round,
}
leaderboard.append(entry)

Expand Down Expand Up @@ -100,18 +103,39 @@ def get_final_leaderboard(self) -> list[dict[str, Any]]:
Note: is_current is set client-side based on playerName.

"""
# Sort by score descending, then by name for tie-breaking display order
sorted_players = sorted(
self.players.values(),
key=lambda p: (-p.score, p.name),
# Issue #827: in a Sudden Death game the finish order IS the survival
# order — the last one standing is 1st, then players in reverse
# elimination order (eliminated latest = higher), score breaking ties.
# Ranks are sequential (no score-tie grouping) because survival is a
# total order. Falls back to the score sort for normal games.
sudden_death = self.sudden_death_mode and any(
p.eliminated for p in self.players.values()
)
if sudden_death:
sorted_players = sorted(
self.players.values(),
key=lambda p: (
p.eliminated,
-(p.eliminated_round or 0),
-p.score,
p.name,
),
)
else:
# Sort by score descending, then by name for tie-breaking display order
sorted_players = sorted(
self.players.values(),
key=lambda p: (-p.score, p.name),
)

leaderboard = []
current_rank = 0
previous_score = None

for i, player in enumerate(sorted_players):
if player.score != previous_score:
if sudden_death:
current_rank = i + 1
elif player.score != previous_score:
current_rank = i + 1
previous_score = player.score

Expand All @@ -125,6 +149,9 @@ def get_final_leaderboard(self) -> list[dict[str, Any]]:
"best_streak": player.best_streak,
"rounds_played": player.rounds_played,
"bets_won": player.bets_won,
# Issue #827: Sudden Death
"eliminated": player.eliminated,
"eliminated_round": player.eliminated_round,
}
leaderboard.append(entry)

Expand Down
13 changes: 13 additions & 0 deletions custom_components/beatify/game/state_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,19 @@ async def start_round(self, _retry_count: int = 0) -> bool:
_LOGGER.error("No playlist manager configured")
return False

# Issue #827: Sudden Death — when only one player is left standing, the
# game is over. Carry the just-finished round's REVEAL through to END
# rather than starting another round. round >= 2 guards against ending
# a fresh game (round 1 never eliminates).
if (
self.sudden_death_mode
and self.round >= 2
and len(self.non_eliminated_players()) <= 1
):
_LOGGER.info("Sudden Death: one player remains — ending game")
self._set_phase(GamePhase.END)
return False

# Get next playable song (skip songs without URI for selected provider)
song = self._playlist_manager.get_next_song()
if not song:
Expand Down
11 changes: 11 additions & 0 deletions custom_components/beatify/game/state_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,17 @@ def compute_winners(self) -> tuple[list[PlayerSession], int]:
"""
if not self.players:
return [], 0
# Issue #827: in Sudden Death the winner is the last player standing,
# regardless of cumulative score — provided the game actually ran to its
# conclusion (at least one elimination and exactly one survivor). Falls
# through to the score-based winner when Sudden Death is off or the game
# ended without resolving (e.g. force-ended early).
if self.sudden_death_mode:
survivors = [p for p in self.players.values() if not p.eliminated]
if len(survivors) == 1 and any(
p.eliminated for p in self.players.values()
):
return survivors, survivors[0].score
top_score = max(p.score for p in self.players.values())
winners = [p for p in self.players.values() if p.score == top_score]
return winners, top_score
Expand Down
4 changes: 4 additions & 0 deletions custom_components/beatify/game/state_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def create_game( # noqa: PLR0913
movie_quiz_enabled: bool = True,
intro_mode_enabled: bool = False,
closest_wins_mode: bool = False,
sudden_death_mode: bool = False,
title_artist_mode: bool = False,
reveal_auto_advance: int = 0,
) -> dict[str, Any]:
Expand Down Expand Up @@ -272,6 +273,8 @@ def create_game( # noqa: PLR0913

# Issue #442: Set closest wins mode
self.closest_wins_mode = closest_wins_mode
# Issue #827: Set sudden death mode
self.sudden_death_mode = sudden_death_mode
self.is_intro_round = False
self.intro_stopped = False
self._round_manager._intro_round_start_time = None
Expand Down Expand Up @@ -392,6 +395,7 @@ def rematch_game(self) -> None:
"movie_quiz_enabled": self.movie_quiz_enabled,
"intro_mode_enabled": self.intro_mode_enabled,
"closest_wins_mode": self.closest_wins_mode,
"sudden_death_mode": self.sudden_death_mode,
"title_artist_mode": self.title_artist_mode,
}

Expand Down
Loading
Loading