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/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.
-- Five of a kind — needs a joker, or five identical physical cards from a multi-deck shoe.
+- Five of a kind — needs a joker (wild).
- Royal flush — A-K-Q-J-10 of one suit.
- Straight flush — five in a row of one suit.
- Four of a kind.
@@ -281,7 +281,7 @@ Hand rankings
- One pair.
- 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
- 7,462 distinct ranks — the packaged rank table sorts to exactly the 7,462 natural 5-card ranks, with no gap and no repeat.
- Joker vector table (28) — the 28 hand-authored wild-card vectors each still evaluate to their expected hand class and kickers.
-- Five-of-a-kind ordering — five of a kind outranks a royal flush, and a natural five of a kind outranks a wild one.
+- Five-of-a-kind ordering — five of a kind outranks a royal flush.
- Whole-field 180×12 timing — a seeded 180-entry field of 12-card hands scores inside its budget.
- compare() total order — hand comparison is still a strict total order, so the standings sort by the hands, not by crossing order.
- best_hand() joker bound — a best hand never plays more than five jokers, even when the pool holds six or seven.
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.
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/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/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/fixtures/pdfexport/epic-2026-podium.pdf b/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf
index 16b33df5..ca63a8ec 100644
Binary files a/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf and b/tests/unit/fixtures/pdfexport/epic-2026-podium.pdf differ
diff --git a/tests/unit/fixtures/pdfexport/epic-2026-results.pdf b/tests/unit/fixtures/pdfexport/epic-2026-results.pdf
index a676425e..999c01af 100644
Binary files a/tests/unit/fixtures/pdfexport/epic-2026-results.pdf and b/tests/unit/fixtures/pdfexport/epic-2026-results.pdf differ
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_pdfexport.py b/tests/unit/test_pdfexport.py
index 411b4581..1f8cbe3c 100644
--- a/tests/unit/test_pdfexport.py
+++ b/tests/unit/test_pdfexport.py
@@ -675,7 +675,7 @@ def test_draw_marker_given_a_zero_card_row_that_drew_is_the_draw_alone() -> 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:
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"