pgn-postmortem 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.
- pgn_postmortem/__init__.py +39 -0
- pgn_postmortem/__main__.py +5 -0
- pgn_postmortem/analysis.py +288 -0
- pgn_postmortem/cli.py +159 -0
- pgn_postmortem/collection.py +400 -0
- pgn_postmortem/site.py +1544 -0
- pgn_postmortem/static/history.js +363 -0
- pgn_postmortem-0.1.0.dist-info/METADATA +327 -0
- pgn_postmortem-0.1.0.dist-info/RECORD +13 -0
- pgn_postmortem-0.1.0.dist-info/WHEEL +5 -0
- pgn_postmortem-0.1.0.dist-info/entry_points.txt +2 -0
- pgn_postmortem-0.1.0.dist-info/licenses/LICENSE +21 -0
- pgn_postmortem-0.1.0.dist-info/top_level.txt +1 -0
pgn_postmortem/site.py
ADDED
|
@@ -0,0 +1,1544 @@
|
|
|
1
|
+
"""The static site: one Wikipedia-style article per game, and an index of the
|
|
2
|
+
games by year.
|
|
3
|
+
|
|
4
|
+
The site is HTML and one stylesheet, with a small script inlined in every
|
|
5
|
+
page for the optional reading history (below), written to an output
|
|
6
|
+
directory::
|
|
7
|
+
|
|
8
|
+
index.html the games by year, each linking to its article
|
|
9
|
+
games/<date>-<id>.html one article per game, named like the game's PGN file
|
|
10
|
+
quiz.html the player's own critical moments, worst first
|
|
11
|
+
(below), when the site has a player
|
|
12
|
+
assets/style.css the layout, the colours (light or dark, as the OS
|
|
13
|
+
says) and the one piece set every diagram uses
|
|
14
|
+
|
|
15
|
+
Every link within the site is relative and names a file (``../index.html``,
|
|
16
|
+
not ``../``), so the site works from a web server, from a folder copied to a
|
|
17
|
+
phone, and from ``file://``. The only absolute links are the two lichess
|
|
18
|
+
links of an article (below), which open in a new tab, and lichess is reached
|
|
19
|
+
only when the reader taps one. Nothing is loaded from the network. The only
|
|
20
|
+
JavaScript is the optional reading history (below), inlined in every page;
|
|
21
|
+
without it the pages read the same.
|
|
22
|
+
|
|
23
|
+
An article has an infobox (the players, the event, the result, the final
|
|
24
|
+
position), a lead paragraph, the moves with notes, a diagram and a question at
|
|
25
|
+
each critical moment, a conclusion and the game's PGN. All prose comes from
|
|
26
|
+
templates (the LLM is F-2 in ``ROADMAP.md``).
|
|
27
|
+
|
|
28
|
+
**The lichess links** (ROADMAP.md, F-10). Under the final position, the
|
|
29
|
+
infobox links "Open this game on lichess" to lichess's analysis board with
|
|
30
|
+
the game's moves, ``https://lichess.org/analysis/pgn/<moves>``: the mainline
|
|
31
|
+
in SAN without move numbers and without the check and mate signs (a ``+`` in
|
|
32
|
+
the path reads as a space), separated by spaces, URL-encoded
|
|
33
|
+
(``lichess_game_url``). A game that does not start from the standard position
|
|
34
|
+
(a ``FEN`` header that sets up another one) or has no moves has no game link.
|
|
35
|
+
Inside each critical moment's hidden answer, "Analyze this position on
|
|
36
|
+
lichess" links to ``https://lichess.org/analysis/<FEN>``, the question's
|
|
37
|
+
position (before the move), with the FEN's spaces written as ``_``
|
|
38
|
+
(``lichess_position_url``); it sits in the answer because an engine on the
|
|
39
|
+
question's position gives the answer away. Both links carry
|
|
40
|
+
``target="_blank"`` and ``rel="noopener noreferrer"``, with or without the
|
|
41
|
+
reading history.
|
|
42
|
+
|
|
43
|
+
**Critical moments.** They come from the ``[%eval]`` comments the analysis
|
|
44
|
+
step writes (``pgn_postmortem.analysis``), and only from those: a game read
|
|
45
|
+
without the ``PostmortemAnalysis`` header has none, whatever comments its
|
|
46
|
+
source carried. For every move, the mover's winning chances before and after
|
|
47
|
+
it are the lichess win percentages (``analysis.win_percent``) of the
|
|
48
|
+
evaluation of the position before the move (the ``[%eval]`` of the previous
|
|
49
|
+
move) and after it (its own ``[%eval]``; a move that gives checkmate has none
|
|
50
|
+
and counts as 100 for the side that gave it). The difference is what the
|
|
51
|
+
move lost; it is graded with the analysis step's thresholds
|
|
52
|
+
(``analysis.classify``: 10/20/30 points by default, lichess's), so the grades
|
|
53
|
+
here are the NAGs the analysis step wrote. **A critical moment is a move that
|
|
54
|
+
lost at least ``thresholds.mistake`` points (20 by default), a mistake or a
|
|
55
|
+
blunder, or an outcome swing** (below). Other graded moves get a note but no
|
|
56
|
+
diagram. The first move of a game has no evaluation before it (the analysis
|
|
57
|
+
step does not write the start position's), so it is never graded; from the
|
|
58
|
+
standard start no single move loses that much.
|
|
59
|
+
|
|
60
|
+
**Outcome swings** (ROADMAP.md, F-6). Each analyzed position gets an
|
|
61
|
+
*expected outcome* from White's winning chances after the move: White winning
|
|
62
|
+
at ``outcome_bands[1]`` (60 by default) or more, Black winning at
|
|
63
|
+
``outcome_bands[0]`` (40) or less, level in between. A move is an outcome
|
|
64
|
+
swing when all three hold:
|
|
65
|
+
|
|
66
|
+
1. it makes the expected outcome worse for the side that played it (winning
|
|
67
|
+
to level, level to losing, or winning to losing);
|
|
68
|
+
2. it cost that side at least ``thresholds.inaccuracy`` points (10 by
|
|
69
|
+
default), so every swing is a graded move;
|
|
70
|
+
3. a better move exists: the engine's first choice in the position before
|
|
71
|
+
the move differs from the move played. The first choice is the first move
|
|
72
|
+
of any engine line stored at that position: the move's own better line,
|
|
73
|
+
or the previous move's refutation, which is the engine's line from the
|
|
74
|
+
same position. With no line stored there the move is not a swing either.
|
|
75
|
+
With the thresholds the analysis used, no line there means the engine's
|
|
76
|
+
first choice was the move played (the analysis stores a graded move's
|
|
77
|
+
better line whenever the engine's first choice differs); with a lower
|
|
78
|
+
inaccuracy threshold than the analysis used, a move graded between the
|
|
79
|
+
two thresholds may have no line of its own, and then nothing is known
|
|
80
|
+
about a better move. Either way there is no better move to show.
|
|
81
|
+
|
|
82
|
+
A swing is a critical moment even when it cost less than
|
|
83
|
+
``thresholds.mistake``; a move that is both is one moment. The note on a
|
|
84
|
+
swing, in the moves and in the answer, says how the expected result changed
|
|
85
|
+
("an inaccuracy that turned a level game into a losing one"). A move that
|
|
86
|
+
changed the band without meeting all three conditions gets no such words.
|
|
87
|
+
|
|
88
|
+
At each critical moment the article shows the position before the move, from
|
|
89
|
+
the side of the player to move, and asks "What would you play?"; the answer
|
|
90
|
+
(the engine's better move and line, then what the move played allowed) is in a
|
|
91
|
+
``<details>`` element, hidden until tapped. The engine lines are the
|
|
92
|
+
variations the analysis step attached: the best line from the position before
|
|
93
|
+
the move, and the refutation after it.
|
|
94
|
+
|
|
95
|
+
**Results that were not recorded** (ROADMAP.md, F-5). For a game whose
|
|
96
|
+
``Result`` is ``*`` or missing, the site shows the result ``shown_result``
|
|
97
|
+
gives, in this order: the board's, if the final position is checkmate (the
|
|
98
|
+
mating side wins), stalemate or insufficient material (a draw); else, for an
|
|
99
|
+
analyzed game whose final move carries an ``[%eval]``, a win for the side
|
|
100
|
+
with at least ``presume_threshold`` (70 by default, 55 to 95) winning chances
|
|
101
|
+
by ``analysis.win_percent`` (a forced mate counts as 100), and a draw
|
|
102
|
+
otherwise; else the site says the result was not recorded. The result is
|
|
103
|
+
shown like a recorded one, with no marker, everywhere outside the PGN section
|
|
104
|
+
(the infobox, the lead, the end of the moves, the conclusion, the index).
|
|
105
|
+
The game itself is untouched: its ``Result`` header, so its id and its file
|
|
106
|
+
name, and the article's PGN section stay as the source had them. A recorded
|
|
107
|
+
result is always shown as recorded.
|
|
108
|
+
|
|
109
|
+
**The quiz** (ROADMAP.md, F-9). ``build_site`` takes the player's name and
|
|
110
|
+
aliases (``player``, ``aliases``), matched as ``Collection.read`` matches them:
|
|
111
|
+
letter case and surrounding spaces are ignored. A site without a player (no
|
|
112
|
+
name given, or only blank ones) has no quiz page and no link to it, since no
|
|
113
|
+
move is the player's own; a rebuild without a player removes the
|
|
114
|
+
``quiz.html`` an earlier build wrote (it carries the generator marker,
|
|
115
|
+
``GENERATOR``), and never a ``quiz.html`` without it. With a player, the
|
|
116
|
+
index links at its top to ``quiz.html``, which lists every critical moment
|
|
117
|
+
(the rules above) played by the player, in every game where White or Black
|
|
118
|
+
is one of the player's names (both sides when both are), and none of the
|
|
119
|
+
opponents'. They are ordered by the winning chances the move lost (the
|
|
120
|
+
exact value, not the rounded one shown), the most first; ties go by the
|
|
121
|
+
game's position in the index, then the article's file name, then move
|
|
122
|
+
order. Each line shows its rank, the move played (``30... Rd2``), the points
|
|
123
|
+
it lost and the game (its date and the opponent), and links to the
|
|
124
|
+
question in its article (``games/<file>.html#moment-N``); the page shows no
|
|
125
|
+
diagram and no answer. A player without a critical moment of their own gets
|
|
126
|
+
a quiz page that says so.
|
|
127
|
+
|
|
128
|
+
**The reading history** (ROADMAP.md, F-8). Every page carries, inline and
|
|
129
|
+
byte for byte, the script ``pgn_postmortem/static/history.js`` (package
|
|
130
|
+
data). In the reader's browser only (``localStorage``, never sent anywhere)
|
|
131
|
+
it records a game as viewed when its article is opened, and an answer as
|
|
132
|
+
revealed when its ``<details class="answer">`` is opened, keyed by its move
|
|
133
|
+
(``31b``: Black's 31st), not by the moment's number, which changes when the
|
|
134
|
+
rules for critical moments do. The index shows a "Recently viewed" list (the
|
|
135
|
+
latest 10 of its own games, newest first), a mark on each viewed game with
|
|
136
|
+
how many of its current questions' answers were revealed ("2/4"), and a
|
|
137
|
+
"Clear history" button that removes this site's history after a
|
|
138
|
+
confirmation. The quiz page marks "answered" each question whose answer was
|
|
139
|
+
revealed, keeping the order; it has no button of its own, and the index's
|
|
140
|
+
clears its marks too. For it the pages carry these ``data-`` attributes and
|
|
141
|
+
no others: ``data-site`` on ``<html>`` (the site key), ``data-game`` on the
|
|
142
|
+
article (the ``PostmortemId``), ``data-move`` on each answer,
|
|
143
|
+
``data-game`` and ``data-moves`` (the moves of its current questions) on
|
|
144
|
+
each game in the index, and ``data-game`` and ``data-move`` on each line of
|
|
145
|
+
the quiz. Every stored key starts with ``pgn-postmortem:`` and
|
|
146
|
+
the site key, which the pages carry so that sites sharing an origin (all
|
|
147
|
+
GitHub Pages sites of one user, or ``file://`` pages in some browsers) keep
|
|
148
|
+
separate histories; by default it is derived from the title
|
|
149
|
+
(``default_site_key``), so two sites with the same title share one. The
|
|
150
|
+
index's history section is in the HTML with the ``hidden`` attribute, and
|
|
151
|
+
only the script shows it; its styling is in the script too, added when it
|
|
152
|
+
shows something, so ``assets/style.css`` is the same with or without the
|
|
153
|
+
history. With scripts off, or with no storage, the pages read as they would
|
|
154
|
+
without the history. ``build_site(..., history=False)`` leaves out the
|
|
155
|
+
script, the section and the ``data-`` attributes.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
from __future__ import annotations
|
|
159
|
+
|
|
160
|
+
import hashlib
|
|
161
|
+
import html
|
|
162
|
+
import re
|
|
163
|
+
import unicodedata
|
|
164
|
+
from collections.abc import Iterable
|
|
165
|
+
from dataclasses import dataclass, field
|
|
166
|
+
from importlib import resources
|
|
167
|
+
from pathlib import Path
|
|
168
|
+
from urllib.parse import quote
|
|
169
|
+
|
|
170
|
+
import chess
|
|
171
|
+
import chess.engine
|
|
172
|
+
import chess.pgn
|
|
173
|
+
import chess.svg
|
|
174
|
+
|
|
175
|
+
from pgn_postmortem.analysis import LICHESS_THRESHOLDS, MATE_SCORE, Thresholds, classify, win_percent
|
|
176
|
+
from pgn_postmortem.collection import (
|
|
177
|
+
ANALYSIS_HEADER,
|
|
178
|
+
CollectedGame,
|
|
179
|
+
Collection,
|
|
180
|
+
date_fields,
|
|
181
|
+
file_stem,
|
|
182
|
+
format_game,
|
|
183
|
+
is_number,
|
|
184
|
+
player_names,
|
|
185
|
+
strip_game,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
GRADES = {
|
|
189
|
+
chess.pgn.NAG_DUBIOUS_MOVE: ("inaccuracy", "An inaccuracy", "?!"),
|
|
190
|
+
chess.pgn.NAG_MISTAKE: ("mistake", "A mistake", "?"),
|
|
191
|
+
chess.pgn.NAG_BLUNDER: ("blunder", "A blunder", "??"),
|
|
192
|
+
}
|
|
193
|
+
POSITION_SYMBOLS = {10: "=", 13: "∞", 14: "⩲", 15: "⩱", 16: "±", 17: "∓", 18: "+−", 19: "−+"}
|
|
194
|
+
RESULTS = {"1-0": "1–0", "0-1": "0–1", "1/2-1/2": "½–½"}
|
|
195
|
+
NOT_RECORDED = "*" # the PGN result of a game in progress or with an unknown result
|
|
196
|
+
PRESUME_THRESHOLD = 70.0 # the winning chances (%) that make an unrecorded result a win (owner, 2026-09-24)
|
|
197
|
+
PRESUME_THRESHOLD_RANGE = (55.0, 95.0)
|
|
198
|
+
# White's winning chances (%) at or below which Black is winning, and at or above which White is. The owner
|
|
199
|
+
# changed the shaping's 35/65 to 40/60 on 2026-09-25, so that the example game's 15. Nxc5 (47% -> 37%) is a question.
|
|
200
|
+
OUTCOME_BANDS = (40.0, 60.0)
|
|
201
|
+
OUTCOMES = ("losing", "level", "winning") # the expected outcome for one side, from worst to best
|
|
202
|
+
CHANGES = {
|
|
203
|
+
("winning", "level"): "a winning game into a level one",
|
|
204
|
+
("level", "losing"): "a level game into a losing one",
|
|
205
|
+
("winning", "losing"): "a winning game into a losing one",
|
|
206
|
+
}
|
|
207
|
+
MONTHS = [
|
|
208
|
+
"January", "February", "March", "April", "May", "June",
|
|
209
|
+
"July", "August", "September", "October", "November", "December",
|
|
210
|
+
] # fmt: skip
|
|
211
|
+
NUMBER_WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]
|
|
212
|
+
NBSP = "\u00a0" # between a move number and its move, so a line never breaks between them
|
|
213
|
+
GENERATOR = '<meta name="generator" content="pgn-postmortem">' # in every page; marks what a rebuild may delete
|
|
214
|
+
QUIZ_PAGE = "quiz.html" # the quiz list of the player's own mistakes (ROADMAP.md, F-9)
|
|
215
|
+
SITE_KEY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") # the reading history's site key (ROADMAP.md, F-8)
|
|
216
|
+
LICHESS_ANALYSIS = "https://lichess.org/analysis/" # lichess's analysis board (ROADMAP.md, F-10)
|
|
217
|
+
NEW_TAB = 'target="_blank" rel="noopener noreferrer"' # the lichess links open in a new tab, so the book stays open
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --- reading the analysis ---------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True)
|
|
224
|
+
class MoveReview:
|
|
225
|
+
"""One mainline move and what the analysis says about it. Without
|
|
226
|
+
analysis (or for the first move) ``before``/``after`` are None and the
|
|
227
|
+
move is not graded."""
|
|
228
|
+
|
|
229
|
+
node: chess.pgn.ChildNode
|
|
230
|
+
before: float | None # the mover's winning chances (0-100) before the move
|
|
231
|
+
after: float | None # and after it
|
|
232
|
+
score_before: chess.engine.PovScore | None
|
|
233
|
+
score_after: chess.engine.PovScore | None
|
|
234
|
+
grade: int | None # NAG_DUBIOUS_MOVE, NAG_MISTAKE, NAG_BLUNDER or None
|
|
235
|
+
critical: bool
|
|
236
|
+
# the mover's expected outcome before and after the move ("losing", "level", "winning"), when analyzed
|
|
237
|
+
outcomes: tuple[str, str] | None = None
|
|
238
|
+
swing: bool = False # an outcome swing (the rule is in the module docstring)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def mover(self) -> chess.Color:
|
|
242
|
+
return not self.node.turn()
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def board_before(self) -> chess.Board:
|
|
246
|
+
return self.node.parent.board()
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def symbol(self) -> str:
|
|
250
|
+
return GRADES[self.grade][2] if self.grade else ""
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def change(self) -> str:
|
|
254
|
+
"""``turned a level game into a losing one`` for a swing, else nothing."""
|
|
255
|
+
return f"turned {CHANGES[self.outcomes]}" if self.swing else ""
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def loss(self) -> float:
|
|
259
|
+
"""The winning chances the move cost the mover, exactly as graded
|
|
260
|
+
(0 when it gained, or without analysis)."""
|
|
261
|
+
if self.before is None or self.after is None:
|
|
262
|
+
return 0.0
|
|
263
|
+
return max(self.before - self.after, 0.0)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_analyzed(game: chess.pgn.Game) -> bool:
|
|
267
|
+
"""Whether the game carries the analysis step's marker header."""
|
|
268
|
+
return ANALYSIS_HEADER in game.headers
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def white_cp(node: chess.pgn.ChildNode) -> tuple[int | None, chess.engine.PovScore | None]:
|
|
272
|
+
"""The White-POV evaluation after ``node`` in centipawns (a mate as
|
|
273
|
+
``MATE_SCORE`` minus its length, as the analysis step counts it), and the
|
|
274
|
+
score it came from; a checkmate counts as a full mate for the side that
|
|
275
|
+
gave it."""
|
|
276
|
+
score = node.eval()
|
|
277
|
+
if score is not None:
|
|
278
|
+
return score.white().score(mate_score=MATE_SCORE), score
|
|
279
|
+
if node.board().is_checkmate():
|
|
280
|
+
return (MATE_SCORE if node.turn() == chess.BLACK else -MATE_SCORE), None
|
|
281
|
+
return None, None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def check_outcome_bands(bands: object) -> tuple[float, float]:
|
|
285
|
+
"""``bands`` as ``(lower, upper)``; raise ``ValueError`` unless both are
|
|
286
|
+
finite numbers with 0 < lower < 50 < upper < 100."""
|
|
287
|
+
try:
|
|
288
|
+
lower, upper = bands
|
|
289
|
+
except (TypeError, ValueError):
|
|
290
|
+
lower = upper = None
|
|
291
|
+
numbers = all(isinstance(value, int | float) and not isinstance(value, bool) for value in (lower, upper))
|
|
292
|
+
if not (numbers and 0 < lower < 50 < upper < 100): # also rejects NaN and infinities
|
|
293
|
+
raise ValueError(
|
|
294
|
+
"outcome_bands must be two finite numbers (lower, upper), White's winning chances in percent, "
|
|
295
|
+
f"with 0 < lower < 50 < upper < 100; not {bands!r}"
|
|
296
|
+
)
|
|
297
|
+
return float(lower), float(upper)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def expected_outcome(white: float, bands: tuple[float, float], color: chess.Color) -> str:
|
|
301
|
+
"""``winning``, ``level`` or ``losing`` for ``color``, from White's winning
|
|
302
|
+
chances ``white``: White is winning at ``bands[1]`` or more, Black at
|
|
303
|
+
``bands[0]`` or less."""
|
|
304
|
+
lower, upper = bands
|
|
305
|
+
leader = chess.WHITE if white >= upper else chess.BLACK if white <= lower else None
|
|
306
|
+
return "level" if leader is None else "winning" if leader == color else "losing"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def has_better_move(node: chess.pgn.ChildNode) -> bool:
|
|
310
|
+
"""Whether the engine's first choice before ``node``'s move differs from
|
|
311
|
+
it: some engine line is stored at the position before the move, and none
|
|
312
|
+
starts with the move played (the rule is in the module docstring)."""
|
|
313
|
+
first_choices = {line.move for line in node.parent.variations[1:]}
|
|
314
|
+
return bool(first_choices) and node.move not in first_choices
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def review_moves(
|
|
318
|
+
game: chess.pgn.Game,
|
|
319
|
+
thresholds: Thresholds = LICHESS_THRESHOLDS,
|
|
320
|
+
outcome_bands: tuple[float, float] = OUTCOME_BANDS,
|
|
321
|
+
) -> list[MoveReview]:
|
|
322
|
+
"""Every mainline move with its grade, its expected outcomes, and whether
|
|
323
|
+
it is an outcome swing and a critical moment (the rules are in the module
|
|
324
|
+
docstring). An invalid ``outcome_bands`` raises ``ValueError``."""
|
|
325
|
+
bands = check_outcome_bands(outcome_bands)
|
|
326
|
+
analyzed = is_analyzed(game)
|
|
327
|
+
reviews = []
|
|
328
|
+
cp_before: int | None = None
|
|
329
|
+
score_before: chess.engine.PovScore | None = None
|
|
330
|
+
for node in game.mainline():
|
|
331
|
+
cp_after, score_after = white_cp(node) if analyzed else (None, None)
|
|
332
|
+
before = after = outcomes = None
|
|
333
|
+
grade, critical, swing = None, False, False
|
|
334
|
+
if cp_before is not None and cp_after is not None:
|
|
335
|
+
mover = not node.turn()
|
|
336
|
+
white_before, white_after = win_percent(cp_before), win_percent(cp_after)
|
|
337
|
+
before, after = white_before, white_after
|
|
338
|
+
if mover == chess.BLACK:
|
|
339
|
+
before, after = 100 - before, 100 - after
|
|
340
|
+
loss = max(before - after, 0.0)
|
|
341
|
+
grade = classify(loss, thresholds)
|
|
342
|
+
# the bands are read from White's chances as computed, so an edge set to a position's exact value holds
|
|
343
|
+
outcomes = (expected_outcome(white_before, bands, mover), expected_outcome(white_after, bands, mover))
|
|
344
|
+
# With the clamped ``loss`` at the floor the mover's chances fell, so its band can only stay or
|
|
345
|
+
# worsen: the first condition overlaps the second. Both are kept, as the rule states them.
|
|
346
|
+
swing = (
|
|
347
|
+
OUTCOMES.index(outcomes[1]) < OUTCOMES.index(outcomes[0])
|
|
348
|
+
and loss >= thresholds.inaccuracy
|
|
349
|
+
and has_better_move(node)
|
|
350
|
+
)
|
|
351
|
+
critical = loss >= thresholds.mistake or swing
|
|
352
|
+
reviews.append(MoveReview(node, before, after, score_before, score_after, grade, critical, outcomes, swing))
|
|
353
|
+
cp_before, score_before = cp_after, score_after
|
|
354
|
+
return reviews
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def critical_moments(
|
|
358
|
+
game: chess.pgn.Game,
|
|
359
|
+
thresholds: Thresholds = LICHESS_THRESHOLDS,
|
|
360
|
+
outcome_bands: tuple[float, float] = OUTCOME_BANDS,
|
|
361
|
+
) -> list[MoveReview]:
|
|
362
|
+
"""The game's critical moments, in move order: the moves that lost the
|
|
363
|
+
mover at least ``thresholds.mistake`` points of winning chances, and the
|
|
364
|
+
outcome swings."""
|
|
365
|
+
return [review for review in review_moves(game, thresholds, outcome_bands) if review.critical]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --- the result -----------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def check_presume_threshold(threshold: float) -> None:
|
|
372
|
+
"""Raise ``ValueError`` unless ``threshold`` is from 55 to 95."""
|
|
373
|
+
low, high = PRESUME_THRESHOLD_RANGE
|
|
374
|
+
if not low <= threshold <= high: # also rejects NaN
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f"presume_threshold must be from {low:g} to {high:g} (a side's winning chances in percent), "
|
|
377
|
+
f"not {threshold!r}"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def final_white_chances(game: chess.pgn.Game) -> float | None:
|
|
382
|
+
"""White's winning chances (0-100) in the final position of an analyzed
|
|
383
|
+
game, from the ``[%eval]`` of its last move (a forced mate counts as 100
|
|
384
|
+
for the side with the mate), or None: not analyzed, or no eval there."""
|
|
385
|
+
if not is_analyzed(game):
|
|
386
|
+
return None
|
|
387
|
+
score = game.end().eval()
|
|
388
|
+
if score is None:
|
|
389
|
+
return None
|
|
390
|
+
white = score.white()
|
|
391
|
+
if white.is_mate():
|
|
392
|
+
return 100.0 if white > chess.engine.Cp(0) else 0.0
|
|
393
|
+
return win_percent(white.score())
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def shown_result(game: chess.pgn.Game, presume_threshold: float = PRESUME_THRESHOLD) -> str:
|
|
397
|
+
"""The result the site shows for ``game``, in PGN notation: its
|
|
398
|
+
``Result`` header when one is recorded; otherwise the board's result,
|
|
399
|
+
else the result presumed from the analysis, else ``*`` (not recorded).
|
|
400
|
+
The rule is in the module docstring. ``game`` is not changed."""
|
|
401
|
+
check_presume_threshold(presume_threshold)
|
|
402
|
+
recorded = (game.headers.get("Result") or NOT_RECORDED).strip()
|
|
403
|
+
if recorded not in ("", NOT_RECORDED):
|
|
404
|
+
return recorded
|
|
405
|
+
board = game.end().board()
|
|
406
|
+
if board.is_checkmate():
|
|
407
|
+
return "0-1" if board.turn == chess.WHITE else "1-0"
|
|
408
|
+
if board.is_stalemate() or board.is_insufficient_material():
|
|
409
|
+
return "1/2-1/2"
|
|
410
|
+
white = final_white_chances(game)
|
|
411
|
+
if white is None:
|
|
412
|
+
return NOT_RECORDED
|
|
413
|
+
if white >= presume_threshold:
|
|
414
|
+
return "1-0"
|
|
415
|
+
if 100 - white >= presume_threshold:
|
|
416
|
+
return "0-1"
|
|
417
|
+
return "1/2-1/2"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def engine_line(node: chess.pgn.GameNode) -> tuple[list[chess.Move], str] | None:
|
|
421
|
+
"""The first engine line the analysis step attached off ``node`` (a
|
|
422
|
+
variation, not the mainline), and the symbol of the position it ends in."""
|
|
423
|
+
if len(node.variations) < 2:
|
|
424
|
+
return None
|
|
425
|
+
start = node.variations[1]
|
|
426
|
+
moves = [start.move, *start.mainline_moves()]
|
|
427
|
+
last = start.end()
|
|
428
|
+
symbol = next((POSITION_SYMBOLS[nag] for nag in sorted(last.nags) if nag in POSITION_SYMBOLS), "")
|
|
429
|
+
return moves, symbol
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# --- text helpers -----------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def esc(text: object) -> str:
|
|
436
|
+
"""Text for an element's content."""
|
|
437
|
+
return html.escape(str(text), quote=False)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def attr(text: object) -> str:
|
|
441
|
+
"""Text for a quoted attribute value."""
|
|
442
|
+
return html.escape(str(text), quote=True)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def known(value: str | None) -> str | None:
|
|
446
|
+
value = (value or "").strip()
|
|
447
|
+
return None if value in ("", "?", "-", "??", "????.??.??") else value
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def display_name(name: str | None) -> str:
|
|
451
|
+
"""A player's name as prose wants it: PGN's ``Last, First`` becomes
|
|
452
|
+
``First Last``."""
|
|
453
|
+
name = known(name)
|
|
454
|
+
if name is None:
|
|
455
|
+
return "Unknown"
|
|
456
|
+
if name.count(",") == 1:
|
|
457
|
+
last, first = (part.strip() for part in name.split(","))
|
|
458
|
+
if last and first:
|
|
459
|
+
return f"{first} {last}"
|
|
460
|
+
return name
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def number_word(n: int) -> str:
|
|
464
|
+
return NUMBER_WORDS[n] if 0 <= n < len(NUMBER_WORDS) else str(n)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def plural(n: int, word: str, words: str | None = None) -> str:
|
|
468
|
+
"""``one move``, ``two moves``; a consonant and ``y`` become ``ies``
|
|
469
|
+
(``two inaccuracies``)."""
|
|
470
|
+
if words is None:
|
|
471
|
+
words = word[:-1] + "ies" if word.endswith("y") and word[-2:-1] not in ("", *"aeiou") else word + "s"
|
|
472
|
+
return f"{number_word(n)} {word if n == 1 else words}"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def date_parts(date: str | None) -> tuple[int | None, int | None, int | None]:
|
|
476
|
+
"""The year, month and day as numbers, each None unless it is ASCII digits
|
|
477
|
+
(as ``file_stem`` reads them) and, for the month and day, in range."""
|
|
478
|
+
y, m, d = date_fields(date or "")
|
|
479
|
+
year = int(y) if is_number(y) else None
|
|
480
|
+
month = int(m) if is_number(m) and 1 <= int(m) <= 12 else None
|
|
481
|
+
day = int(d) if is_number(d) and 1 <= int(d) <= 31 and month else None
|
|
482
|
+
return year, month, day
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def format_date(date: str | None) -> str | None:
|
|
486
|
+
"""``2019.03.14`` as ``14 March 2019``; a partial date as far as it goes."""
|
|
487
|
+
year, month, day = date_parts(date)
|
|
488
|
+
if year is None:
|
|
489
|
+
return None
|
|
490
|
+
if month is None:
|
|
491
|
+
return str(year)
|
|
492
|
+
return f"{day} {MONTHS[month - 1]} {year}" if day else f"{MONTHS[month - 1]} {year}"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def format_time_control(value: str | None) -> str | None:
|
|
496
|
+
"""``600`` as ``10 min``, ``180+2`` as ``3 min + 2 s``; anything else as written."""
|
|
497
|
+
value = known(value)
|
|
498
|
+
if value is None:
|
|
499
|
+
return None
|
|
500
|
+
match = re.fullmatch(r"(\d+)(?:\+(\d+))?", value)
|
|
501
|
+
if not match:
|
|
502
|
+
return value
|
|
503
|
+
base, inc = int(match[1]), match[2]
|
|
504
|
+
text = f"{base // 60} min" if base % 60 == 0 else f"{base} s"
|
|
505
|
+
return f"{text} + {int(inc)} s" if inc and int(inc) else text
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def move_label(board: chess.Board, move: chess.Move, symbol: str = "") -> str:
|
|
509
|
+
"""``4. Qxf7#`` or ``3... Nf6``: the move with its number, from ``board``."""
|
|
510
|
+
dots = "." if board.turn == chess.WHITE else "..."
|
|
511
|
+
return f"{board.fullmove_number}{dots}{NBSP}{board.san(move)}{symbol}"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def line_text(board: chess.Board, moves: list[chess.Move]) -> str:
|
|
515
|
+
"""A line of moves in SAN with move numbers, from ``board``."""
|
|
516
|
+
board = board.copy()
|
|
517
|
+
parts = []
|
|
518
|
+
for i, move in enumerate(moves):
|
|
519
|
+
if board.turn == chess.WHITE:
|
|
520
|
+
parts.append(f"{board.fullmove_number}.{NBSP}{board.san(move)}")
|
|
521
|
+
elif i == 0:
|
|
522
|
+
parts.append(f"{board.fullmove_number}...{NBSP}{board.san(move)}")
|
|
523
|
+
else:
|
|
524
|
+
parts.append(board.san(move))
|
|
525
|
+
board.push(move)
|
|
526
|
+
return " ".join(parts)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def format_eval(score: chess.engine.PovScore) -> str:
|
|
530
|
+
white = score.white()
|
|
531
|
+
if white.is_mate():
|
|
532
|
+
mate = white.mate()
|
|
533
|
+
return f"#{mate}" if mate > 0 else f"#−{-mate}"
|
|
534
|
+
return f"{white.score() / 100:+.2f}".replace("-", "−")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def symbol_html(symbol: str) -> str:
|
|
538
|
+
"""A position symbol (``+−``) after a line, kept on the line's last line."""
|
|
539
|
+
return f'{NBSP}<span class="sym">{symbol}</span>' if symbol else ""
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def result_text(result: str, not_recorded: str) -> str:
|
|
543
|
+
"""``1-0`` as ``1–0``; ``*`` as the words ``not_recorded``, never a bare
|
|
544
|
+
``*``; any other value as written."""
|
|
545
|
+
return RESULTS.get(result, not_recorded if result == NOT_RECORDED else result)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def percent(value: float) -> str:
|
|
549
|
+
return f"{value:.0f}%"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def side(color: chess.Color) -> str:
|
|
553
|
+
return "White" if color == chess.WHITE else "Black"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def moves_played(game: chess.pgn.Game) -> int:
|
|
557
|
+
board = game.end().board()
|
|
558
|
+
return board.fullmove_number - (1 if board.turn == chess.WHITE else 0)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def mate_clause(review: MoveReview) -> str:
|
|
562
|
+
"""``it allows mate in 2``, ``it lets a mate in 3 slip``, or nothing."""
|
|
563
|
+
mover = review.mover
|
|
564
|
+
before = review.score_before.pov(mover).mate() if review.score_before else None
|
|
565
|
+
after = review.score_after.pov(mover).mate() if review.score_after else None
|
|
566
|
+
if after is not None and after < 0 and not (before is not None and before < 0):
|
|
567
|
+
return f"it allows mate in {-after}"
|
|
568
|
+
if before is not None and before > 0 and not (after is not None and after > 0):
|
|
569
|
+
return f"it lets a mate in {before} slip"
|
|
570
|
+
return ""
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def grade_words(review: MoveReview) -> str:
|
|
574
|
+
"""``A mistake``, or for an outcome swing ``A mistake that turned a level
|
|
575
|
+
game into a losing one``."""
|
|
576
|
+
_, kind, _ = GRADES[review.grade]
|
|
577
|
+
return f"{kind} that {review.change}" if review.swing else kind
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def note_text(review: MoveReview) -> str:
|
|
581
|
+
"""The note after a graded move in the moves section."""
|
|
582
|
+
kind = grade_words(review)
|
|
583
|
+
clause = mate_clause(review)
|
|
584
|
+
chances = f"{side(review.mover)}'s winning chances fall from {percent(review.before)} to {percent(review.after)}"
|
|
585
|
+
return f"{kind}: {clause}; {chances}." if clause else f"{kind}: {chances}."
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# --- the diagrams -------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
PIECE_CLASSES = {symbol: ("w" if symbol.isupper() else "b") + symbol.upper() for symbol in chess.svg.PIECES}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def piece_css() -> str:
|
|
595
|
+
"""One CSS rule per piece, each an SVG data URI: the one piece set every
|
|
596
|
+
diagram shares (Colin M.L. Burnett's, as python-chess ships it)."""
|
|
597
|
+
rules = []
|
|
598
|
+
for symbol in "KQRBNPkqrbnp":
|
|
599
|
+
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45 45">{chess.svg.PIECES[symbol]}</svg>'
|
|
600
|
+
data = svg.replace('"', "'").replace("#", "%23").replace("<", "%3C").replace(">", "%3E")
|
|
601
|
+
rules.append(f'.{PIECE_CLASSES[symbol]}{{background-image:url("data:image/svg+xml,{data}")}}')
|
|
602
|
+
return "\n".join(rules) + "\n"
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def describe_position(board: chess.Board) -> str:
|
|
606
|
+
"""The pieces in words, for screen readers: ``White: Ke1, Qh5, pawns a2, b2``."""
|
|
607
|
+
parts = []
|
|
608
|
+
for color in (chess.WHITE, chess.BLACK):
|
|
609
|
+
pieces = []
|
|
610
|
+
for piece_type in (chess.KING, chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT):
|
|
611
|
+
letter = chess.piece_symbol(piece_type).upper()
|
|
612
|
+
pieces += [letter + chess.square_name(sq) for sq in board.pieces(piece_type, color)]
|
|
613
|
+
pawns = [chess.square_name(sq) for sq in board.pieces(chess.PAWN, color)]
|
|
614
|
+
if pawns:
|
|
615
|
+
pieces.append(("pawn " if len(pawns) == 1 else "pawns ") + ", ".join(pawns))
|
|
616
|
+
parts.append(f"{side(color)}: {', '.join(pieces) or 'nothing'}")
|
|
617
|
+
return ". ".join(parts) + "."
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def board_html(board: chess.Board, *, flipped: bool = False, highlight: Iterable[int] = (), label: str = "") -> str:
|
|
621
|
+
"""The position as an 8x8 grid of empty elements, one line per rank. Each
|
|
622
|
+
cell draws its own square: its colour (``l`` or ``d``), its piece (a class
|
|
623
|
+
of the shared stylesheet) and the highlight (``hl``), so they share one box
|
|
624
|
+
at any size; the board draws no square pattern of its own, which would
|
|
625
|
+
round differently from the cells when a square is not a whole number of
|
|
626
|
+
pixels. The coordinates are ``data-`` attributes on the edge squares."""
|
|
627
|
+
highlight = set(highlight)
|
|
628
|
+
ranks = range(8) if flipped else range(7, -1, -1)
|
|
629
|
+
files = list(range(7, -1, -1) if flipped else range(8))
|
|
630
|
+
rows = []
|
|
631
|
+
for row, rank in enumerate(ranks):
|
|
632
|
+
cells = []
|
|
633
|
+
for col, file in enumerate(files):
|
|
634
|
+
square = chess.square(file, rank)
|
|
635
|
+
classes = ["l" if chess.BB_LIGHT_SQUARES & chess.BB_SQUARES[square] else "d"]
|
|
636
|
+
piece = board.piece_at(square)
|
|
637
|
+
if piece:
|
|
638
|
+
classes.append(PIECE_CLASSES[piece.symbol()])
|
|
639
|
+
if square in highlight:
|
|
640
|
+
classes.append("hl")
|
|
641
|
+
attrs = f' class="{" ".join(classes)}"'
|
|
642
|
+
if col == 0:
|
|
643
|
+
attrs += f' data-r="{rank + 1}"'
|
|
644
|
+
if row == 7:
|
|
645
|
+
attrs += f' data-f="{chess.FILE_NAMES[file]}"'
|
|
646
|
+
cells.append(f"<i{attrs}></i>")
|
|
647
|
+
rows.append("".join(cells))
|
|
648
|
+
description = f"{label} {describe_position(board)}".strip()
|
|
649
|
+
return f'<div class="board" role="img" aria-label="{attr(description)}">\n' + "\n".join(rows) + "\n</div>"
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# --- the lichess links (ROADMAP.md, F-10) --------------------------------------
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def lichess_game_url(game: chess.pgn.Game) -> str | None:
|
|
656
|
+
"""The game on lichess's analysis board: ``LICHESS_ANALYSIS`` ``pgn/``
|
|
657
|
+
and the mainline moves in SAN, without move numbers and without the check
|
|
658
|
+
and mate signs (``+`` in a path reads as a space), separated by spaces and
|
|
659
|
+
URL-encoded (``e4%20e5%20Qh5``). None for a game that does not start from
|
|
660
|
+
the standard position, or has no moves."""
|
|
661
|
+
board = game.board()
|
|
662
|
+
if board.fen() != chess.STARTING_FEN or game.next() is None:
|
|
663
|
+
return None
|
|
664
|
+
sans = []
|
|
665
|
+
for move in game.mainline_moves():
|
|
666
|
+
sans.append(board.san(move).rstrip("+#"))
|
|
667
|
+
board.push(move)
|
|
668
|
+
return f"{LICHESS_ANALYSIS}pgn/{quote(' '.join(sans), safe='')}"
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def lichess_position_url(board: chess.Board) -> str:
|
|
672
|
+
"""The position on lichess's analysis board: ``LICHESS_ANALYSIS`` and
|
|
673
|
+
its FEN, with the spaces written as ``_``."""
|
|
674
|
+
return LICHESS_ANALYSIS + quote(board.fen().replace(" ", "_"), safe="/")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def lichess_link(url: str, text: str) -> str:
|
|
678
|
+
return f'<a href="{attr(url)}" {NEW_TAB}>{esc(text)}</a>'
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# --- the reading history ------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@dataclass(frozen=True)
|
|
685
|
+
class History:
|
|
686
|
+
"""What the pages carry for the reading history: the site key, and the
|
|
687
|
+
script that every page inlines (the rule is in the module docstring)."""
|
|
688
|
+
|
|
689
|
+
site_key: str
|
|
690
|
+
script: str
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
HISTORY_SECTION = (
|
|
694
|
+
'<section id="history" class="history" hidden>\n'
|
|
695
|
+
"<h2>Recently viewed</h2>\n"
|
|
696
|
+
'<ol id="history-recent" class="games"></ol>\n'
|
|
697
|
+
'<p><button type="button" id="history-clear">Clear history</button> '
|
|
698
|
+
'<span class="note">Kept in this browser only.</span></p>\n'
|
|
699
|
+
"</section>\n"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def history_script() -> str:
|
|
704
|
+
"""The reading-history script, ``pgn_postmortem/static/history.js``, as
|
|
705
|
+
its bytes read (package data)."""
|
|
706
|
+
return (resources.files("pgn_postmortem") / "static" / "history.js").read_bytes().decode("utf-8")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def default_site_key(title: str) -> str:
|
|
710
|
+
"""The site key for a site titled ``title``: its words in ASCII, lower
|
|
711
|
+
case, joined by ``-`` (at most 40 characters), then the first 8 hex
|
|
712
|
+
digits of the title's SHA-256, so that only the same title gives the same
|
|
713
|
+
key: ``Games of Ada Example`` is ``games-of-ada-example-`` and 8 digits."""
|
|
714
|
+
ascii_title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii").lower()
|
|
715
|
+
words = "-".join(re.findall(r"[a-z0-9]+", ascii_title))[:40].strip("-")
|
|
716
|
+
digest = hashlib.sha256(title.encode("utf-8")).hexdigest()[:8]
|
|
717
|
+
return f"{words}-{digest}" if words else digest
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def check_site_key(key: object) -> str:
|
|
721
|
+
"""``key``, or ``ValueError`` unless it is 1 to 64 ASCII letters, digits,
|
|
722
|
+
``.``, ``_`` and ``-``, starting with a letter or a digit."""
|
|
723
|
+
if not (isinstance(key, str) and SITE_KEY.fullmatch(key)):
|
|
724
|
+
raise ValueError(
|
|
725
|
+
"site_key must be 1 to 64 ASCII letters, digits, '.', '_' or '-', starting with a letter or a digit; "
|
|
726
|
+
f"not {key!r}"
|
|
727
|
+
)
|
|
728
|
+
return key
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def data(history: History | None, **values: str) -> str:
|
|
732
|
+
"""`` data-game="…"`` and the like for the reading history, or nothing
|
|
733
|
+
when the pages are built without it."""
|
|
734
|
+
if history is None:
|
|
735
|
+
return ""
|
|
736
|
+
return "".join(f' data-{name}="{attr(value)}"' for name, value in values.items())
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# --- the pages ----------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@dataclass
|
|
743
|
+
class Article:
|
|
744
|
+
"""One game's page, with what the index and the navigation need."""
|
|
745
|
+
|
|
746
|
+
item: CollectedGame
|
|
747
|
+
reviews: list[MoveReview]
|
|
748
|
+
analyzed: bool
|
|
749
|
+
result: str # the result shown (``shown_result``): recorded, from the board, presumed, or "*"
|
|
750
|
+
|
|
751
|
+
@property
|
|
752
|
+
def game(self) -> chess.pgn.Game:
|
|
753
|
+
return self.item.game
|
|
754
|
+
|
|
755
|
+
@property
|
|
756
|
+
def stem(self) -> str:
|
|
757
|
+
return file_stem(self.game, self.item.id)
|
|
758
|
+
|
|
759
|
+
@property
|
|
760
|
+
def filename(self) -> str:
|
|
761
|
+
return f"{self.stem}.html"
|
|
762
|
+
|
|
763
|
+
@property
|
|
764
|
+
def white(self) -> str:
|
|
765
|
+
return display_name(self.game.headers.get("White"))
|
|
766
|
+
|
|
767
|
+
@property
|
|
768
|
+
def black(self) -> str:
|
|
769
|
+
return display_name(self.game.headers.get("Black"))
|
|
770
|
+
|
|
771
|
+
@property
|
|
772
|
+
def year(self) -> int | None:
|
|
773
|
+
return date_parts(self.game.headers.get("Date"))[0]
|
|
774
|
+
|
|
775
|
+
@property
|
|
776
|
+
def title(self) -> str:
|
|
777
|
+
return f"{self.white} vs. {self.black}" + (f", {self.year}" if self.year else "")
|
|
778
|
+
|
|
779
|
+
@property
|
|
780
|
+
def presumed(self) -> bool:
|
|
781
|
+
"""Whether the result shown was not recorded but read from the board
|
|
782
|
+
or presumed from the analysis."""
|
|
783
|
+
return self.result != self.game.headers.get("Result", NOT_RECORDED)
|
|
784
|
+
|
|
785
|
+
@property
|
|
786
|
+
def moments(self) -> list[MoveReview]:
|
|
787
|
+
return [review for review in self.reviews if review.critical]
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def page(title: str, body: str, *, root: str, site_title: str, history: History | None = None,
|
|
791
|
+
home: bool | None = None) -> str: # fmt: skip
|
|
792
|
+
"""A whole page. ``home`` puts the link to the index at the top: by
|
|
793
|
+
default on every page but the index (``root`` is empty only there and on
|
|
794
|
+
the quiz page, which asks for it)."""
|
|
795
|
+
home = root != "" if home is None else home
|
|
796
|
+
home = f'<header class="top"><a href="{root}index.html">{esc(site_title)}</a></header>\n' if home else ""
|
|
797
|
+
return (
|
|
798
|
+
"<!DOCTYPE html>\n"
|
|
799
|
+
f'<html lang="en"{data(history, site=history.site_key) if history else ""}>\n'
|
|
800
|
+
"<head>\n"
|
|
801
|
+
'<meta charset="utf-8">\n'
|
|
802
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
803
|
+
'<meta name="color-scheme" content="light dark">\n'
|
|
804
|
+
f"{GENERATOR}\n"
|
|
805
|
+
f"<title>{esc(title)}</title>\n"
|
|
806
|
+
f'<link rel="stylesheet" href="{root}assets/style.css">\n'
|
|
807
|
+
"</head>\n"
|
|
808
|
+
"<body>\n"
|
|
809
|
+
f"{home}"
|
|
810
|
+
"<main>\n"
|
|
811
|
+
f"{body}"
|
|
812
|
+
"</main>\n"
|
|
813
|
+
'<footer class="bottom">Made with pgn-postmortem. Analysis by Stockfish; '
|
|
814
|
+
"pieces by Colin M.L. Burnett.</footer>\n"
|
|
815
|
+
+ (f"<script>{history.script}</script>\n" if history else "")
|
|
816
|
+
+ "</body>\n"
|
|
817
|
+
"</html>\n"
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def lead_html(article: Article, thresholds: Thresholds) -> str:
|
|
822
|
+
headers = article.game.headers
|
|
823
|
+
date = format_date(headers.get("Date"))
|
|
824
|
+
site, event, round_ = known(headers.get("Site")), known(headers.get("Event")), known(headers.get("Round"))
|
|
825
|
+
where = ""
|
|
826
|
+
if date:
|
|
827
|
+
where += f" on {esc(date)}"
|
|
828
|
+
if site:
|
|
829
|
+
where += f" at {esc(site)}"
|
|
830
|
+
first = f"<b>{esc(article.white)} vs. {esc(article.black)}</b> is a chess game"
|
|
831
|
+
if where:
|
|
832
|
+
first += f" played{where}"
|
|
833
|
+
if event:
|
|
834
|
+
first += f", in round {esc(round_)} of “{esc(event)}”" if round_ else f", part of “{esc(event)}”"
|
|
835
|
+
if not date:
|
|
836
|
+
first += "; its date is not recorded"
|
|
837
|
+
sentences = [first + "."]
|
|
838
|
+
|
|
839
|
+
n = moves_played(article.game)
|
|
840
|
+
moves = plural(n, "move")
|
|
841
|
+
board = article.game.end().board()
|
|
842
|
+
result = article.result
|
|
843
|
+
if result in ("1-0", "0-1"):
|
|
844
|
+
winner = chess.WHITE if result == "1-0" else chess.BLACK
|
|
845
|
+
name = article.white if winner == chess.WHITE else article.black
|
|
846
|
+
how = "by checkmate " if board.is_checkmate() else ""
|
|
847
|
+
sentences.append(f"{esc(name)}, with the {side(winner).lower()} pieces, won {how}in {moves}.")
|
|
848
|
+
elif result == "1/2-1/2":
|
|
849
|
+
how = " by stalemate" if board.is_stalemate() else ""
|
|
850
|
+
sentences.append(f"It was drawn{how} after {moves}.")
|
|
851
|
+
else:
|
|
852
|
+
sentences.append(f"Its result is not recorded; the score stops after {moves}.")
|
|
853
|
+
|
|
854
|
+
opening, eco = known(headers.get("Opening")), known(headers.get("ECO"))
|
|
855
|
+
if opening:
|
|
856
|
+
sentences.append(f"The opening was the {esc(opening)}" + (f" (ECO {esc(eco)})." if eco else "."))
|
|
857
|
+
elif eco:
|
|
858
|
+
sentences.append(f"The opening is classified as ECO {esc(eco)}.")
|
|
859
|
+
|
|
860
|
+
threshold = f"{thresholds.mistake:g}"
|
|
861
|
+
k = len(article.moments)
|
|
862
|
+
if not article.analyzed:
|
|
863
|
+
sentences.append("It has not been analyzed yet, so this article has no notes, critical moments or questions.")
|
|
864
|
+
elif k:
|
|
865
|
+
sentences.append(
|
|
866
|
+
f"The engine found {plural(k, 'critical moment')}, where a single move cost at least {threshold} points "
|
|
867
|
+
f"of winning chances or changed the expected result; {'it is' if k == 1 else 'each is'} a “what would "
|
|
868
|
+
"you play?” question below."
|
|
869
|
+
)
|
|
870
|
+
else:
|
|
871
|
+
sentences.append(
|
|
872
|
+
"The engine found no critical moment: no single move cost either side "
|
|
873
|
+
f"{threshold} points of winning chances."
|
|
874
|
+
)
|
|
875
|
+
return f'<p class="lead">{" ".join(sentences)}</p>\n'
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def infobox_html(article: Article) -> str:
|
|
879
|
+
headers = article.game.headers
|
|
880
|
+
end = article.game.end()
|
|
881
|
+
last = f"after {move_label(end.parent.board(), end.move)}" if end.move else ""
|
|
882
|
+
|
|
883
|
+
def player(color: str) -> str:
|
|
884
|
+
name = esc(article.white if color == "White" else article.black)
|
|
885
|
+
elo = known(headers.get(f"{color}Elo"))
|
|
886
|
+
return f"{name} ({esc(elo)})" if elo else name
|
|
887
|
+
|
|
888
|
+
rows = [
|
|
889
|
+
("White", player("White")),
|
|
890
|
+
("Black", player("Black")),
|
|
891
|
+
("Result", esc(result_text(article.result, "Not recorded"))),
|
|
892
|
+
("Date", esc(format_date(headers.get("Date")) or "Unknown")),
|
|
893
|
+
]
|
|
894
|
+
for label, key in (("Event", "Event"), ("Round", "Round"), ("Site", "Site")):
|
|
895
|
+
if value := known(headers.get(key)):
|
|
896
|
+
rows.append((label, esc(value)))
|
|
897
|
+
if value := format_time_control(headers.get("TimeControl")):
|
|
898
|
+
rows.append(("Time control", esc(value)))
|
|
899
|
+
opening, eco = known(headers.get("Opening")), known(headers.get("ECO"))
|
|
900
|
+
if opening or eco:
|
|
901
|
+
rows.append(("Opening", esc(" ".join(filter(None, [eco, opening])))))
|
|
902
|
+
rows.append(("Moves", str(moves_played(article.game))))
|
|
903
|
+
if value := known(headers.get("Termination")):
|
|
904
|
+
rows.append(("Termination", esc(value)))
|
|
905
|
+
rows.append(("Analysis", esc(headers[ANALYSIS_HEADER]) if article.analyzed else "Not analyzed yet"))
|
|
906
|
+
if article.analyzed:
|
|
907
|
+
links = ", ".join(f'<a href="#moment-{i}">{i}</a>' for i in range(1, len(article.moments) + 1))
|
|
908
|
+
rows.append(("Critical moments", links or "None"))
|
|
909
|
+
# under the final position; none for a set-up position or a game without moves
|
|
910
|
+
game_url = lichess_game_url(article.game)
|
|
911
|
+
lichess = f'\n<div class="lichess">{lichess_link(game_url, "Open this game on lichess")}</div>' if game_url else ""
|
|
912
|
+
|
|
913
|
+
highlight = (end.move.from_square, end.move.to_square) if end.move else ()
|
|
914
|
+
board = board_html(end.board(), highlight=highlight, label=f"The final position{' ' + last if last else ''}.")
|
|
915
|
+
body = "\n".join(f"<tr><th>{label}</th><td>{value}</td></tr>" for label, value in rows)
|
|
916
|
+
return (
|
|
917
|
+
'<table class="infobox">\n'
|
|
918
|
+
f"<caption>{esc(article.title)}</caption>\n"
|
|
919
|
+
f'<tr><td colspan="2" class="figure">\n{board}\n'
|
|
920
|
+
f'<div class="caption">The final position{" " + esc(last) if last else ""}</div>{lichess}</td></tr>\n'
|
|
921
|
+
f"{body}\n"
|
|
922
|
+
"</table>\n"
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def move_key(board: chess.Board) -> str:
|
|
927
|
+
"""The move about to be played on ``board`` as the reading history keys
|
|
928
|
+
it: ``31b`` for Black's 31st move, ``4w`` for White's 4th."""
|
|
929
|
+
return f"{board.fullmove_number}{'w' if board.turn == chess.WHITE else 'b'}"
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def moment_html(review: MoveReview, number: int, history: History | None = None) -> str:
|
|
933
|
+
board = review.board_before
|
|
934
|
+
mover = side(review.mover)
|
|
935
|
+
previous = review.node.parent
|
|
936
|
+
if previous.move is not None:
|
|
937
|
+
where = f"After {move_label(previous.parent.board(), previous.move)}"
|
|
938
|
+
highlight = (previous.move.from_square, previous.move.to_square)
|
|
939
|
+
else:
|
|
940
|
+
where = "The starting position"
|
|
941
|
+
highlight = ()
|
|
942
|
+
caption = f"{where}, {mover} to move."
|
|
943
|
+
diagram = board_html(board, flipped=review.mover == chess.BLACK, highlight=highlight, label=caption)
|
|
944
|
+
|
|
945
|
+
played = move_label(board, review.node.move, review.symbol)
|
|
946
|
+
best = engine_line(review.node.parent)
|
|
947
|
+
if best and best[0][0] != review.node.move:
|
|
948
|
+
moves, symbol = best
|
|
949
|
+
answer = (
|
|
950
|
+
f"<p><b>Best was {esc(move_label(board, moves[0]))}</b>, the engine's line being "
|
|
951
|
+
f"{esc(line_text(board, moves))}{symbol_html(symbol)}. "
|
|
952
|
+
f"With it, {mover}'s winning chances stay at {percent(review.before)}.</p>\n"
|
|
953
|
+
)
|
|
954
|
+
else:
|
|
955
|
+
answer = f"<p>The analysis records no better move than {esc(played)}.</p>\n"
|
|
956
|
+
kind = grade_words(review)
|
|
957
|
+
clause = mate_clause(review)
|
|
958
|
+
answer += (
|
|
959
|
+
f"<p>In the game {mover} played <b>{esc(played)}</b>, {kind.lower()}"
|
|
960
|
+
+ (f": {clause}. " if clause else ". ")
|
|
961
|
+
+ f"{mover}'s winning chances fell from {percent(review.before)} to {percent(review.after)}."
|
|
962
|
+
)
|
|
963
|
+
refutation = engine_line(review.node)
|
|
964
|
+
if refutation:
|
|
965
|
+
moves, symbol = refutation
|
|
966
|
+
answer += f" The refutation: {esc(line_text(review.node.board(), moves))}{symbol_html(symbol)}."
|
|
967
|
+
answer += "</p>\n"
|
|
968
|
+
# inside the hidden answer, since an engine on the question's position gives the answer away
|
|
969
|
+
position = lichess_link(lichess_position_url(board), "Analyze this position on lichess")
|
|
970
|
+
answer += f'<p class="lichess">{position}</p>\n'
|
|
971
|
+
return (
|
|
972
|
+
f'<div class="moment" id="moment-{number}">\n'
|
|
973
|
+
"<figure>\n"
|
|
974
|
+
f"{diagram}\n"
|
|
975
|
+
f"<figcaption>Critical moment {number}. {esc(caption)} <b>What would you play?</b></figcaption>\n"
|
|
976
|
+
"</figure>\n"
|
|
977
|
+
f'<details class="answer"{data(history, move=move_key(board))}>\n'
|
|
978
|
+
"<summary>Show the answer</summary>\n"
|
|
979
|
+
f"{answer}"
|
|
980
|
+
"</details>\n"
|
|
981
|
+
"</div>\n"
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def moves_html(article: Article, history: History | None = None) -> str:
|
|
986
|
+
"""The moves as paragraphs, with a note after each graded move, broken
|
|
987
|
+
by a diagram and a question before each critical moment."""
|
|
988
|
+
blocks: list[str] = []
|
|
989
|
+
tokens: list[str] = []
|
|
990
|
+
need_number = True
|
|
991
|
+
number = 0
|
|
992
|
+
|
|
993
|
+
def flush() -> None:
|
|
994
|
+
if tokens:
|
|
995
|
+
blocks.append(f'<p class="moves">{" ".join(tokens)}</p>\n')
|
|
996
|
+
tokens.clear()
|
|
997
|
+
|
|
998
|
+
for review in article.reviews:
|
|
999
|
+
if review.critical:
|
|
1000
|
+
flush()
|
|
1001
|
+
number += 1
|
|
1002
|
+
blocks.append(moment_html(review, number, history))
|
|
1003
|
+
need_number = True
|
|
1004
|
+
board = review.board_before
|
|
1005
|
+
san = esc(board.san(review.node.move) + review.symbol)
|
|
1006
|
+
if board.turn == chess.WHITE:
|
|
1007
|
+
token = f"{board.fullmove_number}.{NBSP}{san}"
|
|
1008
|
+
elif need_number:
|
|
1009
|
+
token = f"{board.fullmove_number}...{NBSP}{san}"
|
|
1010
|
+
else:
|
|
1011
|
+
token = san
|
|
1012
|
+
need_number = False
|
|
1013
|
+
if review.grade:
|
|
1014
|
+
token = f"<b>{token}</b> <span class=\"note\">({esc(note_text(review))})</span>"
|
|
1015
|
+
need_number = True
|
|
1016
|
+
tokens.append(token)
|
|
1017
|
+
tokens.append(esc(result_text(article.result, "(result not recorded)")))
|
|
1018
|
+
flush()
|
|
1019
|
+
return "".join(blocks)
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def conclusion_html(article: Article) -> str:
|
|
1023
|
+
game = article.game
|
|
1024
|
+
end = game.end()
|
|
1025
|
+
board = end.board()
|
|
1026
|
+
last = esc(move_label(end.parent.board(), end.move)) if end.move else "the start"
|
|
1027
|
+
result = article.result
|
|
1028
|
+
sentences = []
|
|
1029
|
+
winner = {"1-0": chess.WHITE, "0-1": chess.BLACK}.get(result)
|
|
1030
|
+
if board.is_checkmate():
|
|
1031
|
+
name = article.white if not board.turn == chess.WHITE else article.black
|
|
1032
|
+
sentences.append(f"{esc(name)} delivered checkmate with {last}.")
|
|
1033
|
+
elif board.is_stalemate():
|
|
1034
|
+
sentences.append(f"The game ended in stalemate after {last}.")
|
|
1035
|
+
elif board.is_insufficient_material():
|
|
1036
|
+
sentences.append(f"The game ended after {last}, with too little material left on the board for a mate.")
|
|
1037
|
+
else:
|
|
1038
|
+
if result == NOT_RECORDED:
|
|
1039
|
+
sentences.append(f"The score stops after {last}; the result was not recorded.")
|
|
1040
|
+
else:
|
|
1041
|
+
sentences.append(f"The game ended {esc(RESULTS.get(result, result))} after {last}.")
|
|
1042
|
+
final = article.reviews[-1] if article.reviews else None
|
|
1043
|
+
cp = white_cp(final.node)[0] if (final and article.analyzed) else None
|
|
1044
|
+
if cp is not None:
|
|
1045
|
+
white_chances = win_percent(cp)
|
|
1046
|
+
if winner is None:
|
|
1047
|
+
sentences.append(
|
|
1048
|
+
f"The engine gives White {percent(white_chances)} winning chances in the final position."
|
|
1049
|
+
)
|
|
1050
|
+
else:
|
|
1051
|
+
name = esc(article.white if winner == chess.WHITE else article.black)
|
|
1052
|
+
chances = white_chances if winner == chess.WHITE else 100 - white_chances
|
|
1053
|
+
if chances >= 60:
|
|
1054
|
+
sentences.append(f"{name} was clearly ahead: the engine gives {percent(chances)} winning chances.")
|
|
1055
|
+
elif article.presumed: # a win presumed from these chances, so from the position itself
|
|
1056
|
+
sentences.append(
|
|
1057
|
+
f"{name} was ahead: the engine gives {percent(chances)} winning chances in the final position."
|
|
1058
|
+
)
|
|
1059
|
+
else:
|
|
1060
|
+
sentences.append(
|
|
1061
|
+
f"The engine gives {name} only {percent(chances)} winning chances in the final position, "
|
|
1062
|
+
"so the result came from outside the position on the board: a resignation, the clock or "
|
|
1063
|
+
"an adjudication."
|
|
1064
|
+
)
|
|
1065
|
+
if termination := known(game.headers.get("Termination")):
|
|
1066
|
+
sentences.append(f"The source records the termination as “{esc(termination)}”.")
|
|
1067
|
+
if article.analyzed:
|
|
1068
|
+
tallies = []
|
|
1069
|
+
for color in (chess.WHITE, chess.BLACK):
|
|
1070
|
+
counts = [
|
|
1071
|
+
plural(sum(1 for r in article.reviews if r.mover == color and r.grade == nag), name)
|
|
1072
|
+
for nag, (name, _, _) in GRADES.items()
|
|
1073
|
+
]
|
|
1074
|
+
made = [count for count in counts if not count.startswith("no ")]
|
|
1075
|
+
tallies.append(f"{side(color)} made " + (" and ".join(made) if made else "none"))
|
|
1076
|
+
sentences.append(f"In the engine's grading, {tallies[0]}, and {tallies[1]}.")
|
|
1077
|
+
return f"<p>{' '.join(sentences)}</p>\n"
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def pgn_text(item: CollectedGame) -> str:
|
|
1081
|
+
"""The game as plain PGN: its headers and moves, without the analysis."""
|
|
1082
|
+
return format_game(strip_game(item.game, item.id))
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def article_html(article: Article, previous: Article | None, following: Article | None, *, site_title: str,
|
|
1086
|
+
thresholds: Thresholds, history: History | None = None) -> str: # fmt: skip
|
|
1087
|
+
nav = []
|
|
1088
|
+
if previous:
|
|
1089
|
+
nav.append(f'<a rel="prev" href="{previous.filename}">← {esc(previous.title)}</a>')
|
|
1090
|
+
if following:
|
|
1091
|
+
nav.append(f'<a rel="next" href="{following.filename}">{esc(following.title)} →</a>')
|
|
1092
|
+
body = (
|
|
1093
|
+
f"<article{data(history, game=article.item.id)}>\n"
|
|
1094
|
+
f"<h1>{esc(article.title)}</h1>\n"
|
|
1095
|
+
f"{infobox_html(article)}"
|
|
1096
|
+
f"{lead_html(article, thresholds)}"
|
|
1097
|
+
'<h2 id="game">The game</h2>\n'
|
|
1098
|
+
f"{moves_html(article, history)}"
|
|
1099
|
+
'<h2 id="conclusion">Conclusion</h2>\n'
|
|
1100
|
+
f"{conclusion_html(article)}"
|
|
1101
|
+
'<h2 id="pgn">PGN</h2>\n'
|
|
1102
|
+
f'<pre class="pgn">{esc(pgn_text(article.item))}</pre>\n'
|
|
1103
|
+
"</article>\n"
|
|
1104
|
+
+ (f'<nav class="pager">\n{chr(10).join(nav)}\n</nav>\n' if nav else "")
|
|
1105
|
+
)
|
|
1106
|
+
return page(article.title, body, root="../", site_title=site_title, history=history)
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def index_years(articles: list[Article]) -> list[tuple[int | None, list[Article]]]:
|
|
1110
|
+
"""The index's sections: the years in order (as numbers), then the
|
|
1111
|
+
undated games; in each, the articles in the order given (by file name,
|
|
1112
|
+
as ``build_site`` sorts them)."""
|
|
1113
|
+
years: dict[int | None, list[Article]] = {}
|
|
1114
|
+
for article in articles:
|
|
1115
|
+
years.setdefault(article.year, []).append(article)
|
|
1116
|
+
dated = sorted(year for year in years if year is not None)
|
|
1117
|
+
return [(year, years[year]) for year in [*dated, *([None] if None in years else [])]]
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def index_order(articles: list[Article]) -> list[Article]:
|
|
1121
|
+
"""The articles in the order the index lists them."""
|
|
1122
|
+
return [article for _, section in index_years(articles) for article in section]
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
@dataclass(frozen=True)
|
|
1126
|
+
class Quiz:
|
|
1127
|
+
"""What the index needs to link to the quiz page: its title and its
|
|
1128
|
+
number of questions."""
|
|
1129
|
+
|
|
1130
|
+
title: str
|
|
1131
|
+
questions: int
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def index_html(articles: list[Article], site_title: str, history: History | None = None,
|
|
1135
|
+
quiz: Quiz | None = None) -> str: # fmt: skip
|
|
1136
|
+
sections = index_years(articles)
|
|
1137
|
+
dated = [year for year, _ in sections if year is not None]
|
|
1138
|
+
|
|
1139
|
+
def anchor(year: int | None) -> str:
|
|
1140
|
+
return f"y{year}" if year is not None else "undated"
|
|
1141
|
+
|
|
1142
|
+
def heading(year: int | None) -> str:
|
|
1143
|
+
return str(year) if year is not None else "Undated"
|
|
1144
|
+
|
|
1145
|
+
years = dict(sections)
|
|
1146
|
+
order = [year for year, _ in sections]
|
|
1147
|
+
span = f"from {dated[0]} to {dated[-1]}" if len(dated) > 1 else (f"from {dated[0]}" if dated else "")
|
|
1148
|
+
undated = len(years.get(None, []))
|
|
1149
|
+
summary = f"{plural(len(articles), 'game').capitalize()}"
|
|
1150
|
+
if span:
|
|
1151
|
+
summary += f" {span}"
|
|
1152
|
+
if undated and dated:
|
|
1153
|
+
summary += f" ({number_word(undated)} undated)"
|
|
1154
|
+
summary += (
|
|
1155
|
+
". Every game has its own article. In the analyzed ones, each critical moment is a question, "
|
|
1156
|
+
"“what would you play?”, with the answer hidden until you tap it."
|
|
1157
|
+
)
|
|
1158
|
+
parts = [f"<h1>{esc(site_title)}</h1>\n", f'<p class="lead">{summary}</p>\n']
|
|
1159
|
+
if quiz:
|
|
1160
|
+
count = plural(quiz.questions, "question") if quiz.questions else "no questions"
|
|
1161
|
+
parts.append(f'<p class="quiz-link"><a href="{QUIZ_PAGE}">{esc(quiz.title)}</a>: {count}.</p>\n')
|
|
1162
|
+
if history:
|
|
1163
|
+
parts.append(HISTORY_SECTION)
|
|
1164
|
+
if len(order) > 1:
|
|
1165
|
+
links = " ".join(f'<a href="#{anchor(year)}">{heading(year)}</a>' for year in order)
|
|
1166
|
+
parts.append(f'<nav class="years">{links}</nav>\n')
|
|
1167
|
+
for year in order:
|
|
1168
|
+
parts.append(f'<section id="{anchor(year)}">\n<h2>{heading(year)}</h2>\n<ol class="games">\n')
|
|
1169
|
+
for article in years[year]:
|
|
1170
|
+
headers = article.game.headers
|
|
1171
|
+
meta = [format_date(headers.get("Date")) or "Undated"]
|
|
1172
|
+
if event := known(headers.get("Event")):
|
|
1173
|
+
meta.append(event)
|
|
1174
|
+
if article.analyzed:
|
|
1175
|
+
k = len(article.moments)
|
|
1176
|
+
meta.append(f"{number_word(k)} question{'' if k == 1 else 's'}" if k else "no questions")
|
|
1177
|
+
else:
|
|
1178
|
+
meta.append("not analyzed")
|
|
1179
|
+
questions = " ".join(move_key(review.board_before) for review in article.moments)
|
|
1180
|
+
parts.append(
|
|
1181
|
+
f'<li{data(history, game=article.item.id, moves=questions)}><a href="games/{article.filename}">'
|
|
1182
|
+
f"{esc(article.white)} vs. {esc(article.black)}</a> "
|
|
1183
|
+
f'<span class="result">{esc(result_text(article.result, "result not recorded"))}</span>'
|
|
1184
|
+
f'<span class="meta">{esc(" · ".join(meta))}</span></li>\n'
|
|
1185
|
+
)
|
|
1186
|
+
parts.append("</ol>\n</section>\n")
|
|
1187
|
+
return page(site_title, "".join(parts), root="", site_title=site_title, history=history)
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
# --- the quiz (ROADMAP.md, F-9) -----------------------------------------------------
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
@dataclass(frozen=True)
|
|
1194
|
+
class QuizEntry:
|
|
1195
|
+
"""One line of the quiz: a critical moment that the player played."""
|
|
1196
|
+
|
|
1197
|
+
article: Article
|
|
1198
|
+
review: MoveReview
|
|
1199
|
+
number: int # the moment's number in its article, as in its anchor ``moment-N``
|
|
1200
|
+
loss: float # the winning chances the move cost, exactly
|
|
1201
|
+
position: int # the game's position in the index, from 0
|
|
1202
|
+
filename: str # the article's file name
|
|
1203
|
+
ply: int # the move's place in its game
|
|
1204
|
+
|
|
1205
|
+
def order(self) -> tuple[float, int, str, int]:
|
|
1206
|
+
"""The quiz's order: the most points lost first, then the index's
|
|
1207
|
+
order, then the file name, then move order."""
|
|
1208
|
+
return (-self.loss, self.position, self.filename, self.ply)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def own_colors(game: chess.pgn.Game, names: set[str]) -> set[chess.Color]:
|
|
1212
|
+
"""The sides of ``game`` whose name is one of ``names`` (as
|
|
1213
|
+
``player_names`` gives them): White's, Black's, both or neither, compared
|
|
1214
|
+
as ``Collection.read`` compares them."""
|
|
1215
|
+
colors = set()
|
|
1216
|
+
for color, key in ((chess.WHITE, "White"), (chess.BLACK, "Black")):
|
|
1217
|
+
if (game.headers.get(key) or "").strip().casefold() in names:
|
|
1218
|
+
colors.add(color)
|
|
1219
|
+
return colors
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def quiz_order(entries: Iterable[QuizEntry]) -> list[QuizEntry]:
|
|
1223
|
+
return sorted(entries, key=QuizEntry.order)
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def quiz_entries(articles: list[Article], names: set[str]) -> list[QuizEntry]:
|
|
1227
|
+
"""The quiz for the player called ``names``, in its order: every critical
|
|
1228
|
+
moment of ``articles`` (given in the index's order) played by one of the
|
|
1229
|
+
player's sides."""
|
|
1230
|
+
entries = []
|
|
1231
|
+
for position, article in enumerate(articles):
|
|
1232
|
+
own = own_colors(article.game, names)
|
|
1233
|
+
for number, review in enumerate(article.moments, 1):
|
|
1234
|
+
if review.mover in own:
|
|
1235
|
+
entries.append(
|
|
1236
|
+
QuizEntry(article, review, number, review.loss, position, article.filename, review.node.ply())
|
|
1237
|
+
)
|
|
1238
|
+
return quiz_order(entries)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def player_label(player: str | None, aliases: Iterable[str]) -> str:
|
|
1242
|
+
"""The player's name as the quiz shows it: ``player``'s, else the first
|
|
1243
|
+
alias's, as prose wants it (``Example, Ada`` is ``Ada Example``)."""
|
|
1244
|
+
name = next((name for name in [player or "", *aliases] if name.strip()), "")
|
|
1245
|
+
return display_name(name)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def quiz_title(label: str) -> str:
|
|
1249
|
+
return f"Quiz: {label}'s own mistakes, worst first"
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def quiz_html(entries: list[QuizEntry], games: int, unanalyzed: int, label: str, site_title: str,
|
|
1253
|
+
thresholds: Thresholds, history: History | None = None) -> str: # fmt: skip
|
|
1254
|
+
"""The quiz page: one line per question, linking to it. ``games`` is the
|
|
1255
|
+
number of the player's games, ``unanalyzed`` how many of them have no
|
|
1256
|
+
analysis yet; the page without questions says both. With questions, the
|
|
1257
|
+
lead counts the games they come from."""
|
|
1258
|
+
title = quiz_title(label)
|
|
1259
|
+
name = esc(label)
|
|
1260
|
+
what = (
|
|
1261
|
+
f"a move that cost at least {thresholds.mistake:g} points of winning chances or changed the expected result"
|
|
1262
|
+
)
|
|
1263
|
+
of_games = f"{plural(games, 'game')} of {name}'s"
|
|
1264
|
+
parts = [f"<h1>{esc(title)}</h1>\n"]
|
|
1265
|
+
if not entries:
|
|
1266
|
+
if games:
|
|
1267
|
+
lead = f"The quiz has no questions: in {of_games}, no move {name} played is a critical moment ({what})."
|
|
1268
|
+
else:
|
|
1269
|
+
lead = f"The quiz has no questions: no game here is {name}'s."
|
|
1270
|
+
if unanalyzed:
|
|
1271
|
+
lead += (
|
|
1272
|
+
f" {number_word(unanalyzed).capitalize()} of them {'is' if unanalyzed == 1 else 'are'} not analyzed "
|
|
1273
|
+
"yet, and only analyzed games have critical moments."
|
|
1274
|
+
)
|
|
1275
|
+
parts.append(f'<p class="lead">{lead}</p>\n')
|
|
1276
|
+
return page(title, "".join(parts), root="", site_title=site_title, history=history, home=True)
|
|
1277
|
+
|
|
1278
|
+
# the games the questions come from, not all of the player's games
|
|
1279
|
+
sources = len({entry.article.item.id for entry in entries})
|
|
1280
|
+
parts.append(
|
|
1281
|
+
f'<p class="lead">{plural(len(entries), "question").capitalize()} from '
|
|
1282
|
+
f"{plural(sources, 'game')} of {name}'s: "
|
|
1283
|
+
f"{'it is' if len(entries) == 1 else 'each is'} a critical "
|
|
1284
|
+
f"moment where {name} was the one to move, {what}. The move that cost the most comes first, with the "
|
|
1285
|
+
"points of winning chances each one cost. Each line leads to its “what would you play?” question, where "
|
|
1286
|
+
"the answer stays hidden until you tap it.</p>\n"
|
|
1287
|
+
)
|
|
1288
|
+
parts.append('<ol id="quiz" class="games quiz">\n')
|
|
1289
|
+
for rank, entry in enumerate(entries, 1):
|
|
1290
|
+
article, review = entry.article, entry.review
|
|
1291
|
+
board = review.board_before
|
|
1292
|
+
points = f"{entry.loss:.0f}"
|
|
1293
|
+
opponent = article.black if review.mover == chess.WHITE else article.white
|
|
1294
|
+
meta = f"{format_date(article.game.headers.get('Date')) or 'Undated'} · vs. {opponent}"
|
|
1295
|
+
parts.append(
|
|
1296
|
+
f"<li{data(history, game=article.item.id, move=move_key(board))}>"
|
|
1297
|
+
f'<span class="rank">{rank}.</span> '
|
|
1298
|
+
f'<a href="games/{entry.filename}#moment-{entry.number}">{esc(move_label(board, review.node.move))}</a> '
|
|
1299
|
+
f'<span class="lost">{points} point{"" if points == "1" else "s"}</span>'
|
|
1300
|
+
f'<span class="meta">{esc(meta)}</span></li>\n'
|
|
1301
|
+
)
|
|
1302
|
+
parts.append("</ol>\n")
|
|
1303
|
+
return page(title, "".join(parts), root="", site_title=site_title, history=history, home=True)
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
STYLE = """\
|
|
1307
|
+
/* pgn-postmortem site. Mobile first; light or dark as the OS says. */
|
|
1308
|
+
:root {
|
|
1309
|
+
--bg: #ffffff; --fg: #202122; --muted: #54595d; --link: #3366cc; --rule: #a2a9b1;
|
|
1310
|
+
--box: #f8f9fa; --note: #54595d; --answer: #eaf3ff;
|
|
1311
|
+
--light: #f0d9b5; --dark: #b58863; --hl-light: #f7d764; --hl-dark: #d6ab36;
|
|
1312
|
+
}
|
|
1313
|
+
@media (prefers-color-scheme: dark) {
|
|
1314
|
+
:root {
|
|
1315
|
+
--bg: #101418; --fg: #e8eaed; --muted: #a8adb3; --link: #8ab4f8; --rule: #3c4043;
|
|
1316
|
+
--box: #1b2026; --note: #b0b6bd; --answer: #17263a;
|
|
1317
|
+
--light: #d8c3a0; --dark: #9c7453; --hl-light: #e8ca60; --hl-dark: #c49b32;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
* { box-sizing: border-box; }
|
|
1321
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
1322
|
+
body {
|
|
1323
|
+
margin: 0; background: var(--bg); color: var(--fg);
|
|
1324
|
+
font: 1rem/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
1325
|
+
}
|
|
1326
|
+
a { color: var(--link); text-decoration: none; }
|
|
1327
|
+
a:hover, a:focus { text-decoration: underline; }
|
|
1328
|
+
main { max-width: 52rem; margin: 0 auto; padding: 0 1rem 2rem; }
|
|
1329
|
+
.top { border-bottom: 1px solid var(--rule); padding: .6rem 1rem; font-weight: 600; }
|
|
1330
|
+
.bottom { max-width: 52rem; margin: 0 auto; padding: 1rem; color: var(--muted); font-size: .85rem;
|
|
1331
|
+
border-top: 1px solid var(--rule); }
|
|
1332
|
+
h1, h2 { font-family: Georgia, "Linux Libertine", "Times New Roman", serif; font-weight: normal; line-height: 1.25; }
|
|
1333
|
+
h1 { font-size: 1.8rem; margin: 1rem 0 .5rem; border-bottom: 1px solid var(--rule); padding-bottom: .2rem; }
|
|
1334
|
+
h2 { font-size: 1.4rem; margin: 1.6rem 0 .5rem; border-bottom: 1px solid var(--rule); padding-bottom: .1rem; }
|
|
1335
|
+
.lead { margin-top: 0; }
|
|
1336
|
+
.moves { font-weight: 600; overflow-wrap: anywhere; }
|
|
1337
|
+
.moves b { font-weight: 700; }
|
|
1338
|
+
.note { font-weight: normal; font-style: italic; color: var(--note); }
|
|
1339
|
+
.infobox { width: 100%; margin: 0 0 1rem; border: 1px solid var(--rule); background: var(--box);
|
|
1340
|
+
border-collapse: collapse; font-size: .9rem; line-height: 1.4; }
|
|
1341
|
+
.infobox caption { font-weight: bold; font-size: 1.05rem; padding: .4rem; }
|
|
1342
|
+
.infobox th, .infobox td { text-align: left; vertical-align: top; padding: .25rem .5rem; }
|
|
1343
|
+
.infobox th { white-space: nowrap; }
|
|
1344
|
+
.infobox .figure { padding: .5rem; }
|
|
1345
|
+
.infobox .board { max-width: 100%; }
|
|
1346
|
+
.caption { font-size: .85rem; color: var(--muted); text-align: center; margin-top: .3rem; }
|
|
1347
|
+
.infobox .lichess { text-align: center; font-size: .85rem; margin-top: .3rem; }
|
|
1348
|
+
@media (min-width: 44rem) {
|
|
1349
|
+
.infobox { float: right; clear: right; width: 20rem; margin: 0 0 1rem 1.2rem; }
|
|
1350
|
+
}
|
|
1351
|
+
h2 { overflow: hidden; }
|
|
1352
|
+
article::after { content: ""; display: block; clear: both; }
|
|
1353
|
+
.sym { white-space: nowrap; }
|
|
1354
|
+
.board {
|
|
1355
|
+
display: grid; grid-template-columns: repeat(8, 1fr); aspect-ratio: 1; width: 100%; max-width: 24rem;
|
|
1356
|
+
border: 1px solid var(--rule); font: 600 .65rem/1 system-ui, sans-serif; margin: 0 auto;
|
|
1357
|
+
}
|
|
1358
|
+
.board i { position: relative; background-size: 100% 100%; background-repeat: no-repeat; }
|
|
1359
|
+
/* Each cell draws its own square's colour, under its piece: no pattern on the board, whose squares
|
|
1360
|
+
would round differently from the cells. The highlight is solid: rgba(255, 213, 0, .45) over the
|
|
1361
|
+
square's colour (.4 in dark mode), worked out, so the piece stays drawn over it. */
|
|
1362
|
+
.board i.l { background-color: var(--light); }
|
|
1363
|
+
.board i.d { background-color: var(--dark); }
|
|
1364
|
+
.board i.l.hl { background-color: var(--hl-light); }
|
|
1365
|
+
.board i.d.hl { background-color: var(--hl-dark); }
|
|
1366
|
+
.board i[data-r]::before, .board i[data-f]::after { position: absolute; color: #5b4632; opacity: .8; }
|
|
1367
|
+
.board i[data-r]::before { content: attr(data-r); top: 3%; left: 4%; }
|
|
1368
|
+
.board i[data-f]::after { content: attr(data-f); bottom: 3%; right: 6%; }
|
|
1369
|
+
figure { margin: 1rem 0 .5rem; }
|
|
1370
|
+
figcaption { text-align: center; font-size: .9rem; color: var(--muted); margin-top: .4rem; }
|
|
1371
|
+
figcaption b { color: var(--fg); }
|
|
1372
|
+
.moment { margin: 1.2rem 0; }
|
|
1373
|
+
.answer { display: flow-root; background: var(--answer); border-radius: .4rem; padding: .2rem .8rem; max-width: 36rem;
|
|
1374
|
+
margin: 0 auto; }
|
|
1375
|
+
.answer summary { cursor: pointer; font-weight: 600; padding: .5rem 0; min-height: 2.75rem; display: flex;
|
|
1376
|
+
align-items: center; }
|
|
1377
|
+
.answer summary::before { content: "▸"; margin-right: .4rem; }
|
|
1378
|
+
.answer[open] summary::before { content: "▾"; }
|
|
1379
|
+
.answer summary::-webkit-details-marker { display: none; }
|
|
1380
|
+
.answer summary { list-style: none; }
|
|
1381
|
+
.pgn { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--box); border: 1px solid var(--rule);
|
|
1382
|
+
padding: .6rem; font-size: .85rem; }
|
|
1383
|
+
.pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem 1rem;
|
|
1384
|
+
border-top: 1px solid var(--rule); padding-top: .8rem; margin-top: 1.5rem; }
|
|
1385
|
+
.years { display: flex; flex-wrap: wrap; gap: .3rem 1rem; margin: 0 0 1rem; }
|
|
1386
|
+
.years a { padding: .3rem 0; }
|
|
1387
|
+
.games { list-style: none; padding: 0; margin: 0; }
|
|
1388
|
+
.games li { padding: .5rem 0; border-bottom: 1px solid var(--rule); }
|
|
1389
|
+
.games .result { margin-left: .5rem; font-weight: 600; }
|
|
1390
|
+
.games .meta { display: block; color: var(--muted); font-size: .85rem; }
|
|
1391
|
+
.quiz .rank { display: inline-block; min-width: 2rem; color: var(--muted); }
|
|
1392
|
+
.quiz .lost { margin-left: .5rem; font-weight: 600; }
|
|
1393
|
+
/* The piece set (Colin M.L. Burnett's, as python-chess ships it), shared by every diagram. */
|
|
1394
|
+
"""
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
@dataclass
|
|
1398
|
+
class SiteReport:
|
|
1399
|
+
articles: list[Path] = field(default_factory=list)
|
|
1400
|
+
analyzed: int = 0
|
|
1401
|
+
critical_moments: int = 0
|
|
1402
|
+
removed: list[Path] = field(default_factory=list)
|
|
1403
|
+
quiz: Path | None = None # the quiz page, when the site has a player
|
|
1404
|
+
quiz_questions: int = 0
|
|
1405
|
+
|
|
1406
|
+
def summary(self, out_dir: str | Path) -> str:
|
|
1407
|
+
text = (
|
|
1408
|
+
f"Wrote {len(self.articles)} article(s) to {out_dir}: {self.analyzed} analyzed, "
|
|
1409
|
+
f"{len(self.articles) - self.analyzed} not analyzed yet, {self.critical_moments} critical moment(s)."
|
|
1410
|
+
)
|
|
1411
|
+
if self.quiz:
|
|
1412
|
+
text += f" The quiz lists {self.quiz_questions} of them, the player's own."
|
|
1413
|
+
return text
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def write_text(path: Path, text: str) -> None:
|
|
1417
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1418
|
+
path.write_bytes(text.encode("utf-8")) # "\n" line ends on every OS, for byte-for-byte golden files
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def is_generated(path: Path) -> bool:
|
|
1422
|
+
"""Whether ``path`` is a page this builder wrote: it carries the
|
|
1423
|
+
``GENERATOR`` line in its head, where ``page`` puts it."""
|
|
1424
|
+
with path.open("rb") as handle:
|
|
1425
|
+
head = handle.read(1024)
|
|
1426
|
+
return f"\n{GENERATOR}\n".encode() in head
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def build_site(
|
|
1430
|
+
games: Iterable[CollectedGame],
|
|
1431
|
+
out_dir: str | Path,
|
|
1432
|
+
*,
|
|
1433
|
+
title: str = "Games",
|
|
1434
|
+
thresholds: Thresholds = LICHESS_THRESHOLDS,
|
|
1435
|
+
presume_threshold: float = PRESUME_THRESHOLD,
|
|
1436
|
+
outcome_bands: tuple[float, float] = OUTCOME_BANDS,
|
|
1437
|
+
history: bool = True,
|
|
1438
|
+
site_key: str | None = None,
|
|
1439
|
+
player: str | None = None,
|
|
1440
|
+
aliases: Iterable[str] = (),
|
|
1441
|
+
) -> SiteReport:
|
|
1442
|
+
"""Write the site for ``games`` (a ``Collection``, or any iterable of
|
|
1443
|
+
``CollectedGame``) to ``out_dir``: ``index.html``, ``assets/style.css``
|
|
1444
|
+
and one ``games/<date>-<id>.html`` per game, in date order.
|
|
1445
|
+
|
|
1446
|
+
Games read with ``Collection.read(..., keep_analysis=True)`` from the
|
|
1447
|
+
analysis step's output get notes, critical moments and questions; any
|
|
1448
|
+
other game gets an article without them. A page that an earlier build
|
|
1449
|
+
wrote in ``out_dir/games/`` for a game no longer in ``games`` is removed,
|
|
1450
|
+
whatever its name, so the site has one article per game; a file there
|
|
1451
|
+
that the builder did not write (no ``GENERATOR`` line) is never touched.
|
|
1452
|
+
The output depends only on the games and the options: building twice
|
|
1453
|
+
gives the same bytes.
|
|
1454
|
+
|
|
1455
|
+
A game whose result was not recorded is shown with the result
|
|
1456
|
+
``shown_result`` gives (the rule is in the module docstring):
|
|
1457
|
+
``presume_threshold`` is the winning chances, in percent, at which the
|
|
1458
|
+
analysis makes it a win, 70 by default. A value below 55 or above 95
|
|
1459
|
+
raises ``ValueError`` before anything is written. The games are not
|
|
1460
|
+
changed.
|
|
1461
|
+
|
|
1462
|
+
``outcome_bands`` is the pair ``(lower, upper)`` of White's winning
|
|
1463
|
+
chances, in percent, that decides a position's expected outcome for the
|
|
1464
|
+
outcome swings (the rule is in the module docstring): Black is winning at
|
|
1465
|
+
``lower`` or less, White at ``upper`` or more, 40 and 60 by default. Both
|
|
1466
|
+
must be finite, with 0 < lower < 50 < upper < 100; any other pair raises
|
|
1467
|
+
``ValueError`` before anything is written.
|
|
1468
|
+
|
|
1469
|
+
``history`` puts the reading history in the pages (the rule is in the
|
|
1470
|
+
module docstring); with ``history=False`` the pages carry no script, no
|
|
1471
|
+
history section and no ``data-`` attribute for it. ``site_key`` is the
|
|
1472
|
+
key the history is stored under, by default ``default_site_key(title)``:
|
|
1473
|
+
1 to 64 ASCII letters, digits, ``.``, ``_`` and ``-``, starting with a
|
|
1474
|
+
letter or a digit; any other value raises ``ValueError`` before anything
|
|
1475
|
+
is written, with or without the history.
|
|
1476
|
+
|
|
1477
|
+
``player`` and ``aliases`` name the player, as ``Collection.read`` takes
|
|
1478
|
+
them and compares them (letter case and surrounding spaces ignored), for
|
|
1479
|
+
the quiz page ``quiz.html`` (the rule is in the module docstring). When
|
|
1480
|
+
neither is given and ``games`` is a ``Collection``, the names it was read
|
|
1481
|
+
with are used; names given here take their place.
|
|
1482
|
+
"""
|
|
1483
|
+
check_presume_threshold(presume_threshold)
|
|
1484
|
+
check_outcome_bands(outcome_bands)
|
|
1485
|
+
key = check_site_key(site_key) if site_key is not None else default_site_key(title)
|
|
1486
|
+
reading = History(key, history_script()) if history else None
|
|
1487
|
+
aliases = tuple(aliases)
|
|
1488
|
+
if player is None and not aliases and isinstance(games, Collection):
|
|
1489
|
+
player, aliases = games.player, games.aliases
|
|
1490
|
+
names = player_names(player, aliases)
|
|
1491
|
+
out_dir = Path(out_dir)
|
|
1492
|
+
articles = make_articles(games, thresholds, presume_threshold, outcome_bands)
|
|
1493
|
+
|
|
1494
|
+
report = SiteReport()
|
|
1495
|
+
write_text(out_dir / "assets" / "style.css", STYLE + piece_css())
|
|
1496
|
+
for i, article in enumerate(articles):
|
|
1497
|
+
previous = articles[i - 1] if i > 0 else None
|
|
1498
|
+
following = articles[i + 1] if i + 1 < len(articles) else None
|
|
1499
|
+
path = out_dir / "games" / article.filename
|
|
1500
|
+
html_text = article_html(article, previous, following, site_title=title, thresholds=thresholds, history=reading)
|
|
1501
|
+
write_text(path, html_text)
|
|
1502
|
+
report.articles.append(path)
|
|
1503
|
+
report.analyzed += article.analyzed
|
|
1504
|
+
report.critical_moments += len(article.moments)
|
|
1505
|
+
quiz = None
|
|
1506
|
+
if names:
|
|
1507
|
+
in_order = index_order(articles)
|
|
1508
|
+
entries = quiz_entries(in_order, names)
|
|
1509
|
+
players = [article for article in in_order if own_colors(article.game, names)]
|
|
1510
|
+
label = player_label(player, aliases)
|
|
1511
|
+
unanalyzed = sum(not article.analyzed for article in players)
|
|
1512
|
+
quiz_text = quiz_html(entries, len(players), unanalyzed, label, title, thresholds, reading)
|
|
1513
|
+
write_text(out_dir / QUIZ_PAGE, quiz_text)
|
|
1514
|
+
quiz = Quiz(quiz_title(label), len(entries))
|
|
1515
|
+
report.quiz, report.quiz_questions = out_dir / QUIZ_PAGE, len(entries)
|
|
1516
|
+
write_text(out_dir / "index.html", index_html(articles, title, reading, quiz))
|
|
1517
|
+
|
|
1518
|
+
written = {path.name for path in report.articles}
|
|
1519
|
+
for path in sorted((out_dir / "games").glob("*.html")):
|
|
1520
|
+
if path.name not in written and is_generated(path):
|
|
1521
|
+
path.unlink()
|
|
1522
|
+
report.removed.append(path)
|
|
1523
|
+
stale = out_dir / QUIZ_PAGE
|
|
1524
|
+
if not names and stale.is_file() and is_generated(stale): # a site without a player has no quiz page
|
|
1525
|
+
stale.unlink()
|
|
1526
|
+
report.removed.append(stale)
|
|
1527
|
+
return report
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def make_articles(
|
|
1531
|
+
games: Iterable[CollectedGame],
|
|
1532
|
+
thresholds: Thresholds = LICHESS_THRESHOLDS,
|
|
1533
|
+
presume_threshold: float = PRESUME_THRESHOLD,
|
|
1534
|
+
outcome_bands: tuple[float, float] = OUTCOME_BANDS,
|
|
1535
|
+
) -> list[Article]:
|
|
1536
|
+
"""An ``Article`` for each game, with its reviewed moves and the result
|
|
1537
|
+
it shows, sorted by file name."""
|
|
1538
|
+
articles = []
|
|
1539
|
+
for item in games:
|
|
1540
|
+
analyzed = is_analyzed(item.game)
|
|
1541
|
+
result = shown_result(item.game, presume_threshold)
|
|
1542
|
+
articles.append(Article(item, review_moves(item.game, thresholds, outcome_bands), analyzed, result))
|
|
1543
|
+
articles.sort(key=lambda article: article.stem)
|
|
1544
|
+
return articles
|