diff --git a/client/animation-queue.js b/client/animation-queue.js index 70da13c..06abe7c 100644 --- a/client/animation-queue.js +++ b/client/animation-queue.js @@ -31,14 +31,17 @@ class AnimationQueue { }; } - // Add movements to the queue and start processing + // Add movements to the queue and start processing. + // The onComplete callback only fires after the LAST movement in this batch — + // intermediate movements don't trigger it. This is intentional: callers want + // to know when the whole sequence is done, not each individual step. async enqueue(movements, onComplete) { if (!movements || movements.length === 0) { if (onComplete) onComplete(); return; } - // Add completion callback to last movement + // Attach callback to last movement only const movementsWithCallback = movements.map((m, i) => ({ ...m, onComplete: i === movements.length - 1 ? onComplete : null @@ -185,7 +188,9 @@ class AnimationQueue { await this.delay(this.timing.flipDuration); } - // Step 2: Quick crossfade swap + // Step 2: Quick crossfade swap. + // 150ms is short enough to feel instant but long enough for the eye to + // register the transition. Shorter looks like a glitch, longer looks laggy. handCard.classList.add('fade-out'); heldCard.classList.add('fade-out'); await this.delay(150); diff --git a/client/app.js b/client/app.js index f54a975..8a98dae 100644 --- a/client/app.js +++ b/client/app.js @@ -30,7 +30,14 @@ class GolfGame { this.soundEnabled = true; this.audioCtx = null; - // Swap animation state + // --- Animation coordination flags --- + // These flags form a system: they block renderGame() from touching the discard pile + // while an animation is in flight. If any flag gets stuck true, the discard pile + // freezes and the UI looks broken. Every flag MUST be cleared in every code path: + // animation callbacks, error handlers, fallbacks, and the `your_turn` safety net. + // If you're debugging a frozen discard pile, check these first. + + // Swap animation state — local player's swap defers state updates until animation completes this.swapAnimationInProgress = false; this.swapAnimationCardEl = null; this.swapAnimationFront = null; @@ -44,19 +51,19 @@ class GolfGame { // Animation lock - prevent overlapping animations on same elements this.animatingPositions = new Set(); - // Track opponent swap animation in progress (to apply swap-out class after render) + // Blocks discard update: opponent swap animation in progress this.opponentSwapAnimation = null; // { playerId, position } - // Track draw pulse animation in progress (defer held card display until pulse completes) + // Blocks held card display: draw pulse animation hasn't finished yet this.drawPulseAnimation = false; - // Track local discard animation in progress (prevent renderGame from updating discard) + // Blocks discard update: local player discarding drawn card to pile this.localDiscardAnimating = false; - // Track opponent discard animation in progress (prevent renderGame from updating discard) + // Blocks discard update: opponent discarding without swap this.opponentDiscardAnimating = false; - // Track deal animation in progress (suppress flip prompts until dealing complete) + // Blocks discard update + suppresses flip prompts: deal animation in progress this.dealAnimationInProgress = false; // Track round winners for visual highlight @@ -818,7 +825,11 @@ class GolfGame { hasDrawn: newState.has_drawn_card }); - // V3_03: Intercept round_over transition to defer card reveals + // V3_03: Intercept round_over transition to defer card reveals. + // The problem: the last turn's swap animation flips a card, and then + // the round-end reveal animation would flip it again. We snapshot the + // old state, patch it to mark the swap position as already face-up, + // and use that as the "before" for the reveal animation. const roundJustEnded = oldState?.phase !== 'round_over' && newState.phase === 'round_over'; @@ -834,7 +845,8 @@ class GolfGame { } // Build preRevealState from oldState, but mark swap position as - // already handled so reveal animation doesn't double-flip it + // already handled so reveal animation doesn't double-flip it. + // Without this patch, the card visually flips twice in a row. const preReveal = JSON.parse(JSON.stringify(oldState)); if (this.opponentSwapAnimation) { const { playerId, position } = this.opponentSwapAnimation; @@ -1332,14 +1344,16 @@ class GolfGame { this.heldCardFloating.classList.add('hidden'); this.heldCardFloating.style.cssText = ''; - // Pre-emptively skip the flip animation - the server may broadcast the new state - // before our animation completes, and we don't want renderGame() to trigger - // the flip-in animation (which starts with opacity: 0, causing a flash) + // Three-part race guard. All three are needed, and they protect different things: + // 1. skipNextDiscardFlip: prevents the CSS flip-in animation from firing + // (it starts at opacity:0, which causes a visible flash) + // 2. lastDiscardKey: prevents renderGame() from detecting a "change" to the + // discard pile and re-rendering it mid-animation + // 3. localDiscardAnimating: blocks renderGame() from touching the discard DOM + // entirely until our animation callback fires + // Remove any one of these and you get a different flavor of visual glitch. this.skipNextDiscardFlip = true; - // Also update lastDiscardKey so renderGame() won't see a "change" this.lastDiscardKey = `${discardedCard.rank}-${discardedCard.suit}`; - - // Block renderGame from updating discard during animation (prevents race condition) this.localDiscardAnimating = true; // Animate held card to discard using anime.js @@ -2414,7 +2428,13 @@ class GolfGame { }; } - // Fire-and-forget animation triggers based on state changes + // Fire-and-forget animation triggers based on state diffs. + // Two-step detection: + // STEP 1: Did someone draw? (drawn_card goes null -> something) + // STEP 2: Did someone finish their turn? (discard pile changed + turn advanced) + // Critical: if STEP 1 detects a draw-from-discard, STEP 2 must be skipped. + // The discard pile changed because a card was REMOVED, not ADDED. Without this + // suppression, we'd fire a phantom discard animation for a card nobody discarded. triggerAnimationsForStateChange(oldState, newState) { if (!oldState) return; @@ -2522,18 +2542,18 @@ class GolfGame { } // STEP 2: Detect when someone FINISHES their turn (discard changes, turn advances) - // Skip if we just detected a draw - the discard change was from REMOVING a card, not adding one + // Skip if we just detected a draw — see comment at top of function. if (discardChanged && wasOtherPlayer && !justDetectedDraw) { - // Check if the previous player actually SWAPPED (has a new face-up card) - // vs just discarding the drawn card (no hand change) + // Figure out if the previous player SWAPPED (a card in their hand changed) + // or just discarded their drawn card (hand is identical). + // Three cases to detect a swap: + // Case 1: face-down -> face-up (normal swap into hidden position) + // Case 2: both face-up but different card (swap into already-revealed position) + // Case 3: card identity null -> known (race condition: face_up flag lagging behind) const oldPlayer = oldState.players.find(p => p.id === previousPlayerId); const newPlayer = newState.players.find(p => p.id === previousPlayerId); if (oldPlayer && newPlayer) { - // Find the position that changed - // Could be: face-down -> face-up (new reveal) - // Or: different card at same position (replaced visible card) - // Or: card identity became known (null -> value, indicates swap) let swappedPosition = -1; let wasFaceUp = false; // Track if old card was already face-up @@ -3993,7 +4013,11 @@ class GolfGame { // Not holding - show normal discard pile this.discard.classList.remove('picked-up'); - // Skip discard update during any discard-related animation - animation handles the visual + // The discard pile is touched by four different animation paths. + // Each flag represents a different in-flight animation that "owns" the discard DOM. + // renderGame() must not update the discard while any of these are active, or you'll + // see the card content flash/change underneath the animation overlay. + // Priority order doesn't matter — any one of them is reason enough to skip. const skipReason = this.localDiscardAnimating ? 'localDiscardAnimating' : this.opponentSwapAnimation ? 'opponentSwapAnimation' : this.opponentDiscardAnimating ? 'opponentDiscardAnimating' : @@ -4017,7 +4041,9 @@ class GolfGame { const discardCard = this.gameState.discard_top; const cardKey = `${discardCard.rank}-${discardCard.suit}`; - // Only animate discard flip during active gameplay, not at round/game end + // Only animate discard flip during active gameplay, not at round/game end. + // lastDiscardKey is pre-set by discardDrawn() to prevent a false "change" + // detection when the server confirms what we already animated locally. const isActivePlay = this.gameState.phase !== 'round_over' && this.gameState.phase !== 'game_over'; const shouldAnimate = isActivePlay && this.lastDiscardKey && diff --git a/client/card-animations.js b/client/card-animations.js index ce651ba..28c2576 100644 --- a/client/card-animations.js +++ b/client/card-animations.js @@ -43,10 +43,14 @@ class CardAnimations { const discardRect = this.getDiscardRect(); if (!deckRect || !discardRect) return null; + // Center the held card between deck and discard pile const centerX = (deckRect.left + deckRect.right + discardRect.left + discardRect.right) / 4; const cardWidth = deckRect.width; const cardHeight = deckRect.height; const isMobilePortrait = document.body.classList.contains('mobile-portrait'); + // Overlap percentages: how much the held card peeks above the deck/discard row. + // 48% on mobile (tighter vertical space, needs more overlap to fit), + // 35% on desktop (more breathing room). Tuned by eye, not by math. const overlapOffset = cardHeight * (isMobilePortrait ? 0.48 : 0.35); return { @@ -463,6 +467,9 @@ class CardAnimations { } }); + // Register a no-op entry so cancelAll() can find and stop this animation. + // The actual anime.js instance doesn't need to be tracked (fire-and-forget), + // but we need SOMETHING in the map or cleanup won't know we're animating. this.activeAnimations.set(`initialFlip-${Date.now()}`, { pause: () => {} }); } catch (e) { console.error('Initial flip animation error:', e); @@ -786,7 +793,11 @@ class CardAnimations { }); }; - // Delay first shake, then repeat at interval + // Two-phase timing: wait initialDelay, then shake on an interval. + // Edge case: if stopTurnPulse() is called between the timeout firing and + // the interval being stored on the entry, the interval would leak. That's + // why we re-check activeAnimations.has(id) after the timeout fires — if + // stop was called during the delay, we bail before creating the interval. const timeout = setTimeout(() => { if (!this.activeAnimations.has(id)) return; doShake(); @@ -1114,18 +1125,18 @@ class CardAnimations { return; } - // Wait for any in-progress draw animation to complete - // Check if there's an active draw animation by looking for overlay cards + // Collision detection: if a draw animation is still in flight (its overlay cards + // are still in the DOM), we can't start the swap yet — both animations touch the + // same visual space. 350ms is enough for the draw to finish its arc and land. + // This happens when the server sends the swap state update before the draw + // animation's callback fires (network is faster than anime.js, sometimes). const existingDrawCards = document.querySelectorAll('.draw-anim-card[data-animating="true"]'); if (existingDrawCards.length > 0) { - // Draw animation still in progress - wait a bit and retry setTimeout(() => { - // Clean up the draw animation overlay existingDrawCards.forEach(el => { delete el.dataset.animating; el.remove(); }); - // Now run the swap animation this._runUnifiedSwap(handCardData, heldCardData, handRect, heldRect, discardRect, T, rotation, wasHandFaceDown, onComplete); }, 350); return; @@ -1208,6 +1219,9 @@ class CardAnimations { ], width: discardRect.width, height: discardRect.height, + // Counter-rotate from the card's grid tilt back to 0. The -3 intermediate + // value adds a slight overshoot that makes the arc feel physical. + // Do not "simplify" this to [rotation, 0]. It will look robotic. rotate: [rotation, rotation - 3, 0], duration: T.arc, easing: this.getEasing('arc'), diff --git a/client/card-manager.js b/client/card-manager.js index 785f201..6dc3c4c 100644 --- a/client/card-manager.js +++ b/client/card-manager.js @@ -100,12 +100,14 @@ class CardManager { } } - // Get the deck color class for a card based on its deck_id + // Get the deck color class for a card based on its deck_id. + // Reads from window.currentDeckColors, which app.js sets from game state. + // This global coupling is intentional — card-manager shouldn't know about + // game state directly, and passing it through every call site isn't worth it. getDeckColorClass(cardData) { if (!cardData || cardData.deck_id === undefined || cardData.deck_id === null) { return null; } - // Get deck colors from game state (set by app.js) const deckColors = window.currentDeckColors || ['red', 'blue', 'gold']; const colorName = deckColors[cardData.deck_id] || deckColors[0] || 'red'; return `deck-${colorName}`; @@ -126,7 +128,10 @@ class CardManager { cardEl.style.width = `${rect.width}px`; cardEl.style.height = `${rect.height}px`; - // On mobile, scale font proportional to card width so rank/suit fit + // On mobile, scale font proportional to card width so rank/suit fit. + // This must stay in sync with the CSS .card font-size on desktop — if CSS + // sets a fixed size and we set an inline style, the inline wins. Clearing + // fontSize on desktop lets the CSS rule take over. if (document.body.classList.contains('mobile-portrait')) { cardEl.style.fontSize = `${rect.width * 0.35}px`; } else { @@ -235,7 +240,9 @@ class CardManager { await this.delay(flipDuration); } - // Step 2: Move card to discard + // Step 2: Move card to discard. + // The +50ms buffer accounts for CSS transition timing jitter — without it, + // we occasionally remove the 'moving' class before the transition finishes. cardEl.classList.add('moving'); this.positionCard(cardEl, discardRect); await this.delay(duration + 50); diff --git a/server/ai.py b/server/ai.py index f071343..06f8499 100644 --- a/server/ai.py +++ b/server/ai.py @@ -55,17 +55,15 @@ CPU_TIMING = { "post_action_pause": (0.5, 0.7), } -# Thinking time ranges by card difficulty (seconds) +# Thinking time ranges by card difficulty (seconds). +# Yes, these are all identical. That's intentional — the categories exist so we +# CAN tune them independently later, but right now a uniform 0.15-0.3s feels +# natural enough. The structure is the point, not the current values. THINKING_TIME = { - # Obviously good cards (Jokers, Kings, 2s, Aces) - easy take "easy_good": (0.15, 0.3), - # Obviously bad cards (10s, Jacks, Queens) - easy pass "easy_bad": (0.15, 0.3), - # Medium difficulty (3, 4, 8, 9) "medium": (0.15, 0.3), - # Hardest decisions (5, 6, 7 - middle of range) "hard": (0.15, 0.3), - # No discard available - quick decision "no_card": (0.15, 0.3), } @@ -800,7 +798,9 @@ class GolfAI: ai_log(f" >> TAKE: {discard_card.rank.value} for four-of-a-kind ({rank_count} visible)") return True - # Take card if it could make a column pair (but NOT for negative value cards) + # Take card if it could make a column pair (but NOT for negative value cards). + # Why exclude negatives: a Joker (-2) paired in a column scores 0, which is + # worse than keeping it unpaired at -2. Same logic for 2s with default values. if discard_value > 0: for i, card in enumerate(player.cards): pair_pos = (i + 3) % 6 if i < 3 else i - 3 @@ -1031,7 +1031,11 @@ class GolfAI: if not creates_negative_pair: expected_hidden = EXPECTED_HIDDEN_VALUE point_gain = expected_hidden - drawn_value - discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0 + # Personality discount: swap_threshold ranges 0-8, so this maps to 0.5-1.0. + # Conservative players (low threshold) discount heavily — they need a bigger + # point gain to justify swapping into the unknown. Aggressive players take + # the swap at closer to face value. + discount = 0.5 + (profile.swap_threshold / 16) return point_gain * discount return 0.0 @@ -1252,8 +1256,6 @@ class GolfAI: """If player has exactly 1 face-down card, decide the best go-out swap. Returns position to swap into, or None to fall through to normal scoring. - Uses a sentinel value of -1 (converted to None by caller) is not needed - - we return None to indicate "no early decision, continue normal flow". """ options = game.options face_down_positions = hidden_positions(player) @@ -1361,7 +1363,11 @@ class GolfAI: if not face_down or random.random() >= 0.5: return None - # SAFETY: Don't randomly go out with a bad score + # SAFETY: Don't randomly go out with a bad score. + # This duplicates some logic from project_score() on purpose — project_score() + # is designed for strategic decisions with weighted estimates, but here we need + # a hard pass/fail check with exact pair math. Close enough isn't good enough + # when the downside is accidentally ending the round at 30 points. if len(face_down) == 1: last_pos = face_down[0] projected = drawn_value @@ -1965,7 +1971,8 @@ def _log_cpu_action(logger, game_id: Optional[str], cpu_player: Player, game: Ga async def process_cpu_turn( - game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None + game: Game, cpu_player: Player, broadcast_callback, game_id: Optional[str] = None, + reveal_callback=None, ) -> None: """Process a complete turn for a CPU player. @@ -2083,6 +2090,13 @@ async def process_cpu_turn( if swap_pos is not None: old_card = cpu_player.cards[swap_pos] + # Reveal the face-down card before swapping + if not old_card.face_up and reveal_callback: + await reveal_callback( + cpu_player.id, swap_pos, + {"rank": old_card.rank.value, "suit": old_card.suit.value}, + ) + await asyncio.sleep(1.0) game.swap_card(cpu_player.id, swap_pos) _log_cpu_action(logger, game_id, cpu_player, game, action="swap", card=drawn, position=swap_pos, diff --git a/server/game.py b/server/game.py index d548222..169f8ee 100644 --- a/server/game.py +++ b/server/game.py @@ -358,6 +358,13 @@ class Player: jack_pairs = 0 # Track paired Jacks for Wolfpack bonus paired_ranks: list[Rank] = [] # Track all paired ranks for four-of-a-kind + # Evaluation order matters here. We check special-case pairs BEFORE the + # default "pairs cancel to 0" rule, because house rules can override that: + # 1. Eagle Eye joker pairs -> -4 (better than 0, exit early) + # 2. Negative pairs keep value -> sum of negatives (worse than 0, exit early) + # 3. Normal pairs -> 0 (skip both cards) + # 4. Non-matching -> sum both values + # Bonuses (wolfpack, four-of-a-kind) are applied after all columns are scored. for col in range(3): top_idx = col bottom_idx = col + 3 @@ -932,7 +939,8 @@ class Game: if self.current_round > 1: self.dealer_idx = (self.dealer_idx + 1) % len(self.players) - # First player is to the left of dealer (next in order) + # "Left of dealer goes first" — standard card game convention. + # In our circular list, "left" is the next index. self.current_player_index = (self.dealer_idx + 1) % len(self.players) # Emit round_started event with deck seed and all dealt cards @@ -1415,6 +1423,9 @@ class Game: Args: player: The player whose turn just ended. """ + # This method and _next_turn() are tightly coupled. _check_end_turn populates + # players_with_final_turn BEFORE calling _next_turn(), which reads it to decide + # whether the round is over. Reordering these calls will break end-of-round logic. if player.all_face_up() and self.finisher_id is None: self.finisher_id = player.id self.phase = GamePhase.FINAL_TURN @@ -1431,7 +1442,8 @@ class Game: Advance to the next player's turn. In FINAL_TURN phase, tracks which players have had their final turn - and ends the round when everyone has played. + and ends the round when everyone has played. Depends on _check_end_turn() + having already added the current player to players_with_final_turn. """ if self.phase == GamePhase.FINAL_TURN: next_index = (self.current_player_index + 1) % len(self.players) @@ -1474,6 +1486,10 @@ class Game: player.calculate_score(self.options) # --- Apply House Rule Bonuses/Penalties --- + # Order matters. Blackjack converts 21->0 first, so knock penalty checks + # against the post-blackjack score. Knock penalty before knock bonus so they + # can stack (you get penalized AND rewarded, net +5). Underdog before tied shame + # so the -3 bonus can create new ties that then get punished. It's mean by design. # Blackjack: exact score of 21 becomes 0 if self.options.blackjack: @@ -1597,6 +1613,10 @@ class Game: """ current = self.current_player() + # Card visibility has three cases: + # 1. Round/game over: all cards revealed to everyone (reveal=True) + # 2. Your own cards: always revealed to you (is_self=True) + # 3. Opponent cards mid-game: only face-up cards shown, hidden cards are redacted players_data = [] for player in self.players: reveal = self.phase in (GamePhase.ROUND_OVER, GamePhase.GAME_OVER) diff --git a/server/handlers.py b/server/handlers.py index 2bcaab4..0bb0bf8 100644 --- a/server/handlers.py +++ b/server/handlers.py @@ -290,6 +290,18 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat player = ctx.current_room.game.get_player(ctx.player_id) old_card = player.cards[position] if player and 0 <= position < len(player.cards) else None + # Capture old card info BEFORE the swap mutates the player's hand. + # game.swap_card() overwrites player.cards[position] in place, so if we + # read it after, we'd get the new card. The client needs the old card data + # to animate the outgoing card correctly. + old_was_face_down = old_card and not old_card.face_up if old_card else False + old_card_data = None + if old_card and old_was_face_down: + old_card_data = { + "rank": old_card.rank.value if old_card.rank else None, + "suit": old_card.suit.value if old_card.suit else None, + } + discarded = ctx.current_room.game.swap_card(ctx.player_id, position) if discarded: @@ -301,6 +313,22 @@ async def handle_swap(data: dict, ctx: ConnectionContext, *, broadcast_game_stat reason=f"swapped {drawn_card.rank.value} into position {position}, replaced {old_rank}", ) + # Broadcast reveal of old face-down card before state update + if old_card_data: + reveal_msg = { + "type": "card_revealed", + "player_id": ctx.player_id, + "position": position, + "card": old_card_data, + } + for pid, p in ctx.current_room.players.items(): + if not p.is_cpu and p.websocket: + try: + await p.websocket.send_json(reveal_msg) + except Exception: + pass + await asyncio.sleep(1.0) + await broadcast_game_state(ctx.current_room) await asyncio.sleep(1.0) check_and_run_cpu_turn(ctx.current_room) diff --git a/server/main.py b/server/main.py index 9358bf0..83ffc05 100644 --- a/server/main.py +++ b/server/main.py @@ -394,6 +394,8 @@ async def lifespan(app: FastAPI): result = await conn.execute( "UPDATE games_v2 SET status = 'abandoned', completed_at = NOW() WHERE status = 'active'" ) + # PostgreSQL returns command tags like "UPDATE 3" — the last word is + # the affected row count. This is a documented protocol behavior. count = int(result.split()[-1]) if result else 0 if count > 0: logger.info(f"Marked {count} orphaned active game(s) as abandoned on startup") @@ -613,6 +615,8 @@ async def reset_cpu_profiles(): return {"status": "ok", "message": "All CPU profiles reset"} +# Per-user game limit. Prevents a single account from creating dozens of rooms +# and exhausting server memory. 4 is generous — most people play 1 at a time. MAX_CONCURRENT_GAMES = 4 @@ -653,6 +657,10 @@ async def websocket_endpoint(websocket: WebSocket): else: logger.debug(f"WebSocket connected anonymously as {connection_id}") + # player_id = connection_id by design. Originally these were separate concepts + # (connection vs game identity), but in practice a player IS their connection. + # Reconnection creates a new connection_id, and the room layer handles the + # identity mapping. Keeping both fields lets handlers be explicit about intent. ctx = ConnectionContext( websocket=websocket, connection_id=connection_id, @@ -857,14 +865,30 @@ async def _run_cpu_chain(room: Room): room.touch() - # Brief pause before CPU starts - animations are faster now + # Brief pause before CPU starts. Without this, the CPU's draw message arrives + # before the client has finished processing the previous turn's state update, + # and animations overlap. 0.25s is enough for the client to settle. await asyncio.sleep(0.25) # Run CPU turn async def broadcast_cb(): await broadcast_game_state(room) - await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id) + async def reveal_cb(player_id, position, card_data): + reveal_msg = { + "type": "card_revealed", + "player_id": player_id, + "position": position, + "card": card_data, + } + for pid, p in room.players.items(): + if not p.is_cpu and p.websocket: + try: + await p.websocket.send_json(reveal_msg) + except Exception: + pass + + await process_cpu_turn(room.game, current, broadcast_cb, game_id=room.game_log_id, reveal_callback=reveal_cb) async def handle_player_leave(room: Room, player_id: str): @@ -881,7 +905,8 @@ async def handle_player_leave(room: Room, player_id: str): room_code = room.code room_player = room.remove_player(player_id) - # If no human players left, clean up the room entirely + # Check both is_empty() AND human_player_count() — CPU players keep rooms + # technically non-empty, but a room with only CPUs is an abandoned room. if room.is_empty() or room.human_player_count() == 0: # Remove all remaining CPU players to release their profiles for cpu in list(room.get_cpu_players()): diff --git a/server/room.py b/server/room.py index ebf97f7..edc9239 100644 --- a/server/room.py +++ b/server/room.py @@ -98,6 +98,9 @@ class Room: Returns: The created RoomPlayer object. """ + # First player in becomes host. On reconnection, the player gets a new + # connection_id, so they rejoin as a "new" player — host status may shift + # if the original host disconnected and someone else was promoted. is_host = len(self.players) == 0 room_player = RoomPlayer( id=player_id, @@ -173,7 +176,9 @@ class Room: if room_player.is_cpu: release_profile(room_player.name, self.code) - # Assign new host if needed + # Assign new host if needed. next(iter(...)) gives us the first value in + # insertion order (Python 3.7+ dict guarantee). This means the longest-tenured + # player becomes host, which is the least surprising behavior. if room_player.is_host and self.players: next_host = next(iter(self.players.values())) next_host.is_host = True