player-prepper 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.
@@ -0,0 +1,3 @@
1
+ """Player Prepper -- scout an opponent and measure your preparation against them."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,223 @@
1
+ """Board images, rendered server-side as SVG.
2
+
3
+ python-chess draws the board itself, so a diagram needs nothing beyond the
4
+ core dependency. That matters here because a gap is only understandable as a
5
+ picture: "you have no answer after 1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6
6
+ 5.Nc3 a6" is a sentence nobody can hold in their head, and the same thing as
7
+ a diagram is obvious.
8
+
9
+ The palette matches the sibling apps so a position looks the same here, in the
10
+ repertoire editor and in the printed book.
11
+
12
+ The board is also playable: you can carry on from any position the report puts
13
+ in front of you. Legality is decided here rather than in the browser, which
14
+ keeps the page free of a chess library -- the same choice both sibling apps
15
+ made. That costs a request per move and buys one source of truth about what
16
+ is legal.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ import chess
24
+ import chess.svg
25
+
26
+ BOARD_COLORS = {
27
+ "square light": "#f0d9b5",
28
+ "square dark": "#b58863",
29
+ "square light lastmove": "#cdd26a",
30
+ "square dark lastmove": "#aaa23a",
31
+ "margin": "#f7f2e8",
32
+ "coord": "#5c4a33",
33
+ "arrow green": "#15781baa",
34
+ "arrow red": "#882020aa",
35
+ "arrow yellow": "#e68f00aa",
36
+ "arrow blue": "#003088aa",
37
+ }
38
+
39
+
40
+ def _arrow(uci: str, color: str):
41
+ """One ``e2e4`` into a python-chess arrow, or None if it is nonsense."""
42
+ if not uci or len(uci) < 4:
43
+ return None
44
+ try:
45
+ move = chess.Move.from_uci(uci[:5])
46
+ except ValueError:
47
+ return None
48
+ return chess.svg.Arrow(move.from_square, move.to_square, color=color)
49
+
50
+
51
+ def board_svg(
52
+ fen: str,
53
+ *,
54
+ size: int = 360,
55
+ flipped: bool = False,
56
+ last_move: str = "",
57
+ arrows: str = "",
58
+ coordinates: bool = True,
59
+ ) -> str:
60
+ """A diagram of one position.
61
+
62
+ ``arrows`` is the compact query form the browser sends:
63
+ ``blue:e2e4,yellow:g1f3``. The colours mean one thing each, everywhere:
64
+ **blue** is what the engine suggests, **yellow** is the next move of the
65
+ line you are looking at, and **green** is a move of your own.
66
+ """
67
+ board = chess.Board(fen)
68
+
69
+ shapes = []
70
+ for item in filter(None, (arrows or "").split(",")):
71
+ color, _, uci = item.partition(":")
72
+ arrow = _arrow(uci, color or "green")
73
+ if arrow is not None:
74
+ shapes.append(arrow)
75
+
76
+ move = None
77
+ if last_move and len(last_move) >= 4:
78
+ try:
79
+ move = chess.Move.from_uci(last_move[:5])
80
+ except ValueError:
81
+ move = None
82
+
83
+ return chess.svg.board(
84
+ board,
85
+ size=size,
86
+ orientation=chess.BLACK if flipped else chess.WHITE,
87
+ lastmove=move,
88
+ arrows=shapes,
89
+ coordinates=coordinates,
90
+ check=board.king(board.turn) if board.is_check() else None,
91
+ colors=BOARD_COLORS,
92
+ )
93
+
94
+
95
+ _MARGIN_CACHE: dict = {}
96
+
97
+
98
+ def margin_fraction(coordinates: bool = True) -> float:
99
+ """How much of the image is coordinate margin, as a fraction of one side.
100
+
101
+ With coordinates on, python-chess draws the rank and file labels *inside*
102
+ the SVG: the image is 390 units across but the 8x8 board only occupies
103
+ 15..375 of it. A click overlay stretched across the whole image is
104
+ therefore off by that margin on every square -- a bug you see rather than
105
+ read about, as a piece that picks up the wrong square near the edge.
106
+
107
+ Measured from a real SVG rather than taken from ``chess.svg.MARGIN``,
108
+ because that constant does not match what the library actually emits and
109
+ either one could change in a future version. Same approach, and the same
110
+ reason, as Repertoire-Creator's copy.
111
+ """
112
+ if coordinates in _MARGIN_CACHE:
113
+ return _MARGIN_CACHE[coordinates]
114
+
115
+ svg = chess.svg.board(chess.Board(), size=400, coordinates=coordinates)
116
+ fraction = 0.0
117
+ view_box = re.search(r'viewBox="[\d.]+ [\d.]+ ([\d.]+) ', svg)
118
+ # Must be a *square* rect: the first rect in the document is the board
119
+ # background, which spans the margin too and would measure zero.
120
+ square = re.search(
121
+ r'<rect x="([\d.]+)" y="[\d.]+" width="([\d.]+)"[^>]*class="square', svg)
122
+ if view_box and square:
123
+ total = float(view_box.group(1))
124
+ board_size = float(square.group(2)) * 8
125
+ if total > 0 < board_size <= total:
126
+ fraction = (total - board_size) / 2.0 / total
127
+
128
+ _MARGIN_CACHE[coordinates] = fraction
129
+ return fraction
130
+
131
+
132
+ def legal_moves(fen: str) -> dict:
133
+ """Legal destinations per origin square, for click-to-move.
134
+
135
+ The browser has no chess library -- deliberately, as in both sibling apps
136
+ -- so legality is decided here and the page only ever draws what it is
137
+ told.
138
+ """
139
+ board = chess.Board(fen)
140
+ out: dict = {}
141
+ for move in board.legal_moves:
142
+ out.setdefault(chess.square_name(move.from_square), set()).add(
143
+ chess.square_name(move.to_square))
144
+
145
+ return {
146
+ "moves": {key: sorted(value) for key, value in out.items()},
147
+ "turn": "white" if board.turn == chess.WHITE else "black",
148
+ "check": board.is_check(),
149
+ "gameOver": board.is_game_over(),
150
+ }
151
+
152
+
153
+ def play_move(fen: str, uci: str) -> dict:
154
+ """Play one move and report the position it reaches.
155
+
156
+ Promotions are filled in as a queen when the browser sends a bare
157
+ ``e7e8``: offering an under-promotion picker in a scouting tool is UI
158
+ nobody would use, and refusing the move outright would look broken.
159
+ """
160
+ board = chess.Board(fen)
161
+ try:
162
+ move = chess.Move.from_uci(uci)
163
+ except (ValueError, AssertionError) as exc:
164
+ raise ValueError(f"{uci} is not a move.") from exc
165
+
166
+ if move not in board.legal_moves and move.promotion is None:
167
+ promoted = chess.Move(move.from_square, move.to_square,
168
+ promotion=chess.QUEEN)
169
+ if promoted in board.legal_moves:
170
+ move = promoted
171
+
172
+ if move not in board.legal_moves:
173
+ raise ValueError(f"{uci} is not legal in that position.")
174
+
175
+ san = board.san(move)
176
+ board.push(move)
177
+ return {
178
+ "fen": board.fen(),
179
+ "uci": move.uci(),
180
+ "san": san,
181
+ "turn": "white" if board.turn == chess.WHITE else "black",
182
+ "check": board.is_check(),
183
+ "gameOver": board.is_game_over(),
184
+ }
185
+
186
+
187
+ def line_positions(ucis) -> dict:
188
+ """Every position along a line, so the board can be stepped through.
189
+
190
+ One request per line rather than one per ply: the wheel has to feel
191
+ instant, and a round trip for each notch would not. Stops at the first
192
+ move that will not play and says how far it got, which keeps a stale
193
+ cached line usable instead of erroring.
194
+ """
195
+ board = chess.Board()
196
+ fens = [board.fen()]
197
+ sans = []
198
+ played = []
199
+
200
+ for uci in ucis or []:
201
+ try:
202
+ move = chess.Move.from_uci(uci)
203
+ except (ValueError, AssertionError):
204
+ break
205
+ if move not in board.legal_moves:
206
+ break
207
+ sans.append(board.san(move))
208
+ board.push(move)
209
+ fens.append(board.fen())
210
+ played.append(uci)
211
+
212
+ return {"fens": fens, "sans": sans, "uci": played,
213
+ "complete": len(played) == len(list(ucis or []))}
214
+
215
+
216
+ __all__ = [
217
+ "BOARD_COLORS",
218
+ "board_svg",
219
+ "legal_moves",
220
+ "line_positions",
221
+ "margin_fraction",
222
+ "play_move",
223
+ ]