magmacrunch-backgammon 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.
backgammon/__init__.py ADDED
File without changes
backgammon/__main__.py ADDED
@@ -0,0 +1,31 @@
1
+ """``python -m backgammon`` -- play backgammon 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="backgammon", description="Backgammon against the computer.")
11
+ parser.add_argument("--play", action="store_true", help="skip the title screen")
12
+ parser.add_argument("--online", action="store_true",
13
+ help="go straight to PLAY ONLINE (needs the [online] extra)")
14
+ parser.add_argument("--server", metavar="URL",
15
+ help="the multiplayer server for PLAY ONLINE, e.g. "
16
+ "ws://127.0.0.1:8771 for a web/server.py on this machine")
17
+ parser.add_argument(
18
+ "--ascii", action="store_true", dest="ascii_only",
19
+ help="draw with plain ASCII instead of block and box glyphs. Detected "
20
+ "automatically from the terminal's encoding; this forces it. "
21
+ "MAGMACRUNCH_ASCII=1 says the same for every cabinet.",
22
+ )
23
+ args = parser.parse_args()
24
+
25
+ from backgammon.app import run
26
+
27
+ run(args.play, args.ascii_only, server=args.server, online=args.online)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
backgammon/app.py ADDED
@@ -0,0 +1,73 @@
1
+ """Wiring -- how the screens reach each other.
2
+
3
+ Everything that draws lives in :mod:`backgammon.scenes`; everything that
4
+ decides lives in :mod:`backgammon.rules`. **The terminal is not owned here**:
5
+ it belongs to a ``TuiHost``, which is what lets this run as its own command or
6
+ be seated by the arcade. There is no score table: the page keeps none for
7
+ backgammon.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import random
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ from backgammon.arcade import INFO
17
+ from backgammon.scenes import GameScene, TitleScene
18
+
19
+
20
+ class BackgammonApp:
21
+ def __init__(self, host: Any, rand: Callable[[], float] | None = None,
22
+ server: str | None = None):
23
+ self.host = host
24
+ #: Where PLAY ONLINE connects; None for the arcade's public server.
25
+ self.server = server
26
+ #: Stands in for the page's Math.random. Tests pass a seeded one.
27
+ self.rand = rand or random.random
28
+ self.root_scene = TitleScene(self)
29
+
30
+ @property
31
+ def renderer(self):
32
+ return self.host.renderer
33
+
34
+ def start_game(self) -> None:
35
+ self.host.push_scene(GameScene(self))
36
+
37
+ def play_online(self) -> None:
38
+ from backgammon.online import DEFAULT_SERVER
39
+ from backgammon.online_scenes import OnlineLobbyScene
40
+
41
+ self.host.push_scene(OnlineLobbyScene(self, self.server or DEFAULT_SERVER))
42
+
43
+ def show_rules(self) -> None:
44
+ from backgammon.scenes import RulesScene
45
+
46
+ self.host.push_scene(RulesScene(self))
47
+
48
+ def to_title(self) -> None:
49
+ self.host.pop_scene()
50
+
51
+ def leave(self) -> None:
52
+ self.host.pop_scene()
53
+
54
+ @property
55
+ def scene(self):
56
+ return self.host.scene
57
+
58
+
59
+ def run(skip_title: bool = False, ascii_only: bool = False, server: str | None = None,
60
+ online: bool = False) -> None:
61
+ """Play backgammon as its own command."""
62
+ from magmacrunch.engine.core.tui_host import TuiHost
63
+ from magmacrunch.engine.ui.glyphs import Glyphs
64
+
65
+ host = TuiHost(title=INFO.title, fps=INFO.fps, hold_ms=INFO.hold_ms,
66
+ glyphs=Glyphs.detect(ascii_only=ascii_only))
67
+ app = BackgammonApp(host, server=server)
68
+ host.push_scene(app.root_scene)
69
+ if online:
70
+ app.play_online()
71
+ elif skip_title:
72
+ host.push_scene(GameScene(app))
73
+ host.run()
backgammon/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 backgammon import theme
11
+
12
+ INFO = GameInfo(
13
+ key="backgammon",
14
+ title="Backgammon",
15
+ blurb="Against the computer, with the doubling cube.",
16
+ # Turn-based: the frame rate paces the computer's one-second pause and
17
+ # the cursor, nothing else. Edge input, so one arrow is one point.
18
+ fps=20,
19
+ hold_ms=0,
20
+ min_cols=theme.MIN_COLS,
21
+ min_rows=theme.MIN_ROWS,
22
+ accent=theme.PLAYER,
23
+ )
24
+
25
+
26
+ class BackgammonGame:
27
+ """Satisfies :class:`magmacrunch.engine.arcade.ArcadeGame`."""
28
+
29
+ info = INFO
30
+
31
+ def start(self, host: Any) -> Any:
32
+ from backgammon.app import BackgammonApp
33
+
34
+ return BackgammonApp(host).root_scene
35
+
36
+
37
+ GAME = BackgammonGame()
38
+
39
+ __all__ = ["GAME", "INFO", "BackgammonGame"]
backgammon/online.py ADDED
@@ -0,0 +1,382 @@
1
+ """Playing a person online, through the arcade's own backgammon server.
2
+
3
+ **A prototype**, built the way the cribbage and checkers ones are. The rules
4
+ belong to ``web/server.py``, the server behind the page's PLAY ONLINE button.
5
+ Every state it sends carries the mover's legal moves in full, and a move goes
6
+ back as an index into that list. So this client never works out a legal move,
7
+ whose turn it is or who won; it picks from what the server offers. A terminal
8
+ and a browser can share a game: two clients of one referee.
9
+
10
+ Two halves, so the part with the logic can be tested without a socket:
11
+
12
+ - :class:`Table` is pure. It reads the server's frames -- room, players, the
13
+ state -- and builds the frames a choice sends.
14
+ - :class:`Connection` is the socket: a ``websockets`` client on a thread of
15
+ its own, polled once a frame. It is the cribbage and checkers client's,
16
+ copied; three copies is the point to move it into the engine.
17
+
18
+ **Sides.** The first player in the room takes the server's ``player`` seat,
19
+ moving down from 24 and bearing off below 1; the second takes ``ai``, moving
20
+ up. The second is shown the board turned round, as the page does for it, so
21
+ both players see their own checkers in cyan moving down to the bottom right.
22
+ :meth:`Table.mirror` maps a place either way, being its own inverse; every
23
+ board and step this module hands out is already in the viewer's terms, with
24
+ :data:`backgammon.rules.PLAYER` meaning "mine".
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import queue
31
+ import threading
32
+ from dataclasses import dataclass, field
33
+
34
+ from backgammon import rules as R
35
+ from backgammon.scenes import move_name
36
+
37
+ #: ``mpServerFor('backgammon', 8771)`` on the public site, through nginx on 443.
38
+ DEFAULT_SERVER = "wss://magmacrunch.duckdns.org/backgammon"
39
+
40
+ #: The sender's "nothing to send this time", distinct from None, which is a
41
+ #: request to close.
42
+ _IDLE = object()
43
+
44
+ #: The server's phases, as the local game's states.
45
+ PHASE_STATE = {"playing": R.MOVING, "doubling": R.DOUBLING, "game_over": R.GAME_OVER}
46
+
47
+
48
+ def mirror(p: int) -> int:
49
+ """A place on the server's board as the other seat sees it: points turn
50
+ round, and the two bars and the two trays change places."""
51
+ if 1 <= p <= 24:
52
+ return 25 - p
53
+ return {R.BAR_PLAYER: R.BAR_AI, R.BAR_AI: R.BAR_PLAYER,
54
+ R.OFF_PLAYER: R.OFF_AI, R.OFF_AI: R.OFF_PLAYER}[p]
55
+
56
+
57
+ @dataclass
58
+ class Table:
59
+ """What this client knows about its room and its game. No I/O."""
60
+
61
+ me: str = ""
62
+ room: str = ""
63
+ is_host: bool = False
64
+ players: list[str] = field(default_factory=list)
65
+ can_start: bool = False
66
+ #: ``BackgammonGame.get_state()`` from the server, as last sent.
67
+ state: dict | None = None
68
+ #: What happened, newest last: (line, whether it was this player). Never
69
+ #: trimmed, so a screen can follow it by index; a game is a few hundred
70
+ #: lines at most.
71
+ log: list[tuple[str, bool]] = field(default_factory=list)
72
+ rejected: str = ""
73
+ left: str = ""
74
+ #: Counts every state received, so a screen can tell one apart from the
75
+ #: last even when nothing visible changed.
76
+ version: int = 0
77
+
78
+ def receive(self, msg: dict) -> None:
79
+ kind = msg.get("type")
80
+ if kind == "welcome":
81
+ self.me = msg.get("playerName", self.me)
82
+ self.room = msg.get("room", self.room)
83
+ self.is_host = bool(msg.get("isHost"))
84
+ self.rejected = ""
85
+ elif kind == "lobby_update":
86
+ self.players = [p.get("name", "") for p in msg.get("players", [])
87
+ if isinstance(p, dict)]
88
+ self.can_start = bool(msg.get("canStart"))
89
+ elif kind == "rejected":
90
+ self.rejected = str(msg.get("reason", "rejected"))
91
+ elif kind == "game_started":
92
+ self._set(msg.get("state"))
93
+ first = "You move" if self.my_turn else f"{self.opponent} moves"
94
+ self._say(f"The game has started. {first} first.", self.my_turn)
95
+ elif kind == "game_state":
96
+ self._set(msg.get("state"))
97
+ elif kind == "game_action":
98
+ action = msg.get("action") or {}
99
+ rolled = self.dice # the state after a move holds the next roll
100
+ self._set(action.get("state"))
101
+ self._describe(action, rolled)
102
+ elif kind == "player_quit":
103
+ self.left = str(msg.get("playerName", "Your opponent"))
104
+ self._say(f"{self.left} left the game.", False)
105
+ elif kind == "system":
106
+ self._say(str(msg.get("text", "")), False)
107
+
108
+ def _set(self, state) -> None:
109
+ if state:
110
+ self.state = state
111
+ self.version += 1
112
+
113
+ def _say(self, line: str, mine: bool) -> None:
114
+ self.log.append((line, mine))
115
+
116
+ def _describe(self, a: dict, rolled: list[int]) -> None:
117
+ mine = a.get("player") == self.me
118
+ who = "You" if mine else a.get("player", "?")
119
+ kind = a.get("type")
120
+ if kind == "move_made":
121
+ steps = [self._step(s) for s in a.get("moves") or []]
122
+ self._say(f"{who} {rolled[0]}-{rolled[1]}: {move_name(steps)}", mine)
123
+ passes = (a.get("state") or {}).get("passes")
124
+ if passes:
125
+ # Each side that rolled after this move and had no move.
126
+ for p in passes:
127
+ passer = self._side(p.get("side"))
128
+ name = "You" if passer == R.PLAYER else self.opponent
129
+ d = p.get("dice") or [0, 0]
130
+ self._say(f"{name} {d[0]}-{d[1]}: no move.", passer == R.PLAYER)
131
+ elif passes is None and a.get("nextTurn") == a.get("player") \
132
+ and not a.get("gameOver"):
133
+ # A server from before passes were reported: the turn came
134
+ # straight back to the side that just moved.
135
+ other = self.opponent if mine else "You"
136
+ self._say(f"{other} had no move.", not mine)
137
+ if a.get("gameOver"):
138
+ self._say(self._winner_line(a.get("winner")), a.get("winner") == self.me)
139
+ elif kind == "double_requested":
140
+ self._say(f"{who} doubled to {2 * self.cube}.", mine)
141
+ elif kind == "double_accepted":
142
+ self._say(f"{who} took. The cube is at {self.cube}.", mine)
143
+ elif kind == "double_rejected":
144
+ self._say(f"{who} passed. {self._winner_line(a.get('winner'))}", mine)
145
+ elif kind == "player_resigned":
146
+ self._say(f"{who} resigned. {self._winner_line(a.get('winner'))}", mine)
147
+
148
+ def _winner_line(self, name) -> str:
149
+ if not name:
150
+ return "Neither side can ever move again: no winner."
151
+ return "You win." if name == self.me else f"{name} wins."
152
+
153
+ # -- What the screen asks ------------------------------------------
154
+
155
+ @property
156
+ def _s(self) -> dict:
157
+ return self.state or {}
158
+
159
+ @property
160
+ def started(self) -> bool:
161
+ return self.state is not None
162
+
163
+ @property
164
+ def seat(self) -> str:
165
+ """The server's name for our side, ``player`` or ``ai``."""
166
+ sides = self._s.get("sides") or {}
167
+ return next((s for s, name in sides.items() if name == self.me), R.PLAYER)
168
+
169
+ @property
170
+ def mirrored(self) -> bool:
171
+ return self.seat == R.AI
172
+
173
+ def _place(self, p: int) -> int:
174
+ return mirror(p) if self.mirrored else p
175
+
176
+ def _side(self, side) -> str | None:
177
+ """A server side as ours: :data:`R.PLAYER` is always this client."""
178
+ if side is None:
179
+ return None
180
+ return R.PLAYER if side == self.seat else R.AI
181
+
182
+ def _step(self, s: dict) -> R.Step:
183
+ # Bearing off goes to 0 or 25, the two homes, which swap like the bars.
184
+ return R.Step(self._place(s["from"]), self._place(s["to"]), s["type"], s["die"])
185
+
186
+ @property
187
+ def board(self) -> list[int]:
188
+ raw = self._s.get("board") or [0] * R.ARRAY_SIZE
189
+ if not self.mirrored:
190
+ return list(raw)
191
+ out = [0] * R.ARRAY_SIZE
192
+ for i, v in enumerate(raw):
193
+ out[mirror(i)] = -v or 0
194
+ return out
195
+
196
+ @property
197
+ def moves(self) -> list[R.Move]:
198
+ """Our legal moves, in the server's order, in our terms. Empty when it
199
+ is not our turn."""
200
+ if not self.my_turn:
201
+ return []
202
+ return [[self._step(s) for s in m] for m in self._s.get("moves") or []]
203
+
204
+ @property
205
+ def dice(self) -> list[int]:
206
+ return list(self._s.get("dice") or [0, 0])
207
+
208
+ @property
209
+ def phase(self) -> str:
210
+ return self._s.get("phase", "")
211
+
212
+ @property
213
+ def cube(self) -> int:
214
+ return int(self._s.get("doublingCube") or 1)
215
+
216
+ @property
217
+ def cube_owner(self) -> str | None:
218
+ return self._side(self._s.get("doublingOwner"))
219
+
220
+ @property
221
+ def opponent(self) -> str:
222
+ sides = self._s.get("sides") or {}
223
+ return next((n for n in sides.values() if n != self.me), "Opponent")
224
+
225
+ @property
226
+ def on_turn(self) -> str | None:
227
+ """Whose turn it is, in our terms."""
228
+ return self._side(self._s.get("currentTurnSide"))
229
+
230
+ @property
231
+ def over(self) -> bool:
232
+ return self.phase == "game_over"
233
+
234
+ @property
235
+ def winner(self) -> str:
236
+ return self._s.get("winner") or ""
237
+
238
+ @property
239
+ def my_turn(self) -> bool:
240
+ return self.started and not self.over and self.on_turn == R.PLAYER
241
+
242
+ @property
243
+ def to_answer(self) -> bool:
244
+ """The other side has doubled, and the answer is ours."""
245
+ return self.phase == "doubling" and self.on_turn == R.AI
246
+
247
+ def can_double(self) -> bool:
248
+ return (self.my_turn and self.phase == "playing" and self.cube < R.DOUBLING_MAX
249
+ and self.cube_owner in (None, R.PLAYER))
250
+
251
+ # -- What a choice sends -------------------------------------------
252
+
253
+ @staticmethod
254
+ def create_room(name: str) -> dict:
255
+ return {"type": "create_room", "name": name, "color": None, "room": ""}
256
+
257
+ @staticmethod
258
+ def join_room(name: str, code: str) -> dict:
259
+ return {"type": "join_room", "name": name, "color": None, "room": code.upper()}
260
+
261
+ @staticmethod
262
+ def start_game() -> dict:
263
+ return {"type": "start_game"}
264
+
265
+ @staticmethod
266
+ def quit() -> dict:
267
+ return {"type": "quit"}
268
+
269
+ @staticmethod
270
+ def move(index: int) -> dict:
271
+ return {"type": "game_action", "action": {"type": "move", "moveIndex": index}}
272
+
273
+ @staticmethod
274
+ def double() -> dict:
275
+ return {"type": "game_action", "action": {"type": "double"}}
276
+
277
+ @staticmethod
278
+ def answer(take: bool) -> dict:
279
+ kind = "accept_double" if take else "reject_double"
280
+ return {"type": "game_action", "action": {"type": kind}}
281
+
282
+ @staticmethod
283
+ def resign() -> dict:
284
+ return {"type": "game_action", "action": {"type": "resign"}}
285
+
286
+
287
+ class Connection:
288
+ """A WebSocket on its own thread, with a queue each way.
289
+
290
+ ``poll`` and ``send`` are safe to call from the game's frame loop and never
291
+ block it. ``websockets`` is imported here and only here, so a player who
292
+ never goes online never needs it installed.
293
+ """
294
+
295
+ def __init__(self, url: str):
296
+ self.url = url
297
+ self.status = "connecting"
298
+ self.error = ""
299
+ self._inbox: queue.Queue[dict] = queue.Queue()
300
+ self._outbox: queue.Queue[dict | None] = queue.Queue()
301
+ self._thread = threading.Thread(target=self._run, name="backgammon-online",
302
+ daemon=True)
303
+ self._thread.start()
304
+
305
+ def send(self, msg: dict) -> None:
306
+ self._outbox.put(msg)
307
+
308
+ def poll(self) -> list[dict]:
309
+ out = []
310
+ while True:
311
+ try:
312
+ out.append(self._inbox.get_nowait())
313
+ except queue.Empty:
314
+ return out
315
+
316
+ def close(self) -> None:
317
+ self._outbox.put(None)
318
+
319
+ @property
320
+ def open(self) -> bool:
321
+ return self.status == "open"
322
+
323
+ def _run(self) -> None:
324
+ import asyncio
325
+
326
+ try:
327
+ asyncio.run(self._main())
328
+ except Exception as exc: # noqa: BLE001 - reported to the screen
329
+ self.status, self.error = "closed", str(exc) or type(exc).__name__
330
+
331
+ async def _main(self) -> None:
332
+ import asyncio
333
+
334
+ try:
335
+ from websockets.asyncio.client import connect
336
+ except ImportError:
337
+ self.status = "closed"
338
+ self.error = 'needs websockets: pip install "magmacrunch-backgammon[online]"'
339
+ return
340
+
341
+ # The full list of legal moves rides in every state. Doubles early in
342
+ # a game run to thousands of moves, which can pass websockets' default
343
+ # limit of 1 MiB a frame; the browser has none.
344
+ async with connect(self.url, open_timeout=10, max_size=16 * 2**20) as ws:
345
+ self.status = "open"
346
+
347
+ async def reader():
348
+ async for raw in ws:
349
+ try:
350
+ self._inbox.put(json.loads(raw))
351
+ except json.JSONDecodeError:
352
+ continue
353
+
354
+ read = asyncio.create_task(reader())
355
+ loop = asyncio.get_running_loop()
356
+ try:
357
+ while not read.done():
358
+ # A short wait rather than a blocking one, so a server that
359
+ # hangs up is noticed within a fifth of a second even when
360
+ # the player has nothing to send.
361
+ msg = await loop.run_in_executor(None, self._next_out)
362
+ if msg is _IDLE:
363
+ continue
364
+ if msg is None:
365
+ break
366
+ await ws.send(json.dumps(msg))
367
+ if read.done() and not read.cancelled() and read.exception():
368
+ self.error = str(read.exception())
369
+ finally:
370
+ read.cancel()
371
+ self.status = "closed"
372
+ if not self.error:
373
+ self.error = "the server closed the connection"
374
+
375
+ def _next_out(self):
376
+ try:
377
+ return self._outbox.get(timeout=0.2)
378
+ except queue.Empty:
379
+ return _IDLE
380
+
381
+
382
+ __all__ = ["DEFAULT_SERVER", "PHASE_STATE", "Connection", "Table", "mirror"]