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 +0 -0
- backgammon/__main__.py +31 -0
- backgammon/app.py +73 -0
- backgammon/arcade.py +39 -0
- backgammon/online.py +382 -0
- backgammon/online_scenes.py +398 -0
- backgammon/rules.py +495 -0
- backgammon/scenes.py +709 -0
- backgammon/theme.py +70 -0
- magmacrunch_backgammon-0.1.0.dist-info/METADATA +190 -0
- magmacrunch_backgammon-0.1.0.dist-info/RECORD +15 -0
- magmacrunch_backgammon-0.1.0.dist-info/WHEEL +4 -0
- magmacrunch_backgammon-0.1.0.dist-info/entry_points.txt +5 -0
- magmacrunch_backgammon-0.1.0.dist-info/licenses/LICENSE +75 -0
- magmacrunch_backgammon-0.1.0.dist-info/licenses/NOTICE +30 -0
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"]
|