renforge 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. renforge-0.1.0/.gitignore +48 -0
  2. renforge-0.1.0/LICENSE +21 -0
  3. renforge-0.1.0/PKG-INFO +171 -0
  4. renforge-0.1.0/README.md +141 -0
  5. renforge-0.1.0/pyproject.toml +50 -0
  6. renforge-0.1.0/src/renforge/__init__.py +4 -0
  7. renforge-0.1.0/src/renforge/__main__.py +5 -0
  8. renforge-0.1.0/src/renforge/activity_log.py +93 -0
  9. renforge-0.1.0/src/renforge/assets.py +149 -0
  10. renforge-0.1.0/src/renforge/autopilot.py +171 -0
  11. renforge-0.1.0/src/renforge/bridge/__init__.py +10 -0
  12. renforge-0.1.0/src/renforge/bridge/bridge.rpy +406 -0
  13. renforge-0.1.0/src/renforge/bridge/client.py +139 -0
  14. renforge-0.1.0/src/renforge/bridge/launcher.py +132 -0
  15. renforge-0.1.0/src/renforge/build.py +79 -0
  16. renforge-0.1.0/src/renforge/cli.py +103 -0
  17. renforge-0.1.0/src/renforge/docs.py +122 -0
  18. renforge-0.1.0/src/renforge/dump.py +80 -0
  19. renforge-0.1.0/src/renforge/lint.py +194 -0
  20. renforge-0.1.0/src/renforge/project.py +54 -0
  21. renforge-0.1.0/src/renforge/scanner.py +247 -0
  22. renforge-0.1.0/src/renforge/sdk.py +263 -0
  23. renforge-0.1.0/src/renforge/server.py +439 -0
  24. renforge-0.1.0/src/renforge/tools/__init__.py +3 -0
  25. renforge-0.1.0/src/renforge/tools/live.py +274 -0
  26. renforge-0.1.0/src/renforge/tools/project_ops.py +127 -0
  27. renforge-0.1.0/src/renforge/tools/static.py +65 -0
  28. renforge-0.1.0/src/renforge/translation.py +206 -0
  29. renforge-0.1.0/src/renforge/ui/__init__.py +14 -0
  30. renforge-0.1.0/src/renforge/ui/activity.py +98 -0
  31. renforge-0.1.0/src/renforge/ui/graph.py +254 -0
  32. renforge-0.1.0/src/renforge/ui/poller.py +67 -0
  33. renforge-0.1.0/src/renforge/ui/server.py +330 -0
  34. renforge-0.1.0/src/renforge/ui/static/assets/index-g2AQglUZ.js +92 -0
  35. renforge-0.1.0/src/renforge/ui/static/assets/index-wSw967Fa.css +1 -0
  36. renforge-0.1.0/src/renforge/ui/static/index.html +13 -0
  37. renforge-0.1.0/src/renforge/ui/ws.py +55 -0
  38. renforge-0.1.0/src/renforge/util/__init__.py +10 -0
  39. renforge-0.1.0/src/renforge/util/files.py +33 -0
  40. renforge-0.1.0/src/renforge/util/subprocess.py +95 -0
@@ -0,0 +1,48 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.so
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ coverage/
9
+ .coverage
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ node_modules/
14
+ *.tsbuildinfo
15
+ .DS_Store
16
+ *.tmp
17
+ *.log
18
+ logs/
19
+ examples/demo_game/.cache/
20
+ examples/demo_game/saves/
21
+ examples/demo_game/error.log
22
+ examples/demo_game/*.rpyc.bak
23
+ examples/demo_game/game/cache/
24
+ examples/demo_game/game/saves/
25
+ examples/demo_game/.renforge/
26
+ examples/demo_game/log.txt
27
+ examples/demo_game/game/renforge_bridge.rpyc.bak
28
+ # Bridge source injected at runtime by the launcher (the .rpyc is caught by *.rpyc)
29
+ renforge_bridge.rpy
30
+ examples/demo_game/game/gui/*.png
31
+ examples/demo_game/game/gui/**/*.png
32
+ !examples/demo_game/game/gui/bubble.png
33
+ !examples/demo_game/game/gui/thoughtbubble.png
34
+
35
+ # Internal working docs (reviews, competitive analysis, plans) — keep private
36
+ docs/
37
+
38
+ # Ren'Py / RenForge runtime artifacts
39
+ *.rpyc
40
+ *.rpymc
41
+ *.rpyb
42
+ cache/
43
+ saves/
44
+ log.txt
45
+ traceback.txt
46
+ errors.txt
47
+ .renforge/
48
+ screens
renforge-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RenForge contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,171 @@
1
+ Metadata-Version: 2.4
2
+ Name: renforge
3
+ Version: 0.1.0
4
+ Summary: MCP server, CLI, and web dashboard for Ren'Py visual-novel development
5
+ Project-URL: Homepage, https://github.com/alex-jordan547/renforge-mcp
6
+ Project-URL: Repository, https://github.com/alex-jordan547/renforge-mcp
7
+ Project-URL: Issues, https://github.com/alex-jordan547/renforge-mcp/issues
8
+ Author: RenForge contributors
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: game-dev,mcp,model-context-protocol,renpy,visual-novel
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Games/Entertainment
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: mcp>=1.0.0
21
+ Provides-Extra: fastmcp
22
+ Requires-Dist: fastmcp>=2.0.0; extra == 'fastmcp'
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8; extra == 'test'
25
+ Provides-Extra: ui
26
+ Requires-Dist: starlette>=0.31; extra == 'ui'
27
+ Requires-Dist: uvicorn[standard]>=0.30; extra == 'ui'
28
+ Requires-Dist: watchfiles>=0.22; extra == 'ui'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # RenForge
32
+
33
+ RenForge is an **MCP (Model Context Protocol) server, CLI, and web dashboard**
34
+ for working with [Ren'Py](https://www.renpy.org/) visual-novel projects.
35
+
36
+ It lets an AI agent — or a human via the dashboard — inspect a project, launch
37
+ and drive a running game, read/write game state, capture screenshots, generate
38
+ translations, find orphaned assets, run builds, and search Ren'Py's docs.
39
+
40
+ > Status: **alpha**, actively developed. The core surfaces (MCP tools, in-game
41
+ > bridge, CLI, dashboard) are functional; APIs may still change.
42
+
43
+ ## What it does
44
+
45
+ - **Project inspection** — summarize structure, scan scripts/labels/assets,
46
+ parse lint output.
47
+ - **Live game control** — launch a project with an injected in-game bridge, then
48
+ advance dialogue, list/select choices, evaluate expressions, get/set store
49
+ variables, poll pushed events, and capture frames the model can literally see.
50
+ - **Autopilot** — auto-play the game across branches and report label coverage
51
+ and crashes.
52
+ - **Assets & translations** — find orphaned/missing image+audio assets, list
53
+ languages, compute translation stats, generate/update `game/tl/<lang>/` files,
54
+ export dialogue as text.
55
+ - **Builds** — package desktop distributions and web builds.
56
+ - **Docs** — search and read Ren'Py's offline documentation.
57
+ - **Web dashboard** — Starlette + WebSocket UI with a live story map, activity
58
+ log, autopilot coverage, lint view, and game-state controls.
59
+
60
+ ## Quick start
61
+
62
+ Requires Python 3.11+. With [uv](https://docs.astral.sh/uv/) installed, no
63
+ setup is needed:
64
+
65
+ ```bash
66
+ # Start the web dashboard on your project
67
+ uvx --from "renforge[ui]" renforge ui --project /path/to/your/game
68
+
69
+ # Or add the MCP server to Claude Code
70
+ claude mcp add renforge -- uvx --from "renforge[fastmcp]" renforge serve --project /path/to/your/game
71
+ ```
72
+
73
+ For Claude Desktop (or any MCP client using JSON config):
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "renforge": {
79
+ "command": "uvx",
80
+ "args": [
81
+ "--from", "renforge[fastmcp]", "renforge",
82
+ "serve", "--project", "/path/to/your/game"
83
+ ]
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ Prefer pip? `pip install "renforge[fastmcp,ui]"` gives you the `renforge` CLI.
90
+
91
+ ## Install (dev)
92
+
93
+ ```bash
94
+ python -m venv .venv
95
+ source .venv/bin/activate
96
+ pip install -e ".[fastmcp]" # full MCP runtime (fastmcp)
97
+ pip install -e ".[ui]" # dashboard (starlette, uvicorn, watchfiles)
98
+ pip install -e ".[test]" # pytest
99
+ ```
100
+
101
+ The base install only requires `mcp>=1.0.0`; the server falls back to a
102
+ compatibility mode with a clear message if `fastmcp` is not installed.
103
+
104
+ ## Usage
105
+
106
+ ### CLI
107
+
108
+ ```bash
109
+ renforge --version
110
+ renforge inspect <project> # lightweight project summary (JSON)
111
+ renforge serve [--project .] # start the MCP server (stdio transport)
112
+ renforge ui --project <project> [--port 8765] # start the web dashboard
113
+ ```
114
+
115
+ ### MCP server
116
+
117
+ `renforge serve` exposes the tools below to any MCP client. A subset:
118
+
119
+ - `renforge_inspect_project`, `renforge_scan_project`, `renforge_parse_lint`
120
+ - `renforge_launch`, `renforge_stop`
121
+ - `renforge_game_state`, `renforge_advance`, `renforge_list_choices`,
122
+ `renforge_select_choice`, `renforge_eval`, `renforge_get_var`,
123
+ `renforge_set_var`, `renforge_poll_events`, `renforge_screenshot`
124
+ - `renforge_autopilot`
125
+ - `renforge_assets`, `renforge_languages`, `renforge_translation_stats`,
126
+ `renforge_generate_translations`, `renforge_export_dialogue`
127
+ - `renforge_web_build`, `renforge_distribute`
128
+ - `renforge_search_docs`, `renforge_get_doc`, `renforge_list_docs`
129
+
130
+ ### Live control
131
+
132
+ `renforge_launch` injects a bridge into `<project>/game/` (removed on teardown)
133
+ and starts the game. Live tools require a display — under WSLg it works
134
+ directly; headless CI should wrap the call with `xvfb-run`.
135
+
136
+ ### Web dashboard
137
+
138
+ `renforge ui --project <project>` serves a dashboard (default `127.0.0.1:8765`)
139
+ with a story map, activity log, autopilot coverage, lint view, and live game
140
+ controls over WebSocket.
141
+
142
+ ## Examples
143
+
144
+ A small sample project lives in `examples/demo_game/`.
145
+
146
+ ## Architecture
147
+
148
+ ```
149
+ src/renforge/
150
+ cli.py # argparse entrypoint (inspect / serve / ui)
151
+ server.py # MCP app bootstrap + fallback + tool registration
152
+ bridge/ # in-game .rpy bridge, launcher, and client
153
+ tools/
154
+ live.py # running-game control (launch, eval, screenshot, ...)
155
+ project_ops.py # assets, translations, builds, docs
156
+ static.py # inspect / scan / parse-lint
157
+ ui/ # Starlette dashboard (server, ws, graph, activity, poller)
158
+ util/ # filesystem + subprocess helpers
159
+ sdk.py # Ren'Py SDK download/cache
160
+ scanner.py # script/label/asset scanning
161
+ lint.py # lint runner + parsing
162
+ autopilot.py # branch auto-play + coverage
163
+ translation.py # translation generation/stats
164
+ ```
165
+
166
+ Packaging uses `hatchling`; the console script is
167
+ `renforge = renforge.cli:main`.
168
+
169
+ ## License
170
+
171
+ MIT
@@ -0,0 +1,141 @@
1
+ # RenForge
2
+
3
+ RenForge is an **MCP (Model Context Protocol) server, CLI, and web dashboard**
4
+ for working with [Ren'Py](https://www.renpy.org/) visual-novel projects.
5
+
6
+ It lets an AI agent — or a human via the dashboard — inspect a project, launch
7
+ and drive a running game, read/write game state, capture screenshots, generate
8
+ translations, find orphaned assets, run builds, and search Ren'Py's docs.
9
+
10
+ > Status: **alpha**, actively developed. The core surfaces (MCP tools, in-game
11
+ > bridge, CLI, dashboard) are functional; APIs may still change.
12
+
13
+ ## What it does
14
+
15
+ - **Project inspection** — summarize structure, scan scripts/labels/assets,
16
+ parse lint output.
17
+ - **Live game control** — launch a project with an injected in-game bridge, then
18
+ advance dialogue, list/select choices, evaluate expressions, get/set store
19
+ variables, poll pushed events, and capture frames the model can literally see.
20
+ - **Autopilot** — auto-play the game across branches and report label coverage
21
+ and crashes.
22
+ - **Assets & translations** — find orphaned/missing image+audio assets, list
23
+ languages, compute translation stats, generate/update `game/tl/<lang>/` files,
24
+ export dialogue as text.
25
+ - **Builds** — package desktop distributions and web builds.
26
+ - **Docs** — search and read Ren'Py's offline documentation.
27
+ - **Web dashboard** — Starlette + WebSocket UI with a live story map, activity
28
+ log, autopilot coverage, lint view, and game-state controls.
29
+
30
+ ## Quick start
31
+
32
+ Requires Python 3.11+. With [uv](https://docs.astral.sh/uv/) installed, no
33
+ setup is needed:
34
+
35
+ ```bash
36
+ # Start the web dashboard on your project
37
+ uvx --from "renforge[ui]" renforge ui --project /path/to/your/game
38
+
39
+ # Or add the MCP server to Claude Code
40
+ claude mcp add renforge -- uvx --from "renforge[fastmcp]" renforge serve --project /path/to/your/game
41
+ ```
42
+
43
+ For Claude Desktop (or any MCP client using JSON config):
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "renforge": {
49
+ "command": "uvx",
50
+ "args": [
51
+ "--from", "renforge[fastmcp]", "renforge",
52
+ "serve", "--project", "/path/to/your/game"
53
+ ]
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Prefer pip? `pip install "renforge[fastmcp,ui]"` gives you the `renforge` CLI.
60
+
61
+ ## Install (dev)
62
+
63
+ ```bash
64
+ python -m venv .venv
65
+ source .venv/bin/activate
66
+ pip install -e ".[fastmcp]" # full MCP runtime (fastmcp)
67
+ pip install -e ".[ui]" # dashboard (starlette, uvicorn, watchfiles)
68
+ pip install -e ".[test]" # pytest
69
+ ```
70
+
71
+ The base install only requires `mcp>=1.0.0`; the server falls back to a
72
+ compatibility mode with a clear message if `fastmcp` is not installed.
73
+
74
+ ## Usage
75
+
76
+ ### CLI
77
+
78
+ ```bash
79
+ renforge --version
80
+ renforge inspect <project> # lightweight project summary (JSON)
81
+ renforge serve [--project .] # start the MCP server (stdio transport)
82
+ renforge ui --project <project> [--port 8765] # start the web dashboard
83
+ ```
84
+
85
+ ### MCP server
86
+
87
+ `renforge serve` exposes the tools below to any MCP client. A subset:
88
+
89
+ - `renforge_inspect_project`, `renforge_scan_project`, `renforge_parse_lint`
90
+ - `renforge_launch`, `renforge_stop`
91
+ - `renforge_game_state`, `renforge_advance`, `renforge_list_choices`,
92
+ `renforge_select_choice`, `renforge_eval`, `renforge_get_var`,
93
+ `renforge_set_var`, `renforge_poll_events`, `renforge_screenshot`
94
+ - `renforge_autopilot`
95
+ - `renforge_assets`, `renforge_languages`, `renforge_translation_stats`,
96
+ `renforge_generate_translations`, `renforge_export_dialogue`
97
+ - `renforge_web_build`, `renforge_distribute`
98
+ - `renforge_search_docs`, `renforge_get_doc`, `renforge_list_docs`
99
+
100
+ ### Live control
101
+
102
+ `renforge_launch` injects a bridge into `<project>/game/` (removed on teardown)
103
+ and starts the game. Live tools require a display — under WSLg it works
104
+ directly; headless CI should wrap the call with `xvfb-run`.
105
+
106
+ ### Web dashboard
107
+
108
+ `renforge ui --project <project>` serves a dashboard (default `127.0.0.1:8765`)
109
+ with a story map, activity log, autopilot coverage, lint view, and live game
110
+ controls over WebSocket.
111
+
112
+ ## Examples
113
+
114
+ A small sample project lives in `examples/demo_game/`.
115
+
116
+ ## Architecture
117
+
118
+ ```
119
+ src/renforge/
120
+ cli.py # argparse entrypoint (inspect / serve / ui)
121
+ server.py # MCP app bootstrap + fallback + tool registration
122
+ bridge/ # in-game .rpy bridge, launcher, and client
123
+ tools/
124
+ live.py # running-game control (launch, eval, screenshot, ...)
125
+ project_ops.py # assets, translations, builds, docs
126
+ static.py # inspect / scan / parse-lint
127
+ ui/ # Starlette dashboard (server, ws, graph, activity, poller)
128
+ util/ # filesystem + subprocess helpers
129
+ sdk.py # Ren'Py SDK download/cache
130
+ scanner.py # script/label/asset scanning
131
+ lint.py # lint runner + parsing
132
+ autopilot.py # branch auto-play + coverage
133
+ translation.py # translation generation/stats
134
+ ```
135
+
136
+ Packaging uses `hatchling`; the console script is
137
+ `renforge = renforge.cli:main`.
138
+
139
+ ## License
140
+
141
+ MIT
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "renforge"
7
+ version = "0.1.0"
8
+ description = "MCP server, CLI, and web dashboard for Ren'Py visual-novel development"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "RenForge contributors" }]
13
+ keywords = ["renpy", "mcp", "model-context-protocol", "visual-novel", "game-dev"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Games/Entertainment",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ dependencies = [
24
+ "mcp>=1.0.0"
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/alex-jordan547/renforge-mcp"
29
+ Repository = "https://github.com/alex-jordan547/renforge-mcp"
30
+ Issues = "https://github.com/alex-jordan547/renforge-mcp/issues"
31
+
32
+ [project.optional-dependencies]
33
+ fastmcp = ["fastmcp>=2.0.0"]
34
+ test = ["pytest>=8"]
35
+ ui = ["starlette>=0.31", "uvicorn[standard]>=0.30", "watchfiles>=0.22"]
36
+
37
+ [project.scripts]
38
+ renforge = "renforge.cli:main"
39
+
40
+ [tool.hatch.build]
41
+ include = ["src/renforge", "src/renforge/**/*.rpy", "src/renforge/ui/static/**"]
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/renforge"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = ["src/renforge"]
48
+
49
+ [tool.pytest.ini_options]
50
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ """RenForge package."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
@@ -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())
@@ -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"]