Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cursor/environment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "ttl (Victoria 3 mod)",
"user": "ubuntu",
"install": "bash ./.cursor/install.sh"
}
65 changes: 65 additions & 0 deletions .cursor/install.sh
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions scripts/validate.sh
Original file line number Diff line number Diff line change
@@ -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
196 changes: 196 additions & 0 deletions scripts/validate_mod.py
Original file line number Diff line number Diff line change
@@ -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_<language>:` 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:<n> \"value\"' "
f"format ({line.strip()!r})"
)
if not header_seen:
rep.error(f"{path}: no 'l_<language>:' 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))