rpgsync 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.
rpgsync/cli.py ADDED
@@ -0,0 +1,1242 @@
1
+ """Command line interface.
2
+
3
+ cd /path/to/MyGame
4
+ rpgsync # keep game and scripts in sync (live); sets the scripts up the first time
5
+ rpgsync status # what is pending, on which side
6
+ rpgsync pull # game -> scripts
7
+ rpgsync push # scripts -> game
8
+ rpgsync check # compile, engine and type checks, without writing
9
+ rpgsync clean # delete the backups of overwritten files
10
+ rpgsync locate --map 1 --event 2 --command 3 # FILE:LINE where a script writes it (for editors)
11
+ rpgsync locate --troop 4 --page 1 # ... or a troop's battle event page
12
+
13
+ Every command works on the game of the current folder (the folder holding
14
+ RPG_RT.ldb, its Scripts folder, or any folder inside them), or on the game
15
+ or scripts folder given as argument.
16
+
17
+ With the RPG Maker 2000/2003 editor: rpgsync pull, edit the Python files,
18
+ rpgsync push, then open the editor and keep editing there. Keep the editor
19
+ closed while you edit the Python files: it keeps its own copy of the game in
20
+ memory and ignores them.
21
+
22
+ With the EasyRPG Editor (https://github.com/oulianov/Editor): turn on its
23
+ rpgsync mode (Settings). It reloads what changes on disk and shows it as well
24
+ as it can, so both can stay open while rpgsync runs.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import difflib
30
+ import io
31
+ import os
32
+ import re
33
+ import shutil
34
+ import subprocess
35
+ import sys
36
+ import time
37
+ import tomllib
38
+ from collections.abc import Iterator
39
+ from pathlib import Path
40
+ from typing import Annotated, Any
41
+
42
+ import typer
43
+ from pydantic import BaseModel
44
+ from rich.console import Console
45
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
46
+ from rich.text import Text
47
+
48
+ from . import dynrpg, scriptfiles
49
+ from .commands import TableSize
50
+ from .compat import check_specs, check_warnings, load_rules, target_engine
51
+ from .maptree import StartSpec, TreeOrder, map_reference_problems, map_tree_maps, tree_problems
52
+ from .project import Project
53
+ from .resources import Collector
54
+ from .resources import key as resource_key
55
+ from .resources import problems as resource_problems
56
+ from .scaffold import init_scripts_project, update_scripts_project
57
+ from .script import (
58
+ CompileError,
59
+ compile_common_events_source,
60
+ compile_map_source,
61
+ compile_troop_events_source,
62
+ unreachable_pages,
63
+ )
64
+ from .sync import Result, ScriptError, Syncer, Unit, read_bytes, sha
65
+ from .venv import repair as repair_venv
66
+
67
+ EDITORS_HELP = (
68
+ "With the RPG Maker 2000/2003 editor: [bold]rpgsync pull[/bold], edit the Python files, "
69
+ "[bold]rpgsync push[/bold], then open the editor and keep editing there. "
70
+ "Keep the editor closed while you edit the Python files: it keeps its own copy "
71
+ "of the game in memory and ignores your Python edits.\n\n"
72
+ "With the EasyRPG Editor (https://github.com/oulianov/Editor): make sure its "
73
+ "[bold]rpgsync mode[/bold] is on (Settings). It reloads the changes made on disk "
74
+ "and shows them as well as it can, so it can stay open while rpgsync runs."
75
+ )
76
+
77
+ app = typer.Typer(
78
+ help="A python twin of your RPG Maker 2000/2003 game.",
79
+ epilog=EDITORS_HELP,
80
+ no_args_is_help=True,
81
+ add_completion=False,
82
+ )
83
+
84
+ ProjectArg = Annotated[
85
+ str | None,
86
+ typer.Argument(
87
+ help="The game folder (with RPG_RT.ldb) or its scripts folder [default: the current folder].",
88
+ show_default=False,
89
+ ),
90
+ ]
91
+ MapOpt = Annotated[list[int] | None, typer.Option("--map", help="Only this map id (repeatable).")]
92
+ NoCommonOpt = Annotated[bool, typer.Option("--no-common", help="Skip database/common_events.py.")]
93
+ YesOpt = Annotated[bool, typer.Option("--yes", "-y", help="First run: set the game up without asking.")]
94
+
95
+ ENGINES = {"2k": "RPG Maker 2000", "2k3": "RPG Maker 2003"}
96
+
97
+
98
+ # --------------------------------------------------------------------------
99
+ # Output
100
+ # --------------------------------------------------------------------------
101
+
102
+ # one marker at the start of a line: an emoji, or plain ASCII where the
103
+ # console cannot show emojis (Windows cmd.exe with cp437/cp850/cp1252...)
104
+ MARKERS = {
105
+ "ok": ("\u2705", "[ok]"), # check mark
106
+ "script": ("\U0001f40d", "[s]"), # snake: a Python script was written / changed
107
+ "game": ("\U0001f5d2\ufe0f", "[g]"), # spiral notepad: a game file was written / changed
108
+ "merge": ("\U0001f500", "[~]"), # twisted arrows
109
+ "conflict": ("\u26a0\ufe0f", "[!]"), # warning
110
+ "warning": ("\u26a0\ufe0f", "[!]"),
111
+ "error": ("\u274c", "[x]"), # cross mark
112
+ "info": ("\u2139\ufe0f", "[i]"), # information
113
+ "watch": ("\U0001f501", "[*]"), # repeat: watching
114
+ "new": ("\u2728", "[+]"), # sparkles
115
+ "file": ("\U0001f4c4", "[+]"), # page
116
+ "delete": ("\U0001f5d1\ufe0f", "[-]"), # wastebasket
117
+ "stop": ("\U0001f44b", "[.]"), # waving hand
118
+ }
119
+
120
+ # shown the first time rpgsync runs on a game: colored block letters (ANSI escapes,
121
+ # parsed by rich, so they go away with NO_COLOR or when the output is not a terminal)
122
+ BANNER = [
123
+ "\x1b[0;91;41m░▒▒\x1b[0;31m▄\x1b[0;33m▓▄\x1b[0;37m \x1b[0;91;41m░▒▒░\x1b[0;33m▓▄\x1b[0;37m \x1b[0;31m▄\x1b[0;91;41m▒▓\x1b[0;91m█\x1b[0;91;41m▒░\x1b[0;37m \x1b[0;31m▄\x1b[0;91;41m▄▀▄\x1b[0;31m▄\x1b[0;37m \x1b[0;31m█\x1b[0;91;41m▒\x1b[0;31m▌▐\x1b[0;91;41m▒░\x1b[0;37m \x1b[0;91;41m░▒▒░\x1b[0;33m▓▄\x1b[0;37m \x1b[0;31m▄\x1b[0;91;41m▄▀▄\x1b[0;31m▄\x1b[0;37m \x1b[0m",
124
+ "\x1b[0;33m▓\x1b[0;91;41m░\x1b[0;31m▌▐\x1b[0;33;41m▓\x1b[0;33m▓\x1b[0;37m \x1b[0;33m▓\x1b[0;91;41m░\x1b[0;31m▌▐\x1b[0;33;41m▓\x1b[0;33m▓\x1b[0;37m \x1b[0;91;41m▓\x1b[0;91;43m▓\x1b[0;31m▌▐\x1b[0;91;41m░\x1b[0;33m█\x1b[0;37m \x1b[0;91;41m▒▓\x1b[0;31m▌▐\x1b[0;91;41m▓▄\x1b[0;37m \x1b[0;91;41m▓\x1b[0;91;43m▓\x1b[0;31m▌▐\x1b[0;91;41m░\x1b[0;33m█\x1b[0;37m \x1b[0;33m▓\x1b[0;91;41m░\x1b[0;31m▌▐\x1b[0;33;41m▓\x1b[0;33m▓\x1b[0;37m \x1b[0;91;41m▒▓\x1b[0;31m▌▐\x1b[0;91;41m▓▄\x1b[0m",
125
+ "\x1b[0;33m▒▒\x1b[0;31m▌\x1b[0;37m \x1b[0;33m▒▒\x1b[0;31m▌▐\x1b[0;33m▓▓\x1b[0;37m \x1b[0;33m▓\x1b[0;91;41m░\x1b[0;31m▌▐\x1b[0;33m▓▓\x1b[0;37m \x1b[0;31m▀\x1b[0;91m▀\x1b[0;91;41m▀\x1b[0;91m▄▄\x1b[0;31m▄\x1b[0;37m \x1b[0;33m▓\x1b[0;91;41m░\x1b[0;31m▌▐\x1b[0;33m▓▓\x1b[0;37m \x1b[0;33m▒▒\x1b[0;31m▌▐\x1b[0;33m▓▓\x1b[0;37m \x1b[0;91;41m░▒\x1b[0;31m▌\x1b[0;37m \x1b[0m",
126
+ "\x1b[0;33m░░\x1b[0;31m▌\x1b[0;37m \x1b[0;33m░░\x1b[0;31m▌▐\x1b[0;33m▒▒\x1b[0;37m \x1b[0;33m░▒\x1b[0;31m▌▐\x1b[0;33m▒▒\x1b[0;37m \x1b[0;33m▓\x1b[0;91;41m░\x1b[0;31m▌▐\x1b[0;91;41m░\x1b[0;33m█\x1b[0;37m \x1b[0;33m░▒\x1b[0;31m▌▐\x1b[0;33m▒▒\x1b[0;37m \x1b[0;33m░░\x1b[0;31m▌▐\x1b[0;33m▒▒\x1b[0;37m \x1b[0;33m▓\x1b[0;91;41m░\x1b[0;31m▌▐\x1b[0;91;41m░\x1b[0;33m█\x1b[0m",
127
+ "\x1b[0;90m░▒▌\x1b[0;37m \x1b[0;90m▓▓▌██▀\x1b[0;37m \x1b[0;90m▀▀▐██\x1b[0;37m \x1b[0;31m▀█\x1b[0;90;41m░▓▒\x1b[0;90m▀\x1b[0;37m \x1b[0;90m▀▀▐██\x1b[0;37m \x1b[0;90m░▒▌▐██\x1b[0;37m \x1b[0;31m▀\x1b[0;90;41m░▓▒\x1b[0;90m▀\x1b[0m",
128
+ "\x1b[0;37m \x1b[0;90m▒▓\x1b[0;37m \x1b[0;90m▒█▄█▓▀\x1b[0;37m \x1b[0;90m▒█▄█▓▀\x1b[0;37m \x1b[0m",
129
+ ]
130
+
131
+ REPOSITORY = "https://github.com/oulianov/rpgsync"
132
+
133
+ # file names in sync messages, shown in cyan
134
+ FILE_NAMES = r"[\w./\\-]+\.(?:py|lmu|ldb)\b"
135
+
136
+
137
+ def _never_fail(stream) -> None:
138
+ """Characters the console cannot show become "?" instead of raising UnicodeEncodeError."""
139
+ try:
140
+ if getattr(stream, "errors", "replace") == "strict":
141
+ stream.reconfigure(errors="replace")
142
+ except (AttributeError, ValueError, io.UnsupportedOperation):
143
+ pass
144
+
145
+
146
+ def _emoji_ok(console: Console) -> bool:
147
+ """Emojis need a UTF-8 output and a terminal font that has them."""
148
+ if os.environ.get("RPGSYNC_ASCII"):
149
+ return False
150
+ encoding = (console.encoding or "").lower().replace("-", "").replace("_", "")
151
+ if not encoding.startswith("utf") or console.legacy_windows:
152
+ return False
153
+ if sys.platform == "win32":
154
+ # Python writes UTF-8 to every Windows console, but the classic console
155
+ # (cmd.exe, PowerShell window) shows emojis as boxes: only trust Windows
156
+ # Terminal and the VS Code terminal
157
+ return bool(os.environ.get("WT_SESSION") or os.environ.get("TERM_PROGRAM"))
158
+ return True
159
+
160
+
161
+ class UI:
162
+ """rich output on stdout or stderr: colors on a terminal (unless NO_COLOR is set),
163
+ emojis only where the output encoding can show them."""
164
+
165
+ def __init__(self, err: bool = False):
166
+ _never_fail(sys.stderr if err else sys.stdout)
167
+ # markup/emoji off: paths and game texts may contain "[...]" or ":name:"
168
+ self.console = Console(stderr=err, highlight=False, soft_wrap=True, markup=False, emoji=False)
169
+ self.fancy = _emoji_ok(self.console)
170
+
171
+ def marker(self, kind: str) -> str:
172
+ emoji, ascii_marker = MARKERS[kind]
173
+ return (emoji if self.fancy else ascii_marker) + " "
174
+
175
+ def print(self, *parts: str | Text | tuple[str, str], kind: str | None = None) -> None:
176
+ """One line made of strings, Texts or (text, style) pairs, after an optional marker."""
177
+ t = Text(self.marker(kind)) if kind else Text()
178
+ for part in parts:
179
+ if isinstance(part, Text):
180
+ t.append_text(part)
181
+ elif isinstance(part, tuple):
182
+ t.append(*part)
183
+ else:
184
+ t.append(part)
185
+ self.console.print(t)
186
+
187
+ def echo(self, line: str) -> None:
188
+ self.console.print(Text(line))
189
+
190
+ def bullet(self, *parts: str | Text | tuple[str, str]) -> None:
191
+ self.print(" \u2022 " if self.fancy else " - ", *parts)
192
+
193
+ def banner(self) -> None:
194
+ """The rpgsync title, one color per line, and what it does."""
195
+ self.echo("")
196
+ for line in BANNER:
197
+ self.console.print(Text.from_ansi(line))
198
+ self.print(("a Python twin of your RPG Maker 2000/2003 game", "italic dim"))
199
+ self.print(
200
+ (REPOSITORY, "underline cyan"),
201
+ (" \u00b7 " if self.fancy else " - ", "dim"),
202
+ ("MIT License, 2026", "dim"),
203
+ )
204
+ self.echo("")
205
+
206
+ def report(self, r: Result) -> None:
207
+ """A sync result as a log line: marker, time, unit, message, duration."""
208
+ if r.action == "none" and not r.message:
209
+ return
210
+ kind = {"export": "script", "import": "game", "merge": "merge", "conflict": "conflict", "error": "error"}
211
+ style = {"error": "red", "conflict": "yellow"}.get(r.action, "")
212
+ first, _, rest = (r.message or r.action).partition("\n")
213
+ msg = Text(first, style)
214
+ msg.highlight_regex(FILE_NAMES, "cyan")
215
+ parts: list = [("[%s] " % time.strftime("%H:%M:%S"), "dim"), (r.unit, "bold"), ": ", msg]
216
+ if r.seconds is not None and r.action != "none":
217
+ parts.append((" (%.2f s)" % r.seconds, "dim"))
218
+ if rest:
219
+ parts.append(("\n" + rest, style))
220
+ self.print(*parts, kind=kind.get(r.action, "info"))
221
+
222
+
223
+ class CliSyncer(Syncer):
224
+ """A Syncer reporting through rich."""
225
+
226
+ def __init__(self, project: Project):
227
+ self.ui = UI()
228
+ super().__init__(project, log=self.ui.echo)
229
+
230
+ def report(self, r: Result) -> None:
231
+ self.ui.report(r)
232
+
233
+
234
+ def _fail(message: str, code: int = 2) -> typer.Exit:
235
+ UI(err=True).print(("Error: %s" % message, "red"), kind="error")
236
+ return typer.Exit(code)
237
+
238
+
239
+ # --------------------------------------------------------------------------
240
+ # Finding the project
241
+ # --------------------------------------------------------------------------
242
+
243
+
244
+ class ProjectNotFound(Exception):
245
+ pass
246
+
247
+
248
+ class Located(BaseModel):
249
+ game: str
250
+ scripts: str | None = None
251
+
252
+
253
+ def _find(where: Path) -> Located | None:
254
+ """The game folder (RPG_RT.ldb) or scripts folder (pyproject.toml with
255
+ [tool.rpgsync] game = "...") containing `where`; the nearest one wins."""
256
+ for d in (where, *where.parents):
257
+ if (d / "RPG_RT.ldb").is_file():
258
+ return Located(game=str(d))
259
+ pyproject = d / "pyproject.toml"
260
+ if pyproject.is_file():
261
+ try:
262
+ game = tomllib.loads(pyproject.read_text(encoding="utf-8")).get("tool", {}).get("rpgsync", {})
263
+ game = game.get("game")
264
+ except (OSError, ValueError, AttributeError):
265
+ continue
266
+ if isinstance(game, str) and (d / game / "RPG_RT.ldb").is_file():
267
+ return Located(game=str((d / game).resolve()), scripts=str(d))
268
+ return None
269
+
270
+
271
+ def _locate(project: str | None) -> Located:
272
+ """The project of a folder (default: the current one)."""
273
+ if project is not None and not os.path.isdir(project):
274
+ raise typer.BadParameter("%s is not a folder" % project)
275
+ where = Path(project if project is not None else os.getcwd()).resolve()
276
+ found = _find(where)
277
+ if found is None:
278
+ raise ProjectNotFound(
279
+ "no RPG Maker game at %s: neither it nor a parent folder holds RPG_RT.ldb or a scripts folder "
280
+ "(pyproject.toml with [tool.rpgsync]).\nRun rpgsync inside your game folder, or pass its path." % where
281
+ )
282
+ return found
283
+
284
+
285
+ def _open(project: str | None) -> CliSyncer:
286
+ try:
287
+ loc = _locate(project)
288
+ except ProjectNotFound as e:
289
+ raise _fail(str(e)) from None
290
+ project = Project(loc.game, loc.scripts)
291
+ # a game folder copied or renamed: its scripts' commands still run the old one's
292
+ old = repair_venv(project.script_dir)
293
+ if old:
294
+ UI(err=True).print("repaired the scripts' Python environment, made in %s" % old, kind="info")
295
+ # files that an older rpgsync did not write; uv.lock and .venv follow pyproject.toml
296
+ if str(Path(project.script_dir) / "pyproject.toml") in update_scripts_project(project):
297
+ _uv_sync(project.script_dir, UI(err=True))
298
+ return CliSyncer(project)
299
+
300
+
301
+ def _uv_sync(script_dir: str, ui: UI) -> None:
302
+ """Install the scripts' Python environment, when uv is there."""
303
+ if shutil.which("uv"):
304
+ with ui.console.status("Installing the scripts' Python environment (uv sync)..."):
305
+ subprocess.run(["uv", "sync", "--quiet"], cwd=script_dir, check=False)
306
+
307
+
308
+ def _interactive() -> bool:
309
+ """Can we ask a question (a terminal on stdin)?"""
310
+ return sys.stdin.isatty()
311
+
312
+
313
+ def _needs_set_up(syncer: CliSyncer) -> bool:
314
+ return not os.path.exists(os.path.join(syncer.project.script_dir, "pyproject.toml"))
315
+
316
+
317
+ def _set_up(syncer: CliSyncer, yes: bool) -> None:
318
+ """First run on a game: say what will happen, ask, then make its scripts folder
319
+ a small Python project (pyproject.toml, tests, editor settings) installed with uv."""
320
+ ui, project = syncer.ui, syncer.project
321
+ maps = sum(u.kind == "map" for u in syncer.units())
322
+ engine = ENGINES.get(project.context().engine, project.context().engine)
323
+ scripts = os.path.relpath(project.script_dir, os.path.dirname(project.game_dir))
324
+ ui.banner()
325
+ ui.print(
326
+ (os.path.basename(project.game_dir), "bold bright_white"),
327
+ (" (%s, %d maps)" % (engine, maps), "dim"),
328
+ " is not synced with rpgsync yet.",
329
+ kind="info",
330
+ )
331
+ ui.print(("Setting it up will:", "bold"))
332
+ ui.bullet(
333
+ ("create ", "bold green"),
334
+ (scripts, "cyan"),
335
+ ": your maps, database and common events as Python scripts, with tests",
336
+ )
337
+ ui.bullet(("install ", "bold green"), "the scripts' Python environment with ", ("uv", "cyan"))
338
+ ui.bullet(("leave the game files as they are", "bold yellow"), ": only your edits to the scripts are written back")
339
+ if not yes:
340
+ if not _interactive():
341
+ raise _fail("not set up: run again with -y to set this game up")
342
+ question = "Set up rpgsync for this game?"
343
+ if ui.console.color_system is not None:
344
+ question = typer.style(question, bold=True)
345
+ if not typer.confirm(question, default=True):
346
+ raise typer.Exit(1)
347
+ ui.echo("")
348
+ written = init_scripts_project(project)
349
+ ui.print(
350
+ "created %s (%s)" % (scripts, ", ".join(os.path.relpath(p, project.script_dir) for p in written)),
351
+ kind="file",
352
+ )
353
+ _uv_sync(project.script_dir, ui)
354
+ ui.echo("")
355
+
356
+
357
+ def _units(syncer: Syncer, maps: list[int] | None, no_common: bool) -> Iterator[Unit]:
358
+ for u in syncer.units():
359
+ if u.kind == "common" and no_common:
360
+ continue
361
+ if maps and (u.kind != "map" or u.map_id not in maps):
362
+ continue
363
+ yield u
364
+
365
+
366
+ def _sync_all(
367
+ syncer: CliSyncer, units: list[Unit], prefer: str | None, initial: bool = False, force: bool = False
368
+ ) -> int:
369
+ """Sync units under a progress bar; return how many failed. initial: the first
370
+ export of a new project, summed up in one line instead of one line per script."""
371
+ ui = syncer.ui
372
+ failed = unchanged = reported = written = 0
373
+ begin = time.perf_counter()
374
+ columns = (
375
+ TextColumn("{task.description}"),
376
+ BarColumn(),
377
+ MofNCompleteColumn(),
378
+ TextColumn("{task.fields[unit]}"),
379
+ TimeElapsedColumn(),
380
+ )
381
+ title = "Initial sync (game -> scripts)" if initial else "Syncing"
382
+ # the initial sync's bar stays on screen; later ones vanish once done
383
+ with Progress(*columns, console=ui.console, transient=not initial, disable=not ui.console.is_terminal) as progress:
384
+ task = progress.add_task(title, total=len(units), unit="")
385
+ for u in units:
386
+ progress.update(task, unit=u.name)
387
+ start = time.perf_counter()
388
+ r = syncer.sync(u, prefer, force)
389
+ r.seconds = time.perf_counter() - start
390
+ progress.advance(task)
391
+ if r.action in ("export", "import") and r.message.endswith(": no changes"):
392
+ unchanged += 1 # rewritten identically: one summary line for all of them
393
+ continue
394
+ if initial and r.action == "export":
395
+ written += 1
396
+ continue
397
+ syncer.report(r)
398
+ reported += r.action != "none" or bool(r.message) # a line printed
399
+ failed += r.action in ("error", "conflict")
400
+ if initial:
401
+ took = time.perf_counter() - begin
402
+ if ui.console.is_terminal:
403
+ ui.echo("") # after the bar
404
+ ui.print(
405
+ ("Initial sync done: ", "bold green"),
406
+ "%d scripts written in %s" % (written, _duration(took)),
407
+ kind="ok",
408
+ )
409
+ if unchanged:
410
+ files = "%d %sfile%s" % (unchanged, "other " if reported else "", "s" * (unchanged > 1))
411
+ ui.print(("%s already up to date" % files, "dim"))
412
+ elif prefer == "script" and not reported:
413
+ ui.print(("Nothing to push: no script or game file changed since the last sync", "dim"))
414
+ return failed
415
+
416
+
417
+ def _duration(seconds: float) -> str:
418
+ return "%.1f s" % seconds if seconds < 60 else "%d min %02d s" % divmod(round(seconds), 60)
419
+
420
+
421
+ def _run(syncer: CliSyncer, units, prefer: str | None, initial: bool = False, force: bool = False) -> None:
422
+ if _sync_all(syncer, list(units), prefer, initial, force):
423
+ raise typer.Exit(1)
424
+
425
+
426
+ # --------------------------------------------------------------------------
427
+ # Status helpers
428
+ # --------------------------------------------------------------------------
429
+
430
+ Part = tuple[str, str] # (text, rich style)
431
+
432
+ PLURAL = {"event": "events", "common event": "common events", "troop": "troops", "map": "maps", "entry": "entries"}
433
+
434
+
435
+ def _ids(ids: list[int], limit: int = 8) -> str:
436
+ """1, 2, 3, 5 -> "1-3, 5"; long lists are cut."""
437
+ runs: list[list[int]] = []
438
+ for i in ids:
439
+ if runs and i == runs[-1][1] + 1:
440
+ runs[-1][1] = i
441
+ else:
442
+ runs.append([i, i])
443
+ out = [str(a) if a == b else "%d-%d" % (a, b) for a, b in runs]
444
+ if len(out) > limit:
445
+ out = out[:limit] + ["... (%d in all)" % len(ids)]
446
+ return ", ".join(out)
447
+
448
+
449
+ def _entry_diff(old: list, new: list, what: str) -> list[Part]:
450
+ """Per-entry difference between two spec lists: what changed from old to new."""
451
+ a = {s.id: s for s in old if s.id is not None}
452
+ b = {s.id: s for s in new if s.id is not None}
453
+ parts: list[Part] = []
454
+ pa, pb = a.get(0), b.get(0) # pseudo-entry 0: the table's size, or the map tree's order
455
+ if isinstance(pa, TableSize) and isinstance(pb, TableSize) and pa.size != pb.size:
456
+ parts.append(("size %d -> %d" % (pa.size, pb.size), "yellow"))
457
+ for label, ids, style in (
458
+ ("changed", sorted(i for i in a.keys() & b.keys() if i > 0 and a[i].key() != b[i].key()), "yellow"),
459
+ ("added", sorted(i for i in b.keys() - a.keys() if i > 0), "green"),
460
+ ("removed", sorted(i for i in a.keys() - b.keys() if i > 0), "red"),
461
+ ):
462
+ if ids:
463
+ parts.append(("%s %s %s" % (PLURAL[what] if len(ids) > 1 else what, _ids(ids), label), style))
464
+ if isinstance(pa, TreeOrder) and isinstance(pb, TreeOrder):
465
+ both = set(pa.order) & set(pb.order) # maps added or removed are listed above
466
+ if [m for m in pa.order if m in both] != [m for m in pb.order if m in both]:
467
+ parts.append(("new tree order", "yellow"))
468
+ sa, sb = a.get(-1), b.get(-1) # pseudo-entry -1: the map tree's start
469
+ if isinstance(sa, StartSpec) and isinstance(sb, StartSpec) and sa.key() != sb.key():
470
+ parts.append(("new start positions", "yellow"))
471
+ return parts
472
+
473
+
474
+ def _line_counts(old: str, new: str) -> tuple[int, int]:
475
+ """Lines added and removed from old to new, like `git diff --stat`."""
476
+ plus = minus = 0
477
+ matcher = difflib.SequenceMatcher(None, old.splitlines(), new.splitlines(), autojunk=False)
478
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
479
+ if tag != "equal":
480
+ minus += i2 - i1
481
+ plus += j2 - j1
482
+ return plus, minus
483
+
484
+
485
+ class StatusLine(BaseModel):
486
+ name: str
487
+ counts: list[tuple[str, int, int]] = [] # (label, added lines, removed lines)
488
+ detail: list[Part] = []
489
+
490
+
491
+ def _counts_text(counts: list[tuple[str, int, int]]) -> Text:
492
+ t = Text()
493
+ for n, (label, plus, minus) in enumerate(counts):
494
+ if n:
495
+ t.append(", ")
496
+ if label:
497
+ t.append(label + " ")
498
+ t.append("+%d" % plus, "green")
499
+ t.append(" ")
500
+ t.append("-%d" % minus, "red")
501
+ return t
502
+
503
+
504
+ def _render(ui: UI, kind: str, title: str, lines: list[StatusLine], label: str, style: str, w: int, cw: int) -> None:
505
+ ui.print((title, "bold"), kind=kind)
506
+ console = ui.console
507
+ for line in lines:
508
+ t = Text(" " * 8)
509
+ if label:
510
+ t.append(label.ljust(12), style)
511
+ t.append(line.name.ljust(w), style)
512
+ if line.counts or line.detail:
513
+ counts = _counts_text(line.counts)
514
+ t.append(" ")
515
+ t.append_text(counts)
516
+ t.append(" " * (cw - len(counts.plain)))
517
+ for n, (text, part_style) in enumerate(line.detail):
518
+ t.append(" " if n == 0 else "; ")
519
+ t.append(text, part_style)
520
+ console.print(t)
521
+
522
+
523
+ def _error_name(e: ScriptError, script_dir: str) -> str:
524
+ """A script error as status lists it: path:line (relative to the scripts folder): message."""
525
+ line = ":%d" % e.line if e.line else ""
526
+ return "%s%s: %s" % (os.path.relpath(e.path, script_dir), line, e.err)
527
+
528
+
529
+ def _status(syncer: Syncer, units: list[Unit]) -> dict[str, list[StatusLine]]:
530
+ project = syncer.project
531
+ out: dict[str, list[StatusLine]] = {k: [] for k in ("script", "game", "both", "unsynced", "errors", "new")}
532
+ data_of: dict[str, bytes | None] = {}
533
+ for u in units:
534
+ if u.bin_path not in data_of:
535
+ data_of[u.bin_path] = read_bytes(u.bin_path)
536
+ data = data_of[u.bin_path]
537
+ if data is None:
538
+ continue
539
+ what = {"map": "event", "common": "common event", "troop": "troop", "maptree": "map"}.get(u.kind, "entry")
540
+ py_name = u.script_label(project.script_dir)
541
+ bin_name = os.path.relpath(u.bin_path, project.game_dir)
542
+ if u.kind == "database":
543
+ bin_name += " [%s]" % u.table
544
+ elif u.kind == "common":
545
+ bin_name += " [common events]"
546
+ elif u.kind == "troop":
547
+ bin_name += " [troop events]"
548
+ try:
549
+ found = u.read_script()
550
+ except ScriptError as e:
551
+ out["errors"].append(StatusLine(name=_error_name(e, project.script_dir)))
552
+ continue
553
+ if found is None:
554
+ out["new"].append(StatusLine(name=py_name))
555
+ continue
556
+ text = found.text
557
+ st = syncer.state.get(u)
558
+ bin_changed = u.bin_changed(data, st)
559
+ py_changed = st is None or sha(text.encode("utf-8")) != st["py"]
560
+ if not (bin_changed or py_changed):
561
+ continue
562
+
563
+ specs: list = []
564
+ if py_changed:
565
+ try:
566
+ specs, _ = u.compile_checked(text, data)
567
+ except ScriptError as e:
568
+ out["errors"].append(StatusLine(name=_error_name(e, project.script_dir)))
569
+ continue
570
+ problems = syncer.check_engine(u, specs, data)
571
+ if problems:
572
+ out["errors"] += [StatusLine(name="%s: %s" % (py_name, p)) for p in problems]
573
+ continue
574
+ game_specs = u.specs_from_bin(data)
575
+
576
+ if st is None: # never synced: a sync adopts the pair if it agrees
577
+ detail = _entry_diff(game_specs, specs, what)
578
+ if detail:
579
+ out["unsynced"].append(StatusLine(name=py_name, detail=detail))
580
+ continue
581
+
582
+ base = syncer.state.base_text(u)
583
+ base_specs = None
584
+ if base is not None and bin_changed:
585
+ try:
586
+ base_specs = u.compile(base)
587
+ except Exception: # base written by an older rpgsync, renamed switch...
588
+ base_specs = None
589
+ pulled = None
590
+ if bin_changed:
591
+ try:
592
+ pulled = u.decompile(data)
593
+ except Exception:
594
+ pulled = None
595
+
596
+ if py_changed and not bin_changed:
597
+ counts = [("", *_line_counts(base, text))] if base is not None else []
598
+ detail = _entry_diff(game_specs, specs, what) or [("no change for the game", "dim")]
599
+ out["script"].append(StatusLine(name=py_name, counts=counts, detail=detail))
600
+ elif bin_changed and not py_changed:
601
+ counts = [("", *_line_counts(text, pulled))] if base is not None and pulled is not None else []
602
+ detail = _entry_diff(base_specs, game_specs, what) if base_specs is not None else []
603
+ out["game"].append(StatusLine(name=bin_name, counts=counts, detail=detail))
604
+ else:
605
+ counts = []
606
+ if base is not None:
607
+ counts.append(("scripts", *_line_counts(base, text)))
608
+ if pulled is not None:
609
+ counts.append(("game", *_line_counts(base, pulled)))
610
+ detail = []
611
+ if base_specs is not None:
612
+ for side, old, new in (("scripts", base_specs, specs), ("game", base_specs, game_specs)):
613
+ parts = _entry_diff(old, new, what)
614
+ if parts: # "scripts: event 9 changed; game: event 1 changed"
615
+ detail += [(side + ": " + parts[0][0], parts[0][1])] + parts[1:]
616
+ out["both"].append(StatusLine(name="%s / %s" % (py_name, bin_name), counts=counts, detail=detail))
617
+ return out
618
+
619
+
620
+ # --------------------------------------------------------------------------
621
+ # Commands
622
+ # --------------------------------------------------------------------------
623
+
624
+
625
+ def watch(
626
+ project: ProjectArg = None,
627
+ interval: Annotated[float, typer.Option(help="Poll interval in seconds.")] = 0.5,
628
+ yes: YesOpt = False,
629
+ ):
630
+ """Keep the game and its scripts in sync until Ctrl+C (also: `rpgsync [FOLDER]`).
631
+ The first time, the scripts are written and their folder is set up.
632
+
633
+ For the EasyRPG Editor with its rpgsync mode on, which reloads what changes on disk.
634
+ With the RPG Maker 2000/2003 editor, keep it closed while you edit the scripts."""
635
+ syncer = _open(project)
636
+ ui = syncer.ui
637
+ initial = _needs_set_up(syncer)
638
+ if initial:
639
+ _set_up(syncer, yes)
640
+ _sync_all(syncer, syncer.units(), None, initial)
641
+ if initial:
642
+ rel = os.path.relpath(syncer.project.script_dir, os.path.dirname(syncer.project.game_dir))
643
+ ui.echo("")
644
+ ui.print(("Next:", "bold"))
645
+ ui.bullet(("edit ", "bold green"), "the scripts in ", (rel, "cyan"), ": saving one writes it into the game")
646
+ ui.bullet(
647
+ ("Default RPG Maker 2000/2003 editor: ", "bold"),
648
+ "keep it closed while you edit the scripts (it keeps its own copy of the game "
649
+ "and ignores them), then open it and keep editing there",
650
+ )
651
+ ui.bullet(
652
+ ("EasyRPG Editor: ", "bold"),
653
+ "turn on its ",
654
+ ("rpgsync mode", "bold"),
655
+ " (Settings): it reloads what changes on disk, so it can stay open",
656
+ )
657
+ ui.bullet(
658
+ ("rpgsync status", "bold cyan"),
659
+ " lists what changed, ",
660
+ ("rpgsync check", "bold cyan"),
661
+ " runs the engine and type checks",
662
+ )
663
+ ui.echo("")
664
+ ui.print("Watching ", (syncer.project.game_dir, "cyan"), kind="watch")
665
+ ui.echo("Every edit will be synced between python files and game files.")
666
+ ui.echo("Ctrl+C to stop")
667
+ try:
668
+ syncer.watch(interval, announce=False, first_pass=False)
669
+ except KeyboardInterrupt:
670
+ ui.print("stopped", kind="stop")
671
+
672
+
673
+ def pull(
674
+ project: ProjectArg = None,
675
+ map: MapOpt = None,
676
+ no_common: NoCommonOpt = False,
677
+ force: Annotated[bool, typer.Option("--force", help="Overwrite scripts that have unsynced edits.")] = False,
678
+ yes: YesOpt = False,
679
+ ):
680
+ """Game -> scripts: regenerate the scripts from the game files.
681
+
682
+ With the RPG Maker 2000/2003 editor: save and close it first."""
683
+ syncer = _open(project)
684
+ initial = _needs_set_up(syncer)
685
+ if initial:
686
+ _set_up(syncer, yes)
687
+ _run(syncer, _units(syncer, map, no_common), "game" if force else None, initial)
688
+
689
+
690
+ def push(
691
+ project: ProjectArg = None,
692
+ map: MapOpt = None,
693
+ no_common: NoCommonOpt = False,
694
+ force: Annotated[
695
+ bool, typer.Option("--force", help="Write every script, also those unchanged since the last sync.")
696
+ ] = False,
697
+ ):
698
+ """Scripts -> game: write the scripts changed since the last sync into the game files (the
699
+ script wins over edits made in the editor).
700
+
701
+ With the RPG Maker 2000/2003 editor: open it after the push, so it reads them."""
702
+ syncer = _open(project)
703
+ _run(syncer, _units(syncer, map, no_common), "script", force=force)
704
+
705
+
706
+ def locate(
707
+ project: ProjectArg = None,
708
+ map: Annotated[int | None, typer.Option("--map", help="The map of the event.")] = None,
709
+ event: Annotated[int | None, typer.Option("--event", help="The event, on --map.")] = None,
710
+ page: Annotated[int, typer.Option("--page", help="Its page, from 1.")] = 1,
711
+ common_event: Annotated[int | None, typer.Option("--common-event", help="A common event instead.")] = None,
712
+ troop: Annotated[
713
+ int | None, typer.Option("--troop", help="The battle events of a troop instead (with --page).")
714
+ ] = None,
715
+ command: Annotated[
716
+ int | None, typer.Option("--command", help="A command of the list, from 0 (as in the editor).")
717
+ ] = None,
718
+ map_tree: Annotated[
719
+ int | None,
720
+ typer.Option("--map-tree", help="A map or area of database/map_tree.py instead (0: the start positions)."),
721
+ ] = None,
722
+ ):
723
+ """Print FILE:LINE where a script writes an event, its page or one of its commands, a
724
+ map (--map alone) or a map's entry in the map tree (for editors: open the script
725
+ there). Writes nothing."""
726
+ syncer = _open(project)
727
+ proj = syncer.project
728
+ ctx = proj.context()
729
+ if map_tree is not None:
730
+ print("%s:%d" % _locate_tree_entry(proj, map_tree))
731
+ return
732
+ if map is not None and event is None and common_event is None and troop is None:
733
+ found = _read_script(proj.script_for_map(map), scriptfiles.MAP)
734
+ print("%s:%d" % _abs_where(found, 1))
735
+ return
736
+ if troop is not None:
737
+ path, style = proj.troop_events_script, scriptfiles.TROOP_EVENTS
738
+ elif common_event is not None:
739
+ path, style = proj.common_events_script, scriptfiles.COMMON_EVENTS
740
+ elif map is not None and event is not None:
741
+ path, style = proj.script_for_map(map), scriptfiles.MAP
742
+ else:
743
+ raise _fail("give --map and --event, --common-event, or --troop")
744
+ found = _read_script(path, style)
745
+ src = found.text
746
+ # compiled from the files as they are now: the lines of the cache can be stale
747
+ try:
748
+ if troop is not None:
749
+ specs = [s for s in compile_troop_events_source(src, ctx, path) if s.id == troop]
750
+ pages = specs[0].pages if specs else []
751
+ if specs and 1 <= page <= len(pages):
752
+ line, commands = pages[page - 1].lineno, pages[page - 1].commands
753
+ else:
754
+ line, commands = (specs[0].lineno if specs else None), []
755
+ elif common_event is not None:
756
+ specs = [s for s in compile_common_events_source(src, ctx, path) if s.id == common_event]
757
+ line = specs[0].lineno if specs else None
758
+ commands = specs[0].commands if specs else []
759
+ else:
760
+ specs = [s for s in compile_map_source(src, ctx, path) if s.id == event]
761
+ pages = specs[0].pages if specs else []
762
+ if specs and 1 <= page <= len(pages):
763
+ line, commands = pages[page - 1].lineno, pages[page - 1].commands
764
+ else:
765
+ line, commands = (specs[0].lineno if specs else None), []
766
+ except CompileError as e:
767
+ raise _fail("%s:%s: %s" % (*_abs_where(found, e.lineno or 1), e)) from None
768
+ if line is None:
769
+ raise _fail("not in %s" % path)
770
+ if command is not None and commands:
771
+ # the command, else the nearest one written before it (a block's end,
772
+ # the end of the list)
773
+ index = min(max(command, 0), len(commands) - 1)
774
+ lines = [c.line for c in commands[: index + 1] if c.line is not None]
775
+ line = lines[-1] if lines else line
776
+ print("%s:%d" % _abs_where(found, line))
777
+
778
+
779
+ def _read_script(path: str, style: scriptfiles.Style | None) -> scriptfiles.ScriptFiles:
780
+ """A script as it is now (one file or a folder), or a failure."""
781
+ try:
782
+ found = scriptfiles.read(path, style)
783
+ except scriptfiles.LayoutError as e:
784
+ raise _fail(str(e)) from None
785
+ if found is None:
786
+ raise _fail("no script %s: run rpgsync first" % path)
787
+ return found
788
+
789
+
790
+ def _abs_where(found: scriptfiles.ScriptFiles, line: int) -> tuple[str, int]:
791
+ """The absolute path and the line of a line of a script's text."""
792
+ path, n = found.where(line)
793
+ return os.path.abspath(path), n or 1
794
+
795
+
796
+ def _locate_tree_entry(proj: Project, map_id: int) -> tuple[str, int]:
797
+ """map_tree.py and the line of a map's entry (0: of `start`)."""
798
+ from .maptree import StartSpec, compile_map_tree
799
+
800
+ path = proj.map_tree_script
801
+ if not os.path.isfile(path):
802
+ raise _fail("no script %s: run rpgsync first" % path)
803
+ with open(path, encoding="utf-8") as f:
804
+ src = f.read()
805
+ try:
806
+ specs = compile_map_tree(src, proj.context(), path)
807
+ except CompileError as e:
808
+ raise _fail("%s:%s: %s" % (path, e.lineno or 1, e)) from None
809
+ for s in specs:
810
+ if map_id == 0 and isinstance(s, StartSpec):
811
+ return os.path.abspath(path), s.lineno or 1
812
+ if map_id != 0 and getattr(s, "id", None) == map_id and s.lineno:
813
+ return os.path.abspath(path), s.lineno
814
+ raise _fail("map %d is not in %s" % (map_id, path))
815
+
816
+
817
+ def status(project: ProjectArg = None, map: MapOpt = None, no_common: NoCommonOpt = False):
818
+ """Show what is pending on each side, without writing anything."""
819
+ syncer = _open(project)
820
+ ui = syncer.ui
821
+ engine = ENGINES.get(syncer.project.context().engine, syncer.project.context().engine)
822
+ ui.print("On ", (os.path.basename(syncer.project.game_dir), "bold"), " (%s)" % engine)
823
+ ui.print(" game: ", (syncer.project.game_dir, "cyan"))
824
+ ui.print(" scripts: ", (syncer.project.script_dir, "cyan"))
825
+ ui.echo("")
826
+
827
+ found = _status(syncer, list(_units(syncer, map, no_common)))
828
+ found["tree"] = []
829
+ if not map and os.path.exists(syncer.project.lmt_path):
830
+ proj = syncer.project
831
+ tree_errors, tree_warnings = tree_problems(proj.game_dir, proj.script_dir, proj.encoding)
832
+ found["tree"] = [StatusLine(name=p) for p in tree_errors + tree_warnings]
833
+ listed = [line for k in ("script", "game", "both", "unsynced") for line in found[k]]
834
+ if not listed and not found["errors"] and not found["new"] and not found["tree"]:
835
+ ui.print(("All files are synced", "bold green"), kind="ok")
836
+ return
837
+ w = max((len(line.name) for line in listed), default=0)
838
+ cw = max((len(_counts_text(line.counts).plain) for line in listed), default=0)
839
+ sections = (
840
+ ("script", "Changes in scripts (`rpgsync push` writes them into the game):", "modified:", "yellow"),
841
+ ("game", "Changes in the game (`rpgsync pull` updates the scripts):", "modified:", "yellow"),
842
+ ("merge", "Changed on both sides (`rpgsync` merges them per event):", "modified:", "yellow"),
843
+ (
844
+ "conflict",
845
+ "Never synced and different (`rpgsync push` keeps the scripts, `rpgsync pull --force` keeps the game):",
846
+ "differs:",
847
+ "yellow",
848
+ ),
849
+ ("error", "Scripts with errors (fix them before they can be synced):", "", "red"),
850
+ ("new", "Not exported yet (`rpgsync pull` creates them):", "", "green"),
851
+ ("warning", "Map tree (maps, map files and scripts that don't match):", "", "yellow"),
852
+ )
853
+ keys = {
854
+ "script": "script",
855
+ "game": "game",
856
+ "merge": "both",
857
+ "conflict": "unsynced",
858
+ "error": "errors",
859
+ "warning": "tree",
860
+ }
861
+ for kind, title, label, style in sections:
862
+ lines = found[keys.get(kind, kind)]
863
+ if lines:
864
+ _render(ui, kind, title, lines, label, style, w if label else 0, cw)
865
+
866
+
867
+ def check(
868
+ paths: Annotated[
869
+ list[str] | None,
870
+ typer.Argument(
871
+ help="The game folder (with RPG_RT.ldb) or its scripts folder (default: the current folder), "
872
+ "or scripts and folders of scripts to check only those: Scripts/Map0004.py, Scripts/database.",
873
+ show_default=False,
874
+ ),
875
+ ] = None,
876
+ map: MapOpt = None,
877
+ no_common: NoCommonOpt = False,
878
+ patches: Annotated[
879
+ list[str] | None,
880
+ typer.Option("--patch", help="Allowed engine patch: dynrpg, maniac or easyrpg (repeatable)."),
881
+ ] = None,
882
+ no_types: Annotated[bool, typer.Option("--no-types", help="Skip the type check (ty).")] = False,
883
+ resources: Annotated[
884
+ bool,
885
+ typer.Option(
886
+ "--resources",
887
+ help="Also check the resource files: used by an event but not imported, imported but never used.",
888
+ ),
889
+ ] = False,
890
+ jobs: Annotated[
891
+ int | None,
892
+ typer.Option(
893
+ "--jobs", "-j", min=1, help="Processes compiling the scripts (default: one per CPU for a large game)."
894
+ ),
895
+ ] = None,
896
+ ):
897
+ """Compile every script (or the ones given), check engine compatibility and types (ty), without
898
+ writing anything."""
899
+ syncer = _open(_paths_project(paths))
900
+ ui, err = syncer.ui, UI(err=True)
901
+ proj = syncer.project
902
+ chosen = _chosen_paths(proj, paths) # None: the whole game
903
+ engine = target_engine(proj.script_dir, proj.context().engine)
904
+ declared, allow = load_rules(proj.script_dir)
905
+ errors = warnings = 0
906
+ tree_maps, sizes = map_tree_maps(proj)
907
+ files = Collector(proj.encoding) if resources else None # the files the scripts and the game use
908
+ allowed = patches or declared
909
+ dyn = "dynrpg" in allowed or "easyrpg" in allowed # plugin calls run: check them against the game
910
+ rules = _Rules(
911
+ engine=engine,
912
+ patches=allowed,
913
+ allow=allow,
914
+ tree_maps=tree_maps,
915
+ sizes=sizes,
916
+ plugins=dynrpg.installed(proj.game_dir) if dyn else None,
917
+ tables=proj.table_sizes() if dyn else None,
918
+ )
919
+ units = list(_units(syncer, map, no_common))
920
+ if chosen is not None:
921
+ keep = {u.py_path for u in _chosen_units(syncer.units(), chosen)}
922
+ units = [u for u in units if u.py_path in keep]
923
+ for found, part in _checked_units(proj, units, rules, resources, jobs):
924
+ for kind, where, problem in found:
925
+ color = "red" if kind == "error" else "yellow"
926
+ err.print(*([(where, "cyan"), ": "] if where else []), (problem, color), kind=kind)
927
+ errors += kind == "error"
928
+ warnings += kind == "warning"
929
+ if files is not None and part is not None:
930
+ files.merge(part)
931
+ if tree_maps is not None and any(u.kind == "maptree" for u in units):
932
+ tree_errors, tree_warnings = tree_problems(proj.game_dir, proj.script_dir, proj.encoding)
933
+ for problem in tree_errors:
934
+ err.print(("RPG_RT.lmt", "cyan"), ": ", (problem, "red"), kind="error")
935
+ for problem in tree_warnings:
936
+ err.print(("RPG_RT.lmt", "cyan"), ": ", (problem, "yellow"), kind="warning")
937
+ errors += len(tree_errors)
938
+ warnings += len(tree_warnings)
939
+ if rules.plugins is not None and "dynrpg" in rules.patches and chosen is None and not map:
940
+ for problem in dynrpg.setup_problems(rules.plugins, rules.tables): # once, for the whole game
941
+ err.print(("DynRPG.ini", "cyan"), ": ", (problem, "yellow"), kind="warning")
942
+ warnings += 1
943
+ for problem in dynrpg.easyrpg_ini_problems(rules.plugins):
944
+ err.print(("EasyRPG.ini", "cyan"), ": ", (problem, "yellow"), kind="warning")
945
+ warnings += 1
946
+ if files is not None:
947
+ complete = chosen is None and not map and not no_common
948
+ warnings += _check_resources(files, proj, ui, err, complete=complete)
949
+ if not no_types:
950
+ errors += _check_types(syncer.project.script_dir, ui, err, chosen)
951
+ warned = ("%d warning(s)" % warnings, "bold yellow") if warnings else None
952
+ if errors:
953
+ ui.print(("%d problem(s)" % errors, "bold red"), *([", ", warned] if warned else []), kind="error")
954
+ raise typer.Exit(1)
955
+ if chosen is not None and len(units) == 1: # Map0004.py compiles and fits the engine
956
+ scripts, verbs = units[0].script_label(), ("compiles", "fits", "passes")
957
+ else:
958
+ scripts, verbs = "All scripts" if chosen is None else "The %d scripts" % len(units), ("compile", "fit", "pass")
959
+ what = "%s and %s the engine" % verbs[:2] + (" and %s the type check" % verbs[2] if not no_types else "")
960
+ ui.print(("%s %s" % (scripts, what), "bold green"), *([" (", warned, ")"] if warned else []), kind="ok")
961
+
962
+
963
+ def _paths_project(paths: list[str] | None) -> str | None:
964
+ """The folder whose game `check` checks: that of the paths it was given (one game)."""
965
+ if not paths:
966
+ return None
967
+ games = {}
968
+ for p in paths:
969
+ if not os.path.exists(p):
970
+ raise typer.BadParameter("%s does not exist" % p)
971
+ where = Path(p).resolve()
972
+ folder = where if where.is_dir() else where.parent
973
+ games[p] = (_find(folder), str(folder))
974
+ first = paths[0]
975
+ for p in paths[1:]:
976
+ if games[p][0] != games[first][0]:
977
+ raise typer.BadParameter("%s and %s are not in the same game" % (first, p))
978
+ return games[first][1]
979
+
980
+
981
+ def _chosen_paths(proj: Project, paths: list[str] | None) -> list[Path] | None:
982
+ """The scripts and folders of scripts to check; None: the whole game (no paths, or its
983
+ game or scripts folder)."""
984
+ if not paths:
985
+ return None
986
+ chosen = [Path(p).resolve() for p in paths]
987
+ whole = (Path(proj.game_dir).resolve(), Path(proj.script_dir).resolve())
988
+ return None if any(p in whole for p in chosen) else chosen
989
+
990
+
991
+ def _chosen_units(units: list[Unit], chosen: list[Path]) -> list[Unit]:
992
+ """The units whose script is one of `chosen` or in one of its folders (a file of a
993
+ script split in files counts for the whole script)."""
994
+ scripts = {u.py_path: Path(u.py_path).resolve() for u in units}
995
+ folders = {u.py_path: Path(scriptfiles.package_dir(u.py_path)).resolve() for u in units}
996
+
997
+ def chose(p: Path, py_path: str) -> bool:
998
+ s, d = scripts[py_path], folders[py_path]
999
+ return p == s or p in s.parents or p == d or d in p.parents
1000
+
1001
+ out = [u for u in units if any(chose(p, u.py_path) for p in chosen)]
1002
+ for p in chosen:
1003
+ if not any(chose(p, u.py_path) for u in units):
1004
+ what = "holds no script" if p.is_dir() else "is not a script"
1005
+ raise _fail("%s %s of the game (a map, a database table, the common events...)" % (p, what))
1006
+ return out
1007
+
1008
+
1009
+ class _Rules(BaseModel):
1010
+ """What `check` checks the compiled scripts against."""
1011
+
1012
+ engine: str
1013
+ patches: list[str]
1014
+ allow: list[int]
1015
+ tree_maps: dict[int, str] | None # the maps of the game (None: no map tree) and their sizes
1016
+ sizes: dict[int, tuple[int, int] | None]
1017
+ plugins: dynrpg.Installed | None = None # DynPlugins/ and DynRPG.ini (None: no plugin call runs)
1018
+ tables: dict[str, int] | None = None # entries of the variables, actors, items, skills
1019
+
1020
+
1021
+ def _check_unit(u: Unit, rules: _Rules, files: Collector | None) -> list[tuple[str, str, str]]:
1022
+ """Compile a unit's script and check it. -> the problems to print: (kind, where, problem)"""
1023
+ found: list[tuple[str, str, str]] = []
1024
+ try:
1025
+ script = u.read_script()
1026
+ if script is None:
1027
+ if files is not None:
1028
+ _game_resources(files, u)
1029
+ return found
1030
+ specs, _ = u.compile_checked(script.text)
1031
+ except ScriptError as e:
1032
+ if files is not None:
1033
+ _game_resources(files, u)
1034
+ return [("error", "", str(e))]
1035
+ if files is not None:
1036
+ files.specs(specs, u.py_path, u.where if script.package else None)
1037
+ entry_files = script.entry_files(u.split_style) if script.package else {}
1038
+
1039
+ def place(problem: str) -> str: # "event 5 page 1: ...": the file of event 5, for a script in files
1040
+ m = re.match(r"(?:event|common event|troop) (\d+)\b", problem)
1041
+ return entry_files.get(int(m.group(1)), u.at(None)) if m and entry_files else u.at(None)
1042
+
1043
+ for problem in check_specs(specs, rules.engine, rules.patches, rules.allow):
1044
+ found.append(("error", place(problem), problem))
1045
+ for problem in check_warnings(specs, rules.engine, rules.patches, rules.allow, rules.plugins, rules.tables):
1046
+ found.append(("warning", place(problem), problem))
1047
+ if u.kind == "map":
1048
+ # the active page is the last one whose conditions hold: pages after it may hide it
1049
+ for line, problem in unreachable_pages(specs):
1050
+ found.append(("warning", u.at(line), problem))
1051
+ if rules.tree_maps is not None and u.kind in ("map", "common", "troop", "maptree"):
1052
+ # teleports (vehicles, teleport / escape targets, start positions) to maps of the game, inside them
1053
+ for line, problem in map_reference_problems(specs, rules.tree_maps, rules.sizes):
1054
+ found.append(("error", u.at(line), problem))
1055
+ return found
1056
+
1057
+
1058
+ _PARALLEL_SIZE = 3_000_000 # scripts (bytes) worth compiling in several processes: ~1 s in one
1059
+
1060
+
1061
+ def _checked_units(
1062
+ proj: Project, units: list[Unit], rules: _Rules, resources: bool, jobs: int | None
1063
+ ) -> Iterator[tuple[list[tuple[str, str, str]], Collector | None]]:
1064
+ """_check_unit() of each unit, in order: (problems, the files it uses). A large game's
1065
+ scripts compile in several processes (`jobs`, default one per CPU)."""
1066
+ sizes = {
1067
+ u.py_path: sum(os.path.getsize(p) for p in scriptfiles.paths(u.py_path) if os.path.isfile(p)) for u in units
1068
+ }
1069
+ if jobs is None:
1070
+ jobs = (os.cpu_count() or 1) if sum(sizes.values()) >= _PARALLEL_SIZE else 1
1071
+ jobs = min(jobs, len(units))
1072
+ if jobs <= 1:
1073
+ for u in units:
1074
+ files = Collector(proj.encoding) if resources else None
1075
+ yield _check_unit(u, rules, files), files
1076
+ return
1077
+ import multiprocessing
1078
+ from concurrent.futures import ProcessPoolExecutor
1079
+
1080
+ context = multiprocessing.get_context("spawn") # fork is unsafe on macOS
1081
+ init = (proj.game_dir, proj.script_dir, rules, resources)
1082
+ with ProcessPoolExecutor(jobs, mp_context=context, initializer=_worker_init, initargs=init) as pool:
1083
+ try:
1084
+ # the largest first, so that the last ones to finish are short
1085
+ futures = {
1086
+ u.py_path: pool.submit(_worker_check, u.py_path) for u in sorted(units, key=lambda u: -sizes[u.py_path])
1087
+ }
1088
+ for u in units:
1089
+ yield futures[u.py_path].result()
1090
+ finally:
1091
+ pool.shutdown(cancel_futures=True)
1092
+
1093
+
1094
+ _WORKER: dict[str, Any] = {} # a check process: its units, rules
1095
+
1096
+
1097
+ def _worker_init(game_dir: str, script_dir: str, rules: _Rules, resources: bool) -> None:
1098
+ project = Project(game_dir, script_dir)
1099
+ _WORKER.update(units={u.py_path: u for u in Syncer(project).units()}, rules=rules, resources=resources)
1100
+ _WORKER["encoding"] = project.encoding
1101
+
1102
+
1103
+ def _worker_check(py_path: str) -> tuple[list[tuple[str, str, str]], Collector | None]:
1104
+ files = Collector(_WORKER["encoding"]) if _WORKER["resources"] else None
1105
+ return _check_unit(_WORKER["units"][py_path], _WORKER["rules"], files), files
1106
+
1107
+
1108
+ def _game_resources(resources: Collector, unit: Unit) -> None:
1109
+ """The resources of a unit whose script is missing or does not compile, from its game file."""
1110
+ data = read_bytes(unit.bin_path)
1111
+ if data is None:
1112
+ return
1113
+ try:
1114
+ resources.specs(unit.bin_specs(data), os.path.basename(unit.bin_path))
1115
+ except Exception: # unreadable game data: reported by the sync
1116
+ pass
1117
+
1118
+
1119
+ def _check_resources(resources: Collector, proj: Project, ui: UI, err: UI, complete: bool) -> int:
1120
+ """Warn about references to files the game does not have (not imported), and, when
1121
+ every script was read (`complete`), about imported files nothing uses. -> warnings"""
1122
+ if complete:
1123
+ resources.game(proj.game_dir, proj.ldb_path)
1124
+ found = resource_problems(resources, proj.game_dir, proj.context().engine, complete)
1125
+ grouped: dict[tuple[str, str], list] = {} # (database file, folder) -> its references
1126
+ uses: dict[tuple[str, str], list] = {} # (folder, file) -> the events using it
1127
+ for ref in found.missing:
1128
+ if not ref.event: # the database keeps many (default entries never shown): one line per folder
1129
+ grouped.setdefault((ref.path, ref.folder), []).append(ref)
1130
+ else: # one line per file, at its first use
1131
+ uses.setdefault((ref.folder, resource_key(ref.name)), []).append(ref)
1132
+ for refs in uses.values():
1133
+ ref = refs[0]
1134
+ where = "%s:%d" % (ref.path, ref.line) if ref.line else ref.path
1135
+ problem = "%s uses %s/%s, which is not imported" % (ref.what, ref.folder, ref.name)
1136
+ if len(refs) > 1:
1137
+ others = [
1138
+ "%s:%d" % (os.path.basename(r.path), r.line) if r.line else os.path.basename(r.path) for r in refs[1:4]
1139
+ ]
1140
+ more = " and %d more" % (len(refs) - 4) if len(refs) > 4 else ""
1141
+ problem += " (also used at %s%s)" % (", ".join(others), more)
1142
+ err.print((where, "cyan"), ": ", (problem, "yellow"), kind="warning")
1143
+ for (path, folder), refs in grouped.items():
1144
+ where = "%s:%d" % (path, refs[0].line) if refs[0].line else path
1145
+ names = list(dict.fromkeys(r.name for r in refs))
1146
+ shown = ", ".join(names[:8]) + (" and %d more" % (len(names) - 8) if len(names) > 8 else "")
1147
+ if len(refs) == 1:
1148
+ problem = "%s uses %s/%s, which is not imported" % (refs[0].what, folder, refs[0].name)
1149
+ else:
1150
+ problem = "%d entries use %s files that are not imported: %s" % (len(refs), folder, shown)
1151
+ err.print((where, "cyan"), ": ", (problem, "yellow"), kind="warning")
1152
+ for folder, names in found.unused.items():
1153
+ problem = "%d imported file%s never used: %s" % (len(names), "s" * (len(names) > 1), ", ".join(names))
1154
+ err.print((folder + "/", "cyan"), ": ", (problem, "yellow"), kind="warning")
1155
+ if found.note:
1156
+ ui.print(found.note, kind="info")
1157
+ return len(uses) + len(grouped) + len(found.unused)
1158
+
1159
+
1160
+ def _ty(script_dir: str) -> str | None:
1161
+ """The ty of the scripts folder's environment, else one on the PATH."""
1162
+ for rel in (".venv/bin/ty", ".venv/Scripts/ty.exe"):
1163
+ path = os.path.join(script_dir, rel)
1164
+ if os.path.isfile(path):
1165
+ return path
1166
+ return shutil.which("ty")
1167
+
1168
+
1169
+ def _check_types(script_dir: str, ui: UI, err: UI, paths: list[Path] | None = None) -> int:
1170
+ """Run ty on the scripts folder (or on `paths` in it); print its diagnostics. -> number
1171
+ of problems (With DynParams, database/game_types.py lets a variable stand where the DSL
1172
+ takes a fixed number.)"""
1173
+ ty = _ty(script_dir)
1174
+ if ty is None:
1175
+ ui.print("type check skipped: ty is not installed (run `uv sync` in %s)" % script_dir, kind="info")
1176
+ return 0
1177
+ cmd = [ty, "check", "--output-format", "concise", *(str(p) for p in paths or ())]
1178
+ venv = os.path.join(script_dir, ".venv")
1179
+ if os.path.isdir(venv): # the scripts' environment, even when another virtualenv is active
1180
+ cmd += ["--python", venv]
1181
+ result = subprocess.run(cmd, cwd=script_dir, capture_output=True, text=True, errors="replace", check=False)
1182
+ lines = [ln for ln in result.stdout.splitlines() if ln.strip() and not ln.startswith(("Found ", "All checks"))]
1183
+ if result.returncode == 0 or not lines:
1184
+ return 0
1185
+ for line in lines:
1186
+ err.print((line, "red"), kind="error")
1187
+ return max(len(lines), 1)
1188
+
1189
+
1190
+ def clean(
1191
+ project: ProjectArg = None,
1192
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
1193
+ ):
1194
+ """Delete the backups of overwritten files (Scripts/.rpgsync/history)."""
1195
+ syncer = _open(project)
1196
+ ui = syncer.ui
1197
+ history = Path(syncer.history_dir)
1198
+ snapshots = sorted(history.iterdir()) if history.is_dir() else []
1199
+ if not snapshots:
1200
+ ui.print("no backups to delete", kind="info")
1201
+ return
1202
+ size = sum(f.stat().st_size for f in history.rglob("*") if f.is_file())
1203
+ ui.print(
1204
+ "%d backup(s), %.1f MB in " % (len(snapshots), max(size / 1e6, 0.1)), (str(history), "cyan"), kind="delete"
1205
+ )
1206
+ if not yes and typer.prompt('Type "yes" to delete them', default="", show_default=False) != "yes":
1207
+ ui.print("nothing deleted", kind="info")
1208
+ raise typer.Exit(1)
1209
+ shutil.rmtree(history)
1210
+ ui.print(("deleted", "green"), kind="ok")
1211
+
1212
+
1213
+ # registered here so that `rpgsync --help` lists them in this order
1214
+ for _command in (watch, status, check, pull, push, clean, locate):
1215
+ app.command()(_command)
1216
+
1217
+ COMMANDS = {"watch", "status", "check", "pull", "push", "clean", "locate"}
1218
+
1219
+
1220
+ def main() -> None:
1221
+ """Entry point. `rpgsync FOLDER` is a shortcut for `rpgsync watch FOLDER`,
1222
+ and a bare `rpgsync` watches the game of the current folder."""
1223
+ for stream in (sys.stdout, sys.stderr):
1224
+ _never_fail(stream) # legacy Windows consoles: "?" rather than UnicodeEncodeError
1225
+ argv = sys.argv[1:]
1226
+ if not argv:
1227
+ try:
1228
+ _locate(None)
1229
+ except ProjectNotFound as e:
1230
+ sys.argv.append("--help")
1231
+ try:
1232
+ app(prog_name="rpgsync")
1233
+ except SystemExit:
1234
+ pass
1235
+ UI(err=True).echo("")
1236
+ _fail(str(e))
1237
+ sys.exit(2)
1238
+ sys.argv.insert(1, "watch")
1239
+ elif argv[0] not in COMMANDS and argv[0] != "--help":
1240
+ # a project name/folder, or watch options such as --interval
1241
+ sys.argv.insert(1, "watch")
1242
+ app(prog_name="rpgsync")