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/__init__.py +8 -0
- rpgsync/__main__.py +3 -0
- rpgsync/cli.py +1242 -0
- rpgsync/commands.py +920 -0
- rpgsync/compat.py +203 -0
- rpgsync/database.py +1025 -0
- rpgsync/db.py +1377 -0
- rpgsync/dbschema.py +1011 -0
- rpgsync/dsl.py +2616 -0
- rpgsync/dynparams.py +357 -0
- rpgsync/dynrpg.py +612 -0
- rpgsync/fmt.py +79 -0
- rpgsync/lcf.py +826 -0
- rpgsync/maniac.py +387 -0
- rpgsync/maptree.py +888 -0
- rpgsync/messages.py +113 -0
- rpgsync/named.py +1051 -0
- rpgsync/project.py +323 -0
- rpgsync/py.typed +0 -0
- rpgsync/pyexpr.py +1472 -0
- rpgsync/resources.py +455 -0
- rpgsync/scaffold.py +567 -0
- rpgsync/script.py +3532 -0
- rpgsync/scriptfiles.py +924 -0
- rpgsync/sync.py +1365 -0
- rpgsync/tables.py +114 -0
- rpgsync/venv.py +102 -0
- rpgsync-0.1.0.dist-info/METADATA +288 -0
- rpgsync-0.1.0.dist-info/RECORD +31 -0
- rpgsync-0.1.0.dist-info/WHEEL +4 -0
- rpgsync-0.1.0.dist-info/entry_points.txt +3 -0
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")
|