WeaponScriptToWiki.py: Difference between revisions

Jump to navigation Jump to search
No edit summary
Update weapon page automation workflow
Line 1: Line 1:
from __future__ import annotations
import argparse
import csv
import os
import os
import re
import re
import sys
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable


# Utility functions for conversions
def kg_to_g(kg):
    return round(kg * 1000, 2)


def kg_to_grains(kg):
ROOT = Path(__file__).resolve().parent
     return round(kg * 15432.36, 2)
DEFAULT_GAME_DIR = Path(r"C:\MCV\repos\mcv-game\vietnam")
DEFAULT_OUTPUT_DIR = ROOT / "generated_weapon_pages"
DEFAULT_REPORT = ROOT / "weapon_page_creation_report.csv"
DEFAULT_TEMPLATE_URL = (
     "https://wiki.militaryconflictvietnam.com/"
    "index.php?title=WikiTemplate.txt&action=raw"
)
DEFAULT_SCRIPT_INDEX_URL = (
    "https://wiki.militaryconflictvietnam.com/"
    "index.php?title=List:Weapon_Scripts_By_Name&action=raw"
)
API_URL = "https://wiki.militaryconflictvietnam.com/api.php"
DEFAULT_SUMMARY = "Create weapon page from game script data"


def kg_to_lbs(kg):
FACTIONS = {"US", "VC"}
     return round(kg * 2.20462, 2)
CLASSES = {"assault", "medic", "gunner", "sniper", "engineer", "radioman"}
CLASS_ORDER = ["assault", "medic", "gunner", "sniper", "engineer", "radioman"]
CLASS_IMAGE_MAP = {
    "assault": "Class_Assault.png",
    "medic": "Class_medic.png",
    "gunner": "Class_Gunner.png",
    "sniper": "Class_sniper.png",
    "engineer": "Class_Engineer.png",
     "radioman": "Class_radioman.png",
}


# Load the template file
GROUP_TO_TYPE = {
def load_template(template_path):
    "assault_rifles": "Assault Rifle",
     with open(template_path, "r", encoding="utf-8") as f:
    "battle_rifles": "Battle Rifle",
         return f.read()
    "bolt_actions": "Bolt Action Rifle",
    "carbines": "Carbine",
    "grenade_launchers": "Grenade Launcher",
    "grenades": "Grenade",
    "lmgs": "Light Machine Gun",
    "machine_pistols": "Machine Pistol",
    "melee": "Melee",
    "pistols": "Pistol",
    "revolvers": "Revolver",
    "rifle_grenades": "Rifle Grenade",
    "rocket_launchers": "Rocket Launcher",
    "shotguns": "Shotgun",
    "sniper_rifles": "Sniper Rifle",
    "smgs": "Submachine Gun",
}
 
WEAPON_TYPE_MAP = {
    "BattleRifle": "Battle Rifle",
    "BoltActionRifle": "Bolt Action Rifle",
    "C4": "C4",
    "Carbine": "Carbine",
    "Crossbow": "Crossbow",
    "Equipment": "Equipment",
    "Fists": "Fists",
    "Flamethrower": "Flamethrower",
    "Flaregun": "Flare Gun",
    "Grenade": "Grenade",
    "GrenadeLauncher": "Grenade Launcher",
    "Incendiary": "Incendiary",
    "Machinegun": "Light Machine Gun",
    "MachinePistol": "Machine Pistol",
    "Melee": "Melee",
    "Mine": "Mine",
    "Pistol": "Pistol",
    "Ptrd": "Anti-Tank Rifle",
    "Revolver": "Revolver",
    "Rifle": "Rifle",
    "RifleGrenade": "Rifle Grenade",
    "RocketLauncher": "Rocket Launcher",
    "Shotgun": "Shotgun",
    "SmokeGrenade": "Smoke Grenade",
    "SniperRifle": "Sniper Rifle",
    "SubMachinegun": "Submachine Gun",
}
 
SECTION_BY_TYPE = {
    "Assault Rifle": "Assault Rifles",
    "Battle Rifle": "Battle Rifles",
    "Bolt Action Rifle": "Bolt Action",
    "Carbine": "Carbines",
    "Grenade Launcher": "Grenade Launchers",
    "Grenade": "Grenades and Throwables",
    "Incendiary": "Grenades and Throwables",
    "Smoke Grenade": "Grenades and Throwables",
    "Light Machine Gun": "Light Machine Guns",
    "Machine Pistol": "Machine Pistols",
    "Melee": "Melee",
    "Pistol": "Pistols",
    "Revolver": "Revolvers",
    "Rifle Grenade": "Rifle Grenades",
    "Rocket Launcher": "Rocket Launchers",
    "Shotgun": "Shotguns",
    "Sniper Rifle": "Sniper Rifles",
    "Submachine Gun": "Submachine Guns",
}
 
 
@dataclass
class LoadoutInfo:
    factions: set[str] = field(default_factory=set)
    classes: set[str] = field(default_factory=set)
    groups: set[str] = field(default_factory=set)
    sources: set[str] = field(default_factory=set)
 
    def absorb(self, other: "LoadoutInfo") -> None:
        self.factions.update(other.factions)
        self.classes.update(other.classes)
        self.groups.update(other.groups)
        self.sources.update(other.sources)
 
 
@dataclass
class WeaponPage:
    script_name: str
    title: str
    status: str
    output_path: Path | None
    page_text: str
    weapon_type: str
    factions: str
    classes: str
    loadout_sources: str
    note: str = ""
 
 
def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Generate and optionally create MCV wiki weapon pages from game "
            "weapon script data."
        )
    )
    parser.add_argument(
        "--game-dir",
        type=Path,
        default=DEFAULT_GAME_DIR,
        help=f"Vietnam game folder. Default: {DEFAULT_GAME_DIR}",
    )
    parser.add_argument(
        "--scripts-dir",
        type=Path,
        help="Folder containing weapon_*.txt scripts. Overrides --game-dir.",
    )
    parser.add_argument(
        "--resource-dir",
        type=Path,
        help="Folder containing vietnam_english.txt and loadout files.",
    )
    parser.add_argument(
        "--template-path",
        type=Path,
        help="Local WikiTemplate.txt path. If omitted, the live wiki template is used.",
    )
    parser.add_argument(
        "--template-url",
        default=DEFAULT_TEMPLATE_URL,
        help="Raw wiki template URL used when --template-path is omitted.",
    )
    parser.add_argument(
        "--script-index-url",
        default=DEFAULT_SCRIPT_INDEX_URL,
        help="Raw wiki page mapping weapon script names to existing page titles.",
    )
    parser.add_argument(
        "--no-script-index",
        action="store_true",
        help="Do not use the wiki Weapon Script Name index as a title/duplicate guard.",
    )
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=DEFAULT_OUTPUT_DIR,
        help=f"Folder for generated .wiki preview files. Default: {DEFAULT_OUTPUT_DIR}",
    )
     parser.add_argument(
        "--clean-output",
        action="store_true",
        help="Delete existing .wiki preview files in --output-dir before writing new previews.",
    )
    parser.add_argument(
        "--report",
        type=Path,
        default=DEFAULT_REPORT,
        help=f"CSV report path. Default: {DEFAULT_REPORT}",
    )
    parser.add_argument(
         "--overrides",
        type=Path,
        help="Optional CSV for page titles, image names, text sections, and real-world fields.",
    )
    parser.add_argument(
        "--only",
        action="append",
        help=(
            "Only process a script name or wiki title. Example: --only weapon_ak47 "
            "or --only AK-47. May be passed more than once."
        ),
    )
    parser.add_argument(
        "--limit",
        type=int,
        help="Only process the first N matching scripts.",
    )
    parser.add_argument(
        "--include-existing",
        action="store_true",
        help="Also write preview files for pages that already exist.",
    )
    parser.add_argument(
        "--no-wiki-check",
        action="store_true",
        help="Do not check whether pages already exist on the wiki.",
    )
    parser.add_argument(
        "--upload",
        action="store_true",
        help="Create missing pages on the wiki. Without this, this is a dry run.",
    )
    parser.add_argument(
        "--upload-unlisted",
        action="store_true",
        help=(
            "With --upload, also create missing pages that were not found in the "
            "Vietnam, zombie, or special loadout files."
        ),
    )
    parser.add_argument(
        "--overwrite-existing",
        action="store_true",
        help="With --upload, update existing pages too. Use carefully.",
    )
    parser.add_argument(
        "--username",
        default=os.environ.get("MCV_WIKI_USER", "Skizmophonic"),
        help="Wiki username for Pywikibot upload/login.",
    )
    parser.add_argument(
        "--summary",
        default=DEFAULT_SUMMARY,
        help="Edit summary for uploaded pages.",
    )
    return parser.parse_args()


# Parse key-value pairs from script file (Source-style weapon script)
def parse_file(file_path):
    variables = {}
    with open(file_path, "r", encoding="utf-8") as f:
        for line in f:
            match = re.match(r'\s*"(.+?)"\s+"(.+?)"', line)
            if match:
                key, value = match.groups()
                variables[key.lower()] = value
    return variables


# Generic lookup from vietnam_english.txt, stripping any leading '#'
def read_text(path: Path) -> str:
def lookup_english_token(token_key):
     encodings = ("utf-8-sig", "utf-16", "cp1252", "latin-1")
    file_path = r"C:\Program Files (x86)\Steam\steamapps\common\Military Conflict - Vietnam\vietnam\resource\vietnam_english.txt"
     for encoding in encodings:
     key = token_key.lstrip('#')
    encodings = ["utf-8", "utf-16", "latin-1"]
     for enc in encodings:
         try:
         try:
             with open(file_path, "r", encoding=enc) as f:
             return path.read_text(encoding=encoding)
                for line in f:
                    line = line.strip()
                    m = re.match(r'^\s*"(.+?)"\s+"(.+?)"\s*$', line)
                    if m:
                        k, v = m.groups()
                        if k.lower() == key.lower():
                            return v
         except UnicodeDecodeError:
         except UnicodeDecodeError:
             continue
             continue
     return None
     return path.read_text(errors="replace")
 
 
def fetch_text(url: str) -> str:
    with urllib.request.urlopen(url, timeout=30) as response:
        return response.read().decode("utf-8-sig")
 
 
def load_template(args: argparse.Namespace) -> str:
    if args.template_path:
        return read_text(args.template_path)
    return fetch_text(args.template_url)
 
 
def load_script_index(args: argparse.Namespace) -> dict[str, str]:
    if args.no_script_index:
        return {}
    text = fetch_text(args.script_index_url)
    index: dict[str, str] = {}
    for match in re.finditer(r"\|\s*(weapon_[^\s|]+)\s*\n\|\s*\[\[([^\]|]+)", text):
        script_name, title = match.groups()
        index.setdefault(script_name.lower(), title.strip())
    return index
 
 
def strip_line_comment(line: str) -> str:
    in_quote = False
    i = 0
    while i < len(line) - 1:
        char = line[i]
        if char == '"':
            in_quote = not in_quote
        if not in_quote and line[i : i + 2] == "//":
            return line[:i]
        i += 1
    return line


# Retrieve the printnameenglish value (kept for existing behavior)
def get_printname_english(printname):
    return lookup_english_token(printname)


# Get the origin value and process it
def parse_top_level_pairs(path: Path) -> dict[str, str]:
def get_origin(origin):
     values: dict[str, str] = {}
     origin = origin.lstrip('#').replace("_", " ").title()
    pending_key: str | None = None
     return origin
     stack: list[str] = []


# -------------------------
    for raw_line in read_text(path).splitlines():
# Loadout parsing utilities
        line = strip_line_comment(raw_line).strip()
# -------------------------
        if not line:
            continue
 
        key_only = re.fullmatch(r'"([^"]+)"', line)
        if key_only:
            pending_key = key_only.group(1)
            continue
 
        bare_key_only = re.fullmatch(r"([A-Za-z_][\w]*)", line)
        if bare_key_only:
            pending_key = bare_key_only.group(1)
            continue
 
        inline_open = re.fullmatch(r'([A-Za-z_][\w]*)\s*\{', line)
        quoted_inline_open = re.fullmatch(r'"([^"]+)"\s*\{', line)
        if inline_open or quoted_inline_open:
            stack.append((inline_open or quoted_inline_open).group(1))
            pending_key = None
            continue
 
        if line == "{":
            stack.append(pending_key or "__anon__")
            pending_key = None
            continue
 
        if line == "}":
            if stack:
                stack.pop()
            pending_key = None
            continue
 
        pair = re.fullmatch(r'"([^"]+)"\s+"([^"]*)"', line)
        if not pair:
            continue
 
        if not stack or stack == ["WeaponData"]:
            key, value = pair.groups()
            values[key.lower()] = value
 
    return values
 
 
def parse_token_file(path: Path) -> dict[str, str]:
    tokens: dict[str, str] = {}
    if not path.exists():
        return tokens
    for raw_line in read_text(path).splitlines():
        line = strip_line_comment(raw_line).strip()
        match = re.fullmatch(r'"([^"]+)"\s+"([^"]*)"', line)
        if match:
            key, value = match.groups()
            tokens[key.lower()] = value
    return tokens
 
 
def lookup_token(tokens: dict[str, str], token: str, default: str = "") -> str:
    key = token.lstrip("#").lower()
    return tokens.get(key, default)


CLASSES = {"assault", "medic", "gunner", "sniper", "engineer", "radioman"}
FACTIONS = {"US", "VC"}


def parse_vietnam_loadout(loadout_path):
def parse_loadout(path: Path, source_name: str) -> dict[str, LoadoutInfo]:
    """
     mapping: dict[str, LoadoutInfo] = {}
    Parse a KeyValues-style loadout and build a mapping:
     if not path.exists():
        weapon_name -> {"factions": set(...), "classes": set(...)}
    We only consider weapons listed under the "weapons" tree.
    """
     mapping = {}
     if not os.path.exists(loadout_path):
         return mapping
         return mapping


     pending_key = None
     pending_key: str | None = None
     stack = []
     stack: list[str] = []
    cur_faction = None
    cur_class = None
    in_weapons_block = False


     def push(key):
     def current_faction() -> str | None:
         nonlocal cur_faction, cur_class, in_weapons_block
         return next((item for item in reversed(stack) if item in FACTIONS), None)
        stack.append(key)
        if key == "weapons":
            in_weapons_block = True
        if key in FACTIONS:
            cur_faction = key
        if key in CLASSES:
            cur_class = key


     def pop():
     def current_class() -> str | None:
         nonlocal cur_faction, cur_class, in_weapons_block
         return next((item for item in reversed(stack) if item in CLASSES), None)
        if not stack:
            return
        key = stack.pop()
        if key in FACTIONS and cur_faction == key:
            cur_faction = None
        if key in CLASSES and cur_class == key:
            cur_class = None
        if key == "weapons" and "weapons" not in stack:
            in_weapons_block = False


     with open(loadout_path, "r", encoding="utf-8", errors="ignore") as f:
     def current_group() -> str | None:
         for raw in f:
         return next((item for item in reversed(stack) if item in GROUP_TO_TYPE), None)
            line = raw.strip()


            # Strip comments
    for raw_line in read_text(path).splitlines():
            if "//" in line:
        line = strip_line_comment(raw_line).strip()
                line = line[: line.index("//")].rstrip()
        if not line:
            continue


             if not line:
        key_only = re.fullmatch(r'"([^"]+)"', line)
                continue
        if key_only:
            pending_key = key_only.group(1)
             continue
 
        if line == "{":
            stack.append(pending_key or "__anon__")
            pending_key = None
            continue


            m_key_only = re.fullmatch(r'"([^"]+)"', line)
        if line == "}":
             if m_key_only:
             if stack:
                 pending_key = m_key_only.group(1)
                 stack.pop()
                continue
            pending_key = None
            continue


            if line == "{":
        inline_open = re.fullmatch(r'"([^"]+)"\s*\{', line)
                if pending_key is not None:
        if inline_open:
                    push(pending_key)
            stack.append(inline_open.group(1))
                    pending_key = None
            pending_key = None
                else:
            continue
                    push("__anon__")
                continue


            if line == "}":
        pair = re.fullmatch(r'"([^"]+)"\s+"([^"]*)"', line)
                pop()
        if not pair:
                continue
            continue


            m_pair = re.fullmatch(r'"([^"]+)"\s+"([^"]*)"', line)
        key = pair.group(1)
            if m_pair and in_weapons_block and cur_faction and cur_class:
        if not key.startswith("weapon_"):
                key = m_pair.group(1)
            continue
                if key.startswith("weapon_"):
                    w = mapping.setdefault(key, {"factions": set(), "classes": set()})
                    w["factions"].add(cur_faction)
                    w["classes"].add(cur_class)
                continue


            m_inline_open = re.fullmatch(r'"([^"]+)"\s*\{', line)
        info = mapping.setdefault(key, LoadoutInfo())
             if m_inline_open:
        faction = current_faction()
                push(m_inline_open.group(1))
        cls = current_class()
                continue
        group = current_group()
        if faction:
             info.factions.add(faction)
        if cls:
            info.classes.add(cls)
        if group:
            info.groups.add(group)
        info.sources.add(source_name)


     return mapping
     return mapping


def file_contains_weapon(path, weapon_key):
 
     """
def merge_loadout_maps(maps: Iterable[dict[str, LoadoutInfo]]) -> dict[str, LoadoutInfo]:
     Simple presence check for a weapon key in a text file.
     merged: dict[str, LoadoutInfo] = {}
    Uses a word-boundary regex to reduce false positives.
     for mapping in maps:
     """
        for weapon, info in mapping.items():
     if not os.path.exists(path):
            merged.setdefault(weapon, LoadoutInfo()).absorb(info)
     return merged
 
 
def file_contains_token(path: Path, token: str) -> bool:
     if not path.exists():
         return False
         return False
    content = read_text(path)
    pattern = re.compile(r"\b" + re.escape(token) + r"\b")
    return pattern.search(content) is not None
def fmt_float(value: float, decimals: int = 2) -> str:
    text = f"{value:.{decimals}f}"
    return text.rstrip("0").rstrip(".") if "." in text else text
def float_value(values: dict[str, str], key: str, default: float = 0.0) -> float:
     try:
     try:
         with open(path, "r", encoding="utf-8", errors="ignore") as f:
         return float(values.get(key.lower(), default))
            content = f.read()
    except (TypeError, ValueError):
        pattern = r'\b' + re.escape(weapon_key) + r'\b'
        return default
        return re.search(pattern, content) is not None
 
     except Exception:
 
         return False
def format_damage(base: float, multiplier: float) -> str:
    return fmt_float(round(base * multiplier, 2))
 
 
def format_clip_size(raw: str, extra_bullet_chamber: str = "0") -> str:
    raw = (raw or "").strip()
    if not raw or raw in {"-1", "-1/-1", "0/0"}:
        return "N/A"
 
    if "/" not in raw:
        return raw
 
    current, reserve = [part.strip() for part in raw.split("/", 1)]
    if current == "-1" and reserve not in {"-1", "0", ""}:
        current = "1"
 
    marker = ""
    if extra_bullet_chamber == "1":
        marker = "[[+1]]"
    elif extra_bullet_chamber and extra_bullet_chamber not in {"0", "-1"}:
        marker = f"[[+{extra_bullet_chamber}]]"
 
    return f"{current}{marker} / {reserve}"
 
 
def title_to_filename(title: str) -> str:
    safe = re.sub(r'[<>:"/\\|?*]+', "_", title)
    safe = re.sub(r"\s+", " ", safe).strip(" .")
    return safe or "untitled"
 
 
def script_icon_filename(script_name: str) -> str:
    return f"{script_name[0].upper()}{script_name[1:]}.svg"
 
 
def build_classes_markup(classes: set[str]) -> str:
    if not classes:
        return "[[File:Class_unknown.png|50px]] <b>[[unknown]]</b><br>"
     parts: list[str] = []
    for cls in CLASS_ORDER:
         if cls in classes:
            image = CLASS_IMAGE_MAP.get(cls, f"Class_{cls}.png")
            label = cls.capitalize()
            parts.append(f"[[File:{image}|50px]] <b>[[{label}]]</b><br>")
    return "".join(parts)


def preprocess_calculations(variables, file_name, folder_path, loadout_main, loadout_zombie, loadout_special, paths):
    calculations = {}


     damage_generic = float(variables.get("damagegeneric", "0"))
def load_overrides(path: Path | None) -> dict[str, dict[str, str]]:
    damage_head_multiplier = float(variables.get("damageheadmultiplier", "1"))
     if not path:
    damage_chest_multiplier = float(variables.get("damagechestmultiplier", "1"))
        return {}
    damage_stomach_multiplier = float(variables.get("damagestomachmultiplier", "1"))
    rows: dict[str, dict[str, str]] = {}
    damage_leg_multiplier = float(variables.get("damagelegmultiplier", "1"))
    with path.open(encoding="utf-8-sig", newline="") as handle:
    damage_arm_multiplier = float(variables.get("damagearmmultiplier", "1"))
        reader = csv.DictReader(handle)
        for row in reader:
            cleaned = {
                (key or "").strip().lower(): (value or "").strip()
                for key, value in row.items()
            }
            keys = [
                cleaned.get("script_name", ""),
                cleaned.get("weapon_script_name", ""),
                cleaned.get("title", ""),
                cleaned.get("page_title", ""),
            ]
            for key in keys:
                if key:
                    rows[key.lower()] = cleaned
    return rows


    if damage_generic > 0:
        calculations["damagegenericxdamageheadmultiplier"] = round(damage_generic * damage_head_multiplier, 2)
        calculations["damagegenericxdamagechestmultiplier"] = round(damage_generic * damage_chest_multiplier, 2)
        calculations["damagegenericxdamagestomachmultiplier"] = round(damage_generic * damage_stomach_multiplier, 2)
        calculations["damagegenericxdamagelegmultiplier"] = round(damage_generic * damage_leg_multiplier, 2)
        calculations["damagegenericxdamagearmmultiplier"] = round(damage_generic * damage_arm_multiplier, 2)
    else:
        calculations["damagegenericxdamageheadmultiplier"] = "N/A"
        calculations["damagegenericxdamagechestmultiplier"] = "N/A"
        calculations["damagegenericxdamagestomachmultiplier"] = "N/A"
        calculations["damagegenericxdamagelegmultiplier"] = "N/A"
        calculations["damagegenericxdamagearmultiplier"] = "N/A"


     has_bayonet = variables.get("hasbayonet", "0") == "1"
def first_override(
     calculations["hasbayonet"] = "YES" if has_bayonet else "NO"
    overrides: dict[str, str],
    *keys: str,
     default: str = "",
) -> str:
     for key in keys:
        value = overrides.get(key.lower(), "")
        if value:
            return value
    return default


    file_base_name = os.path.splitext(file_name)[0]
    rifle_grenade_file = os.path.join(folder_path, f"{file_base_name}_riflegrenade.txt")
    has_rifle_grenade = os.path.exists(rifle_grenade_file)
    calculations["hasriflegrenade"] = "YES" if has_rifle_grenade else "NO"


     bullet_weight = float(variables.get("bullet_weight", "0"))
def apply_section_overrides(page_text: str, overrides: dict[str, str]) -> str:
     weight = float(variables.get("weight", "0"))
     full_name = first_override(overrides, "full_name")
    date = first_override(overrides, "date")
    manufacturer = first_override(overrides, "manufacturer")
    barrel_length = first_override(overrides, "barrel_length")
     total_length = first_override(overrides, "total_length")
    description = first_override(overrides, "description")
    gameplay = first_override(overrides, "gameplay", "in_game", "in_game_function")
    history = first_override(overrides, "history")
    sources = first_override(overrides, "sources")
    gallery = first_override(overrides, "gallery")
    videos = first_override(overrides, "videos")


     calculations["bullet_weight"] = kg_to_g(bullet_weight)
     if full_name:
     calculations["bullet_weightingr"] = kg_to_grains(bullet_weight)
        page_text = page_text.replace("|FN||", f"|{full_name}||", 1)
     calculations["weightinlbs"] = kg_to_lbs(weight)
    if date:
        page_text = page_text.replace("||D8||", f"||{date}||", 1)
    if manufacturer:
        page_text = page_text.replace("||ARM||", f"||{manufacturer}||", 1)
    if barrel_length or total_length:
        page_text = page_text.replace(
            "|| in ( mm)|| in ( mm)||",
            f"||{barrel_length or ' in ( mm)'}||{total_length or ' in ( mm)'}||",
            1,
        )
    if description:
        page_text = page_text.replace("DESCRIPTION GOES HERE", description, 1)
    if gameplay:
        page_text = page_text.replace("GAMEPLAY GOES HERE", gameplay, 1)
    if history:
        page_text = page_text.replace("TEXT GOES HERE", history, 1)
     if sources:
        source_lines = "\n".join(
            line if line.startswith("*") else f"* {line}"
            for line in sources.splitlines()
            if line.strip()
        )
        page_text = re.sub(
            r"===Sources===\n\* \[SOURCE URL Title \| Publisher\]",
            f"===Sources===\n{source_lines}",
            page_text,
            count=1,
        )
    if gallery:
        page_text = re.sub(
            r"<gallery mode=\"packed\" heights=\"400px\">\n.*?\n\s*</gallery>",
            f'<gallery mode="packed" heights="400px">\n{gallery}\n    </gallery>',
            page_text,
            count=1,
            flags=re.DOTALL,
        )
     if videos:
        video_lines = "\n".join(
            line if "{{#ev:youtube|" in line else f"    {{{{#ev:youtube|{line.strip()}}}}}"
            for line in videos.splitlines()
            if line.strip()
        )
        if "<!-- VIDEOS_GO_HERE -->" in page_text:
            page_text = page_text.replace("<!-- VIDEOS_GO_HERE -->", video_lines, 1)
        else:
            page_text = re.sub(
                r"\{\{#ev:youtube\|[^}]*\}\}",
                video_lines,
                page_text,
                count=1,
            )
    return page_text


    calculations["extrabulletchamber"] = variables.get("extrabulletchamber", "0")


     calculations["filename"] = file_base_name
def determine_weapon_type(values: dict[str, str], info: LoadoutInfo) -> str:
     for group in GROUP_TO_TYPE:
        if group in info.groups:
            return GROUP_TO_TYPE[group]
    raw_type = values.get("weapontype", "")
    return WEAPON_TYPE_MAP.get(raw_type, raw_type or "Unknown")


    printname = variables.get("printname", "").lstrip("#")
    printname_english = get_printname_english(printname)
    calculations["printnameenglish"] = printname_english if printname_english else printname


     origin = variables.get("origin", "")
def build_values(
     calculations["origin"] = get_origin(origin)
    script_path: Path,
    values: dict[str, str],
    tokens: dict[str, str],
    loadout: dict[str, LoadoutInfo],
     resource_dir: Path,
    overrides: dict[str, str],
     script_index: dict[str, str],
) -> tuple[str, dict[str, str], LoadoutInfo]:
    script_name = script_path.stem
    info = loadout.get(script_name, LoadoutInfo())


     # Caliber / primary_ammo via ammo_id_display lookup
     print_token = values.get("printname", script_name)
    ammo_id_display = variables.get("ammo_id_display", "").strip()
    token_name = print_token.lstrip("#")
     ammo_display_name = None
     display_title = lookup_token(tokens, print_token, token_name)
     if ammo_id_display:
     indexed_title = script_index.get(script_name.lower(), "")
        ammo_display_name = lookup_english_token(ammo_id_display)
     page_title = first_override(
     if ammo_display_name:
         overrides,
         calculations["primary_ammo"] = ammo_display_name
         "page_title",
         calculations["caliber"] = ammo_display_name
         "title",
    else:
         default=indexed_title or display_title,
         calculations["primary_ammo"] = variables.get("primary_ammo", "N/A")
    )
         calculations["caliber"] = variables.get("primary_ammo", "N/A")


     # Presence in various files
     ammo_token = values.get("ammo_id_display", "")
    in_main = file_base_name in loadout_main
     ammo_display = lookup_token(tokens, ammo_token, "")
     in_zombie = file_base_name in loadout_zombie
     primary_ammo = ammo_display or values.get("primary_ammo", "N/A")
     in_special = file_base_name in loadout_special
     if primary_ammo and primary_ammo != "N/A" and not primary_ammo.startswith("[["):
     in_gamemodes = file_contains_weapon(paths["gamemodes_path"], file_base_name)
        primary_ammo = f"[[{primary_ammo}]]"


     # Faction logic: prefer main; else zombie; else special
     origin_token = values.get("origin", "")
    factions_set = set()
     origin = lookup_token(tokens, origin_token, "")
     classes_list = []
     if not origin:
     if in_main:
         origin = origin_token.lstrip("#").replace("_", " ").title() or "N/A"
         factions_set = set(loadout_main[file_base_name]["factions"])
        classes_list = list(loadout_main[file_base_name]["classes"])
    elif in_zombie:
        factions_set = set(loadout_zombie[file_base_name]["factions"])
        classes_list = []  # replace class block for non-main
    elif in_special:
        factions_set = set(loadout_special[file_base_name]["factions"])
        classes_list = []  # replace class block for non-main


     # faction string for placeholder
     base_damage = float_value(values, "damagegeneric")
     if len(factions_set) == 1:
     damage_keys = {
         calculations["faction"] = next(iter(factions_set))
         "damageheadmultiplier": float_value(values, "damageheadmultiplier", 1.0),
    elif len(factions_set) > 1:
        "damagechestmultiplier": float_value(values, "damagechestmultiplier", 1.0),
         calculations["faction"] = "/".join(sorted(factions_set))
         "damagestomachmultiplier": float_value(values, "damagestomachmultiplier", 1.0),
    else:
        "damagelegmultiplier": float_value(values, "damagelegmultiplier", 1.0),
         calculations["faction"] = "N/A"
         "damagearmmultiplier": float_value(values, "damagearmmultiplier", 1.0),
    }


     calculations["factions_set"] = factions_set
     bullet_weight_kg = float_value(values, "bullet_weight")
     calculations["classes_list"] = classes_list
    weight_kg = float_value(values, "weight")
     calculations["in_main_loadout"] = in_main
     extra_bullet_chamber = values.get("extrabulletchamber", "0")
     calculations["in_zombie_loadout"] = in_zombie
     clip_text = format_clip_size(values.get("clip_size", ""), extra_bullet_chamber)
     calculations["in_special_loadout"] = in_special
     clip2_text = format_clip_size(values.get("clip2_size", ""), "0")
    calculations["in_gamemodes"] = in_gamemodes
     if clip2_text != "N/A" and values.get("secondary_ammo", "None") != "None":
        clip_text = f"{clip_text}<br>{clip2_text}"


     # Build category lines if not in main loadout
     rifle_grenade_path = script_path.with_name(f"{script_name}_riflegrenade.txt")
     if not in_main:
    gamemodes_path = resource_dir.parent / "gamemodes.txt"
        category_lines = []
     in_gamemodes = file_contains_token(gamemodes_path, script_name)
    category_lines: list[str] = []
    if "main" not in info.sources:
         if in_gamemodes:
         if in_gamemodes:
             category_lines.append('[[Gun Game]]<br>')
             category_lines.append("[[Gun Game]]<br>")
         if in_zombie:
         if "zombie" in info.sources:
             category_lines.append('[[Zombies]]<br>')
             category_lines.append("[[Zombies]]<br>")
         if in_special:
         if "special" in info.sources:
             category_lines.append('[[Special Loadout]]<br>')
             category_lines.append("[[Special Loadout]]<br>")
        calculations["category_lines"] = "".join(category_lines)
    else:
        calculations["category_lines"] = ""


     return calculations
     weapon_type = determine_weapon_type(values, info)
    muzzle_velocity = values.get("muzzle_velocity") or values.get("gl_velocity") or "N/A"
    image_file = first_override(overrides, "image_file", default=f"{page_title}.png")
    icon_file = first_override(overrides, "icon_file", default=script_icon_filename(script_name))


# Case-sensitive mapping for image filenames by class, and display labels
    calculated = {
CLASS_IMAGE_MAP = {
        "filename": script_name,
    "assault": "Class_Assault.png",
        "printname": image_file.removeprefix("File:").rsplit(".", 1)[0],
    "medic": "Class_medic.png",
        "printnameenglish": page_title,
    "gunner": "Class_Gunner.png",
        "primary_ammo": primary_ammo,
    "sniper": "Class_sniper.png",
        "caliber": primary_ammo,
    "engineer": "Class_Engineer.png",
        "origin": origin,
    "radioman": "Class_radioman.png",
        "weapontype": weapon_type,
}
        "clip_size": clip_text,
        "extrabulletchamber": "",
        "damagegeneric": fmt_float(base_damage) if base_damage else "N/A",
        "damagegenericxdamageheadmultiplier": format_damage(base_damage, damage_keys["damageheadmultiplier"]) if base_damage else "N/A",
        "damagegenericxdamagechestmultiplier": format_damage(base_damage, damage_keys["damagechestmultiplier"]) if base_damage else "N/A",
        "damagegenericxdamagestomachmultiplier": format_damage(base_damage, damage_keys["damagestomachmultiplier"]) if base_damage else "N/A",
        "damagegenericxdamagelegmultiplier": format_damage(base_damage, damage_keys["damagelegmultiplier"]) if base_damage else "N/A",
        "damagegenericxdamagearmmultiplier": format_damage(base_damage, damage_keys["damagearmmultiplier"]) if base_damage else "N/A",
        "hasbayonet": "YES" if values.get("hasbayonet", "0") == "1" else "NO",
        "hasriflegrenade": "YES" if rifle_grenade_path.exists() else "NO",
        "bullet_weight": fmt_float(bullet_weight_kg * 1000) if bullet_weight_kg else "N/A",
        "bullet_weightingr": fmt_float(bullet_weight_kg * 15432.36) if bullet_weight_kg else "N/A",
        "weightinlbs": fmt_float(weight_kg * 2.20462) if weight_kg else "N/A",
        "muzzle_velocity": muzzle_velocity,
        "faction": "/".join(sorted(info.factions)) if info.factions else "N/A",
        "category_lines": "".join(category_lines),
        "icon_file": icon_file.removeprefix("File:"),
    }


# Enforced output order
    for key, value in overrides.items():
CLASS_ORDER = ["assault", "medic", "gunner", "sniper", "engineer", "radioman"]
        if value:
            calculated[key] = value


def build_classes_markup(classes_list):
     return page_title, calculated, info
     if not classes_list:
        return '[[File:Class_unknown.png|50px]] <b>[[unknown]]</b><br>'
    parts = []
    present = set(classes_list)
    for cls in CLASS_ORDER:
        if cls in present:
            img = CLASS_IMAGE_MAP.get(cls, f"Class_{cls}.png")
            label = cls.capitalize()
            parts.append(f'[[File:{img}|50px]] <b>[[{label}]]</b><br>')
    return "".join(parts)


def replace_placeholders(template, variables, calculations):
    result = template


    classes_pattern = r'\[\[File:Class_""class""\.png\|50px\]\] <b>\[\[""class""\]\]</b><br>'
def render_page(template: str, values: dict[str, str], calculated: dict[str, str], info: LoadoutInfo) -> str:
    result = template.replace("rolspan=", "colspan=")


     # If not in main loadout and we have categories, replace with category lines;
     classes_pattern = (
    # otherwise, render the ordered classes markup.
        r'\[\[File:Class_""class""\.png\|50px\]\] '
    if (not calculations.get("in_main_loadout", False)) and calculations.get("category_lines"):
        r'<b>\[\[""class""\]\]</b><br>'
         result = re.sub(classes_pattern, calculations["category_lines"], result)
    )
    if calculated.get("category_lines"):
         classes_markup = calculated["category_lines"]
     else:
     else:
         result = re.sub(classes_pattern, build_classes_markup(calculations.get("classes_list", [])), result)
         classes_markup = build_classes_markup(info.classes)
    result = re.sub(classes_pattern, classes_markup, result)


     for placeholder in re.findall(r'""([^"]+)""', result):
     for placeholder in sorted(set(re.findall(r'""([^"]+)""', result))):
         key_lower = placeholder.lower()
         key_lower = placeholder.lower()
         if key_lower == "class":
         if key_lower == "class":
             continue
             continue
        value = calculated.get(key_lower, values.get(key_lower, "N/A"))
        result = result.replace(f'""{placeholder}""', str(value))
    image_stem = calculated["printname"]
    icon_file = calculated["icon_file"]
    result = result.replace(f"[[File:{image_stem}.svg|512px]]", f"[[File:{icon_file}|512px]]")
    factions = info.factions
    if "US" not in factions:
        result = result.replace("[[File:Flag_us_new.png|50px]]", "")
    if "VC" not in factions:
        result = result.replace("[[File:Flag_vc_new.png|50px]]", "")
    result = result.replace("[[#", "[[")
    result = result.replace("|#", "|")
    result = result.replace("#weapon_", "weapon_")
    return result
def api_query(params: dict[str, str]) -> dict:
    url = API_URL + "?" + urllib.parse.urlencode(params)
    with urllib.request.urlopen(url, timeout=30) as response:
        import json
        return json.loads(response.read().decode("utf-8"))


        if key_lower == "filename":
            value = calculations["filename"]
        elif key_lower == "bullet_weightingr":
            value = str(calculations["bullet_weightingr"])
        elif key_lower == "extrabulletchamber":
            extra_bullet_chamber = calculations.get("extrabulletchamber", "0")
            value = "" if extra_bullet_chamber == "0" else "[[]]"
        else:
            value = calculations.get(key_lower, variables.get(key_lower, "N/A"))
        result = result.replace(f'""{placeholder}""', str(value))


    # Handle ammo column +/- marker
def existing_titles(titles: list[str]) -> set[str]:
     result = re.sub(
     existing: set[str] = set()
        r"(\d+)/(\d+)(\[\[\]\])?",
    for index in range(0, len(titles), 50):
         lambda m: m.group(1)
         group = titles[index : index + 50]
         + (
         data = api_query(
             f"[[+{calculations['extrabulletchamber']}]]"
             {
            if calculations["extrabulletchamber"] == "2"
                "action": "query",
            else f"[[+1]]" if calculations["extrabulletchamber"] == "1" else ""
                "titles": "|".join(group),
                "format": "json",
                "formatversion": "2",
                "redirects": "1",
            }
         )
         )
         + "/"
 
         + m.group(2),
        query = data.get("query", {})
         result,
        normalized = {
            item["from"].replace("_", " ").casefold(): item["to"]
            for item in query.get("normalized", [])
        }
        redirects = {
            item["from"].replace("_", " ").casefold(): item["to"]
            for item in query.get("redirects", [])
         }
        page_status = {
            page["title"].replace("_", " ").casefold(): not page.get("missing")
            for page in query.get("pages", [])
        }
 
         def resolve(title: str) -> str:
            current = normalized.get(title.replace("_", " ").casefold(), title)
            seen: set[str] = set()
            while True:
                current_key = current.replace("_", " ").casefold()
                if current_key in seen or current_key not in redirects:
                    return current_key
                seen.add(current_key)
                current = redirects[current_key]
 
        for title in group:
            if page_status.get(resolve(title), False):
                existing.add(title.replace("_", " ").casefold())
        for page in query.get("pages", []):
            if not page.get("missing"):
                existing.add(page["title"].replace("_", " ").casefold())
    return existing
 
 
def collect_scripts(scripts_dir: Path, only: list[str] | None, limit: int | None) -> list[Path]:
    scripts = sorted(scripts_dir.glob("weapon_*.txt"))
    if only:
        script_needles = {item.lower() for item in only if item.lower().startswith("weapon_")}
        if script_needles:
            scripts = [script for script in scripts if script.stem.lower() in script_needles]
    if limit is not None:
        scripts = scripts[:limit]
    return scripts
 
 
def prepare_pages(args: argparse.Namespace) -> list[WeaponPage]:
    game_dir = args.game_dir
    scripts_dir = args.scripts_dir or game_dir / "scripts"
    resource_dir = args.resource_dir or game_dir / "resource"
    english_path = resource_dir / "vietnam_english.txt"
 
    if not scripts_dir.exists():
        raise FileNotFoundError(f"Scripts folder does not exist: {scripts_dir}")
    if not resource_dir.exists():
        raise FileNotFoundError(f"Resource folder does not exist: {resource_dir}")
 
    template = load_template(args)
    script_index = load_script_index(args)
    tokens = parse_token_file(english_path)
    overrides_by_key = load_overrides(args.overrides)
    loadout = merge_loadout_maps(
         [
            parse_loadout(resource_dir / "vietnam_loadout.txt", "main"),
            parse_loadout(resource_dir / "vietnam_loadout_zombie.txt", "zombie"),
            parse_loadout(resource_dir / "vietnam_loadout_special.txt", "special"),
        ]
     )
     )


     # Remove faction flags that don't apply
     scripts = collect_scripts(scripts_dir, args.only, args.limit)
    factions_set = calculations.get("factions_set", set())
     generated: list[tuple[Path, str, str, dict[str, str], LoadoutInfo, dict[str, str]]] = []
     if "US" not in factions_set:
        result = result.replace('[[File:Flag_us_new.png|50px]]', '')
    if "VC" not in factions_set:
        result = result.replace('[[File:Flag_vc_new.png|50px]]', '')


     return result
     for script_path in scripts:
        values = parse_top_level_pairs(script_path)
        script_name = script_path.stem
        overrides = overrides_by_key.get(script_name.lower(), {})
        title, calculated, info = build_values(
            script_path,
            values,
            tokens,
            loadout,
            resource_dir,
            overrides,
            script_index,
        )
        overrides = overrides or overrides_by_key.get(title.lower(), {})
        if overrides:
            title, calculated, info = build_values(
                script_path,
                values,
                tokens,
                loadout,
                resource_dir,
                overrides,
                script_index,
            )


def process_files(template, scripts_path, output_path, loadout_main, loadout_zombie, loadout_special, paths):
        if args.only:
    for root, _, files in os.walk(scripts_path):
            needles = {item.lower() for item in args.only}
        for file in files:
            word_needles = {item.lower().replace("_", " ") for item in args.only}
             if not (file.endswith(".txt") and file.startswith("weapon_")):
             if (
                script_name.lower() not in needles
                and script_name.lower().replace("_", " ") not in word_needles
                and title.lower() not in needles
                and title.lower().replace("_", " ") not in word_needles
            ):
                 continue
                 continue


             input_file = os.path.join(root, file)
        page_text = render_page(template, values, calculated, info)
            file_name = os.path.basename(file)
        page_text = apply_section_overrides(page_text, overrides)
             variables = parse_file(input_file)
        generated.append((script_path, title, page_text, calculated, info, values))
             calculations = preprocess_calculations(variables, file_name, root, loadout_main, loadout_zombie, loadout_special, paths)
 
             processed_template = replace_placeholders(template, variables, calculations)
    existing = set()
    if not args.no_wiki_check:
        existing = existing_titles([item[1] for item in generated])
 
    pages: list[WeaponPage] = []
    args.output_dir.mkdir(parents=True, exist_ok=True)
    if args.clean_output:
        for old_preview in args.output_dir.glob("*.wiki"):
            old_preview.unlink()
    for script_path, title, page_text, calculated, info, values in generated:
        exists = title.replace("_", " ").casefold() in existing
        status = "exists" if exists else "missing"
        should_write = args.include_existing or not exists or args.no_wiki_check
        output_path = args.output_dir / f"{title_to_filename(title)}.wiki"
        if should_write:
             output_path.write_text(page_text, encoding="utf-8")
 
        weapon_type = calculated["weapontype"]
        pages.append(
            WeaponPage(
                script_name=script_path.stem,
                title=title,
                status=status,
                output_path=output_path if should_write else None,
                page_text=page_text,
                weapon_type=weapon_type,
                factions="/".join(sorted(info.factions)),
                classes="/".join(sorted(info.classes)),
                loadout_sources="/".join(sorted(info.sources)),
                note=SECTION_BY_TYPE.get(weapon_type, "Miscellaneous"),
             )
        )
 
    return pages
 
 
def write_report(pages: list[WeaponPage], report_path: Path) -> None:
    report_path.parent.mkdir(parents=True, exist_ok=True)
    with report_path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(
             handle,
            fieldnames=[
                "status",
                "script_name",
                "title",
                "weapon_type",
                "factions",
                "classes",
                "loadout_sources",
                "section_hint",
                "preview_file",
            ],
        )
        writer.writeheader()
        for page in pages:
            writer.writerow(
                {
                    "status": page.status,
                    "script_name": page.script_name,
                    "title": page.title,
                    "weapon_type": page.weapon_type,
                    "factions": page.factions,
                    "classes": page.classes,
                    "loadout_sources": page.loadout_sources,
                    "section_hint": page.note,
                    "preview_file": str(page.output_path) if page.output_path is not None else "",
                }
             )
 
 
def upload_pages(args: argparse.Namespace, pages: list[WeaponPage]) -> None:
    os.environ.setdefault("PYWIKIBOT_DIR", str(ROOT / "pywikibot"))
    if args.username:
        os.environ["MCV_WIKI_USER"] = args.username
 
    try:
        import pywikibot
    except ImportError as exc:
        raise RuntimeError(
            "Pywikibot is required for --upload. Install it with "
            "`python -m pip install pywikibot`, then run this command again."
        ) from exc
 
    site = pywikibot.Site("en", "mcvwiki")
    site.login()


            output_file = os.path.join(output_path, file_name)
    for page_info in pages:
             with open(output_file, "w", encoding="utf-8") as f:
        if page_info.status == "exists" and not args.overwrite_existing:
                f.write(processed_template)
             continue
        if page_info.status == "missing" and not page_info.loadout_sources and not args.upload_unlisted:
            print(f"Skip unlisted missing page: {page_info.title}")
            continue


# Paths (edit as needed)
        page = pywikibot.Page(site, page_info.title)
template_path = r"C:\MCV_PROJECTS\wiki\WikiTemplate.txt"
        if page.exists() and not args.overwrite_existing:
scripts_path = r"C:\Program Files (x86)\Steam\steamapps\common\Military Conflict - Vietnam\vietnam\scripts"
            print(f"Skip existing page: {page_info.title}")
output_path = r"C:\MCV_PROJECTS\wiki\output"
            continue
loadout_path = r"C:\Program Files (x86)\Steam\steamapps\common\Military Conflict - Vietnam\vietnam\resource\vietnam_loadout.txt"
zombie_loadout_path = r"C:\Program Files (x86)\Steam\steamapps\common\Military Conflict - Vietnam\vietnam\resource\vietnam_loadout_zombie.txt"
special_loadout_path = r"C:\Program Files (x86)\Steam\steamapps\common\Military Conflict - Vietnam\vietnam\resource\vietnam_loadout_special.txt"
gamemodes_path = r"C:\Program Files (x86)\Steam\steamapps\common\Military Conflict - Vietnam\vietnam\gamemodes.txt"


os.makedirs(output_path, exist_ok=True)
        page.text = page_info.page_text
        page.save(summary=args.summary, minor=False)
        print(f"Uploaded: {page_info.title}")


template = load_template(template_path)
loadout_main = parse_vietnam_loadout(loadout_path)
loadout_zombie = parse_vietnam_loadout(zombie_loadout_path)
loadout_special = parse_vietnam_loadout(special_loadout_path)


paths = {
def main() -> int:
     "special_loadout_path": special_loadout_path,
    args = parse_args()
     "gamemodes_path": gamemodes_path,
    pages = prepare_pages(args)
}
    write_report(pages, args.report)
 
    missing = sum(1 for page in pages if page.status == "missing")
    existing = sum(1 for page in pages if page.status == "exists")
    previews = sum(1 for page in pages if page.output_path is not None)
 
    print(f"Checked {len(pages)} weapon scripts.")
     print(f"Missing pages: {missing}; existing pages: {existing}.")
    print(f"Wrote {previews} preview files to: {args.output_dir}")
     print(f"Report: {args.report}")
 
    if args.upload:
        upload_pages(args, pages)
    else:
        print("Dry run only. Add --upload to create missing wiki pages.")


process_files(template, scripts_path, output_path, loadout_main, loadout_zombie, loadout_special, paths)
    return 0


def remove_hash_from_files(directory):
    if not os.path.exists(directory):
        print(f"The directory '{directory}' does not exist.")
        return
    for filename in os.listdir(directory):
        file_path = os.path.join(directory, filename)
        if os.path.isfile(file_path) and filename.endswith('.txt'):
            try:
                with open(file_path, 'r', encoding='utf-8') as file:
                    content = file.read()
                updated_content = content.replace('#', '')
                with open(file_path, 'w', encoding='utf-8') as file:
                    file.write(updated_content)
                print(f"Processed file: {filename}")
            except Exception as e:
                print(f"Error processing file {filename}: {e}")


directory = output_path
if __name__ == "__main__":
remove_hash_from_files(directory)
    raise SystemExit(main())
print("Processing complete.")

Revision as of 22:56, 14 June 2026

from __future__ import annotations

import argparse import csv import os import re import sys import urllib.parse import urllib.request from dataclasses import dataclass, field from pathlib import Path from typing import Iterable


ROOT = Path(__file__).resolve().parent DEFAULT_GAME_DIR = Path(r"C:\MCV\repos\mcv-game\vietnam") DEFAULT_OUTPUT_DIR = ROOT / "generated_weapon_pages" DEFAULT_REPORT = ROOT / "weapon_page_creation_report.csv" DEFAULT_TEMPLATE_URL = (

   "https://wiki.militaryconflictvietnam.com/"
   "index.php?title=WikiTemplate.txt&action=raw"

) DEFAULT_SCRIPT_INDEX_URL = (

   "https://wiki.militaryconflictvietnam.com/"
   "index.php?title=List:Weapon_Scripts_By_Name&action=raw"

) API_URL = "https://wiki.militaryconflictvietnam.com/api.php" DEFAULT_SUMMARY = "Create weapon page from game script data"

FACTIONS = {"US", "VC"} CLASSES = {"assault", "medic", "gunner", "sniper", "engineer", "radioman"} CLASS_ORDER = ["assault", "medic", "gunner", "sniper", "engineer", "radioman"] CLASS_IMAGE_MAP = {

   "assault": "Class_Assault.png",
   "medic": "Class_medic.png",
   "gunner": "Class_Gunner.png",
   "sniper": "Class_sniper.png",
   "engineer": "Class_Engineer.png",
   "radioman": "Class_radioman.png",

}

GROUP_TO_TYPE = {

   "assault_rifles": "Assault Rifle",
   "battle_rifles": "Battle Rifle",
   "bolt_actions": "Bolt Action Rifle",
   "carbines": "Carbine",
   "grenade_launchers": "Grenade Launcher",
   "grenades": "Grenade",
   "lmgs": "Light Machine Gun",
   "machine_pistols": "Machine Pistol",
   "melee": "Melee",
   "pistols": "Pistol",
   "revolvers": "Revolver",
   "rifle_grenades": "Rifle Grenade",
   "rocket_launchers": "Rocket Launcher",
   "shotguns": "Shotgun",
   "sniper_rifles": "Sniper Rifle",
   "smgs": "Submachine Gun",

}

WEAPON_TYPE_MAP = {

   "BattleRifle": "Battle Rifle",
   "BoltActionRifle": "Bolt Action Rifle",
   "C4": "C4",
   "Carbine": "Carbine",
   "Crossbow": "Crossbow",
   "Equipment": "Equipment",
   "Fists": "Fists",
   "Flamethrower": "Flamethrower",
   "Flaregun": "Flare Gun",
   "Grenade": "Grenade",
   "GrenadeLauncher": "Grenade Launcher",
   "Incendiary": "Incendiary",
   "Machinegun": "Light Machine Gun",
   "MachinePistol": "Machine Pistol",
   "Melee": "Melee",
   "Mine": "Mine",
   "Pistol": "Pistol",
   "Ptrd": "Anti-Tank Rifle",
   "Revolver": "Revolver",
   "Rifle": "Rifle",
   "RifleGrenade": "Rifle Grenade",
   "RocketLauncher": "Rocket Launcher",
   "Shotgun": "Shotgun",
   "SmokeGrenade": "Smoke Grenade",
   "SniperRifle": "Sniper Rifle",
   "SubMachinegun": "Submachine Gun",

}

SECTION_BY_TYPE = {

   "Assault Rifle": "Assault Rifles",
   "Battle Rifle": "Battle Rifles",
   "Bolt Action Rifle": "Bolt Action",
   "Carbine": "Carbines",
   "Grenade Launcher": "Grenade Launchers",
   "Grenade": "Grenades and Throwables",
   "Incendiary": "Grenades and Throwables",
   "Smoke Grenade": "Grenades and Throwables",
   "Light Machine Gun": "Light Machine Guns",
   "Machine Pistol": "Machine Pistols",
   "Melee": "Melee",
   "Pistol": "Pistols",
   "Revolver": "Revolvers",
   "Rifle Grenade": "Rifle Grenades",
   "Rocket Launcher": "Rocket Launchers",
   "Shotgun": "Shotguns",
   "Sniper Rifle": "Sniper Rifles",
   "Submachine Gun": "Submachine Guns",

}


@dataclass class LoadoutInfo:

   factions: set[str] = field(default_factory=set)
   classes: set[str] = field(default_factory=set)
   groups: set[str] = field(default_factory=set)
   sources: set[str] = field(default_factory=set)
   def absorb(self, other: "LoadoutInfo") -> None:
       self.factions.update(other.factions)
       self.classes.update(other.classes)
       self.groups.update(other.groups)
       self.sources.update(other.sources)


@dataclass class WeaponPage:

   script_name: str
   title: str
   status: str
   output_path: Path | None
   page_text: str
   weapon_type: str
   factions: str
   classes: str
   loadout_sources: str
   note: str = ""


def parse_args() -> argparse.Namespace:

   parser = argparse.ArgumentParser(
       description=(
           "Generate and optionally create MCV wiki weapon pages from game "
           "weapon script data."
       )
   )
   parser.add_argument(
       "--game-dir",
       type=Path,
       default=DEFAULT_GAME_DIR,
       help=f"Vietnam game folder. Default: {DEFAULT_GAME_DIR}",
   )
   parser.add_argument(
       "--scripts-dir",
       type=Path,
       help="Folder containing weapon_*.txt scripts. Overrides --game-dir.",
   )
   parser.add_argument(
       "--resource-dir",
       type=Path,
       help="Folder containing vietnam_english.txt and loadout files.",
   )
   parser.add_argument(
       "--template-path",
       type=Path,
       help="Local WikiTemplate.txt path. If omitted, the live wiki template is used.",
   )
   parser.add_argument(
       "--template-url",
       default=DEFAULT_TEMPLATE_URL,
       help="Raw wiki template URL used when --template-path is omitted.",
   )
   parser.add_argument(
       "--script-index-url",
       default=DEFAULT_SCRIPT_INDEX_URL,
       help="Raw wiki page mapping weapon script names to existing page titles.",
   )
   parser.add_argument(
       "--no-script-index",
       action="store_true",
       help="Do not use the wiki Weapon Script Name index as a title/duplicate guard.",
   )
   parser.add_argument(
       "--output-dir",
       type=Path,
       default=DEFAULT_OUTPUT_DIR,
       help=f"Folder for generated .wiki preview files. Default: {DEFAULT_OUTPUT_DIR}",
   )
   parser.add_argument(
       "--clean-output",
       action="store_true",
       help="Delete existing .wiki preview files in --output-dir before writing new previews.",
   )
   parser.add_argument(
       "--report",
       type=Path,
       default=DEFAULT_REPORT,
       help=f"CSV report path. Default: {DEFAULT_REPORT}",
   )
   parser.add_argument(
       "--overrides",
       type=Path,
       help="Optional CSV for page titles, image names, text sections, and real-world fields.",
   )
   parser.add_argument(
       "--only",
       action="append",
       help=(
           "Only process a script name or wiki title. Example: --only weapon_ak47 "
           "or --only AK-47. May be passed more than once."
       ),
   )
   parser.add_argument(
       "--limit",
       type=int,
       help="Only process the first N matching scripts.",
   )
   parser.add_argument(
       "--include-existing",
       action="store_true",
       help="Also write preview files for pages that already exist.",
   )
   parser.add_argument(
       "--no-wiki-check",
       action="store_true",
       help="Do not check whether pages already exist on the wiki.",
   )
   parser.add_argument(
       "--upload",
       action="store_true",
       help="Create missing pages on the wiki. Without this, this is a dry run.",
   )
   parser.add_argument(
       "--upload-unlisted",
       action="store_true",
       help=(
           "With --upload, also create missing pages that were not found in the "
           "Vietnam, zombie, or special loadout files."
       ),
   )
   parser.add_argument(
       "--overwrite-existing",
       action="store_true",
       help="With --upload, update existing pages too. Use carefully.",
   )
   parser.add_argument(
       "--username",
       default=os.environ.get("MCV_WIKI_USER", "Skizmophonic"),
       help="Wiki username for Pywikibot upload/login.",
   )
   parser.add_argument(
       "--summary",
       default=DEFAULT_SUMMARY,
       help="Edit summary for uploaded pages.",
   )
   return parser.parse_args()


def read_text(path: Path) -> str:

   encodings = ("utf-8-sig", "utf-16", "cp1252", "latin-1")
   for encoding in encodings:
       try:
           return path.read_text(encoding=encoding)
       except UnicodeDecodeError:
           continue
   return path.read_text(errors="replace")


def fetch_text(url: str) -> str:

   with urllib.request.urlopen(url, timeout=30) as response:
       return response.read().decode("utf-8-sig")


def load_template(args: argparse.Namespace) -> str:

   if args.template_path:
       return read_text(args.template_path)
   return fetch_text(args.template_url)


def load_script_index(args: argparse.Namespace) -> dict[str, str]:

   if args.no_script_index:
       return {}
   text = fetch_text(args.script_index_url)
   index: dict[str, str] = {}
   for match in re.finditer(r"\|\s*(weapon_[^\s|]+)\s*\n\|\s*\[\[([^\]|]+)", text):
       script_name, title = match.groups()
       index.setdefault(script_name.lower(), title.strip())
   return index


def strip_line_comment(line: str) -> str:

   in_quote = False
   i = 0
   while i < len(line) - 1:
       char = line[i]
       if char == '"':
           in_quote = not in_quote
       if not in_quote and line[i : i + 2] == "//":
           return line[:i]
       i += 1
   return line


def parse_top_level_pairs(path: Path) -> dict[str, str]:

   values: dict[str, str] = {}
   pending_key: str | None = None
   stack: list[str] = []
   for raw_line in read_text(path).splitlines():
       line = strip_line_comment(raw_line).strip()
       if not line:
           continue
       key_only = re.fullmatch(r'"([^"]+)"', line)
       if key_only:
           pending_key = key_only.group(1)
           continue
       bare_key_only = re.fullmatch(r"([A-Za-z_][\w]*)", line)
       if bare_key_only:
           pending_key = bare_key_only.group(1)
           continue
       inline_open = re.fullmatch(r'([A-Za-z_][\w]*)\s*\{', line)
       quoted_inline_open = re.fullmatch(r'"([^"]+)"\s*\{', line)
       if inline_open or quoted_inline_open:
           stack.append((inline_open or quoted_inline_open).group(1))
           pending_key = None
           continue
       if line == "{":
           stack.append(pending_key or "__anon__")
           pending_key = None
           continue
       if line == "}":
           if stack:
               stack.pop()
           pending_key = None
           continue
       pair = re.fullmatch(r'"([^"]+)"\s+"([^"]*)"', line)
       if not pair:
           continue
       if not stack or stack == ["WeaponData"]:
           key, value = pair.groups()
           values[key.lower()] = value
   return values


def parse_token_file(path: Path) -> dict[str, str]:

   tokens: dict[str, str] = {}
   if not path.exists():
       return tokens
   for raw_line in read_text(path).splitlines():
       line = strip_line_comment(raw_line).strip()
       match = re.fullmatch(r'"([^"]+)"\s+"([^"]*)"', line)
       if match:
           key, value = match.groups()
           tokens[key.lower()] = value
   return tokens


def lookup_token(tokens: dict[str, str], token: str, default: str = "") -> str:

   key = token.lstrip("#").lower()
   return tokens.get(key, default)


def parse_loadout(path: Path, source_name: str) -> dict[str, LoadoutInfo]:

   mapping: dict[str, LoadoutInfo] = {}
   if not path.exists():
       return mapping
   pending_key: str | None = None
   stack: list[str] = []
   def current_faction() -> str | None:
       return next((item for item in reversed(stack) if item in FACTIONS), None)
   def current_class() -> str | None:
       return next((item for item in reversed(stack) if item in CLASSES), None)
   def current_group() -> str | None:
       return next((item for item in reversed(stack) if item in GROUP_TO_TYPE), None)
   for raw_line in read_text(path).splitlines():
       line = strip_line_comment(raw_line).strip()
       if not line:
           continue
       key_only = re.fullmatch(r'"([^"]+)"', line)
       if key_only:
           pending_key = key_only.group(1)
           continue
       if line == "{":
           stack.append(pending_key or "__anon__")
           pending_key = None
           continue
       if line == "}":
           if stack:
               stack.pop()
           pending_key = None
           continue
       inline_open = re.fullmatch(r'"([^"]+)"\s*\{', line)
       if inline_open:
           stack.append(inline_open.group(1))
           pending_key = None
           continue
       pair = re.fullmatch(r'"([^"]+)"\s+"([^"]*)"', line)
       if not pair:
           continue
       key = pair.group(1)
       if not key.startswith("weapon_"):
           continue
       info = mapping.setdefault(key, LoadoutInfo())
       faction = current_faction()
       cls = current_class()
       group = current_group()
       if faction:
           info.factions.add(faction)
       if cls:
           info.classes.add(cls)
       if group:
           info.groups.add(group)
       info.sources.add(source_name)
   return mapping


def merge_loadout_maps(maps: Iterable[dict[str, LoadoutInfo]]) -> dict[str, LoadoutInfo]:

   merged: dict[str, LoadoutInfo] = {}
   for mapping in maps:
       for weapon, info in mapping.items():
           merged.setdefault(weapon, LoadoutInfo()).absorb(info)
   return merged


def file_contains_token(path: Path, token: str) -> bool:

   if not path.exists():
       return False
   content = read_text(path)
   pattern = re.compile(r"\b" + re.escape(token) + r"\b")
   return pattern.search(content) is not None


def fmt_float(value: float, decimals: int = 2) -> str:

   text = f"{value:.{decimals}f}"
   return text.rstrip("0").rstrip(".") if "." in text else text


def float_value(values: dict[str, str], key: str, default: float = 0.0) -> float:

   try:
       return float(values.get(key.lower(), default))
   except (TypeError, ValueError):
       return default


def format_damage(base: float, multiplier: float) -> str:

   return fmt_float(round(base * multiplier, 2))


def format_clip_size(raw: str, extra_bullet_chamber: str = "0") -> str:

   raw = (raw or "").strip()
   if not raw or raw in {"-1", "-1/-1", "0/0"}:
       return "N/A"
   if "/" not in raw:
       return raw
   current, reserve = [part.strip() for part in raw.split("/", 1)]
   if current == "-1" and reserve not in {"-1", "0", ""}:
       current = "1"
   marker = ""
   if extra_bullet_chamber == "1":
       marker = "+1"
   elif extra_bullet_chamber and extra_bullet_chamber not in {"0", "-1"}:
       marker = f"[[+{extra_bullet_chamber}]]"
   return f"{current}{marker} / {reserve}"


def title_to_filename(title: str) -> str:

   safe = re.sub(r'[<>:"/\\|?*]+', "_", title)
   safe = re.sub(r"\s+", " ", safe).strip(" .")
   return safe or "untitled"


def script_icon_filename(script_name: str) -> str:

   return f"{script_name[0].upper()}{script_name[1:]}.svg"


def build_classes_markup(classes: set[str]) -> str:

   if not classes:
       return "File:Class unknown.png unknown
" parts: list[str] = [] for cls in CLASS_ORDER: if cls in classes: image = CLASS_IMAGE_MAP.get(cls, f"Class_{cls}.png") label = cls.capitalize() parts.append(f"[[File:{image}|50px]] [[{label}]]
") return "".join(parts)


def load_overrides(path: Path | None) -> dict[str, dict[str, str]]:

   if not path:
       return {}
   rows: dict[str, dict[str, str]] = {}
   with path.open(encoding="utf-8-sig", newline="") as handle:
       reader = csv.DictReader(handle)
       for row in reader:
           cleaned = {
               (key or "").strip().lower(): (value or "").strip()
               for key, value in row.items()
           }
           keys = [
               cleaned.get("script_name", ""),
               cleaned.get("weapon_script_name", ""),
               cleaned.get("title", ""),
               cleaned.get("page_title", ""),
           ]
           for key in keys:
               if key:
                   rows[key.lower()] = cleaned
   return rows


def first_override(

   overrides: dict[str, str],
   *keys: str,
   default: str = "",

) -> str:

   for key in keys:
       value = overrides.get(key.lower(), "")
       if value:
           return value
   return default


def apply_section_overrides(page_text: str, overrides: dict[str, str]) -> str:

   full_name = first_override(overrides, "full_name")
   date = first_override(overrides, "date")
   manufacturer = first_override(overrides, "manufacturer")
   barrel_length = first_override(overrides, "barrel_length")
   total_length = first_override(overrides, "total_length")
   description = first_override(overrides, "description")
   gameplay = first_override(overrides, "gameplay", "in_game", "in_game_function")
   history = first_override(overrides, "history")
   sources = first_override(overrides, "sources")
   gallery = first_override(overrides, "gallery")
   videos = first_override(overrides, "videos")
   if full_name:
       page_text = page_text.replace("|FN||", f"|{full_name}||", 1)
   if date:
       page_text = page_text.replace("||D8||", f"||{date}||", 1)
   if manufacturer:
       page_text = page_text.replace("||ARM||", f"||{manufacturer}||", 1)
   if barrel_length or total_length:
       page_text = page_text.replace(
           "|| in ( mm)|| in ( mm)||",
           f"||{barrel_length or ' in ( mm)'}||{total_length or ' in ( mm)'}||",
           1,
       )
   if description:
       page_text = page_text.replace("DESCRIPTION GOES HERE", description, 1)
   if gameplay:
       page_text = page_text.replace("GAMEPLAY GOES HERE", gameplay, 1)
   if history:
       page_text = page_text.replace("TEXT GOES HERE", history, 1)
   if sources:
       source_lines = "\n".join(
           line if line.startswith("*") else f"* {line}"
           for line in sources.splitlines()
           if line.strip()
       )
       page_text = re.sub(
           r"===Sources===\n\* \[SOURCE URL Title \| Publisher\]",
           f"===Sources===\n{source_lines}",
           page_text,
           count=1,
       )
   if gallery:
       page_text = re.sub(

r"

", f'

',

           page_text,
           count=1,
           flags=re.DOTALL,
       )
   if videos:
       video_lines = "\n".join(
           line if "Provided ID could not be validated."
           for line in videos.splitlines()
           if line.strip()
       )
       if "" in page_text:
           page_text = page_text.replace("", video_lines, 1)
       else:
           page_text = re.sub(
               r"\{\{#ev:youtube\|[^}]*\}\}",
               video_lines,
               page_text,
               count=1,
           )
   return page_text


def determine_weapon_type(values: dict[str, str], info: LoadoutInfo) -> str:

   for group in GROUP_TO_TYPE:
       if group in info.groups:
           return GROUP_TO_TYPE[group]
   raw_type = values.get("weapontype", "")
   return WEAPON_TYPE_MAP.get(raw_type, raw_type or "Unknown")


def build_values(

   script_path: Path,
   values: dict[str, str],
   tokens: dict[str, str],
   loadout: dict[str, LoadoutInfo],
   resource_dir: Path,
   overrides: dict[str, str],
   script_index: dict[str, str],

) -> tuple[str, dict[str, str], LoadoutInfo]:

   script_name = script_path.stem
   info = loadout.get(script_name, LoadoutInfo())
   print_token = values.get("printname", script_name)
   token_name = print_token.lstrip("#")
   display_title = lookup_token(tokens, print_token, token_name)
   indexed_title = script_index.get(script_name.lower(), "")
   page_title = first_override(
       overrides,
       "page_title",
       "title",
       default=indexed_title or display_title,
   )
   ammo_token = values.get("ammo_id_display", "")
   ammo_display = lookup_token(tokens, ammo_token, "")
   primary_ammo = ammo_display or values.get("primary_ammo", "N/A")
   if primary_ammo and primary_ammo != "N/A" and not primary_ammo.startswith("[["):
       primary_ammo = f"[[{primary_ammo}]]"
   origin_token = values.get("origin", "")
   origin = lookup_token(tokens, origin_token, "")
   if not origin:
       origin = origin_token.lstrip("#").replace("_", " ").title() or "N/A"
   base_damage = float_value(values, "damagegeneric")
   damage_keys = {
       "damageheadmultiplier": float_value(values, "damageheadmultiplier", 1.0),
       "damagechestmultiplier": float_value(values, "damagechestmultiplier", 1.0),
       "damagestomachmultiplier": float_value(values, "damagestomachmultiplier", 1.0),
       "damagelegmultiplier": float_value(values, "damagelegmultiplier", 1.0),
       "damagearmmultiplier": float_value(values, "damagearmmultiplier", 1.0),
   }
   bullet_weight_kg = float_value(values, "bullet_weight")
   weight_kg = float_value(values, "weight")
   extra_bullet_chamber = values.get("extrabulletchamber", "0")
   clip_text = format_clip_size(values.get("clip_size", ""), extra_bullet_chamber)
   clip2_text = format_clip_size(values.get("clip2_size", ""), "0")
   if clip2_text != "N/A" and values.get("secondary_ammo", "None") != "None":
       clip_text = f"{clip_text}
{clip2_text}"
   rifle_grenade_path = script_path.with_name(f"{script_name}_riflegrenade.txt")
   gamemodes_path = resource_dir.parent / "gamemodes.txt"
   in_gamemodes = file_contains_token(gamemodes_path, script_name)
   category_lines: list[str] = []
   if "main" not in info.sources:
       if in_gamemodes:
           category_lines.append("Gun Game
") if "zombie" in info.sources: category_lines.append("Zombies
") if "special" in info.sources: category_lines.append("Special Loadout
")
   weapon_type = determine_weapon_type(values, info)
   muzzle_velocity = values.get("muzzle_velocity") or values.get("gl_velocity") or "N/A"
   image_file = first_override(overrides, "image_file", default=f"{page_title}.png")
   icon_file = first_override(overrides, "icon_file", default=script_icon_filename(script_name))
   calculated = {
       "filename": script_name,
       "printname": image_file.removeprefix("File:").rsplit(".", 1)[0],
       "printnameenglish": page_title,
       "primary_ammo": primary_ammo,
       "caliber": primary_ammo,
       "origin": origin,
       "weapontype": weapon_type,
       "clip_size": clip_text,
       "extrabulletchamber": "",
       "damagegeneric": fmt_float(base_damage) if base_damage else "N/A",
       "damagegenericxdamageheadmultiplier": format_damage(base_damage, damage_keys["damageheadmultiplier"]) if base_damage else "N/A",
       "damagegenericxdamagechestmultiplier": format_damage(base_damage, damage_keys["damagechestmultiplier"]) if base_damage else "N/A",
       "damagegenericxdamagestomachmultiplier": format_damage(base_damage, damage_keys["damagestomachmultiplier"]) if base_damage else "N/A",
       "damagegenericxdamagelegmultiplier": format_damage(base_damage, damage_keys["damagelegmultiplier"]) if base_damage else "N/A",
       "damagegenericxdamagearmmultiplier": format_damage(base_damage, damage_keys["damagearmmultiplier"]) if base_damage else "N/A",
       "hasbayonet": "YES" if values.get("hasbayonet", "0") == "1" else "NO",
       "hasriflegrenade": "YES" if rifle_grenade_path.exists() else "NO",
       "bullet_weight": fmt_float(bullet_weight_kg * 1000) if bullet_weight_kg else "N/A",
       "bullet_weightingr": fmt_float(bullet_weight_kg * 15432.36) if bullet_weight_kg else "N/A",
       "weightinlbs": fmt_float(weight_kg * 2.20462) if weight_kg else "N/A",
       "muzzle_velocity": muzzle_velocity,
       "faction": "/".join(sorted(info.factions)) if info.factions else "N/A",
       "category_lines": "".join(category_lines),
       "icon_file": icon_file.removeprefix("File:"),
   }
   for key, value in overrides.items():
       if value:
           calculated[key] = value
   return page_title, calculated, info


def render_page(template: str, values: dict[str, str], calculated: dict[str, str], info: LoadoutInfo) -> str:

   result = template.replace("rolspan=", "colspan=")
   classes_pattern = (
       r'\[\[File:Class_""class""\.png\|50px\]\] '
       r'\[\[""class""\]\]
' ) if calculated.get("category_lines"): classes_markup = calculated["category_lines"] else: classes_markup = build_classes_markup(info.classes) result = re.sub(classes_pattern, classes_markup, result)
   for placeholder in sorted(set(re.findall(r'""([^"]+)""', result))):
       key_lower = placeholder.lower()
       if key_lower == "class":
           continue
       value = calculated.get(key_lower, values.get(key_lower, "N/A"))
       result = result.replace(f'""{placeholder}""', str(value))
   image_stem = calculated["printname"]
   icon_file = calculated["icon_file"]
   result = result.replace(f"[[File:{image_stem}.svg|512px]]", f"[[File:{icon_file}|512px]]")
   factions = info.factions
   if "US" not in factions:
       result = result.replace("", "")
   if "VC" not in factions:
       result = result.replace("", "")
   result = result.replace("[[#", "[[")
   result = result.replace("|#", "|")
   result = result.replace("#weapon_", "weapon_")
   return result


def api_query(params: dict[str, str]) -> dict:

   url = API_URL + "?" + urllib.parse.urlencode(params)
   with urllib.request.urlopen(url, timeout=30) as response:
       import json
       return json.loads(response.read().decode("utf-8"))


def existing_titles(titles: list[str]) -> set[str]:

   existing: set[str] = set()
   for index in range(0, len(titles), 50):
       group = titles[index : index + 50]
       data = api_query(
           {
               "action": "query",
               "titles": "|".join(group),
               "format": "json",
               "formatversion": "2",
               "redirects": "1",
           }
       )
       query = data.get("query", {})
       normalized = {
           item["from"].replace("_", " ").casefold(): item["to"]
           for item in query.get("normalized", [])
       }
       redirects = {
           item["from"].replace("_", " ").casefold(): item["to"]
           for item in query.get("redirects", [])
       }
       page_status = {
           page["title"].replace("_", " ").casefold(): not page.get("missing")
           for page in query.get("pages", [])
       }
       def resolve(title: str) -> str:
           current = normalized.get(title.replace("_", " ").casefold(), title)
           seen: set[str] = set()
           while True:
               current_key = current.replace("_", " ").casefold()
               if current_key in seen or current_key not in redirects:
                   return current_key
               seen.add(current_key)
               current = redirects[current_key]
       for title in group:
           if page_status.get(resolve(title), False):
               existing.add(title.replace("_", " ").casefold())
       for page in query.get("pages", []):
           if not page.get("missing"):
               existing.add(page["title"].replace("_", " ").casefold())
   return existing


def collect_scripts(scripts_dir: Path, only: list[str] | None, limit: int | None) -> list[Path]:

   scripts = sorted(scripts_dir.glob("weapon_*.txt"))
   if only:
       script_needles = {item.lower() for item in only if item.lower().startswith("weapon_")}
       if script_needles:
           scripts = [script for script in scripts if script.stem.lower() in script_needles]
   if limit is not None:
       scripts = scripts[:limit]
   return scripts


def prepare_pages(args: argparse.Namespace) -> list[WeaponPage]:

   game_dir = args.game_dir
   scripts_dir = args.scripts_dir or game_dir / "scripts"
   resource_dir = args.resource_dir or game_dir / "resource"
   english_path = resource_dir / "vietnam_english.txt"
   if not scripts_dir.exists():
       raise FileNotFoundError(f"Scripts folder does not exist: {scripts_dir}")
   if not resource_dir.exists():
       raise FileNotFoundError(f"Resource folder does not exist: {resource_dir}")
   template = load_template(args)
   script_index = load_script_index(args)
   tokens = parse_token_file(english_path)
   overrides_by_key = load_overrides(args.overrides)
   loadout = merge_loadout_maps(
       [
           parse_loadout(resource_dir / "vietnam_loadout.txt", "main"),
           parse_loadout(resource_dir / "vietnam_loadout_zombie.txt", "zombie"),
           parse_loadout(resource_dir / "vietnam_loadout_special.txt", "special"),
       ]
   )
   scripts = collect_scripts(scripts_dir, args.only, args.limit)
   generated: list[tuple[Path, str, str, dict[str, str], LoadoutInfo, dict[str, str]]] = []
   for script_path in scripts:
       values = parse_top_level_pairs(script_path)
       script_name = script_path.stem
       overrides = overrides_by_key.get(script_name.lower(), {})
       title, calculated, info = build_values(
           script_path,
           values,
           tokens,
           loadout,
           resource_dir,
           overrides,
           script_index,
       )
       overrides = overrides or overrides_by_key.get(title.lower(), {})
       if overrides:
           title, calculated, info = build_values(
               script_path,
               values,
               tokens,
               loadout,
               resource_dir,
               overrides,
               script_index,
           )
       if args.only:
           needles = {item.lower() for item in args.only}
           word_needles = {item.lower().replace("_", " ") for item in args.only}
           if (
               script_name.lower() not in needles
               and script_name.lower().replace("_", " ") not in word_needles
               and title.lower() not in needles
               and title.lower().replace("_", " ") not in word_needles
           ):
               continue
       page_text = render_page(template, values, calculated, info)
       page_text = apply_section_overrides(page_text, overrides)
       generated.append((script_path, title, page_text, calculated, info, values))
   existing = set()
   if not args.no_wiki_check:
       existing = existing_titles([item[1] for item in generated])
   pages: list[WeaponPage] = []
   args.output_dir.mkdir(parents=True, exist_ok=True)
   if args.clean_output:
       for old_preview in args.output_dir.glob("*.wiki"):
           old_preview.unlink()
   for script_path, title, page_text, calculated, info, values in generated:
       exists = title.replace("_", " ").casefold() in existing
       status = "exists" if exists else "missing"
       should_write = args.include_existing or not exists or args.no_wiki_check
       output_path = args.output_dir / f"{title_to_filename(title)}.wiki"
       if should_write:
           output_path.write_text(page_text, encoding="utf-8")
       weapon_type = calculated["weapontype"]
       pages.append(
           WeaponPage(
               script_name=script_path.stem,
               title=title,
               status=status,
               output_path=output_path if should_write else None,
               page_text=page_text,
               weapon_type=weapon_type,
               factions="/".join(sorted(info.factions)),
               classes="/".join(sorted(info.classes)),
               loadout_sources="/".join(sorted(info.sources)),
               note=SECTION_BY_TYPE.get(weapon_type, "Miscellaneous"),
           )
       )
   return pages


def write_report(pages: list[WeaponPage], report_path: Path) -> None:

   report_path.parent.mkdir(parents=True, exist_ok=True)
   with report_path.open("w", encoding="utf-8", newline="") as handle:
       writer = csv.DictWriter(
           handle,
           fieldnames=[
               "status",
               "script_name",
               "title",
               "weapon_type",
               "factions",
               "classes",
               "loadout_sources",
               "section_hint",
               "preview_file",
           ],
       )
       writer.writeheader()
       for page in pages:
           writer.writerow(
               {
                   "status": page.status,
                   "script_name": page.script_name,
                   "title": page.title,
                   "weapon_type": page.weapon_type,
                   "factions": page.factions,
                   "classes": page.classes,
                   "loadout_sources": page.loadout_sources,
                   "section_hint": page.note,
                   "preview_file": str(page.output_path) if page.output_path is not None else "",
               }
           )


def upload_pages(args: argparse.Namespace, pages: list[WeaponPage]) -> None:

   os.environ.setdefault("PYWIKIBOT_DIR", str(ROOT / "pywikibot"))
   if args.username:
       os.environ["MCV_WIKI_USER"] = args.username
   try:
       import pywikibot
   except ImportError as exc:
       raise RuntimeError(
           "Pywikibot is required for --upload. Install it with "
           "`python -m pip install pywikibot`, then run this command again."
       ) from exc
   site = pywikibot.Site("en", "mcvwiki")
   site.login()
   for page_info in pages:
       if page_info.status == "exists" and not args.overwrite_existing:
           continue
       if page_info.status == "missing" and not page_info.loadout_sources and not args.upload_unlisted:
           print(f"Skip unlisted missing page: {page_info.title}")
           continue
       page = pywikibot.Page(site, page_info.title)
       if page.exists() and not args.overwrite_existing:
           print(f"Skip existing page: {page_info.title}")
           continue
       page.text = page_info.page_text
       page.save(summary=args.summary, minor=False)
       print(f"Uploaded: {page_info.title}")


def main() -> int:

   args = parse_args()
   pages = prepare_pages(args)
   write_report(pages, args.report)
   missing = sum(1 for page in pages if page.status == "missing")
   existing = sum(1 for page in pages if page.status == "exists")
   previews = sum(1 for page in pages if page.output_path is not None)
   print(f"Checked {len(pages)} weapon scripts.")
   print(f"Missing pages: {missing}; existing pages: {existing}.")
   print(f"Wrote {previews} preview files to: {args.output_dir}")
   print(f"Report: {args.report}")
   if args.upload:
       upload_pages(args, pages)
   else:
       print("Dry run only. Add --upload to create missing wiki pages.")
   return 0


if __name__ == "__main__":

   raise SystemExit(main())