Auction the hot seat instead of assigning it - #654
Merged
Conversation
added 3 commits
August 24, 2026 13:52
Once per game, at a randomly drawn round, the chair is sold rather than handed to whoever is last. Everyone bids in secret; the highest bid answers one question alone while the rest of the room may stake points on the outcome. Bids are a SHARE of the bidder's own score, never an absolute number of points. This is the decision the mode stands on: in points the auction always goes to whoever is already ahead, which inverts the comeback lever the issue was opened for. In percent everyone can commit everything, so the chair goes to whoever risks the most rather than whoever holds the most β and it is cheapest for the player in last place. It also matches how the wager finale already stakes (player.py:84), so the game gains no second notion of "an amount you put down". Settlement is symmetric, like the finale. Unlike the finale, an unanswered question still costs the stake: the chair was bought either way, and "bid, then let the clock run out" would otherwise be free β which is what any sleeping phone does on its own, no bad intent required. #653 brings the finale in line so this ends up being one rule rather than two. A bid must also cost something. A player on zero points bidding 100% would be the highest bid in the room at a stake of nothing: they would win nothing, lose nothing, and the only effect would be that nobody else gets the chair. Found by a test that was checking something else. Implementation follows the Lightning Round exactly β rules in a synchronous game/hot_seat.py, three phases, and one loop in the WS layer. The auction never draws the round Lightning already owns, and it reserves the main game's queued questions the same way (#544). Off in the kids preset, for the same family of reasons Lightning is: losing points is the mechanic children like least. 67 new tests, suite 2175.
SIM102 on the trigger check, and two typing holes the gates caught: the reveal sorted a list of dicts (so every field read as object), and _hot_seat was declared without its type, which made the assignment in start_hot_seat_auction an error against an implicit None.
Two holes on the same path, both silent. preset_store only persisted rounds/timer/difficulty/category/lightning/ packs, so the hot_seat flag the admin sends was accepted and dropped. A host would switch the auction off, save the preset, reload, and find it on again with nothing on screen to explain why. And _applyCustomPreset read p.hotSeat while a preset coming back from the server carries hot_seat β the wire name the store persists. Both names are kept: the JS side stays camelCase, the payload stays snake_case, and the boundary is the one place that has to know about the difference. The round-trip test now asserts the flag survives a reload, which is what would have caught the first hole.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #616
Builds the auction variant exactly as decided in the interview on #616. Six answers, six behaviours, each with a test that fails if it is quietly reversed later.
What it does
Once per game, at a randomly drawn round nobody sees coming, the chair is sold rather than handed to whoever is last. Everyone bids in secret; the highest bid answers one question alone while the rest of the room may stake points on whether they get it.
Ties go to the worse-placed player.
Why bids are a share
This is the decision the whole mode stands on, so it is worth stating plainly: in absolute points the auction always goes to whoever is already ahead. Mid-game at 96 / 84 / 71 / 58 / 39, the leader can outbid the last-placed player's entire score and still hold more than that player started with. The chair would belong to the person who needs it least, which is the reverse of what this issue was opened for.
In percent everyone can commit everything, so the seat goes to whoever risks the most rather than whoever holds the most β and it is cheapest for the player in last place.
It is also not a new idea in this codebase. The wager finale already stakes in percent (
game/player.py:84,game/scoring_engine.py:155), and the phone already ships amin=0 max=100slider. The game gains no second notion of "an amount you put down".One rule this deliberately breaks
game/state.py:1280leaves an unanswered wager unsettled β decided on purpose in #301 so a sleeping phone never costs points.An auction cannot inherit that. The stake buys the chair, so "bid, then let the clock run out" would mean taking the spotlight, clearing the field and paying nothing β and that is what any locked screen does on its own, no bad intent required. So here, winning means paying.
Running two settlement rules side by side would be worse than either, which is why #653 brings the finale in line. That change is not in this PR; it alters released behaviour and deserves its own review.
A hole a test found
A player sitting on zero points can bid 100 %. That is nominally the highest bid in the room at a stake of nothing β they win nothing, lose nothing, and the only effect is that nobody else gets the chair.
Caught by
test_loss_never_pushes_below_the_bank, which was checking something else entirely. A bid now has to cost something to rank:test_a_broke_player_cannot_block_the_chair.How it is built
Deliberately shaped like the Lightning Round, because that detour has already paid for its lessons:
game/hot_seat.pyβ all rules, synchronous, no asyncio. Unit-testable without an event loop.GamePhase:HOT_SEAT_AUCTIONβHOT_SEATβHOT_SEAT_REVEAL._start_next_questionand ends in thenext_questionhandler β the same two seams Lightning uses, so there is one pattern to understand rather than two.The TV gets the seat holder's shuffled order rather than canonical β which also keeps #521 shut β and dashboards never receive the correct index (#604).
Frontend
New
player-hotseat.js(added to the bundle), one panel with three states, full de/en/es strings, and a host toggle beside the Lightning one. Off in the kids preset for the same family of reasons Lightning is: losing points is the mechanic children like least.Tests
67 new, suite 2,175, all green (
.venv, matching CI).tests/test_hot_seat_616.pyβ 49 tests on the rulestests/test_hot_seat_state_616.pyβ 18 on the detour seam, including the failure every detour in this project has had at least once: resuming on the wrong roundNot done
No live verification. This has never run on real hardware β no HA deploy, no phone, no TV. Every claim above is from the test suite. The auction window and the reveal hold in particular are timing values chosen on paper; they will want a real room.