soliterm 1.0.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.
Files changed (44) hide show
  1. soliterm/__init__.py +24 -0
  2. soliterm/__main__.py +8 -0
  3. soliterm/aisleriot.py +398 -0
  4. soliterm/camo.py +369 -0
  5. soliterm/cli.py +492 -0
  6. soliterm/deals.py +204 -0
  7. soliterm/debuginfo.py +177 -0
  8. soliterm/engine/__init__.py +71 -0
  9. soliterm/engine/cards.py +54 -0
  10. soliterm/engine/core.py +807 -0
  11. soliterm/engine/gamedef.py +223 -0
  12. soliterm/engine/games/__init__.py +111 -0
  13. soliterm/engine/games/bakersdozen.py +99 -0
  14. soliterm/engine/games/canfield.py +166 -0
  15. soliterm/engine/games/eightoff.py +118 -0
  16. soliterm/engine/games/fortythieves.py +154 -0
  17. soliterm/engine/games/freecell.py +108 -0
  18. soliterm/engine/games/golf.py +79 -0
  19. soliterm/engine/games/klondike.py +185 -0
  20. soliterm/engine/games/scorpion.py +109 -0
  21. soliterm/engine/games/spider.py +212 -0
  22. soliterm/engine/games/spiderette.py +29 -0
  23. soliterm/engine/games/triplepeaks.py +120 -0
  24. soliterm/engine/games/yukon.py +103 -0
  25. soliterm/engine/rng.py +81 -0
  26. soliterm/history.py +183 -0
  27. soliterm/migrate.py +157 -0
  28. soliterm/py.typed +0 -0
  29. soliterm/saves.py +155 -0
  30. soliterm/store.py +819 -0
  31. soliterm/textmode.py +469 -0
  32. soliterm/themes.py +229 -0
  33. soliterm/tui/__init__.py +12 -0
  34. soliterm/tui/__main__.py +11 -0
  35. soliterm/tui/app.py +1817 -0
  36. soliterm/tui/board.py +835 -0
  37. soliterm/tui/cascade.py +111 -0
  38. soliterm/tui/keys.py +150 -0
  39. soliterm-1.0.0.dist-info/METADATA +454 -0
  40. soliterm-1.0.0.dist-info/RECORD +44 -0
  41. soliterm-1.0.0.dist-info/WHEEL +5 -0
  42. soliterm-1.0.0.dist-info/entry_points.txt +2 -0
  43. soliterm-1.0.0.dist-info/licenses/LICENSE +21 -0
  44. soliterm-1.0.0.dist-info/top_level.txt +1 -0
soliterm/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Soliterm: solitaire for your terminal, AisleRiot-compatible.
2
+
3
+ Twelve solitaire games with a curses TUI (keyboard and mouse) and a
4
+ pipe-friendly text mode. The engine follows GNOME AisleRiot's model and
5
+ rules, and statistics are shared with an installed AisleRiot through its
6
+ keyfile. The runtime is standard library only, apart from windows-curses
7
+ on Windows, where CPython ships no curses module.
8
+
9
+ Modules:
10
+ engine the slot/card engine; engine.games holds one module per game
11
+ cli argument parsing and the entry point (main)
12
+ textmode the pipe-friendly text mode (plain board, command loop)
13
+ tui the curses front-end
14
+ store config and statistics under the XDG directories
15
+ saves unfinished games kept for next time
16
+ history every game counted, for streaks and recent games
17
+ aisleriot reads and writes GNOME AisleRiot's statistics keyfile
18
+ camo boss-mode and code-skin text
19
+ """
20
+
21
+ __version__ = "1.0.0"
22
+
23
+ # The product name as players see it (titles, banners, --help).
24
+ APP_NAME = "Soliterm"
soliterm/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Lets `python -m soliterm` start the game."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
soliterm/aisleriot.py ADDED
@@ -0,0 +1,398 @@
1
+ """soliterm.aisleriot - bridge to the installed GNOME AisleRiot statistics.
2
+
3
+ AisleRiot (the `/usr/games/sol` binary) stores per-game statistics in a GLib
4
+ GKeyFile at $XDG_CONFIG_HOME/gnome-games/aisleriot (default ~/.config/...).
5
+ Each game has a section keyed by its Scheme file name, e.g.:
6
+
7
+ [spider.scm]
8
+ Statistic=20;112;591;1966;
9
+ Options=2
10
+
11
+ The Statistic value is `wins;total;best;worst;` where best/worst are winning
12
+ times in whole seconds (best = fastest, worst = slowest; 0 means "no win yet").
13
+
14
+ This module lets our CLI share that exact file so games played in either
15
+ program are mirrored in both. It edits the file surgically - only the
16
+ `Statistic=` line of the games we manage is touched; every other key, section,
17
+ comment, and the file's ordering are preserved so AisleRiot's own config
18
+ (Recent, Theme, per-game Options, ...) is never disturbed.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ import shutil
26
+ from typing import Callable
27
+
28
+ # Our game keys -> AisleRiot section names. AisleRiot's config sections use the
29
+ # Scheme file name with hyphens converted to underscores (e.g. the file
30
+ # eight-off.scm is recorded under [eight_off.scm], as triple_peaks.scm shows).
31
+ GAME_TO_SECTION: dict[str, str] = {
32
+ "klondike": "klondike.scm",
33
+ "spider": "spider.scm",
34
+ "spiderette": "spiderette.scm",
35
+ "freecell": "freecell.scm",
36
+ "eightoff": "eight_off.scm",
37
+ "golf": "golf.scm",
38
+ "triplepeaks": "triple_peaks.scm",
39
+ "yukon": "yukon.scm",
40
+ "scorpion": "scorpion.scm",
41
+ "bakersdozen": "bakers_dozen.scm",
42
+ "fortythieves": "forty_thieves.scm",
43
+ "canfield": "canfield.scm",
44
+ }
45
+
46
+ SECTION_TO_GAME: dict[str, str] = {v: k for k, v in GAME_TO_SECTION.items()}
47
+
48
+
49
+ def _config_base() -> str:
50
+ base = os.environ.get("XDG_CONFIG_HOME")
51
+ if not base:
52
+ base = os.path.join(os.path.expanduser("~"), ".config")
53
+ return base
54
+
55
+
56
+ def gnome_games_dir() -> str:
57
+ return os.path.join(_config_base(), "gnome-games")
58
+
59
+
60
+ def keyfile_path() -> str:
61
+ return os.path.join(gnome_games_dir(), "aisleriot")
62
+
63
+
64
+ def installed() -> bool:
65
+ """True if the AisleRiot program (sol, or aisleriot) is on the PATH."""
66
+ return any(shutil.which(name) for name in ("sol", "aisleriot"))
67
+
68
+
69
+ def available() -> bool:
70
+ """True if there is an AisleRiot to share stats with: its keyfile is
71
+ there, or the program is installed and will make one once it has run.
72
+
73
+ An empty gnome-games folder isn't enough, as other GNOME games keep
74
+ their settings there too. The keyfile is looked for at the (possibly
75
+ XDG-overridden) config path, so test harnesses that point
76
+ XDG_CONFIG_HOME at a temp dir never reach the real AisleRiot file.
77
+ """
78
+ return os.path.exists(keyfile_path()) or installed()
79
+
80
+
81
+ # --------------------------------------------------------------------------- #
82
+ # Low-level keyfile access
83
+ # --------------------------------------------------------------------------- #
84
+
85
+
86
+ def _read_text() -> str:
87
+ """The keyfile's text, or "" when there is no keyfile yet.
88
+
89
+ Only a missing file reads as empty. Any other error (no permission, an
90
+ I/O error) is raised: taking an unreadable keyfile for an empty one would
91
+ make the next write replace all of AisleRiot's settings and stats.
92
+ """
93
+ try:
94
+ # newline="": no newline translation, so "\r\n" and a lone "\r"
95
+ # come back exactly as they are in the file. surrogateescape: bytes
96
+ # that aren't UTF-8 (a value in another encoding) read without an
97
+ # error and are written back unchanged by _write_text.
98
+ with open(keyfile_path(), encoding="utf-8", errors="surrogateescape", newline="") as fh:
99
+ return fh.read()
100
+ except FileNotFoundError:
101
+ return ""
102
+
103
+
104
+ def readable() -> bool:
105
+ """False if the keyfile is there but can't be read."""
106
+ try:
107
+ _read_text()
108
+ except OSError:
109
+ return False
110
+ return True
111
+
112
+
113
+ class _Changed(Exception):
114
+ """The keyfile is no longer what we based our edit on."""
115
+
116
+
117
+ def _write_text(text: str, expect: str | None = None) -> bool:
118
+ """Write the keyfile atomically.
119
+
120
+ The file is shared with a live program (AisleRiot), so we write a temp file
121
+ in the same directory and os.replace() it onto the target: a crash or full
122
+ disk can never leave the keyfile truncated or half-written. The temp file
123
+ takes the keyfile's mode, so a replaced keyfile keeps its permissions.
124
+ The gnome-games folder is AisleRiot's to make: if it isn't there yet,
125
+ nothing is written.
126
+
127
+ With `expect`, the keyfile is read once more right before it is replaced,
128
+ and _Changed is raised (with nothing written) if it no longer holds
129
+ exactly that text.
130
+ """
131
+ import tempfile
132
+
133
+ try:
134
+ d = gnome_games_dir()
135
+ try:
136
+ mode: int | None = os.stat(keyfile_path()).st_mode & 0o7777
137
+ except OSError:
138
+ mode = None
139
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".aisleriot.", suffix=".tmp")
140
+ try:
141
+ if mode is not None:
142
+ os.chmod(tmp, mode)
143
+ with os.fdopen(fd, "w", encoding="utf-8", errors="surrogateescape", newline="") as fh:
144
+ fh.write(text)
145
+ fh.flush()
146
+ os.fsync(fh.fileno())
147
+ if expect is not None and _read_text() != expect:
148
+ raise _Changed()
149
+ os.replace(tmp, keyfile_path())
150
+ except (OSError, _Changed) as exc:
151
+ try:
152
+ os.unlink(tmp)
153
+ except OSError:
154
+ pass
155
+ if isinstance(exc, _Changed):
156
+ raise
157
+ return False
158
+ return True
159
+ except OSError:
160
+ return False
161
+
162
+
163
+ # The keyfile is read here the way GLib reads it, so that we and AisleRiot
164
+ # always see the same numbers. GLib's idea of white space (g_ascii_isspace)
165
+ # leaves out the vertical tab.
166
+ _SPACE = " \t\n\f\r"
167
+
168
+
169
+ def _split(text: str) -> list[str]:
170
+ """`text` in lines as GLib splits it: at "\n" only, dropping the "\r" of
171
+ a "\r\n". On a last line with no "\n" after it, a "\r" is kept as part
172
+ of the line.
173
+ """
174
+ lines = text.split("\n")
175
+ return [s[:-1] if s.endswith("\r") and i < len(lines) - 1 else s for i, s in enumerate(lines)]
176
+
177
+
178
+ def _is_header(line: str) -> str | None:
179
+ s = line.lstrip(_SPACE).rstrip(" \t")
180
+ if len(s) >= 2 and s.startswith("[") and s.endswith("]") and "]" not in s[1:-1]:
181
+ return s[1:-1]
182
+ return None
183
+
184
+
185
+ _ESCAPES = {"s": " ", "n": "\n", "t": "\t", "r": "\r", "\\": "\\", ";": ";"}
186
+
187
+
188
+ def _list_items(value: str) -> list[str] | None:
189
+ """A list value split at its ";"s with escapes undone, or None if it has
190
+ an escape GLib rejects. As in GLib, a last ";" ends the list rather than
191
+ starting an empty item.
192
+ """
193
+ items: list[str] = []
194
+ item = ""
195
+ chars = iter(value)
196
+ for c in chars:
197
+ if c == "\\":
198
+ unescaped = _ESCAPES.get(next(chars, ""), "")
199
+ if not unescaped:
200
+ return None
201
+ item += unescaped # an escaped ";" stays in the item
202
+ elif c == ";":
203
+ items.append(item)
204
+ item = ""
205
+ else:
206
+ item += c
207
+ if item:
208
+ items.append(item)
209
+ return items
210
+
211
+
212
+ # what strtol() reads: optional C white space, a sign, decimal digits
213
+ _STRTOL = re.compile(r"[ \t\n\v\f\r]*[+-]?[0-9]+")
214
+
215
+
216
+ def _glib_int(item: str) -> int | None:
217
+ """`item` as g_key_file_get_integer_list() reads it, or None where GLib
218
+ says it isn't a number (so "+5", "007" and "5 x" pass, "5x" and "0x5"
219
+ don't, and a blank item is 0).
220
+ """
221
+ if not item:
222
+ return None
223
+ m = _STRTOL.match(item)
224
+ end = m.end() if m else 0
225
+ if end < len(item) and item[end] not in _SPACE:
226
+ return None
227
+ n = int(item[:end]) if m else 0
228
+ return n if -(2**31) <= n < 2**31 else None
229
+
230
+
231
+ def _parse_statistic(value: str) -> dict[str, int]:
232
+ """A Statistic value as AisleRiot reads it: four integers, or all zeros if
233
+ GLib can't read the list or it doesn't hold exactly four.
234
+ """
235
+ items = _list_items(value.lstrip(_SPACE))
236
+ nums = [_glib_int(i) for i in items] if items is not None else []
237
+ if len(nums) != 4 or None in nums:
238
+ nums = [0, 0, 0, 0]
239
+ return {k: n or 0 for k, n in zip(("wins", "total", "best", "worst"), nums)}
240
+
241
+
242
+ def read_stat(section: str) -> dict[str, int] | None:
243
+ """The (wins,total,best,worst) dict for a section, or None if not present.
244
+
245
+ As in GLib, the last Statistic line wins, and a section that appears
246
+ more than once is read as one.
247
+
248
+ Raises OSError if the keyfile exists but can't be read.
249
+ """
250
+ return _stat_in(_read_text(), section)
251
+
252
+
253
+ def _stat_in(text: str, section: str) -> dict[str, int] | None:
254
+ current = None
255
+ value: str | None = None
256
+ for line in _split(text):
257
+ head = _is_header(line)
258
+ if head is not None:
259
+ current = head
260
+ continue
261
+ if current == section and _is_statistic_line(line):
262
+ value = line.lstrip(_SPACE).partition("=")[2]
263
+ return None if value is None else _parse_statistic(value)
264
+
265
+
266
+ def _format_statistic(stat: dict[str, int]) -> str:
267
+ return (
268
+ f"Statistic={int(stat.get('wins', 0))};{int(stat.get('total', 0))};"
269
+ f"{int(stat.get('best', 0))};{int(stat.get('worst', 0))};"
270
+ )
271
+
272
+
273
+ def _is_statistic_line(line: str) -> bool:
274
+ """True if `line` is a Statistic key, matching how read_stat / GLib parse it.
275
+
276
+ GLib (and read_stat) strip whitespace around '=', so 'Statistic = ...' and
277
+ 'Statistic\\t=\\t...' are valid Statistic keys; write_stat must recognise and
278
+ replace them, not insert a duplicate.
279
+ """
280
+ line = line.lstrip(_SPACE)
281
+ if "=" not in line or line.startswith("#"):
282
+ return False
283
+ key, _, _ = line.partition("=")
284
+ return key.rstrip(_SPACE) == "Statistic"
285
+
286
+
287
+ def write_stat(section: str, stat: dict[str, int]) -> bool:
288
+ """Surgically set a section's Statistic line, preserving everything else.
289
+
290
+ Only the targeted Statistic line changes; all other keys, sections,
291
+ comments, ordering, and trailing whitespace are kept byte-for-byte.
292
+ Returns False, and writes nothing, if the keyfile can't be read.
293
+ """
294
+ return update_stat(section, lambda current: stat) is not None
295
+
296
+
297
+ # How many times update_stat works a change out again when the keyfile keeps
298
+ # changing under it, before giving up.
299
+ _UPDATE_TRIES = 5
300
+
301
+
302
+ def update_stat(
303
+ section: str, change: Callable[[dict[str, int] | None], dict[str, int] | None]
304
+ ) -> dict[str, int] | None:
305
+ """Set a section's Statistic from its value at the moment of writing.
306
+
307
+ `change` gets the current stat (None if there is none) and returns the
308
+ new one, or None to leave the file alone. The keyfile is read again just
309
+ before it is replaced; if anything wrote it in the meantime, the change
310
+ is worked out again from what is there now, so a stat saved by someone
311
+ else is never put back to an older value.
312
+
313
+ Returns the stat written, or None if nothing was written (the keyfile
314
+ can't be read or written, it would not hold still, or `change` said no).
315
+
316
+ This only closes the gap between our read and our write. AisleRiot reads
317
+ the keyfile once when it starts and writes its own copy back whenever it
318
+ saves, so a result we record while it is running is lost from the keyfile
319
+ at its next save. Our stats.json still has it.
320
+ """
321
+ for _ in range(_UPDATE_TRIES):
322
+ try:
323
+ text = _read_text()
324
+ except OSError:
325
+ return None
326
+ new = change(_stat_in(text, section))
327
+ if new is None:
328
+ return None
329
+ try:
330
+ ok = _write_text(_with_stat(text, section, new), expect=text)
331
+ except _Changed:
332
+ continue
333
+ return new if ok else None
334
+ return None
335
+
336
+
337
+ def _with_stat(text: str, section: str, stat: dict[str, int]) -> str:
338
+ """`text` with the section's Statistic line set to `stat`."""
339
+ # GLib ends a line at "\n" and nowhere else. splitlines() would also
340
+ # break at "\r", "\x0c", "\u2028" and others, which can sit inside a
341
+ # value, and the rejoined file would have a newline in their place.
342
+ # Lines in a CRLF file keep their "\r", and lines we add get one too.
343
+ lines = text.split("\n")
344
+ seen = _split(text) # the same lines, as GLib reads them
345
+ final = "\n" if text.endswith("\n") else "" # keep "no trailing newline" as-is
346
+ if final or lines == [""]:
347
+ lines.pop()
348
+ cr = "\r" if "\r\n" in text else ""
349
+
350
+ new_line = _format_statistic(stat)
351
+ in_section = False
352
+ header_idx: int | None = None
353
+ stat_idx: int | None = None
354
+
355
+ for i in range(len(lines)):
356
+ head = _is_header(seen[i])
357
+ if head is not None:
358
+ in_section = head == section
359
+ if in_section:
360
+ header_idx = i
361
+ continue
362
+ if in_section and _is_statistic_line(seen[i]):
363
+ stat_idx = i # the last one is the one GLib reads
364
+
365
+ if stat_idx is not None:
366
+ # keep the "\r" of a "\r\n"; one with no "\n" after it was part of
367
+ # the old value
368
+ lines[stat_idx] = new_line + ("\r" if seen[stat_idx] != lines[stat_idx] else "")
369
+ return "\n".join(lines) + final
370
+
371
+ if header_idx is not None:
372
+ # insert right after the section header
373
+ at, new = header_idx + 1, [new_line]
374
+ else:
375
+ # section doesn't exist: append a fresh one (blank-line separated)
376
+ at = len(lines)
377
+ new = [f"[{section}]", new_line]
378
+ if lines and lines[-1].strip() != "":
379
+ new.insert(0, "")
380
+ if at == len(lines) and not final:
381
+ # adding to the end of a file with no newline at its end: end the
382
+ # last line first, and the file after ours. A "\r" on that line is
383
+ # part of its value, so it keeps it by getting a "\r\n" of its own.
384
+ if lines and (cr or lines[-1].endswith("\r")):
385
+ lines[-1] += "\r"
386
+ final = "\n"
387
+ lines[at:at] = [s + cr for s in new]
388
+ return "\n".join(lines) + final
389
+
390
+
391
+ def all_known_stats() -> dict[str, dict[str, int]]:
392
+ """Every stat we recognise from the keyfile, keyed by OUR game key."""
393
+ out: dict[str, dict[str, int]] = {}
394
+ for section, game_key in SECTION_TO_GAME.items():
395
+ s = read_stat(section)
396
+ if s is not None:
397
+ out[game_key] = s
398
+ return out