From 0f9cce7d85da88bb19e0286032e8419aa39b3230 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 08:59:26 +0000 Subject: [PATCH] Add Cloud Agent dev environment for the ttl Victoria 3 mod - .cursor/environment.json + install.sh install vic3-tiger (canonical Victoria 3 mod validator) idempotently. - scripts/validate_mod.py: offline well-formedness checks (Paradox script brace/quote balance, localization BOM/format, metadata JSON, PNG signature). - scripts/validate.sh: runs the offline checks and, when a Victoria 3 game directory is available (VIC3_DIR), the full vic3-tiger validation. Co-authored-by: IAALAI --- .cursor/environment.json | 5 + .cursor/install.sh | 65 +++++++++++++ scripts/validate.sh | 41 ++++++++ scripts/validate_mod.py | 196 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 .cursor/environment.json create mode 100755 .cursor/install.sh create mode 100755 scripts/validate.sh create mode 100755 scripts/validate_mod.py diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 0000000..c5ff867 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,5 @@ +{ + "name": "ttl (Victoria 3 mod)", + "user": "ubuntu", + "install": "bash ./.cursor/install.sh" +} diff --git a/.cursor/install.sh b/.cursor/install.sh new file mode 100755 index 0000000..7934622 --- /dev/null +++ b/.cursor/install.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Idempotent environment bootstrap for the ttl Victoria 3 mod. +# +# Installs `vic3-tiger` (the canonical Victoria 3 mod validator) and makes the +# repository's validation scripts executable. Safe to run repeatedly. +set -euo pipefail + +TIGER_VERSION="v1.19.0" +INSTALL_DIR="/usr/local/bin" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Prefer sudo when we are not root and it is available. +SUDO="" +if [[ "$(id -u)" -ne 0 ]]; then + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + else + INSTALL_DIR="$HOME/.local/bin" + fi +fi +mkdir -p "$HOME/.local/bin" + +install_vic3_tiger() { + if command -v vic3-tiger >/dev/null 2>&1 && \ + vic3-tiger --version 2>/dev/null | grep -q "${TIGER_VERSION#v}"; then + echo "vic3-tiger ${TIGER_VERSION} already installed." + return + fi + + local url tmp + url="https://github.com/amtep/tiger/releases/download/${TIGER_VERSION}/vic3-tiger-linux-${TIGER_VERSION}.tar.gz" + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + + echo "Downloading vic3-tiger ${TIGER_VERSION}..." + local attempt delay=4 + for attempt in 1 2 3 4 5; do + if curl -fsSL "$url" -o "$tmp/vic3-tiger.tar.gz"; then + break + fi + if [[ "$attempt" -eq 5 ]]; then + echo "ERROR: failed to download vic3-tiger after 5 attempts." >&2 + return 1 + fi + echo "download failed (attempt $attempt), retrying in ${delay}s..." + sleep "$delay" + delay=$((delay * 2)) + done + + tar -xzf "$tmp/vic3-tiger.tar.gz" -C "$tmp" + local bin + bin="$(find "$tmp" -name vic3-tiger -type f | head -n1)" + chmod +x "$bin" + $SUDO install -m 0755 "$bin" "$INSTALL_DIR/vic3-tiger" + echo "Installed vic3-tiger to $INSTALL_DIR/vic3-tiger" +} + +install_vic3_tiger + +chmod +x "$ROOT/scripts/validate.sh" "$ROOT/scripts/validate_mod.py" + +echo +echo "Environment ready." +vic3-tiger --version || "$INSTALL_DIR/vic3-tiger" --version +python3 --version diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..335217c --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Validate the ttl Victoria 3 mod. +# +# Always runs the offline well-formedness checks (scripts/validate_mod.py). +# If a Victoria 3 game directory is available (via the VIC3_DIR environment +# variable or a standard Steam path) and vic3-tiger is installed, it also runs +# the full vic3-tiger semantic validation. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "== Offline well-formedness checks ==" +python3 "$ROOT/scripts/validate_mod.py" "$ROOT" + +if command -v vic3-tiger >/dev/null 2>&1; then + GAME_DIR="${VIC3_DIR:-}" + if [[ -z "$GAME_DIR" ]]; then + for candidate in \ + "$HOME/.steam/steam/steamapps/common/Victoria 3" \ + "$HOME/.local/share/Steam/steamapps/common/Victoria 3"; do + if [[ -d "$candidate" ]]; then + GAME_DIR="$candidate" + break + fi + done + fi + + echo + if [[ -n "$GAME_DIR" && -d "$GAME_DIR" ]]; then + echo "== vic3-tiger full validation (game: $GAME_DIR) ==" + vic3-tiger --no-color --game "$GAME_DIR" "$ROOT" + else + echo "== vic3-tiger ==" + echo "vic3-tiger is installed ($(vic3-tiger --version))." + echo "Set VIC3_DIR to your Victoria 3 install to run full semantic validation, e.g.:" + echo " VIC3_DIR=\"/path/to/Victoria 3\" $ROOT/scripts/validate.sh" + fi +else + echo + echo "vic3-tiger not installed; skipping full semantic validation." +fi diff --git a/scripts/validate_mod.py b/scripts/validate_mod.py new file mode 100755 index 0000000..34d9141 --- /dev/null +++ b/scripts/validate_mod.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Offline well-formedness validator for the `ttl` Victoria 3 mod. + +This performs the checks that are possible *without* the copyrighted Victoria 3 +game files (which cannot be shipped in CI or a Cloud Agent VM): + + * Paradox script (`.txt`) brace and quote balance. + * Localization (`.yml`) format: UTF-8 BOM, `l_:` header, entry syntax. + * `.metadata/metadata.json` is valid JSON. + * Image assets (`.png`) have a valid PNG signature. + +For full semantic validation (missing localizations, scope checks, undefined +references, ...) run `vic3-tiger` with a real Victoria 3 install: + + vic3-tiger --game "/path/to/Victoria 3" . + +Exit code is non-zero if any error is found, so this doubles as a CI/pre-commit +check. +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +# Directories (relative to the mod root) that contain Paradox script files. +SCRIPT_DIRS = ["common", "events"] +LOC_DIR = "localization" +METADATA_FILE = ".metadata/metadata.json" + +LOC_ENTRY_RE = re.compile(r'^\s+[A-Za-z0-9_.\-]+:\d*\s+".*"\s*$') +LOC_HEADER_RE = re.compile(r"^l_[a-z_]+:\s*$") + + +class Report: + def __init__(self) -> None: + self.errors: list[str] = [] + self.warnings: list[str] = [] + self.checked = 0 + + def error(self, msg: str) -> None: + self.errors.append(msg) + + def warn(self, msg: str) -> None: + self.warnings.append(msg) + + +def strip_script_line(line: str) -> str: + """Remove comments and quoted-string contents from a Paradox script line. + + Returns a version of the line safe for brace counting: everything inside a + double-quoted string and everything after an unquoted `#` is removed. + """ + out = [] + in_string = False + i = 0 + while i < len(line): + ch = line[i] + if in_string: + if ch == "\\": # skip escaped char inside string + i += 2 + continue + if ch == '"': + in_string = False + i += 1 + continue + if ch == '"': + in_string = True + elif ch == "#": + break + else: + out.append(ch) + i += 1 + return "".join(out), in_string + + +def check_script_file(path: Path, rep: Report) -> None: + rep.checked += 1 + try: + text = path.read_text(encoding="utf-8-sig") + except UnicodeDecodeError as exc: + rep.error(f"{path}: not valid UTF-8 ({exc})") + return + + depth = 0 + for lineno, raw in enumerate(text.splitlines(), start=1): + cleaned, unterminated_string = strip_script_line(raw) + if unterminated_string: + rep.error(f"{path}:{lineno}: unterminated quoted string") + for ch in cleaned: + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth < 0: + rep.error( + f"{path}:{lineno}: unbalanced '}}' " + f"(closing brace with no matching '{{')" + ) + depth = 0 + if depth != 0: + rep.error(f"{path}: {depth} unclosed '{{' brace(s) at end of file") + + +def check_loc_file(path: Path, rep: Report) -> None: + rep.checked += 1 + raw = path.read_bytes() + if not raw.startswith(b"\xef\xbb\xbf"): + rep.error( + f"{path}: missing UTF-8 BOM (Victoria 3 localization files must be " + f"UTF-8 with BOM or the game silently ignores them)" + ) + text = raw.decode("utf-8-sig") + + header_seen = False + for lineno, line in enumerate(text.splitlines(), start=1): + if not line.strip() or line.lstrip().startswith("#"): + continue + if not header_seen: + if not LOC_HEADER_RE.match(line): + rep.error( + f"{path}:{lineno}: first entry must be a language header " + f"like 'l_english:' (found: {line.strip()!r})" + ) + header_seen = True + continue + if line.count('"') % 2 != 0: + rep.error(f"{path}:{lineno}: odd number of '\"' (unterminated value)") + continue + if not LOC_ENTRY_RE.match(line): + rep.warn( + f"{path}:{lineno}: entry does not match 'KEY: \"value\"' " + f"format ({line.strip()!r})" + ) + if not header_seen: + rep.error(f"{path}: no 'l_:' header found") + + +def check_metadata(path: Path, rep: Report) -> None: + if not path.exists(): + rep.warn(f"{path}: metadata file not found") + return + rep.checked += 1 + try: + json.loads(path.read_text(encoding="utf-8-sig")) + except json.JSONDecodeError as exc: + rep.error(f"{path}: invalid JSON ({exc})") + + +def check_png(path: Path, rep: Report) -> None: + rep.checked += 1 + sig = path.read_bytes()[:8] + if sig != b"\x89PNG\r\n\x1a\n": + rep.error(f"{path}: not a valid PNG (bad signature)") + + +def main(argv: list[str]) -> int: + root = Path(argv[1]).resolve() if len(argv) > 1 else Path(__file__).resolve().parent.parent + if not root.exists(): + print(f"error: mod root {root} does not exist", file=sys.stderr) + return 2 + + rep = Report() + + for d in SCRIPT_DIRS: + for p in sorted((root / d).rglob("*.txt")): + check_script_file(p, rep) + + loc_root = root / LOC_DIR + if loc_root.exists(): + for p in sorted(loc_root.rglob("*.yml")): + check_loc_file(p, rep) + + check_metadata(root / METADATA_FILE, rep) + + for p in sorted(root.rglob("*.png")): + if ".git" in p.parts: + continue + check_png(p, rep) + + print(f"Validated {rep.checked} file(s) under {root}") + for w in rep.warnings: + print(f" WARN {w}") + for e in rep.errors: + print(f" ERROR {e}") + + if rep.errors: + print(f"\nFAILED: {len(rep.errors)} error(s), {len(rep.warnings)} warning(s)") + return 1 + print(f"\nOK: 0 errors, {len(rep.warnings)} warning(s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv))