WeaponScriptToWiki.py: Difference between revisions

Jump to navigation Jump to search
Created page with "import os import re # Utility functions for conversions def kg_to_g(kg): return round(kg * 1000, 2) def kg_to_grains(kg): return round(kg * 15432.36, 2) def kg_to_lbs(kg): return round(kg * 2.20462, 2) # Load the template file def load_template(template_path): with open(template_path, "r", encoding="utf-8") as f: return f.read() # Parse key-value pairs from script file def parse_file(file_path): variables = {} with open(file_path, "r"..."
 
Use #ff6905 for Zombies links in weapon page generator
 
(6 intermediate revisions by the same user not shown)
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
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"
ZOMBIE_STAT_ORANGE = "#ff6905"
ZOMBIES_LINK_ORANGE = "#ff6905"
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)


# Utility functions for conversions
    def absorb(self, other: "LoadoutInfo") -> None:
def kg_to_g(kg):
        self.factions.update(other.factions)
    return round(kg * 1000, 2)
        self.classes.update(other.classes)
        self.groups.update(other.groups)
        self.sources.update(other.sources)


def kg_to_grains(kg):
    return round(kg * 15432.36, 2)


def kg_to_lbs(kg):
@dataclass
     return round(kg * 2.20462, 2)
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 = ""


# Load the template file
def load_template(template_path):
    with open(template_path, "r", encoding="utf-8") as f:
        return f.read()


# Parse key-value pairs from script file
def parse_args() -> argparse.Namespace:
def parse_file(file_path):
    parser = argparse.ArgumentParser(
     variables = {}
        description=(
     with open(file_path, "r", encoding="utf-8") as f:
            "Generate and optionally create MCV wiki weapon pages from game "
         for line in f:
            "weapon script data."
             match = re.match(r'\s*"(.+?)"\s+"(.+?)"', line)
        )
            if match:
    )
                key, value = match.groups()
     parser.add_argument(
                variables[key.lower()] = value
        "--game-dir",
     return variables
        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()


# Retrieve the printnameenglish value from the vietnam_english.txt file
def get_printname_english(printname):
    file_path = r"C:\Program Files (x86)\Steam\steamapps\common\Military Conflict - Vietnam\vietnam\resource\vietnam_english.txt"
   
    # Remove the leading "#" from printname
    printname = printname.lstrip('#')


    # Try opening the file with different encodings
def read_text(path: Path) -> str:
     encodings = ["utf-8", "utf-16", "latin-1"]
     encodings = ("utf-8-sig", "utf-16", "cp1252", "latin-1")
     for enc in encodings:
     for encoding 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()
                    # Ensure the line is in the format we expect (e.g. "weapon_ammobox" "Ammunition Box")
                    match = re.match(r'^\s*"(.+?)"\s+"(.+?)"\s*$', line)
                    if match:
                        key, value = match.groups()
                        # If the key matches the printname, return the corresponding value
                        if key.lower() == printname.lower():
                            return value
         except UnicodeDecodeError:
         except UnicodeDecodeError:
             continue # Try the next encoding if decoding fails
             continue
     print(f"Error: Unable to decode the file or find the key '{printname}'")
     return path.read_text(errors="replace")
     return None
 
 
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


# Get the origin value and process it
def get_origin(origin):
    # Remove leading "#" and any other unwanted characters
    origin = origin.lstrip('#').replace("_", " ").title()
    return origin


# Preprocess calculations for the template
def strip_line_comment(line: str) -> str:
def preprocess_calculations(variables, file_name, folder_path):
     in_quote = False
     calculations = {}
    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 damage-related values (set default to 0 if not found)
    damage_generic = float(variables.get("damagegeneric", "0"))
    damage_head_multiplier = float(variables.get("damageheadmultiplier", "1"))
    damage_chest_multiplier = float(variables.get("damagechestmultiplier", "1"))
    damage_stomach_multiplier = float(variables.get("damagestomachmultiplier", "1"))
    damage_leg_multiplier = float(variables.get("damagelegmultiplier", "1"))
    damage_arm_multiplier = float(variables.get("damagearmmultiplier", "1"))


     # Calculate damage multipliers (only if damage_generic is greater than 0)
def parse_top_level_pairs(path: Path) -> dict[str, str]:
     if damage_generic > 0:
    values: dict[str, str] = {}
         calculations["damagegenericxdamageheadmultiplier"] = round(damage_generic * damage_head_multiplier, 2)
    pending_key: str | None = None
         calculations["damagegenericxdamagechestmultiplier"] = round(damage_generic * damage_chest_multiplier, 2)
    stack: list[str] = []
         calculations["damagegenericxdamagestomachmultiplier"] = round(damage_generic * damage_stomach_multiplier, 2)
 
         calculations["damagegenericxdamagelegmultiplier"] = round(damage_generic * damage_leg_multiplier, 2)
    for raw_line in read_text(path).splitlines():
         calculations["damagegenericxdamagearmmultiplier"] = round(damage_generic * damage_arm_multiplier, 2)
        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 is_semi_auto_only(values: dict[str, str]) -> bool:
    raw = values.get("supportedfiremodes", "")
    modes = [part.strip().lower() for part in re.split(r"[+/,\s]+", raw) if part.strip()]
    return bool(modes) and set(modes) == {"semi"}
 
 
def format_fire_rate_display(values: dict[str, str]) -> str:
    if is_semi_auto_only(values):
        return ""
    fire_rate = values.get("firerate", "").strip()
    if not fire_rate or fire_rate in {"-1", "0"}:
        return "N/A"
    return f"{fire_rate} RPM"
 
 
def zombie_link() -> str:
    return f'[[Zombies|<span style="color:{ZOMBIES_LINK_ORANGE};">Zombies</span>]]'
 
 
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 "''[[WIP]]''"
    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 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")
    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 = re.sub(
            r"<!--\nDESCRIPTION \(Lead\).*?-->\n",
            "",
            page_text,
            count=1,
            flags=re.DOTALL,
        )
        page_text = page_text.replace("DESCRIPTION GOES HERE", description, 1)
    if history:
        page_text = re.sub(
            r"<!--\nHISTORY\n.*?-->\n",
            "",
            page_text,
            count=1,
            flags=re.DOTALL,
        )
        page_text = page_text.replace("TEXT GOES HERE", history, 1)
    if sources:
         def format_source_line(raw_line: str) -> str:
            line = raw_line.strip()
            if line.startswith("*"):
                return line
            if line.startswith("["):
                return f"* {line}"
            if re.match(r"https?://", line):
                return f"* [{line}]"
            return f"* {line}"
 
        source_lines = "\n".join(
            format_source_line(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,
            )
     else:
     else:
         calculations["damagegenericxdamageheadmultiplier"] = "N/A"
         page_text = page_text.replace("<!-- VIDEOS_GO_HERE -->", "", 1)
        calculations["damagegenericxdamagechestmultiplier"] = "N/A"
    return page_text
         calculations["damagegenericxdamagestomachmultiplier"] = "N/A"
 
        calculations["damagegenericxdamagelegmultiplier"] = "N/A"
 
        calculations["damagegenericxdamagearmmultiplier"] = "N/A"
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")
 


     # Bayonet check
def build_values(
     has_bayonet = variables.get("hasbayonet", "0") == "1"
    script_path: Path,
    calculations["hasbayonet"] = "YES" if has_bayonet else "NO"
    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())


     # Rifle grenade file check
     print_token = values.get("printname", script_name)
     file_base_name = os.path.splitext(file_name)[0]
     token_name = print_token.lstrip("#")
     rifle_grenade_file = os.path.join(folder_path, f"{file_base_name}_riflegrenade.txt")
     display_title = lookup_token(tokens, print_token, token_name)
     has_rifle_grenade = os.path.exists(rifle_grenade_file)
     indexed_title = script_index.get(script_name.lower(), "")
     calculations["hasriflegrenade"] = "YES" if has_rifle_grenade else "NO"
     page_title = first_override(
        overrides,
        "page_title",
        "title",
        default=indexed_title or display_title,
    )


     # Weight conversions
     ammo_token = values.get("ammo_id_display", "")
    bullet_weight = float(variables.get("bullet_weight", "0"))
    ammo_display = lookup_token(tokens, ammo_token, "")
     weight = float(variables.get("weight", "0"))
     primary_ammo = ammo_display or values.get("primary_ammo", "N/A")
     print(f"DEBUG: Bullet weight in kg: {bullet_weight}")  # Debug print
     if primary_ammo and primary_ammo != "N/A" and not primary_ammo.startswith("[["):
        primary_ammo = f"[[{primary_ammo}]]"


     calculations["bullet_weight"] = kg_to_g(bullet_weight)
     origin_token = values.get("origin", "")
     calculations["bullet_weightingr"] = kg_to_grains(bullet_weight)  # Ensure conversion to grains
    origin = lookup_token(tokens, origin_token, "")
    calculations["weightinlbs"] = kg_to_lbs(weight)
     if not origin:
        origin = origin_token.lstrip("#").replace("_", " ").title() or "N/A"


     # ExtraBulletChamber check
     base_damage = float_value(values, "damagegeneric")
     calculations["extrabulletchamber"] = variables.get("extrabulletchamber", "0")
     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),
    }


     # File name replacement (ensure base name without extension)
     bullet_weight_kg = float_value(values, "bullet_weight")
     calculations["filename"] = file_base_name
    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}<br>{clip2_text}"


     # Get the printname and replace with the appropriate english translation if found
     rifle_grenade_path = script_path.with_name(f"{script_name}_riflegrenade.txt")
     printname = variables.get("printname", "").lstrip("#") # Strip the leading "#" here as well
    gamemodes_path = resource_dir.parent / "gamemodes.txt"
     printname_english = get_printname_english(printname)
    in_gamemodes = file_contains_token(gamemodes_path, script_name)
    if printname_english:
     category_lines: list[str] = []
         calculations["printnameenglish"] = printname_english
    if "main" not in info.sources:
    else:
        if in_gamemodes:
         calculations["printnameenglish"] = printname
            category_lines.append("[[Gun Game]]<br>")
        if "zombie" in info.sources:
            category_lines.append(f"{zombie_link()}<br>")
        if "special" in info.sources:
            category_lines.append("[[Special Loadout]]<br>")
 
    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,
        "fireratedisplay": format_fire_rate_display(values),
        "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


     # Get the origin and process it
     return page_title, calculated, info
    origin = variables.get("origin", "")
    calculations["origin"] = get_origin(origin)


    return calculations


# Replace placeholders in the template
def render_page(template: str, values: dict[str, str], calculated: dict[str, str], info: LoadoutInfo) -> str:
def replace_placeholders(template, variables, calculations):
     result = template.replace("rolspan=", "colspan=")
     result = template


     # Replace placeholders with variables or calculations
     classes_pattern = (
    for placeholder in re.findall(r'""([^"]+)""', result):
        r'\[\[File:Class_""class""\.png\|50px\]\] '
         # Check if placeholder is "FILENAME" and replace it with the file base name
         r'<b>\[\[""class""\]\]</b><br>'
        if placeholder.lower() == "filename":
    )
            value = calculations["filename"]
    if calculated.get("category_lines"):
         elif placeholder.lower() == "bullet_weightingr":  # Ensure correct grains conversion handling
        classes_markup = calculated["category_lines"]
            value = str(calculations["bullet_weightingr"]) # Correct grains replacement
    else:
        elif placeholder.lower() == "extrabulletchamber":
         classes_markup = build_classes_markup(info.classes)
            # Only add [[]] if extra_bullet_chamber is not "0"
    result = re.sub(classes_pattern, classes_markup, result)
            extra_bullet_chamber = calculations.get("extrabulletchamber", "0")
 
            if extra_bullet_chamber == "0":
    for placeholder in sorted(set(re.findall(r'""([^"]+)""', result))):
                value = ""  # Remove the [[]] if the chamber is not present
        key_lower = placeholder.lower()
             else:
        if key_lower == "class":
                value = "[[]]"  # Keep the [[]] if ExtraBulletChamber is set
             continue
         else:
         value = calculated.get(key_lower, values.get(key_lower, "N/A"))
            value = calculations.get(placeholder.lower(), variables.get(placeholder.lower(), "N/A"))
        # Replace the placeholder correctly (ensuring the exact match with quotes)
         result = result.replace(f'""{placeholder}""', str(value))
         result = result.replace(f'""{placeholder}""', str(value))


     # Handle ammo column, ensuring the proper format (no extra [[]] and correct ammo count)
     image_stem = calculated["printname"]
     result = re.sub(r"(\d+)/(\d+)(\[\[\]\])?", lambda m: m.group(1) + (
    icon_file = calculated["icon_file"]
        f"[[+{calculations['extrabulletchamber']}]]" if calculations["extrabulletchamber"] == "2" else
     result = result.replace(f"[[File:{image_stem}.svg|512px]]", f"[[File:{icon_file}|512px]]")
         f"[[+1]]" if calculations["extrabulletchamber"] == "1" else "")
 
         + "/" + m.group(2), result)
    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
     return result


# Process all script files
 
def process_files(template, scripts_path, output_path):
def api_query(params: dict[str, str]) -> dict:
     for root, _, files in os.walk(scripts_path):
    url = API_URL + "?" + urllib.parse.urlencode(params)
         for file in files:
    with urllib.request.urlopen(url, timeout=30) as response:
             if not file.endswith(".txt"):
        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
                 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)
 
             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 "",
                }
            )


            # Save processed template to output
            output_file = os.path.join(output_path, file_name)
            with open(output_file, "w", encoding="utf-8") as f:
                f.write(processed_template)


# Paths
def upload_pages(args: argparse.Namespace, pages: list[WeaponPage]) -> None:
template_path = r"C:\MCV\wiki\WikiTemplate.txt"
    os.environ.setdefault("PYWIKIBOT_DIR", str(ROOT / "pywikibot"))
scripts_path = r"C:\Program Files (x86)\Steam\steamapps\common\Military Conflict - Vietnam\vietnam\scripts"
    if args.username:
output_path = r"C:\MCV\wiki\output"
        os.environ["MCV_WIKI_USER"] = args.username
os.makedirs(output_path, exist_ok=True)


# Main execution
    try:
template = load_template(template_path)
        import pywikibot
process_files(template, scripts_path, output_path)
    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


def remove_hash_from_files(directory):
     site = pywikibot.Site("en", "mcvwiki")
     # Check if the directory exists
    site.login()
    if not os.path.exists(directory):
        print(f"The directory '{directory}' does not exist.")
        return
   
    # Iterate through all files in the directory
    for filename in os.listdir(directory):
        # Full path of the file
        file_path = os.path.join(directory, filename)
       
        # Process only text files
        if os.path.isfile(file_path) and filename.endswith('.txt'):
            try:
                # Read the file contents
                with open(file_path, 'r', encoding='utf-8') as file:
                    content = file.read()
               
                # Remove '#' characters
                updated_content = content.replace('#', '')
               
                # Write the updated content back to the file
                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}")


# Define the directory
    for page_info in pages:
directory = r"C:\MCV\wiki\output"
        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.")


# Call the function
    return 0
remove_hash_from_files(directory)




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

Latest revision as of 00:05, 15 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" ZOMBIE_STAT_ORANGE = "#ff6905" ZOMBIES_LINK_ORANGE = "#ff6905"

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 is_semi_auto_only(values: dict[str, str]) -> bool:

   raw = values.get("supportedfiremodes", "")
   modes = [part.strip().lower() for part in re.split(r"[+/,\s]+", raw) if part.strip()]
   return bool(modes) and set(modes) == {"semi"}


def format_fire_rate_display(values: dict[str, str]) -> str:

   if is_semi_auto_only(values):
       return ""
   fire_rate = values.get("firerate", "").strip()
   if not fire_rate or fire_rate in {"-1", "0"}:
       return "N/A"
   return f"{fire_rate} RPM"


def zombie_link() -> str:

   return f'Zombies'


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 "WIP"
   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")
   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 = re.sub(
           r"\n",
           "",
           page_text,
           count=1,
           flags=re.DOTALL,
       )
       page_text = page_text.replace("DESCRIPTION GOES HERE", description, 1)
   if history:
       page_text = re.sub(
           r"\n",
           "",
           page_text,
           count=1,
           flags=re.DOTALL,
       )
       page_text = page_text.replace("TEXT GOES HERE", history, 1)
   if sources:
       def format_source_line(raw_line: str) -> str:
           line = raw_line.strip()
           if line.startswith("*"):
               return line
           if line.startswith("["):
               return f"* {line}"
           if re.match(r"https?://", line):
               return f"* [{line}]"
           return f"* {line}"
       source_lines = "\n".join(
           format_source_line(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,
           )
   else:
       page_text = page_text.replace("", "", 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(f"{zombie_link()}
") 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,
       "fireratedisplay": format_fire_rate_display(values),
       "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())