diff --git a/README.md b/README.md index 1f255ce..0531de8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Ariane is a map viewer and editor for Grand Theft Auto III, Vice City and San An - Preview models in 3D before placing them - Build reusable prefabs from map selections, then browse, import and place them as a group - Import custom DFF models and TXD textures +- Use object assets from an existing SA-MP/open.mp installation and import literal Pawn mappings through the agent CLI/MCP ### Saving and iteration diff --git a/tools/agent/README.md b/tools/agent/README.md index 18912ca..426f1d4 100644 --- a/tools/agent/README.md +++ b/tools/agent/README.md @@ -44,9 +44,52 @@ draw distances. Names supply weak search hints. Dimensions, collision availabili native IPL usage and thumbnails are not extracted by this basic importer; use live `assets inspect ID --ensure-renderable` and `assets preview ID` for runtime checks. `--has-collision` and dimension filters need an enriched index. -All IDE files under `data` are scanned, so `--defined-only` checks which entries -are actually defined in the running editor. Rebuild when changing games or mods; -use a separate `--db` for each installation because model IDs overlap. +All IDE files under `data` and explicit modloader `IDE` additions are scanned, +so `--defined-only` checks which entries are actually defined in the running +editor. Rebuild when changing games or mods; use a separate `--db` for each +installation because model IDs overlap. + +### SA-MP/open.mp assets and Pawn mappings + +Ariane does not distribute SA-MP, open.mp or GTA assets. Install open.mp or +SA-MP first, then create an isolated modloader package from those local files: + +```powershell +.venv-agent\Scripts\arianectl.exe assets install-samp ` + --gta-dir 'C:\Games\GTA San Andreas' +``` + +On Windows the command auto-detects open.mp's shared `SAMP` directory beneath +`%LOCALAPPDATA%\mp.open.launcher`. Use `--source` for another installed copy. +It requires `SAMP.ide`, `SAMP.img` and `SAMPCOL.img`, copies them under +`modloader\SAMP`, writes only a small gta.dat-style manifest, and rebuilds the +local catalogue. `SAMP.ipl` is deliberately not loaded because it contains +unrelated default placements. Restart Ariane after installing or updating the +package. `--force` is required to update an existing generated package. + +Literal Pawn mappings can then be opened as a reversible scratch proposal: + +```powershell +.venv-agent\Scripts\arianectl.exe import-pawn '.\mapping.pwn' ` + --output '.\agent-output\mapping.ipl' +``` + +The importer supports `CreateObject`, `CreateDynamicObject`, +`CreatePlayerObject` and `RemoveBuildingForPlayer`. It preserves all three +rotation axes and applies building removals at their authored coordinates and +radius while the scene is open (Ariane's native suppression is model-specific +and planar, so the Pawn Z coordinate is retained but not used). Building +removals remain in the Pawn source because the IPL format cannot persist +`RemoveBuildingForPlayer`. By default the scratch session stays active for +validation and visual review; finish it with `session commit` or `session +rollback`, then use `save` after committing. `--commit --save` is available for +an explicitly requested one-shot import. + +Pawn code is parsed, never executed. Numeric literals are accepted; variables, +macros, computed expressions and material/text calls are reported as warnings +instead of being guessed. Existing IPL destinations are never overwritten. +The MCP adapter exposes the same workflow as `inspect_pawn_mapping` and +`import_pawn_mapping`. An optional existing gtastuff checkout supplies richer geometry, collision, IPL and thumbnail metadata: diff --git a/tools/agent/ariane_mcp.py b/tools/agent/ariane_mcp.py index 91fbdf1..100fbd0 100644 --- a/tools/agent/ariane_mcp.py +++ b/tools/agent/ariane_mcp.py @@ -28,6 +28,10 @@ from .service import ArianeService, ScenePatchError, DEFAULT_STATE_DIR else: from service import ArianeService, ScenePatchError, DEFAULT_STATE_DIR +if __package__: + from .samp import parse_pawn_mapping +else: + from samp import parse_pawn_mapping service = ArianeService( @@ -636,6 +640,27 @@ def open_mapping_scene(logical_path: str, physical_path: str) -> dict: return service.engine("scene", [logical_path, str(Path(physical_path).resolve())]) +@mcp.tool() +def inspect_pawn_mapping(source_path: str) -> dict: + """Parse a .pwn mapping safely and report supported objects, removals and warnings without editing.""" + return parse_pawn_mapping(Path(source_path)).summary() + + +@mcp.tool() +def import_pawn_mapping(source_path: str, logical_path: str, physical_path: str, + group: str | None = None, commit: bool = False, + save: bool = False) -> dict: + """Import literal SA-MP/open.mp mapping calls into a new reviewable scratch IPL. + + CreateObject, CreateDynamicObject, CreatePlayerObject and + RemoveBuildingForPlayer are supported. Existing IPLs are never overwritten. + Keep commit/save false for visual review, then use the normal finish and save tools. + """ + return service.import_pawn_mapping( + source_path, logical_path, physical_path, group=group, + commit=commit, save=save) + + @mcp.tool() def compare_mapping_variants(first: str, second: str) -> dict: """Compare two named checkpoints using durable keys and their saved creative brief.""" diff --git a/tools/agent/arianectl.py b/tools/agent/arianectl.py index d95eb6b..90a8398 100644 --- a/tools/agent/arianectl.py +++ b/tools/agent/arianectl.py @@ -25,6 +25,10 @@ from .service import ArianeService, ScenePatchError, DEFAULT_STATE_DIR else: from service import ArianeService, ScenePatchError, DEFAULT_STATE_DIR +if __package__: + from .samp import install_samp_assets +else: + from samp import install_samp_assets def emit(payload: object) -> None: @@ -185,6 +189,16 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE_DIR) sub = parser.add_subparsers(dest="command", required=True) add_engine_commands(sub) + pawn_import = sub.add_parser("import-pawn", help="Import literal SA-MP/open.mp mapping calls") + pawn_import.add_argument("source", type=Path) + pawn_import.add_argument("--output", type=Path, required=True, + help="new IPL destination; existing files are never overwritten") + pawn_import.add_argument("--logical", help="logical IPL path used by Ariane") + pawn_import.add_argument("--group") + pawn_import.add_argument("--commit", action="store_true", + help="commit immediately instead of leaving a reviewable scratch session") + pawn_import.add_argument("--save", action="store_true", + help="save the IPL after commit; requires --commit") call = sub.add_parser("call", help="Call a shared service method; JSON parameters inline or from a file") call.add_argument("method") inputs = call.add_mutually_exclusive_group() @@ -207,6 +221,13 @@ def main(argv: list[str] | None = None) -> int: sources.add_argument("--gta-dir", type=Path, help="build a basic catalogue from your GTA data/*.ide files") index.add_argument("--semantic-assets", type=Path, help="optional AI-enriched asset JSON; kept external when its license is unknown") + install_samp = asset_sub.add_parser("install-samp", + help="use assets from an existing SA-MP/open.mp installation") + install_samp.add_argument("--gta-dir", type=Path, required=True) + install_samp.add_argument("--source", type=Path, + help="directory containing SAMP.ide, SAMP.img and SAMPCOL.img; open.mp is auto-detected") + install_samp.add_argument("--force", action="store_true", help="update an existing generated package") + install_samp.add_argument("--no-index", action="store_true", help="do not rebuild the local asset catalogue") search = asset_sub.add_parser("search") search.add_argument("query", nargs="?", default="") search.add_argument("--category"); search.add_argument("--max-width", type=float) @@ -300,7 +321,7 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("validate-composition") args = parser.parse_args(argv) try: - if args.command in {"call", "survey", "environment"}: + if args.command in {"call", "survey", "environment", "import-pawn"}: service = ArianeService(args.socket, args.db, args.timeout, args.discovery_dir, args.state_dir) if args.command == "call": params = json.loads(args.file.read_text() if args.file else args.params) @@ -308,12 +329,21 @@ def main(argv: list[str] | None = None) -> int: payload = service.dispatch(args.method, params) elif args.command == "survey": payload = service.vibe.observe(args.output_directory, args.radius, args.grid, args.pixel_x, args.pixel_y) + elif args.command == "import-pawn": + logical = args.logical or f"ariane/{args.source.stem}.ipl" + payload = service.import_pawn_mapping( + str(args.source), logical, str(args.output), group=args.group, + commit=args.commit, save=args.save) else: settings = {key: getattr(args, key) for key in ("hour", "minute", "weather_a", "weather_b", "blend") if getattr(args, key) is not None} payload = service.environment(**settings) elif args.command == "assets": - if args.asset_command == "index": payload = build_index( + if args.asset_command == "install-samp": + payload = install_samp_assets(args.gta_dir, args.source, force=args.force) + if not args.no_index: + payload["index"] = build_index(database=args.db, gta_dir=args.gta_dir) + elif args.asset_command == "index": payload = build_index( args.gtastuff, args.db, semantic_assets=args.semantic_assets, gta_dir=args.gta_dir) elif args.asset_command == "search": service = ArianeService(args.socket, args.db, args.timeout, diff --git a/tools/agent/asset_index.py b/tools/agent/asset_index.py index 87cd7bf..427a5d3 100644 --- a/tools/agent/asset_index.py +++ b/tools/agent/asset_index.py @@ -170,19 +170,56 @@ def _load_semantic_assets(path: Path | None) -> dict[int, dict]: if item.get("Id", item.get("id")) is not None} -def _gta_models(gta_dir: Path) -> list[dict]: - """Read object definitions locally; no game assets or external catalogue needed. +def _casefold_directory(parent: Path, name: str) -> Path | None: + if not parent.is_dir(): + return None + return next((path for path in parent.iterdir() + if path.is_dir() and path.name.lower() == name.lower()), None) + - Scan data IDE files in deterministic order. Runtime --defined-only and inspect - remain authoritative for active definitions, collision and dimensions. +def _gta_ide_paths(gta_dir: Path) -> list[Path]: + """Return base IDEs plus explicit modloader IDE additions. + + Mod files are user-owned and remain outside the catalogue. Only IDE paths + declared by a mod's gta.dat-style text manifest are indexed, matching the + engine's addition contract instead of treating every loose IDE as active. """ - data = next((p for p in gta_dir.iterdir() if p.is_dir() and p.name.lower() == "data"), None) + data = _casefold_directory(gta_dir, "data") if data is None: raise FileNotFoundError(f"GTA data directory not found in {gta_dir}") + paths = {path.resolve() for path in data.rglob("*") if path.suffix.lower() == ".ide"} + modloader = _casefold_directory(gta_dir, "modloader") + if modloader is None: + return sorted(paths, key=lambda path: str(path).lower()) + for mod in sorted((path for path in modloader.iterdir() if path.is_dir()), + key=lambda path: path.name.lower()): + manifests = (path for path in mod.rglob("*") + if path.is_file() and path.suffix.lower() == ".txt") + for manifest in sorted(manifests, key=lambda path: str(path).lower()): + for raw in manifest.read_text(encoding="latin-1", errors="replace").splitlines(): + line = raw.split("#", 1)[0].strip() + if not line.upper().startswith("IDE "): + continue + relative = Path(line[4:].strip().replace("\\", "/")) + candidate = (mod / relative).resolve() + try: + candidate.relative_to(mod.resolve()) + except ValueError: + continue + if candidate.is_file() and candidate.suffix.lower() == ".ide": + paths.add(candidate) + return sorted(paths, key=lambda path: str(path).lower()) + + +def _gta_models(gta_dir: Path) -> list[dict]: + """Read object definitions locally; no game assets or external catalogue needed. + + Scan base and declared modloader IDE files in deterministic order. Runtime + --defined-only and inspect remain authoritative for active definitions, + collision and dimensions. + """ models = {} - for path in sorted(data.rglob("*")): - if path.suffix.lower() != ".ide": - continue + for path in _gta_ide_paths(gta_dir): section = "" for line_number, line in enumerate(path.read_text(encoding="latin-1").splitlines(), 1): line = line.split("#", 1)[0].strip() @@ -211,7 +248,7 @@ def _gta_models(gta_dir: Path) -> list[dict]: except (ValueError, IndexError) as exc: raise ValueError(f"Invalid object definition at {path}:{line_number}: {exc}") from exc if not models: - raise ValueError(f"No object definitions found beneath {data}") + raise ValueError(f"No object definitions found beneath {gta_dir}") return list(models.values()) diff --git a/tools/agent/samp.py b/tools/agent/samp.py new file mode 100644 index 0000000..0081d7b --- /dev/null +++ b/tools/agent/samp.py @@ -0,0 +1,273 @@ +"""SA-MP/open.mp asset setup and literal Pawn mapping import support.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import math +import os +from pathlib import Path +import re +import shutil +import tempfile + + +SAMP_ASSET_FILES = ("SAMP.ide", "SAMP.img", "SAMPCOL.img") +SAMP_MODLOADER_README = """# SA-MP/open.mp object pack for Ariane +IDE data\\maps\\samp\\SAMP.ide +IMG models\\SAMP.img +COLFILE 0 models\\SAMPCOL.img +""" +_NUMBER = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$") +_CALL = re.compile( + r"\b(CreateObject|CreateDynamicObject|CreatePlayerObject|RemoveBuildingForPlayer)\s*" + r"\((.*?)\)\s*;", + re.IGNORECASE | re.DOTALL, +) +_MAPPING_CALL = re.compile( + r"\b(CreateObject|CreateDynamicObject|CreatePlayerObject|RemoveBuildingForPlayer|" + r"SetObjectMaterial|SetDynamicObjectMaterial|SetObjectMaterialText|SetDynamicObjectMaterialText)\s*\(", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class PawnObject: + model: int + x: float + y: float + z: float + pitch: float + roll: float + heading: float + line: int + function: str + + +@dataclass(frozen=True) +class PawnRemoval: + model: int + x: float + y: float + z: float + radius: float + line: int + + +@dataclass +class PawnMapping: + source: str + objects: list[PawnObject] = field(default_factory=list) + removals: list[PawnRemoval] = field(default_factory=list) + warnings: list[dict] = field(default_factory=list) + + def summary(self) -> dict: + return { + "source": self.source, + "object_count": len(self.objects), + "removal_count": len(self.removals), + "models": sorted({item.model for item in self.objects}), + "warnings": self.warnings, + } + + +def _strip_comments(text: str) -> str: + """Remove Pawn comments while preserving newlines and quoted strings.""" + result, index, state = [], 0, "code" + while index < len(text): + char = text[index] + next_char = text[index + 1] if index + 1 < len(text) else "" + if state == "code" and char == '"': + state = "string" + result.append(char) + elif state == "string": + result.append(char) + if char == "\\" and next_char: + result.append(next_char) + index += 1 + elif char == '"': + state = "code" + elif state == "code" and char == "/" and next_char == "/": + state = "line_comment" + result.extend(" ") + index += 1 + elif state == "code" and char == "/" and next_char == "*": + state = "block_comment" + result.extend(" ") + index += 1 + elif state == "line_comment": + result.append("\n" if char == "\n" else " ") + if char == "\n": + state = "code" + elif state == "block_comment": + if char == "*" and next_char == "/": + result.extend(" ") + index += 1 + state = "code" + else: + result.append("\n" if char == "\n" else " ") + else: + result.append(char) + index += 1 + return "".join(result) + + +def _literal(value: str) -> float: + value = value.strip() + if not _NUMBER.fullmatch(value): + raise ValueError(f"not a numeric literal: {value!r}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"numeric literal is not finite: {value!r}") + return number + + +def _model_id(value: float) -> int: + if not value.is_integer() or not 0 <= value <= 2_147_483_647: + raise ValueError(f"model id must be a non-negative integer: {value}") + return int(value) + + +def parse_pawn_text(text: str, source: str = "") -> PawnMapping: + """Parse literal SA-MP mapping calls without executing Pawn code.""" + clean = _strip_comments(text) + mapping = PawnMapping(source=source) + consumed: set[int] = set() + for match in _CALL.finditer(clean): + function = match.group(1) + name = function.lower() + line = clean.count("\n", 0, match.start()) + 1 + consumed.add(match.start()) + arguments = [item.strip() for item in match.group(2).split(",")] + try: + if name == "removebuildingforplayer": + if len(arguments) < 6: + raise ValueError("expected player, model, position and radius") + values = [_literal(item) for item in arguments[1:6]] + mapping.removals.append(PawnRemoval( + model=_model_id(values[0]), x=values[1], y=values[2], z=values[3], + radius=values[4], line=line)) + continue + if name == "createplayerobject": + if len(arguments) < 8: + raise ValueError("expected player, model, position and rotation") + values = [_literal(item) for item in arguments[1:8]] + else: + if len(arguments) < 7: + raise ValueError("expected model, position and rotation") + values = [_literal(item) for item in arguments[:7]] + mapping.objects.append(PawnObject( + model=_model_id(values[0]), x=values[1], y=values[2], z=values[3], + pitch=values[4], roll=values[5], heading=values[6], + line=line, function=function)) + except (ValueError, IndexError) as error: + mapping.warnings.append({ + "line": line, "function": function, "code": "non_literal_call", + "message": str(error), + }) + + # Surface mapping-related calls that the literal importer did not consume. + for match in _MAPPING_CALL.finditer(clean): + if match.start() in consumed: + continue + function = match.group(1) + line = clean.count("\n", 0, match.start()) + 1 + code = "material_not_supported" if "material" in function.lower() else "unsupported_call" + mapping.warnings.append({ + "line": line, "function": function, "code": code, + "message": "call was not imported; use literal mapping calls", + }) + return mapping + + +def parse_pawn_mapping(path: Path) -> PawnMapping: + path = Path(path).expanduser().resolve() + if path.suffix.lower() != ".pwn": + raise ValueError(f"Pawn mapping must use a .pwn extension: {path}") + return parse_pawn_text(path.read_text(encoding="utf-8-sig", errors="replace"), str(path)) + + +def pawn_operations(mapping: PawnMapping, *, group: str | None = None) -> tuple[list[dict], list[dict]]: + """Return placement operations followed by full-rotation corrections.""" + stem = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(mapping.source).stem).strip("-.") or "pawn" + group = group or f"pawn:{stem[:96]}" + placements, rotations = [], [] + for index, item in enumerate(mapping.objects, 1): + key = f"pawn:{stem[:80]}:{index:05d}" + placements.append({ + "action": "place", "key": key, "group": group, "model": item.model, + "x": item.x, "y": item.y, "z": item.z, + "heading": item.heading, "snap": False, + }) + if abs(item.pitch) > 1.0e-6 or abs(item.roll) > 1.0e-6: + rotations.append({ + "action": "transform3d", "key": key, + "x": item.x, "y": item.y, "z": item.z, + "pitch": item.pitch, "roll": item.roll, + "heading": item.heading, "snap": False, + }) + return placements, rotations + + +def _casefold_file(directory: Path, name: str) -> Path | None: + if not directory.is_dir(): + return None + return next((item for item in directory.iterdir() + if item.is_file() and item.name.lower() == name.lower()), None) + + +def find_samp_assets(source: Path | None = None) -> Path: + """Locate a user-owned SA-MP asset directory, including open.mp's cache.""" + candidates: list[Path] = [] + if source is not None: + root = Path(source).expanduser() + candidates.extend([root, root / "SAMP"]) + local = os.environ.get("LOCALAPPDATA") + if local: + candidates.append(Path(local) / "mp.open.launcher" / "samp" / "shared" / "SAMP") + for candidate in candidates: + if all(_casefold_file(candidate, name) for name in SAMP_ASSET_FILES): + return candidate.resolve() + raise FileNotFoundError( + "SA-MP assets not found; pass --source pointing to a directory containing " + + ", ".join(SAMP_ASSET_FILES)) + + +def install_samp_assets(gta_dir: Path, source: Path | None = None, *, force: bool = False) -> dict: + """Install user-owned SA-MP assets as an isolated Ariane modloader package.""" + gta_dir = Path(gta_dir).expanduser().resolve() + data_dir = next((path for path in gta_dir.iterdir() + if path.is_dir() and path.name.lower() == "data"), None) if gta_dir.is_dir() else None + if data_dir is None: + raise FileNotFoundError(f"GTA data directory not found in {gta_dir}") + source_dir = find_samp_assets(source) + target = gta_dir / "modloader" / "SAMP" + destinations = { + "SAMP.ide": target / "data" / "maps" / "samp" / "SAMP.ide", + "SAMP.img": target / "models" / "SAMP.img", + "SAMPCOL.img": target / "models" / "SAMPCOL.img", + } + conflicts = [str(path) for path in destinations.values() if path.exists()] + readme = target / "readme.txt" + if readme.exists(): + conflicts.append(str(readme)) + if conflicts and not force: + raise FileExistsError("SA-MP modloader package already exists; use --force to update it: " + + ", ".join(conflicts)) + for name, destination in destinations.items(): + destination.parent.mkdir(parents=True, exist_ok=True) + source_file = _casefold_file(source_dir, name) + assert source_file is not None + with tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as temporary: + temporary_path = Path(temporary.name) + try: + shutil.copy2(source_file, temporary_path) + os.replace(temporary_path, destination) + finally: + temporary_path.unlink(missing_ok=True) + readme.parent.mkdir(parents=True, exist_ok=True) + readme.write_text(SAMP_MODLOADER_README, encoding="ascii") + return { + "source": str(source_dir), "target": str(target), + "files": [str(readme), *[str(path) for path in destinations.values()]], + "samp_ipl_loaded": False, + } diff --git a/tools/agent/service.py b/tools/agent/service.py index 4f92eb6..5fb8c16 100644 --- a/tools/agent/service.py +++ b/tools/agent/service.py @@ -38,6 +38,10 @@ from .review_rig import ReviewRig else: from review_rig import ReviewRig +if __package__: + from .samp import parse_pawn_mapping, pawn_operations +else: + from samp import parse_pawn_mapping, pawn_operations if __package__: from .vibe import VibeWorkflow else: @@ -1396,6 +1400,59 @@ def suppress_native_models(self, x, y, radius, model_ids): if offset is None: break return self.engine("suppress_world_models", [x, y, radius, *model_ids]) + def import_pawn_mapping(self, source_path: str, logical_path: str, + physical_path: str, *, group: str | None = None, + commit: bool = False, save: bool = False, + chunk_size: int = 128) -> dict: + """Import literal SA-MP/open.mp mapping calls into a new scratch scene.""" + if save and not commit: + raise ValueError("save requires commit=true; review the scratch session first") + if not 1 <= int(chunk_size) <= 256: + raise ValueError("chunk_size must be between 1 and 256") + status = self.engine("session_status") + if status.get("active"): + raise RuntimeError("finish the current scratch session before importing a Pawn mapping") + source = Path(source_path).expanduser().resolve() + physical = Path(physical_path).expanduser().resolve() + if physical.exists(): + raise FileExistsError(f"refusing to import over an existing IPL: {physical}") + mapping = parse_pawn_mapping(source) + if not mapping.objects and not mapping.removals: + raise ValueError("Pawn file contains no supported literal mapping calls") + placements, rotations = pawn_operations(mapping, group=group) + physical.parent.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(source.read_bytes()).hexdigest()[:16] + session_name = f"pawn-import:{source.stem}"[:127] + self.engine("scene", [logical_path, str(physical)]) + self.engine("session_begin", [session_name]) + receipts, suppression = {"place": [], "rotate": []}, [] + try: + for phase, operations in (("place", placements), ("rotate", rotations)): + for offset in range(0, len(operations), int(chunk_size)): + chunk = operations[offset:offset + int(chunk_size)] + revision = int(self.engine("session_status")["scene_revision"]) + receipts[phase].append(self.apply_scene_patch( + chunk, patch_id=f"pawn:{digest}:{phase}:{offset // int(chunk_size)}", + expected_revision=revision)) + for removal in mapping.removals: + suppression.append(self.suppress_native_models( + removal.x, removal.y, removal.radius, [removal.model])) + except Exception: + self.engine("session_rollback") + raise + commit_result = self.engine("session_commit") if commit else None + save_result = self.engine("save") if save else None + return { + "ok": True, **mapping.summary(), "logical_path": logical_path, + "physical_path": str(physical), "session_name": session_name, + "placement_patch_count": len([item for item in receipts["place"] if item]), + "rotation_patch_count": len([item for item in receipts["rotate"] if item]), + "full_rotation_count": len(rotations), + "suppressed_native_count": sum(int(item.get("suppressed_count", 0)) for item in suppression), + "active_for_review": not commit, "committed": bool(commit), "saved": bool(save), + "commit_result": commit_result, "save_result": save_result, + } + def dispatch(self, method: str, params: dict | None = None) -> Any: params = params or {} if method.startswith("vibe."): @@ -1418,6 +1475,7 @@ def dispatch(self, method: str, params: dict | None = None) -> Any: return methods[method](**params) if method == "scene.environment": return self.environment(**params) if method == "scene.suppress_native": return self.suppress_native_models(**params) + if method == "scene.import_pawn": return self.import_pawn_mapping(**params) if method == "health": return {"engine": self.engine("ping"), "database": str(self.database)} if method == "engine.command": diff --git a/tools/agent/tests/test_mcp_stdio.py b/tools/agent/tests/test_mcp_stdio.py index 615c27c..7f0f6e5 100644 --- a/tools/agent/tests/test_mcp_stdio.py +++ b/tools/agent/tests/test_mcp_stdio.py @@ -52,9 +52,10 @@ async def test_discovery_tools_are_typed_and_callable(self): "build_composition_recipe", "asset_passport", "annotate_asset_passport", "mapping_edit_history", "undo_mapping_edit", "review_mapping", "record_mapping_review", "mapping_environment", "selected_mapping_objects", "compare_mapping_variants", - "search_creative_catalogue", "propose_asset_palette", "render_palette_board", - "propose_composition_variants", "inspect_placement_anchors", - "create_mapping_review_rig", "capture_mapping_review_rig", "compare_mapping_review_images", + "search_creative_catalogue", "propose_asset_palette", "render_palette_board", + "propose_composition_variants", "inspect_placement_anchors", + "create_mapping_review_rig", "capture_mapping_review_rig", "compare_mapping_review_images", + "inspect_pawn_mapping", "import_pawn_mapping", }.issubset(names)) recipes = await session.call_tool("list_composition_recipes", {}) self.assertFalse(recipes.is_error) diff --git a/tools/agent/tests/test_samp.py b/tools/agent/tests/test_samp.py new file mode 100644 index 0000000..32b58bf --- /dev/null +++ b/tools/agent/tests/test_samp.py @@ -0,0 +1,148 @@ +"""SA-MP/open.mp asset setup and Pawn mapping importer tests.""" + +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import Mock + + +AGENT_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(AGENT_DIR)) + +from asset_index import AssetIndex, build_index # noqa: E402 +from samp import install_samp_assets, parse_pawn_mapping, parse_pawn_text, pawn_operations # noqa: E402 +from service import ArianeService # noqa: E402 + + +class PawnParserTests(unittest.TestCase): + def test_literal_mapping_variants_comments_and_full_rotation(self): + mapping = parse_pawn_text(""" + // CreateObject(999, 0, 0, 0, 0, 0, 0); + new object = CreateObject(1000, 1, 2, 3, 40, 5, 270); + CreateDynamicObject(1001, 4, 5, 6, 0, 0, -1.5, -1, -1); + CreatePlayerObject(playerid, 1002, 7, 8, 9, 0, 0, 90); + RemoveBuildingForPlayer(playerid, 708, -1, -2, 34.5, 0.25); + SetObjectMaterial(object, 0, 1000, "txd", "texture", 0); + """, "mapping.pwn") + self.assertEqual([1000, 1001, 1002], [item.model for item in mapping.objects]) + self.assertEqual(708, mapping.removals[0].model) + self.assertEqual(0.25, mapping.removals[0].radius) + self.assertEqual("material_not_supported", mapping.warnings[0]["code"]) + placements, rotations = pawn_operations(mapping) + self.assertEqual(3, len(placements)) + self.assertEqual(1, len(rotations)) + self.assertEqual([40.0, 5.0, 270.0], [ + rotations[0]["pitch"], rotations[0]["roll"], rotations[0]["heading"]]) + + def test_non_literal_mapping_call_is_reported_not_guessed(self): + mapping = parse_pawn_text("CreateObject(MODEL_ID, x, 2.0, 3.0, 0.0, 0.0, 0.0);", "variables.pwn") + self.assertEqual([], mapping.objects) + self.assertEqual("non_literal_call", mapping.warnings[0]["code"]) + + def test_file_extension_is_required(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "mapping.txt" + path.write_text("CreateObject(1,0,0,0,0,0,0);") + with self.assertRaisesRegex(ValueError, r"\.pwn"): + parse_pawn_mapping(path) + + +class SampAssetTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.gta = self.root / "gta" + (self.gta / "DATA").mkdir(parents=True) + (self.gta / "DATA" / "base.ide").write_text( + "objs\n1000, base_model, base_txd, 100.0, 0\nend\n", encoding="latin-1") + self.source = self.root / "openmp" / "SAMP" + self.source.mkdir(parents=True) + (self.source / "SAMP.ide").write_text( + "objs\n19552, Plane125x125Conc2, cs_ebridge, 599.0, 2097152\nend\n", + encoding="latin-1") + (self.source / "SAMP.img").write_bytes(b"VER2") + (self.source / "SAMPCOL.img").write_bytes(b"VER2") + + def tearDown(self): + self.temporary.cleanup() + + def test_installs_user_assets_without_samp_ipl_and_indexes_declared_ide(self): + result = install_samp_assets(self.gta, self.source) + target = Path(result["target"]) + self.assertTrue((target / "models" / "SAMP.img").exists()) + self.assertTrue((target / "data" / "maps" / "samp" / "SAMP.ide").exists()) + self.assertFalse((target / "SAMP.ipl").exists()) + database = self.root / "assets.sqlite3" + index = build_index(database=database, gta_dir=self.gta) + self.assertEqual(2, index["asset_count"]) + self.assertEqual("Plane125x125Conc2", AssetIndex(database).inspect(19552)["name"]) + + def test_existing_generated_package_requires_force(self): + install_samp_assets(self.gta, self.source) + with self.assertRaisesRegex(FileExistsError, "--force"): + install_samp_assets(self.gta, self.source) + + +class PawnImportServiceTests(unittest.TestCase): + def test_import_places_then_restores_xyz_rotation_and_suppresses_native(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "mapping.pwn" + source.write_text(""" + CreateObject(1000, 1, 2, 3, 40, 0, 270); + CreateObject(1001, 4, 5, 6, 0, 0, 90); + RemoveBuildingForPlayer(playerid, 708, 10, 20, 30, 0.25); + """) + service = ArianeService.__new__(ArianeService) + state = {"active": False, "revision": 7} + engine_calls = [] + + def engine(command, fields=None): + engine_calls.append((command, fields or [])) + if command == "session_status": + return {"active": state["active"], "scene_revision": state["revision"]} + if command == "session_begin": state["active"] = True + if command in {"session_commit", "session_rollback"}: state["active"] = False + return {"ok": True} + + service.engine = Mock(side_effect=engine) + service.apply_scene_patch = Mock(side_effect=lambda operations, **kwargs: {"objects": operations}) + service.suppress_native_models = Mock(return_value={"suppressed_count": 1}) + result = service.import_pawn_mapping( + str(source), "ariane/mapping.ipl", str(root / "mapping.ipl"), + commit=True, save=True) + self.assertEqual(2, result["object_count"]) + self.assertEqual(1, result["full_rotation_count"]) + self.assertEqual("transform3d", service.apply_scene_patch.call_args_list[1].args[0][0]["action"]) + service.suppress_native_models.assert_called_once_with(10.0, 20.0, 0.25, [708]) + self.assertIn(("save", []), engine_calls) + + def test_import_rolls_back_when_a_patch_fails(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "mapping.pwn" + source.write_text("CreateObject(1000, 1, 2, 3, 0, 0, 90);", encoding="utf-8") + service = ArianeService.__new__(ArianeService) + state = {"active": False, "revision": 7} + engine_calls = [] + + def engine(command, fields=None): + engine_calls.append(command) + if command == "session_status": + return {"active": state["active"], "scene_revision": state["revision"]} + if command == "session_begin": state["active"] = True + if command == "session_rollback": state["active"] = False + return {"ok": True} + + service.engine = Mock(side_effect=engine) + service.apply_scene_patch = Mock(side_effect=RuntimeError("patch failed")) + with self.assertRaisesRegex(RuntimeError, "patch failed"): + service.import_pawn_mapping( + str(source), "ariane/mapping.ipl", str(root / "mapping.ipl")) + self.assertEqual("session_rollback", engine_calls[-1]) + self.assertFalse(state["active"]) + + +if __name__ == "__main__": + unittest.main()