magmacrunch-cribbage 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cribbage/__init__.py +0 -0
- cribbage/__main__.py +34 -0
- cribbage/ai.py +124 -0
- cribbage/app.py +111 -0
- cribbage/arcade.py +39 -0
- cribbage/cards.py +221 -0
- cribbage/game.py +344 -0
- cribbage/online.py +377 -0
- cribbage/online_scenes.py +352 -0
- cribbage/scenes.py +694 -0
- cribbage/theme.py +58 -0
- magmacrunch_cribbage-0.1.0.dist-info/METADATA +148 -0
- magmacrunch_cribbage-0.1.0.dist-info/RECORD +17 -0
- magmacrunch_cribbage-0.1.0.dist-info/WHEEL +4 -0
- magmacrunch_cribbage-0.1.0.dist-info/entry_points.txt +5 -0
- magmacrunch_cribbage-0.1.0.dist-info/licenses/LICENSE +75 -0
- magmacrunch_cribbage-0.1.0.dist-info/licenses/NOTICE +34 -0
cribbage/__init__.py
ADDED
|
File without changes
|
cribbage/__main__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""``python -m cribbage`` -- play cribbage against the computer in a terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="cribbage", description="Cribbage against the computer.")
|
|
11
|
+
parser.add_argument("--seed", type=int, default=None,
|
|
12
|
+
help="fix the session's deals, for a reproducible one")
|
|
13
|
+
parser.add_argument("--play", action="store_true", help="skip the title screen")
|
|
14
|
+
parser.add_argument("--online", action="store_true",
|
|
15
|
+
help="go straight to the online lobby (a prototype)")
|
|
16
|
+
parser.add_argument("--server", default=None,
|
|
17
|
+
help="the multiplayer server to play online through, "
|
|
18
|
+
"e.g. ws://127.0.0.1:8766 for a web/server.py run "
|
|
19
|
+
"locally. Default: the arcade's own")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--ascii", action="store_true", dest="ascii_only",
|
|
22
|
+
help="draw with plain ASCII instead of box, suit and block glyphs. "
|
|
23
|
+
"Detected automatically from the terminal's encoding; this "
|
|
24
|
+
"forces it. MAGMACRUNCH_ASCII=1 says the same for every cabinet.",
|
|
25
|
+
)
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
from cribbage.app import run
|
|
29
|
+
|
|
30
|
+
run(args.seed, args.play, args.ascii_only, args.server, args.online)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
main()
|
cribbage/ai.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The opponent: ``CribbageAI`` from ``web/js/ai.js``, line for line.
|
|
2
|
+
|
|
3
|
+
Two decisions and nothing else -- which two cards to throw to the crib, and
|
|
4
|
+
which card to lay while pegging -- each made by scoring every option with a
|
|
5
|
+
hand-tuned heuristic and taking the first best. Ties go to the earliest
|
|
6
|
+
option, as ``score > bestScore`` gives them in the browser, which is why the
|
|
7
|
+
loops below walk the hand in the same order it does.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from cribbage.cards import Card, order, value
|
|
15
|
+
|
|
16
|
+
MAX_PEG_COUNT = 31
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def select_crib_cards(hand: Sequence[Card], crib_is_opponents: bool) -> list[Card]:
|
|
20
|
+
"""Two cards to discard. ``crib_is_opponents`` is True when the human
|
|
21
|
+
deals, so the crib these land in is not the AI's to score."""
|
|
22
|
+
if len(hand) != 6:
|
|
23
|
+
return list(hand[:2])
|
|
24
|
+
best: list[Card] | None = None
|
|
25
|
+
best_score = float("-inf")
|
|
26
|
+
for i in range(len(hand)):
|
|
27
|
+
for j in range(i + 1, len(hand)):
|
|
28
|
+
discard = [hand[i], hand[j]]
|
|
29
|
+
remaining = [c for k, c in enumerate(hand) if k not in (i, j)]
|
|
30
|
+
score = score_crib_discard(remaining, discard, crib_is_opponents)
|
|
31
|
+
if score > best_score:
|
|
32
|
+
best_score = score
|
|
33
|
+
best = discard
|
|
34
|
+
return best or list(hand[:2])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def score_crib_discard(remaining: Sequence[Card], discard: Sequence[Card],
|
|
38
|
+
crib_is_opponents: bool) -> float:
|
|
39
|
+
score = 0
|
|
40
|
+
for i in range(len(remaining)):
|
|
41
|
+
for j in range(i + 1, len(remaining)):
|
|
42
|
+
if value(remaining[i].rank) + value(remaining[j].rank) == 15:
|
|
43
|
+
score += 4
|
|
44
|
+
|
|
45
|
+
ranks: dict[str, int] = {}
|
|
46
|
+
for c in remaining:
|
|
47
|
+
ranks[c.rank] = ranks.get(c.rank, 0) + 1
|
|
48
|
+
score += 2 * sum(1 for n in ranks.values() if n == 2)
|
|
49
|
+
|
|
50
|
+
orders = sorted(order(c.rank) for c in remaining)
|
|
51
|
+
for i in range(len(orders) - 2):
|
|
52
|
+
if orders[i + 1] == orders[i] + 1 and orders[i + 2] == orders[i] + 2:
|
|
53
|
+
score += 3
|
|
54
|
+
|
|
55
|
+
suits: dict[str, int] = {}
|
|
56
|
+
for c in remaining:
|
|
57
|
+
suits[c.suit] = suits.get(c.suit, 0) + 1
|
|
58
|
+
score += 2 * sum(1 for n in suits.values() if n == 3)
|
|
59
|
+
|
|
60
|
+
score += sum(1 for c in remaining if c.rank == "5")
|
|
61
|
+
|
|
62
|
+
if crib_is_opponents:
|
|
63
|
+
for c in discard:
|
|
64
|
+
if c.rank == "5":
|
|
65
|
+
score -= 2
|
|
66
|
+
if c.rank in ("10", "J", "Q", "K"):
|
|
67
|
+
score -= 1
|
|
68
|
+
return score
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def select_pegging_card(hand: Sequence[Card], count: int,
|
|
72
|
+
played: Sequence[Card]) -> Card | None:
|
|
73
|
+
"""The card to lay, or None for a go."""
|
|
74
|
+
playable = [c for c in hand if value(c.rank) + count <= MAX_PEG_COUNT]
|
|
75
|
+
if not playable:
|
|
76
|
+
return None
|
|
77
|
+
if len(playable) == 1:
|
|
78
|
+
return playable[0]
|
|
79
|
+
best: Card | None = None
|
|
80
|
+
best_score = float("-inf")
|
|
81
|
+
for c in playable:
|
|
82
|
+
score = score_pegging_play(c, count, played, hand)
|
|
83
|
+
if score > best_score:
|
|
84
|
+
best_score = score
|
|
85
|
+
best = c
|
|
86
|
+
return best or playable[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def score_pegging_play(card: Card, count: int, played: Sequence[Card],
|
|
90
|
+
hand: Sequence[Card]) -> float:
|
|
91
|
+
score: float = 0
|
|
92
|
+
new_count = count + value(card.rank)
|
|
93
|
+
|
|
94
|
+
if new_count in (15, 31):
|
|
95
|
+
score += 2
|
|
96
|
+
|
|
97
|
+
if played:
|
|
98
|
+
if card.rank == played[-1].rank:
|
|
99
|
+
score += 2
|
|
100
|
+
if len(played) >= 2 and played[-2].rank == card.rank:
|
|
101
|
+
score += 4
|
|
102
|
+
if len(played) >= 3 and played[-3].rank == card.rank:
|
|
103
|
+
score += 6
|
|
104
|
+
|
|
105
|
+
# Only the last three cards are looked at for a run, as in the browser.
|
|
106
|
+
if len(played) >= 2:
|
|
107
|
+
last = [*played[-2:], card]
|
|
108
|
+
ranked = sorted(last, key=lambda c: order(c.rank))
|
|
109
|
+
if all(i == 0 or order(c.rank) == order(ranked[i - 1].rank) + 1
|
|
110
|
+
for i, c in enumerate(ranked)):
|
|
111
|
+
score += len(last)
|
|
112
|
+
|
|
113
|
+
if new_count not in (15, 31) and new_count in (10, 20, 25):
|
|
114
|
+
score -= 1
|
|
115
|
+
|
|
116
|
+
if new_count > 25:
|
|
117
|
+
score -= value(card.rank) / 10
|
|
118
|
+
|
|
119
|
+
after = [c for c in hand if c != card]
|
|
120
|
+
score += sum(1 for c in after if value(c.rank) + new_count <= MAX_PEG_COUNT) * 0.5
|
|
121
|
+
return score
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["select_crib_cards", "select_pegging_card"]
|
cribbage/app.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Wiring -- what outlives one game, and how the screens reach it.
|
|
2
|
+
|
|
3
|
+
Everything that draws lives in :mod:`cribbage.scenes`; everything that decides
|
|
4
|
+
lives in :mod:`cribbage.game`. **The terminal is not owned here**: it belongs
|
|
5
|
+
to a ``TuiHost``, which is what lets this run as its own command or be seated
|
|
6
|
+
by the arcade.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from magmacrunch.engine.scores import ScoreBook
|
|
15
|
+
|
|
16
|
+
from cribbage.arcade import INFO
|
|
17
|
+
from cribbage.scenes import GameScene, TitleScene
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CribbageApp:
|
|
21
|
+
def __init__(self, host: Any, seed: int | None = None,
|
|
22
|
+
scores: ScoreBook | None = None, server: str | None = None):
|
|
23
|
+
self.host = host
|
|
24
|
+
#: The multiplayer server PLAY ONLINE connects to. The arcade's own by
|
|
25
|
+
#: default; ``--server`` points it somewhere else, such as a server.py
|
|
26
|
+
#: running on this machine.
|
|
27
|
+
from cribbage.online import DEFAULT_SERVER
|
|
28
|
+
|
|
29
|
+
self.server = server or DEFAULT_SERVER
|
|
30
|
+
#: Where each game's shuffles and cuts come from. Seeded, so
|
|
31
|
+
#: ``--seed`` replays a whole session.
|
|
32
|
+
self.rng = random.Random(seed)
|
|
33
|
+
#: The board the browser posts to. It records wins only, as the
|
|
34
|
+
#: player's final score, which is what the page saves.
|
|
35
|
+
self.scores = scores or ScoreBook(INFO.scoreboard)
|
|
36
|
+
self.initials = "AAA"
|
|
37
|
+
self.last_rank: int | None = None
|
|
38
|
+
self.root_scene = TitleScene(self)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def renderer(self):
|
|
42
|
+
return self.host.renderer
|
|
43
|
+
|
|
44
|
+
def new_rand(self):
|
|
45
|
+
return random.Random(self.rng.randrange(1 << 32)).random
|
|
46
|
+
|
|
47
|
+
def start_game(self) -> None:
|
|
48
|
+
self.host.push_scene(GameScene(self))
|
|
49
|
+
|
|
50
|
+
def play_online(self) -> None:
|
|
51
|
+
"""The lobby. A prototype: see :mod:`cribbage.online`."""
|
|
52
|
+
from cribbage.online_scenes import OnlineLobbyScene
|
|
53
|
+
|
|
54
|
+
self.host.push_scene(OnlineLobbyScene(self, self.server))
|
|
55
|
+
|
|
56
|
+
def show_scores(self) -> None:
|
|
57
|
+
from cribbage.scenes import ScoresScene
|
|
58
|
+
|
|
59
|
+
self.host.push_scene(ScoresScene(self))
|
|
60
|
+
|
|
61
|
+
def show_rules(self) -> None:
|
|
62
|
+
from cribbage.scenes import RulesScene
|
|
63
|
+
|
|
64
|
+
self.host.push_scene(RulesScene(self))
|
|
65
|
+
|
|
66
|
+
def enter_initials(self, score: int, extra: dict) -> None:
|
|
67
|
+
from cribbage.scenes import InitialsScene
|
|
68
|
+
|
|
69
|
+
self.host.push_scene(InitialsScene(self, score, extra))
|
|
70
|
+
|
|
71
|
+
def to_title(self) -> None:
|
|
72
|
+
self.host.pop_scene()
|
|
73
|
+
|
|
74
|
+
def leave(self) -> None:
|
|
75
|
+
self.host.pop_scene()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def best(self) -> int:
|
|
79
|
+
return self.scores.best()
|
|
80
|
+
|
|
81
|
+
def qualifies(self, score: int) -> bool:
|
|
82
|
+
return score > 0 and self.scores.qualifies(score)
|
|
83
|
+
|
|
84
|
+
def record(self, score: int, initials: str | None = None,
|
|
85
|
+
extra: dict | None = None):
|
|
86
|
+
self.initials = initials or self.initials
|
|
87
|
+
result = self.scores.save(self.initials, score, **(extra or {}))
|
|
88
|
+
self.last_rank = result.rank
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def scene(self):
|
|
93
|
+
return self.host.scene
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run(seed: int | None = None, skip_title: bool = False,
|
|
97
|
+
ascii_only: bool = False, server: str | None = None,
|
|
98
|
+
online: bool = False) -> None:
|
|
99
|
+
"""Play cribbage as its own command."""
|
|
100
|
+
from magmacrunch.engine.core.tui_host import TuiHost
|
|
101
|
+
from magmacrunch.engine.ui.glyphs import Glyphs
|
|
102
|
+
|
|
103
|
+
host = TuiHost(title=INFO.title, fps=INFO.fps, hold_ms=INFO.hold_ms,
|
|
104
|
+
glyphs=Glyphs.detect(ascii_only=ascii_only))
|
|
105
|
+
app = CribbageApp(host, seed, server=server)
|
|
106
|
+
host.push_scene(app.root_scene)
|
|
107
|
+
if online:
|
|
108
|
+
app.play_online()
|
|
109
|
+
elif skip_title:
|
|
110
|
+
host.push_scene(GameScene(app))
|
|
111
|
+
host.run()
|
cribbage/arcade.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""What the arcade needs to know to launch this game. Kept tiny: a menu loads
|
|
2
|
+
one of these per installed game just to draw a card."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from magmacrunch.engine.arcade import GameInfo
|
|
9
|
+
|
|
10
|
+
from cribbage import theme
|
|
11
|
+
|
|
12
|
+
INFO = GameInfo(
|
|
13
|
+
key="cribbage",
|
|
14
|
+
title="Cribbage",
|
|
15
|
+
blurb="First to 121 against the computer. Fifteen two, fifteen four.",
|
|
16
|
+
# Turn-based. The frame rate only paces the computer's moves and the
|
|
17
|
+
# cursor; edge input so one arrow press moves one card.
|
|
18
|
+
fps=20,
|
|
19
|
+
hold_ms=0,
|
|
20
|
+
min_cols=theme.MIN_COLS,
|
|
21
|
+
min_rows=theme.MIN_ROWS,
|
|
22
|
+
accent=theme.ACCENT,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CribbageGame:
|
|
27
|
+
"""Satisfies :class:`magmacrunch.engine.arcade.ArcadeGame`."""
|
|
28
|
+
|
|
29
|
+
info = INFO
|
|
30
|
+
|
|
31
|
+
def start(self, host: Any) -> Any:
|
|
32
|
+
from cribbage.app import CribbageApp
|
|
33
|
+
|
|
34
|
+
return CribbageApp(host).root_scene
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
GAME = CribbageGame()
|
|
38
|
+
|
|
39
|
+
__all__ = ["GAME", "INFO", "CribbageGame"]
|
cribbage/cards.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Cards, the deck, and cribbage's scoring.
|
|
2
|
+
|
|
3
|
+
A port of the parts of ``@magmacrunch/adenosine-cards`` the game uses: the
|
|
4
|
+
``Deck`` and its shuffle, and ``CribbageHandEval`` with ``CRIBBAGE_SCORE``.
|
|
5
|
+
The browser loads those from the website's ``arcade/shared/``, so they are
|
|
6
|
+
not in this repo's ``web/`` at all; ``tools/js_oracle.mjs`` loads the same
|
|
7
|
+
bundle the page does and ``tests/test_oracle.py`` holds this to it.
|
|
8
|
+
|
|
9
|
+
Two numbers per rank, and keeping them apart is the whole trick. ``value`` is
|
|
10
|
+
what a card counts toward fifteen and thirty-one (court cards are ten);
|
|
11
|
+
``order`` is where it sits in a run (J, Q and K are 11, 12 and 13). The
|
|
12
|
+
package once built runs out of values, which made J-Q-K three tens.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
#: The bundle's order, which is also the order a fresh deck is built in --
|
|
22
|
+
#: and therefore part of what a seeded shuffle deals.
|
|
23
|
+
SUITS = ("hearts", "diamonds", "clubs", "spades")
|
|
24
|
+
RANKS = ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")
|
|
25
|
+
|
|
26
|
+
SUIT_SYMBOLS = {"hearts": "♥", "diamonds": "♦", "clubs": "♣", "spades": "♠"}
|
|
27
|
+
RED = frozenset({"hearts", "diamonds"})
|
|
28
|
+
|
|
29
|
+
SCORE = {
|
|
30
|
+
"FIFTEEN": 2, "PAIR": 2, "THREE_OF_KIND": 6, "FOUR_OF_KIND": 12,
|
|
31
|
+
"FLUSH_4": 4, "FLUSH_5": 5, "NIBS": 1, "HIS_HEELS": 2, "GO": 1,
|
|
32
|
+
"THIRTY_ONE": 2,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ORDER = {rank: i + 1 for i, rank in enumerate(RANKS)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def order(rank: str) -> int:
|
|
39
|
+
return ORDER.get(rank, 0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def value(rank: str) -> int:
|
|
43
|
+
return min(order(rank), 10)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Card:
|
|
48
|
+
"""A card is its suit and rank. Two cards equal by value, as the browser
|
|
49
|
+
compares them (``c.suit === card.suit && c.rank === card.rank``)."""
|
|
50
|
+
|
|
51
|
+
suit: str
|
|
52
|
+
rank: str
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
return f"{self.rank}{SUIT_SYMBOLS[self.suit]}"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def red(self) -> bool:
|
|
59
|
+
return self.suit in RED
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Deck:
|
|
63
|
+
"""``AdCards.Deck``: built in suit-then-rank order, dealt from the end."""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self.cards = [Card(s, r) for s in SUITS for r in RANKS]
|
|
67
|
+
|
|
68
|
+
def shuffle(self, rand: Callable[[], float]) -> None:
|
|
69
|
+
"""Fisher-Yates from the top down, one draw per position, as the
|
|
70
|
+
bundle does it. Same draws, same deck."""
|
|
71
|
+
for i in range(len(self.cards) - 1, 0, -1):
|
|
72
|
+
j = math.floor(rand() * (i + 1))
|
|
73
|
+
self.cards[i], self.cards[j] = self.cards[j], self.cards[i]
|
|
74
|
+
|
|
75
|
+
def deal(self) -> Card:
|
|
76
|
+
return self.cards.pop()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── CribbageHandEval ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def count_fifteens(cards: Sequence[Card]) -> int:
|
|
82
|
+
n = len(cards)
|
|
83
|
+
count = 0
|
|
84
|
+
for mask in range(1, 1 << n):
|
|
85
|
+
if sum(value(cards[i].rank) for i in range(n) if mask & (1 << i)) == 15:
|
|
86
|
+
count += 1
|
|
87
|
+
return count
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def count_pairs(cards: Sequence[Card]) -> int:
|
|
91
|
+
counts: dict[str, int] = {}
|
|
92
|
+
for c in cards:
|
|
93
|
+
counts[c.rank] = counts.get(c.rank, 0) + 1
|
|
94
|
+
points = 0
|
|
95
|
+
for n in counts.values():
|
|
96
|
+
if n == 2:
|
|
97
|
+
points += SCORE["PAIR"]
|
|
98
|
+
elif n == 3:
|
|
99
|
+
points += SCORE["THREE_OF_KIND"]
|
|
100
|
+
elif n == 4:
|
|
101
|
+
points += SCORE["FOUR_OF_KIND"]
|
|
102
|
+
return points
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def count_runs(cards: Sequence[Card]) -> int:
|
|
106
|
+
"""Only the longest run in each consecutive block, once per way the
|
|
107
|
+
duplicate ranks can build it: 2-3-4-5 is 4, a double run of three is 6."""
|
|
108
|
+
if len(cards) < 3:
|
|
109
|
+
return 0
|
|
110
|
+
counts: dict[int, int] = {}
|
|
111
|
+
for c in cards:
|
|
112
|
+
o = order(c.rank)
|
|
113
|
+
counts[o] = counts.get(o, 0) + 1
|
|
114
|
+
unique = sorted(counts)
|
|
115
|
+
total = 0
|
|
116
|
+
i = 0
|
|
117
|
+
while i < len(unique):
|
|
118
|
+
j = i
|
|
119
|
+
while j + 1 < len(unique) and unique[j + 1] == unique[j] + 1:
|
|
120
|
+
j += 1
|
|
121
|
+
length = j - i + 1
|
|
122
|
+
if length >= 3:
|
|
123
|
+
ways = 1
|
|
124
|
+
for k in range(i, j + 1):
|
|
125
|
+
ways *= counts[unique[k]]
|
|
126
|
+
total += length * ways
|
|
127
|
+
i = j + 1
|
|
128
|
+
return total
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def count_flush(hand: Sequence[Card], starter: Card | None, is_crib: bool) -> int:
|
|
132
|
+
"""The starter never makes a four-card flush; a crib flush must be five."""
|
|
133
|
+
if len(hand) < 4:
|
|
134
|
+
return 0
|
|
135
|
+
suit = hand[0].suit
|
|
136
|
+
if not all(c.suit == suit for c in hand):
|
|
137
|
+
return 0
|
|
138
|
+
if starter is not None and starter.suit == suit:
|
|
139
|
+
return SCORE["FLUSH_5"]
|
|
140
|
+
return 0 if is_crib else SCORE["FLUSH_4"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def count_nobs(hand: Sequence[Card], starter: Card | None) -> int:
|
|
144
|
+
if starter is None:
|
|
145
|
+
return 0
|
|
146
|
+
for c in hand:
|
|
147
|
+
if c.rank == "J" and c.suit == starter.suit:
|
|
148
|
+
return SCORE["NIBS"]
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class HandScore:
|
|
154
|
+
fifteens: int
|
|
155
|
+
pairs: int
|
|
156
|
+
runs: int
|
|
157
|
+
flush: int
|
|
158
|
+
nobs: int
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def total(self) -> int:
|
|
162
|
+
return self.fifteens + self.pairs + self.runs + self.flush + self.nobs
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def score_hand(hand: Sequence[Card], starter: Card | None,
|
|
166
|
+
is_crib: bool = False) -> HandScore:
|
|
167
|
+
every = [*hand, starter] if starter is not None else list(hand)
|
|
168
|
+
return HandScore(
|
|
169
|
+
fifteens=count_fifteens(every) * SCORE["FIFTEEN"],
|
|
170
|
+
pairs=count_pairs(every),
|
|
171
|
+
runs=count_runs(every),
|
|
172
|
+
flush=count_flush(hand, starter, is_crib),
|
|
173
|
+
nobs=count_nobs(hand, starter),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class PegScore:
|
|
179
|
+
points: int
|
|
180
|
+
description: str
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def score_pegging_play(card: Card, played: Sequence[Card]) -> PegScore:
|
|
184
|
+
"""Laying ``card`` on the series so far. Every category counts: a card
|
|
185
|
+
that makes thirty-one and completes a run scores both."""
|
|
186
|
+
sequence = [*played, card]
|
|
187
|
+
count = sum(value(c.rank) for c in sequence)
|
|
188
|
+
points = 0
|
|
189
|
+
said: list[str] = []
|
|
190
|
+
if count == 15:
|
|
191
|
+
points += SCORE["FIFTEEN"]
|
|
192
|
+
said.append("Fifteen!")
|
|
193
|
+
if count == 31:
|
|
194
|
+
points += SCORE["THIRTY_ONE"]
|
|
195
|
+
said.append("Thirty-one!")
|
|
196
|
+
matching = 1
|
|
197
|
+
for prev in reversed(played):
|
|
198
|
+
if prev.rank != card.rank:
|
|
199
|
+
break
|
|
200
|
+
matching += 1
|
|
201
|
+
if matching == 2:
|
|
202
|
+
points += SCORE["PAIR"]
|
|
203
|
+
said.append("Pair!")
|
|
204
|
+
elif matching == 3:
|
|
205
|
+
points += SCORE["THREE_OF_KIND"]
|
|
206
|
+
said.append("Pair royal!")
|
|
207
|
+
elif matching >= 4:
|
|
208
|
+
points += SCORE["FOUR_OF_KIND"]
|
|
209
|
+
said.append("Double pair royal!")
|
|
210
|
+
for length in range(len(sequence), 2, -1):
|
|
211
|
+
tail = sorted(order(c.rank) for c in sequence[-length:])
|
|
212
|
+
if all(i == 0 or o == tail[i - 1] + 1 for i, o in enumerate(tail)):
|
|
213
|
+
points += length
|
|
214
|
+
said.append(f"Run of {length}!")
|
|
215
|
+
break
|
|
216
|
+
return PegScore(points, " + ".join(said))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
__all__ = ["RANKS", "SCORE", "SUITS", "SUIT_SYMBOLS", "Card", "Deck",
|
|
220
|
+
"HandScore", "PegScore", "order", "score_hand",
|
|
221
|
+
"score_pegging_play", "value"]
|