renforge 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.
renforge/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """RenForge package."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
renforge/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,93 @@
1
+ """Activity feed helpers for MCP tool calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def _coerce_project_root(project_root: str | Path) -> Path:
13
+ return Path(project_root).expanduser().resolve()
14
+
15
+
16
+ def _coerce_files_touched(value: Any) -> list[str]:
17
+ if isinstance(value, str):
18
+ return [value]
19
+ if isinstance(value, (list, tuple, set)):
20
+ return [str(item) for item in value if isinstance(item, (str, Path))]
21
+ return []
22
+
23
+
24
+ def _coerce_result_payload(result: Any) -> Any:
25
+ if isinstance(result, dict) and "ok" in result:
26
+ return result
27
+ if isinstance(result, (str, int, float, bool, list, type(None))):
28
+ return result
29
+ return str(result)
30
+
31
+
32
+ def _coerce_payload(value: Any) -> Any:
33
+ if isinstance(value, (str, int, float, bool, type(None))):
34
+ return value
35
+ if isinstance(value, list):
36
+ return [_coerce_payload(item) for item in value]
37
+ if isinstance(value, dict):
38
+ return {str(k): _coerce_payload(v) for k, v in value.items()}
39
+ if isinstance(value, set):
40
+ return sorted(str(item) for item in value)
41
+ if isinstance(value, tuple):
42
+ return [_coerce_payload(item) for item in value]
43
+ return str(value)
44
+
45
+
46
+ def summarize_result(result: Any) -> dict[str, Any]:
47
+ if isinstance(result, dict):
48
+ ok = result.get("ok", not isinstance(result.get("error"), str))
49
+ files_touched: list[str] = []
50
+ for key in ("files_touched", "files", "changed_files", "changed", "file_touches"):
51
+ candidate = result.get(key)
52
+ if candidate:
53
+ files_touched = _coerce_files_touched(candidate)
54
+ break
55
+ return {"ok": bool(ok), "files_touched": files_touched, "result": result}
56
+
57
+ if isinstance(result, (str, int, float, bool, list, type(None))):
58
+ return {"ok": True, "files_touched": [], "result": result}
59
+
60
+ return {"ok": True, "files_touched": [], "result": str(result)}
61
+
62
+
63
+ def log_tool_call(
64
+ project_root: str | Path,
65
+ name: str,
66
+ params: dict[str, Any],
67
+ duration_ms: float,
68
+ result: Any,
69
+ files_touched: list[str] | None = None,
70
+ ) -> None:
71
+ summary = summarize_result(result)
72
+ entry = {
73
+ "ts": int(time.time() * 1000),
74
+ "name": name,
75
+ "params": _coerce_payload(params),
76
+ "duration_ms": duration_ms,
77
+ "ok": summary["ok"],
78
+ "result": _coerce_result_payload(summary["result"]),
79
+ "files_touched": files_touched or summary["files_touched"],
80
+ }
81
+
82
+ root = _coerce_project_root(project_root)
83
+ if not root.exists() or not root.is_dir():
84
+ return
85
+ path = root / ".renforge" / "activity.jsonl"
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+
88
+ payload = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
89
+ with path.open("a", encoding="utf-8") as file_obj:
90
+ file_obj.write(payload)
91
+ file_obj.write("\n")
92
+ file_obj.flush()
93
+ os.fsync(file_obj.fileno())
renforge/assets.py ADDED
@@ -0,0 +1,149 @@
1
+ """Asset analysis: find orphaned and missing images/audio in a Ren'Py project.
2
+
3
+ Heuristic and deliberately conservative — Ren'Py's image resolution is dynamic
4
+ (``show eileen happy`` maps to a defined image or a file like
5
+ ``images/eileen happy.png``), so this reports *likely* orphans/missing rather
6
+ than a proof. It reads the ``game/`` tree and the ``.rpy`` sources; the engine
7
+ stays the source of truth via ``lint``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif", ".tga", ".bmp"}
18
+ AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".flac", ".m4a", ".aac", ".mp2", ".wma"}
19
+ VIDEO_EXTS = {".webm", ".mp4", ".ogv", ".avi", ".mkv", ".mov", ".mpg", ".mpeg", ".flv"}
20
+ ASSET_EXTS = IMAGE_EXTS | AUDIO_EXTS | VIDEO_EXTS
21
+
22
+ _QUOTED_RE = re.compile(r"""["']([^"'\n]+?)["']""")
23
+ _IMAGE_DEF_RE = re.compile(r"^\s*image\s+([^\n=:]+?)\s*(?:=|:)")
24
+ _SCENE_SHOW_RE = re.compile(r"^\s*(?:scene|show)\s+(.+?)\s*(?:#.*)?$")
25
+ # Tokens that end the image-name part of a scene/show statement.
26
+ _SHOW_STOP = {"at", "with", "as", "behind", "onlayer", "zorder", "expression"}
27
+
28
+
29
+ def _game_dir(project_path: str | Path) -> Path:
30
+ return Path(project_path).expanduser().resolve() / "game"
31
+
32
+
33
+ def _iter_rpy(game: Path):
34
+ for root, _dirs, files in os.walk(game):
35
+ for name in files:
36
+ if name.endswith(".rpy"):
37
+ yield Path(root) / name
38
+
39
+
40
+ def _image_name_from_show(rest: str) -> str:
41
+ tokens = rest.split()
42
+ keep: list[str] = []
43
+ for tok in tokens:
44
+ if tok in _SHOW_STOP:
45
+ break
46
+ keep.append(tok)
47
+ return " ".join(keep)
48
+
49
+
50
+ def analyze_assets(project_path: str | Path) -> dict[str, Any]:
51
+ game = _game_dir(project_path)
52
+ result: dict[str, Any] = {
53
+ "asset_files": [],
54
+ "orphans": [],
55
+ "missing_files": [],
56
+ "undefined_images": [],
57
+ }
58
+ if not game.is_dir():
59
+ result["error"] = f"no game/ directory under {project_path}"
60
+ return result
61
+
62
+ # 1. Asset files on disk (relative to game/, posix-style).
63
+ disk_files: list[str] = []
64
+ for root, _dirs, files in os.walk(game):
65
+ for name in files:
66
+ if Path(name).suffix.lower() in ASSET_EXTS:
67
+ rel = (Path(root) / name).relative_to(game).as_posix()
68
+ disk_files.append(rel)
69
+ disk_files.sort()
70
+ result["asset_files"] = disk_files
71
+
72
+ # 2. References from the scripts.
73
+ quoted: set[str] = set()
74
+ defined_images: set[str] = set()
75
+ shown_images: set[str] = set()
76
+ for rpy in _iter_rpy(game):
77
+ try:
78
+ text = rpy.read_text(encoding="utf-8", errors="replace")
79
+ except OSError:
80
+ continue
81
+ for line in text.splitlines():
82
+ stripped = line.strip()
83
+ if not stripped or stripped.startswith("#"):
84
+ continue
85
+ for m in _QUOTED_RE.finditer(line):
86
+ quoted.add(m.group(1))
87
+ dm = _IMAGE_DEF_RE.match(line)
88
+ if dm:
89
+ defined_images.add(dm.group(1).strip())
90
+ sm = _SCENE_SHOW_RE.match(line)
91
+ if sm:
92
+ name = _image_name_from_show(sm.group(1))
93
+ if name:
94
+ shown_images.add(name)
95
+
96
+ quoted_basenames = {Path(q).name for q in quoted}
97
+
98
+ def _referenced(rel: str) -> bool:
99
+ base = Path(rel).name
100
+ stem = Path(rel).stem # image name candidate, e.g. "eileen happy"
101
+ if rel in quoted or base in quoted_basenames:
102
+ return True
103
+ if any(q.endswith(rel) or q.endswith(base) for q in quoted):
104
+ return True
105
+ # Image files referenced via `scene/show <name>` or `image <name>`.
106
+ if Path(rel).suffix.lower() in IMAGE_EXTS:
107
+ if stem in shown_images or stem in defined_images:
108
+ return True
109
+ return False
110
+
111
+ result["orphans"] = [rel for rel in disk_files if not _referenced(rel)]
112
+
113
+ # 3. Missing: quoted references that look like *developer* asset paths but
114
+ # aren't on disk. We skip substitution patterns ("[prefix_]...") and the
115
+ # gui/ tree, whose images the GUI framework generates or renders by
116
+ # default — flagging those would be noise, not actionable findings.
117
+ disk_set = set(disk_files)
118
+ disk_basenames = {Path(f).name for f in disk_files}
119
+ for q in sorted(quoted):
120
+ if "[" in q or "]" in q:
121
+ continue
122
+ if q.startswith("gui/"):
123
+ continue
124
+ if Path(q).suffix.lower() in ASSET_EXTS:
125
+ if q not in disk_set and Path(q).name not in disk_basenames:
126
+ result["missing_files"].append(q)
127
+
128
+ # 4. Images shown but neither defined nor backed by a file (Ren'Py would use
129
+ # a placeholder). Reported separately as a soft signal.
130
+ disk_stems = {Path(f).stem for f in disk_files if Path(f).suffix.lower() in IMAGE_EXTS}
131
+ for name in sorted(shown_images):
132
+ if name in defined_images:
133
+ continue
134
+ # A single-tag show may resolve to "<first-tag>.png"; check tag stems too.
135
+ first = name.split()[0] if name.split() else name
136
+ if name in disk_stems or first in disk_stems or first in defined_images:
137
+ continue
138
+ result["undefined_images"].append(name)
139
+
140
+ result["summary"] = {
141
+ "asset_count": len(disk_files),
142
+ "orphan_count": len(result["orphans"]),
143
+ "missing_count": len(result["missing_files"]),
144
+ "undefined_image_count": len(result["undefined_images"]),
145
+ }
146
+ return result
147
+
148
+
149
+ __all__ = ["analyze_assets"]
renforge/autopilot.py ADDED
@@ -0,0 +1,171 @@
1
+ """Autopilot: explore a Ren'Py visual novel and report label coverage + crashes.
2
+
3
+ Strategy — systematic branch exploration by replay. Each run launches the game
4
+ fresh, replays a fixed prefix of menu choices, then at the first *new* menu it
5
+ takes choice 0 and queues the remaining choices as future runs. Repeating until
6
+ the frontier is empty covers every branch combination, using only primitives
7
+ that work reliably (launch / advance / list_choices / select_choice /
8
+ poll_events) — no in-game save/load, which cannot be driven from the bridge's
9
+ main-thread callback.
10
+
11
+ Menus are detected by ``list_choices`` returning options. (A game with an
12
+ always-on quick menu could surface non-choice buttons here; refining detection
13
+ to the active ``choice`` screen is a future improvement.)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import time
20
+ from collections import deque
21
+ from pathlib import Path
22
+ from typing import Any, Callable
23
+
24
+ from .bridge.launcher import launch_with_bridge
25
+ from .project import RenpyProject
26
+ from .scanner import scan_project
27
+ from .sdk import RenpySdk
28
+
29
+
30
+ def _story_labels(project_path: str | Path) -> set[str]:
31
+ # Labels authored in the project, excluding Ren'Py-internal (underscore) ones.
32
+ index = scan_project(str(project_path))
33
+ return {label["name"] for label in index.get("labels", []) if not label["name"].startswith("_")}
34
+
35
+
36
+ def _menu_choices(client) -> list[dict]:
37
+ """Choices that belong to the active ``choice`` screen (ignores quick menu)."""
38
+ return [c for c in client.list_choices() if c.get("screen") == "choice"]
39
+
40
+
41
+ def autopilot(
42
+ sdk: RenpySdk,
43
+ project: RenpyProject,
44
+ *,
45
+ max_runs: int = 16,
46
+ max_steps: int = 60,
47
+ settle: float = 0.4,
48
+ startup_timeout: float = 90.0,
49
+ progress_callback: Callable[[dict], None] | None = None,
50
+ ) -> dict[str, Any]:
51
+ """Explore the game and return a coverage/crash report.
52
+
53
+ After each run a partial report is written to
54
+ ``<project>/.renforge/autopilot.json`` and passed to ``progress_callback``
55
+ (if given), so long explorations can be followed incrementally.
56
+ """
57
+ total_labels = _story_labels(project.root)
58
+
59
+ reached: set[str] = set()
60
+ dialogue: set[str] = set()
61
+ crashes: list[dict] = []
62
+ choices_explored = 0
63
+
64
+ frontier: deque[list[int]] = deque([[]])
65
+ seen_prefixes: set[tuple[int, ...]] = set()
66
+ runs = 0
67
+
68
+ progress_path = project.cache_dir / "autopilot.json"
69
+
70
+ def _report(done: bool) -> dict[str, Any]:
71
+ covered = sorted(total_labels & reached)
72
+ return {
73
+ "ok": True,
74
+ "done": done,
75
+ "runs": runs,
76
+ "runs_pending": len(frontier),
77
+ "labels_total": len(total_labels),
78
+ "labels_reached": sorted(reached),
79
+ "labels_covered": covered,
80
+ "labels_unreached": sorted(total_labels - reached),
81
+ "coverage": round(len(covered) / len(total_labels), 3) if total_labels else 1.0,
82
+ "dialogue_lines": len(dialogue),
83
+ "choices_explored": choices_explored,
84
+ "crashes": crashes,
85
+ }
86
+
87
+ def _emit(done: bool) -> None:
88
+ report = _report(done)
89
+ try:
90
+ progress_path.write_text(json.dumps(report), encoding="utf-8")
91
+ except OSError:
92
+ pass
93
+ if progress_callback is not None:
94
+ try:
95
+ progress_callback(report)
96
+ except Exception:
97
+ pass
98
+
99
+ def _drain(client, cursor: int, run_labels: list[str]) -> int:
100
+ events = client.poll_events(since=cursor)
101
+ for event in events["events"]:
102
+ kind = event.get("type")
103
+ if kind == "label":
104
+ name = event.get("label")
105
+ if name and not name.startswith("_"):
106
+ reached.add(name)
107
+ run_labels.append(name)
108
+ elif kind == "say":
109
+ dialogue.add(event.get("what"))
110
+ elif kind == "exception":
111
+ crashes.append({"short": event.get("short"), "full": event.get("full")})
112
+ return events["cursor"]
113
+
114
+ while frontier and runs < max_runs:
115
+ prefix = frontier.popleft()
116
+ if tuple(prefix) in seen_prefixes:
117
+ continue
118
+ seen_prefixes.add(tuple(prefix))
119
+ runs += 1
120
+
121
+ session = launch_with_bridge(sdk, project, startup_timeout=startup_timeout)
122
+ try:
123
+ cursor = 0
124
+ seq: list[int] = list(prefix)
125
+ seq_pos = 0
126
+ run_labels: list[str] = []
127
+
128
+ for _step in range(max_steps):
129
+ cursor = _drain(session.client, cursor, run_labels)
130
+
131
+ # Loop guard: a repeated story label means the game cycled back
132
+ # (e.g. returned to the main menu); stop this run so we don't
133
+ # keep re-answering the same menu forever.
134
+ if len(run_labels) != len(set(run_labels)):
135
+ break
136
+
137
+ choices = _menu_choices(session.client)
138
+ if choices:
139
+ # Let the menu's show transition finish so the choice buttons
140
+ # are at stable positions before we click one.
141
+ time.sleep(settle)
142
+ choices = _menu_choices(session.client) or choices
143
+ if seq_pos < len(seq):
144
+ idx = seq[seq_pos]
145
+ else:
146
+ # New branch point: take choice 0, queue the alternatives.
147
+ for alt in range(1, len(choices)):
148
+ frontier.append(seq + [alt])
149
+ idx = 0
150
+ seq.append(0)
151
+ seq_pos += 1
152
+ idx = min(idx, len(choices) - 1)
153
+ # Select by visible text (the reliable focus-resolution path).
154
+ session.client.select_choice(text=choices[idx]["text"])
155
+ choices_explored += 1
156
+ time.sleep(settle)
157
+ continue
158
+
159
+ session.client.advance()
160
+ time.sleep(settle)
161
+
162
+ _drain(session.client, cursor, run_labels)
163
+ finally:
164
+ session.close()
165
+
166
+ _emit(done=not frontier or runs >= max_runs)
167
+
168
+ return _report(done=True)
169
+
170
+
171
+ __all__ = ["autopilot"]
@@ -0,0 +1,10 @@
1
+ """Bridge utilities for future real-time Ren'Py TCP communication."""
2
+
3
+ from .client import BridgeClient, BridgeConfig, BridgeError, BridgeProtocolError
4
+
5
+ __all__ = [
6
+ "BridgeClient",
7
+ "BridgeConfig",
8
+ "BridgeError",
9
+ "BridgeProtocolError",
10
+ ]