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.
- soliterm/__init__.py +24 -0
- soliterm/__main__.py +8 -0
- soliterm/aisleriot.py +398 -0
- soliterm/camo.py +369 -0
- soliterm/cli.py +492 -0
- soliterm/deals.py +204 -0
- soliterm/debuginfo.py +177 -0
- soliterm/engine/__init__.py +71 -0
- soliterm/engine/cards.py +54 -0
- soliterm/engine/core.py +807 -0
- soliterm/engine/gamedef.py +223 -0
- soliterm/engine/games/__init__.py +111 -0
- soliterm/engine/games/bakersdozen.py +99 -0
- soliterm/engine/games/canfield.py +166 -0
- soliterm/engine/games/eightoff.py +118 -0
- soliterm/engine/games/fortythieves.py +154 -0
- soliterm/engine/games/freecell.py +108 -0
- soliterm/engine/games/golf.py +79 -0
- soliterm/engine/games/klondike.py +185 -0
- soliterm/engine/games/scorpion.py +109 -0
- soliterm/engine/games/spider.py +212 -0
- soliterm/engine/games/spiderette.py +29 -0
- soliterm/engine/games/triplepeaks.py +120 -0
- soliterm/engine/games/yukon.py +103 -0
- soliterm/engine/rng.py +81 -0
- soliterm/history.py +183 -0
- soliterm/migrate.py +157 -0
- soliterm/py.typed +0 -0
- soliterm/saves.py +155 -0
- soliterm/store.py +819 -0
- soliterm/textmode.py +469 -0
- soliterm/themes.py +229 -0
- soliterm/tui/__init__.py +12 -0
- soliterm/tui/__main__.py +11 -0
- soliterm/tui/app.py +1817 -0
- soliterm/tui/board.py +835 -0
- soliterm/tui/cascade.py +111 -0
- soliterm/tui/keys.py +150 -0
- soliterm-1.0.0.dist-info/METADATA +454 -0
- soliterm-1.0.0.dist-info/RECORD +44 -0
- soliterm-1.0.0.dist-info/WHEEL +5 -0
- soliterm-1.0.0.dist-info/entry_points.txt +2 -0
- soliterm-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
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
|