WeaponScriptToWiki.py: Difference between revisions
Skizmophonic (talk | contribs) 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"..." |
Skizmophonic (talk | contribs) 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) | |||
def absorb(self, other: "LoadoutInfo") -> None: | |||
def | 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", | |||
return | 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 = | encodings = ("utf-8-sig", "utf-16", "cp1252", "latin-1") | ||
for | for encoding in encodings: | ||
try: | try: | ||
return path.read_text(encoding=encoding) | |||
except UnicodeDecodeError: | except UnicodeDecodeError: | ||
continue | continue | ||
return path.read_text(errors="replace") | |||
return | |||
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: | |||
def | 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]: | ||
if | 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|<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: | ||
page_text = page_text.replace("<!-- VIDEOS_GO_HERE -->", "", 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}<br>{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]]<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 | |||
return page_title, calculated, info | |||
def render_page(template: str, values: dict[str, str], calculated: dict[str, str], info: LoadoutInfo) -> str: | |||
def | result = template.replace("rolspan=", "colspan=") | ||
result = template | |||
classes_pattern = ( | |||
r'\[\[File:Class_""class""\.png\|50px\]\] ' | |||
r'<b>\[\[""class""\]\]</b><br>' | |||
) | |||
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)) | result = result.replace(f'""{placeholder}""', str(value)) | ||
image_stem = calculated["printname"] | |||
result = | 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 | return result | ||
def | def api_query(params: dict[str, str]) -> dict: | ||
for | url = API_URL + "?" + urllib.parse.urlencode(params) | ||
for | with urllib.request.urlopen(url, timeout=30) as response: | ||
if not | 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 | ||
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 | |||
os. | |||
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()) | |||
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())