LVPWB/lvpwb/games.py

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']