p0054_s0.py

problem 54 · 142 lines

#!/usr/bin/env python3.14
# -*- coding: utf-8 -*-
""" Solution to Euler Problem 54: Poker Hands [Level 54]. """
from __future__ import annotations

import dataclasses
import enum
import functools
import typing

from solver.runners import runner

VALUES = "23456789TJQKA"


class PokerRank(enum.IntEnum):
    HIGH_CARD = 0
    ONE_PAIR = 1
    TWO_PAIRS = 2
    THREE_OF_A_KIND = 3
    STRAIGHT = 4
    FLUSH = 5
    FULL_HOUSE = 6
    FOUR_OF_A_KIND = 7
    STRAIGHT_FLUSH = 8
    ROYAL_FLUSH = 9


@dataclasses.dataclass
class HandRank:
    """A hand's comparison key: its category plus tie-breakers ordered most-significant first."""

    rank: PokerRank
    tie_breakers: int | tuple[int, ...]

    def __gt__(self, other: typing.Any) -> bool:
        """Compare rank first, then break ties by lexicographic comparison of tie_breakers."""
        if not isinstance(other, HandRank):
            return NotImplemented
        if self.rank != other.rank:
            return self.rank > other.rank
        if isinstance(self.tie_breakers, int) and isinstance(other.tie_breakers, int):
            return self.tie_breakers > other.tie_breakers
        if isinstance(self.tie_breakers, tuple) and isinstance(other.tie_breakers, tuple):
            if len(self.tie_breakers) == len(other.tie_breakers):
                return self.tie_breakers > other.tie_breakers
        return NotImplemented


SUITS = "CDHS"
SUIT_ORDER: dict[str, int] = {"C": 0, "D": 1, "H": 2, "S": 3}


@dataclasses.dataclass
class PokerHand:
    """Five validated cards, with values and suits decoded into numeric/character tuples."""

    cards: tuple[str, str, str, str, str]
    values: tuple[int, int, int, int, int] = dataclasses.field(init=False, default=None)  # type: ignore[assignment]
    suits: tuple[str, str, str, str, str] = dataclasses.field(init=False, default=None)  # type: ignore[assignment]

    def __post_init__(self) -> None:
        """Validate the five cards and decode each into its value rank and suit character."""
        if not isinstance(self.cards, (list, tuple)) or len(self.cards) != 5:
            raise ValueError("Cards must be a list or tuple of exactly 5 cards")
        for card in self.cards:
            if any((not isinstance(card, str), len(card) != 2, card[0] not in VALUES, card[1] not in SUITS)):
                raise ValueError(f"Invalid card: {card}")
        if isinstance(self.cards, list):
            self.cards = typing.cast(tuple[str, str, str, str, str], tuple(self.cards))  # type: ignore[unreachable]
        if self.values is None:
            self.values = (  # type: ignore[unreachable]
                VALUES.index(self.cards[0][0]),
                VALUES.index(self.cards[1][0]),
                VALUES.index(self.cards[2][0]),
                VALUES.index(self.cards[3][0]),
                VALUES.index(self.cards[4][0]),
            )
        if self.suits is None:
            self.suits = (self.cards[0][1],  # type: ignore[unreachable]
                          self.cards[1][1],
                          self.cards[2][1],
                          self.cards[3][1],
                          self.cards[4][1])

    @functools.cached_property
    def hand_rank(self) -> HandRank:
        """Classify into a HandRank via a count-sorted value table and flush/straight flags; O(1)."""
        values, suits = (self.values, self.suits)
        is_straight = len(set(values)) == 5 and max(values) - min(values) == 4
        is_flush = len(set(suits)) == 1
        if is_straight and is_flush:
            if min(values) == 8:
                return HandRank(PokerRank.ROYAL_FLUSH, SUIT_ORDER[suits[0]])
            return HandRank(PokerRank.STRAIGHT_FLUSH, max(values))
        # Sort (count, value) pairs descending so the dominant group is value_counts[0].
        value_counts = tuple(sorted([(self.values.count(v), v) for v in set(self.values)], reverse=True))
        if value_counts[0][0] == 4:
            return HandRank(PokerRank.FOUR_OF_A_KIND, (value_counts[0][1], value_counts[1][1]))
        if value_counts[0][0] == 3 and value_counts[1][0] == 2:
            return HandRank(PokerRank.FULL_HOUSE, (value_counts[0][1], value_counts[1][1]))
        if is_flush:
            return HandRank(PokerRank.FLUSH, tuple(sorted(values, reverse=True)))
        if is_straight:
            return HandRank(PokerRank.STRAIGHT, max(values))
        if value_counts[0][0] == 3:
            return HandRank(
                PokerRank.THREE_OF_A_KIND,
                (value_counts[0][1], *sorted([v for v in values if v != value_counts[0][1]], reverse=True)),
            )
        if len(value_counts) >= 2 and value_counts[0][0] == 2 and (value_counts[1][0] == 2):
            high_pair, low_pair = sorted([value_counts[0][1], value_counts[1][1]], reverse=True)
            return HandRank(
                PokerRank.TWO_PAIRS, (high_pair, low_pair, [v for v in values if v != high_pair and v != low_pair][0])
            )
        if value_counts[0][0] == 2:
            return HandRank(
                PokerRank.ONE_PAIR,
                (value_counts[0][1], *sorted([v for v in values if v != value_counts[0][1]], reverse=True)),
            )
        return HandRank(PokerRank.HIGH_CARD, tuple(sorted(values, reverse=True)))


@runner.main
def solve(*args: str) -> str:
    """Reduce each hand to a comparison key and tally Player 1 wins by comparing keys; O(N) lines."""
    file_url = args[0]

    plays = (
        (
            PokerHand(typing.cast(tuple[str, str, str, str, str], tuple(cards[:5]))),
            PokerHand(typing.cast(tuple[str, str, str, str, str], tuple(cards[5:]))),
        )
        for line in runner.get_text_file(file_url).splitlines()
        if (cards := line.split())
    )
    return str(sum((player_1_hand.hand_rank > player_2_hand.hand_rank for player_1_hand, player_2_hand in plays)))


if __name__ == "__main__":
    raise SystemExit(solve())