diff --git a/server/documents/shared/phase10_rules/_metadata.json b/server/documents/shared/phase10_rules/_metadata.json new file mode 100644 index 00000000..20419735 --- /dev/null +++ b/server/documents/shared/phase10_rules/_metadata.json @@ -0,0 +1,14 @@ +{ + "categories": [], + "source_locale": "en", + "titles": { + "en": "Phase 10 Rules" + }, + "locales": { + "en": { + "created": "2026-04-13T00:00:00.000000+00:00", + "modified_contents": "2026-04-13T00:00:00.000000+00:00", + "public": true + } + } +} diff --git a/server/documents/shared/phase10_rules/en.md b/server/documents/shared/phase10_rules/en.md new file mode 100644 index 00000000..52f056fb --- /dev/null +++ b/server/documents/shared/phase10_rules/en.md @@ -0,0 +1,140 @@ +# Rules Of Phase 10 +PlayPalace team, 2026. + +## TL;DR +Phase 10 is a card game for 2 to 6 players, designed by Kenneth Johnson and published by Fundex Games. It is one of the best-selling card games in the United States. + +Each player works through ten phases in order, from Phase 1 to Phase 10. A phase is a specific combination of cards — such as two sets of three, or a run of seven — that you must assemble in your hand and lay down on the table. Each hand, players race to go out (empty their hand) by drawing, laying down their phase, and discarding. Only players who successfully laid down their phase this hand advance to the next phase. The first player to complete Phase 10 wins. + +## The Cards +The deck contains 108 cards: + +* **Numbered cards:** 1 through 12, in four colors (Red, Blue, Green, Yellow). Two copies of each combination, for 96 numbered cards total. +* **Wild cards:** 8 Wilds. A Wild can stand in for any numbered card in any phase group. +* **Skip cards:** 4 Skips. A Skip has no numeric value and cannot be used in a phase group. When discarded, you must immediately choose a player to skip — their next turn is forfeited. Skip cards cannot be taken from the discard pile. + +## Card Values (Penalty Points) +At the end of each hand, players who did not go out score penalty points for every card remaining in their hand. Lower scores are better. + +* Numbered cards 1–9: **5 points each** +* Numbered cards 10–12: **10 points each** +* Skip cards: **15 points each** +* Wild cards: **25 points each** + +## The Phases +There are ten phases. Each player must complete them in order, one per hand. + +* **Phase 1:** 2 sets of 3 +* **Phase 2:** 1 set of 3 + 1 run of 4 +* **Phase 3:** 1 set of 4 + 1 run of 4 +* **Phase 4:** 1 run of 7 +* **Phase 5:** 1 run of 8 +* **Phase 6:** 1 run of 9 +* **Phase 7:** 2 sets of 4 +* **Phase 8:** 7 cards of one color +* **Phase 9:** 1 set of 5 + 1 set of 2 +* **Phase 10:** 1 set of 4 + 1 set of 3 + +### Sets +A set is a group of cards all sharing the same number. For example, three 7s (of any colors) form a set of 3. Wilds may substitute for any card in a set. + +### Runs +A run is a group of cards with consecutive numbers, for example 3-4-5-6-7. Colors do not matter in a run. Wilds may substitute for any missing number in a run. A run cannot wrap around (12 is not followed by 1). + +### Color Groups +A color group is a group of cards all sharing the same color, regardless of number. Wilds may substitute for any card in a color group, but at least one natural (non-Wild) card of the target color is required to establish the color. + +## Gameplay + +### Setup +Each player is dealt 10 cards at the start of every hand. The remaining cards form the draw pile. The top card of the draw pile is turned face up to start the discard pile. If the starting card is a Skip, the first player's turn is automatically skipped. + +All players start on Phase 1. A player advances to the next phase only if they successfully laid down their phase during the hand that just ended. + +### Turn Structure +On your turn you must do three things in order: + +1. **Draw** one card, either from the top of the draw pile or the top of the discard pile. You cannot take a Skip from the discard pile. +2. **Optionally lay down your phase** (if you have not already done so this hand) and/or **hit** cards onto laid-down groups on the table. +3. **Discard** one card to end your turn. + +### Laying Down a Phase +Once you have the cards to satisfy your current phase, you may lay it down during step 2 of your turn. Laying down is a multi-step process: + +1. Press N (or select "Lay down Phase" from the menu) to enter lay-down mode. +2. Navigate to cards in your hand and press Enter to toggle them in or out of the current group. You can press "Check requirement" at any time to hear the requirement for the group you are currently building. +3. When you have selected enough cards for the current group, press F or Enter on "Confirm group" to lock that group in. You then move on to the next group. +4. Once all groups are confirmed, the phase is laid down to the table and the cards leave your hand. +5. Press Escape at any time to cancel the entire lay-down (your hand is returned unchanged). + +Each group requires at least one natural (non-Wild) card. You may use Wilds to fill any remaining slots. + +### Hitting +After laying down your own phase (not before), you may hit additional cards onto any group already on the table, including your own. A hit card must be a valid extension of that group: + +* **Set:** The card must match the set's number. Any color is fine. +* **Run:** The card must extend the run at either end. You cannot insert cards into the interior of a run. Wilds extend at either end (you will be asked which end when hitting a Wild onto a run). +* **Color group:** The card must match the color of the group. + +The primary way to hit is to navigate to the card in your hand and press Enter. If your phase is already laid down and groups are on the table, this immediately enters hit mode with that card ready, and the turn menu switches to showing available groups to hit onto. Alternatively, press H to enter hit mode first and then navigate to select a card — useful if you want to browse the groups before committing to a card. + +### Discarding +After drawing and taking any optional actions, you must discard exactly one card to end your turn. Navigate to the card you want to discard and press J — whichever card is currently focused in your hand menu is used directly. No prior selection step is needed. + +If you were in hit mode and cancelled with Escape, the card you had selected for hitting remains marked, and pressing J will discard that card. + +When you discard a Skip card, you must immediately choose a player to skip (the skip-target menu appears automatically). You cannot skip yourself. Each player can only be skipped once per round (once per full rotation around the table). + +### Going Out +A player goes out when they have no cards left in their hand after discarding. This ends the hand immediately. + +Going out requires that you have already laid down your phase. It is possible to lay down your phase and hit all remaining cards onto the table in the same turn, ending with no cards to discard — but you must still make at least one discard to go out, so plan accordingly. + +### End of Hand Scoring +When someone goes out, the hand ends. Each player who did not go out counts up penalty points for every card remaining in their hand. Wilds are especially costly (25 points each), so discard them as soon as possible if you are not close to laying down. + +Players who successfully laid down their phase this hand advance to the next phase. Players who did not lay down their phase stay on the same phase. + +### Winning +The first player to complete Phase 10 (or whichever phase is set as the winning phase in the options) wins. If multiple players complete the winning phase in the same hand, the player among them with the lowest penalty point total wins. If there is a tie on penalty points as well, those players replay the final phase until the tie is broken. + +## Customizable Options +The host can configure the following settings before the game starts: + +* **Winning Phase:** The phase number a player must complete to win. Defaults to 10. Can be set from 1 to 10. Setting it to a lower number makes for a shorter game. +* **Even Phases Only:** When enabled, players work through only the even-numbered phases (2, 4, 6, 8, 10) instead of all ten. Useful for a shorter game with a different character — the even phases tend to be longer runs and larger groups. +* **Fixed Hands Mode:** When enabled, the game always runs for exactly 10 hands regardless of whether anyone has completed the winning phase. Phases are still awarded normally, but the game ends after hand 10. The player on the highest phase (with lowest score as tiebreaker) wins. +* **Turn Timer:** A time limit for each turn. Defaults to no limit. Options range from 5 seconds to 90 seconds. If a player's timer expires, their turn is forfeited. + +## Keyboard Shortcuts +Shortcuts specific to Phase 10: + +* **Space:** Draw from the deck. +* **Shift+D:** Draw from the discard pile. +* **N:** Lay down your phase (opens lay-down mode). +* **Enter (on a group):** Confirm the selected hit group (after entering hit mode via a card). +* **F:** Confirm the current group during lay-down. +* **Escape:** Cancel lay-down mode or cancel a hit in progress. +* **Enter (on a hand card):** If your phase is laid down and groups are on the table, immediately enters hit mode with that card. In lay-down mode, toggles the card in or out of the current group. +* **H:** Enter hit mode without pre-selecting a card (browse groups first, then select a card). +* **J:** Discard the currently focused hand card. +* **D:** Read the top card of the discard pile. +* **C:** Read all groups currently on the table. +* **P:** Check your current phase and all players' phase status. +* **E:** Read card counts for all players and the draw pile. +* **Shift+C:** Sort your hand by color. +* **Shift+H:** Sort your hand by number (ascending; press again for descending). +* **S:** Check scores. +* **Shift+T:** Check the turn timer. + +## Strategy Tips +* **Hold Wilds for your phase, not for hitting.** Wilds are worth 25 penalty points if you get caught with them. Use them to complete your phase as quickly as possible. Only hold onto Wilds if you are close to laying down and they are the key to completing your phase. +* **Draw from the discard pile when it gives you a phase card.** The discard pile shows only the top card, so you always know exactly what you are getting. If it completes or advances your phase, take it. If not, draw from the deck. +* **Track other players' phases.** Press P at any time to hear which phase each player is on. If someone is close to completing Phase 10, you may want to skip them rather than a player who is far behind. +* **Skip players who are close to going out.** A well-timed Skip can prevent an opponent from going out this hand, giving you extra turns to lay down your own phase and minimize your penalty. +* **Lay down early, even if your phase is incomplete.** Wait — you cannot lay down a partial phase. But if you have your phase and can lay it down this turn, do it immediately: it lets you start hitting off cards rather than holding them in hand. +* **Hit aggressively once you are laid down.** Every card you hit onto the table is a card that costs you nothing if someone goes out. Even low-value cards (5 points) add up fast over many hands. +* **Sort your hand.** Use Shift+C (by color) or Shift+H (by number) to reorder your hand so you can quickly navigate to the cards you need during lay-down. +* **Long runs (Phases 4–6) need planning.** You need 7, 8, or 9 consecutive numbers. Start collecting early. Wilds are your best friend here — they can fill a single gap anywhere in the sequence. +* **Phase 8 (7 of one color) is often the hardest.** Color is not evenly distributed in draws; you may need to discard useful numbered cards to focus on a single color. Once you identify your target color early in a hand, commit to it and discard off-color cards. +* **Managing penalty points matters.** The lowest score among tied players wins. Even in a hand where you cannot lay down, discarding your Wilds and Skips early keeps your penalty low. diff --git a/server/game_utils/action_set_creation_mixin.py b/server/game_utils/action_set_creation_mixin.py index 5d6fdd42..454831ae 100644 --- a/server/game_utils/action_set_creation_mixin.py +++ b/server/game_utils/action_set_creation_mixin.py @@ -143,6 +143,7 @@ def create_standard_action_set(self, player: "Player") -> ActionSet: handler="_action_check_scores", is_enabled="_is_check_scores_enabled", is_hidden="_is_check_scores_hidden", + skip_menu_rebuild=True, ) ) action_set.add( @@ -152,6 +153,7 @@ def create_standard_action_set(self, player: "Player") -> ActionSet: handler="_action_check_scores_detailed", is_enabled="_is_check_scores_detailed_enabled", is_hidden="_is_check_scores_detailed_hidden", + skip_menu_rebuild=True, ) ) action_set.add( diff --git a/server/game_utils/actions.py b/server/game_utils/actions.py index 2e6d7040..99bb2dff 100644 --- a/server/game_utils/actions.py +++ b/server/game_utils/actions.py @@ -83,6 +83,7 @@ class Action(DataClassJSONMixin): include_spectators: bool = False # Whether spectators can see/execute this action disabled_message: str = "" # Locale key spoken when activated while disabled show_disabled_label: bool = True # Append "unavailable" suffix when disabled but visible + skip_menu_rebuild: bool = False # If True, executing via keybind won't trigger a menu rebuild @dataclass diff --git a/server/game_utils/cards.py b/server/game_utils/cards.py index 98a18bc0..26c2040d 100644 --- a/server/game_utils/cards.py +++ b/server/game_utils/cards.py @@ -199,6 +199,50 @@ def rs_games_deck() -> tuple[Deck, dict[int, Card]]: deck.shuffle() return deck, card_lookup + @staticmethod + def phase10_deck() -> tuple[Deck, dict[int, Card]]: + """ + Create Phase 10 108-card deck. + + Contains: + - 2 copies of each number 1-12 in 4 colors (suit 1=Red, 2=Blue, 3=Green, 4=Yellow) = 96 cards + - 8 Wild cards (rank=13, suit=0) + - 4 Skip cards (rank=14, suit=0) + + Returns: + Tuple of (shuffled deck, card lookup dict mapping id -> Card) + """ + cards = [] + card_lookup: dict[int, Card] = {} + card_id = 0 + + # Numbered cards: 2 copies of ranks 1-12 in each of 4 colors + for _ in range(2): + for suit in range(1, 5): # 1=Red, 2=Blue, 3=Green, 4=Yellow + for rank in range(1, 13): # 1-12 + card = Card(id=card_id, rank=rank, suit=suit) + cards.append(card) + card_lookup[card_id] = card + card_id += 1 + + # Wild cards (rank=13, suit=0) + for _ in range(8): + card = Card(id=card_id, rank=13, suit=SUIT_NONE) + cards.append(card) + card_lookup[card_id] = card + card_id += 1 + + # Skip cards (rank=14, suit=0) + for _ in range(4): + card = Card(id=card_id, rank=14, suit=SUIT_NONE) + cards.append(card) + card_lookup[card_id] = card + card_id += 1 + + deck = Deck(cards=cards) + deck.shuffle() + return deck, card_lookup + # Suit localization keys SUIT_KEYS = { diff --git a/server/game_utils/event_handling_mixin.py b/server/game_utils/event_handling_mixin.py index 71242787..827b8f15 100644 --- a/server/game_utils/event_handling_mixin.py +++ b/server/game_utils/event_handling_mixin.py @@ -121,9 +121,9 @@ def _handle_keybind_event(self, player: "Player", event: dict) -> None: from_keybind=True, ) - executed_any = self._execute_keybinds(player, keybinds, is_spectator, menu_item_id, context) + executed_any, wants_rebuild = self._execute_keybinds(player, keybinds, is_spectator, menu_item_id, context) - if self._should_rebuild_after_keybind(player, executed_any): + if wants_rebuild and self._should_rebuild_after_keybind(player, executed_any): self.rebuild_all_menus() def _handle_actions_menu_selection(self, player: "Player", action_id: str) -> None: @@ -259,8 +259,13 @@ def _execute_keybinds( is_spectator: bool, menu_item_id: str | None, context: "ActionContext", - ) -> bool: + ) -> tuple[bool, bool]: + """Execute matching keybinds and return (executed_any, wants_rebuild). + + wants_rebuild is False only when every executed action has skip_menu_rebuild=True. + """ executed_any = False + wants_rebuild = False for keybind in keybinds: if not keybind.can_player_use(self, player, is_spectator): continue @@ -273,6 +278,8 @@ def _execute_keybinds( if resolved.enabled: self.execute_action(player, action_id, context=context) executed_any = True + if not action.skip_menu_rebuild: + wants_rebuild = True elif action.disabled_message: user = self.get_user(player) if user: @@ -282,7 +289,7 @@ def _execute_keybinds( user = self.get_user(player) if user: user.speak_l(resolved.disabled_reason) - return executed_any + return executed_any, wants_rebuild def _should_rebuild_after_keybind(self, player: "Player", executed_any: bool) -> bool: return ( diff --git a/server/games/__init__.py b/server/games/__init__.py index 9f7aad68..95d5a8cd 100644 --- a/server/games/__init__.py +++ b/server/games/__init__.py @@ -42,6 +42,7 @@ from .dominos.game import DominosGame from .lastcard.game import LastCardGame from .pusoydos.game import PusoyDosGame +from .phase10.game import Phase10Game __all__ = [ "Game", @@ -83,4 +84,5 @@ "DominosGame", "LastCardGame", "PusoyDosGame", + "Phase10Game", ] diff --git a/server/games/phase10/__init__.py b/server/games/phase10/__init__.py new file mode 100644 index 00000000..42530a00 --- /dev/null +++ b/server/games/phase10/__init__.py @@ -0,0 +1 @@ +"""Phase 10 game implementation.""" diff --git a/server/games/phase10/bot.py b/server/games/phase10/bot.py new file mode 100644 index 00000000..6045cf9d --- /dev/null +++ b/server/games/phase10/bot.py @@ -0,0 +1,330 @@ +"""Phase 10 bot AI. + +Strategy: + 1. Draw: prefer discard pile if top card helps current phase; else draw from deck. + 2. Lay down: attempt to complete phase whenever hand contains enough cards. + 3. Hit: after laying down, hit any cards that extend table groups, prioritising + high-penalty cards first (Wilds 25 pts, Skips 15 pts). + 4. Skip: play a Skip card on the opponent closest to finishing (highest phase + or already laid down), but only if we cannot use the Skip turn productively + (i.e. we already want to discard it anyway). + 5. Discard: the card least useful to the current phase, prioritising high-penalty + dead weight (Wild > Skip > 10-12 > 1-9). +""" + +from __future__ import annotations + +import random +from collections import Counter +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .game import Phase10Game + from .state import Phase10Player + +from ...game_utils.cards import Card +from .state import P10_RANK_WILD, P10_RANK_SKIP, PHASES, GROUP_SET, GROUP_RUN, GROUP_COLOR +from .evaluator import ( + is_wild, + is_skip, + is_numbered, + score_card, + find_phase_assignment, + can_hit_group, + p10_card_name, +) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def bot_think(game: "Phase10Game", player: "Phase10Player") -> str | None: # noqa: C901 + """Return the next action ID for the bot to execute, or None.""" + + # ---- draw phase --------------------------------------------------------- + if not game.turn_has_drawn: + return _choose_draw(game, player) + + # ---- lay-down mode (bot is mid-group-selection) ------------------------- + if game.lay_down_active: + return _handle_lay_down_mode(game, player) + + # ---- wild placement choice ---------------------------------------------- + if game.hit_wild_group_idx is not None: + from .evaluator import resolve_run_order + group = game.table_groups[game.hit_wild_group_idx] + ordered = resolve_run_order(group.cards) + if ordered and ordered[0][1] > 1: + return "hit_wild_low" + return "hit_wild_high" + + # ---- hit mode ----------------------------------------------------------- + if game.hit_active: + return _handle_hit_mode(game, player) + + # ---- skip target selection ---------------------------------------------- + if game.skip_discard_active: + return _choose_skip_target(game, player) + + # ---- try to lay down phase ---------------------------------------------- + if not player.phase_laid_down: + reqs = game._current_phase_reqs(player) + assignment = find_phase_assignment(player.hand, reqs) + if assignment is not None: + # Start the lay-down flow + return "lay_down_phase" + + # ---- hit on table groups ------------------------------------------------ + if player.phase_laid_down and game.table_groups: + hit_target = _find_hit(game, player) + if hit_target: + return "hit" + + # ---- discard ------------------------------------------------------------ + return _choose_discard(game, player) + + +# --------------------------------------------------------------------------- +# Draw +# --------------------------------------------------------------------------- + + +def _choose_draw(game: "Phase10Game", player: "Phase10Player") -> str: + """Choose draw_deck or draw_discard.""" + if not game.discard_pile: + return "draw_deck" + top = game.discard_pile[-1] + if is_skip(top): + return "draw_deck" + if _discard_helps_phase(top, player.hand, game._current_phase_reqs(player)): + return "draw_discard" + return "draw_deck" + + +def _discard_helps_phase(card: Card, hand: list[Card], reqs) -> bool: + """Return True if drawing the discard top card would help complete the phase. + + Wilds: always helpful (we keep them), but only draw from discard if the + phase assignment isn't already complete without it — otherwise taking it + just wastes a Wild slot and creates ping-pong loops. + """ + if is_wild(card): + # Only take the Wild if we can't already lay down without it + already_done = find_phase_assignment(hand, reqs) is not None + return not already_done + test_hand = hand + [card] + return find_phase_assignment(test_hand, reqs) is not None + + +# --------------------------------------------------------------------------- +# Lay-down group filling +# --------------------------------------------------------------------------- + + +def _handle_lay_down_mode(game: "Phase10Game", player: "Phase10Player") -> str | None: + """During lay-down mode, toggle the right cards then confirm.""" + reqs = game._current_phase_reqs(player) + req = reqs[game.lay_down_group_index] + + # Work out which card IDs to place in this group. + # Re-run the assignment from scratch to stay deterministic. + already_staged: set[int] = set() + for group_ids in game.lay_down_staged: + already_staged.update(group_ids) + + available = [c for c in player.hand if c.id not in already_staged] + assignment = find_phase_assignment(available, reqs[game.lay_down_group_index:]) + if assignment is None: + # Can't complete — cancel + return "cancel_lay_down" + + target_ids = set(c.id for c in assignment[0]) + current_ids = set(game.lay_down_current) + + # Toggle cards that differ from target + for card in player.hand: + if card.id in already_staged: + continue + in_target = card.id in target_ids + in_current = card.id in current_ids + if in_target != in_current: + return game._card_action_id(player, card) or "card_1" + + # Selection matches target — confirm + return "confirm_group" + + +# --------------------------------------------------------------------------- +# Hit +# --------------------------------------------------------------------------- + + +def _handle_hit_mode(game: "Phase10Game", player: "Phase10Player") -> str | None: + """During hit mode, select the card and group.""" + if game.hit_card_id is None: + # Choose the best card to hit with + hit_pair = _find_hit(game, player) + if not hit_pair: + return "cancel_hit" + card, _group_idx = hit_pair + return game._card_action_id(player, card) or "card_1" + else: + # Card chosen; find the matching group + card = next((c for c in player.hand if c.id == game.hit_card_id), None) + if not card: + return "cancel_hit" + for i, group in enumerate(game.table_groups): + ok, _ = can_hit_group(group, card) + if ok: + return f"hit_group_{i}" + return "cancel_hit" + + +def _find_hit(game: "Phase10Game", player: "Phase10Player") -> tuple[Card, int] | None: + """Return (card, group_index) for the best hit, or None.""" + # Sort hand by descending penalty so we shed high-value dead cards first + candidates = sorted( + [c for c in player.hand if not is_skip(c)], + key=lambda c: score_card(c), + reverse=True, + ) + for card in candidates: + for i, group in enumerate(game.table_groups): + ok, _ = can_hit_group(group, card) + if ok: + # Make sure this card isn't needed for the phase (if not laid down yet) + if not player.phase_laid_down: + continue + return card, i + return None + + +# --------------------------------------------------------------------------- +# Skip target +# --------------------------------------------------------------------------- + + +def _choose_skip_target(game: "Phase10Game", player: "Phase10Player") -> str: + """Choose which player to skip — target the one closest to winning.""" + active = [p for p in game._active_players() if p.id != player.id] + if not active: + return "cancel_skip" + + # Target whoever is on the highest phase (and not already skipped this hand) + eligible = [ + p for p in active + if p.id not in game.skip_targets_this_round + ] + if not eligible: + return "cancel_skip" + + target = max(eligible, key=lambda p: (p.current_phase, int(p.phase_laid_down))) + return f"skip_target_{target.id}" + + +# --------------------------------------------------------------------------- +# Discard +# --------------------------------------------------------------------------- + + +def _choose_discard(game: "Phase10Game", player: "Phase10Player") -> str | None: + """Choose which card to discard and queue it via discard_pending_card_id.""" + if not player.hand: + return None + + reqs = game._current_phase_reqs(player) + + # Identify which card IDs are "useful" for the phase. + # If the phase is completable now, keep exactly the assigned cards. + # Otherwise, keep cards that contribute to the best partial progress per + # requirement so we don't throw away the run/set we're building toward. + useful_ids: set[int] = set() + assignment = find_phase_assignment(player.hand, reqs) + if assignment: + for group in assignment: + for c in group: + useful_ids.add(c.id) + else: + useful_ids = _partial_useful_ids(player.hand, reqs) + + # Wilds are always considered useful — never discard them if alternatives exist + for c in player.hand: + if is_wild(c): + useful_ids.add(c.id) + + # Prefer to discard dead cards (highest penalty first; selecting a Skip + # triggers the skip-discard target-selection flow automatically). + # However, avoid feeding table groups — deprioritize cards that an opponent + # could immediately draw and hit onto an existing group. + dead = sorted( + [c for c in player.hand if c.id not in useful_ids], + key=lambda c: score_card(c), + reverse=True, + ) + card = None + if dead: + if game.table_groups: + safe = [c for c in dead if not any(can_hit_group(g, c)[0] for g in game.table_groups)] + if safe: + card = safe[0] + if card is None: + card = dead[0] + + if card is None: + # All cards are useful — discard the lowest-value non-Wild if possible, else Wild + non_wilds = [c for c in player.hand if not is_wild(c)] + if non_wilds: + card = min(non_wilds, key=lambda c: score_card(c)) + else: + card = min(player.hand, key=lambda c: score_card(c)) + + # Set the pending card directly so do_discard can find it without needing + # a menu focus context (bots don't navigate the UI the way humans do). + game.discard_pending_card_id = card.id + return "do_discard" + + +def _partial_useful_ids(hand: list[Card], reqs) -> set[int]: + """Return card IDs worth keeping when the full phase can't yet be assembled. + + For each requirement we keep the cards that best contribute to that group: + - SET: all naturals of the most common rank + - RUN: all naturals that form the longest consecutive chain + - COLOR: all naturals of the most common color + """ + nats = [c for c in hand if not is_wild(c) and not is_skip(c) and is_numbered(c)] + useful: set[int] = set() + + for req in reqs: + if req.kind == GROUP_SET: + if nats: + best_rank = Counter(c.rank for c in nats).most_common(1)[0][0] + useful.update(c.id for c in nats if c.rank == best_rank) + + elif req.kind == GROUP_RUN: + seen_ranks = sorted(set(c.rank for c in nats)) + if not seen_ranks: + continue + # Find the longest chain of consecutive ranks + best: list[int] = [] + current: list[int] = [seen_ranks[0]] + for r in seen_ranks[1:]: + if r == current[-1] + 1: + current.append(r) + else: + if len(current) > len(best): + best = current + current = [r] + if len(current) > len(best): + best = current + best_set = set(best) + useful.update(c.id for c in nats if c.rank in best_set) + + elif req.kind == GROUP_COLOR: + if nats: + best_color = Counter(c.suit for c in nats).most_common(1)[0][0] + useful.update(c.id for c in nats if c.suit == best_color) + + return useful diff --git a/server/games/phase10/evaluator.py b/server/games/phase10/evaluator.py new file mode 100644 index 00000000..e61c6a46 --- /dev/null +++ b/server/games/phase10/evaluator.py @@ -0,0 +1,419 @@ +"""Phase 10 evaluation: group validation, hit checking, scoring, card naming.""" + +from __future__ import annotations + +from collections import defaultdict + +from ...game_utils.cards import Card +from ...messages.localization import Localization +from .state import ( + PhaseRequirement, + TableGroup, + GROUP_SET, + GROUP_RUN, + GROUP_COLOR, + P10_RANK_WILD, + P10_RANK_SKIP, + P10_COLOR_NAMES, + PHASES, + PHASE_DESC_KEYS, + EVEN_PHASES, +) + +# --------------------------------------------------------------------------- +# Card predicates +# --------------------------------------------------------------------------- + + +def is_wild(card: Card) -> bool: + return card.rank == P10_RANK_WILD + + +def is_skip(card: Card) -> bool: + return card.rank == P10_RANK_SKIP + + +def is_numbered(card: Card) -> bool: + return 1 <= card.rank <= 12 + + +# --------------------------------------------------------------------------- +# Card naming +# --------------------------------------------------------------------------- + + +def p10_card_name(card: Card, locale: str = "en") -> str: + """Return the spoken name for a Phase 10 card.""" + if is_wild(card): + return Localization.get(locale, "phase10-card-wild") + if is_skip(card): + return Localization.get(locale, "phase10-card-skip") + color_key = P10_COLOR_NAMES.get(card.suit, "") + color = Localization.get(locale, color_key) if color_key else str(card.suit) + return Localization.get(locale, "phase10-card-numbered", number=card.rank, color=color) + + +def p10_cards_name(cards: list[Card], locale: str = "en") -> str: + """Format a list of Phase 10 cards for speech output.""" + if not cards: + return Localization.get(locale, "no-cards") + names = [p10_card_name(c, locale) for c in cards] + return Localization.format_list_and(locale, names) + + +def req_description(req: PhaseRequirement, locale: str = "en") -> str: + """Short spoken description of a phase requirement, e.g. 'set of 3'.""" + if req.kind == GROUP_SET: + return Localization.get(locale, "phase10-req-set", count=req.count) + if req.kind == GROUP_RUN: + return Localization.get(locale, "phase10-req-run", count=req.count) + return Localization.get(locale, "phase10-req-color", count=req.count) + + +def phase_description(phase_num: int, locale: str = "en") -> str: + """Full spoken description of a phase, e.g. 'Phase 1: 2 sets of 3'.""" + key = PHASE_DESC_KEYS.get(phase_num, "") + return Localization.get(locale, key) if key else f"Phase {phase_num}" + + +# --------------------------------------------------------------------------- +# Scoring +# --------------------------------------------------------------------------- + + +def score_card(card: Card) -> int: + """Penalty point value of a card remaining in hand at round end.""" + if is_wild(card): + return 25 + if is_skip(card): + return 15 + if card.rank >= 10: + return 10 + return 5 + + +def score_hand(cards: list[Card]) -> int: + """Total penalty points for a list of cards.""" + return sum(score_card(c) for c in cards) + + +# --------------------------------------------------------------------------- +# Group validation +# --------------------------------------------------------------------------- + + +def _naturals(cards: list[Card]) -> list[Card]: + return [c for c in cards if not is_wild(c)] + + +def _validate_set_cards(cards: list[Card], min_count: int) -> tuple[bool, str]: + """Validate that cards form a valid set (same rank, at least 1 natural).""" + if len(cards) < min_count: + return False, "phase10-err-need-cards" + nats = _naturals(cards) + if not nats: + return False, "phase10-err-need-natural" + ref_rank = nats[0].rank + if any(c.rank != ref_rank for c in nats): + return False, "phase10-err-invalid-set" + return True, "" + + +def _validate_run_cards(cards: list[Card], min_count: int) -> tuple[bool, str]: + """Validate that cards form a valid run (consecutive numbers, at least 1 natural). + + Wilds fill internal gaps and may extend the run at either end. + The combined span must be achievable with the available wilds. + """ + if len(cards) < min_count: + return False, "phase10-err-need-cards" + nats = _naturals(cards) + if not nats: + return False, "phase10-err-need-natural" + # Skips cannot appear in a run + if any(is_skip(c) for c in nats): + return False, "phase10-err-invalid-run" + # Only numbered cards (1-12) may appear in a run + if any(not is_numbered(c) for c in nats): + return False, "phase10-err-invalid-run" + + wild_count = len(cards) - len(nats) + nat_ranks = sorted(c.rank for c in nats) + + # Duplicate natural ranks are forbidden in a run + if len(nat_ranks) != len(set(nat_ranks)): + return False, "phase10-err-invalid-run" + + min_r, max_r = nat_ranks[0], nat_ranks[-1] + internal_gaps = (max_r - min_r + 1) - len(nat_ranks) + + if internal_gaps > wild_count: + return False, "phase10-err-invalid-run" + + return True, "" + + +def _validate_color_cards(cards: list[Card], min_count: int) -> tuple[bool, str]: + """Validate that cards are all one color (at least 1 natural).""" + if len(cards) < min_count: + return False, "phase10-err-need-cards" + nats = _naturals(cards) + if not nats: + return False, "phase10-err-need-natural" + ref_color = nats[0].suit + if any(c.suit != ref_color for c in nats): + return False, "phase10-err-invalid-color" + return True, "" + + +def validate_group(cards: list[Card], req: PhaseRequirement) -> tuple[bool, str]: + """Validate a list of cards against a phase requirement. + + Returns: + (True, "") on success or (False, error_ftl_key) on failure. + """ + if req.kind == GROUP_SET: + return _validate_set_cards(cards, req.count) + if req.kind == GROUP_RUN: + return _validate_run_cards(cards, req.count) + return _validate_color_cards(cards, req.count) + + +# --------------------------------------------------------------------------- +# Hit validation +# --------------------------------------------------------------------------- + + +def can_hit_group(group: TableGroup, new_card: Card) -> tuple[bool, str]: + """Check whether new_card can legally be added to an existing table group. + + Returns: + (True, "") if valid, (False, reason_ftl_key) otherwise. + """ + if is_skip(new_card): + # Skips cannot be used as hits + return False, "phase10-hit-invalid-skip" + + if is_wild(new_card): + if group.requirement.kind == GROUP_RUN: + ordered = resolve_run_order(group.cards) + if ordered and ordered[0][1] <= 1 and ordered[-1][1] >= 12: + return False, "phase10-hit-invalid-run" + return True, "" + + if group.requirement.kind == GROUP_SET: + nats = _naturals(group.cards) + if nats and new_card.rank != nats[0].rank: + return False, "phase10-hit-invalid-set" + return True, "" + + if group.requirement.kind == GROUP_RUN: + # Natural cards must extend the run at one of its ends, not fill + # interior slots (including slots currently covered by a wild). + ordered = resolve_run_order(group.cards) + if not ordered: + return False, "phase10-hit-invalid-run" + min_val = ordered[0][1] + max_val = ordered[-1][1] + if new_card.rank != min_val - 1 and new_card.rank != max_val + 1: + return False, "phase10-hit-invalid-run" + return True, "" + + # GROUP_COLOR + nats = _naturals(group.cards) + if nats and new_card.suit != nats[0].suit: + return False, "phase10-hit-invalid-color" + return True, "" + + +# --------------------------------------------------------------------------- +# Phase assignment helper (used by bot and lay-down validation) +# --------------------------------------------------------------------------- + + +def find_phase_assignment( + hand: list[Card], + phase_reqs: list[PhaseRequirement], +) -> list[list[Card]] | None: + """Try to find a valid assignment of hand cards to phase requirements. + + Returns a list of card groups (one per requirement) if successful, else None. + Only numbered cards and wilds are candidates; skips are excluded from phases. + + Uses a greedy approach: satisfy requirements in order, preferring naturals + before committing wilds. Good enough for bot use; not exhaustive. + """ + available = [c for c in hand if not is_skip(c)] + groups: list[list[Card]] = [] + + for req in phase_reqs: + group = _pick_group(available, req) + if group is None: + return None + groups.append(group) + for c in group: + available.remove(c) + + return groups + + +def _pick_group(available: list[Card], req: PhaseRequirement) -> list[Card] | None: + """Greedily pick cards from available to satisfy req, or return None.""" + wilds = [c for c in available if is_wild(c)] + nats = [c for c in available if not is_wild(c) and not is_skip(c)] + + if req.kind == GROUP_SET: + return _pick_set(nats, wilds, req.count) + if req.kind == GROUP_RUN: + return _pick_run(nats, wilds, req.count) + return _pick_color(nats, wilds, req.count) + + +def _pick_set(nats: list[Card], wilds: list[Card], count: int) -> list[Card] | None: + rank_groups: dict[int, list[Card]] = {} + for c in nats: + rank_groups.setdefault(c.rank, []).append(c) + + # Try to find a rank with enough naturals + best: list[Card] | None = None + best_nat_count = -1 + for rank, cards in rank_groups.items(): + nat_count = len(cards) + needed_wilds = max(0, count - nat_count) + if needed_wilds <= len(wilds) and nat_count > best_nat_count: + best = cards + wilds[:max(0, count - len(cards))] + best_nat_count = nat_count + + if best is not None and len(best) >= count: + # Return exactly count or more (extras of same rank) + nat_for_rank = [c for c in nats if c.rank == best[0].rank] + needed_wilds = max(0, count - len(nat_for_rank)) + if needed_wilds <= len(wilds): + return nat_for_rank + wilds[:needed_wilds] + + # Fall back: all wilds (invalid — needs at least 1 natural) + return None + + +def _pick_run(nats: list[Card], wilds: list[Card], count: int) -> list[Card] | None: + """Find a run of at least `count` among nats+wilds.""" + numbered = sorted((c for c in nats if is_numbered(c)), key=lambda c: c.rank) + if not numbered: + return None + + # Try each possible starting rank + best: list[Card] | None = None + seen_ranks = sorted(set(c.rank for c in numbered)) + + for start_idx in range(len(seen_ranks)): + # Build the longest run starting from seen_ranks[start_idx] + run_nats: list[Card] = [] + wilds_used = 0 + + for rank in range(seen_ranks[start_idx], 13): + # Find a natural card with this rank (prefer first available) + nat_for_rank = next((c for c in numbered if c.rank == rank and c not in run_nats), None) + if nat_for_rank: + run_nats.append(nat_for_rank) + elif wilds_used < len(wilds): + # Fill gap or extend with a wild + wilds_used += 1 + else: + break # can't extend further + + total = len(run_nats) + wilds_used + if total >= count: + # Collect the actual wild objects + used_wilds = wilds[:wilds_used] + candidate = run_nats + used_wilds + if best is None or len(candidate) > len(best): + best = candidate + + if best and len(best) >= count: + return best + + return best if best and len(best) >= count else None + + +def _pick_color(nats: list[Card], wilds: list[Card], count: int) -> list[Card] | None: + """Find a color group of at least `count`.""" + color_groups: dict[int, list[Card]] = defaultdict(list) + for c in nats: + if is_numbered(c): + color_groups[c.suit].append(c) + + for color, cards in color_groups.items(): + needed_wilds = max(0, count - len(cards)) + if needed_wilds <= len(wilds): + return cards + wilds[:needed_wilds] + + return None + + +# --------------------------------------------------------------------------- +# Run display helpers +# --------------------------------------------------------------------------- + + +def resolve_run_order(cards: list[Card]) -> list[tuple[Card, int]]: + """Return (card, assigned_value) pairs for a run, sorted by assigned value. + + Wilds fill internal gaps first, then extend the run at the low end (greedy). + All-wild groups start at 1. + """ + wilds = [c for c in cards if is_wild(c)] + nats = sorted((c for c in cards if not is_wild(c)), key=lambda c: c.rank) + + if not nats: + return [(c, i + 1) for i, c in enumerate(wilds)] + + nat_ranks = [c.rank for c in nats] + min_nat = nat_ranks[0] + max_nat = nat_ranks[-1] + + internal_gaps = (max_nat - min_nat + 1) - len(nats) + wilds_for_boundary = len(wilds) - internal_gaps + + # Greedy: extend left first, but don't go below 1 + start = max(1, min_nat - wilds_for_boundary) + + result: list[tuple[Card, int]] = [] + nat_iter = iter(nats) + wild_iter = iter(wilds) + next_nat = next(nat_iter, None) + + for val in range(start, start + len(cards)): + if next_nat is not None and next_nat.rank == val: + result.append((next_nat, val)) + next_nat = next(nat_iter, None) + else: + w = next(wild_iter, None) + if w is not None: + result.append((w, val)) + + return result + + + +# --------------------------------------------------------------------------- +# Phase utility +# --------------------------------------------------------------------------- + + +def active_phases(even_only: bool) -> list[int]: + """Return the ordered list of phase numbers used in this game variant.""" + return EVEN_PHASES if even_only else list(range(1, 11)) + + +def next_phase(current: int, even_only: bool) -> int: + """Return the next phase number, or 11 if the game is complete.""" + phases = active_phases(even_only) + try: + idx = phases.index(current) + return phases[idx + 1] if idx + 1 < len(phases) else 11 + except ValueError: + return 11 + + +def starting_phase(even_only: bool) -> int: + return active_phases(even_only)[0] diff --git a/server/games/phase10/game.py b/server/games/phase10/game.py new file mode 100644 index 00000000..68106c56 --- /dev/null +++ b/server/games/phase10/game.py @@ -0,0 +1,1925 @@ +"""Phase 10 game implementation for PlayPalace v11.""" + +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from datetime import datetime + +from ..base import Game, Player, GameOptions +from ..registry import register_game +from ...game_utils.actions import Action, ActionSet, Visibility +from ...game_utils.action_guard_mixin import ActionGuardMixin +from ...game_utils.bot_helper import BotHelper +from ...game_utils.cards import Card, Deck, DeckFactory +from ...game_utils.game_result import GameResult, PlayerResult +from ...game_utils.poker_timer import PokerTurnTimer +from ...messages.localization import Localization +from server.core.ui.keybinds import KeybindState +from server.core.users.bot import Bot +from server.core.users.base import User + +from .state import ( + Phase10Player, + Phase10Options, + TableGroup, + PhaseRequirement, + PHASES, + P10_RANK_WILD, + P10_RANK_SKIP, + EVEN_PHASES, + GROUP_RUN, + GROUP_COLOR, + P10_COLOR_NAMES, +) +from .evaluator import ( + is_wild, + is_skip, + p10_card_name, + p10_cards_name, + resolve_run_order, + req_description, + phase_description, + score_hand, + score_card, + validate_group, + can_hit_group, + find_phase_assignment, + active_phases, + next_phase, + starting_phase, +) +from .bot import bot_think + +# --------------------------------------------------------------------------- +# Sounds +# --------------------------------------------------------------------------- + +SOUND_MUSIC = "game_ninetynine/mus.ogg" +SOUND_SHUFFLE = ["game_cards/shuffle1.ogg", "game_cards/shuffle2.ogg", "game_cards/shuffle3.ogg"] +SOUND_DRAW = ["game_cards/draw1.ogg", "game_cards/draw2.ogg", "game_cards/draw3.ogg", "game_cards/draw4.ogg"] +SOUND_DISCARD = ["game_cards/discard1.ogg", "game_cards/discard2.ogg", "game_cards/discard3.ogg"] +SOUND_LAY_DOWN = ["game_cards/play1.ogg", "game_cards/play2.ogg", "game_cards/play3.ogg"] +SOUND_ROUND_START = "game_pig/roundstart.ogg" +SOUND_WIN_ROUND = "game_uno/winround.ogg" +SOUND_WIN_GAME = "game_uno/wingame.ogg" +SOUND_TURN = "game_pig/turn.ogg" + + +# --------------------------------------------------------------------------- +# Game +# --------------------------------------------------------------------------- + + +@register_game +@dataclass +class Phase10Game(Game, ActionGuardMixin): + """Phase 10: complete all 10 phases before your opponents.""" + + players: list[Phase10Player] = field(default_factory=list) + options: Phase10Options = field(default_factory=Phase10Options) + + # Deck / pile + deck: Deck = field(default_factory=Deck) + discard_pile: list[Card] = field(default_factory=list) + + # Table groups from all players + table_groups: list[TableGroup] = field(default_factory=list) + + # Turn-level flags (reset each turn) + turn_has_drawn: bool = False + + # Lay-down mode state + lay_down_active: bool = False + lay_down_group_index: int = 0 # which requirement we're filling (0-based) + lay_down_staged: list[list[int]] = field(default_factory=list) # card IDs per confirmed group + lay_down_current: list[int] = field(default_factory=list) # card IDs selected for current group + + # Hit mode state + hit_active: bool = False + hit_card_id: int | None = None # card selected as the hit card (None = choosing card) + hit_wild_group_idx: int | None = None # set when choosing wild placement on a run + + # Skip discard state + skip_discard_active: bool = False + skip_pending_card_id: int | None = None + + # Discard confirmation state + discard_pending_card_id: int | None = None + + # Round / game flow + dealer_index: int = -1 + next_round_wait_ticks: int = 0 + intro_wait_ticks: int = 0 + + # Skip targets already used this round (player IDs). Resets each time turns + # wrap around — "round" per the official rules means once around the table. + skip_targets_this_round: list[str] = field(default_factory=list) + + # Tiebreaker (only players in this set take turns) + tiebreaker_mode: bool = False + tiebreaker_player_ids: list[str] = field(default_factory=list) + + # Fixed-hands countdown + fixed_hands_remaining: int = 10 + + # Set when the game ends so build_game_result can include it + game_winner_id: str | None = None + + # Turn timer + timer: PokerTurnTimer = field(default_factory=PokerTurnTimer) + _timer_warning_played: bool = False + _suppress_keybind_rebuild_player_ids: set[str] = field(default_factory=set) + + def __post_init__(self) -> None: + super().__post_init__() + self._timer_warning_played = False + self._suppress_keybind_rebuild_player_ids = set() + + # ========================================================================= + # Metadata + # ========================================================================= + + @classmethod + def get_name(cls) -> str: + return "Phase 10" + + @classmethod + def get_type(cls) -> str: + return "phase10" + + @classmethod + def get_category(cls) -> str: + return "category-card-games" + + @classmethod + def get_min_players(cls) -> int: + return 2 + + @classmethod + def get_max_players(cls) -> int: + return 6 + + # ========================================================================= + # Player management + # ========================================================================= + + def create_player(self, player_id: str, name: str, is_bot: bool = False) -> Phase10Player: + return Phase10Player(id=player_id, name=name, is_bot=is_bot) + + def _get_p10_player(self, player: Player) -> Phase10Player | None: + return player if isinstance(player, Phase10Player) else None + + def _require_current_player(self, player: Player) -> Phase10Player | None: + p = self._get_p10_player(player) + if not p or p.is_spectator: + return None + if self.current_player != p: + return None + return p + + def _sorted_hand_for_menu(self, p: Phase10Player) -> list: + """Return the player's hand sorted for menu display. + + During lay-down, already-staged cards (committed to a previous group) + are pushed to the bottom so the player sees available cards first. + Sort order is controlled by p.hand_sort. + """ + staged_ids = {cid for group in self.lay_down_staged for cid in group} + staged = lambda c: c.id in staged_ids # noqa: E731 + if p.hand_sort == "color": + return sorted(p.hand, key=lambda c: (staged(c), c.suit, c.rank, c.id)) + if p.hand_sort == "number_desc": + return sorted(p.hand, key=lambda c: (staged(c), -c.rank, c.suit, c.id)) + # "default" and "number_asc" both sort ascending by rank + return sorted(p.hand, key=lambda c: (staged(c), c.rank, c.suit, c.id)) + + def _card_action_id(self, p: Phase10Player, card) -> str | None: + """Return the positional action ID for a card in the player's sorted hand.""" + sorted_hand = self._sorted_hand_for_menu(p) + for i, c in enumerate(sorted_hand, 1): + if c.id == card.id: + return f"card_{i}" + return None + + def _player_locale(self, player: Player) -> str: + user = self.get_user(player) + return user.locale if user else "en" + + def _active_players(self) -> list[Phase10Player]: + return [p for p in self.players if isinstance(p, Phase10Player) and not p.is_spectator] + + # ========================================================================= + # Action sets + # ========================================================================= + + def create_turn_action_set(self, player: Phase10Player) -> ActionSet: + action_set = ActionSet(name="turn") + # Card items are dynamically injected by _sync_turn_actions. + # We add placeholder-free fixed actions here so ordering is stable. + return action_set + + def create_standard_action_set(self, player: Player) -> ActionSet: + action_set = super().create_standard_action_set(player) + locale = self._player_locale(player) + local_actions = [ + Action( + id="read_hand", + label=Localization.get(locale, "phase10-read-hand-action"), + handler="_action_read_hand", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + ), + Action( + id="read_discard", + label=Localization.get(locale, "phase10-read-discard-action"), + handler="_action_read_discard", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + include_spectators=True, + ), + Action( + id="read_table", + label=Localization.get(locale, "phase10-read-table-action"), + handler="_action_read_table", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + include_spectators=True, + ), + Action( + id="check_phase", + label=Localization.get(locale, "phase10-check-phase-action"), + handler="_action_check_phase", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + ), + Action( + id="read_counts", + label=Localization.get(locale, "phase10-read-counts-action"), + handler="_action_read_counts", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + include_spectators=True, + ), + Action( + id="check_turn_timer", + label=Localization.get(locale, "phase10-turn-timer-action"), + handler="_action_check_turn_timer", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + include_spectators=True, + ), + Action( + id="sort_by_color", + label=Localization.get(locale, "phase10-sort-by-color-action"), + handler="_action_sort_by_color", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + ), + Action( + id="sort_by_number", + label=Localization.get(locale, "phase10-sort-by-number-action"), + handler="_action_sort_by_number", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + ), + Action( + id="discard", + label=Localization.get(locale, "phase10-discard-action"), + handler="_action_do_discard", + is_enabled="_is_playing_enabled", + is_hidden="_is_playing_hidden", + get_label="_get_do_discard_label", + ), + ] + for action in reversed(local_actions): + action_set.add(action) + if action.id in action_set._order: + action_set._order.remove(action.id) + action_set._order.insert(0, action.id) + return action_set + + def setup_keybinds(self) -> None: + super().setup_keybinds() + # Draw + self.define_keybind("space", "Draw from deck", ["draw_deck"], state=KeybindState.ACTIVE) + self.define_keybind("shift+d", "Draw from discard", ["draw_discard"], state=KeybindState.ACTIVE) + # Phase / hit / skip actions + self.define_keybind("n", "Lay down phase", ["lay_down_phase"], state=KeybindState.ACTIVE) + self.define_keybind("enter", "Confirm group / select", ["confirm_group", "select_card_for_hit", "select_hit_group", "select_skip_target"], state=KeybindState.ACTIVE) + self.define_keybind("f", "Confirm group", ["confirm_group"], state=KeybindState.ACTIVE) + + self.define_keybind("h", "Hit", ["hit"], state=KeybindState.ACTIVE) + self.define_keybind("j", "Discard selected card", ["do_discard"], state=KeybindState.ACTIVE) + # Info + self.define_keybind("d", "Read top of discard", ["read_discard"], state=KeybindState.ACTIVE, include_spectators=True) + self.define_keybind("c", "Read table groups", ["read_table"], state=KeybindState.ACTIVE, include_spectators=True) + self.define_keybind("p", "Check phase status", ["check_phase"], state=KeybindState.ACTIVE) + self.define_keybind("e", "Read card counts", ["read_counts"], state=KeybindState.ACTIVE, include_spectators=True) + self.define_keybind("shift+t", "Turn timer", ["check_turn_timer"], state=KeybindState.ACTIVE, include_spectators=True) + self.define_keybind("shift+c", "Sort hand by color", ["sort_by_color"], state=KeybindState.ACTIVE) + self.define_keybind("shift+h", "Sort hand by number", ["sort_by_number"], state=KeybindState.ACTIVE) + + # ========================================================================= + # Menu sync + # ========================================================================= + + def rebuild_player_menu(self, player: Player, **kwargs) -> None: + self._sync_turn_actions(player) + super().rebuild_player_menu(player, **kwargs) + + def update_player_menu(self, player: Player, selection_id: str | None = None, **kwargs) -> None: + self._sync_turn_actions(player) + super().update_player_menu(player, selection_id=selection_id, **kwargs) + + def rebuild_all_menus(self) -> None: + for player in self.players: + self._sync_turn_actions(player) + super().rebuild_all_menus() + + def _suppress_keybind_rebuild(self, player: Player) -> None: + """Call from read-only info actions to prevent the post-keybind menu rebuild.""" + context = self.get_action_context(player) + if not context or not context.from_keybind: + return + self._suppress_keybind_rebuild_player_ids.add(player.id) + + def _should_rebuild_after_keybind(self, player: Player, executed_any: bool) -> bool: + if player.id in self._suppress_keybind_rebuild_player_ids: + self._suppress_keybind_rebuild_player_ids.discard(player.id) + return False + return super()._should_rebuild_after_keybind(player, executed_any) + + def _sync_turn_actions(self, player: Player) -> None: # noqa: C901 + p = self._get_p10_player(player) + if not p: + return + turn_set = self.get_action_set(p, "turn") + if not turn_set: + return + + # Remove all dynamic items + turn_set.remove_by_prefix("card_") + turn_set.remove_by_prefix("hit_group_") + turn_set.remove_by_prefix("skip_target_") + for action_id in [ + "draw_deck", "draw_discard", + "lay_down_phase", "check_requirement", "confirm_group", "cancel_lay_down", + "hit", "select_card_for_hit", "select_hit_group", "cancel_hit", + "select_skip_target", "cancel_skip", + "do_discard", + ]: + turn_set.remove(action_id) + + if self.status != "playing" or p.is_spectator: + return + + is_current = self.current_player == p + locale = self._player_locale(p) + + # ---- Hand cards: shown in most modes; hidden during skip/group/wild-placement selection ---- + current_in_group_select = is_current and self.hit_active and self.hit_card_id is not None + current_in_skip_select = is_current and self.skip_discard_active + current_in_wild_place = is_current and self.hit_wild_group_idx is not None + if not current_in_skip_select and not current_in_group_select and not current_in_wild_place: + sorted_hand = self._sorted_hand_for_menu(p) + for i, card in enumerate(sorted_hand, 1): + turn_set.add(Action( + id=f"card_{i}", + label="", + handler="_action_card_selected", + is_enabled="_is_card_enabled", + is_hidden="_is_card_hidden", + get_label="_get_card_label", + show_in_actions_menu=False, + )) + + if not is_current: + return + + # ---- Mode-specific actions ---------------------------------------- + + if self.skip_discard_active: + for other in self._active_players(): + if other.id != p.id: + turn_set.add(Action( + id=f"skip_target_{other.id}", + label=Localization.get(locale, "phase10-skip-target-label", + player=other.name, phase=other.current_phase), + handler="_action_select_skip_target", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + turn_set.add(Action( + id="cancel_skip", + label=Localization.get(locale, "phase10-skip-cancel-action"), + handler="_action_cancel_skip", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + return + + if self.hit_wild_group_idx is not None: + # Choosing which end of a run to place a wild + group = self.table_groups[self.hit_wild_group_idx] + ordered = resolve_run_order(group.cards) + low, high = ordered[0][1], ordered[-1][1] + if low > 1: + turn_set.add(Action( + id="hit_wild_low", + label=Localization.get(locale, "phase10-hit-wild-low", value=low - 1), + handler="_action_place_wild", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + if high < 12: + turn_set.add(Action( + id="hit_wild_high", + label=Localization.get(locale, "phase10-hit-wild-high", value=high + 1), + handler="_action_place_wild", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + turn_set.add(Action( + id="cancel_hit", + label=Localization.get(locale, "phase10-hit-cancel-action"), + handler="_action_cancel_hit", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + return + + if self.hit_active and self.hit_card_id is not None: + # Choosing which group to hit onto + for i, group in enumerate(self.table_groups): + turn_set.add(Action( + id=f"hit_group_{i}", + label="", + handler="_action_select_hit_group", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + get_label="_get_hit_group_label", + )) + turn_set.add(Action( + id="cancel_hit", + label=Localization.get(locale, "phase10-hit-cancel-action"), + handler="_action_cancel_hit", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + return + + if self.hit_active and self.hit_card_id is None: + # Choosing which card to hit with (cards already shown above) + turn_set.add(Action( + id="cancel_hit", + label=Localization.get(locale, "phase10-hit-cancel-action"), + handler="_action_cancel_hit", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + return + + if self.lay_down_active: + reqs = self._current_phase_reqs(p) + total = len(reqs) + current = self.lay_down_group_index + 1 + turn_set.add(Action( + id="check_requirement", + label=Localization.get(locale, "phase10-check-req-action"), + handler="_action_check_requirement", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + turn_set.add(Action( + id="confirm_group", + label=Localization.get( + locale, "phase10-confirm-group-action", + current=current, total=total, + ), + handler="_action_confirm_lay_down_group", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + turn_set.add(Action( + id="cancel_lay_down", + label=Localization.get(locale, "phase10-cancel-lay-down-action"), + handler="_action_cancel_lay_down", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_turn_action_hidden", + )) + return + + # ---- Normal turn actions ------------------------------------------ + if not self.turn_has_drawn: + # Draw from deck + turn_set.add(Action( + id="draw_deck", + label=Localization.get(locale, "phase10-draw-deck-action"), + handler="_action_draw_deck", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_keybind_only_hidden", + )) + # Draw from discard (if top is not a Skip and pile is non-empty) + if self.discard_pile and not is_skip(self.discard_pile[-1]): + top_name = p10_card_name(self.discard_pile[-1], locale) + turn_set.add(Action( + id="draw_discard", + label=Localization.get(locale, "phase10-draw-discard-action", card=top_name), + handler="_action_draw_discard", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_keybind_only_hidden", + )) + else: + # Lay down phase + if not p.phase_laid_down: + reqs = self._current_phase_reqs(p) + desc = phase_description(p.current_phase, locale) + turn_set.add(Action( + id="lay_down_phase", + label=Localization.get(locale, "phase10-lay-down-action", phase=p.current_phase), + handler="_action_start_lay_down", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_keybind_only_hidden", + )) + # Hit (only if own phase is down and groups exist) + if p.phase_laid_down and self.table_groups: + turn_set.add(Action( + id="hit", + label=Localization.get(locale, "phase10-hit-action"), + handler="_action_start_hit", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_keybind_only_hidden", + )) + # Discard (Delete key; always present post-draw, hidden from actions menu) + turn_set.add(Action( + id="do_discard", + label="", + handler="_action_do_discard", + is_enabled="_is_turn_action_enabled", + is_hidden="_is_keybind_only_hidden", + get_label="_get_do_discard_label", + show_in_actions_menu=False, + )) + + # ========================================================================= + # Dynamic label helpers + # ========================================================================= + + def _get_card_label(self, player: Player, action_id: str) -> str: + p = self._get_p10_player(player) + locale = self._player_locale(player) + if not p: + return action_id + index = int(action_id.split("_")[-1]) - 1 + sorted_hand = self._sorted_hand_for_menu(p) + if index < 0 or index >= len(sorted_hand): + return action_id + card = sorted_hand[index] + name = p10_card_name(card, locale) + if self.lay_down_active: + staged_ids = {cid for group in self.lay_down_staged for cid in group} + if card.id in staged_ids: + return Localization.get(locale, "phase10-card-label-staged", card=name) + if card.id in self.lay_down_current: + return Localization.get(locale, "phase10-card-label-selected", card=name) + elif self.discard_pending_card_id == card.id: + return Localization.get(locale, "phase10-card-label-selected", card=name) + return name + + def _get_hit_group_label(self, player: Player, action_id: str) -> str: + locale = self._player_locale(player) + idx = int(action_id.split("_")[-1]) + if idx >= len(self.table_groups): + return action_id + group = self.table_groups[idx] + owner = self.get_player_by_id(group.owner_id) + owner_name = owner.name if owner else "?" + cards_str = self._format_group_summary(group.cards, group.requirement, locale) + return Localization.get( + locale, "phase10-table-group-entry", + owner=owner_name, + cards=cards_str, + ) + + def _get_do_discard_label(self, player: Player, action_id: str) -> str: + p = self._get_p10_player(player) + locale = self._player_locale(player) + if p and self.discard_pending_card_id is not None: + card = next((c for c in p.hand if c.id == self.discard_pending_card_id), None) + if card: + return Localization.get(locale, "phase10-discard-confirm-action", + card=p10_card_name(card, locale)) + return Localization.get(locale, "phase10-discard-action") + + def _format_group_summary(self, cards: list[Card], req: PhaseRequirement, locale: str) -> str: + """Brief summary of a group's current contents.""" + naturals = [c for c in cards if not is_wild(c)] + if req.kind == GROUP_RUN: + ordered = resolve_run_order(cards) + low = ordered[0][1] + high = ordered[-1][1] + return Localization.get(locale, "phase10-group-summary-run", low=low, high=high) + if req.kind == GROUP_COLOR: + color_key = P10_COLOR_NAMES.get(naturals[0].suit, "") if naturals else "" + color = Localization.get(locale, color_key) if color_key else "?" + return Localization.get(locale, "phase10-group-summary-color", + count=len(cards), color=color) + # SET + rank = naturals[0].rank if naturals else 0 + return Localization.get(locale, "phase10-group-summary-set", count=len(cards), rank=rank) + + # ========================================================================= + # Visibility / enabled helpers + # ========================================================================= + + def _is_playing_enabled(self, player: Player) -> str | None: + if self.status != "playing": + return "action-not-playing" + return None + + def _is_playing_hidden(self, player: Player) -> Visibility: + return Visibility.HIDDEN + + def _is_card_enabled(self, player: Player, *, action_id: str | None = None) -> str | None: + p = self._get_p10_player(player) + if not p or self.status != "playing": + return "action-not-playing" + if p.is_spectator: + return "action-not-playing" + if self.current_player == p: + return None + return "action-not-playing" + + def _is_card_hidden(self, player: Player, *, action_id: str | None = None) -> Visibility: + return Visibility.VISIBLE + + def _is_turn_action_enabled(self, player: Player) -> str | None: + """Always enabled — these actions are only added when the mode is active.""" + return None + + def _is_keybind_only_hidden(self, player: Player) -> Visibility: + """Actions accessible via keybind; hide from turn menu.""" + return Visibility.HIDDEN + + def _is_turn_action_hidden(self, player: Player) -> Visibility: + """Always visible — these actions are only added when the mode is active.""" + return Visibility.VISIBLE + + # ========================================================================= + # Game flow + # ========================================================================= + + def on_start(self) -> None: + self.status = "playing" + self._sync_table_status() + self.game_active = True + self.round = 0 + self.tiebreaker_mode = False + self.tiebreaker_player_ids = [] + self.fixed_hands_remaining = 10 + + active = self._active_players() + sp = starting_phase(self.options.even_phases_only) + for p in active: + p.current_phase = sp + p.phase_laid_down = False + p.score = 0 + p.skipped = False + + self._team_manager.team_mode = "individual" + self._team_manager.setup_teams([p.name for p in active]) + + self.play_music(SOUND_MUSIC) + self.intro_wait_ticks = 7 * 20 + + def on_tick(self) -> None: + super().on_tick() + if not self.game_active: + return + + if self.intro_wait_ticks > 0: + self.intro_wait_ticks -= 1 + if self.intro_wait_ticks == 0: + self._start_new_hand() + return + + if self.next_round_wait_ticks > 0: + self.next_round_wait_ticks -= 1 + if self.next_round_wait_ticks == 0: + self._start_new_hand() + return + + if self.timer.tick(): + self._handle_turn_timeout() + self._maybe_play_timer_warning() + BotHelper.on_tick(self) + + def bot_think(self, player: Phase10Player) -> str | None: + return bot_think(self, player) + + def _start_new_hand(self) -> None: + self.round += 1 + self.table_groups = [] + self.skip_targets_this_round = [] + + # Deal + self.deck, _ = DeckFactory.phase10_deck() + self.discard_pile = [] + + if self.tiebreaker_mode: + active = [p for p in self._active_players() if p.id in self.tiebreaker_player_ids] + else: + active = self._active_players() + + for p in active: + p.hand = [] + p.phase_laid_down = False + p.skipped = False + + for _ in range(10): + for p in active: + card = self.deck.draw_one() + if card: + p.hand.append(card) + + # Rotate dealer + if self.turn_player_ids: + self.dealer_index = (self.dealer_index + 1) % len(self.turn_player_ids) + else: + # First hand: seat human players before bots so humans go first. + # Dealer is set to the last player so turn_index wraps to 0 (first human). + active = sorted(active, key=lambda p: p.is_bot) + self.dealer_index = len(active) - 1 + + self.set_turn_players(active, reset_index=False) + if self.turn_player_ids: + # First player is left of dealer + self.turn_index = (self.dealer_index + 1) % len(self.turn_player_ids) + + # Flip starting discard + start_card = self.deck.draw_one() + if start_card: + self.discard_pile.append(start_card) + + self.play_sound(random.choice(SOUND_SHUFFLE)) + self.schedule_sound(random.choice(SOUND_DRAW), 10) + + self.broadcast_l("phase10-new-hand", round=self.round) + + if start_card: + if is_skip(start_card): + first = self.current_player + first_name = first.name if first else "?" + self.broadcast_l("phase10-start-discard-skip", player=first_name) + # Auto-skip the first player + if first: + p10_first = self._get_p10_player(first) + if p10_first: + p10_first.skipped = True + else: + for p in self._active_players(): + user = self.get_user(p) + if user: + user.speak_l("phase10-start-discard", + card=p10_card_name(start_card, self._player_locale(p)), + buffer="table") + + self.play_sound(SOUND_ROUND_START) + self.rebuild_all_menus() + self._start_turn() + + def _start_turn(self) -> None: + player = self.current_player + p = self._get_p10_player(player) if player else None + if not p: + return + + # Reset turn flags + self.turn_has_drawn = False + self.lay_down_active = False + self.lay_down_staged = [] + self.lay_down_current = [] + self.hit_active = False + self.hit_card_id = None + self.hit_wild_group_idx = None + self.skip_discard_active = False + self.skip_pending_card_id = None + self.discard_pending_card_id = None + self._timer_warning_played = False + + # Handle skip + if p.skipped: + p.skipped = False + user = self.get_user(p) + if user: + user.speak_l("phase10-your-turn-skipped", buffer="table") + self._advance_turn() + return + + self._start_turn_timer() + self.broadcast_personal_l(p, "game-your-turn", "game-turn-start") + + if p.is_bot: + BotHelper.jolt_bot(p, ticks=random.randint(25, 40)) + + self.rebuild_player_menu(p, position=1) + # Update other players' menus so they can see fresh card counts + for other in self.players: + if other.id != p.id: + self.rebuild_player_menu(other) + + def _advance_turn(self) -> None: + old_index = self.turn_index + self.advance_turn(announce=False) + new_index = self.turn_index + # Detect wrap-around: when turn_index cycles back, a new round begins and + # skip targets reset (official rule: once per round = once around the table). + if (self.turn_direction >= 0 and new_index <= old_index) or \ + (self.turn_direction < 0 and new_index >= old_index): + self.skip_targets_this_round = [] + self._start_turn() + + def _ensure_deck(self) -> None: + """Reshuffle discard pile into deck if deck is empty.""" + if not self.deck.is_empty(): + return + if len(self.discard_pile) <= 1: + self.broadcast_l("phase10-deck-truly-empty") + self._end_round(None) + return + top = self.discard_pile[-1] + rest = self.discard_pile[:-1] + self.discard_pile = [top] + self.deck = Deck(cards=rest) + self.deck.shuffle() + self.broadcast_l("phase10-deck-reshuffled") + + # ========================================================================= + # Round end + # ========================================================================= + + def _end_round(self, winner: Phase10Player | None) -> None: # noqa: C901 + self.game_active = False # Pause ticking while computing state + + # Determine active players + if self.tiebreaker_mode: + active = [p for p in self._active_players() if p.id in self.tiebreaker_player_ids] + else: + active = self._active_players() + + # Announce who went out immediately — single clear message, rest follows + if winner: + self.broadcast_personal_l(winner, "phase10-you-go-out", "phase10-player-goes-out", + round=self.round) + self.play_sound(SOUND_WIN_ROUND) + + # Compute scores (state only, no announces yet) + round_penalties: dict[str, int] = {} + for p in active: + penalty = 0 if p is winner else score_hand(p.hand) + p.score += penalty + round_penalties[p.id] = penalty + + # Clear hands now that scoring is done + for p in active: + p.hand = [] + + # Advance phases (state only, no announces yet) + even_only = self.options.even_phases_only + phase_announces: list[tuple] = [] + for p in active: + if self.options.fixed_hands: + new_phase = next_phase(p.current_phase, even_only) + p.current_phase = min(new_phase, 11) + phase_announces.append((p, "phase10-you-fixed-hands-advance", "phase10-fixed-hands-advance", + {"next": p.current_phase})) + elif p.phase_laid_down: + new_phase = next_phase(p.current_phase, even_only) + p.current_phase = new_phase + phase_announces.append((p, "phase10-you-advance", "phase10-player-advances", + {"next": p.current_phase})) + else: + phase_announces.append((p, "phase10-you-stay", "phase10-player-stays", + {"phase": p.current_phase})) + + # Update TeamManager scores (negate so higher = better internally) + for p in active: + penalty = round_penalties.get(p.id, 0) + if penalty > 0: + self._team_manager.add_to_team_score(p.name, -penalty) + + # Check whether the game ends this round before showing results. + game_over = self._check_game_end(active) + + # Determine whether this is the final fixed-hands hand. + fixed_hands_over = False + if self.options.fixed_hands and not self.tiebreaker_mode: + self.fixed_hands_remaining -= 1 + if self.fixed_hands_remaining <= 0: + fixed_hands_over = True + + if not game_over: + self.game_active = True + + # Show each player a status box with their personalised round summary. + # Using a status box lets each player read at their own pace rather than + # racing against a fixed stagger timer. + for p in active: + user = self.get_user(p) + if not user: + continue + locale = user.locale + lines: list[str] = [Localization.get(locale, "phase10-round-scoring-header")] + + # Score lines: personal pronoun for self, third person for others. + for q in active: + if q is winner: + if q is p: + lines.append(Localization.get(locale, "phase10-you-score-zero")) + else: + lines.append(Localization.get(locale, "phase10-player-scores-zero", player=q.name)) + else: + if q is p: + lines.append(Localization.get(locale, "phase10-you-score", + points=round_penalties[q.id], total=q.score)) + else: + lines.append(Localization.get(locale, "phase10-player-scores", + player=q.name, points=round_penalties[q.id], total=q.score)) + + # Phase advancement lines. + for q, personal_id, others_id, kwargs in phase_announces: + if q is p: + lines.append(Localization.get(locale, personal_id, **kwargs)) + else: + lines.append(Localization.get(locale, others_id, player=q.name, **kwargs)) + + if fixed_hands_over: + lines.append(Localization.get(locale, "phase10-fixed-hands-over")) + + user.speak("; ".join(lines), buffer="table") + + if game_over: + return + + if fixed_hands_over: + # Small delay so the status box is visible before winner resolution fires. + self.schedule_event("resolve_winner", {}, delay_ticks=20) + self.next_round_wait_ticks = 40 + return + + self.next_round_wait_ticks = 2 * 20 # 2 seconds before next round + + def _check_game_end(self, active: list[Phase10Player]) -> bool: # noqa: C901 + """Check whether any player has completed the target phase. Returns True if game ended.""" + winning_phase = self.options.winning_phase + even_only = self.options.even_phases_only + + # Clamp winning_phase to what's actually in play + valid_phases = active_phases(even_only) + if winning_phase not in valid_phases: + winning_phase = valid_phases[-1] + + # Players who completed the winning phase this round + # (current_phase has already been advanced, so check if > winning_phase OR == 11) + completers = [p for p in active if p.current_phase > winning_phase] + + if not completers: + return False + + if len(completers) == 1: + winner = completers[0] + self._declare_winner(winner, active) + return True + + # Multiple completers: lowest score wins + min_score = min(p.score for p in completers) + top = [p for p in completers if p.score == min_score] + + if len(top) == 1: + self._declare_winner(top[0], active) + return True + + # Genuine tie: tiebreaker round + tied_names = Localization.format_list_and("en", [p.name for p in top]) + self.broadcast_l("phase10-tiebreaker", players=tied_names, phase=winning_phase) + for p in top: + user = self.get_user(p) + if user: + user.speak_l("phase10-tiebreaker-you", phase=winning_phase, buffer="table") + + # Reset to the winning phase for tied players + for p in top: + p.current_phase = winning_phase + p.phase_laid_down = False + + self.tiebreaker_mode = True + self.tiebreaker_player_ids = [p.id for p in top] + self.game_active = True + self.next_round_wait_ticks = 4 * 20 + return True + + def on_game_event(self, event_type: str, data: dict) -> None: + if event_type == "resolve_winner": + active = self._active_players() + self._resolve_winner(active) + + def _resolve_winner(self, active: list[Phase10Player]) -> None: + """Resolve winner for fixed-hands mode (lowest score wins). Ties enter a tiebreaker hand.""" + min_score = min(p.score for p in active) + winners = [p for p in active if p.score == min_score] + if len(winners) == 1: + self._declare_winner(winners[0], active) + return + # Genuine score tie: enter a tiebreaker hand using the same infrastructure + # as _check_game_end. The fixed_hands countdown is suspended while tiebreaker_mode + # is active (see _end_round) so this cannot loop with _resolve_winner. + winning_phase = max(p.current_phase for p in winners) + tied_names = Localization.format_list_and("en", [p.name for p in winners]) + self.broadcast_l("phase10-tiebreaker", players=tied_names, phase=winning_phase) + for p in winners: + user = self.get_user(p) + if user: + user.speak_l("phase10-tiebreaker-you", phase=winning_phase, buffer="table") + p.current_phase = winning_phase + p.phase_laid_down = False + self.tiebreaker_mode = True + self.tiebreaker_player_ids = [p.id for p in winners] + self.game_active = True + self.next_round_wait_ticks = 4 * 20 + + def _declare_winner(self, winner: Phase10Player, active: list[Phase10Player]) -> None: + self.play_sound(SOUND_WIN_GAME) + self.broadcast_personal_l(winner, "phase10-you-win", "phase10-game-winner", + score=winner.score) + self.game_winner_id = winner.id + self.finish_game() + + def build_game_result(self) -> GameResult: + active = self._active_players() + winner = next((p for p in active if p.id == self.game_winner_id), None) + final_scores = { + p.name: {"phase": p.current_phase, "score": p.score} + for p in active + } + return GameResult( + game_type=self.get_type(), + timestamp=datetime.now().isoformat(), + duration_ticks=self.sound_scheduler_tick, + player_results=[ + PlayerResult( + player_id=p.id, + player_name=p.name, + is_bot=p.is_bot, + is_virtual_bot=p.is_virtual_bot, + ) + for p in active + ], + custom_data={ + "winner_name": winner.name if winner else None, + "final_scores": final_scores, + }, + ) + + def format_end_screen(self, result: GameResult, locale: str) -> list[str]: + lines = [Localization.get(locale, "phase10-score-header")] + final_scores: dict = result.custom_data.get("final_scores", {}) + # Sort by score ascending (lower is better) + sorted_entries = sorted(final_scores.items(), key=lambda kv: kv[1]["score"]) + for player_name, data in sorted_entries: + lines.append(Localization.get( + locale, "phase10-score-entry", + player=player_name, + phase=data["phase"], + score=data["score"], + )) + return lines + + # ========================================================================= + # Draw actions + # ========================================================================= + + def _action_draw_deck(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p: + return + if self.turn_has_drawn: + return + self._ensure_deck() + if not self.game_active: + return + card = self.deck.draw_one() + if not card: + return + p.hand.append(card) + self.turn_has_drawn = True + locale = self._player_locale(p) + card_name = p10_card_name(card, locale) + self.play_sound(random.choice(SOUND_DRAW)) + self.broadcast_personal_l(p, "phase10-you-draw-deck", "phase10-player-draws-deck", + card=card_name) + self._start_turn_timer() + if p.is_bot: + BotHelper.jolt_bot(p, ticks=random.randint(15, 25)) + selection_id = self._card_action_id(p, card) + self.update_player_menu(p, selection_id=selection_id) + for other in self.players: + if other.id != p.id: + self.rebuild_player_menu(other) + + def _action_draw_discard(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p: + return + if self.turn_has_drawn: + return + if not self.discard_pile: + return + top = self.discard_pile[-1] + if is_skip(top): + user = self.get_user(p) + if user: + user.speak_l("phase10-cannot-draw-skip") + return + card = self.discard_pile.pop() + p.hand.append(card) + self.turn_has_drawn = True + locale = self._player_locale(p) + card_name = p10_card_name(card, locale) + self.play_sound(random.choice(SOUND_DRAW)) + self.broadcast_personal_l(p, "phase10-you-draw-discard", "phase10-player-draws-discard", + card=card_name) + self._start_turn_timer() + if p.is_bot: + BotHelper.jolt_bot(p, ticks=random.randint(15, 25)) + selection_id = self._card_action_id(p, card) + self.update_player_menu(p, selection_id=selection_id) + for other in self.players: + if other.id != p.id: + self.rebuild_player_menu(other) + + # ========================================================================= + # Card selection (dispatches based on current mode) + # ========================================================================= + + def _action_card_selected(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p: + return + + index = int(action_id.split("_")[-1]) - 1 + sorted_hand = self._sorted_hand_for_menu(p) + if index < 0 or index >= len(sorted_hand): + return + card = sorted_hand[index] + + if not self.turn_has_drawn and not self.lay_down_active and not self.hit_active: + user = self.get_user(p) + if user: + user.speak_l("phase10-must-draw-first") + return + + if self.lay_down_active: + self._toggle_card_in_group(p, card) + elif self.hit_active and self.hit_card_id is None: + self._select_hit_card(p, card) + elif self.hit_active and self.hit_card_id is not None: + # Already chose a hit card; re-selecting switches to a different card + self._select_hit_card(p, card) + elif self.skip_discard_active: + pass # Ignore; player should pick a skip target + else: + user = self.get_user(p) + if not p.phase_laid_down: + # Can't hit until own phase is laid down — say so, don't mark for discard. + if user: + user.speak_l("phase10-hit-no-phase", buffer="table") + self.update_player_menu(p, selection_id=self._card_action_id(p, card)) + elif not self.table_groups: + if user: + user.speak_l("phase10-hit-no-groups", buffer="table") + self.update_player_menu(p, selection_id=self._card_action_id(p, card)) + else: + # Phase is laid down and groups exist — always enter hit mode with this + # card so the player can choose a group (even if no group accepts it). + self.discard_pending_card_id = card.id + self.hit_active = True + self.hit_card_id = card.id + locale = self._player_locale(p) + if user: + user.speak_l("phase10-hit-choose-group", + card=p10_card_name(card, locale), buffer="table") + self.rebuild_player_menu(p, position=1) + if p.is_bot: + BotHelper.jolt_bot(p, ticks=random.randint(5, 10)) + + # ========================================================================= + # Lay-down mode + # ========================================================================= + + def _action_start_lay_down(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p: + return + if not self.turn_has_drawn: + user = self.get_user(p) + if user: + user.speak_l("phase10-must-draw-first") + return + if p.phase_laid_down: + user = self.get_user(p) + if user: + user.speak_l("phase10-already-laid-down") + return + + self.lay_down_active = True + self.lay_down_group_index = 0 + self.lay_down_staged = [] + self.lay_down_current = [] + + reqs = self._current_phase_reqs(p) + locale = self._player_locale(p) + desc = phase_description(p.current_phase, locale) + req = req_description(reqs[0], locale) + user = self.get_user(p) + if user: + user.speak_l( + "phase10-lay-down-start", + phase=p.current_phase, description=desc, + current=1, total=len(reqs), req=req, + buffer="table", + ) + self.rebuild_player_menu(p, position=1) + + def _toggle_card_in_group(self, p: Phase10Player, card: Card) -> None: + locale = self._player_locale(p) + card_name = p10_card_name(card, locale) + reqs = self._current_phase_reqs(p) + current_num = self.lay_down_group_index + 1 + + # Build current selection label (after toggle) + staged_ids = {cid for group in self.lay_down_staged for cid in group} + if card.id in self.lay_down_current: + self.lay_down_current.remove(card.id) + msg_key = "phase10-lay-down-remove" + elif card.id in staged_ids: + user = self.get_user(p) + if user: + user.speak_l("phase10-lay-down-card-already-staged", card=card_name) + self.update_player_menu(p, selection_id=self._card_action_id(p, card)) + return + else: + self.lay_down_current.append(card.id) + msg_key = "phase10-lay-down-add" + + # Build current cards string + selected_cards = [c for c in p.hand if c.id in self.lay_down_current] + cards_str = p10_cards_name(selected_cards, locale) if selected_cards else "" + user = self.get_user(p) + if user: + if selected_cards: + user.speak_l(msg_key, card=card_name, current=current_num, + cards=cards_str, buffer="table") + else: + user.speak_l("phase10-lay-down-selection-empty", current=current_num, buffer="table") + + self.update_player_menu(p, selection_id=self._card_action_id(p, card)) + + def _action_confirm_lay_down_group(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p or not self.lay_down_active: + return + + reqs = self._current_phase_reqs(p) + req = reqs[self.lay_down_group_index] + locale = self._player_locale(p) + user = self.get_user(p) + + # Gather selected cards + selected = [c for c in p.hand if c.id in self.lay_down_current] + + # Also include already-staged cards (must not overlap) + staged_ids: set[int] = set() + for group_ids in self.lay_down_staged: + staged_ids.update(group_ids) + + # Validate + ok, err_key = validate_group(selected, req) + if not ok: + self.lay_down_current = [] + if user: + if err_key == "phase10-err-need-cards": + user.speak_l(err_key, count=req.count, buffer="table") + else: + user.speak_l(err_key, buffer="table") + self.rebuild_player_menu(p, position=1) + return + + # Confirm this group + self.lay_down_staged.append(list(self.lay_down_current)) + current_num = self.lay_down_group_index + 1 + cards_str = p10_cards_name(selected, locale) + if user: + user.speak_l("phase10-lay-down-confirmed-group", current=current_num, cards=cards_str, buffer="table") + + self.lay_down_group_index += 1 + self.lay_down_current = [] + + if self.lay_down_group_index >= len(reqs): + # All groups confirmed — commit the phase + self._commit_lay_down(p) + else: + # Prompt for next group + next_req = reqs[self.lay_down_group_index] + req_str = req_description(next_req, locale) + next_num = self.lay_down_group_index + 1 + if user: + user.speak_l( + "phase10-lay-down-next-group", + prev=current_num, current=next_num, + total=len(reqs), req=req_str, + buffer="table", + ) + self.rebuild_player_menu(p, position=1) + + def _commit_lay_down(self, p: Phase10Player) -> None: + """Remove staged cards from hand and add groups to the table.""" + all_staged_ids: set[int] = set() + for group_ids in self.lay_down_staged: + all_staged_ids.update(group_ids) + + reqs = self._current_phase_reqs(p) + # Count existing groups for this player (for index offset) + existing = sum(1 for g in self.table_groups if g.owner_id == p.id) + new_groups: list[TableGroup] = [] + + for group_idx, group_ids in enumerate(self.lay_down_staged): + cards = [c for c in p.hand if c.id in group_ids] + req = reqs[group_idx] + tg = TableGroup( + owner_id=p.id, + group_index=existing + group_idx, + requirement=req, + cards=cards, + ) + self.table_groups.append(tg) + new_groups.append(tg) + + # Remove cards from hand + p.hand = [c for c in p.hand if c.id not in all_staged_ids] + p.phase_laid_down = True + + self.lay_down_active = False + self.lay_down_staged = [] + self.lay_down_current = [] + self.lay_down_group_index = 0 + + locale = self._player_locale(p) + desc = phase_description(p.current_phase, locale) + + # Build per-group card detail strings for the announcement + group_detail_parts: list[str] = [] + for tg in new_groups: + group_detail_parts.append(self._format_group_summary(tg.cards, tg.requirement, locale)) + details = Localization.format_list_and(locale, group_detail_parts) + + self.play_sound(random.choice(SOUND_LAY_DOWN)) + self.broadcast_personal_l( + p, + "phase10-lay-down-success", + "phase10-player-lays-down", + phase=p.current_phase, + description=desc, + details=details, + ) + + if len(p.hand) == 0: + self._end_round(p) + return + + self.rebuild_player_menu(p, position=1) + for other in self.players: + if other.id != p.id: + self.rebuild_player_menu(other) + + def _action_cancel_lay_down(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p or not self.lay_down_active: + return + self.lay_down_active = False + self.lay_down_staged = [] + self.lay_down_current = [] + self.lay_down_group_index = 0 + locale = self._player_locale(p) + user = self.get_user(p) + if user: + user.speak_l("phase10-lay-down-cancel", buffer="table") + self.rebuild_player_menu(p, position=1) + + def _action_check_requirement(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p or not self.lay_down_active: + return + reqs = self._current_phase_reqs(p) + if self.lay_down_group_index >= len(reqs): + return + req = reqs[self.lay_down_group_index] + locale = self._player_locale(p) + user = self.get_user(p) + if user: + user.speak_l("phase10-check-req-result", req=req_description(req, locale)) + + # ========================================================================= + # Hit mode + # ========================================================================= + + def _action_start_hit(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p: + return + if not self.turn_has_drawn: + user = self.get_user(p) + if user: + user.speak_l("phase10-must-draw-first") + return + if not p.phase_laid_down: + user = self.get_user(p) + if user: + user.speak_l("phase10-hit-no-phase") + return + if not self.table_groups: + user = self.get_user(p) + if user: + user.speak_l("phase10-hit-no-groups") + return + + self.hit_active = True + self.hit_card_id = None + user = self.get_user(p) + if user: + user.speak_l("phase10-hit-mode-start", buffer="table") + self.rebuild_player_menu(p, position=1) + + def _select_hit_card(self, p: Phase10Player, card: Card) -> None: + self.hit_card_id = card.id + locale = self._player_locale(p) + card_name = p10_card_name(card, locale) + user = self.get_user(p) + if user: + lines: list[str] = [Localization.get(locale, "phase10-hit-choose-group", card=card_name)] + for group in self.table_groups: + owner = self.get_player_by_id(group.owner_id) + owner_name = owner.name if owner else "?" + cards_str = self._format_group_summary(group.cards, group.requirement, locale) + lines.append(Localization.get(locale, "phase10-table-group-entry", + owner=owner_name, cards=cards_str)) + user.speak("; ".join(lines)) + self.rebuild_player_menu(p, position=1) + + def _action_select_hit_group(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p or not self.hit_active or self.hit_card_id is None: + return + + group_idx = int(action_id.split("_")[-1]) + if group_idx >= len(self.table_groups): + return + + card = next((c for c in p.hand if c.id == self.hit_card_id), None) + if not card: + self.hit_card_id = None + self.hit_wild_group_idx = None + return + + group = self.table_groups[group_idx] + ok, reason_key = can_hit_group(group, card) + locale = self._player_locale(p) + user = self.get_user(p) + + if not ok: + reason = Localization.get(locale, reason_key) + if user: + user.speak_l("phase10-hit-invalid", card=p10_card_name(card, locale), + reason=reason, buffer="table") + self.hit_active = False + self.hit_card_id = None + self.hit_wild_group_idx = None + self.rebuild_player_menu(p, position=1) + return + + # Wild hitting a run: ask player which end to extend (if both ends open) + if is_wild(card) and group.requirement.kind == GROUP_RUN: + ordered = resolve_run_order(group.cards) + low, high = ordered[0][1], ordered[-1][1] + can_go_low = low > 1 + can_go_high = high < 12 + if can_go_low and can_go_high: + self.hit_wild_group_idx = group_idx + if user: + user.speak_l("phase10-hit-wild-choose", buffer="table") + self.rebuild_player_menu(p, position=1) + return + # Only one end available — place automatically + # (can_hit_group already blocks the all-full case) + + self._apply_hit(p, card, group_idx) + + def _apply_hit(self, p: Phase10Player, card: Card, group_idx: int) -> None: + """Apply a hit and announce it.""" + group = self.table_groups[group_idx] + locale = self._player_locale(p) + group.cards.append(card) + p.hand.remove(card) + owner = self.get_player_by_id(group.owner_id) + owner_name = owner.name if owner else "?" + self.play_sound(random.choice(SOUND_DISCARD)) + self.broadcast_personal_l(p, "phase10-hit-success", "phase10-player-hits", + target=owner_name, + card=p10_card_name(card, locale)) + + self.hit_active = False + self.hit_card_id = None + self.hit_wild_group_idx = None + self.discard_pending_card_id = None + + if len(p.hand) == 0: + self._end_round(p) + return + + self.rebuild_player_menu(p, position=1) + for other in self.players: + if other.id != p.id: + self.rebuild_player_menu(other) + + def _action_place_wild(self, player: Player, action_id: str) -> None: + """Place a wild on the low or high end of a run.""" + p = self._require_current_player(player) + if not p or self.hit_wild_group_idx is None or self.hit_card_id is None: + return + card = next((c for c in p.hand if c.id == self.hit_card_id), None) + if not card: + self.hit_wild_group_idx = None + self.hit_card_id = None + return + self._apply_hit(p, card, self.hit_wild_group_idx) + + def _action_cancel_hit(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p: + return + self.hit_active = False + self.hit_card_id = None + self.hit_wild_group_idx = None + user = self.get_user(p) + if user: + user.speak_l("phase10-hit-cancelled", buffer="table") + self.rebuild_player_menu(p, position=1) + + # ========================================================================= + # Discard (Delete key) + # ========================================================================= + + def _action_do_discard(self, player: Player, action_id: str) -> None: + """Discard the pending card, or the currently focused hand card if none is pending.""" + p = self._require_current_player(player) + if not p: + return + if not self.turn_has_drawn: + user = self.get_user(p) + if user: + user.speak_l("phase10-must-draw-first") + return + card = None + if self.discard_pending_card_id is not None: + card = next((c for c in p.hand if c.id == self.discard_pending_card_id), None) + self.discard_pending_card_id = None + else: + # Fall back to whichever card is focused in the menu right now. + context = self.get_action_context(player) + mid = context.menu_item_id if context else None + if mid and mid.startswith("card_"): + try: + idx = int(mid.split("_", 1)[1]) - 1 + sorted_hand = self._sorted_hand_for_menu(p) + if 0 <= idx < len(sorted_hand): + card = sorted_hand[idx] + except (ValueError, IndexError): + pass + if card is None: + user = self.get_user(p) + if user: + user.speak_l("phase10-no-card-selected") + return + # Cancel any in-progress hit mode so the discard goes through cleanly. + self.hit_active = False + self.hit_card_id = None + self.hit_wild_group_idx = None + if is_skip(card): + self._start_skip_discard(p, card) + else: + self._do_discard(p, card) + + # ========================================================================= + # Discard + # ========================================================================= + + def _do_discard(self, p: Phase10Player, card: Card) -> None: + """Discard a non-Skip card and end the turn.""" + p.hand.remove(card) + self.discard_pile.append(card) + locale = self._player_locale(p) + self.play_sound(random.choice(SOUND_DISCARD)) + self.broadcast_personal_l(p, "phase10-you-discard", "phase10-player-discards", + card=p10_card_name(card, locale)) + + if len(p.hand) == 0: + self._end_round(p) + return + + self._advance_turn() + + # ========================================================================= + # Skip discard + # ========================================================================= + + def _start_skip_discard(self, p: Phase10Player, card: Card) -> None: + # If all other players have already been skipped this hand, the skip + # card cannot be used — discard it normally instead of entering the + # target-selection flow (which would loop forever for bots). + eligible = [ + other for other in self._active_players() + if other.id != p.id and other.id not in self.skip_targets_this_round + ] + if not eligible: + self._do_discard(p, card) + return + + self.skip_discard_active = True + self.skip_pending_card_id = card.id + user = self.get_user(p) + if user: + user.speak_l("phase10-skip-choose-target", buffer="table") + self.rebuild_player_menu(p, position=1) + + def _action_select_skip_target(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p or not self.skip_discard_active: + return + + target_id = action_id.split("_", 2)[-1] # "skip_target_{player_id}" + target = self._get_p10_player(self.get_player_by_id(target_id)) + if not target or target.id == p.id: + user = self.get_user(p) + if user: + user.speak_l("phase10-skip-self") + return + + if target.id in self.skip_targets_this_round: + user = self.get_user(p) + if user: + user.speak_l("phase10-skip-already-used", player=target.name) + return + + # Retrieve the skip card + skip_card = next((c for c in p.hand if c.id == self.skip_pending_card_id), None) + if not skip_card: + self.skip_discard_active = False + self.skip_pending_card_id = None + return + + # Apply + target.skipped = True + self.skip_targets_this_round.append(target.id) + p.hand.remove(skip_card) + self.discard_pile.append(skip_card) + + locale = self._player_locale(p) + self.play_sound(random.choice(SOUND_DISCARD)) + self.broadcast_personal_l(p, "phase10-skip-played", "phase10-player-skips", + target=target.name) + + # Inform the target + target_user = self.get_user(target) + if target_user: + target_user.speak_l("phase10-you-are-skipped", skipping_player=p.name, buffer="table") + + self.skip_discard_active = False + self.skip_pending_card_id = None + + if len(p.hand) == 0: + self._end_round(p) + return + + self._advance_turn() + + def _action_cancel_skip(self, player: Player, action_id: str) -> None: + p = self._require_current_player(player) + if not p or not self.skip_discard_active: + return + self.skip_discard_active = False + self.skip_pending_card_id = None + user = self.get_user(p) + if user: + user.speak_l("phase10-skip-cancelled", buffer="table") + self.rebuild_player_menu(p, position=1) + + # ========================================================================= + # Info / status actions + # ========================================================================= + + def _action_whose_turn(self, player: Player, action_id: str) -> None: + self._suppress_keybind_rebuild(player) + super()._action_whose_turn(player, action_id) + + def _action_read_hand(self, player: Player, action_id: str) -> None: + p = self._get_p10_player(player) + if not p or p.is_spectator: + return + user = self.get_user(p) + if not user: + return + self._suppress_keybind_rebuild(player) + locale = user.locale + cards_str = p10_cards_name(self._sorted_hand_for_menu(p), locale) + user.speak_l("phase10-hand-contents", count=len(p.hand), cards=cards_str) + + def _action_read_discard(self, player: Player, action_id: str) -> None: + user = self.get_user(player) + if not user: + return + self._suppress_keybind_rebuild(player) + locale = user.locale + if not self.discard_pile: + user.speak_l("phase10-no-discard") + else: + card_name = p10_card_name(self.discard_pile[-1], locale) + user.speak_l("phase10-top-discard", card=card_name) + + def _action_read_table(self, player: Player, action_id: str) -> None: + user = self.get_user(player) + if not user: + return + locale = user.locale + if not self.table_groups: + user.speak_l("phase10-no-table-groups") + self._suppress_keybind_rebuild(player) + return + lines: list[str] = [Localization.get(locale, "phase10-table-group-header")] + for group in self.table_groups: + owner = self.get_player_by_id(group.owner_id) + owner_name = owner.name if owner else "?" + cards_str = self._format_group_summary(group.cards, group.requirement, locale) + lines.append(Localization.get( + locale, "phase10-table-group-entry", + owner=owner_name, + cards=cards_str, + )) + self._suppress_keybind_rebuild(player) + self.status_box(player, lines) + + def _action_check_phase(self, player: Player, action_id: str) -> None: + p = self._get_p10_player(player) + if not p or p.is_spectator: + return + user = self.get_user(p) + if not user: + return + self._suppress_keybind_rebuild(player) + locale = user.locale + if p.phase_laid_down: + user.speak_l("phase10-your-phase-laid-down", phase=p.current_phase) + else: + desc = phase_description(p.current_phase, locale) + user.speak_l("phase10-your-phase", phase=p.current_phase, description=desc) + + def _action_read_counts(self, player: Player, action_id: str) -> None: + user = self.get_user(player) + if not user: + return + self._suppress_keybind_rebuild(player) + locale = user.locale + lines: list[str] = [] + for p in self._active_players(): + lines.append(Localization.get(locale, "phase10-player-hand-count", + player=p.name, count=len(p.hand))) + lines.append(Localization.get(locale, "phase10-deck-count", count=len(self.deck.cards))) + user.speak("; ".join(lines)) + + def _action_check_turn_timer(self, player: Player, action_id: str) -> None: + user = self.get_user(player) + if not user: + return + self._suppress_keybind_rebuild(player) + remaining = self.timer.seconds_remaining() + if remaining > 0: + user.speak_l("poker-timer-remaining", seconds=remaining) + else: + user.speak_l("poker-timer-unlimited") + + def _action_sort_by_color(self, player: Player, action_id: str) -> None: + p = self._get_p10_player(player) + user = self.get_user(player) + if not p or not user: + return + p.hand_sort = "color" + user.speak_l("phase10-sorted-by-color") + self.rebuild_player_menu(player) + + def _action_sort_by_number(self, player: Player, action_id: str) -> None: + p = self._get_p10_player(player) + user = self.get_user(player) + if not p or not user: + return + if p.hand_sort == "number_asc": + p.hand_sort = "number_desc" + user.speak_l("phase10-sorted-by-number-desc") + else: + p.hand_sort = "number_asc" + user.speak_l("phase10-sorted-by-number-asc") + self.rebuild_player_menu(player) + + # ========================================================================= + # Score display overrides + # ========================================================================= + + def _build_score_lines(self, locale: str) -> list[str]: + lines = [Localization.get(locale, "phase10-score-header")] + for p in sorted(self._active_players(), key=lambda x: x.score): + lines.append(Localization.get( + locale, "phase10-score-entry", + player=p.name, phase=p.current_phase, score=p.score, + )) + return lines + + def _action_check_scores(self, player: Player, action_id: str) -> None: + user = self.get_user(player) + if not user: + return + user.speak("; ".join(self._build_score_lines(user.locale))) + + def _action_check_scores_detailed(self, player: Player, action_id: str) -> None: + user = self.get_user(player) + if not user: + return + self.status_box(player, self._build_score_lines(user.locale)) + + def _is_check_scores_enabled(self, player: Player) -> str | None: + if self.status != "playing": + return "action-not-playing" + return None + + def _is_check_scores_hidden(self, player: Player) -> Visibility: + return self._is_playing_hidden(player) + + def _is_check_scores_detailed_enabled(self, player: Player) -> str | None: + if self.status != "playing": + return "action-not-playing" + return None + + def _is_check_scores_detailed_hidden(self, player: Player) -> Visibility: + return self._is_playing_hidden(player) + + # ========================================================================= + # Timer + # ========================================================================= + + def _timer_seconds(self) -> int: + try: + return int(self.options.turn_timer) + except ValueError: + return 0 + + def _start_turn_timer(self) -> None: + seconds = self._timer_seconds() + if seconds <= 0: + self.timer.clear() + return + self.timer.start(seconds) + self._timer_warning_played = False + + def _maybe_play_timer_warning(self) -> None: + if self._timer_seconds() < 20 or self._timer_warning_played: + return + if self.timer.seconds_remaining() == 5: + self._timer_warning_played = True + self.play_sound("game_crazyeights/fivesec.ogg") + + def _handle_turn_timeout(self) -> None: + player = self.current_player + p = self._get_p10_player(player) if player else None + if not p: + return + action_id = self.bot_think(p) + if action_id: + self.execute_action(p, action_id) + + # ========================================================================= + # Phase helper + # ========================================================================= + + def _current_phase_reqs(self, p: Phase10Player) -> list[PhaseRequirement]: + """Return the requirement list for the player's current phase.""" + idx = p.current_phase - 1 + if 0 <= idx < len(PHASES): + return PHASES[idx] + return [] diff --git a/server/games/phase10/state.py b/server/games/phase10/state.py new file mode 100644 index 00000000..762258c8 --- /dev/null +++ b/server/games/phase10/state.py @@ -0,0 +1,185 @@ +"""Phase 10 state dataclasses, phase definitions, and constants.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from mashumaro.mixins.json import DataClassJSONMixin + +from ..base import Player +from ...game_utils.cards import Card +from ...game_utils.options import ( + GameOptions, + IntOption, + BoolOption, + MenuOption, + option_field, +) + +# --------------------------------------------------------------------------- +# Card rank / suit constants +# --------------------------------------------------------------------------- + +P10_RANK_WILD = 13 +P10_RANK_SKIP = 14 + +P10_COLOR_RED = 1 +P10_COLOR_BLUE = 2 +P10_COLOR_GREEN = 3 +P10_COLOR_YELLOW = 4 + +P10_COLOR_NAMES = { + P10_COLOR_RED: "phase10-color-red", + P10_COLOR_BLUE: "phase10-color-blue", + P10_COLOR_GREEN: "phase10-color-green", + P10_COLOR_YELLOW: "phase10-color-yellow", +} + +# --------------------------------------------------------------------------- +# Group kind constants +# --------------------------------------------------------------------------- + +GROUP_SET = "set" +GROUP_RUN = "run" +GROUP_COLOR = "color" + +# --------------------------------------------------------------------------- +# Phase requirement / phase table +# --------------------------------------------------------------------------- + + +@dataclass +class PhaseRequirement(DataClassJSONMixin): + """One group requirement within a phase (e.g. 'set of 3' or 'run of 4').""" + + kind: str # GROUP_SET | GROUP_RUN | GROUP_COLOR + count: int # minimum card count + + +# 10 phases in order. Index 0 = Phase 1 … Index 9 = Phase 10. +PHASES: list[list[PhaseRequirement]] = [ + [PhaseRequirement(GROUP_SET, 3), PhaseRequirement(GROUP_SET, 3)], # 1 + [PhaseRequirement(GROUP_SET, 3), PhaseRequirement(GROUP_RUN, 4)], # 2 + [PhaseRequirement(GROUP_SET, 4), PhaseRequirement(GROUP_RUN, 4)], # 3 + [PhaseRequirement(GROUP_RUN, 7)], # 4 + [PhaseRequirement(GROUP_RUN, 8)], # 5 + [PhaseRequirement(GROUP_RUN, 9)], # 6 + [PhaseRequirement(GROUP_SET, 4), PhaseRequirement(GROUP_SET, 4)], # 7 + [PhaseRequirement(GROUP_COLOR, 7)], # 8 + [PhaseRequirement(GROUP_SET, 5), PhaseRequirement(GROUP_SET, 2)], # 9 + [PhaseRequirement(GROUP_SET, 4), PhaseRequirement(GROUP_SET, 3)], # 10 +] + +# FTL keys for phase descriptions (indexed 1-10) +PHASE_DESC_KEYS: dict[int, str] = {i: f"phase10-phase-desc-{i}" for i in range(1, 11)} + +# Even phases variant uses only these phase numbers +EVEN_PHASES = [2, 4, 6, 8, 10] + +# --------------------------------------------------------------------------- +# Table group (a laid-down phase group on the table) +# --------------------------------------------------------------------------- + + +@dataclass +class TableGroup(DataClassJSONMixin): + """A single group laid down on the table by a player. + + Attributes: + owner_id: Player ID of the owner. + group_index: 0-based index of this group among the owner's groups. + requirement: The phase requirement this group satisfies. + cards: Cards currently in this group (grows as hits are applied). + """ + + owner_id: str + group_index: int + requirement: PhaseRequirement + cards: list[Card] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Player +# --------------------------------------------------------------------------- + + +@dataclass +class Phase10Player(Player): + """Per-player state for Phase 10. + + Attributes: + hand: Cards currently in hand. + current_phase: The phase number this player is working on (1-10). + phase_laid_down: True once the player has laid their phase down this hand. + score: Accumulated penalty points across all hands. + skipped: True if a Skip card has been played against this player and + their next turn should be forfeited. + """ + + hand: list[Card] = field(default_factory=list) + current_phase: int = 1 + phase_laid_down: bool = False + score: int = 0 + skipped: bool = False + hand_sort: str = "default" # "default" | "color" | "number_asc" | "number_desc" + + +# --------------------------------------------------------------------------- +# Options +# --------------------------------------------------------------------------- + +TURN_TIMER_CHOICES = ["5", "10", "15", "20", "30", "45", "60", "90", "0"] +TURN_TIMER_LABELS = { + "5": "phase10-timer-5", + "10": "phase10-timer-10", + "15": "phase10-timer-15", + "20": "phase10-timer-20", + "30": "phase10-timer-30", + "45": "phase10-timer-45", + "60": "phase10-timer-60", + "90": "phase10-timer-90", + "0": "phase10-timer-unlimited", +} + + +@dataclass +class Phase10Options(GameOptions): + """Configurable options for Phase 10.""" + + winning_phase: int = option_field( + IntOption( + default=10, + min_val=1, + max_val=10, + value_key="phase", + label="phase10-set-winning-phase", + prompt="phase10-enter-winning-phase", + change_msg="phase10-option-changed-winning-phase", + ) + ) + turn_timer: str = option_field( + MenuOption( + choices=TURN_TIMER_CHOICES, + default="0", + label="phase10-set-turn-timer", + prompt="phase10-select-turn-timer", + change_msg="phase10-option-changed-turn-timer", + choice_labels=TURN_TIMER_LABELS, + ) + ) + even_phases_only: bool = option_field( + BoolOption( + default=False, + value_key="enabled", + label="phase10-toggle-even-phases", + change_msg="phase10-option-changed-even-phases", + ) + ) + fixed_hands: bool = option_field( + BoolOption( + default=False, + value_key="enabled", + label="phase10-toggle-fixed-hands", + change_msg="phase10-option-changed-fixed-hands", + ) + ) diff --git a/server/locales/en/phase10.ftl b/server/locales/en/phase10.ftl new file mode 100644 index 00000000..551ee56e --- /dev/null +++ b/server/locales/en/phase10.ftl @@ -0,0 +1,239 @@ +# Phase 10 + +game-name-phase10 = Phase 10 + +# Card colors +phase10-color-red = Red +phase10-color-blue = Blue +phase10-color-green = Green +phase10-color-yellow = Yellow + +# Card names +phase10-card-numbered = { $number } { $color } +phase10-card-wild = Wild +phase10-card-skip = Skip +phase10-card-label-selected = { $card } (selected) +phase10-card-label-staged = { $card } (staged) +phase10-lay-down-card-already-staged = { $card } is already staged in a previous group. + +# Phase requirement short descriptions (used in prompts) +phase10-req-set = set of { $count } +phase10-req-run = run of { $count } +phase10-req-color = { $count } of one color + +# Phase long descriptions +phase10-phase-desc-1 = 2 sets of 3 +phase10-phase-desc-2 = 1 set of 3 and 1 run of 4 +phase10-phase-desc-3 = 1 set of 4 and 1 run of 4 +phase10-phase-desc-4 = 1 run of 7 +phase10-phase-desc-5 = 1 run of 8 +phase10-phase-desc-6 = 1 run of 9 +phase10-phase-desc-7 = 2 sets of 4 +phase10-phase-desc-8 = 7 cards of one color +phase10-phase-desc-9 = 1 set of 5 and 1 set of 2 +phase10-phase-desc-10 = 1 set of 4 and 1 set of 3 + +# Options +phase10-set-winning-phase = Winning phase: { $phase } +phase10-enter-winning-phase = Enter the phase number to reach in order to win (1-10) +phase10-option-changed-winning-phase = Winning phase set to { $phase }. + +phase10-set-turn-timer = Turn timer: { $mode } +phase10-select-turn-timer = Select turn timer +phase10-option-changed-turn-timer = Turn timer set to { $mode }. +phase10-timer-5 = 5 seconds +phase10-timer-10 = 10 seconds +phase10-timer-15 = 15 seconds +phase10-timer-20 = 20 seconds +phase10-timer-30 = 30 seconds +phase10-timer-45 = 45 seconds +phase10-timer-60 = 1 minute +phase10-timer-90 = 90 seconds +phase10-timer-unlimited = No limit + +phase10-toggle-even-phases = Even phases only: { $enabled } +phase10-option-changed-even-phases = Even phases only: { $enabled }. + +phase10-toggle-fixed-hands = Fixed hands mode: { $enabled } +phase10-option-changed-fixed-hands = Fixed hands mode: { $enabled }. + +# Game setup +phase10-new-hand = Hand { $round }. Each player is dealt 10 cards. +phase10-start-discard = { $card } starts the discard pile. +phase10-start-discard-skip = A Skip starts the discard pile. { $player }'s turn is automatically skipped. + +# Draw +phase10-draw-deck-action = Draw from deck +phase10-draw-discard-action = Draw { $card } from discard pile +phase10-you-draw-deck = You draw { $card }. +phase10-player-draws-deck = { $player } draws from the deck. +phase10-you-draw-discard = You draw { $card } from the discard pile. +phase10-player-draws-discard = { $player } draws { $card } from the discard pile. +phase10-cannot-draw-skip = Skip cards cannot be taken from the discard pile. +phase10-deck-reshuffled = Discard pile reshuffled into the draw pile. +phase10-deck-truly-empty = No cards remain to draw. Hand ends. + +# Lay down phase — action labels +phase10-lay-down-action = Lay down Phase { $phase } +phase10-confirm-group-action = Confirm group { $current } of { $total } +phase10-check-req-action = Check requirement +phase10-check-req-result = { $req } +phase10-cancel-lay-down-action = Cancel lay-down + +# Lay down phase — flow messages +phase10-lay-down-start = Laying down Phase { $phase }: { $description }. Group { $current } of { $total }: { $req }. +phase10-lay-down-next-group = Group { $prev } confirmed. Group { $current } of { $total }: { $req }. +phase10-lay-down-add = { $card } added. Group { $current } selection: { $cards }. +phase10-lay-down-remove = { $card } removed. Group { $current } selection: { $cards }. +phase10-lay-down-selection-empty = Group { $current } selection is empty. +phase10-lay-down-confirmed-group = Group { $current } confirmed: { $cards }. +phase10-lay-down-success = You lay down Phase { $phase } ({ $description }): { $details }. +phase10-player-lays-down = { $player } lays down Phase { $phase } ({ $description }): { $details }. +phase10-lay-down-cancel = Phase lay-down cancelled. +phase10-hit-cancelled = Hit cancelled. + +# Lay down phase — validation errors +phase10-err-need-cards = Need at least { $count } { $count -> + [one] card + *[other] cards +} for this group. +phase10-err-invalid-set = All cards in a set must share the same number. +phase10-err-invalid-run = Cards in a run must form a consecutive sequence. +phase10-err-invalid-color = All cards in a color group must share the same color. +phase10-err-need-natural = At least one non-Wild card is required per group. + +# Lay down phase — guard messages +phase10-already-laid-down = You have already laid down your phase this hand. +phase10-must-draw-first = Draw a card before taking this action. + +# Hit — action labels +phase10-hit-action = Hit +phase10-hit-cancel-action = Cancel hit + +# Hit — flow messages +phase10-hit-mode-start = Select a card from your hand to hit with. Press Escape to cancel. +phase10-hit-choose-group = Hitting { $card }. Select a group to hit onto, or cancel. +phase10-hit-success = You hit { $card } onto { $target }'s group. +phase10-player-hits = { $player } hits { $card } onto { $target }'s group. +phase10-hit-invalid = { $card } does not fit that group: { $reason }. +phase10-hit-no-phase = Lay down your own phase before hitting. +phase10-hit-no-groups = No phases have been laid down yet. +phase10-hit-invalid-set = card does not match the group's number +phase10-hit-invalid-run = card does not extend the run +phase10-hit-invalid-color = card does not match the group's color +phase10-hit-invalid-skip = Skip cards cannot be used as hits +phase10-hit-wild-choose = Wild on a run. Choose which end to extend. +phase10-hit-wild-low = Extend low end to { $value } +phase10-hit-wild-high = Extend high end to { $value } + +# Discard +phase10-discard-action = Discard +phase10-discard-confirm-action = Discard { $card } +phase10-no-card-selected = No card selected. Navigate to a card and press Enter to select it, then Delete to discard. +phase10-you-discard = You discard { $card }. +phase10-player-discards = { $player } discards { $card }. + +# Skip — action labels +phase10-skip-target-label = { $player }, Phase { $phase } +phase10-skip-cancel-action = Cancel skip + +# Skip — flow messages +phase10-skip-choose-target = You discarded a Skip. Choose a player to skip, or cancel. +phase10-skip-cancelled = Skip cancelled. +phase10-skip-played = You skip { $target }. +phase10-player-skips = { $player } plays a Skip on { $target }. +phase10-you-are-skipped = { $skipping_player } skips you. Your turn is lost. +phase10-skip-already-used = { $player } has already been skipped this round. +phase10-skip-self = You cannot skip yourself. +phase10-your-turn-skipped = Your turn has been skipped. + +# Status / info — action labels +phase10-read-hand-action = Read hand +phase10-read-discard-action = Read top of discard pile +phase10-read-table-action = Read table groups +phase10-check-phase-action = Check phase status +phase10-read-counts-action = Read card counts +phase10-turn-timer-action = Check turn timer + +# Status / info — messages +phase10-your-phase = You are on Phase { $phase }: { $description }. +phase10-your-phase-laid-down = Your Phase { $phase } is laid down. Waiting for end of hand. +phase10-top-discard = { $card }. +phase10-no-discard = The discard pile is empty. +phase10-hand-contents = Your hand ({ $count } { $count -> + [one] card + *[other] cards +}): { $cards }. +phase10-player-hand-count = { $player }: { $count } { $count -> + [one] card + *[other] cards +}. +phase10-deck-count = Draw pile: { $count } { $count -> + [one] card + *[other] cards +} remaining. +phase10-group-summary-set = { $count } { $rank -> + [1] ones + [2] twos + [3] threes + [4] fours + [5] fives + [6] sixes + [7] sevens + [8] eights + [9] nines + [10] tens + [11] elevens + [12] twelves + *[other] { $rank }s +} +phase10-group-summary-run = { $low } through { $high } +phase10-group-summary-color = { $count } { $color }s +phase10-no-table-groups = No phases have been laid down on the table yet. +phase10-table-group-header = Table groups: +phase10-table-group-entry = { $owner }: { $cards }. + +# Round end +phase10-you-go-out = You go out! Hand { $round } over. +phase10-player-goes-out = { $player } goes out. Hand { $round } over. +phase10-round-scoring-header = Scoring: +phase10-you-score-zero = You went out. No penalty points this hand. +phase10-you-score = You score { $points } penalty { $points -> + [one] point + *[other] points +}. Running total: { $total }. +phase10-player-scores-zero = { $player } went out. No penalty. +phase10-player-scores = { $player } scores { $points } penalty { $points -> + [one] point + *[other] points +}. Total: { $total }. +phase10-you-advance = You advance to Phase { $next }. +phase10-you-stay = You stay on Phase { $phase }. +phase10-player-advances = { $player } advances to Phase { $next }. +phase10-player-stays = { $player } stays on Phase { $phase }. +phase10-fixed-hands-advance = { $player } advances to Phase { $next } (fixed hands). +phase10-you-fixed-hands-advance = You advance to Phase { $next } (fixed hands). + +# Game end +phase10-game-winner = { $player } wins with { $score } penalty { $score -> + [one] point + *[other] points +}! +phase10-you-win = You win with { $score } penalty { $score -> + [one] point + *[other] points +}! +phase10-tiebreaker = Scores are tied between { $players }! Replaying Phase { $phase }. +phase10-tiebreaker-you = It's a tie! You replay Phase { $phase }. +phase10-fixed-hands-over = 10 hands complete. + +# Hand sort +phase10-sort-by-color-action = Sort hand by color +phase10-sort-by-number-action = Sort hand by number +phase10-sorted-by-color = Hand sorted by color. +phase10-sorted-by-number-asc = Hand sorted by number, ascending. +phase10-sorted-by-number-desc = Hand sorted by number, descending. + +# Score display (S key) +phase10-score-header = Scores (lower is better): +phase10-score-entry = { $player }: Phase { $phase }, { $score } diff --git a/server/tests/test_phase10.py b/server/tests/test_phase10.py new file mode 100644 index 00000000..481da639 --- /dev/null +++ b/server/tests/test_phase10.py @@ -0,0 +1,406 @@ +"""Tests for Phase 10: unit tests, play tests, and persistence.""" + +import pytest + +from server.core.users.bot import Bot +from server.core.users.test_user import MockUser +from server.game_utils.cards import Card +from server.games.phase10.evaluator import ( + can_hit_group, + score_hand, + validate_group, + next_phase, + active_phases, + is_wild, + is_skip, +) +from server.games.phase10.game import Phase10Game +from server.games.phase10.state import ( + Phase10Options, + Phase10Player, + PhaseRequirement, + TableGroup, + GROUP_SET, + GROUP_RUN, + GROUP_COLOR, + P10_RANK_WILD, + P10_RANK_SKIP, + P10_COLOR_RED, + P10_COLOR_BLUE, + P10_COLOR_GREEN, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_id_counter = iter(range(100_000, 200_000)).__next__ + + +def c(rank: int, suit: int = P10_COLOR_RED) -> Card: + return Card(id=_id_counter(), rank=rank, suit=suit) + + +def wild() -> Card: + return Card(id=_id_counter(), rank=P10_RANK_WILD, suit=0) + + +def skip() -> Card: + return Card(id=_id_counter(), rank=P10_RANK_SKIP, suit=0) + + +def set_group(*cards: Card) -> TableGroup: + return TableGroup( + owner_id="p1", + group_index=0, + requirement=PhaseRequirement(kind=GROUP_SET, count=len(cards)), + cards=list(cards), + ) + + +def color_group(*cards: Card) -> TableGroup: + return TableGroup( + owner_id="p1", + group_index=0, + requirement=PhaseRequirement(kind=GROUP_COLOR, count=len(cards)), + cards=list(cards), + ) + + +def _make_game(n_bots: int = 2, options: Phase10Options | None = None) -> Phase10Game: + game = Phase10Game(options=options or Phase10Options()) + for i in range(n_bots): + name = ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank"][i] + game.add_player(name, Bot(name)) + game.on_start() + return game + + +def _run_to_finish(game: Phase10Game, max_ticks: int = 2_000_000) -> None: + for _ in range(max_ticks): + if game.status == "finished": + return + game.on_tick() + raise AssertionError(f"Game did not finish within {max_ticks} ticks") + + +# --------------------------------------------------------------------------- +# Game metadata +# --------------------------------------------------------------------------- + +class TestMetadata: + def test_name(self): + assert Phase10Game.get_name() == "Phase 10" + + def test_type(self): + assert Phase10Game.get_type() == "phase10" + + def test_player_bounds(self): + assert Phase10Game.get_min_players() == 2 + assert Phase10Game.get_max_players() == 6 + + +# --------------------------------------------------------------------------- +# Options defaults +# --------------------------------------------------------------------------- + +class TestOptionsDefaults: + def test_defaults(self): + opts = Phase10Options() + assert opts.winning_phase == 10 + assert opts.turn_timer == "0" + assert opts.even_phases_only is False + assert opts.fixed_hands is False + + +# --------------------------------------------------------------------------- +# Evaluator — scoring +# --------------------------------------------------------------------------- + +class TestScoring: + def test_numbered_card_scores(self): + # Ranks 1-9 score 5 each, ranks 10-12 score 10 each + assert score_hand([c(1), c(5), c(10)]) == 20 # 5 + 5 + 10 + + def test_wild_scores_25(self): + assert score_hand([wild()]) == 25 + + def test_skip_scores_15(self): + assert score_hand([skip()]) == 15 + + def test_empty_hand_scores_zero(self): + assert score_hand([]) == 0 + + def test_mixed_hand(self): + # 5 + 25 + 15 + 5 = 50 + assert score_hand([c(3), wild(), skip(), c(7)]) == 50 + + +# --------------------------------------------------------------------------- +# Evaluator — validate_group +# --------------------------------------------------------------------------- + +class TestValidateGroup: + def test_valid_set(self): + req = PhaseRequirement(GROUP_SET, 3) + ok, _ = validate_group([c(5), c(5), c(5)], req) + assert ok + + def test_set_with_wild(self): + req = PhaseRequirement(GROUP_SET, 3) + ok, _ = validate_group([c(5), c(5), wild()], req) + assert ok + + def test_set_mismatched_ranks_fails(self): + req = PhaseRequirement(GROUP_SET, 3) + ok, _ = validate_group([c(5), c(5), c(6)], req) + assert not ok + + def test_set_too_few_fails(self): + req = PhaseRequirement(GROUP_SET, 3) + ok, _ = validate_group([c(5), c(5)], req) + assert not ok + + def test_valid_run(self): + req = PhaseRequirement(GROUP_RUN, 4) + ok, _ = validate_group([c(3), c(4), c(5), c(6)], req) + assert ok + + def test_run_with_wild(self): + req = PhaseRequirement(GROUP_RUN, 4) + ok, _ = validate_group([c(3), wild(), c(5), c(6)], req) + assert ok + + def test_run_non_consecutive_fails(self): + req = PhaseRequirement(GROUP_RUN, 4) + ok, _ = validate_group([c(3), c(4), c(6), c(7)], req) + assert not ok + + def test_valid_color_group(self): + req = PhaseRequirement(GROUP_COLOR, 3) + ok, _ = validate_group([c(1, P10_COLOR_RED), c(5, P10_COLOR_RED), c(9, P10_COLOR_RED)], req) + assert ok + + def test_color_group_mixed_colors_fails(self): + req = PhaseRequirement(GROUP_COLOR, 3) + ok, _ = validate_group([c(1, P10_COLOR_RED), c(5, P10_COLOR_BLUE), c(9, P10_COLOR_RED)], req) + assert not ok + + def test_all_wilds_fails_no_natural(self): + req = PhaseRequirement(GROUP_SET, 3) + ok, _ = validate_group([wild(), wild(), wild()], req) + assert not ok + + +# --------------------------------------------------------------------------- +# Evaluator — can_hit_group (sets and color groups) +# --------------------------------------------------------------------------- + +class TestHitSet: + def test_matching_rank_accepted(self): + group = set_group(c(7), c(7), c(7)) + ok, _ = can_hit_group(group, c(7)) + assert ok + + def test_wrong_rank_rejected(self): + group = set_group(c(7), c(7), c(7)) + ok, _ = can_hit_group(group, c(8)) + assert not ok + + def test_wild_always_accepted_on_set(self): + group = set_group(c(7), c(7), c(7)) + ok, _ = can_hit_group(group, wild()) + assert ok + + +class TestHitColor: + def test_matching_color_accepted(self): + group = color_group(c(1, P10_COLOR_RED), c(5, P10_COLOR_RED)) + ok, _ = can_hit_group(group, c(9, P10_COLOR_RED)) + assert ok + + def test_wrong_color_rejected(self): + group = color_group(c(1, P10_COLOR_RED), c(5, P10_COLOR_RED)) + ok, _ = can_hit_group(group, c(9, P10_COLOR_BLUE)) + assert not ok + + def test_wild_accepted_on_color_group(self): + group = color_group(c(1, P10_COLOR_RED), c(5, P10_COLOR_RED)) + ok, _ = can_hit_group(group, wild()) + assert ok + + +# --------------------------------------------------------------------------- +# Evaluator — phase progression helpers +# --------------------------------------------------------------------------- + +class TestPhaseProgression: + def test_next_phase_normal(self): + assert next_phase(1, False) == 2 + assert next_phase(9, False) == 10 + assert next_phase(10, False) == 11 + + def test_next_phase_even_only(self): + assert next_phase(2, True) == 4 + assert next_phase(8, True) == 10 + assert next_phase(10, True) == 11 + + def test_active_phases_normal(self): + phases = active_phases(False) + assert phases == list(range(1, 11)) + + def test_active_phases_even_only(self): + phases = active_phases(True) + assert phases == [2, 4, 6, 8, 10] + + +# --------------------------------------------------------------------------- +# Play tests +# --------------------------------------------------------------------------- + +class TestBotGameCompletes: + def test_two_bots_complete(self): + game = _make_game(2, Phase10Options(winning_phase=1)) + _run_to_finish(game) + assert game.status == "finished" + + def test_four_bots_complete(self): + game = _make_game(4, Phase10Options(winning_phase=2)) + _run_to_finish(game) + assert game.status == "finished" + + def test_even_phases_complete(self): + game = _make_game(2, Phase10Options(winning_phase=2, even_phases_only=True)) + _run_to_finish(game) + assert game.status == "finished" + + def test_fixed_hands_complete(self): + game = _make_game(2, Phase10Options(fixed_hands=True, winning_phase=10)) + _run_to_finish(game) + assert game.status == "finished" + + def test_winner_has_lowest_score(self): + game = _make_game(3, Phase10Options(winning_phase=1)) + _run_to_finish(game) + winner_id = game.game_winner_id + assert winner_id is not None + winner = next(p for p in game.players if p.id == winner_id) + assert all(winner.score <= p.score for p in game.players) + + +# --------------------------------------------------------------------------- +# Round-end behaviour +# --------------------------------------------------------------------------- + +class TestRoundEnd: + def test_hands_cleared_after_round(self): + """After a round ends, all hands should be empty during the wait period.""" + game = _make_game(2, Phase10Options(winning_phase=1)) + # Tick until first round ends (game_active becomes True and next_round_wait_ticks > 0) + for _ in range(500_000): + game.on_tick() + if game.next_round_wait_ticks > 0 and game.round >= 1: + break + for p in game.players: + assert p.hand == [], f"{p.name} hand not empty after round end" + + def test_scores_assigned_after_round(self): + """At least the loser should have a non-zero penalty after the first round.""" + game = _make_game(2, Phase10Options(winning_phase=1)) + for _ in range(500_000): + game.on_tick() + if game.round == 2 or game.status == "finished": + break + total = sum(p.score for p in game.players) + assert total >= 0 # winner has 0, loser may have 0 if they also went out + + +# --------------------------------------------------------------------------- +# Skip card +# --------------------------------------------------------------------------- + +class TestSkipCard: + def test_is_skip_identifies_skip_card(self): + assert is_skip(skip()) + assert not is_skip(c(5)) + assert not is_skip(wild()) + + def test_skip_not_drawable_from_discard(self): + """Skip on top of discard pile should be refused when a player tries to draw it.""" + game = _make_game(2) + game.status = "playing" + # Put a skip on the discard pile + game.discard_pile = [skip()] + p = game.players[0] + game.set_turn_players(game.players, reset_index=True) + user = MockUser(p.name) + game._users[p.id] = user + p.hand = [c(3), c(4), c(5)] + game.turn_has_drawn = False + + game._action_draw_discard(p, "draw_discard") + + spoken = user.get_spoken_messages() + assert any("cannot" in m.lower() or "skip" in m.lower() for m in spoken) + + +# --------------------------------------------------------------------------- +# Persistence +# --------------------------------------------------------------------------- + +class TestPersistence: + def test_round_trip_preserves_player_state(self): + game = _make_game(2, Phase10Options(winning_phase=3)) + # Advance a bit so there's interesting state to preserve + for _ in range(5000): + if game.status != "waiting": + break + game.on_tick() + for _ in range(30000): + game.on_tick() + if game.round >= 2: + break + + payload = game.to_json() + loaded = Phase10Game.from_json(payload) + loaded.rebuild_runtime_state() + + assert loaded.round == game.round + assert loaded.status == game.status + for orig, rest in zip(game.players, loaded.players): + assert rest.current_phase == orig.current_phase + assert rest.score == orig.score + assert rest.phase_laid_down == orig.phase_laid_down + + def test_round_trip_preserves_table_groups(self): + game = _make_game(2, Phase10Options(winning_phase=3)) + for _ in range(100_000): + game.on_tick() + if game.table_groups: + break + + if not game.table_groups: + pytest.skip("No table groups formed in time") + + payload = game.to_json() + loaded = Phase10Game.from_json(payload) + loaded.rebuild_runtime_state() + + assert len(loaded.table_groups) == len(game.table_groups) + for orig, rest in zip(game.table_groups, loaded.table_groups): + assert rest.owner_id == orig.owner_id + assert rest.requirement.kind == orig.requirement.kind + assert len(rest.cards) == len(orig.cards) + + def test_round_trip_preserves_options(self): + opts = Phase10Options(winning_phase=5, even_phases_only=True, fixed_hands=False) + game = _make_game(2, opts) + payload = game.to_json() + loaded = Phase10Game.from_json(payload) + loaded.rebuild_runtime_state() + + assert loaded.options.winning_phase == 5 + assert loaded.options.even_phases_only is True + assert loaded.options.fixed_hands is False + diff --git a/server/tests/test_phase10_evaluator.py b/server/tests/test_phase10_evaluator.py new file mode 100644 index 00000000..d1567e04 --- /dev/null +++ b/server/tests/test_phase10_evaluator.py @@ -0,0 +1,142 @@ +"""Unit tests for Phase 10 evaluator: hit validation on runs.""" + +import pytest +from ..games.phase10.evaluator import can_hit_group, resolve_run_order +from ..games.phase10.state import TableGroup, PhaseRequirement, GROUP_RUN, GROUP_SET, P10_RANK_WILD +from ..game_utils.cards import Card + + +_next_id = iter(range(1, 10000)).__next__ + + +def c(rank: int, suit: int = 1) -> Card: + """Shorthand card constructor.""" + return Card(id=_next_id(), rank=rank, suit=suit) + + +def wild() -> Card: + return Card(id=_next_id(), rank=P10_RANK_WILD, suit=0) + + +def run_group(*cards: Card) -> TableGroup: + return TableGroup( + owner_id="p1", + group_index=0, + requirement=PhaseRequirement(kind=GROUP_RUN, count=len(cards)), + cards=list(cards), + ) + + +# --------------------------------------------------------------------------- +# resolve_run_order +# --------------------------------------------------------------------------- + +class TestResolveRunOrder: + def test_all_naturals(self): + cards = [c(4), c(5), c(6), c(7)] + ordered = resolve_run_order(cards) + assert [v for _, v in ordered] == [4, 5, 6, 7] + + def test_wild_fills_internal_gap(self): + # [4, Wild, 6, 7] — wild must fill position 5 + cards = [c(4), wild(), c(6), c(7)] + ordered = resolve_run_order(cards) + values = [v for _, v in ordered] + assert values == [4, 5, 6, 7] + + def test_wild_extends_low_end(self): + # [4, 5, Wild] — wild greedy extends to 3 + cards = [c(4), c(5), wild()] + ordered = resolve_run_order(cards) + values = [v for _, v in ordered] + assert values == [3, 4, 5] + + def test_two_wilds_one_gap_one_low(self): + # [4, Wild, 6, Wild] — one wild fills gap at 5, one extends to 3 + cards = [c(4), wild(), c(6), wild()] + ordered = resolve_run_order(cards) + values = [v for _, v in ordered] + assert values == [3, 4, 5, 6] + + +# --------------------------------------------------------------------------- +# can_hit_group — run extension +# --------------------------------------------------------------------------- + +class TestHitRun: + def test_extend_high_end(self): + # [4, Wild, 6, 7] span=4-7; can add 8 + group = run_group(c(4), wild(), c(6), c(7)) + ok, _ = can_hit_group(group, c(8)) + assert ok + + def test_extend_low_end(self): + # [4, Wild, 6, 7] span=4-7; can add 3 + group = run_group(c(4), wild(), c(6), c(7)) + ok, _ = can_hit_group(group, c(3)) + assert ok + + def test_interior_natural_blocked(self): + # [4, Wild, 6, 7] — wild covers 5; cannot add natural 5 + group = run_group(c(4), wild(), c(6), c(7)) + ok, reason = can_hit_group(group, c(5)) + assert not ok + assert reason == "phase10-hit-invalid-run" + + def test_interior_natural_no_wild_blocked(self): + # [4, 5, 6, 7] — no wilds; 6 is interior, blocked + group = run_group(c(4), c(5), c(6), c(7)) + ok, _ = can_hit_group(group, c(6)) + assert not ok + + def test_non_adjacent_high_blocked(self): + # [4, Wild, 6, 7] span=4-7; 9 is two steps above, blocked + group = run_group(c(4), wild(), c(6), c(7)) + ok, _ = can_hit_group(group, c(9)) + assert not ok + + def test_non_adjacent_low_blocked(self): + # [4, Wild, 6, 7] span=4-7; 2 is two steps below, blocked + group = run_group(c(4), wild(), c(6), c(7)) + ok, _ = can_hit_group(group, c(2)) + assert not ok + + def test_wild_always_allowed(self): + # Wild can be added regardless of span + group = run_group(c(4), c(5), c(6), c(7)) + ok, _ = can_hit_group(group, wild()) + assert ok + + def test_wild_on_full_boundary_run(self): + # Even a run at boundary accepts a wild + group = run_group(c(10), c(11), c(12)) + ok, _ = can_hit_group(group, wild()) + assert ok + + def test_all_naturals_extend_high(self): + # [4, 5, 6] — can add 7 + group = run_group(c(4), c(5), c(6)) + ok, _ = can_hit_group(group, c(7)) + assert ok + + def test_all_naturals_extend_low(self): + # [4, 5, 6] — can add 3 + group = run_group(c(4), c(5), c(6)) + ok, _ = can_hit_group(group, c(3)) + assert ok + + def test_wild_at_low_end_blocks_same_position(self): + # [4, 5, Wild] — resolve assigns wild to 3; can extend with 2 or 6, not 3 + group = run_group(c(4), c(5), wild()) + ok_2, _ = can_hit_group(group, c(2)) + ok_6, _ = can_hit_group(group, c(6)) + ok_3, _ = can_hit_group(group, c(3)) + assert ok_2 + assert ok_6 + assert not ok_3 + + def test_duplicate_natural_blocked(self): + # [4, 5, 6] — cannot add another 4 (it's interior to naturals range too) + group = run_group(c(4), c(5), c(6)) + ok, _ = can_hit_group(group, c(4)) + assert not ok