372 lines
17 KiB
Python
372 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import lvpwb as app
|
|
|
|
def load_roster(game_id: str) -> list[str]:
|
|
"""Load and validate a game's roster from the shared JSON schema."""
|
|
if game_id in app.CUSTOM_GAME_CONFIGS:
|
|
return list(app.CUSTOM_GAME_CONFIGS[game_id]["roster"])
|
|
path = app.ROOT / app.GAMES[game_id]["roster"]
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"Roster file not found: {path}")
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid JSON roster file: {path}") from exc
|
|
if not isinstance(payload, dict) or payload.get("game_id") != game_id:
|
|
raise ValueError(f"Roster game_id does not match {game_id}: {path}")
|
|
names = payload.get("wrestlers")
|
|
if not isinstance(names, list) or any(not isinstance(name, str) or not name.strip() for name in names):
|
|
raise ValueError(f"Roster must contain a list of non-empty wrestler names: {path}")
|
|
if len(names) != len(set(names)):
|
|
raise ValueError(f"Roster contains duplicate wrestler names: {path}")
|
|
if payload.get("game") != app.GAMES[game_id]["title"]:
|
|
raise ValueError(f"Roster game title does not match {game_id}: {path}")
|
|
return names
|
|
|
|
|
|
def game_title(game_id: str) -> str:
|
|
return app.GAMES[game_id]["title"]
|
|
|
|
|
|
def federation_name(game_id: str) -> str:
|
|
if game_id in app.CUSTOM_GAME_CONFIGS:
|
|
return app.CUSTOM_GAME_CONFIGS[game_id]["federation_name"]
|
|
return "World Wrestling Federation"
|
|
|
|
|
|
def game_display_name(game_id: str) -> str:
|
|
if game_id in app.CUSTOM_GAME_CONFIGS:
|
|
config = app.CUSTOM_GAME_CONFIGS[game_id]
|
|
return f"{config['federation_name']} — {config['game_title']}"
|
|
return app.GAMES[game_id]["title"]
|
|
|
|
|
|
def is_wwf_federation(name: str | None) -> bool:
|
|
folded = str(name or "").strip().casefold()
|
|
return folded in ("world wrestling federation", "wwf")
|
|
|
|
|
|
def title_definitions_for_game(game_id: str,
|
|
profile: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
|
if profile and isinstance(profile.get("championships"), list):
|
|
return [dict(title) for title in profile["championships"]]
|
|
if game_id in app.CUSTOM_GAME_CONFIGS:
|
|
return [dict(title) for title in app.CUSTOM_GAME_CONFIGS[game_id]["championships"]]
|
|
return [
|
|
{
|
|
"name": name,
|
|
"kind": (
|
|
"tag" if name == "Tag Team"
|
|
else "women" if name == "Women's"
|
|
else "light_heavyweight" if name == "Light-Heavyweight"
|
|
else "singles"
|
|
),
|
|
}
|
|
for name in app.CHAMPIONSHIPS
|
|
]
|
|
|
|
|
|
def championship_names_for_game(game_id: str,
|
|
profile: dict[str, Any] | None = None) -> list[str]:
|
|
return [title["name"] for title in app.title_definitions_for_game(game_id, profile)]
|
|
|
|
|
|
def championship_kind(game_id: str, title_name: str,
|
|
profile: dict[str, Any] | None = None) -> str:
|
|
for title in app.title_definitions_for_game(game_id, profile):
|
|
if title["name"] == title_name:
|
|
return title.get("kind", "singles")
|
|
return "singles"
|
|
|
|
|
|
def shows_snapshot(config: dict[str, Any] | None) -> dict[str, str]:
|
|
"""Resolved show names recorded with a season at creation time."""
|
|
shows = (config or {}).get("shows")
|
|
return {brand: app.show_name_for_brand(
|
|
brand, shows if isinstance(shows, dict) else None) for brand in app.SHOW_BRANDS}
|
|
|
|
|
|
def season_title_names(season: dict[str, Any]) -> list[str]:
|
|
definitions = season.get("title_definitions")
|
|
if isinstance(definitions, list):
|
|
return [item["name"] for item in definitions]
|
|
return list(season.get("champions", {})) or app.championship_names_for_game(season["game_id"])
|
|
|
|
|
|
def season_championship_kind(season: dict[str, Any], title_name: str) -> str:
|
|
for definition in season.get("title_definitions", []):
|
|
if definition.get("name") == title_name:
|
|
return definition.get("kind", "singles")
|
|
return app.championship_kind(season["game_id"], title_name)
|
|
|
|
|
|
def game_profile(game_id: str) -> dict[str, Any]:
|
|
if game_id in app.CUSTOM_GAME_CONFIGS:
|
|
return json.loads(json.dumps(app.CUSTOM_GAME_CONFIGS[game_id]))
|
|
return app.builtin_game_profile(game_id)
|
|
|
|
|
|
def season_game_profile(season: dict[str, Any]) -> dict[str, Any]:
|
|
profile = season.get("game_config")
|
|
return profile if isinstance(profile, dict) else app.game_profile(season["game_id"])
|
|
|
|
|
|
def match_type_definitions(game_id: str, profile: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
if profile and isinstance(profile.get("match_types"), list):
|
|
return [dict(item) for item in profile["match_types"]]
|
|
if game_id in app.CUSTOM_GAME_CONFIGS:
|
|
return [dict(item) for item in app.CUSTOM_GAME_CONFIGS[game_id]["match_types"]]
|
|
return [
|
|
{
|
|
"name": name,
|
|
"sides": list(sides),
|
|
"base_rating": app.MATCH_BASE_RATINGS.get(name, 4.5),
|
|
"outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES)),
|
|
}
|
|
for name, sides in app.MATCH_TYPES[game_id]
|
|
]
|
|
|
|
|
|
def match_type_definition(game_id: str, match_type: str,
|
|
profile: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
for definition in app.match_type_definitions(game_id, profile):
|
|
if definition["name"] == match_type:
|
|
return definition
|
|
return {"name": match_type, "sides": [1, 1],
|
|
"base_rating": app.MATCH_BASE_RATINGS.get(match_type, 4.5),
|
|
"outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(match_type, app.STANDARD_MATCH_OUTCOMES))}
|
|
|
|
|
|
def refresh_custom_games(directory: Path | None = None) -> list[str]:
|
|
"""Load user-managed game definitions without changing built-in profiles."""
|
|
for game_id in list(app.CUSTOM_GAME_CONFIGS):
|
|
app.GAMES.pop(game_id, None)
|
|
app.MATCH_TYPES.pop(game_id, None)
|
|
app.CUSTOM_GAME_CONFIGS.clear()
|
|
directory = directory or app.CUSTOM_GAME_DIR
|
|
if not directory.is_dir():
|
|
return []
|
|
loaded: list[str] = []
|
|
for path in sorted(directory.glob("*.json")):
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
config = app.validate_custom_game(raw)
|
|
game_id = config["game_id"]
|
|
if game_id in app.BUILTIN_GAME_IDS or game_id in app.CUSTOM_GAME_CONFIGS:
|
|
raise ValueError(f"Game ID is already in use: {game_id}")
|
|
app.CUSTOM_GAME_CONFIGS[game_id] = config
|
|
app.GAMES[game_id] = {
|
|
"title": config["game_title"],
|
|
"roster": str(path),
|
|
"federation": config["federation_name"],
|
|
"platform": config["platform"],
|
|
}
|
|
app.MATCH_TYPES[game_id] = [
|
|
(entry["name"], tuple(entry["sides"])) for entry in config["match_types"]
|
|
]
|
|
loaded.append(game_id)
|
|
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
print(f"Skipping custom game {path.name}: {exc}")
|
|
return loaded
|
|
|
|
|
|
def validate_custom_game(data: Any) -> dict[str, Any]:
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Game definition must be a JSON object.")
|
|
game_id = data.get("game_id")
|
|
if not isinstance(game_id, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", game_id):
|
|
raise ValueError("game_id must use lowercase letters, digits, and hyphens.")
|
|
if game_id in app.BUILTIN_GAME_IDS:
|
|
raise ValueError("Custom definitions cannot overwrite built-in games.")
|
|
game_title_value = data.get("game_title")
|
|
federation = data.get("federation_name")
|
|
if not isinstance(game_title_value, str) or not game_title_value.strip():
|
|
raise ValueError("game_title is required.")
|
|
if not isinstance(federation, str) or not federation.strip():
|
|
raise ValueError("federation_name is required.")
|
|
roster = data.get("roster")
|
|
if (not isinstance(roster, list) or len(roster) < 2
|
|
or any(not isinstance(name, str) or not name.strip() for name in roster)):
|
|
raise ValueError("roster must contain at least two non-empty wrestler names.")
|
|
if len({name.casefold() for name in roster}) != len(roster):
|
|
raise ValueError("roster names must be unique, ignoring capitalization.")
|
|
platform = str(data.get("platform", "other")).casefold()
|
|
if platform not in {"ps1", "ps2", "other"}:
|
|
raise ValueError("platform must be ps1, ps2, or other.")
|
|
screenshot_directory = data.get("screenshot_directory")
|
|
if screenshot_directory is not None and not isinstance(screenshot_directory, str):
|
|
raise ValueError("screenshot_directory must be a path string or null.")
|
|
if screenshot_directory is not None:
|
|
screenshot_directory = screenshot_directory.strip() or None
|
|
if screenshot_directory:
|
|
if "\0" in screenshot_directory:
|
|
raise ValueError("screenshot_directory contains an invalid null character.")
|
|
try:
|
|
Path(screenshot_directory).expanduser()
|
|
except (RuntimeError, ValueError) as exc:
|
|
raise ValueError("screenshot_directory is not a valid path.") from exc
|
|
raw_match_types = data.get("match_types")
|
|
if not isinstance(raw_match_types, list) or not raw_match_types:
|
|
raise ValueError("match_types must contain at least one match type.")
|
|
match_types: list[dict[str, Any]] = []
|
|
seen_match_types: set[str] = set()
|
|
for item in raw_match_types:
|
|
if not isinstance(item, dict):
|
|
raise ValueError("Each match type must be a JSON object.")
|
|
name = item.get("name")
|
|
sides = item.get("sides")
|
|
if not isinstance(name, str) or not name.strip() or name.casefold() in seen_match_types:
|
|
raise ValueError("Match type names must be non-empty and unique.")
|
|
if (not isinstance(sides, list) or len(sides) < 2
|
|
or any(not isinstance(count, int) or isinstance(count, bool) or count < 1
|
|
for count in sides)
|
|
or sum(sides) > len(roster)):
|
|
raise ValueError(f"Match type '{name}' has invalid side sizes.")
|
|
outcomes = item.get("outcomes", list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES)))
|
|
if not isinstance(outcomes, list) or not outcomes or any(
|
|
not isinstance(outcome, str) or not outcome.strip() for outcome in outcomes
|
|
):
|
|
raise ValueError(f"Match type '{name}' must have at least one valid outcome.")
|
|
base_rating = item.get("base_rating", app.MATCH_BASE_RATINGS.get(name, 4.5))
|
|
if (not isinstance(base_rating, (int, float)) or isinstance(base_rating, bool)
|
|
or not math.isfinite(base_rating) or not 0 <= base_rating <= 100):
|
|
raise ValueError(f"Match type '{name}' has an invalid base_rating.")
|
|
match_types.append({"name": name.strip(), "sides": list(sides),
|
|
"outcomes": [outcome.strip() for outcome in outcomes],
|
|
"base_rating": float(base_rating)})
|
|
seen_match_types.add(name.casefold())
|
|
raw_titles = data.get("championships", [])
|
|
if not isinstance(raw_titles, list):
|
|
raise ValueError("championships must be a list.")
|
|
championships: list[dict[str, str]] = []
|
|
seen_titles: set[str] = set()
|
|
for item in raw_titles:
|
|
if not isinstance(item, dict):
|
|
raise ValueError("Each championship must be a JSON object with name and kind.")
|
|
name = item.get("name")
|
|
if not isinstance(name, str) or not name.strip() or name.casefold() in seen_titles:
|
|
raise ValueError("Championship names must be non-empty and unique.")
|
|
default_kind = "light_heavyweight" if name.casefold() == "light-heavyweight" else "singles"
|
|
kind = str(item.get("kind", default_kind)).casefold()
|
|
if kind not in {"singles", "tag", "women", "light_heavyweight"}:
|
|
raise ValueError(
|
|
f"Championship '{name}' kind must be singles, tag, women, or light_heavyweight."
|
|
)
|
|
championships.append({"name": name.strip(), "kind": kind})
|
|
seen_titles.add(name.casefold())
|
|
women = data.get("women_roster", [])
|
|
if not isinstance(women, list) or any(name not in roster for name in women):
|
|
raise ValueError("women_roster must be a subset of the roster.")
|
|
light_heavyweight = data.get("light_heavyweight_roster", [])
|
|
if (not isinstance(light_heavyweight, list)
|
|
or any(name not in roster for name in light_heavyweight)):
|
|
raise ValueError("light_heavyweight_roster must be a subset of the roster.")
|
|
if (any(title["kind"] == "light_heavyweight" for title in championships)
|
|
and not light_heavyweight):
|
|
raise ValueError("light_heavyweight_roster must contain eligible wrestlers.")
|
|
raw_shows = data.get("shows")
|
|
if raw_shows is not None and not isinstance(raw_shows, dict):
|
|
raise ValueError("shows must be an object with Raw, SmackDown, and PPV names.")
|
|
shows: dict[str, str] = {}
|
|
seen_show_names: set[str] = set()
|
|
for brand in app.SHOW_BRANDS:
|
|
name = (raw_shows or {}).get(brand, app.DEFAULT_CUSTOM_SHOWS[brand])
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ValueError(f"Show name for '{brand}' must be a non-empty string.")
|
|
name = name.strip()
|
|
if name.casefold() in seen_show_names:
|
|
raise ValueError("Show names must be unique, ignoring capitalization.")
|
|
seen_show_names.add(name.casefold())
|
|
shows[brand] = name
|
|
return {
|
|
"schema_version": 1,
|
|
"game_id": game_id,
|
|
"game_title": game_title_value.strip(),
|
|
"federation_name": federation.strip(),
|
|
"platform": platform,
|
|
"screenshot_directory": screenshot_directory,
|
|
"roster": list(roster),
|
|
"match_types": match_types,
|
|
"championships": championships,
|
|
"women_roster": list(dict.fromkeys(women)),
|
|
"light_heavyweight_roster": list(dict.fromkeys(light_heavyweight)),
|
|
"shows": shows,
|
|
}
|
|
|
|
|
|
def save_custom_game(data: dict[str, Any], directory: Path | None = None) -> dict[str, Any]:
|
|
config = app.validate_custom_game(data)
|
|
directory = directory or app.CUSTOM_GAME_DIR
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / f"{config['game_id']}.json"
|
|
temporary = path.with_suffix(".json.tmp")
|
|
temporary.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8")
|
|
os.replace(temporary, path)
|
|
if directory.resolve() == app.CUSTOM_GAME_DIR.resolve():
|
|
app.refresh_custom_games(directory)
|
|
return config
|
|
|
|
|
|
def builtin_game_profile(game_id: str) -> dict[str, Any]:
|
|
return {
|
|
"game_id": game_id,
|
|
"game_title": app.game_title(game_id),
|
|
"federation_name": app.federation_name(game_id),
|
|
"platform": "ps1" if game_id in app.PS1_GAMES else "ps2",
|
|
"roster": app.load_roster(game_id),
|
|
"match_types": app.match_type_definitions(game_id),
|
|
"championships": app.title_definitions_for_game(game_id),
|
|
"women_roster": sorted(app.WOMEN_ROSTERS.get(game_id, set())),
|
|
"light_heavyweight_roster": None,
|
|
"shows": {"Raw": "Monday Night Raw", "SmackDown": "Thursday Night SmackDown!",
|
|
"PPV": "WWF PPV"},
|
|
}
|
|
|
|
|
|
def new_custom_game_id(federation: str, game_title_value: str) -> str:
|
|
base = f"custom-{app.slugify(federation + '-' + game_title_value)}"
|
|
candidate = base
|
|
suffix = 2
|
|
while candidate in app.GAMES or (app.CUSTOM_GAME_DIR / f"{candidate}.json").exists():
|
|
candidate = f"{base}-{suffix}"
|
|
suffix += 1
|
|
return candidate
|
|
|
|
|
|
def parse_side_sizes(raw: str) -> list[int] | None:
|
|
try:
|
|
sizes = [int(part.strip()) for part in raw.split(",")]
|
|
except ValueError:
|
|
return None
|
|
if len(sizes) < 2 or any(size < 1 for size in sizes):
|
|
return None
|
|
return sizes
|
|
|
|
|
|
def default_custom_match_types(roster_size: int) -> list[dict[str, Any]]:
|
|
defaults = [
|
|
("Singles", [1, 1]),
|
|
("Tag Team", [2, 2]),
|
|
("Triple Threat", [1, 1, 1]),
|
|
("Fatal 4-Way", [1, 1, 1, 1]),
|
|
("Hardcore", [1, 1]),
|
|
("Ladder", [1, 1]),
|
|
("Cage", [1, 1]),
|
|
]
|
|
return [
|
|
{"name": name, "sides": sides,
|
|
"base_rating": app.MATCH_BASE_RATINGS.get(name, 4.5),
|
|
"outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES))}
|
|
for name, sides in defaults if sum(sides) <= roster_size
|
|
]
|
|
|
|
__all__ = ['builtin_game_profile', 'championship_kind', 'championship_names_for_game', 'default_custom_match_types', 'federation_name', 'game_display_name', 'game_profile', 'game_title', 'is_wwf_federation', 'load_roster', 'match_type_definition', 'match_type_definitions', 'new_custom_game_id', 'parse_side_sizes', 'refresh_custom_games', 'save_custom_game', 'season_championship_kind', 'season_game_profile', 'season_title_names', 'shows_snapshot', 'title_definitions_for_game', 'validate_custom_game']
|