@@ -79,14 +79,7 @@ def create_match(self, room: Room) -> ActiveMatch:
7979
8080 ai_ctrl : Optional [AIPlayer ] = AIPlayer (player ) if is_bot else None
8181
82- # Derive account_type from the seat's identity:
83- # bots are "bot"; registered users have a user_id; guests do not.
84- if is_bot :
85- account_type = "bot"
86- elif participant .user_id is not None :
87- account_type = "registered"
88- else :
89- account_type = "guest"
82+ account_type = participant .account_type if not is_bot else "bot"
9083
9184 seats [seat_idx ] = MatchSeat (
9285 seat_index = seat_idx ,
@@ -146,6 +139,47 @@ def start_next_round(self, match: ActiveMatch) -> None:
146139 """Start the next round (used after ROUND_RESULT is broadcast)."""
147140 self ._start_round (match )
148141
142+ def begin_round_settlement (self , match : ActiveMatch , summary : RoundSummary ) -> None :
143+ """Mark the match as waiting on the round-settlement screen."""
144+ match .round_settlement_pending = True
145+ match .round_settlement_ready_seats .clear ()
146+ match .round_summary_pending = summary
147+
148+ def has_round_settlement_pending (self , match : ActiveMatch ) -> bool :
149+ return match .round_settlement_pending and match .round_summary_pending is not None
150+
151+ def mark_round_ready (self , match : ActiveMatch , seat_index : int ) -> bool :
152+ """
153+ Record one human seat's acknowledgement of the round-settlement screen.
154+
155+ Returns True once every human seat still controlled by a REMOTE player
156+ has acknowledged.
157+ """
158+ if not self .has_round_settlement_pending (match ):
159+ raise MatchError ("Round settlement is not active" )
160+
161+ seat = match .seats .get (seat_index )
162+ if seat is None :
163+ raise MatchError (f"Seat { seat_index } not found in match" )
164+ if seat .controller_type != SeatControllerType .REMOTE :
165+ raise MatchError ("Only human (REMOTE) seats can acknowledge settlement" )
166+
167+ match .round_settlement_ready_seats .add (seat_index )
168+ required = {
169+ s .seat_index
170+ for s in match .seats .values ()
171+ if s .controller_type == SeatControllerType .REMOTE and not s .player .is_eliminated # type: ignore[union-attr]
172+ }
173+ return required .issubset (match .round_settlement_ready_seats )
174+
175+ def clear_round_settlement (self , match : ActiveMatch ) -> Optional [RoundSummary ]:
176+ """Clear the active round-settlement state and return its summary."""
177+ summary = match .round_summary_pending
178+ match .round_settlement_pending = False
179+ match .round_settlement_ready_seats .clear ()
180+ match .round_summary_pending = None
181+ return summary
182+
149183 def reselect_bots (self , match : ActiveMatch ) -> None :
150184 """
151185 Auto-select cards for all non-eliminated bot seats.
@@ -287,6 +321,7 @@ def resolve_turn_stepwise(
287321 penalty_score = placement .penalty_score ,
288322 had_to_take_row = placement .had_to_take_row ,
289323 order = order_idx + 1 ,
324+ penalty_card_count = len (placement .penalty_cards ),
290325 )
291326 accumulated .append (step )
292327 # Temporarily set last_turn_steps so build_public_state reflects
@@ -307,12 +342,36 @@ def finalize_round(self, match: ActiveMatch) -> RoundSummary:
307342 Commit round scores and determine if the game is over.
308343
309344 Must be called after resolve_turn() confirms is_round_over().
345+ Players who voluntarily left mid-match are force-eliminated here.
310346 """
311347 rules : GameRules = match .rules # type: ignore[assignment]
312348
349+ # Force-eliminate players who left voluntarily during this round.
350+ for seat_idx in match .voluntarily_left_seats :
351+ seat = match .seats .get (seat_idx )
352+ if seat is not None and not seat .player .is_eliminated : # type: ignore[union-attr]
353+ seat .player .is_eliminated = True # type: ignore[union-attr]
354+ match .voluntarily_left_seats .clear ()
355+
356+ # Sole-survivor check: if only one active player remains after
357+ # voluntary eliminations, declare them the winner immediately
358+ # — their score doesn't matter (they outlasted everyone else).
359+ active_before_commit = [p for p in rules .players if not p .is_eliminated ]
360+ sole_survivor_win = len (active_before_commit ) == 1
361+
313362 # {player_id: (round_danger, new_total)}
314363 score_results = rules .commit_round_scores ()
315364
365+ # Override game-end result for the sole-survivor case: the last
366+ # remaining player wins even if commit_round_scores() would have
367+ # eliminated them for exceeding 66 danger.
368+ if sole_survivor_win :
369+ survivor = active_before_commit [0 ]
370+ rules .game_over = True
371+ rules .winner = survivor
372+ # Undo the elimination that commit_round_scores may have set.
373+ survivor .is_eliminated = False
374+
316375 round_danger : dict [int , int ] = {}
317376 total_scores : dict [int , int ] = {}
318377 for player_id , (rd , total ) in score_results .items ():
@@ -438,6 +497,7 @@ def build_private_state(self, match: ActiveMatch, seat_index: int) -> PrivatePla
438497 seat_index = seat_index ,
439498 hand = hand ,
440499 has_selected = seat .player .selected_card is not None , # type: ignore[union-attr]
500+ is_eliminated = seat .player .is_eliminated , # type: ignore[union-attr]
441501 )
442502
443503 # ------------------------------------------------------------------
0 commit comments