"""Install / uninstall Forge into an RPG Maker MV or MZ game folder. Credits: len — https://gitgud.io/zero64801/forge-mvmz (Forge plugin) """ from __future__ import annotations import re from pathlib import Path from util.forge.config import PLUGIN_BY_ENGINE, is_legacy_forge_plugin, plugin_entry, prepare_forge_js _PKG_ROOT = Path(__file__).resolve().parent def bundled_plugin_path(engine: str) -> Path: from util.forge.config import bundled_plugin_path as _path return _path(engine) def detect_engine(game_root: Path) -> tuple[str, Path, Path] | None: """Return (engine, plugins_js, plugins_dir) or None if not MV/MZ.""" root = Path(game_root) mv_js = root / "www" / "js" / "plugins.js" mz_js = root / "js" / "plugins.js" if mv_js.is_file(): return "MV", mv_js, root / "www" / "js" / "plugins" if mz_js.is_file(): return "MZ", mz_js, root / "js" / "plugins" return None def detect_mz(game_root: Path) -> tuple[Path, Path] | None: """Return (plugins_js, plugins_dir) for MZ only, or None.""" info = detect_engine(game_root) if info is None or info[0] != "MZ": return None return info[1], info[2] def _plugin_name(engine: str) -> str: return PLUGIN_BY_ENGINE[engine] def _read_plugins_js(plugins_js: Path) -> tuple[str, str]: content = plugins_js.read_text(encoding="utf-8") nl = "\r\n" if "\r\n" in content else "\n" return content, nl def _is_declared(content: str, plugin_name: str) -> bool: return bool(re.search(rf'"name"\s*:\s*"{re.escape(plugin_name)}"', content)) def status(game_root: Path) -> dict: """Return install state for the game folder.""" info = detect_engine(game_root) if info is None: return { "ok": False, "engine": None, "installed": False, "declared": False, "plugin_file": None, "message": "No RPG Maker MV/MZ game found (missing plugins.js).", } engine, plugins_js, plugins_dir = info plugin_name = _plugin_name(engine) target = plugins_dir / f"{plugin_name}.js" content, _ = _read_plugins_js(plugins_js) declared = _is_declared(content, plugin_name) file_there = target.is_file() return { "ok": True, "engine": engine, "installed": declared or file_there, "declared": declared, "plugin_file": file_there, "plugins_js": str(plugins_js), "target": str(target), "message": ( "Installed" if declared and file_there else "Partially installed" if declared or file_there else "Not installed" ), } def _find_plugin_block_span(content: str, plugin_name: str) -> tuple[int, int] | None: """Return [start, end) span of the plugin object in plugins.js, or None.""" m = re.search(rf'"name"\s*:\s*"{re.escape(plugin_name)}"', content) if not m: return None start = content.rfind("{", 0, m.start()) if start < 0: return None depth = 0 for i in range(start, len(content)): ch = content[i] if ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: end = i + 1 while end < len(content) and content[end] in " \t\r\n": end += 1 if end < len(content) and content[end] == ",": end += 1 return start, end return None def _remove_plugin_block(content: str, plugin_name: str) -> str: compact = rf"\{{[^{{}}]*\"name\"\s*:\s*\"{re.escape(plugin_name)}\"[^{{}}]*\}}\s*,?" content = re.sub(compact, "", content) while True: span = _find_plugin_block_span(content, plugin_name) if span is None: break start, end = span before = content[:start].rstrip() after = content[end:].lstrip() if before.endswith(","): before = before[:-1].rstrip() elif after.startswith(","): after = after[1:].lstrip() gap = "\n" if before and after and not before.endswith("\n") else "" content = before + gap + after lines = [ line for line in re.split(r"\r?\n", content) if not re.search(rf"\"name\"\s*:\s*\"{re.escape(plugin_name)}\"", line) ] content = "\n".join(lines) # Leftover fragments from a previously corrupted multi-line entry. content = re.sub( r"\{\s*\"status\"\s*:\s*true\s*,\s*\"description\"\s*:\s*\"Forge[^\"]*\"" r"\s*,\s*\"parameters\"\s*:\s*\{[^{}]*\}\s*\}\s*,?", "", content, ) return content def _upsert_plugin_entry(content: str, plugin_name: str, entry: str, nl: str) -> str: """Insert or replace a Forge plugin entry in plugins.js.""" span = _find_plugin_block_span(content, plugin_name) if span is not None: start, end = span before = content[:start].rstrip() after = content[end:].lstrip() sep = nl if before.endswith(",") or not before else "," + nl return before + sep + entry + nl + after idx = content.rfind("];") if idx < 0: raise ValueError("Could not find plugin list end ( ]; ) in plugins.js.") before = content[:idx].rstrip() after = content[idx:] sep = nl if before.endswith(",") else "," + nl return before + sep + entry + nl + " " + after def install(game_root: Path, source_js: Path | None = None, cfg: dict | None = None) -> tuple[bool, str]: """Copy Forge_MV.js or Forge_MZ.js into the game and declare it in plugins.js.""" info = detect_engine(game_root) if info is None: return False, "No RPG Maker MV/MZ game found at that path." engine, plugins_js, plugins_dir = info plugin_name = _plugin_name(engine) default_src = bundled_plugin_path(engine) if source_js and not Path(source_js).is_file(): return False, f"{plugin_name}.js not found: {source_js}" if not source_js and not default_src.is_file(): return False, f"{plugin_name}.js not found: {default_src}" target = plugins_dir / f"{plugin_name}.js" content, nl = _read_plugins_js(plugins_js) plugins_dir.mkdir(parents=True, exist_ok=True) target.write_text(prepare_forge_js(engine, source_js, cfg), encoding="utf-8") hotkey = (cfg or {}).get("forgeHotkey", "F10") ui_scale = (cfg or {}).get("uiScale", "auto") modern = not is_legacy_forge_plugin((source_js or default_src).read_text(encoding="utf-8")) entry = plugin_entry(engine, hotkey, ui_scale, modern=modern) try: content = _upsert_plugin_entry(content, plugin_name, entry, nl) plugins_js.write_text(content, encoding="utf-8", newline="") except ValueError as exc: return False, str(exc) return True, f"Forge installed for RPG Maker {engine}. Press {hotkey} in-game to open." def uninstall(game_root: Path) -> tuple[bool, str]: """Remove Forge from plugins.js and delete the plugin file.""" info = detect_engine(game_root) if info is None: return False, "No RPG Maker MV/MZ game found at that path." engine, plugins_js, plugins_dir = info plugin_name = _plugin_name(engine) target = plugins_dir / f"{plugin_name}.js" content, nl = _read_plugins_js(plugins_js) if _is_declared(content, plugin_name): content = _remove_plugin_block(content, plugin_name) content = re.sub(r",(\s*)\];", r"\1];", content) plugins_js.write_text(content, encoding="utf-8", newline="") if target.is_file(): target.unlink() return True, "Forge uninstalled." def apply_config(game_root: Path, cfg: dict | None = None) -> tuple[bool, str]: """Rewrite an installed Forge plugin with current playtest settings.""" info = detect_engine(game_root) if info is None: return False, "No RPG Maker MV/MZ game found at that path." engine, _, plugins_dir = info plugin_name = _plugin_name(engine) target = plugins_dir / f"{plugin_name}.js" if not target.is_file(): return False, "Forge is not installed in this game folder." target.write_text(prepare_forge_js(engine, cfg=cfg), encoding="utf-8") return True, f"Forge settings applied to the installed {plugin_name} plugin."