From 77e23291c36517cbbddfff0622289a94f6be4dd9 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sat, 19 Sep 2026 07:40:26 -0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(pdfexport):=20render=20joker=20as=20"?= =?UTF-8?q?=E2=98=85=20JOKER"=20and=20widen=20card=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report's inline card cell drew a joker as a bare ★, while the HTML chip renders "★ JOKER". Unify the three joker spellings on one _JOKER_TEXT and widen the Best-5 columns (1.20/1.30 -> 1.60 in) so the wider text does not clip the trailing card. Regenerates both PDF goldens. --- src/rivercrossing/pdfexport.py | 46 ++++++++++-------- .../fixtures/pdfexport/epic-2026-podium.pdf | Bin 442454 -> 442460 bytes .../fixtures/pdfexport/epic-2026-results.pdf | Bin 533442 -> 534622 bytes tests/unit/test_pdfexport.py | 11 +++-- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/rivercrossing/pdfexport.py b/src/rivercrossing/pdfexport.py index c691bdb1..36a033f2 100644 --- a/src/rivercrossing/pdfexport.py +++ b/src/rivercrossing/pdfexport.py @@ -96,6 +96,11 @@ _ROW_HEIGHT = 0.24 +# The Best-5 card column's width, in inches: wide enough for a "★ +# JOKER" joker face alongside the hand name without clipping. The +# team field's hand column then takes the remaining content width. +_CARDS_COL = 1.60 + # A team row's inline logo (R-61's base64 card bitmap) at the HTML's # compact inline size. _INLINE_LOGO = 0.14 @@ -146,7 +151,7 @@ Suit.DIAMONDS: "♦", Suit.CLUBS: "♣", } -_JOKER_GLYPH = "★" +_JOKER_TEXT = "★ JOKER" # The payload's own suit letters (``CardPair``'s second element) to the # glyphs above -- derived, never a second glyph table. @@ -165,10 +170,10 @@ def _format_km(lap_km: float) -> str: def _pair_text(pair: CardPair) -> str: - """Return one pair's text: rank letter + suit glyph, or ★.""" + """Return one pair's text: rank letter + suit glyph, or ★ JOKER.""" rank, suit = pair if rank == "JK": - return _JOKER_GLYPH + return _JOKER_TEXT return f"{rank}{_SUIT_GLYPH_BY_LETTER[suit]}" @@ -195,9 +200,9 @@ def _decode_logo(logo: str) -> io.BytesIO: def _card_text(card: Card) -> str: - """Return one card's text: rank letter + suit glyph, or ★.""" + """Return one card's text: rank letter + suit glyph, or ★ JOKER.""" if card.joker: - return _JOKER_GLYPH + return _JOKER_TEXT rank = cast("Rank", card.rank) suit = cast("Suit", card.suit) return f"{_RANK_LETTER[rank.value]}{_SUIT_GLYPH[suit]}" @@ -209,14 +214,14 @@ def _is_steel_card(card: Card) -> bool: def _poster_card_text(card: Card) -> str: - """Return one large poster face: rank+suit, or ★JOKER for a joker. + """Return one large poster face: rank+suit, or ★ JOKER for a joker. Natural cards reuse :func:`_card_text`'s "9♠" spelling; the joker - spells its face out at poster size (the [5d] mock's own "★JOKER") - rather than the report's bare "★". + spells its face out (the [5d] mock's own "★JOKER", matching the + report's "★ JOKER" chip). """ if card.joker: - return "★JOKER" + return _JOKER_TEXT return _card_text(card) @@ -249,10 +254,11 @@ def _draw_marker(row: ResultRow) -> str: R-14's draw rides after the hand prose in the same cell -- "THREE OF A KIND — NINES · DRAW A♥" -- so no layout width moves. The separator is dropped when there is no hand prose to lead with, - so a no-show entry's cell reads "DRAW ★" rather than starting with - one. The caller draws the marker as its own run, in the DejaVu face, - because Barlow carries no suit glyph (measured: fpdf2 drops ♥ and ★ - from it); the two runs share the one column's width. + so a no-show entry's cell reads "DRAW ★ JOKER" rather than + starting with one. The caller draws the marker as its own run, in + the DejaVu face, because Barlow carries no suit glyph (measured: + fpdf2 drops ♥ and ★ from it); the two runs share the one column's + width. """ if row.draw is None: return "" @@ -314,7 +320,7 @@ def _top_ten_widths(*, show_times: bool, content: float) -> list[float]: widths = [0.40, 0.62, 1.50, 0.45] if show_times: widths.append(0.90) - widths += [1.20, content - sum(widths) - 1.20] + widths += [_CARDS_COL, content - sum(widths) - _CARDS_COL] return widths @@ -328,7 +334,7 @@ def _team_top_widths(*, show_times: bool, content: float) -> list[float]: widths = [0.40, 1.80, 0.45] if show_times: widths.append(0.90) - widths += [1.20, content - sum(widths) - 1.20] + widths += [_CARDS_COL, content - sum(widths) - _CARDS_COL] return widths @@ -355,7 +361,7 @@ def _team_field_widths(*, show_times: bool, content: float) -> list[float]: widths = [0.35, 1.40, 0.40] if show_times: widths += [0.85, 0.80] - widths += [1.30, content - sum(widths) - 1.30] + widths += [_CARDS_COL, content - sum(widths) - _CARDS_COL] return widths @@ -428,10 +434,10 @@ def _marker_style(hand_style: _TextStyle) -> _TextStyle: """Return the drawn-card marker style for a hand run's own size. R-14's marker is drawn in DejaVu -- the face that carries the suit - glyphs and the joker star Barlow lacks (measured: fpdf2 drops ♥ and - ★ from it) -- at the hand prose's size, so the two runs read as one - line whatever cell they ride in. Never bold: DejaVu is registered - for the regular style only. + glyphs and the joker's "★ JOKER" text Barlow lacks (measured: fpdf2 + drops ♥ and ★ from it) -- at the hand prose's size, so the two runs + read as one line whatever cell they ride in. Never bold: DejaVu is + registered for the regular style only. """ return _TextStyle(_FONT_GLYPH, hand_style.size, _STEEL) diff --git a/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf b/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf index 16b33df5e0145fc3e50fee24b341496835b652b0..ca63a8ece3fd78cfaa4a7e516d1dc78d47f0524e 100644 GIT binary patch delta 472 zcmZXQu}T9$5Qf>9JC`dY5V$5qwn@J;vopI_Sc!pDl1K0bd<6q3d;p8B?NSD=NEdwy z3$d{FDcmR)nH%_F`1a@LpMBrVJ~y*HnGtc%=BqO9c|O5@dHG?D>*tg2LbkSjvaKl^ z6iZd>ziF0$@$Z`2iN>AUI-xHAM5F(=%<+cz#daX?Mps|a|YBQf?`Z8e!wE6 zKX&!s2{?db<}>wFk~UAPMAcX%j;8wI*UAF$LFzyF{@L_6O!bY60sgc5Tj}l cD2S+E2mu6CY+(^8S8w&ocFkgOd$(+U0D5a+<^TWy delta 520 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 x#*~24jgYXa1l2C08vu@5Rg} None draw=("JK", "j"), ) - assert pdfexport._draw_marker(row) == "DRAW ★" + assert pdfexport._draw_marker(row) == "DRAW ★ JOKER" def test_render_given_an_unverified_ride_renders_the_self_test_note(tmp_path: Path) -> None: @@ -1298,11 +1298,11 @@ def test_poster_subtitle_preserves_the_solo_name_laps_and_sex( (Card(Rank.TEN, Suit.CLUBS), "10♣"), (Card(Rank.ACE, Suit.HEARTS), "A♥"), (Card(Rank.QUEEN, Suit.DIAMONDS), "Q♦"), - (Card(rank=None, suit=None, joker=True), "★JOKER"), + (Card(rank=None, suit=None, joker=True), "★ JOKER"), ], ) def test_poster_card_text_renders_rank_suit_and_joker(card: Card, expected: str) -> None: - """A large card face reads rank+suit, and "★JOKER" for the joker.""" + """A large card face reads rank+suit, and "★ JOKER" for a joker.""" assert pdfexport._poster_card_text(card) == expected @@ -1338,17 +1338,18 @@ def test_poster_card_text_embeds_rank_letter_and_suit_glyph(rank: Rank, suit: Su (Card(Rank.TEN, Suit.CLUBS), "10♣"), (Card(Rank.ACE, Suit.HEARTS), "A♥"), (Card(Rank.QUEEN, Suit.DIAMONDS), "Q♦"), - (Card(rank=None, suit=None, joker=True), "★"), + (Card(rank=None, suit=None, joker=True), "★ JOKER"), ], ) def test_card_text_renders_rank_suit_and_joker(card: Card, expected: str) -> None: - """One card renders as rank letter + suit glyph, joker as ★.""" + """One card renders as rank letter + suit glyph, or ★ JOKER.""" assert pdfexport._card_text(card) == expected def test_pair_text_given_the_ten_pair_renders_the_10_rank_glyph() -> None: """The payload pair route spells the ten "10", matching "10d".""" assert pdfexport._pair_text(("10", "d")) == "10♦" + assert pdfexport._pair_text(("JK", "j")) == "★ JOKER" def test_rank_letter_given_the_ten_is_the_10_spelling() -> None: From 4c412785bc4453ad48e39fd2f96e9b1f483e3fa6 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sat, 19 Sep 2026 07:40:38 -0400 Subject: [PATCH 2/3] fix(hands): reject natural five of a kind (score as four of a kind) Five of a kind is only a valid poker hand with a wild card. Five physical cards of one rank with no joker now score as four of a kind: classify_pattern returns QUADS for a five-identical natural hand, and standings.hand_name names it via the highest-multiplicity rank so it no longer KeyErrors. Simplifies the five-of-a-kind self-test and updates the spec section 5 and requirements R-41/R-44 to the wild-only rule. --- design/docs-md/requirements.md | 4 +- design/docs-md/spec.md | 6 +-- src/rivercrossing/hands.py | 65 ++++++++++++++++++++---------- src/rivercrossing/standings.py | 38 ++++++++++++------ tests/unit/test_hands.py | 72 +++++++++++++++------------------- tests/unit/test_standings.py | 13 ++++++ 6 files changed, 119 insertions(+), 79 deletions(-) diff --git a/design/docs-md/requirements.md b/design/docs-md/requirements.md index 1db7b7eb..0b84db3f 100644 --- a/design/docs-md/requirements.md +++ b/design/docs-md/requirements.md @@ -61,10 +61,10 @@ Each requirement is testable and traces to the [engineering spec](spec.md) (§) | ID | Level | Requirement | Trace | |---|---|---|---| | R-40 | MUST | Each completed lap deals one card from a seeded, shuffled multi-deck shoe; the deal is deterministic and replayable from the stored seed; empty shoe reshuffles with an audit entry. | §4 | -| R-41 | MUST | Standings rank entries by best 5-card hand from all held cards, jokers fully wild, five of a kind above royal flush, and — on equal class and kickers — fewer jokers above more, so a natural five of a kind beats a wild one of the same rank; ranking table per §5 stored locally. | §5 | +| R-41 | MUST | Standings rank entries by best 5-card hand from all held cards, jokers fully wild, five of a kind above royal flush, and — on equal class and kickers — fewer jokers above more; ranking table per §5 stored locally. | §5 | | R-42 | MUST | The hand algorithm handles 0–2+ jokers and any card count up to the cap; whole-field evaluation (180 entries × 12 cards) completes in under 1 s. | §5/§11 | | R-43 | MUST | Identical hands — same class, same kickers and same joker count — resolve by the configured tie-break order. Where high-card draw is configured, the finish has already drawn one card per tied entry, so a fully drawn group orders by the highest drawn card (rank first, then suit, spades highest; `cards.draw_key`) and renders that card in the results. A tie the sort still cannot separate — no drawn cards, or more tied entries than the 52-card deck holds — is flagged "draw required" — the results window's ⚠ badge, explained on a row double-click — never silently ordered. | §5 · [resultsframe](xrc-windows.md) | -| R-44 | MUST | Evaluator self-test (7,462 ranks + joker vectors + five-of-a-kind ordering, natural above wild, plus a compare() total-order sweep and a best_hand() joker-count bound — six checks) runs at launch and on demand from Help. Five checks are BLOCKING and the whole-field 180×12 timing check is ADVISORY — it measures the host's speed, never the evaluator's correctness, so a slow machine can never strand a multi-hour ride. A failing blocking check no longer blocks finishing outright: the finish route names every failed check and the operator may "Finish anyway", which records those names on the finish event (`self_test_failed_checks`) and marks the results self-test unverified — shown in the Standings window and in the HTML/PDF exports. Cancel leaves the ride unfinished and finishable. | §12 · [selftestdlg](xrc-windows.md) | +| R-44 | MUST | Evaluator self-test (7,462 ranks + joker vectors + five-of-a-kind ordering, plus a compare() total-order sweep and a best_hand() joker-count bound — six checks) runs at launch and on demand from Help. Five checks are BLOCKING and the whole-field 180×12 timing check is ADVISORY — it measures the host's speed, never the evaluator's correctness, so a slow machine can never strand a multi-hour ride. A failing blocking check no longer blocks finishing outright: the finish route names every failed check and the operator may "Finish anyway", which records those names on the finish event (`self_test_failed_checks`) and marks the results self-test unverified — shown in the Standings window and in the HTML/PDF exports. Cancel leaves the ride unfinished and finishable. | §12 · [selftestdlg](xrc-windows.md) | | R-45 | MUST | Team-mode scoring: every lap's card credits the **team entry**, never an individual rider — on `rider_pooled` a member's laps deal into the team's pooled hand (R-16); on `team_relay` the entry's crossings deal into the team's hand (R-22). Riders never hold or score a hand of their own. | §1/§5 | ### 6 · Resilience diff --git a/design/docs-md/spec.md b/design/docs-md/spec.md index 1df02efd..d32f8cee 100644 --- a/design/docs-md/spec.md +++ b/design/docs-md/spec.md @@ -64,7 +64,7 @@ The shoe's jokers follow the ride's **jokers mode** (`ride.jokers_mode`, the set | # | Hand — best → worst | Example | |---|---|---| -| 1 | Five of a Kind, wild or natural | 9 9 9 9 ★ | +| 1 | Five of a Kind (wild card only) | 9 9 9 9 ★ | | 2 | Royal Flush | A K Q J 10 suited | | 3 | Straight Flush | 8 7 6 5 4 suited | | 4 | Four of a Kind | Q Q Q Q 7 | @@ -78,7 +78,7 @@ The shoe's jokers follow the ride's **jokers mode** (`ride.jokers_mode`, the set Within a class, standard kicker comparison — the 7,462 distinct natural ranks are stored as one integer per entry, so sorting the field is a plain sort. Ship the rank table locally; self-test on startup against known vectors (wheel straight, joker five-of-a-kind, …). -**Natural beats wild.** Among hands of equal class and kicker, the hand that uses fewer jokers wins. A natural hand beats an equal wild hand, and one joker beats two, two beats three, up to five. This is part of hand strength, so `hands.compare` applies it before the ride's laps/time tie-breaks, and the pair is never a "draw required" tie. A wild is played only where it improves the hand, so a surplus joker is left unused and an equal-rank natural hand always wins; a *natural* five of a kind also beats a *wild* five of a kind of the same rank. +**Natural beats wild.** Among hands of equal class and kicker, the hand that uses fewer jokers wins. A natural hand beats an equal wild hand, and one joker beats two, two beats three, up to five. This is part of hand strength, so `hands.compare` applies it before the ride's laps/time tie-breaks, and the pair is never a "draw required" tie. A wild is played only where it improves the hand, so a surplus joker is left unused and an equal-rank natural hand always wins. ``` best_hand(cards): # any n — R-16 pools are uncapped @@ -154,7 +154,7 @@ Within `eval5` (exactly 5 cards) and a partial hand (fewer than 5), a joker alwa | A | Five of a Kind, aces | | (none) | Five of a Kind, aces — the `k = 5` row of the pseudocode above | -**Card cap X** (optional per ride): only the first X dealt cards score; later laps still count for laps/time. Entries holding fewer than 5 cards still rank: their cards form the best partial hand, and a missing kicker always ranks below any present one (a 4-card ace-high sits under every 5-card ace-high). A multi-deck shoe can deal one entry two physically identical cards, and within that entry's own hand they rank exactly as the physical cards they are — a pair, three, or four of a kind, or a flush whose kickers happen to repeat a rank, never a dealing error — with five identical cards, wild-assisted or all natural, either way Five of a Kind. Hands this produces outside the 7,462-entry natural table order the same way every hand does: by class, then by the standard kicker comparison. **Ties** between identical hand ranks — same class, same kickers *and* the same joker count — resolve by the ride's ordered rules, applied to a FINISHED ride's results: the stored order defaults to ① high-card draw ② most laps ③ shortest total time. The venue's draw is real, not deferred: `RideEngine.finish()` draws one card per tied entry from one fresh 52-card deck (`cards.high_card_draw`, no jokers) seeded from the ride's stored `rng_seed` salted with a fixed constant (`ride._TIEBREAK_DRAW_SEED_XOR`, so the draw replays from the one stored number yet can never coincide with the shoe's own shuffle), records the cards as an audited `tiebreak_draw` event, and `snapshot()` hands each entry its own card as `EntryResult.tiebreak_card`. `standings` orders a tie group whose every entry holds its drawn card by `cards.draw_key` — rank major, suit minor, spades highest (clubs < diamonds < hearts < spades), 52 distinct keys — so the highest card wins and is the decider, and the drawn card renders in the Standings window's Draw column and in all three exports. A group with even one undrawn entry keeps the barrier, and a pair the sort still cannot separate (no drawn cards, or more tied entries than the 52-card deck holds) is flagged “draw required” at the venue. `reopen()` and a continue discard the draws, so a corrected ride redraws afresh at its next finish; a replayed `tiebreak_draw` restores them from its payload. A ride that is not yet finished ignores the stored order — its standings auto-rank ① most laps ② shortest total time, never a live venue draw. The order is editable after the finish; standings re-run instantly. Hand names render in one style everywhere — title-case em-dash ("Four of a Kind — Nines", "Full House — Aces over Fours"), produced by `rivercrossing.standings.hand_name` (E6.1.1; the frozen results-window sample row and the golden exports agree on it). +**Card cap X** (optional per ride): only the first X dealt cards score; later laps still count for laps/time. Entries holding fewer than 5 cards still rank: their cards form the best partial hand, and a missing kicker always ranks below any present one (a 4-card ace-high sits under every 5-card ace-high). A multi-deck shoe can deal one entry two physically identical cards, and within that entry's own hand they rank exactly as the physical cards they are — a pair, three, or four of a kind, or a flush whose kickers happen to repeat a rank, never a dealing error — with five of a kind only via a wild card; five natural identical cards are four of a kind. Hands this produces outside the 7,462-entry natural table order the same way every hand does: by class, then by the standard kicker comparison. **Ties** between identical hand ranks — same class, same kickers *and* the same joker count — resolve by the ride's ordered rules, applied to a FINISHED ride's results: the stored order defaults to ① high-card draw ② most laps ③ shortest total time. The venue's draw is real, not deferred: `RideEngine.finish()` draws one card per tied entry from one fresh 52-card deck (`cards.high_card_draw`, no jokers) seeded from the ride's stored `rng_seed` salted with a fixed constant (`ride._TIEBREAK_DRAW_SEED_XOR`, so the draw replays from the one stored number yet can never coincide with the shoe's own shuffle), records the cards as an audited `tiebreak_draw` event, and `snapshot()` hands each entry its own card as `EntryResult.tiebreak_card`. `standings` orders a tie group whose every entry holds its drawn card by `cards.draw_key` — rank major, suit minor, spades highest (clubs < diamonds < hearts < spades), 52 distinct keys — so the highest card wins and is the decider, and the drawn card renders in the Standings window's Draw column and in all three exports. A group with even one undrawn entry keeps the barrier, and a pair the sort still cannot separate (no drawn cards, or more tied entries than the 52-card deck holds) is flagged “draw required” at the venue. `reopen()` and a continue discard the draws, so a corrected ride redraws afresh at its next finish; a replayed `tiebreak_draw` restores them from its payload. A ride that is not yet finished ignores the stored order — its standings auto-rank ① most laps ② shortest total time, never a live venue draw. The order is editable after the finish; standings re-run instantly. Hand names render in one style everywhere — title-case em-dash ("Four of a Kind — Nines", "Full House — Aces over Fours"), produced by `rivercrossing.standings.hand_name` (E6.1.1; the frozen results-window sample row and the golden exports agree on it). **Mixed rides rank two sections, Teams and Solo (Phase 3).** A team's pooled cards would dominate most solo hands, so teams rank against teams and solos against solos — never one combined field. `standings.rank_by_kind` runs the ranking once per kind (teams, then solos), each section numbered from 1 with DNF entrants excluded (R-65). The results window and both exports render the split — the HTML/PDF full fields carry a "Teams" section and a "Solo" section (a kind absent from the ride has no section) — and the §15 standings CSV's `type` column labels each row (`place, plate, entry, type, sex, laps, hand, draw[, total_time]` — `draw` being the card the entry drew for the venue's tie-break, blank for every entry that drew none). diff --git a/src/rivercrossing/hands.py b/src/rivercrossing/hands.py index f27252fe..8295fd41 100644 --- a/src/rivercrossing/hands.py +++ b/src/rivercrossing/hands.py @@ -7,19 +7,22 @@ ordering, a Royal-Flush-above-Straight-Flush split that phevaluator itself does not make, and (E2.1.2) a five-of-a-kind check ranked above every natural hand: jokers are wild and always resolve to whichever -natural completion maximizes the hand. +natural completion maximizes the hand, and five of a kind is reached +through one of them alone -- five same-rank naturals are four of a kind. Physical-cards semantics (E2.1.3, spec section 5): a multi-deck shoe can legally deal one entry two identical cards, so a "natural" 5-card hand is not always 5 pairwise-distinct codes -- 9H 9H is simply a pair of nines, and 9H 9H KH QH 2H is a king-high flush whose kickers happen to -include a paired card. phevaluator's native evaluator is undefined -(observed to segfault) on a repeated card id, so any hand with one -takes ``classify_pattern``'s first-principles path instead of -phevaluator's; both paths feed the same :func:`_kicker_tiebreak`, so a -hand's class and tiebreak always compare consistently regardless of -which path produced it. ``tools/gen_rank_vectors.py`` imports -``classify_pattern`` rather than keeping its own copy. +include a paired card. Repeating a card never conjures the wild-only +class either: 9H 9H 9H 9H 9H is four of a kind. phevaluator's native +evaluator is undefined (observed to segfault) on a repeated card id, +so any hand with one takes ``classify_pattern``'s first-principles +path instead of phevaluator's; both paths feed the same +:func:`_kicker_tiebreak`, so a hand's class and tiebreak always +compare consistently regardless of which path produced it. +``tools/gen_rank_vectors.py`` imports ``classify_pattern`` rather +than keeping its own copy. ``best_hand`` finds the best 5-of-N hand (spec section 5's ``best_hand`` pseudocode). For 5 or more cards (E2.3.1) it builds one @@ -114,10 +117,10 @@ class HandClass(IntEnum): """Hand categories, worst to best (spec section 5 table, reversed). Values increase with strength, so a plain ``IntEnum`` comparison - already orders hand classes correctly. ``FIVE_OF_A_KIND`` needs - either a joker (E2.1.2) or 5 physically identical cards from a - multi-deck shoe (E2.1.3); :func:`eval5` never produces it from 5 - pairwise-distinct natural cards. + already orders hand classes correctly. ``FIVE_OF_A_KIND`` needs a + joker (E2.1.2) and only a joker: no all-natural hand reaches it, + however many physically identical cards a multi-deck shoe deals + (E2.1.3) -- five same-rank naturals are ``QUADS``. """ HIGH_CARD = 1 @@ -229,7 +232,8 @@ def classify_pattern(ranks: Sequence[int], suits: Sequence[str]) -> HandClass: and the rest follows the rank-value multiset -- never consults a phevaluator rank, so it can independently confirm one. Duplicate ranks and suits are not a foul (physical-cards semantics, module - docstring): 5 cards sharing one rank is FIVE_OF_A_KIND, and a + docstring): 5 cards sharing one rank is QUADS -- five of a kind + is wild-only, so no arrangement of naturals reaches it -- and a flush's "5 matching suits" is exactly as true when 2 of those 5 happen to be the same physical card. @@ -244,8 +248,11 @@ def classify_pattern(ranks: Sequence[int], suits: Sequence[str]) -> HandClass: if not ranks: return HandClass.HIGH_CARD counts = sorted(Counter(ranks).values(), reverse=True) + # Only a 5-card hand reaches this, and all 5 of its cards share + # one rank -- exactly four of a kind's kickerless top end, never + # five of a kind, which no natural card can make. if counts[0] >= NATURAL_HAND_SIZE: - return HandClass.FIVE_OF_A_KIND + return HandClass.QUADS is_full_hand = len(ranks) == NATURAL_HAND_SIZE is_flush = is_full_hand and len(set(suits)) == 1 @@ -731,6 +738,11 @@ def _spend_leftover_jokers( return spent +def _all_one_rank(cards: Sequence[Card]) -> bool: + """Return True when every card in *cards* holds the same rank.""" + return len({card.rank for card in cards}) == 1 + + def _evaluate_candidate( chosen_naturals: Sequence[Card], jokers_played_as: Sequence[Card], jokers: Sequence[Card] ) -> EvaluatedHand: @@ -743,10 +755,21 @@ def _evaluate_candidate( with the *original* joker placeholders in ``best5`` (module docstring: ``jokers_played_as`` holds the resolution, ``best5`` keeps the raw joker markers). + + The one label that does need the caller is five of a kind: + :func:`_evaluate_five_naturals` sees the resolved cards alone, so + a wild five and five same-rank naturals both reach it as 5 cards + of one rank and both come back QUADS (E2.1.3). With a joker + playing one of those cards it is the wild five of a kind + (E2.1.2), and with none it really is four of a kind. The two + classes share the count-led ``(5, rank)`` tiebreak, so only the + class has to be restored here. """ - evaluated = _evaluate_five_naturals((*chosen_naturals, *jokers_played_as)) + played = (*chosen_naturals, *jokers_played_as) + evaluated = _evaluate_five_naturals(played) + cls = HandClass.FIVE_OF_A_KIND if jokers_played_as and _all_one_rank(played) else evaluated.cls return EvaluatedHand( - cls=evaluated.cls, + cls=cls, tiebreak=evaluated.tiebreak, best5=(*chosen_naturals, *jokers), jokers_played_as=tuple(jokers_played_as), @@ -1141,13 +1164,13 @@ def _check_joker_vectors() -> tuple[bool, str]: def _check_five_of_a_kind_ordering() -> tuple[bool, str]: """Check (c): five of a kind outranks a royal flush. - Also checks that a natural five of a kind outranks a wild one of - the same rank -- fewer jokers wins once class and kicker tie. + The five of a kind here is the only kind there is -- a wild one. + Five same-rank naturals are four of a kind (E2.1.3), so no natural + hand can reach this class at all, let alone rank against a wild one. """ wild_five = eval5([Card.parse(code) for code in ["AS", "AD", "AH", "AC", "JK"]]) - natural_five = eval5([Card.parse(code) for code in ["AS", "AD", "AH", "AC", "AD"]]) royal_flush = eval5([Card.parse(code) for code in ["AS", "KS", "QS", "JS", "10S"]]) - passed = compare(wild_five, royal_flush) == 1 and compare(natural_five, wild_five) == 1 + passed = compare(wild_five, royal_flush) == 1 return passed, "" @@ -1312,7 +1335,7 @@ def self_test() -> SelfTestReport: Six independently-timed checks. The four frozen selftest_dlg canvas lines lead, in their own order: the 7,462-rank sweep, the 28 authored joker vectors, five-of-a-kind ordering (above the royal - flush, and natural above wild), and the whole 180x12 field scoring + flush), and the whole 180x12 field scoring inside its R-42 budget. The two follow-up checks then cover the comparison the field is sorted by (``compare()``'s total order over a seeded sample) and the joker cap ``best_hand`` must respect diff --git a/src/rivercrossing/standings.py b/src/rivercrossing/standings.py index 945e7061..2db10ca9 100644 --- a/src/rivercrossing/standings.py +++ b/src/rivercrossing/standings.py @@ -484,19 +484,16 @@ def time_leaderboard(results: Sequence[EntryResult], top: int = 10) -> list[Plac # The four classes built from a same-rank group, each named from that # group's rank in the plural: "Pair -- Aces", "Three of a Kind -- -# Sevens", "Four of a Kind -- Nines", "Five of a Kind -- Aces". +# Sevens", "Four of a Kind -- Nines", "Five of a Kind -- Aces". The +# rank is the highest-multiplicity one (_highest_count_rank), never an +# exact group size: QUADS covers a four-card group and the five +# identical naturals that also evaluate as quads. _GROUP_LABELS: dict[HandClass, str] = { HandClass.PAIR: "Pair", HandClass.TRIPS: "Three of a Kind", HandClass.QUADS: "Four of a Kind", HandClass.FIVE_OF_A_KIND: "Five of a Kind", } -_GROUP_SIZE: dict[HandClass, int] = { - HandClass.PAIR: 2, - HandClass.TRIPS: 3, - HandClass.QUADS: 4, - HandClass.FIVE_OF_A_KIND: 5, -} # The FULL_HOUSE branch needs its own two group sizes (3 over 2), and # the TWO_PAIR branch filters on the pair count -- named here so no @@ -528,6 +525,21 @@ def _rank_with_count(ranks: Sequence[int], count: int) -> int: return by_count[count] +def _highest_count_rank(ranks: Sequence[int]) -> int: + """Return the rank appearing most often in *ranks*. + + Ties break towards the higher rank, the order a hand's groups are + already sorted by. Needing no exact count is the point: five + identical naturals evaluate as QUADS (E2.1.3), so a group class's + own rank can appear anywhere from twice to five times, and an + exact-size lookup would raise :class:`KeyError` on that hand. + + ``ranks`` is never empty here -- :func:`hand_name` rejects an + empty hand first -- so ``max()`` always has something to return. + """ + return max(Counter(ranks).items(), key=lambda item: (item[1], item[0]))[0] + + def _straight_high(ranks: Sequence[int]) -> int: """Return a straight's display high rank, mindful of the wheel. @@ -546,10 +558,12 @@ def hand_name(hand: EvaluatedHand) -> str: the exact vocabulary the golden exports pin: "High Card -- Ace", "Pair -- Aces", "Two Pair -- Kings & Fives", "Full House -- Aces over Fours", "Straight -- Nine high" (wheel = "Five high"), and - "Royal Flush" with no kicker suffix. A joker's resolution - (``jokers_played_as``) supplies its rank, so a joker-completed hand - names its true kickers. Fewer than 5 cards render the same prose - form as the class they make, with no marker. + "Royal Flush" with no kicker suffix. A group class names the rank + it holds most often, never a fixed group size -- five identical + naturals are quads, and still name their nines. A joker's + resolution (``jokers_played_as``) supplies its rank, so a + joker-completed hand names its true kickers. Fewer than 5 cards + render the same prose form as the class they make, with no marker. Args: hand: The evaluated hand to name. @@ -567,7 +581,7 @@ def hand_name(hand: EvaluatedHand) -> str: raise ValueError(msg) cls = hand.cls if cls in _GROUP_LABELS: - rank = _rank_with_count(ranks, _GROUP_SIZE[cls]) + rank = _highest_count_rank(ranks) return f"{_GROUP_LABELS[cls]} — {_RANK_PLURAL[rank]}" if cls is HandClass.TWO_PAIR: pair_ranks = sorted(rank for rank, n in Counter(ranks).items() if n == _PAIR_COUNT) diff --git a/tests/unit/test_hands.py b/tests/unit/test_hands.py index 05b8aeea..a810dec7 100644 --- a/tests/unit/test_hands.py +++ b/tests/unit/test_hands.py @@ -259,14 +259,6 @@ def test_compare_fewer_jokers_beats_more_jokers() -> None: assert compare(fewer, more) == 1 -def test_compare_natural_five_of_a_kind_beats_wild_five_of_a_kind() -> None: - """Five natural aces beat four aces plus a wild joker.""" - natural = eval5(_cards("AS AD AH AC AD")) - wild = eval5(_cards("AS AD AH AC JK")) - - assert compare(natural, wild) == 1 - - def test_compare_natural_partial_beats_wild_partial() -> None: """A 4-card natural quads beats an equal 4-card wild quads. @@ -491,16 +483,17 @@ def test_eval5_duplicate_card_flush_kickers_are_not_grouped_by_count() -> None: assert evaluated.tiebreak == (NATURAL_HAND_SIZE, 13, 12, 9, 9, 2) -def test_eval5_five_identical_natural_cards_score_as_five_of_a_kind() -> None: - """5 physically identical natural cards are FIVE_OF_A_KIND. +def test_eval5_five_identical_natural_cards_score_as_four_of_a_kind() -> None: + """5 identical natural cards are QUADS, never five of a kind. - No joker involved at all -- reachable only via a 5+ deck shoe - (spec section 4), the natural counterpart to E2.1.2's wild - five-of-a-kind. + No joker is involved at all, and E2.1.2's five-of-a-kind class + needs one; the 5+ deck shoe (spec section 4) that can deal one + entry 9H 9H 9H 9H 9H can only ever reach four of a kind with it, + on the usual count-led tiebreak shape. """ evaluated = eval5(_cards("9H 9H 9H 9H 9H")) - assert evaluated.cls == HandClass.FIVE_OF_A_KIND + assert evaluated.cls == HandClass.QUADS assert evaluated.tiebreak == (NATURAL_HAND_SIZE, 9) @@ -712,6 +705,27 @@ def test_best_hand_pooled_scale_completes_within_measured_budget( assert elapsed < budget_seconds +def _wild_class_restored(evaluated: EvaluatedHand, fill: tuple[Card, ...]) -> EvaluatedHand: + """Return *evaluated* classed as the substitution really plays. + + Each *fill* card stands for one joker -- the oracle's own + substitution -- but ``eval5`` receives it as a natural card and so + cannot see that, which matters for exactly one class: five of a + kind is wild-only (E2.1.2), while five same-rank naturals are four + of a kind (E2.1.3). A candidate that played a joker and resolved + all 5 cards to one rank is therefore the wild five of a kind, and + shares the count-led tiebreak either way. + """ + if fill and all(card.rank == evaluated.best5[0].rank for card in evaluated.best5): + return EvaluatedHand( + cls=HandClass.FIVE_OF_A_KIND, + tiebreak=evaluated.tiebreak, + best5=evaluated.best5, + jokers_played_as=evaluated.jokers_played_as, + ) + return evaluated + + def _exhaustive_best_hand(cards: list[Card]) -> EvaluatedHand: """Compute the ORIGINAL exhaustive subset+substitution search. @@ -731,7 +745,7 @@ def _exhaustive_best_hand(cards: list[Card]) -> EvaluatedHand: else [()] ) candidates = ( - eval5((*subset, *fill)) + _wild_class_restored(eval5((*subset, *fill)), fill) for subset in itertools.combinations(naturals, need) for fill in fills ) @@ -838,12 +852,11 @@ def test_self_test_five_of_a_kind_check_passes_with_no_timing_detail() -> None: def test_self_test_five_of_a_kind_check_fails_when_wild_five_loses_to_the_royal( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Check (c) fails on its first clause: wild five beats royal flush. + """Check (c) fails when the wild five stops outranking the royal. Monkeypatches ``hands.compare`` -- the module's own comparison seam, so the failure is deterministic -- to report every pair as - equal. The check's ``and`` then short-circuits on its left operand - (T-3), which the real vectors can never exercise. + equal, which the check's one remaining clause reads as a loss. """ # logic-coverage-exempt: T-10 -- hands.compare is the SUT's own # comparison seam, patched here only to force this check's failure @@ -855,29 +868,6 @@ def test_self_test_five_of_a_kind_check_fails_when_wild_five_loses_to_the_royal( assert report.checks[2].passed is False -def test_self_test_five_of_a_kind_check_fails_when_natural_five_loses_to_wild( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Check (c) fails on its second clause: natural five beats wild. - - The stub reports a wild hand as better and a natural one as equal, - so the first clause passes, the ``and`` does *not* short-circuit, - and only the natural-beats-wild clause can fail the check (T-3). - """ - - def wild_only_compare(a: EvaluatedHand, _b: EvaluatedHand) -> int: - return 1 if a.jokers_played_as else 0 - - # logic-coverage-exempt: T-10 -- hands.compare is the SUT's own - # comparison seam, patched here only to force this check's failure - # path deterministically; no I/O boundary is involved. - monkeypatch.setattr(hands, "compare", wild_only_compare) - - report = self_test() - - assert report.checks[2].passed is False - - def test_self_test_report_passed_true_when_every_check_passed() -> None: """SelfTestReport.passed is True when every check passed.""" check = SelfTestCheck(name="x", passed=True, duration_seconds=0.0, detail="") diff --git a/tests/unit/test_standings.py b/tests/unit/test_standings.py index 646b368a..c4f94436 100644 --- a/tests/unit/test_standings.py +++ b/tests/unit/test_standings.py @@ -1000,6 +1000,19 @@ def test_hand_name_each_hand_class_matches_golden_string(codes: str, expected: s assert hand_name(best_hand(_cards(codes))) == expected +def test_hand_name_five_identical_natural_cards_names_the_quads_rank() -> None: + """Five identical naturals name their rank, and never raise. + + Those five cards are QUADS -- five of a kind needs a joker -- so + the group rank appears five times where a quads hand usually + shows four; a namer requiring an exact group size raises instead + of naming it. + """ + name = hand_name(best_hand(_cards("9H 9H 9H 9H 9H"))) + + assert name == "Four of a Kind — Nines" + + def test_hand_name_joker_completed_full_house_names_jokers_played_as() -> None: """A joker resolving to an ace names aces over fours (golden).""" assert hand_name(best_hand(_cards("AS AD JK 4C 4H"))) == "Full House — Aces over Fours" From ac2f6d84845ed4c87847d50704c3d5bff578bbf0 Mon Sep 17 00:00:00 2001 From: Mark Buckaway Date: Sat, 19 Sep 2026 07:40:49 -0400 Subject: [PATCH 3/3] docs(user-guide): match the wild-only five-of-a-kind rule The hand-rankings list and the self-test chapter still described natural five of a kind; update both to the wild-only rule and regenerate the bundled HTML. --- docs/user-guide.html | 6 +++--- docs/user-guide.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/user-guide.html b/docs/user-guide.html index e210fa4e..569abda5 100644 --- a/docs/user-guide.html +++ b/docs/user-guide.html @@ -269,7 +269,7 @@

When the ride is finished

Hand rankings

This list is the exact order the app uses, strongest first. A hand lower on the list beats every hand below it.

    -
  1. Five of a kind — needs a joker, or five identical physical cards from a multi-deck shoe.
  2. +
  3. Five of a kind — needs a joker (wild).
  4. Royal flush — A-K-Q-J-10 of one suit.
  5. Straight flush — five in a row of one suit.
  6. Four of a kind.
  7. @@ -281,7 +281,7 @@

    Hand rankings

  8. One pair.
  9. High card.
-

Duplicates across one entry are legal. A multi-deck shoe can deal the same card twice, so 9H 9H is a pair of nines. Two entries can also hold the same card code, because the shoe is shuffled, not dealt from a single deck.

+

Duplicates across one entry are legal. A multi-deck shoe can deal the same card twice, so 9H 9H is a pair of nines. Five natural cards of one rank score as four of a kind, because five of a kind needs a joker. Two entries can also hold the same card code, because the shoe is shuffled, not dealt from a single deck.

Scoring references

These sources describe the rules the app follows. They corroborate the hand rankings and the shuffle.

    @@ -297,7 +297,7 @@

    Evaluator self-test

    1. 7,462 distinct ranks — the packaged rank table sorts to exactly the 7,462 natural 5-card ranks, with no gap and no repeat.
    2. Joker vector table (28) — the 28 hand-authored wild-card vectors each still evaluate to their expected hand class and kickers.
    3. -
    4. Five-of-a-kind ordering — five of a kind outranks a royal flush, and a natural five of a kind outranks a wild one.
    5. +
    6. Five-of-a-kind ordering — five of a kind outranks a royal flush.
    7. Whole-field 180×12 timing — a seeded 180-entry field of 12-card hands scores inside its budget.
    8. compare() total order — hand comparison is still a strict total order, so the standings sort by the hands, not by crossing order.
    9. best_hand() joker bound — a best hand never plays more than five jokers, even when the pool holds six or seven.
    10. diff --git a/docs/user-guide.md b/docs/user-guide.md index 9977f1d2..b8de189e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -292,7 +292,7 @@ DNF entries are excluded entirely: not placed, not listed, not exported. On a mi This list is the exact order the app uses, strongest first. A hand lower on the list beats every hand below it. -1. **Five of a kind** — needs a joker, or five identical physical cards from a multi-deck shoe. +1. **Five of a kind** — needs a joker (wild). 2. **Royal flush** — A-K-Q-J-10 of one suit. 3. **Straight flush** — five in a row of one suit. 4. **Four of a kind**. @@ -304,7 +304,7 @@ This list is the exact order the app uses, strongest first. A hand lower on the 10. **One pair**. 11. **High card**. -Duplicates across one entry are legal. A multi-deck shoe can deal the same card twice, so 9H 9H is a pair of nines. Two entries can also hold the same card code, because the shoe is shuffled, not dealt from a single deck. +Duplicates across one entry are legal. A multi-deck shoe can deal the same card twice, so 9H 9H is a pair of nines. Five natural cards of one rank score as four of a kind, because five of a kind needs a joker. Two entries can also hold the same card code, because the shoe is shuffled, not dealt from a single deck. ## Scoring references {: #scoring-references } @@ -324,7 +324,7 @@ The six checks, in order: 1. **7,462 distinct ranks** — the packaged rank table sorts to exactly the 7,462 natural 5-card ranks, with no gap and no repeat. 2. **Joker vector table (28)** — the 28 hand-authored wild-card vectors each still evaluate to their expected hand class and kickers. -3. **Five-of-a-kind ordering** — five of a kind outranks a royal flush, and a natural five of a kind outranks a wild one. +3. **Five-of-a-kind ordering** — five of a kind outranks a royal flush. 4. **Whole-field 180×12 timing** — a seeded 180-entry field of 12-card hands scores inside its budget. 5. **compare() total order** — hand comparison is still a strict total order, so the standings sort by the hands, not by crossing order. 6. **best_hand() joker bound** — a best hand never plays more than five jokers, even when the pool holds six or seven.