Skip to content

Auction the hot seat instead of assigning it - #654

Merged
mholzi merged 3 commits into
mainfrom
feat/hot-seat-auction-616
Aug 24, 2026
Merged

Auction the hot seat instead of assigning it#654
mholzi merged 3 commits into
mainfrom
feat/hot-seat-auction-616

Conversation

@mholzi

@mholzi mholzi commented Aug 24, 2026

Copy link
Copy Markdown
Owner

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.

Decision Behaviour
What the seat pays Symmetric β€” right gives the stake, wrong takes it
What is bid A share of one's own score, 0–100 %
Visibility Blind. One bid each, revealed together
No answer given Costs the stake, exactly like a wrong one
May the seated player bet No β€” refused server-side
Trigger Automatic, once per game, random round, never Lightning's

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 a min=0 max=100 slider. The game gains no second notion of "an amount you put down".

One rule this deliberately breaks

game/state.py:1280 leaves 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.
  • Three phases in GamePhase: HOT_SEAT_AUCTION β†’ HOT_SEAT β†’ HOT_SEAT_REVEAL.
  • The detour begins in _start_next_question and ends in the next_question handler β€” the same two seams Lightning uses, so there is one pattern to understand rather than two.
  • The draw excludes Lightning's target round. Two detours in one round would fire back to back with no normal question between them, and would read as broken even though both behaved correctly.
  • Questions are reserved for the main game the same way as Auto Lightning Round can strand the main game: 5-round Easy game on the picture pack ends after 2 roundsΒ #544: a bonus mode must never spend the queue and end the game early.
  • A failed start (too few players, no spare question) burns the once-per-game flag and falls through to the normal round, so a skipped bonus can never wedge the game or be retried every round.

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 rules
  • tests/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 round

Not 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.

Claude 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.
@mholzi
mholzi merged commit 902a3e1 into main Aug 24, 2026
6 checks passed
@mholzi
mholzi deleted the feat/hot-seat-auction-616 branch August 24, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hot seat: one player answers, everyone else bets

1 participant