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 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"]