#!/usr/bin/env python3 from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, asdict from pathlib import Path from known_issues import classify_issue_ids, load_known_issue_ids SETTING_TEXTURESET_RE = re.compile(r"^TextureSet\s+(.+)$", re.IGNORECASE) SETTING_ENV_RE = re.compile(r"^Environment\s+(.+)$", re.IGNORECASE) @dataclass class MapCheck: pack: str map_dir: str setting_ok: bool mapproperty_ok: bool textureset_ref: str | None textureset_exists: bool environment_ref: str | None environment_exists: bool def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Validate cross-pack runtime references for Metin2 map/world content." ) parser.add_argument( "--runtime-root", type=Path, required=True, help="Client runtime root containing assets/.", ) parser.add_argument( "--pack", action="append", default=[], help="Specific outdoor/world asset directory to validate. Repeatable. Defaults to common world packs.", ) parser.add_argument( "--json", action="store_true", help="Emit JSON output.", ) parser.add_argument( "--known-issues", type=str, default=None, help="Optional known issues baseline JSON. Defaults to repo known_issues/runtime_known_issues.json if present.", ) parser.add_argument( "--strict-known-issues", action="store_true", help="Also fail when the known-issues baseline contains stale entries not observed anymore.", ) return parser.parse_args() def normalize_virtual_path(value: str) -> str: value = value.strip().strip('"').replace("\\", "/").lower() prefixes = ["d:/", "d:"] for prefix in prefixes: if value.startswith(prefix): value = value[len(prefix):] break return value.lstrip("/") def resolve_textureset_path(runtime_assets: Path, ref: str) -> Path: rel = normalize_virtual_path(ref) if not rel.startswith("textureset/"): rel = f"textureset/{rel}" return runtime_assets / "textureset" / rel def resolve_environment_path(runtime_assets: Path, ref: str) -> Path: rel = normalize_virtual_path(ref) if "/" not in rel: rel = f"ymir work/environment/{rel}" return runtime_assets / "ETC" / rel def validate_map_dir(pack_name: str, map_dir: Path, runtime_assets: Path) -> MapCheck: setting_path = map_dir / "setting.txt" mapproperty_path = map_dir / "mapproperty.txt" textureset_ref = None environment_ref = None textureset_exists = False environment_exists = False if setting_path.is_file(): for raw_line in setting_path.read_text(encoding="utf-8", errors="ignore").splitlines(): line = raw_line.strip() if not line: continue texture_match = SETTING_TEXTURESET_RE.match(line) if texture_match: textureset_ref = texture_match.group(1).strip() textureset_exists = resolve_textureset_path(runtime_assets, textureset_ref).is_file() continue environment_match = SETTING_ENV_RE.match(line) if environment_match: environment_ref = environment_match.group(1).strip() environment_exists = resolve_environment_path(runtime_assets, environment_ref).is_file() return MapCheck( pack=pack_name, map_dir=map_dir.name, setting_ok=setting_path.is_file(), mapproperty_ok=mapproperty_path.is_file(), textureset_ref=textureset_ref, textureset_exists=textureset_exists if textureset_ref else False, environment_ref=environment_ref, environment_exists=environment_exists if environment_ref else False, ) def main() -> int: args = parse_args() runtime_root = args.runtime_root.resolve() runtime_assets = runtime_root / "assets" if not runtime_assets.is_dir(): raise SystemExit(f"assets dir not found: {runtime_assets}") packs = args.pack or [ "OutdoorA1", "OutdoorA2", "OutdoorA3", "OutdoorB1", "OutdoorB3", "OutdoorC1", "OutdoorC3", "OutdoorSnow1", "outdoordesert1", "outdoorflame1", "outdoorfielddungeon1", ] checks: list[MapCheck] = [] failures: list[str] = [] issue_map: dict[str, str] = {} for pack_name in packs: pack_dir = runtime_assets / pack_name if not pack_dir.is_dir(): issue_id = f"world:pack_dir:{pack_name}" message = f"missing pack dir: {pack_name}" failures.append(message) issue_map[issue_id] = message continue map_dirs = sorted([p for p in pack_dir.iterdir() if p.is_dir()]) if not map_dirs: issue_id = f"world:map_dirs:{pack_name}" message = f"no map dirs in pack: {pack_name}" failures.append(message) issue_map[issue_id] = message continue for map_dir in map_dirs: check = validate_map_dir(pack_name, map_dir, runtime_assets) checks.append(check) if not check.setting_ok: issue_id = f"world:setting:{pack_name}/{map_dir.name}" message = f"{pack_name}/{map_dir.name}: missing setting.txt" failures.append(message) issue_map[issue_id] = message if not check.mapproperty_ok: issue_id = f"world:mapproperty:{pack_name}/{map_dir.name}" message = f"{pack_name}/{map_dir.name}: missing mapproperty.txt" failures.append(message) issue_map[issue_id] = message if not check.textureset_ref or not check.textureset_exists: issue_id = f"world:textureset:{pack_name}/{map_dir.name}:{normalize_virtual_path(check.textureset_ref or '')}" message = f"{pack_name}/{map_dir.name}: missing textureset target for {check.textureset_ref!r}" failures.append(message) issue_map[issue_id] = message if not check.environment_ref or not check.environment_exists: issue_id = f"world:environment:{pack_name}/{map_dir.name}:{normalize_virtual_path(check.environment_ref or '')}" message = f"{pack_name}/{map_dir.name}: missing environment target for {check.environment_ref!r}" failures.append(message) issue_map[issue_id] = message observed_issue_ids = set(issue_map.keys()) known_path, known_issue_ids = load_known_issue_ids(__file__, "world", args.known_issues) known_observed, unexpected_issue_ids, stale_known_issue_ids = classify_issue_ids(observed_issue_ids, known_issue_ids) result = { "ok": not unexpected_issue_ids and (not args.strict_known_issues or not stale_known_issue_ids), "checked_map_dirs": len(checks), "packs": packs, "failures": failures, "issue_ids": sorted(observed_issue_ids), "known_issue_ids": sorted(known_observed), "unexpected_issue_ids": sorted(unexpected_issue_ids), "stale_known_issue_ids": sorted(stale_known_issue_ids), "unexpected_failures": [issue_map[issue_id] for issue_id in sorted(unexpected_issue_ids)], "stale_known_failures": sorted(stale_known_issue_ids), "known_issues_path": str(known_path) if known_path else None, "checks": [asdict(check) for check in checks], } if args.json: print(json.dumps(result, indent=2)) else: print(f"ok={result['ok']} checked_map_dirs={result['checked_map_dirs']}") for failure in result["unexpected_failures"]: print(f"FAIL: {failure}") for issue_id in result["stale_known_issue_ids"]: print(f"STALE: {issue_id}") return 0 if result["ok"] else 1 if __name__ == "__main__": sys.exit(main())