3046 lines
130 KiB
Python
3046 lines
130 KiB
Python
#!/usr/bin/env python3
|
|
"""Interactive WWF SmackDown! match-card maker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import random
|
|
import re
|
|
import select
|
|
import shutil
|
|
import sys
|
|
import textwrap
|
|
import time
|
|
from datetime import date, datetime
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
CARD_DIR = ROOT / "cards"
|
|
SEASON_DIR = ROOT / "seasons"
|
|
EDIT_DIR = ROOT / "edit"
|
|
CUSTOM_GAME_DIR = EDIT_DIR / "games"
|
|
DUCKSTATION_SCREENSHOT_DIR = Path.home() / ".local/share/duckstation/screenshots"
|
|
SESSION_START = datetime.now().astimezone()
|
|
PS1_GAMES = {"sd1", "sd2"}
|
|
SEASON_CYCLE = ("Raw", "SmackDown", "PPV")
|
|
SHOW_BRANDS = SEASON_CYCLE
|
|
# Neutral show-name placeholders for BYOG federations; WWE show names stay
|
|
# exclusive to the built-in games.
|
|
DEFAULT_CUSTOM_SHOWS = {
|
|
"Raw": "Weekly Show #1",
|
|
"SmackDown": "Weekly Show #2",
|
|
"PPV": "Big Pay-Per-View",
|
|
}
|
|
CHAMPIONSHIPS = (
|
|
"Tag Team",
|
|
"World Wrestling Federation",
|
|
"Intercontinental",
|
|
"European",
|
|
"Hardcore",
|
|
"Light-Heavyweight",
|
|
"Women's",
|
|
)
|
|
PPV_TITLE_WEIGHT = 1.5
|
|
PPV_RIVALRY_WEIGHT = 1.5
|
|
MATCH_BASE_RATINGS = {
|
|
"Singles": 4.5,
|
|
"Tag Team": 4.5,
|
|
"Tornado Tag": 4.5,
|
|
"Handicap": 4.5,
|
|
"Triple Threat": 5.5,
|
|
"Fatal 4-Way": 5.5,
|
|
"Battle Royal": 5.5,
|
|
"Cage": 6.5,
|
|
"Ladder": 6.5,
|
|
"Hardcore": 7.5,
|
|
"Table": 7.5,
|
|
"TLC": 7.5,
|
|
"Hell in a Cell": 9.5,
|
|
"I Quit": 9.5,
|
|
}
|
|
MATCH_TIME_BONUS_PER_MINUTE = 0.1
|
|
MATCH_TIME_BONUS_CAP = 5.0
|
|
MATCH_ACTION_BONUS_CAP = 10.0
|
|
FINISHER_BONUS = 0.5
|
|
NEAR_FALL_BONUS = 0.2
|
|
TITLE_MATCH_MULTIPLIER = 1.5
|
|
PPV_MATCH_WEIGHTS = (0.2, 0.3, 0.5)
|
|
MATCH_OUTCOME_OPTIONS = {
|
|
"Ladder": ("Item retrieval",),
|
|
"TLC": ("Item retrieval",),
|
|
"Table": ("Table break",),
|
|
"Battle Royal": ("Last wrestler standing",),
|
|
"Cage": ("Escape the cage",),
|
|
}
|
|
STANDARD_MATCH_OUTCOMES = ("Pinfall", "Submission")
|
|
WOMEN_ROSTERS = {
|
|
"sd1": {"Debra", "Tori", "Chyna"},
|
|
"sd2": {"Ivory", "Jacqueline", "The Kat", "Lita", "Chyna",
|
|
"Stephanie McMahon", "Tori", "Trish Stratus"},
|
|
"sd3": {"Ivory", "Lita", "Molly Holly", "Stephanie McMahon-Helmsley",
|
|
"Trish Stratus"},
|
|
}
|
|
GAMES: dict[str, dict[str, str]] = {
|
|
"sd1": {"title": "WWF SmackDown!", "roster": "smackdown1roster.json"},
|
|
"sd2": {"title": "WWF SmackDown! 2: Know Your Role", "roster": "smackdown2roster.json"},
|
|
"sd3": {"title": "WWF SmackDown! Just Bring It", "roster": "smackdown3roster.json"},
|
|
}
|
|
|
|
# Each tuple describes the number of wrestlers on each side. Multi-way matches
|
|
# have one wrestler per side; handicap matches have two on the first side.
|
|
MATCH_TYPES: dict[str, list[tuple[str, tuple[int, ...]]]] = {
|
|
"sd1": [
|
|
("Singles", (1, 1)),
|
|
("Tag Team", (2, 2)),
|
|
("Handicap", (2, 1)),
|
|
("Triple Threat", (1, 1, 1)),
|
|
("Fatal 4-Way", (1, 1, 1, 1)),
|
|
("Battle Royal", (1, 1, 1, 1)),
|
|
("Cage", (1, 1)),
|
|
("Hardcore", (1, 1)),
|
|
],
|
|
"sd2": [
|
|
("Singles", (1, 1)),
|
|
("Tag Team", (2, 2)),
|
|
("Tornado Tag", (2, 2)),
|
|
("Handicap", (2, 1)),
|
|
("Triple Threat", (1, 1, 1)),
|
|
("Fatal 4-Way", (1, 1, 1, 1)),
|
|
("Battle Royal", (1, 1, 1, 1)),
|
|
("Cage", (1, 1)),
|
|
("Hardcore", (1, 1)),
|
|
("Ladder", (1, 1)),
|
|
("Table", (1, 1)),
|
|
("Hell in a Cell", (1, 1)),
|
|
],
|
|
"sd3": [
|
|
("Singles", (1, 1)),
|
|
("Tag Team", (2, 2)),
|
|
("Tornado Tag", (2, 2)),
|
|
("Handicap", (2, 1)),
|
|
("Triple Threat", (1, 1, 1)),
|
|
("Fatal 4-Way", (1, 1, 1, 1)),
|
|
("Battle Royal", (1, 1, 1, 1)),
|
|
("Cage", (1, 1)),
|
|
("Hardcore", (1, 1)),
|
|
("Ladder", (1, 1)),
|
|
("Table", (1, 1)),
|
|
("TLC", (2, 2)),
|
|
("Hell in a Cell", (1, 1)),
|
|
],
|
|
}
|
|
BUILTIN_GAME_IDS = frozenset(GAMES)
|
|
CUSTOM_GAME_CONFIGS: dict[str, dict[str, Any]] = {}
|
|
|
|
# Typical live-event attendance per city; randomization picks a city then an
|
|
# attendance within its venue range. Unknown cities use the default range.
|
|
CITY_ATTENDANCE_RANGES: dict[str, tuple[int, int]] = {
|
|
# United States
|
|
"New York, NY": (17000, 19800),
|
|
"Boston, MA": (14500, 17500),
|
|
"Philadelphia, PA": (15000, 19500),
|
|
"Chicago, IL": (18000, 22000),
|
|
"Detroit, MI": (15000, 20000),
|
|
"Atlanta, GA": (12000, 17000),
|
|
"Dallas, TX": (14000, 18000),
|
|
"Los Angeles, CA": (15000, 19000),
|
|
"San Francisco, CA": (13000, 16000),
|
|
"Seattle, WA": (12000, 15500),
|
|
"Minneapolis, MN": (12000, 16500),
|
|
"Nashville, TN": (10000, 15000),
|
|
"Miami, FL": (12500, 15500),
|
|
# Canada
|
|
"Toronto, ON": (15000, 19000),
|
|
"Montreal, QC": (16000, 20000),
|
|
"Vancouver, BC": (14000, 17000),
|
|
"Calgary, AB": (9500, 13000),
|
|
# Europe
|
|
"London, England": (12000, 17000),
|
|
"Manchester, England": (9000, 12500),
|
|
"Dublin, Ireland": (9000, 13000),
|
|
"Paris, France": (13000, 17000),
|
|
"Berlin, Germany": (11000, 14500),
|
|
"Amsterdam, Netherlands": (9000, 12000),
|
|
"Madrid, Spain": (10000, 13500),
|
|
"Milan, Italy": (9000, 12500),
|
|
# Japan
|
|
"Tokyo, Japan": (15000, 45000),
|
|
"Yokohama, Japan": (12000, 18000),
|
|
"Osaka, Japan": (11000, 17000),
|
|
"Nagoya, Japan": (9000, 12000),
|
|
"Sapporo, Japan": (8000, 11000),
|
|
"Fukuoka, Japan": (8000, 11000),
|
|
}
|
|
DEFAULT_ATTENDANCE_RANGE = (7000, 15000)
|
|
|
|
RESET = "\033[0m"
|
|
RED = "\033[31m"
|
|
YELLOW = "\033[33m"
|
|
CYAN = "\033[36m"
|
|
DIM = "\033[2m"
|
|
|
|
|
|
def ask(prompt: str, default: str | None = None) -> str:
|
|
suffix = f" [{default}]" if default is not None else ""
|
|
answer = input(f"{prompt}{suffix}: ").strip()
|
|
return answer if answer else (default or "")
|
|
|
|
|
|
def ask_yes_no(prompt: str, default: bool | None = None) -> bool:
|
|
suffix = " [Y/n]" if default is True else " [y/N]" if default is False else " [y/n]"
|
|
while True:
|
|
answer = input(f"{prompt}{suffix}: ").strip().casefold()
|
|
if not answer and default is not None:
|
|
return default
|
|
if answer in ("y", "yes"):
|
|
return True
|
|
if answer in ("n", "no"):
|
|
return False
|
|
print("Enter y/yes or n/no.")
|
|
|
|
|
|
def ask_choice(
|
|
prompt: str,
|
|
choices: list[Any],
|
|
default: int | None = None,
|
|
labels: list[str] | None = None,
|
|
) -> int:
|
|
labels = labels or [str(choice) for choice in choices]
|
|
if len(labels) != len(choices):
|
|
raise ValueError("Choice labels must match the number of choices.")
|
|
while True:
|
|
print(f"\n{prompt}:")
|
|
for number, label in enumerate(labels, 1):
|
|
print(f" {number}. {label}")
|
|
raw = ask("Selection", str(default) if default is not None else None)
|
|
try:
|
|
choice = int(raw)
|
|
if 1 <= choice <= len(choices):
|
|
return choice - 1
|
|
except ValueError:
|
|
pass
|
|
print(f"Enter a number from 1 to {len(choices)}.")
|
|
|
|
|
|
def load_roster(game_id: str) -> list[str]:
|
|
"""Load and validate a game's roster from the shared JSON schema."""
|
|
if game_id in CUSTOM_GAME_CONFIGS:
|
|
return list(CUSTOM_GAME_CONFIGS[game_id]["roster"])
|
|
path = ROOT / 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") != GAMES[game_id]["title"]:
|
|
raise ValueError(f"Roster game title does not match {game_id}: {path}")
|
|
return names
|
|
|
|
|
|
def slugify(value: str) -> str:
|
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
return slug or "smackdown-show"
|
|
|
|
|
|
def game_title(game_id: str) -> str:
|
|
return GAMES[game_id]["title"]
|
|
|
|
|
|
def federation_name(game_id: str) -> str:
|
|
if game_id in CUSTOM_GAME_CONFIGS:
|
|
return CUSTOM_GAME_CONFIGS[game_id]["federation_name"]
|
|
return "World Wrestling Federation"
|
|
|
|
|
|
def game_display_name(game_id: str) -> str:
|
|
if game_id in CUSTOM_GAME_CONFIGS:
|
|
config = CUSTOM_GAME_CONFIGS[game_id]
|
|
return f"{config['federation_name']} — {config['game_title']}"
|
|
return 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 CUSTOM_GAME_CONFIGS:
|
|
return [dict(title) for title in CUSTOM_GAME_CONFIGS[game_id]["championships"]]
|
|
return [
|
|
{"name": name, "kind": "tag" if name == "Tag Team" else "women" if name == "Women's" else "singles"}
|
|
for name in CHAMPIONSHIPS
|
|
]
|
|
|
|
|
|
def championship_names_for_game(game_id: str,
|
|
profile: dict[str, Any] | None = None) -> list[str]:
|
|
return [title["name"] for title in 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 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: show_name_for_brand(
|
|
brand, shows if isinstance(shows, dict) else None) for brand in 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 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 championship_kind(season["game_id"], title_name)
|
|
|
|
|
|
def game_profile(game_id: str) -> dict[str, Any]:
|
|
if game_id in CUSTOM_GAME_CONFIGS:
|
|
return json.loads(json.dumps(CUSTOM_GAME_CONFIGS[game_id]))
|
|
return 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 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 CUSTOM_GAME_CONFIGS:
|
|
return [dict(item) for item in CUSTOM_GAME_CONFIGS[game_id]["match_types"]]
|
|
return [
|
|
{
|
|
"name": name,
|
|
"sides": list(sides),
|
|
"base_rating": MATCH_BASE_RATINGS.get(name, 4.5),
|
|
"outcomes": list(MATCH_OUTCOME_OPTIONS.get(name, STANDARD_MATCH_OUTCOMES)),
|
|
}
|
|
for name, sides in 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 match_type_definitions(game_id, profile):
|
|
if definition["name"] == match_type:
|
|
return definition
|
|
return {"name": match_type, "sides": [1, 1],
|
|
"base_rating": MATCH_BASE_RATINGS.get(match_type, 4.5),
|
|
"outcomes": list(MATCH_OUTCOME_OPTIONS.get(match_type, STANDARD_MATCH_OUTCOMES))}
|
|
|
|
|
|
def refresh_custom_games(directory: Path | None = None) -> list[str]:
|
|
"""Load user-managed game definitions without changing built-in profiles."""
|
|
global CUSTOM_GAME_CONFIGS
|
|
for game_id in list(CUSTOM_GAME_CONFIGS):
|
|
GAMES.pop(game_id, None)
|
|
MATCH_TYPES.pop(game_id, None)
|
|
CUSTOM_GAME_CONFIGS = {}
|
|
directory = directory or 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 = validate_custom_game(raw)
|
|
game_id = config["game_id"]
|
|
if game_id in BUILTIN_GAME_IDS or game_id in CUSTOM_GAME_CONFIGS:
|
|
raise ValueError(f"Game ID is already in use: {game_id}")
|
|
CUSTOM_GAME_CONFIGS[game_id] = config
|
|
GAMES[game_id] = {
|
|
"title": config["game_title"],
|
|
"roster": str(path),
|
|
"federation": config["federation_name"],
|
|
"platform": config["platform"],
|
|
}
|
|
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 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.")
|
|
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(MATCH_OUTCOME_OPTIONS.get(name, 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", 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, kind = item.get("name"), str(item.get("kind", "singles")).casefold()
|
|
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.")
|
|
if kind not in {"singles", "tag", "women"}:
|
|
raise ValueError(f"Championship '{name}' kind must be singles, tag, or women.")
|
|
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.")
|
|
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 SHOW_BRANDS:
|
|
name = (raw_shows or {}).get(brand, 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,
|
|
"roster": list(roster),
|
|
"match_types": match_types,
|
|
"championships": championships,
|
|
"women_roster": list(dict.fromkeys(women)),
|
|
"shows": shows,
|
|
}
|
|
|
|
|
|
def save_custom_game(data: dict[str, Any], directory: Path | None = None) -> dict[str, Any]:
|
|
config = validate_custom_game(data)
|
|
directory = directory or 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() == CUSTOM_GAME_DIR.resolve():
|
|
refresh_custom_games(directory)
|
|
return config
|
|
|
|
|
|
def builtin_game_profile(game_id: str) -> dict[str, Any]:
|
|
return {
|
|
"game_id": game_id,
|
|
"game_title": game_title(game_id),
|
|
"federation_name": federation_name(game_id),
|
|
"platform": "ps1" if game_id in PS1_GAMES else "ps2",
|
|
"roster": load_roster(game_id),
|
|
"match_types": match_type_definitions(game_id),
|
|
"championships": title_definitions_for_game(game_id),
|
|
"women_roster": sorted(WOMEN_ROSTERS.get(game_id, set())),
|
|
"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-{slugify(federation + '-' + game_title_value)}"
|
|
candidate = base
|
|
suffix = 2
|
|
while candidate in GAMES or (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": MATCH_BASE_RATINGS.get(name, 4.5),
|
|
"outcomes": list(MATCH_OUTCOME_OPTIONS.get(name, STANDARD_MATCH_OUTCOMES))}
|
|
for name, sides in defaults if sum(sides) <= roster_size
|
|
]
|
|
|
|
|
|
def edit_custom_roster(config: dict[str, Any]) -> None:
|
|
while True:
|
|
choices = ["Add wrestler", "Remove wrestler", "Done"]
|
|
labels = ["Add a wrestler", "Remove a wrestler", f"Done ({len(config['roster'])} listed)"]
|
|
action = ask_choice("Edit roster", choices, labels=labels)
|
|
if action == 2:
|
|
break
|
|
if action == 0:
|
|
name = ask("Wrestler name").strip()
|
|
if not name:
|
|
print("Wrestler name cannot be blank.")
|
|
elif any(existing.casefold() == name.casefold() for existing in config["roster"]):
|
|
print("That wrestler is already in this roster.")
|
|
else:
|
|
config["roster"].append(name)
|
|
elif not config["roster"]:
|
|
print("The roster is empty.")
|
|
else:
|
|
index = ask_choice("Remove wrestler", config["roster"])
|
|
removed = config["roster"].pop(index)
|
|
config["women_roster"] = [name for name in config["women_roster"]
|
|
if name.casefold() != removed.casefold()]
|
|
|
|
|
|
def edit_custom_match_types(config: dict[str, Any]) -> None:
|
|
while True:
|
|
existing = config["match_types"]
|
|
choices = list(range(len(existing))) + ["add", "remove", "done"]
|
|
labels = [f"{item['name']} ({' vs '.join(map(str, item['sides']))}, "
|
|
f"base {item['base_rating']:.1f}%)" for item in existing]
|
|
labels.extend(["Add a match type", "Remove a match type", "Done"])
|
|
action = ask_choice("Edit match types", choices, labels=labels)
|
|
if action == len(existing) + 2:
|
|
return
|
|
if action == len(existing) + 1:
|
|
if not existing:
|
|
print("Add at least one match type before removing one.")
|
|
continue
|
|
remove_index = ask_choice("Remove which match type", list(range(len(existing))),
|
|
labels=[item["name"] for item in existing])
|
|
existing.pop(remove_index)
|
|
continue
|
|
if action == len(existing):
|
|
name = ask("New match type name").strip()
|
|
if not name:
|
|
print("Match type name cannot be blank.")
|
|
continue
|
|
if any(item["name"].casefold() == name.casefold() for item in existing):
|
|
print("That match type already exists.")
|
|
continue
|
|
side_sizes = parse_side_sizes(ask("Wrestler counts per side, comma-separated (e.g. 1,1)"))
|
|
if side_sizes is None:
|
|
print("Enter at least two positive side sizes, such as 1,1 or 2,2.")
|
|
continue
|
|
if sum(side_sizes) > len(config["roster"]):
|
|
print("This match type needs more wrestlers than the roster contains.")
|
|
continue
|
|
base_default = MATCH_BASE_RATINGS.get(name, 4.5)
|
|
while True:
|
|
try:
|
|
base_rating = float(ask("Base rating percentage", str(base_default)))
|
|
if math.isfinite(base_rating) and 0 <= base_rating <= 100:
|
|
break
|
|
except ValueError:
|
|
pass
|
|
print("Enter a rating from 0 to 100.")
|
|
default_outcomes = MATCH_OUTCOME_OPTIONS.get(name, STANDARD_MATCH_OUTCOMES)
|
|
raw_outcomes = ask("Legal outcomes, comma-separated", ", ".join(default_outcomes))
|
|
outcomes = [outcome.strip() for outcome in raw_outcomes.split(",") if outcome.strip()]
|
|
if not outcomes:
|
|
print("At least one legal outcome is required.")
|
|
continue
|
|
existing.append({"name": name, "sides": side_sizes,
|
|
"base_rating": base_rating, "outcomes": outcomes})
|
|
|
|
|
|
def edit_custom_titles(config: dict[str, Any]) -> None:
|
|
while True:
|
|
existing = config["championships"]
|
|
choices = list(range(len(existing))) + ["add", "remove", "done"]
|
|
labels = [f"{item['name']} ({item['kind']})" for item in existing]
|
|
labels.extend(["Add a championship", "Remove a championship", "Done"])
|
|
action = ask_choice("Edit championship rankings", choices, labels=labels)
|
|
if action == len(existing) + 2:
|
|
return
|
|
if action == len(existing) + 1:
|
|
if not existing:
|
|
print("There are no championships to remove.")
|
|
continue
|
|
remove_index = ask_choice("Remove which championship", list(range(len(existing))),
|
|
labels=[item["name"] for item in existing])
|
|
existing.pop(remove_index)
|
|
continue
|
|
if action == len(existing):
|
|
name = ask("Championship name").strip()
|
|
if not name or any(item["name"].casefold() == name.casefold() for item in existing):
|
|
print("Enter a non-empty championship name not already listed.")
|
|
continue
|
|
kind_options = ["singles", "tag", "women"]
|
|
kind_labels = ["Singles / open", "Tag team", "Women's division"]
|
|
kind = kind_options[ask_choice("Championship type", kind_options, labels=kind_labels)]
|
|
existing.append({"name": name, "kind": kind})
|
|
|
|
|
|
def edit_women_roster(config: dict[str, Any]) -> None:
|
|
while True:
|
|
choices = ["add", "remove", "done"]
|
|
labels = ["Add eligible wrestler", "Remove eligible wrestler", "Done"]
|
|
action = ask_choice("Women's division eligibility", choices, labels=labels)
|
|
eligible = config["women_roster"]
|
|
if action == 2:
|
|
return
|
|
if action == 0:
|
|
available = [name for name in config["roster"]
|
|
if name.casefold() not in {item.casefold() for item in eligible}]
|
|
if not available:
|
|
print("Every roster member is already listed.")
|
|
continue
|
|
selected = choose_roster_member(available, "Add which wrestler")
|
|
eligible.append(selected)
|
|
elif not eligible:
|
|
print("No eligible wrestlers are listed.")
|
|
else:
|
|
remove_index = ask_choice("Remove eligible wrestler", eligible)
|
|
eligible.pop(remove_index)
|
|
|
|
|
|
def edit_custom_shows(config: dict[str, Any]) -> None:
|
|
while True:
|
|
shows = config["shows"]
|
|
labels = [f"{brand}: {shows[brand]}" for brand in SHOW_BRANDS] + ["Done"]
|
|
action = ask_choice("Edit show names", list(SHOW_BRANDS) + ["done"], labels=labels)
|
|
if action == len(SHOW_BRANDS):
|
|
return
|
|
brand = SHOW_BRANDS[action]
|
|
while True:
|
|
name = ask(f"{brand} show name", shows[brand]).strip()
|
|
if not name:
|
|
print("Show name cannot be blank.")
|
|
continue
|
|
others = [shows[other] for other in SHOW_BRANDS if other != brand]
|
|
if any(other.casefold() == name.casefold() for other in others):
|
|
print("Show names must be distinct from each other.")
|
|
continue
|
|
shows[brand] = name
|
|
break
|
|
|
|
|
|
def edit_custom_profile(config: dict[str, Any]) -> dict[str, Any]:
|
|
while True:
|
|
options = ["identity", "shows", "roster", "match_types", "titles", "women", "save"]
|
|
labels = ["Game title and federation",
|
|
f"Show names (Raw: {config['shows']['Raw']})",
|
|
f"Edit roster ({len(config['roster'])} wrestlers)",
|
|
f"Edit match types ({len(config['match_types'])})",
|
|
f"Edit championships ({len(config['championships'])})",
|
|
f"Edit Women's eligibility ({len(config['women_roster'])})", "Validate and save"]
|
|
action = ask_choice("BYOG editor", options, labels=labels)
|
|
if action == 0:
|
|
config["game_title"] = ask("Game title", config["game_title"]).strip()
|
|
config["federation_name"] = ask("Federation name", config["federation_name"]).strip()
|
|
platform_options = ["ps1", "ps2", "other"]
|
|
platform_labels = ["PlayStation 1", "PlayStation 2", "Other / platform agnostic"]
|
|
config["platform"] = platform_options[
|
|
ask_choice("Platform", platform_options, labels=platform_labels)
|
|
]
|
|
elif action == 1:
|
|
edit_custom_shows(config)
|
|
elif action == 2:
|
|
edit_custom_roster(config)
|
|
elif action == 3:
|
|
edit_custom_match_types(config)
|
|
elif action == 4:
|
|
edit_custom_titles(config)
|
|
elif action == 5:
|
|
edit_women_roster(config)
|
|
else:
|
|
try:
|
|
return save_custom_game(config)
|
|
except ValueError as exc:
|
|
print(f"Cannot save game profile: {exc}")
|
|
|
|
|
|
def builtin_profile_for_copy(game_id: str, custom_id: str) -> dict[str, Any]:
|
|
definitions = [
|
|
{"name": name, "sides": list(sides),
|
|
"base_rating": MATCH_BASE_RATINGS.get(name, 4.5),
|
|
"outcomes": list(MATCH_OUTCOME_OPTIONS.get(name, STANDARD_MATCH_OUTCOMES))}
|
|
for name, sides in MATCH_TYPES[game_id]
|
|
]
|
|
return validate_custom_game({
|
|
"schema_version": 1,
|
|
"game_id": custom_id,
|
|
"game_title": game_title(game_id) + " Custom Edition",
|
|
"federation_name": federation_name(game_id),
|
|
"platform": "ps1" if game_id in PS1_GAMES else "ps2",
|
|
"roster": load_roster(game_id),
|
|
"match_types": definitions,
|
|
"championships": title_definitions_for_game(game_id),
|
|
"women_roster": sorted(WOMEN_ROSTERS.get(game_id, set())),
|
|
"shows": {"Raw": "Monday Night Raw", "SmackDown": "Thursday Night SmackDown!",
|
|
"PPV": "WWF PPV"},
|
|
})
|
|
|
|
|
|
def create_custom_game() -> dict[str, Any]:
|
|
title = ask("Game title").strip()
|
|
while not title:
|
|
title = ask("Game title cannot be blank; enter a game title").strip()
|
|
federation = ask("Federation name", "My Wrestling Federation").strip()
|
|
if not federation:
|
|
federation = "My Wrestling Federation"
|
|
platform_options = ["ps1", "ps2", "other"]
|
|
platform_labels = ["PlayStation 1", "PlayStation 2", "Other / platform agnostic"]
|
|
platform = platform_options[ask_choice("Platform", platform_options, labels=platform_labels)]
|
|
shows: dict[str, str] = {}
|
|
print("\nName this federation's three shows (weekly #1, weekly #2, and the big event).")
|
|
for brand in SHOW_BRANDS:
|
|
default = DEFAULT_CUSTOM_SHOWS[brand]
|
|
while True:
|
|
name = ask(f"{brand} show name", default).strip() or default
|
|
if any(other.casefold() == name.casefold() for other in shows.values()):
|
|
print("Show names must be distinct from each other.")
|
|
continue
|
|
shows[brand] = name
|
|
break
|
|
config: dict[str, Any] = {
|
|
"schema_version": 1,
|
|
"game_id": new_custom_game_id(federation, title),
|
|
"game_title": title,
|
|
"federation_name": federation,
|
|
"platform": platform,
|
|
"roster": [],
|
|
"match_types": [],
|
|
"championships": [],
|
|
"women_roster": [],
|
|
"shows": shows,
|
|
}
|
|
edit_custom_roster(config)
|
|
config["match_types"] = default_custom_match_types(len(config["roster"]))
|
|
if not config["match_types"]:
|
|
config["match_types"] = [{"name": "Singles", "sides": [1, 1],
|
|
"base_rating": 4.5,
|
|
"outcomes": list(STANDARD_MATCH_OUTCOMES)}]
|
|
print("\nAdd this federation's championship divisions (or finish with none).")
|
|
edit_custom_titles(config)
|
|
if any(title["kind"] == "women" for title in config["championships"]):
|
|
edit_women_roster(config)
|
|
return edit_custom_profile(config)
|
|
|
|
|
|
def choose_builtin_game() -> str:
|
|
ids = list(BUILTIN_GAME_IDS)
|
|
return ids[ask_choice("Built-in game to copy", ids,
|
|
labels=[GAMES[game_id]["title"] for game_id in ids])]
|
|
|
|
|
|
def clone_builtin_game() -> dict[str, Any]:
|
|
refresh_custom_games()
|
|
source_id = choose_builtin_game()
|
|
title = ask("Custom game title", game_title(source_id) + " Custom Edition").strip()
|
|
federation = ask("Federation name", federation_name(source_id)).strip()
|
|
config = builtin_profile_for_copy(source_id, new_custom_game_id(federation, title))
|
|
config["game_title"] = title or config["game_title"]
|
|
config["federation_name"] = federation or config["federation_name"]
|
|
print(f"Copied {game_title(source_id)} into the editable BYOG directory.")
|
|
return edit_custom_profile(config)
|
|
|
|
|
|
def edit_existing_custom_game() -> None:
|
|
refresh_custom_games()
|
|
game_ids = sorted(CUSTOM_GAME_CONFIGS)
|
|
if not game_ids:
|
|
print("No custom games exist yet. Create or copy one first.")
|
|
return
|
|
labels = [game_display_name(game_id) for game_id in game_ids]
|
|
game_id = game_ids[ask_choice("Choose custom game to edit", game_ids, labels=labels)]
|
|
editable = json.loads(json.dumps(CUSTOM_GAME_CONFIGS[game_id]))
|
|
edit_custom_profile(editable)
|
|
refresh_custom_games()
|
|
|
|
|
|
def manage_games() -> None:
|
|
while True:
|
|
refresh_custom_games()
|
|
action = ask_choice(
|
|
"BYOG / Manage Games",
|
|
["create", "copy", "edit", "back"],
|
|
labels=["Create a new game and federation", "Copy a built-in WWF game to edit",
|
|
f"Edit a custom game ({len(CUSTOM_GAME_CONFIGS)} available)",
|
|
"Back to main menu"],
|
|
)
|
|
if action == 0:
|
|
create_custom_game()
|
|
elif action == 1:
|
|
clone_builtin_game()
|
|
elif action == 2:
|
|
edit_existing_custom_game()
|
|
else:
|
|
return
|
|
|
|
|
|
def default_card_path(show: str, show_date: str) -> Path:
|
|
CARD_DIR.mkdir(parents=True, exist_ok=True)
|
|
stem = f"{show_date}-{slugify(show)}"
|
|
path = CARD_DIR / f"{stem}.md"
|
|
suffix = 2
|
|
while path.exists():
|
|
path = CARD_DIR / f"{stem}-{suffix}.md"
|
|
suffix += 1
|
|
return path
|
|
|
|
|
|
def show_name_for_brand(brand: str, shows: dict[str, str] | None = None) -> str:
|
|
shows = shows or {}
|
|
if brand == "Raw":
|
|
return shows.get("Raw") or "Monday Night Raw"
|
|
if brand == "SmackDown":
|
|
return shows.get("SmackDown") or "Thursday Night SmackDown!"
|
|
return shows.get("PPV") or "WWF PPV"
|
|
|
|
|
|
def game_show_names(game_id: str) -> dict[str, str]:
|
|
"""Resolved show names for a registered game (WWE names for built-ins)."""
|
|
config = CUSTOM_GAME_CONFIGS.get(game_id, {})
|
|
shows = config.get("shows") if isinstance(config, dict) else None
|
|
return {brand: show_name_for_brand(brand, shows if isinstance(shows, dict) else None)
|
|
for brand in SHOW_BRANDS}
|
|
|
|
|
|
def is_ppv_card(card: dict[str, Any]) -> bool:
|
|
if card.get("season_show") == "PPV":
|
|
return True
|
|
show = str(card.get("show", ""))
|
|
folded = show.casefold()
|
|
if "ppv" in folded or "pay-per-view" in folded:
|
|
return True
|
|
game_id = str(card.get("game_id", ""))
|
|
if game_id:
|
|
ppv_name = game_show_names(game_id).get("PPV", "")
|
|
if ppv_name and folded == ppv_name.casefold():
|
|
return True
|
|
return False
|
|
|
|
|
|
def base_match_rating(match_type: str) -> float:
|
|
return MATCH_BASE_RATINGS.get(match_type, 4.5)
|
|
|
|
|
|
def calculate_match_rating(match: dict[str, Any]) -> float | None:
|
|
minutes = match.get("match_time_minutes")
|
|
if minutes is None:
|
|
return None
|
|
time_bonus = min(math.floor(max(0.0, float(minutes))) * MATCH_TIME_BONUS_PER_MINUTE,
|
|
MATCH_TIME_BONUS_CAP)
|
|
raw_action = (time_bonus
|
|
+ max(0, int(match.get("near_falls", 0))) * NEAR_FALL_BONUS
|
|
+ max(0, int(match.get("finishers", 0))) * FINISHER_BONUS)
|
|
action_bonus = min(raw_action, MATCH_ACTION_BONUS_CAP)
|
|
base_value = match.get("base_rating")
|
|
if not isinstance(base_value, (int, float)) or isinstance(base_value, bool):
|
|
base_value = base_match_rating(match.get("type", ""))
|
|
earned = float(base_value) + action_bonus
|
|
if match.get("title_defense"):
|
|
earned *= TITLE_MATCH_MULTIPLIER
|
|
return round(min(100.0, earned), 1)
|
|
|
|
|
|
def calculate_ppv_rating(card: dict[str, Any]) -> float | None:
|
|
if not is_ppv_card(card):
|
|
return None
|
|
matches = card.get("matches", [])
|
|
if len(matches) != 3 or any(match.get("rating") is None for match in matches):
|
|
return None
|
|
return round(sum(float(match["rating"]) * weight
|
|
for match, weight in zip(matches, PPV_MATCH_WEIGHTS)), 1)
|
|
|
|
|
|
def parse_match_minutes(raw: str) -> float | None:
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
return None
|
|
return value if math.isfinite(value) and value >= 0 else None
|
|
|
|
|
|
def parse_action_count(raw: str) -> int | None:
|
|
if not raw.isdigit():
|
|
return None
|
|
return int(raw)
|
|
|
|
|
|
def allowed_match_outcomes(match_type: str | dict[str, Any]) -> tuple[str, ...]:
|
|
if isinstance(match_type, dict):
|
|
outcomes = match_type.get("valid_outcomes")
|
|
if isinstance(outcomes, list) and outcomes:
|
|
return tuple(outcomes)
|
|
match_type = str(match_type.get("type", ""))
|
|
return MATCH_OUTCOME_OPTIONS.get(match_type, STANDARD_MATCH_OUTCOMES)
|
|
|
|
|
|
def choose_match_outcome(match_type: str | dict[str, Any]) -> str:
|
|
outcomes = allowed_match_outcomes(match_type)
|
|
if len(outcomes) == 1:
|
|
print(f"Match outcome: {outcomes[0]}")
|
|
return outcomes[0]
|
|
return outcomes[ask_choice("Winning method", list(outcomes))]
|
|
|
|
|
|
def prompt_match_minutes() -> float:
|
|
while True:
|
|
raw = ask("Match time in minutes")
|
|
value = parse_match_minutes(raw)
|
|
if value is not None:
|
|
return value
|
|
print("Enter a non-negative number of minutes (decimals are okay).")
|
|
|
|
|
|
def prompt_action_count(label: str) -> int:
|
|
while True:
|
|
raw = ask(label, "0")
|
|
value = parse_action_count(raw)
|
|
if value is not None:
|
|
return value
|
|
print("Enter a whole number zero or greater.")
|
|
|
|
|
|
def save_card(card: dict[str, Any]) -> None:
|
|
path = Path(card["path"])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
card.setdefault("game_title", game_title(card["game_id"]))
|
|
card.setdefault("federation_name", federation_name(card["game_id"]))
|
|
card.setdefault("title_definitions", title_definitions_for_game(card["game_id"]))
|
|
ppv_rating = calculate_ppv_rating(card)
|
|
card["ppv_rating"] = ppv_rating
|
|
lines = [
|
|
f"# Show: {json.dumps(card['show'], ensure_ascii=False)}",
|
|
f"- City: {json.dumps(card.get('city', ''), ensure_ascii=False)}",
|
|
f"- Attendance: {json.dumps(card.get('attendance'))}",
|
|
f"- Game ID: {json.dumps(card['game_id'])}",
|
|
f"- Game: {json.dumps(card['game_title'], ensure_ascii=False)}",
|
|
f"- Federation: {json.dumps(card['federation_name'], ensure_ascii=False)}",
|
|
f"- Title Definitions: {json.dumps(card['title_definitions'], ensure_ascii=False)}",
|
|
f"- Date: {json.dumps(card['date'])}",
|
|
]
|
|
if is_ppv_card(card):
|
|
lines.append(f"- PPV Rating: {json.dumps(ppv_rating)}")
|
|
if card.get("season_id"):
|
|
lines.extend([
|
|
f"- Season ID: {json.dumps(card['season_id'])}",
|
|
f"- Season Show: {json.dumps(card.get('season_show', ''))}",
|
|
f"- Season Show Number: {json.dumps(card.get('season_show_number'))}",
|
|
])
|
|
lines.append("")
|
|
for number, match in enumerate(card["matches"], 1):
|
|
lines.append(f"## Match {number}")
|
|
lines.append(f"- Title: {json.dumps(match.get('title', ''), ensure_ascii=False)}")
|
|
lines.append(f"- Type: {json.dumps(match['type'], ensure_ascii=False)}")
|
|
lines.append(f"- Status: {json.dumps('Complete' if match.get('winner_side') is not None else 'Pending')}")
|
|
lines.append(f"- Sides: {json.dumps(match['sides'], ensure_ascii=False)}")
|
|
lines.append(f"- Winner side: {json.dumps(match.get('winner_side'))}")
|
|
lines.append(f"- Finish: {json.dumps(match.get('finish', ''), ensure_ascii=False)}")
|
|
lines.append(f"- Promo: {json.dumps(match.get('promo'), ensure_ascii=False)}")
|
|
lines.append(f"- Image: {json.dumps(match.get('image', ''), ensure_ascii=False)}")
|
|
lines.append(f"- Ranking Division: {json.dumps(match.get('ranking_division'))}")
|
|
lines.append(f"- Title Defense: {json.dumps(bool(match.get('title_defense', False)))}")
|
|
lines.append(f"- Rivalry Payoff: {json.dumps(bool(match.get('rivalry_payoff', False)))}")
|
|
lines.append(f"- Match Time Minutes: {json.dumps(match.get('match_time_minutes'))}")
|
|
lines.append(f"- Near Falls: {json.dumps(match.get('near_falls', 0))}")
|
|
lines.append(f"- Finishers: {json.dumps(match.get('finishers', 0))}")
|
|
lines.append(f"- Match Rating: {json.dumps(match.get('rating'))}")
|
|
lines.append(f"- Base Rating: {json.dumps(match.get('base_rating'))}")
|
|
lines.append(f"- Valid Outcomes: {json.dumps(match.get('valid_outcomes'), ensure_ascii=False)}")
|
|
lines.append("")
|
|
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
card["path"] = str(path)
|
|
|
|
|
|
def load_card(path: Path) -> dict[str, Any]:
|
|
"""Load a card written in the app's readable Markdown format."""
|
|
text = path.read_text(encoding="utf-8")
|
|
card: dict[str, Any] = {"path": str(path), "matches": []}
|
|
current: dict[str, Any] | None = None
|
|
for line in text.splitlines():
|
|
if line.startswith("# Show: "):
|
|
card["show"] = json.loads(line[len("# Show: "):])
|
|
continue
|
|
match_header = re.fullmatch(r"## Match (\d+)", line)
|
|
if match_header:
|
|
current = {"title": "", "type": "Singles", "sides": [], "winner_side": None,
|
|
"finish": "", "promo": None, "image": "", "ranking_division": None,
|
|
"title_defense": False, "rivalry_payoff": False,
|
|
"match_time_minutes": None, "near_falls": 0, "finishers": 0,
|
|
"rating": None, "base_rating": None, "valid_outcomes": None}
|
|
card["matches"].append(current)
|
|
continue
|
|
field = re.match(r"^- ([A-Za-z ]+): (.*)$", line)
|
|
if not field:
|
|
continue
|
|
key, raw = field.group(1).strip().lower(), field.group(2)
|
|
try:
|
|
value = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid Markdown card field '{key}' in {path}") from exc
|
|
if key in ("game id", "game", "federation", "title definitions", "date", "city", "attendance",
|
|
"season id", "season show", "season show number", "ppv rating"):
|
|
metadata_key = {"game": "game_title", "game id": "game_id"}.get(
|
|
key, key.replace(" ", "_")
|
|
)
|
|
card[metadata_key] = value
|
|
elif current is not None:
|
|
mapping = {"title": "title", "type": "type", "sides": "sides",
|
|
"winner side": "winner_side", "finish": "finish", "promo": "promo",
|
|
"image": "image", "ranking division": "ranking_division",
|
|
"title defense": "title_defense", "rivalry payoff": "rivalry_payoff",
|
|
"match time minutes": "match_time_minutes", "near falls": "near_falls",
|
|
"finishers": "finishers", "match rating": "rating",
|
|
"base rating": "base_rating", "valid outcomes": "valid_outcomes"}
|
|
if key in mapping:
|
|
current[mapping[key]] = value
|
|
if card.get("game_id") not in GAMES:
|
|
raise ValueError(f"Unknown or missing game ID in {path}")
|
|
if len(card["matches"]) > 3:
|
|
raise ValueError(f"A card cannot contain more than three matches: {path}")
|
|
while len(card["matches"]) < 3:
|
|
card["matches"].append({"title": "", "type": "Singles", "sides": [],
|
|
"winner_side": None, "finish": "", "promo": None,
|
|
"image": "", "ranking_division": None,
|
|
"title_defense": False, "rivalry_payoff": False,
|
|
"match_time_minutes": None, "near_falls": 0,
|
|
"finishers": 0, "rating": None, "base_rating": None,
|
|
"valid_outcomes": None})
|
|
if not card.get("show") or not card.get("date"):
|
|
raise ValueError(f"Card is missing its show name or date: {path}")
|
|
card.setdefault("city", "")
|
|
card.setdefault("attendance", None)
|
|
if card["attendance"] is not None and (
|
|
not isinstance(card["attendance"], int) or card["attendance"] <= 0
|
|
):
|
|
raise ValueError(f"Card has an invalid attendance: {path}")
|
|
for key in ("season_show_number",):
|
|
value = card.get(key)
|
|
if value is not None and (not isinstance(value, int) or value < 1):
|
|
raise ValueError(f"Card has invalid season metadata: {path}")
|
|
for match in card["matches"]:
|
|
if any(not side for side in match.get("sides", [])):
|
|
raise ValueError(f"A match is missing participants: {path}")
|
|
winner = match.get("winner_side")
|
|
if winner is not None and (not isinstance(winner, int) or not 0 <= winner < len(match["sides"])):
|
|
raise ValueError(f"A match has an invalid winner side: {path}")
|
|
division = match.get("ranking_division")
|
|
card_titles = card.get("title_definitions") or title_definitions_for_game(card["game_id"])
|
|
if division is not None and division not in [title["name"] for title in card_titles]:
|
|
raise ValueError(f"A match has an unknown ranking division: {path}")
|
|
if match.get("title_defense") and division is None:
|
|
raise ValueError(f"A title defense must have a ranking division: {path}")
|
|
minutes = match.get("match_time_minutes")
|
|
if minutes is not None and (
|
|
not isinstance(minutes, (int, float)) or not math.isfinite(minutes) or minutes < 0
|
|
):
|
|
raise ValueError(f"A match has an invalid duration: {path}")
|
|
for counter in ("near_falls", "finishers"):
|
|
value = match.get(counter, 0)
|
|
if not isinstance(value, int) or value < 0:
|
|
raise ValueError(f"A match has an invalid {counter.replace('_', ' ')} count: {path}")
|
|
rating = match.get("rating")
|
|
if rating is not None and (
|
|
not isinstance(rating, (int, float)) or not math.isfinite(rating) or not 0 <= rating <= 100
|
|
):
|
|
raise ValueError(f"A match has an invalid rating: {path}")
|
|
base_rating = match.get("base_rating")
|
|
if base_rating is not None and (
|
|
not isinstance(base_rating, (int, float)) or not math.isfinite(base_rating)
|
|
or not 0 <= base_rating <= 100
|
|
):
|
|
raise ValueError(f"A match has an invalid base rating: {path}")
|
|
outcomes = match.get("valid_outcomes")
|
|
if outcomes is not None and (
|
|
not isinstance(outcomes, list) or not outcomes
|
|
or any(not isinstance(outcome, str) or not outcome.strip() for outcome in outcomes)
|
|
):
|
|
raise ValueError(f"A match has invalid outcome options: {path}")
|
|
card.setdefault("game_title", game_title(card["game_id"]))
|
|
card.setdefault("federation_name", federation_name(card["game_id"]))
|
|
card.setdefault("title_definitions", title_definitions_for_game(card["game_id"]))
|
|
card["ppv_rating"] = calculate_ppv_rating(card)
|
|
return card
|
|
|
|
|
|
def random_attendance(city: str) -> int:
|
|
"""Pick an attendance within the city's typical venue range."""
|
|
low, high = CITY_ATTENDANCE_RANGES.get(city, DEFAULT_ATTENDANCE_RANGE)
|
|
return random.randint(low, high)
|
|
|
|
|
|
def random_location() -> tuple[str, int]:
|
|
city = random.choice(sorted(CITY_ATTENDANCE_RANGES))
|
|
return city, random_attendance(city)
|
|
|
|
|
|
def parse_attendance(raw: str) -> int | None:
|
|
cleaned = re.sub(r"[,\s_]", "", raw)
|
|
if not cleaned.isdigit():
|
|
return None
|
|
value = int(cleaned)
|
|
return value if value > 0 else None
|
|
|
|
|
|
def ask_location() -> tuple[str, int]:
|
|
"""Prompt for city and attendance; 'r' randomizes both, or just attendance."""
|
|
while True:
|
|
city = ask("City (r randomizes city and attendance)").strip()
|
|
if city.casefold() == "r":
|
|
city, attendance = random_location()
|
|
print(f"Random location: {city} · {attendance:,} fans")
|
|
return city, attendance
|
|
if city:
|
|
break
|
|
print("Enter a city, or 'r' to randomize.")
|
|
while True:
|
|
raw = ask(f"Attendance in {city} (r to randomize)").strip()
|
|
if raw.casefold() == "r":
|
|
attendance = random_attendance(city)
|
|
print(f"Random attendance: {attendance:,} fans")
|
|
return city, attendance
|
|
attendance = parse_attendance(raw)
|
|
if attendance is not None:
|
|
return city, attendance
|
|
print("Enter a whole number greater than zero, or 'r' to randomize.")
|
|
|
|
|
|
def show_location_text(card: dict[str, Any]) -> str:
|
|
"""Show name with its city and attendance, when the card has them."""
|
|
parts = [card["show"]]
|
|
city = str(card.get("city") or "").strip()
|
|
attendance = card.get("attendance")
|
|
if city:
|
|
parts.append(city)
|
|
if attendance:
|
|
parts.append(f"{attendance:,} fans")
|
|
return " · ".join(parts)
|
|
|
|
|
|
def card_summary(path: Path) -> str:
|
|
"""One-line listing for a saved card; falls back to the file stem."""
|
|
try:
|
|
return show_location_text(load_card(path))
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
return path.stem
|
|
|
|
|
|
SCREENSHOT_STAMP_RE = re.compile(
|
|
r"(?P<y>\d{4})-(?P<mo>\d{2})-(?P<d>\d{2})-(?P<h>\d{2})-(?P<mi>\d{2})-(?P<s>\d{2})"
|
|
)
|
|
|
|
|
|
def parse_screenshot_stamp(filename: str) -> datetime | None:
|
|
"""Parse 'GameTitle yyyy-mm-dd-hh-mm-ss.png' into an aware local datetime."""
|
|
match = SCREENSHOT_STAMP_RE.search(filename)
|
|
if not match:
|
|
return None
|
|
parts = {key: int(value) for key, value in match.groupdict().items()}
|
|
try:
|
|
return datetime(parts["y"], parts["mo"], parts["d"],
|
|
parts["h"], parts["mi"], parts["s"]).astimezone()
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def screenshot_taken_at(path: Path) -> datetime:
|
|
"""Screenshot time from the filename stamp, falling back to file mtime."""
|
|
stamp = parse_screenshot_stamp(path.name)
|
|
if stamp is not None:
|
|
return stamp
|
|
return datetime.fromtimestamp(path.stat().st_mtime).astimezone()
|
|
|
|
|
|
def session_screenshots(since: datetime | None = None,
|
|
directory: Path | None = None) -> list[Path]:
|
|
"""DuckStation screenshots taken after `since` (default: app session start)."""
|
|
since = since or SESSION_START
|
|
directory = directory or DUCKSTATION_SCREENSHOT_DIR
|
|
if not directory.is_dir():
|
|
return []
|
|
found: list[tuple[datetime, Path]] = []
|
|
for path in directory.glob("*.png"):
|
|
try:
|
|
taken = screenshot_taken_at(path)
|
|
except OSError:
|
|
continue
|
|
if taken >= since:
|
|
found.append((taken, path))
|
|
found.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
|
return [path for _, path in found]
|
|
|
|
|
|
def choose_screenshot(directory: Path | None = None) -> Path | None:
|
|
"""Pick a screenshot from those captured since the app was opened."""
|
|
directory = directory or DUCKSTATION_SCREENSHOT_DIR
|
|
candidates = session_screenshots(directory=directory)
|
|
if candidates:
|
|
print(f"\nScreenshots taken since the app opened ({directory}):")
|
|
for index, path in enumerate(candidates, 1):
|
|
taken = screenshot_taken_at(path)
|
|
print(f" {index}. {taken:%Y-%m-%d %H:%M:%S} — {path.name}")
|
|
else:
|
|
print(f"\nNo screenshots found since the app opened in {directory}.")
|
|
while True:
|
|
raw = ask("Screenshot number or path (blank to cancel)").strip()
|
|
if not raw:
|
|
return None
|
|
if raw.isdigit() and candidates and 1 <= int(raw) <= len(candidates):
|
|
return candidates[int(raw) - 1]
|
|
path = Path(raw).expanduser()
|
|
if not path.is_absolute():
|
|
path = directory / path
|
|
if path.is_file():
|
|
return path
|
|
print(f"No such screenshot: {path}")
|
|
|
|
|
|
def attach_match_image(card: dict[str, Any]) -> None:
|
|
"""Attach or change a screenshot for any match on a PS1-game card."""
|
|
if not game_has_screenshots(card["game_id"]):
|
|
print("Screenshot attachments are available for the PS1 games only.")
|
|
return
|
|
index = choose_match_index(card, list(range(len(card["matches"]))), "Choose a match")
|
|
match = card["matches"][index]
|
|
current = str(match.get("image") or "")
|
|
if current:
|
|
print(f"Current image: {current}")
|
|
chosen = choose_screenshot()
|
|
if chosen is None:
|
|
print("Keep or skip the image; card unchanged.")
|
|
return
|
|
match["image"] = str(chosen)
|
|
save_card(card)
|
|
print(f"Image saved for Match {index + 1}: {match['image']}")
|
|
|
|
|
|
def game_has_screenshots(game_id: str) -> bool:
|
|
return game_id in PS1_GAMES or (
|
|
game_id in CUSTOM_GAME_CONFIGS and CUSTOM_GAME_CONFIGS[game_id]["platform"] == "ps1"
|
|
)
|
|
|
|
|
|
def choose_game() -> str:
|
|
refresh_custom_games()
|
|
ids = list(GAMES)
|
|
labels = [game_display_name(game_id) for game_id in ids]
|
|
return ids[ask_choice("Choose a game", ids, labels=labels)]
|
|
|
|
|
|
def choose_match_type(game_id: str) -> tuple[str, tuple[int, ...]]:
|
|
options = match_type_definitions(game_id)
|
|
labels = [f"{item['name']} ({' vs '.join(map(str, item['sides']))})"
|
|
for item in options]
|
|
selected = ask_choice("Choose match type", [item["name"] for item in options], labels=labels)
|
|
definition = options[selected]
|
|
return definition["name"], tuple(definition["sides"])
|
|
|
|
|
|
def choose_sides(side_sizes: tuple[int, ...], roster: list[str]) -> list[list[str]]:
|
|
total = sum(side_sizes)
|
|
if total > len(roster):
|
|
raise ValueError("This match needs more unique wrestlers than the roster provides.")
|
|
roster = sorted(roster, key=str.casefold)
|
|
print("\nParticipant selection: enter a roster number, 'r' for a random pick, or 'a' to "
|
|
"randomly fill the remaining spots.")
|
|
for index, name in enumerate(roster, 1):
|
|
print(f" {index:>2}. {name}")
|
|
selected: list[str] = []
|
|
sides: list[list[str]] = []
|
|
for side_number, size in enumerate(side_sizes, 1):
|
|
side: list[str] = []
|
|
for slot in range(1, size + 1):
|
|
while True:
|
|
choice = ask(f"Side {side_number}, wrestler {slot}/{size}")
|
|
if choice.lower() == "a":
|
|
remaining = [name for name in roster if name not in selected]
|
|
needed = sum(side_sizes[side_number - 1:]) - len(side)
|
|
picks = random.sample(remaining, needed)
|
|
# Fill this side and all remaining sides, then return.
|
|
take = size - len(side)
|
|
side.extend(picks[:take])
|
|
sides.append(side)
|
|
rest = picks[take:]
|
|
cursor = 0
|
|
for remaining_size in side_sizes[side_number:]:
|
|
sides.append(rest[cursor:cursor + remaining_size])
|
|
cursor += remaining_size
|
|
return sides
|
|
if choice.lower() == "r":
|
|
available = [name for name in roster if name not in selected]
|
|
if available:
|
|
pick = random.choice(available)
|
|
break
|
|
else:
|
|
try:
|
|
index = int(choice)
|
|
pick = roster[index - 1]
|
|
if index < 1 or index > len(roster):
|
|
raise IndexError
|
|
if pick in selected:
|
|
print("Each wrestler can only be selected once in a match.")
|
|
continue
|
|
break
|
|
except (ValueError, IndexError):
|
|
print("Choose an available roster number, 'r', or 'a'.")
|
|
side.append(pick)
|
|
selected.append(pick)
|
|
sides.append(side)
|
|
return sides
|
|
|
|
|
|
def create_match(number: int, game_id: str, roster: list[str]) -> dict[str, Any]:
|
|
print(f"\n{'=' * 14} MATCH {number} {'=' * 14}")
|
|
match_name, side_sizes = choose_match_type(game_id)
|
|
title = ask("Optional match title (leave blank for none)")
|
|
sides = choose_sides(side_sizes, roster)
|
|
definition = match_type_definition(game_id, match_name)
|
|
image = ""
|
|
if game_has_screenshots(game_id) and ask("Attach a DuckStation screenshot? (y/N)").lower() == "y":
|
|
chosen = choose_screenshot()
|
|
if chosen is not None:
|
|
image = str(chosen)
|
|
return {"title": title, "type": match_name, "sides": sides,
|
|
"winner_side": None, "finish": "", "promo": None, "image": image,
|
|
"ranking_division": None, "title_defense": False, "rivalry_payoff": False,
|
|
"match_time_minutes": None, "near_falls": 0, "finishers": 0, "rating": None,
|
|
"base_rating": definition["base_rating"],
|
|
"valid_outcomes": list(definition["outcomes"])}
|
|
|
|
|
|
def select_card_path() -> Path | None:
|
|
candidates = sorted(CARD_DIR.glob("*.md")) if CARD_DIR.is_dir() else []
|
|
if candidates:
|
|
print("\nSaved cards:")
|
|
for index, path in enumerate(candidates, 1):
|
|
print(f" {index}. {card_summary(path)} [{path.name}]")
|
|
print(" Enter a number to load a listed card, or type a path.")
|
|
raw = ask("Card path (blank to cancel)")
|
|
if not raw:
|
|
return None
|
|
if raw.isdigit() and candidates and 1 <= int(raw) <= len(candidates):
|
|
return candidates[int(raw) - 1]
|
|
path = Path(raw).expanduser()
|
|
if not path.is_absolute():
|
|
path = ROOT / path
|
|
return path
|
|
|
|
|
|
def create_card() -> dict[str, Any]:
|
|
game_id = choose_game()
|
|
roster = load_roster(game_id)
|
|
show = ask("Show name", game_show_names(game_id)["SmackDown"])
|
|
while True:
|
|
show_date = ask("Show date (YYYY-MM-DD)", date.today().isoformat())
|
|
try:
|
|
show_date = date.fromisoformat(show_date).isoformat()
|
|
break
|
|
except ValueError:
|
|
print("Enter a valid date in YYYY-MM-DD format.")
|
|
city, attendance = ask_location()
|
|
card: dict[str, Any] = {
|
|
"game_id": game_id, "game_title": game_title(game_id),
|
|
"federation_name": federation_name(game_id),
|
|
"title_definitions": title_definitions_for_game(game_id),
|
|
"show": show, "date": show_date,
|
|
"city": city, "attendance": attendance,
|
|
"matches": [{"title": "", "type": "Singles", "sides": [], "winner_side": None,
|
|
"finish": "", "promo": None, "image": "", "ranking_division": None,
|
|
"title_defense": False, "rivalry_payoff": False,
|
|
"match_time_minutes": None, "near_falls": 0, "finishers": 0,
|
|
"rating": None} for _ in range(3)],
|
|
"path": str(default_card_path(show, show_date)),
|
|
}
|
|
save_card(card)
|
|
print(f"\nCard started: {card['path']}")
|
|
for number in range(1, 4):
|
|
card["matches"][number - 1] = create_match(number, game_id, roster)
|
|
save_card(card)
|
|
print(f"\nCard saved to {card['path']}")
|
|
return card
|
|
|
|
|
|
def random_match(game_id: str, roster: list[str], used: set[str],
|
|
profile: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
definition = random.choice(match_type_definitions(game_id, profile))
|
|
match_name, side_sizes = definition["name"], tuple(definition["sides"])
|
|
used_folded = {name.casefold() for name in used}
|
|
available = [name for name in roster if name.casefold() not in used_folded]
|
|
if len(available) < sum(side_sizes):
|
|
available = list(roster)
|
|
picks = random.sample(available, sum(side_sizes))
|
|
sides: list[list[str]] = []
|
|
cursor = 0
|
|
for size in side_sizes:
|
|
sides.append(picks[cursor:cursor + size])
|
|
cursor += size
|
|
used.update(name.casefold() for name in picks)
|
|
return {
|
|
"title": random.choice(["", "Opening Match", "Grudge Match", "Main Event"]),
|
|
"type": match_name, "sides": sides, "winner_side": None, "finish": "",
|
|
"promo": None, "image": "", "ranking_division": None,
|
|
"title_defense": False, "rivalry_payoff": False,
|
|
"match_time_minutes": None, "near_falls": 0, "finishers": 0, "rating": None,
|
|
"base_rating": definition["base_rating"],
|
|
"valid_outcomes": list(definition["outcomes"]),
|
|
}
|
|
|
|
|
|
def create_random_card() -> dict[str, Any]:
|
|
"""Create a randomized three-match show after the user selects a game."""
|
|
game_id = choose_game()
|
|
roster = load_roster(game_id)
|
|
show = random.choice(list(game_show_names(game_id).values()))
|
|
city, attendance = random_location()
|
|
show_date = date.today().isoformat()
|
|
used: set[str] = set()
|
|
card: dict[str, Any] = {
|
|
"game_id": game_id, "game_title": game_title(game_id),
|
|
"federation_name": federation_name(game_id),
|
|
"title_definitions": title_definitions_for_game(game_id),
|
|
"show": show, "date": show_date,
|
|
"city": city, "attendance": attendance,
|
|
"matches": [random_match(game_id, roster, used) for _ in range(3)],
|
|
"path": str(default_card_path(show, show_date)),
|
|
}
|
|
save_card(card)
|
|
print(f"\nRandom card saved to {card['path']}")
|
|
return card
|
|
|
|
|
|
def color(text: str, code: str, enabled: bool = True) -> str:
|
|
return f"{code}{text}{RESET}" if enabled else text
|
|
|
|
|
|
def short(text: str, width: int) -> str:
|
|
if len(text) <= width:
|
|
return text
|
|
if width < 2:
|
|
return text[:width]
|
|
return text[:width - 1] + "…"
|
|
|
|
|
|
def match_lines(
|
|
match: dict[str, Any],
|
|
number: int,
|
|
width: int,
|
|
flash: bool = False,
|
|
promo_selected: bool = False,
|
|
promo_expanded: bool = False,
|
|
) -> list[str]:
|
|
box_width = max(36, min(width - 4, 100))
|
|
inner = box_width - 4
|
|
heading = f"Match {number}" + (f" — {match['title']}" if match.get("title") else "")
|
|
heading = short(heading, inner)
|
|
heading_text = f" {heading} "
|
|
top_fill = max(0, box_width - 2 - len(heading_text))
|
|
lines = [f"┌{heading_text}{'─' * top_fill}┐", f"│ {match['type'].center(inner)} │"]
|
|
sides = match["sides"]
|
|
winner = match.get("winner_side")
|
|
if len(sides) == 2:
|
|
left_width = max(10, (inner - 8) // 2)
|
|
right_width = inner - left_width - 6
|
|
labels: list[str] = []
|
|
for side_number, side in enumerate(sides):
|
|
label = " & ".join(side)
|
|
if winner is not None:
|
|
if side_number == winner:
|
|
label = "WIN!" if flash else short(label, left_width if side_number == 0 else right_width)
|
|
label = color(label, RED)
|
|
else:
|
|
label = "LOSE" if flash else short(label, left_width if side_number == 0 else right_width)
|
|
label = color(label, YELLOW)
|
|
else:
|
|
label = short(label, left_width if side_number == 0 else right_width)
|
|
labels.append(label)
|
|
left = labels[0]
|
|
right = labels[1]
|
|
# ANSI color sequences are zero-width; pad using the original label lengths.
|
|
left_plain_width = left_width
|
|
right_plain_width = right_width
|
|
lines.append(f"│ {left}{' ' * max(0, left_plain_width - visible_length(left))} VS "
|
|
f"{right}{' ' * max(0, right_plain_width - visible_length(right))} │")
|
|
else:
|
|
pieces: list[str] = []
|
|
for side_number, side in enumerate(sides):
|
|
label = " & ".join(side)
|
|
if winner is not None:
|
|
if side_number == winner:
|
|
label = "WIN!" if flash else label
|
|
label = color(label, RED)
|
|
else:
|
|
label = "LOSE" if flash else label
|
|
label = color(label, YELLOW)
|
|
pieces.append(label)
|
|
separator = " VS "
|
|
required = sum(visible_length(piece) for piece in pieces) + len(separator) * (len(pieces) - 1)
|
|
if required > inner and len(pieces) > 1:
|
|
name_budget = max(1, (inner - len(separator) * (len(pieces) - 1)) // len(pieces))
|
|
pieces = [short_ansi(piece, name_budget) for piece in pieces]
|
|
joined = separator.join(pieces)
|
|
padding = max(0, inner - visible_length(joined))
|
|
lines.append(f"│ {' ' * (padding // 2)}{joined}{' ' * (padding - padding // 2)} │")
|
|
status = "COMPLETE" if winner is not None else "UP NEXT"
|
|
if match.get("title_defense"):
|
|
division = str(match.get("ranking_division") or "Championship").upper()
|
|
vacant = str(match.get("title", "")).startswith("Vacant ")
|
|
status = f"{'VACANT ' if vacant else ''}{division} TITLE MATCH · {status}"
|
|
else:
|
|
status = f"NON-TITLE MATCH · {status}"
|
|
if match.get("finish"):
|
|
status += f" · {match['finish']}"
|
|
if match.get("match_time_minutes") is not None:
|
|
status += f" · {float(match['match_time_minutes']):g} min"
|
|
if match.get("rating") is not None:
|
|
status += f" · {float(match['rating']):.1f}%"
|
|
status = short(status, inner)
|
|
if match.get("promo"):
|
|
if promo_expanded:
|
|
control = "▼ Promo (Enter to collapse)"
|
|
else:
|
|
preview = " / ".join(str(match["promo"]).splitlines())
|
|
control = short(f"▶ Promo: {preview}", inner)
|
|
control_line = f"│ {control:<{inner}} │"
|
|
lines.append(color(control_line, "\033[7m", enabled=promo_selected))
|
|
if promo_expanded:
|
|
wrap_width = max(8, inner - 2)
|
|
for turn in str(match["promo"]).splitlines():
|
|
wrapped_turn = textwrap.wrap(
|
|
turn, width=wrap_width, break_long_words=True, break_on_hyphens=False
|
|
) or [""]
|
|
for wrapped in wrapped_turn:
|
|
lines.append(f"│ {wrapped:<{wrap_width}} │")
|
|
status_text = f" {status} "
|
|
status_padding = max(0, box_width - 2 - len(status_text))
|
|
left_padding = status_padding // 2
|
|
right_padding = status_padding - left_padding
|
|
lines.append(f"└{'─' * left_padding}{status_text}{'─' * right_padding}┘")
|
|
return lines
|
|
|
|
|
|
ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
|
|
|
|
def visible_length(text: str) -> int:
|
|
return len(ANSI_RE.sub("", text))
|
|
|
|
|
|
def short_ansi(text: str, width: int) -> str:
|
|
plain = ANSI_RE.sub("", text)
|
|
if len(plain) <= width:
|
|
return text
|
|
# Multi-way names are shortened only in the uncolored portion; retain the
|
|
# match color by applying it to the shortened result.
|
|
code = ""
|
|
if text.startswith(RED):
|
|
code = RED
|
|
elif text.startswith(YELLOW):
|
|
code = YELLOW
|
|
return color(short(plain, width), code, bool(code))
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def _render_image(path: Path, max_columns: int, max_rows: int) -> tuple[list[str], int]:
|
|
"""Render an image as ANSI true-color half-blocks when Pillow is installed."""
|
|
try:
|
|
from PIL import Image
|
|
except ImportError:
|
|
return [], 0
|
|
try:
|
|
with Image.open(path) as source:
|
|
image = source.convert("RGBA")
|
|
image.thumbnail((max_columns, max_rows * 2), Image.Resampling.LANCZOS)
|
|
image_width, image_height = image.size
|
|
pixels = image.load()
|
|
output: list[str] = []
|
|
for y in range(0, image_height, 2):
|
|
row: list[str] = []
|
|
for x in range(image_width):
|
|
top = pixels[x, y]
|
|
bottom = pixels[x, y + 1] if y + 1 < image_height else (0, 0, 0, 255)
|
|
# Composite transparent logo pixels onto the terminal background.
|
|
top = tuple(round(top[i] * top[3] / 255) for i in range(3))
|
|
bottom = tuple(round(bottom[i] * bottom[3] / 255) for i in range(3))
|
|
row.append(f"\033[38;2;{top[0]};{top[1]};{top[2]}m"
|
|
f"\033[48;2;{bottom[0]};{bottom[1]};{bottom[2]}m▀")
|
|
output.append("".join(row) + RESET)
|
|
return output, image_width
|
|
except Exception:
|
|
return [], 0
|
|
|
|
|
|
def show_logo_path(show_name: str) -> Path | None:
|
|
"""Choose a centered show logo from the show name, ignoring case."""
|
|
normalized = show_name.casefold()
|
|
if "smackdown" in normalized:
|
|
return ROOT / "smackdown-logo-source.png"
|
|
if re.search(r"\braw\b", normalized):
|
|
return ROOT / "raw-is-war-logo.png"
|
|
return None
|
|
|
|
|
|
def header_lines(card: dict[str, Any], width: int) -> list[str]:
|
|
card_federation = str(card.get("federation_name")
|
|
or federation_name(card["game_id"]))
|
|
is_wwf = is_wwf_federation(card_federation)
|
|
if is_wwf:
|
|
left, left_width = _render_image(ROOT / "wwfcomlogo.gif", 23, 6)
|
|
else:
|
|
left, left_width = [], 0
|
|
logo_path = show_logo_path(card["show"])
|
|
if logo_path:
|
|
center, center_width = _render_image(logo_path, 38, 6)
|
|
if not center:
|
|
brand = "SMACKDOWN!" if logo_path.name == "smackdown-logo-source.png" else "RAW IS WAR"
|
|
center = [color(brand, CYAN)]
|
|
center_width = len(brand)
|
|
else:
|
|
center, center_width = [], 0
|
|
if is_wwf and not left:
|
|
left = [color("WWF.com", RED)]
|
|
left_width = 7
|
|
row_count = max(len(left), len(center), 2)
|
|
if width < 62:
|
|
title = card.get("game_title", game_title(card["game_id"]))
|
|
heading = f"{color('WWF.com', RED)} {color(title, CYAN)}" if is_wwf \
|
|
else color(title, CYAN)
|
|
return [heading,
|
|
f"{card['date']} · {show_location_text(card)}", ""]
|
|
left_x = 1
|
|
if left:
|
|
center_x = max(left_x + left_width + 3, (width - center_width) // 2)
|
|
else:
|
|
center_x = (width - center_width) // 2
|
|
date_text = card["date"]
|
|
date_x = width - len(date_text) - 2
|
|
output: list[str] = []
|
|
for index in range(row_count):
|
|
segments: list[str] = []
|
|
cursor = 0
|
|
if index < len(left):
|
|
segments.append(" " * (left_x - cursor) + left[index])
|
|
cursor = left_x + left_width
|
|
if index < len(center):
|
|
if center_x < cursor:
|
|
center_x = cursor + 2
|
|
segments.append(" " * (center_x - cursor) + center[index])
|
|
cursor = center_x + center_width
|
|
if index == 0 and date_x > cursor:
|
|
segments.append(" " * (date_x - cursor) + color(date_text, DIM))
|
|
output.append("".join(segments))
|
|
output.append(color(short(show_location_text(card), width - 2), CYAN))
|
|
return output
|
|
|
|
|
|
def render_card_sections(
|
|
card: dict[str, Any],
|
|
flash: bool = False,
|
|
selected_promo: int | None = None,
|
|
expanded_promos: set[int] | None = None,
|
|
width: int | None = None,
|
|
) -> tuple[list[str], list[str], dict[int, tuple[int, int]]]:
|
|
width = width or shutil.get_terminal_size((100, 28)).columns
|
|
expanded_promos = expanded_promos or set()
|
|
header = header_lines(card, width)
|
|
body: list[str] = []
|
|
promo_spans: dict[int, tuple[int, int]] = {}
|
|
for match_index in reversed(range(len(card["matches"]))):
|
|
index = match_index + 1
|
|
match = card["matches"][match_index]
|
|
start = len(body)
|
|
block = match_lines(
|
|
match,
|
|
index,
|
|
width,
|
|
flash=flash,
|
|
promo_selected=(match_index == selected_promo),
|
|
promo_expanded=(match_index in expanded_promos),
|
|
)
|
|
if match.get("promo"):
|
|
control_row = start + 3
|
|
promo_spans[match_index] = (control_row, start + len(block) - 1)
|
|
body.extend(block)
|
|
body.append(color(f"Saved: {card['path']}", DIM))
|
|
ppv_rating = calculate_ppv_rating(card)
|
|
if ppv_rating is not None:
|
|
body.append(color(f"WWF PPV Rating: {ppv_rating:.1f}%", CYAN))
|
|
instructions = "↑/↓ select promo · Enter expand/collapse · PgUp/PgDn scroll · q close"
|
|
body.append(color(short(instructions, max(1, width - 2)), DIM))
|
|
return header, body, promo_spans
|
|
|
|
|
|
def render_card_lines(
|
|
card: dict[str, Any],
|
|
flash: bool = False,
|
|
selected_promo: int | None = None,
|
|
expanded_promos: set[int] | None = None,
|
|
) -> list[str]:
|
|
header, body, _ = render_card_sections(
|
|
card, flash=flash, selected_promo=selected_promo, expanded_promos=expanded_promos
|
|
)
|
|
return header + body
|
|
|
|
|
|
def render_card(card: dict[str, Any], flash: bool = False) -> str:
|
|
return "\n".join(render_card_lines(card, flash=flash))
|
|
|
|
|
|
def viewport_frame(
|
|
header: list[str], body: list[str], height: int, scroll_offset: int
|
|
) -> tuple[list[str], int, int]:
|
|
"""Keep the header fixed while clipping the scrollable card body to the screen."""
|
|
height = max(1, height)
|
|
header_count = min(len(header), max(0, height - 1))
|
|
fixed_header = header[:header_count]
|
|
body_capacity = max(1, height - header_count)
|
|
max_offset = max(0, len(body) - body_capacity)
|
|
offset = min(max(0, scroll_offset), max_offset)
|
|
visible_body = body[offset:offset + body_capacity]
|
|
frame = fixed_header + visible_body
|
|
frame.extend([""] * (height - len(frame)))
|
|
return frame, offset, body_capacity
|
|
|
|
|
|
def keep_promo_visible(
|
|
scroll_offset: int,
|
|
promo_span: tuple[int, int] | None,
|
|
body_capacity: int,
|
|
body_length: int,
|
|
) -> int:
|
|
if promo_span is None:
|
|
return min(scroll_offset, max(0, body_length - body_capacity))
|
|
start, end = promo_span
|
|
span_length = end - start
|
|
if span_length <= body_capacity:
|
|
if start < scroll_offset:
|
|
scroll_offset = start
|
|
elif end > scroll_offset + body_capacity:
|
|
scroll_offset = end - body_capacity
|
|
elif start < scroll_offset or start >= scroll_offset + body_capacity:
|
|
scroll_offset = start
|
|
return min(max(0, scroll_offset), max(0, body_length - body_capacity))
|
|
|
|
|
|
def write_changed_lines(previous: list[str], current: list[str], stream: Any = None) -> None:
|
|
"""Update only changed screen rows; static branding and card text stay put."""
|
|
stream = stream or sys.stdout
|
|
changed = False
|
|
for index, line in enumerate(current):
|
|
if index >= len(previous) or line != previous[index]:
|
|
stream.write(f"\033[{index + 1};1H\033[2K{line}")
|
|
changed = True
|
|
if changed:
|
|
stream.flush()
|
|
|
|
|
|
def interactive_card_frame(
|
|
card: dict[str, Any],
|
|
terminal_width: int,
|
|
terminal_height: int,
|
|
flash: bool = False,
|
|
selected_promo: int | None = None,
|
|
expanded_promos: set[int] | None = None,
|
|
scroll_offset: int = 0,
|
|
keep_selection_visible: bool = False,
|
|
) -> tuple[list[str], int, int, dict[int, tuple[int, int]]]:
|
|
header, body, promo_spans = render_card_sections(
|
|
card,
|
|
flash=flash,
|
|
selected_promo=selected_promo,
|
|
expanded_promos=expanded_promos,
|
|
width=terminal_width,
|
|
)
|
|
_, _, body_capacity = viewport_frame(header, body, terminal_height, scroll_offset)
|
|
if keep_selection_visible and selected_promo is not None:
|
|
scroll_offset = keep_promo_visible(
|
|
scroll_offset, promo_spans.get(selected_promo), body_capacity, len(body)
|
|
)
|
|
frame, scroll_offset, body_capacity = viewport_frame(
|
|
header, body, terminal_height, scroll_offset
|
|
)
|
|
return frame, scroll_offset, body_capacity, promo_spans
|
|
|
|
|
|
def read_view_key(fd: int) -> str:
|
|
"""Read one key from a cbreak terminal, translating common navigation keys."""
|
|
first = os.read(fd, 1)
|
|
if first in (b"\r", b"\n"):
|
|
return "enter"
|
|
if first in (b"q", b"Q"):
|
|
return "quit"
|
|
if first in (b"j", b"J"):
|
|
return "down"
|
|
if first in (b"k", b"K"):
|
|
return "up"
|
|
if first != b"\x1b":
|
|
return "other"
|
|
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
if not ready:
|
|
return "quit"
|
|
if os.read(fd, 1) != b"[":
|
|
return "other"
|
|
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
if not ready:
|
|
return "other"
|
|
code = os.read(fd, 1)
|
|
if code == b"A":
|
|
return "up"
|
|
if code == b"B":
|
|
return "down"
|
|
if code in (b"5", b"6"):
|
|
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
if ready and os.read(fd, 1) == b"~":
|
|
return "pageup" if code == b"5" else "pagedown"
|
|
return "other"
|
|
|
|
|
|
def view_card(card: dict[str, Any]) -> None:
|
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
print("\033[2J\033[H" + render_card(card))
|
|
input("\nPress Enter to continue...")
|
|
return
|
|
try:
|
|
import termios
|
|
import tty
|
|
fd = sys.stdin.fileno()
|
|
old_settings = termios.tcgetattr(fd)
|
|
tty.setcbreak(fd)
|
|
try:
|
|
terminal = shutil.get_terminal_size((100, 28))
|
|
promo_indices = [index for index in reversed(range(len(card["matches"])))
|
|
if card["matches"][index].get("promo")]
|
|
selected_promo = promo_indices[0] if promo_indices else None
|
|
expanded_promos: set[int] = set()
|
|
scroll_offset = 0
|
|
has_results = any(match.get("winner_side") is not None for match in card["matches"])
|
|
flash = False
|
|
frame, scroll_offset, body_capacity, promo_spans = interactive_card_frame(
|
|
card, terminal.columns, terminal.lines, flash=flash,
|
|
selected_promo=selected_promo, expanded_promos=expanded_promos,
|
|
scroll_offset=scroll_offset, keep_selection_visible=True,
|
|
)
|
|
# Paint the static header and collapsed card once. Subsequent frames
|
|
# update changed rows only, with the header pinned above the viewport.
|
|
sys.stdout.write("\033[2J\033[H" + "\n".join(frame))
|
|
sys.stdout.flush()
|
|
while True:
|
|
ready, _, _ = select.select([sys.stdin], [], [], 0.65)
|
|
key = read_view_key(fd) if ready else ""
|
|
keep_selection_visible = False
|
|
if key == "quit":
|
|
break
|
|
elif key in ("up", "down") and promo_indices:
|
|
current = promo_indices.index(selected_promo) if selected_promo in promo_indices else 0
|
|
delta = -1 if key == "up" else 1
|
|
selected_promo = promo_indices[(current + delta) % len(promo_indices)]
|
|
keep_selection_visible = True
|
|
elif key == "enter" and selected_promo is not None:
|
|
if selected_promo in expanded_promos:
|
|
expanded_promos.remove(selected_promo)
|
|
else:
|
|
expanded_promos.add(selected_promo)
|
|
keep_selection_visible = True
|
|
elif key == "pageup":
|
|
scroll_offset -= max(1, body_capacity // 2)
|
|
elif key == "pagedown":
|
|
scroll_offset += max(1, body_capacity // 2)
|
|
elif not key and has_results:
|
|
flash = not flash
|
|
elif key:
|
|
continue
|
|
next_frame, scroll_offset, body_capacity, promo_spans = interactive_card_frame(
|
|
card, terminal.columns, terminal.lines, flash=flash,
|
|
selected_promo=selected_promo, expanded_promos=expanded_promos,
|
|
scroll_offset=scroll_offset,
|
|
keep_selection_visible=keep_selection_visible,
|
|
)
|
|
write_changed_lines(frame, next_frame)
|
|
frame = next_frame
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
sys.stdout.write("\033[0m\033[2J\033[H")
|
|
sys.stdout.flush()
|
|
except (ImportError, OSError, ValueError):
|
|
print("\033[2J\033[H" + render_card(card))
|
|
input("\nPress Enter to continue...")
|
|
|
|
|
|
def pending_indices(card: dict[str, Any]) -> list[int]:
|
|
return [index for index, match in enumerate(card["matches"])
|
|
if match.get("sides") and match.get("winner_side") is None]
|
|
|
|
|
|
def editable_indices(card: dict[str, Any]) -> list[int]:
|
|
return [index for index, match in enumerate(card["matches"])
|
|
if match.get("winner_side") is None]
|
|
|
|
|
|
def choose_match_index(card: dict[str, Any], indices: list[int], prompt: str) -> int:
|
|
labels: list[str] = []
|
|
for index in indices:
|
|
match = card["matches"][index]
|
|
title = f" — {match['title']}" if match.get("title") else ""
|
|
state = " · not configured" if not match.get("sides") else ""
|
|
labels.append(f"Match {index + 1}: {match['type']}{title}{state}")
|
|
selected = ask_choice(prompt, indices, labels=labels)
|
|
return indices[selected]
|
|
|
|
|
|
def season_simulation_weights(season: dict[str, Any] | None,
|
|
match: dict[str, Any]) -> list[float]:
|
|
"""Higher-ranked division entrants get an edge, but lower ranks can upset."""
|
|
sides = match.get("sides", [])
|
|
division = match.get("ranking_division")
|
|
if not season or not division or division not in season_title_names(season):
|
|
return [1.0] * len(sides)
|
|
entries = season_ranking_entries(season, division)
|
|
rank_positions = {side_identity(entry.get("members", [])): position
|
|
for position, entry in enumerate(entries)}
|
|
total = len(entries)
|
|
weights: list[float] = []
|
|
holder = season.get("champions", {}).get(division, {}).get("holder")
|
|
for side in sides:
|
|
key = side_identity(side)
|
|
position = rank_positions.get(key)
|
|
if position is None or total <= 1:
|
|
weight = 1.0
|
|
else:
|
|
normalized_rank = (total - 1 - position) / (total - 1)
|
|
weight = 1.0 + 1.5 * normalized_rank
|
|
record = next((entry for entry in entries
|
|
if side_identity(entry.get("members", [])) == key), None)
|
|
if record:
|
|
wins, losses = record.get("wins", 0), record.get("losses", 0)
|
|
total_results = wins + losses
|
|
if total_results:
|
|
form = wins / total_results
|
|
weight += (form - 0.5) * 0.3
|
|
if match.get("title_defense") and holder and side_identity(holder) == key:
|
|
weight *= 1.15
|
|
weights.append(max(0.5, weight))
|
|
return weights
|
|
|
|
|
|
def simulate_match_result(card: dict[str, Any], index: int,
|
|
season: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""Apply an auto-simulated winner, a legal finish, and plausible rating inputs."""
|
|
match = card["matches"][index]
|
|
sides = match.get("sides", [])
|
|
if not sides:
|
|
raise ValueError("Cannot simulate a match before its participants are configured.")
|
|
weights = season_simulation_weights(season, match) if season else [1.0] * len(sides)
|
|
match["winner_side"] = random.choices(range(len(sides)), weights=weights, k=1)[0]
|
|
match["finish"] = random.choice(allowed_match_outcomes(match["type"]))
|
|
gimmick = match["type"] in {"Cage", "Ladder", "Hardcore", "Table", "TLC", "Hell in a Cell"}
|
|
low, high = (8, 28) if gimmick or match.get("title_defense") else (5, 20)
|
|
if index == 2:
|
|
high += 5
|
|
match["match_time_minutes"] = random.randint(low, high)
|
|
match["near_falls"] = random.randint(0, 5)
|
|
match["finishers"] = random.randint(1, 3)
|
|
match["rating"] = calculate_match_rating(match)
|
|
return match
|
|
|
|
|
|
def record_match_result(card: dict[str, Any], index: int, auto_simulate: bool = False) -> bool:
|
|
match = card["matches"][index]
|
|
season: dict[str, Any] | None = None
|
|
if card.get("season_id"):
|
|
try:
|
|
season = load_season(str(card["season_id"]))
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
print(f"Cannot verify season data: {exc}")
|
|
return False
|
|
if match.get("title_defense") and season and not validate_title_defense(season, match):
|
|
print("Edit this match's participants or title status before recording its result.")
|
|
return False
|
|
|
|
if auto_simulate:
|
|
simulate_match_result(card, index, season)
|
|
print(f"Auto-simulated Match {index + 1}: {' & '.join(match['sides'][match['winner_side']])} "
|
|
f"wins by {match['finish'].lower()} in {match['match_time_minutes']} minutes.")
|
|
else:
|
|
sides = match["sides"]
|
|
side_labels = [f"Side {side_number}: {' & '.join(side)}"
|
|
for side_number, side in enumerate(sides, 1)]
|
|
match["winner_side"] = ask_choice(
|
|
"Winning side", list(range(len(sides))), labels=side_labels
|
|
)
|
|
match["finish"] = choose_match_outcome(match)
|
|
match["match_time_minutes"] = prompt_match_minutes()
|
|
match["near_falls"] = prompt_action_count("Near falls (two-count kickouts)")
|
|
match["finishers"] = prompt_action_count("Finishers executed")
|
|
match["rating"] = calculate_match_rating(match)
|
|
save_card(card)
|
|
if card.get("season_id"):
|
|
sync_season_card_results(card)
|
|
print(f"Result saved to {card['path']} · Match rating: {match['rating']:.1f}%.")
|
|
ppv_rating = calculate_ppv_rating(card)
|
|
if ppv_rating is not None:
|
|
print(f"PPV rating (20/30/50 match weighting): {ppv_rating:.1f}%")
|
|
return True
|
|
|
|
|
|
def record_result(card: dict[str, Any], auto_simulate: bool | None = None,
|
|
match_index: int | None = None) -> None:
|
|
pending = pending_indices(card)
|
|
if not pending:
|
|
print("All three matches are complete.")
|
|
return
|
|
index = match_index if match_index in pending else choose_match_index(
|
|
card, pending, "Choose a match to finish"
|
|
)
|
|
if auto_simulate is None:
|
|
auto_simulate = ask_choice(
|
|
"Result mode", ["Enter result manually", "Auto-simulate result"]
|
|
) == 1
|
|
record_match_result(card, index, auto_simulate=auto_simulate)
|
|
|
|
|
|
def simulate_pending_matches(card: dict[str, Any]) -> None:
|
|
pending = pending_indices(card)
|
|
if not pending:
|
|
print("All three matches are complete.")
|
|
return
|
|
for index in pending:
|
|
if not record_match_result(card, index, auto_simulate=True):
|
|
break
|
|
|
|
|
|
def season_card_exit_label(card: dict[str, Any]) -> str:
|
|
if not card.get("season_id"):
|
|
return "Back to main menu"
|
|
complete = all(match.get("winner_side") is not None for match in card["matches"])
|
|
return "Advance to Next Show" if complete else "Return to Season Menu"
|
|
|
|
|
|
def choose_ranking_division(game_id: str, current: str | None = None,
|
|
allow_unranked: bool = True,
|
|
title_definitions: list[dict[str, str]] | None = None) -> str | None:
|
|
titles = title_definitions or title_definitions_for_game(game_id)
|
|
options: list[str | None] = ([None] if allow_unranked else []) + [title["name"] for title in titles]
|
|
labels = ["Unranked / Others pool" if item is None else item for item in options]
|
|
default = options.index(current) + 1 if current in options else None
|
|
return options[ask_choice("Assign ranking division", options,
|
|
default=default, labels=labels)]
|
|
|
|
|
|
def edit_match(card: dict[str, Any]) -> None:
|
|
editable = editable_indices(card)
|
|
if not editable:
|
|
print("There are no unfinished matches to edit.")
|
|
return
|
|
index = choose_match_index(card, editable, "Choose a match to edit")
|
|
match = card["matches"][index]
|
|
roster = load_roster(card["game_id"])
|
|
options = match_type_definitions(card["game_id"])
|
|
names = [item["name"] for item in options] + ["Keep current"]
|
|
labels = [f"{item['name']} ({' vs '.join(map(str, item['sides']))})"
|
|
for item in options]
|
|
labels.append(f"Keep current: {match['type']}")
|
|
selected = ask_choice("Choose match type", names, labels=labels)
|
|
old_sizes = tuple(len(side) for side in match.get("sides", []))
|
|
if selected < len(options):
|
|
match["type"] = options[selected]["name"]
|
|
side_sizes = tuple(options[selected]["sides"])
|
|
else:
|
|
found = next((entry for entry in options if entry["name"] == match["type"]), options[0])
|
|
side_sizes = tuple(found["sides"])
|
|
needs_participants = old_sizes != side_sizes
|
|
if needs_participants or ask("Change participants too? (y/N)").lower() == "y":
|
|
match["sides"] = choose_sides(side_sizes, roster)
|
|
profile = match_type_definition(card["game_id"], match["type"])
|
|
match["base_rating"] = profile["base_rating"]
|
|
match["valid_outcomes"] = list(profile["outcomes"])
|
|
current_title = match.get("title", "")
|
|
match["title"] = ask(f"Match title (blank removes; current: {current_title or 'none'})")
|
|
if card.get("season_id"):
|
|
match["ranking_division"] = choose_ranking_division(
|
|
card["game_id"], match.get("ranking_division"), allow_unranked=True,
|
|
title_definitions=card.get("title_definitions"),
|
|
)
|
|
if match["ranking_division"]:
|
|
match["title_defense"] = ask_yes_no(
|
|
"Is this a title defense?", default=bool(match.get("title_defense"))
|
|
)
|
|
else:
|
|
match["title_defense"] = False
|
|
save_card(card)
|
|
print(f"Match updated and saved to {card['path']}.")
|
|
|
|
|
|
def season_path(season_id: str) -> Path:
|
|
return SEASON_DIR / f"{slugify(season_id)}.json"
|
|
|
|
|
|
def new_season_state(name: str, game_id: str,
|
|
champions: dict[str, list[str] | None] | None = None,
|
|
season_id: str | None = None) -> dict[str, Any]:
|
|
champions = champions or {}
|
|
profile = game_profile(game_id)
|
|
title_definitions = [dict(item) for item in profile["championships"]]
|
|
title_state: dict[str, Any] = {}
|
|
for definition in title_definitions:
|
|
division = definition["name"]
|
|
holder = champions.get(division)
|
|
title_state[division] = {
|
|
"holder": list(holder) if holder else None,
|
|
"successful_defenses": 0,
|
|
"defense_history": {},
|
|
}
|
|
if holder:
|
|
key = side_identity(holder)
|
|
title_state[division]["defense_history"][key] = {
|
|
"members": list(holder), "successful_defenses": 0,
|
|
}
|
|
if season_id is None:
|
|
season_id = f"{slugify(name)}-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}"
|
|
return {
|
|
"season_id": season_id,
|
|
"name": name,
|
|
"game_id": game_id,
|
|
"game_title": profile["game_title"],
|
|
"federation_name": profile["federation_name"],
|
|
"game_config": profile,
|
|
"title_definitions": title_definitions,
|
|
"shows": shows_snapshot(profile),
|
|
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
"next_show_number": 1,
|
|
"current_card": "",
|
|
"champions": title_state,
|
|
"rankings": {definition["name"]: {} for definition in title_definitions},
|
|
"rivalries": {},
|
|
"processed_results": [],
|
|
"show_history": [],
|
|
}
|
|
|
|
|
|
def save_season(season: dict[str, Any]) -> None:
|
|
SEASON_DIR.mkdir(parents=True, exist_ok=True)
|
|
path = season_path(season["season_id"])
|
|
path.write_text(json.dumps(season, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def load_season(path_or_id: Path | str) -> dict[str, Any]:
|
|
path = path_or_id if isinstance(path_or_id, Path) else season_path(path_or_id)
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(payload, dict) or payload.get("game_id") not in GAMES:
|
|
raise ValueError(f"Invalid season save: {path}")
|
|
if not payload.get("season_id") or not payload.get("name"):
|
|
raise ValueError(f"Season save is missing its identity: {path}")
|
|
payload.setdefault("game_config", game_profile(payload["game_id"]))
|
|
payload.setdefault("game_title", payload["game_config"].get("game_title", game_title(payload["game_id"])))
|
|
payload.setdefault("federation_name", payload["game_config"].get(
|
|
"federation_name", federation_name(payload["game_id"])
|
|
))
|
|
payload.setdefault("title_definitions", payload["game_config"].get(
|
|
"championships", title_definitions_for_game(payload["game_id"])
|
|
))
|
|
payload.setdefault("shows", shows_snapshot(payload.get("game_config")))
|
|
title_names = season_title_names(payload)
|
|
payload.setdefault("next_show_number", 1)
|
|
payload.setdefault("current_card", "")
|
|
payload.setdefault("rankings", {division: {} for division in title_names})
|
|
payload.setdefault("champions", {})
|
|
payload.setdefault("rivalries", {})
|
|
payload.setdefault("processed_results", [])
|
|
payload.setdefault("show_history", [])
|
|
for division in title_names:
|
|
payload.setdefault("rankings", {}).setdefault(division, {})
|
|
payload.setdefault("champions", {}).setdefault(
|
|
division, {"holder": None, "successful_defenses": 0, "defense_history": {}}
|
|
)
|
|
payload["champions"][division].setdefault("successful_defenses", 0)
|
|
payload["champions"][division].setdefault("defense_history", {})
|
|
return payload
|
|
|
|
|
|
def side_identity(side: list[str]) -> str:
|
|
return json.dumps(sorted(side, key=str.casefold), ensure_ascii=False)
|
|
|
|
|
|
def side_display(side: list[str] | None) -> str:
|
|
return "Vacant" if not side else " & ".join(side)
|
|
|
|
|
|
def eligible_roster_for_division(game_id: str, division: str,
|
|
roster: list[str] | None = None,
|
|
profile: dict[str, Any] | None = None) -> list[str]:
|
|
profile = profile or game_profile(game_id)
|
|
roster = list(profile.get("roster", [])) if roster is None else roster
|
|
if championship_kind(game_id, division, profile) == "women":
|
|
eligible_names = profile.get("women_roster", [])
|
|
eligible = {name.casefold() for name in eligible_names}
|
|
return [name for name in roster if name.casefold() in eligible]
|
|
return list(roster)
|
|
|
|
|
|
def choose_roster_member(names: list[str], prompt: str) -> str:
|
|
if not names:
|
|
raise ValueError("No eligible wrestlers are available for that championship.")
|
|
return names[ask_choice(prompt, names)]
|
|
|
|
|
|
def choose_initial_champion(division: str, game_id: str, used: set[str],
|
|
profile: dict[str, Any] | None = None) -> list[str] | None:
|
|
profile = profile or game_profile(game_id)
|
|
roster = eligible_roster_for_division(game_id, division, profile=profile)
|
|
is_tag_title = championship_kind(game_id, division, profile) == "tag"
|
|
print(f"\nInitial {division} champion:")
|
|
mode = ask_choice("Choose initial champion option", ["choose", "random", "vacant"],
|
|
labels=["Choose wrestler/team", "Random", "Vacant"])
|
|
if mode == 2: # ask_choice is zero-based: option 3 is Vacant.
|
|
return None
|
|
choose_manually = mode == 0 # option 1 is Choose; option 2 is Random.
|
|
available = [name for name in roster if name.casefold() not in used]
|
|
if len(available) < (2 if is_tag_title else 1):
|
|
available = roster
|
|
if is_tag_title:
|
|
if choose_manually:
|
|
first = choose_roster_member(available, "First tag champion")
|
|
remaining = [name for name in available if name != first] or available
|
|
second = choose_roster_member(remaining, "Second tag champion")
|
|
return [first, second]
|
|
return random.sample(available, 2)
|
|
if choose_manually:
|
|
return [choose_roster_member(available, f"{division} champion")]
|
|
return [random.choice(available)]
|
|
|
|
|
|
def create_season() -> dict[str, Any]:
|
|
game_id = choose_game()
|
|
profile = game_profile(game_id)
|
|
name = ask("Season name", f"{game_display_name(game_id)} Season")
|
|
champions: dict[str, list[str] | None] = {}
|
|
used: set[str] = set()
|
|
for definition in profile["championships"]:
|
|
division = definition["name"]
|
|
holder = choose_initial_champion(division, game_id, used, profile)
|
|
champions[division] = holder
|
|
if holder:
|
|
used.update(name.casefold() for name in holder)
|
|
season = new_season_state(name, game_id, champions)
|
|
save_season(season)
|
|
print(f"Season saved: {season_path(season['season_id'])}")
|
|
return season
|
|
|
|
|
|
def season_show_brand(show_number: int) -> str:
|
|
return SEASON_CYCLE[(show_number - 1) % len(SEASON_CYCLE)]
|
|
|
|
|
|
def season_ranking_entries(season: dict[str, Any], division: str) -> list[dict[str, Any]]:
|
|
entries = list(season.get("rankings", {}).get(division, {}).values())
|
|
|
|
def ranking_key(entry: dict[str, Any]) -> tuple[Any, ...]:
|
|
wins, losses = entry.get("wins", 0), entry.get("losses", 0)
|
|
total = wins + losses
|
|
percentage = wins / total if total else 0.0
|
|
recent_wins = sum(1 for result in entry.get("recent", [])[-5:] if result == "W")
|
|
return (wins, percentage, recent_wins, -losses,
|
|
side_display(entry.get("members", [])).casefold())
|
|
|
|
return sorted(entries, key=ranking_key, reverse=True)
|
|
|
|
|
|
def season_other_pool(season: dict[str, Any], roster: list[str] | None = None) -> list[str]:
|
|
"""Unassigned wrestlers for non-title booking; this is not a title division."""
|
|
profile = season_game_profile(season)
|
|
roster = roster or list(profile.get("roster", load_roster(season["game_id"])))
|
|
involved: set[str] = set()
|
|
for division in season_title_names(season):
|
|
for entry in season.get("rankings", {}).get(division, {}).values():
|
|
involved.update(name.casefold() for name in entry.get("members", []))
|
|
holder = season.get("champions", {}).get(division, {}).get("holder")
|
|
if holder:
|
|
involved.update(name.casefold() for name in holder)
|
|
return [name for name in roster if name.casefold() not in involved]
|
|
|
|
|
|
def print_power_rankings(season: dict[str, Any]) -> None:
|
|
print(f"\nPOWER RANKINGS — {season['name']} ({game_display_name(season['game_id'])})")
|
|
for division in season_title_names(season):
|
|
championship = season["champions"][division]
|
|
holder = championship.get("holder")
|
|
print(f"\n{division} Champion: {side_display(holder)}")
|
|
if holder:
|
|
holder_stats = championship.get("defense_history", {}).get(side_identity(holder), {})
|
|
print(f" Successful defenses this reign: {championship.get('successful_defenses', 0)}"
|
|
f" · Career defenses: {holder_stats.get('successful_defenses', 0)}")
|
|
history = championship.get("defense_history", {})
|
|
if history:
|
|
print(" Title history:")
|
|
for defense_record in history.values():
|
|
print(f" {side_display(defense_record.get('members', []))}: "
|
|
f"{defense_record.get('successful_defenses', 0)} successful defenses")
|
|
entries = season_ranking_entries(season, division)
|
|
if not entries:
|
|
print(" No division results yet.")
|
|
continue
|
|
for position, entry in enumerate(entries[:10], 1):
|
|
wins, losses = entry.get("wins", 0), entry.get("losses", 0)
|
|
total = wins + losses
|
|
percentage = wins / total if total else 0.0
|
|
print(f" {position:>2}. {side_display(entry.get('members', []))}"
|
|
f" — {wins}-{losses} ({percentage:.0%})")
|
|
pool = season_other_pool(season)
|
|
print(f"\nOthers pool (not currently in a title division): {len(pool)} wrestlers")
|
|
print(" " + ", ".join(pool[:20]) + (" …" if len(pool) > 20 else ""))
|
|
|
|
|
|
def season_rivalry_id(sides: list[list[str]]) -> str | None:
|
|
if len(sides) != 2:
|
|
return None
|
|
return json.dumps(sorted((side_identity(sides[0]), side_identity(sides[1]))),
|
|
ensure_ascii=False)
|
|
|
|
|
|
def record_season_match(season: dict[str, Any], card: dict[str, Any],
|
|
match: dict[str, Any], match_index: int) -> None:
|
|
division = match.get("ranking_division")
|
|
winner = match.get("winner_side")
|
|
if division in season_title_names(season) and winner is not None:
|
|
table = season["rankings"].setdefault(division, {})
|
|
for side_index, side in enumerate(match.get("sides", [])):
|
|
key = side_identity(side)
|
|
entry = table.setdefault(key, {"members": list(side), "wins": 0, "losses": 0,
|
|
"recent": []})
|
|
result = "W" if side_index == winner else "L"
|
|
entry["wins" if result == "W" else "losses"] += 1
|
|
entry.setdefault("recent", []).append(result)
|
|
entry["recent"] = entry["recent"][-10:]
|
|
|
|
if match.get("title_defense") and division in season_title_names(season) and winner is not None:
|
|
championship = season["champions"][division]
|
|
winner_side = list(match["sides"][winner])
|
|
holder = championship.get("holder")
|
|
defense_history = championship.setdefault("defense_history", {})
|
|
if holder and side_identity(holder) == side_identity(winner_side):
|
|
championship["successful_defenses"] = championship.get("successful_defenses", 0) + 1
|
|
lifetime = defense_history.setdefault(side_identity(holder), {
|
|
"members": list(holder), "successful_defenses": 0,
|
|
})
|
|
lifetime["successful_defenses"] += 1
|
|
else:
|
|
championship["holder"] = winner_side
|
|
championship["successful_defenses"] = 0
|
|
defense_history.setdefault(side_identity(winner_side), {
|
|
"members": winner_side, "successful_defenses": 0,
|
|
})
|
|
|
|
rivalry_id = season_rivalry_id(match.get("sides", []))
|
|
if rivalry_id:
|
|
ordered_sides = sorted((list(match["sides"][0]), list(match["sides"][1])),
|
|
key=side_identity)
|
|
rivalry = season["rivalries"].setdefault(rivalry_id, {
|
|
"sides": ordered_sides, "meetings": 0, "winner_counts": {},
|
|
"resolved": False, "ranking_division": division,
|
|
})
|
|
rivalry["meetings"] += 1
|
|
if winner is not None:
|
|
winner_key = side_identity(match["sides"][winner])
|
|
counts = rivalry.setdefault("winner_counts", {})
|
|
counts[winner_key] = counts.get(winner_key, 0) + 1
|
|
rivalry["last_winner"] = winner_key
|
|
if match.get("rivalry_payoff"):
|
|
rivalry["resolved"] = True
|
|
rivalry["resolved_at"] = card.get("season_show", "")
|
|
|
|
|
|
def validate_title_defense(season: dict[str, Any], match: dict[str, Any]) -> bool:
|
|
division = match.get("ranking_division")
|
|
if not match.get("title_defense"):
|
|
return True
|
|
if division not in season_title_names(season):
|
|
print("A title defense must be assigned to a championship division.")
|
|
return False
|
|
holder = season["champions"][division].get("holder")
|
|
if holder and not any(side_identity(side) == side_identity(holder)
|
|
for side in match.get("sides", [])):
|
|
print(f"The current {division} champion ({side_display(holder)}) must be in this match.")
|
|
return False
|
|
return True
|
|
|
|
|
|
def sync_season_card_results(card: dict[str, Any]) -> None:
|
|
season_id = card.get("season_id")
|
|
if not season_id:
|
|
return
|
|
try:
|
|
season = load_season(str(season_id))
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
print(f"Season ranking update skipped: {exc}")
|
|
return
|
|
processed = set(season.get("processed_results", []))
|
|
for index, match in enumerate(card["matches"]):
|
|
if match.get("winner_side") is None:
|
|
continue
|
|
result_id = f"{season_id}:{card.get('season_show_number', 0)}:{index + 1}"
|
|
if result_id in processed:
|
|
continue
|
|
if match.get("title_defense") and not validate_title_defense(season, match):
|
|
continue
|
|
record_season_match(season, card, match, index)
|
|
processed.add(result_id)
|
|
season["processed_results"] = sorted(processed)
|
|
complete = all(match.get("winner_side") is not None for match in card["matches"])
|
|
if complete:
|
|
if Path(str(season.get("current_card", ""))) == Path(str(card.get("path", ""))):
|
|
season["current_card"] = ""
|
|
show_number = int(card.get("season_show_number", season.get("next_show_number", 1)))
|
|
season["next_show_number"] = max(season.get("next_show_number", 1), show_number + 1)
|
|
if card.get("path") not in season.setdefault("show_history", []):
|
|
season["show_history"].append(card["path"])
|
|
save_season(season)
|
|
|
|
|
|
def title_defense_weight(kind: str, brand: str) -> float:
|
|
base = {"title": 1.4, "rivalry": 1.2, "ranked": 1.3, "pool": 1.0}[kind]
|
|
if brand == "PPV" and kind == "title":
|
|
return base * PPV_TITLE_WEIGHT
|
|
if brand == "PPV" and kind == "rivalry":
|
|
return base * PPV_RIVALRY_WEIGHT
|
|
return base
|
|
|
|
|
|
def make_season_match(game_id: str, sides: list[list[str]], division: str | None,
|
|
title: str = "", title_defense: bool = False,
|
|
rivalry_payoff: bool = False,
|
|
profile: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
sizes = tuple(len(side) for side in sides)
|
|
options = [item for item in match_type_definitions(game_id, profile)
|
|
if tuple(item["sides"]) == sizes]
|
|
definition = random.choice(options) if options else match_type_definition(game_id, "Singles", profile)
|
|
match_type = definition["name"]
|
|
return {
|
|
"title": title,
|
|
"type": match_type,
|
|
"sides": [list(side) for side in sides],
|
|
"winner_side": None,
|
|
"finish": "",
|
|
"promo": None,
|
|
"image": "",
|
|
"ranking_division": division,
|
|
"title_defense": title_defense,
|
|
"rivalry_payoff": rivalry_payoff,
|
|
"match_time_minutes": None,
|
|
"near_falls": 0,
|
|
"finishers": 0,
|
|
"rating": None,
|
|
"base_rating": definition["base_rating"],
|
|
"valid_outcomes": list(definition["outcomes"]),
|
|
}
|
|
|
|
|
|
def division_roster(game_id: str, division: str, roster: list[str], used: set[str],
|
|
holder: list[str] | None = None,
|
|
profile: dict[str, Any] | None = None) -> list[str]:
|
|
profile = profile or game_profile(game_id)
|
|
candidates = eligible_roster_for_division(game_id, division, roster, profile)
|
|
is_tag = championship_kind(game_id, division, profile) == "tag"
|
|
excluded = {name.casefold() for name in used}
|
|
if holder:
|
|
excluded.update(name.casefold() for name in holder)
|
|
available = [name for name in candidates if name.casefold() not in excluded]
|
|
if len(available) < (4 if is_tag else 2):
|
|
available = [name for name in candidates if name.casefold() not in {n.casefold() for n in used}]
|
|
return available if len(available) >= (4 if is_tag else 2) else candidates
|
|
|
|
|
|
def seed_division_sides(game_id: str, division: str, roster: list[str], used: set[str],
|
|
holder: list[str] | None = None,
|
|
profile: dict[str, Any] | None = None) -> list[list[str]] | None:
|
|
profile = profile or game_profile(game_id)
|
|
available = division_roster(game_id, division, roster, used, holder, profile)
|
|
is_tag = championship_kind(game_id, division, profile) == "tag"
|
|
needed = 4 if is_tag else 2
|
|
if len(available) < needed:
|
|
return None
|
|
picks = random.sample(available, needed)
|
|
if is_tag:
|
|
return [picks[:2], picks[2:]]
|
|
return [[picks[0]], [picks[1]]]
|
|
|
|
|
|
def season_booking_candidates(season: dict[str, Any], roster: list[str], brand: str,
|
|
used: set[str]) -> list[dict[str, Any]]:
|
|
game_id = season["game_id"]
|
|
profile = season_game_profile(season)
|
|
candidates: list[dict[str, Any]] = []
|
|
used_folded = {name.casefold() for name in used}
|
|
|
|
def add(kind: str, match: dict[str, Any]) -> None:
|
|
members = {name.casefold() for side in match["sides"] for name in side}
|
|
participant_count = sum(len(side) for side in match["sides"])
|
|
if len(members) == participant_count and not members.intersection(used_folded):
|
|
candidates.append({"kind": kind, "weight": title_defense_weight(kind, brand),
|
|
"match": match})
|
|
|
|
# A division's top-ranked non-champion is the natural challenger.
|
|
for division in season_title_names(season):
|
|
championship = season["champions"][division]
|
|
holder = championship.get("holder")
|
|
entries = season_ranking_entries(season, division)
|
|
eligible_size = 2 if season_championship_kind(season, division) == "tag" else 1
|
|
valid_entries = [entry for entry in entries
|
|
if len(entry.get("members", [])) == eligible_size]
|
|
if holder:
|
|
challengers = [entry for entry in valid_entries
|
|
if side_identity(entry["members"]) != side_identity(holder)
|
|
and not ({name.casefold() for name in entry["members"]} & used_folded)]
|
|
if challengers:
|
|
challenger = challengers[0]["members"]
|
|
rivalry_id = season_rivalry_id([list(holder), list(challenger)])
|
|
prior_rivalry = season.get("rivalries", {}).get(rivalry_id or "", {})
|
|
payoff = (brand == "PPV" and not prior_rivalry.get("resolved")
|
|
and prior_rivalry.get("meetings", 0) >= 1)
|
|
match = make_season_match(game_id, [list(holder), list(challenger)], division,
|
|
title=(f"{division} Championship — Feud Finale"
|
|
if payoff else f"{division} Championship"),
|
|
title_defense=True, rivalry_payoff=payoff,
|
|
profile=profile)
|
|
add("title", match)
|
|
elif len(valid_entries) >= 2:
|
|
challengers = [entry["members"] for entry in valid_entries
|
|
if not ({name.casefold() for name in entry["members"]} & used_folded)]
|
|
if len(challengers) >= 2:
|
|
match = make_season_match(
|
|
game_id, [list(challengers[0]), list(challengers[1])], division,
|
|
title=f"Vacant {division} Championship", title_defense=True,
|
|
profile=profile,
|
|
)
|
|
add("title", match)
|
|
|
|
# Non-title contender bouts seed or improve the division rankings.
|
|
ranked_sides = [list(entry["members"]) for entry in valid_entries
|
|
if not ({name.casefold() for name in entry.get("members", [])} & used_folded)]
|
|
sides: list[list[str]] | None = None
|
|
if len(ranked_sides) >= 2:
|
|
sides = ranked_sides[:2]
|
|
elif len(ranked_sides) == 1:
|
|
available = division_roster(game_id, division, roster, used, holder, profile)
|
|
available = [name for name in available
|
|
if name.casefold() not in {n.casefold() for n in ranked_sides[0]}]
|
|
needed = 2 if season_championship_kind(season, division) == "tag" else 1
|
|
if len(available) >= needed:
|
|
new_side = random.sample(available, needed)
|
|
sides = [ranked_sides[0], new_side]
|
|
if sides is None:
|
|
sides = seed_division_sides(game_id, division, roster, used, holder, profile)
|
|
if sides:
|
|
add("ranked", make_season_match(game_id, sides, division, profile=profile))
|
|
|
|
# Repeated head-to-head meetings become active rivalries; PPVs can resolve them.
|
|
for rivalry in season.get("rivalries", {}).values():
|
|
sides = rivalry.get("sides", [])
|
|
if rivalry.get("resolved") or rivalry.get("meetings", 0) < 2 or len(sides) != 2:
|
|
continue
|
|
division = rivalry.get("ranking_division")
|
|
payoff = brand == "PPV"
|
|
title = "Rivalry Finale" if payoff else "Rivalry Match"
|
|
add("rivalry", make_season_match(game_id, sides, division, title=title,
|
|
rivalry_payoff=payoff, profile=profile))
|
|
|
|
# The Others pool supplies unranked, non-title bouts.
|
|
pool = [name for name in season_other_pool(season, roster)
|
|
if name.casefold() not in used_folded]
|
|
if len(pool) < 2:
|
|
pool = [name for name in roster if name.casefold() not in used_folded]
|
|
if len(pool) >= 2:
|
|
available_types = [item for item in match_type_definitions(game_id, profile)
|
|
if tuple(item["sides"]) in ((1, 1), (2, 2), (1, 1, 1))]
|
|
sizes = tuple(random.choice(available_types)["sides"])
|
|
needed = sum(sizes)
|
|
choices = pool if len(pool) >= needed else [name for name in roster
|
|
if name.casefold() not in used_folded]
|
|
if len(choices) < needed:
|
|
choices = list(roster)
|
|
picks = random.sample(choices, needed)
|
|
sides: list[list[str]] = []
|
|
cursor = 0
|
|
for size in sizes:
|
|
sides.append(picks[cursor:cursor + size])
|
|
cursor += size
|
|
add("pool", make_season_match(game_id, sides, None, title="Non-Title Match",
|
|
profile=profile))
|
|
return candidates
|
|
|
|
|
|
def build_season_matches(season: dict[str, Any], brand: str) -> list[dict[str, Any]]:
|
|
profile = season_game_profile(season)
|
|
roster = list(profile.get("roster", load_roster(season["game_id"])))
|
|
used: set[str] = set()
|
|
matches: list[dict[str, Any]] = []
|
|
title_defenses = 0
|
|
max_title_defenses = 2 if brand == "PPV" else 1
|
|
for _ in range(3):
|
|
candidates = season_booking_candidates(season, roster, brand, used)
|
|
if title_defenses >= max_title_defenses:
|
|
candidates = [candidate for candidate in candidates if candidate["kind"] != "title"]
|
|
if not candidates:
|
|
fallback = random_match(season["game_id"], roster, used, profile=profile)
|
|
matches.append(fallback)
|
|
used.update(name.casefold() for side in fallback["sides"] for name in side)
|
|
continue
|
|
picked = random.choices(candidates, weights=[item["weight"] for item in candidates], k=1)[0]
|
|
match = picked["match"]
|
|
matches.append(match)
|
|
used.update(name.casefold() for side in match["sides"] for name in side)
|
|
if match.get("title_defense"):
|
|
title_defenses += 1
|
|
return matches
|
|
|
|
|
|
def show_for_season_number(show_number: int) -> str:
|
|
return season_show_brand(show_number)
|
|
|
|
|
|
def book_next_season_show(season: dict[str, Any]) -> dict[str, Any]:
|
|
current_path = str(season.get("current_card") or "")
|
|
if current_path and Path(current_path).is_file():
|
|
card = load_card(Path(current_path))
|
|
sync_season_card_results(card)
|
|
updated = load_season(str(season["season_id"]))
|
|
if updated.get("current_card"):
|
|
raise ValueError("Finish the current season show before booking the next one.")
|
|
season.clear()
|
|
season.update(updated)
|
|
number = int(season.get("next_show_number", 1))
|
|
brand = show_for_season_number(number)
|
|
show = show_name_for_brand(brand, season.get("shows"))
|
|
city, attendance = random_location()
|
|
show_date = date.today().isoformat()
|
|
card: dict[str, Any] = {
|
|
"game_id": season["game_id"], "show": show, "date": show_date,
|
|
"city": city, "attendance": attendance,
|
|
"season_id": season["season_id"], "season_show": brand,
|
|
"season_show_number": number,
|
|
"matches": build_season_matches(season, brand),
|
|
"path": str(default_card_path(show, show_date)),
|
|
}
|
|
save_card(card)
|
|
season["current_card"] = card["path"]
|
|
save_season(season)
|
|
print(f"\nBooked Season Show {number}: {show} ({brand})")
|
|
return card
|
|
|
|
|
|
def season_files() -> list[Path]:
|
|
return sorted(SEASON_DIR.glob("*.json"), reverse=True) if SEASON_DIR.is_dir() else []
|
|
|
|
|
|
def choose_or_create_season() -> dict[str, Any]:
|
|
paths = season_files()
|
|
labels = ["Start a new season"]
|
|
for path in paths:
|
|
try:
|
|
saved = load_season(path)
|
|
next_brand = season_show_brand(saved.get("next_show_number", 1))
|
|
next_show = show_name_for_brand(next_brand, saved.get("shows"))
|
|
labels.append(f"{saved['name']} · {saved.get('federation_name', federation_name(saved['game_id']))}"
|
|
f" · {saved.get('game_title', game_title(saved['game_id']))} · next: {next_show}")
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
labels.append(f"{path.stem} (invalid save)")
|
|
selected = ask_choice("Season Mode", ["new"] + [path.name for path in paths], labels=labels)
|
|
if selected == 0:
|
|
return create_season()
|
|
return load_season(paths[selected - 1])
|
|
|
|
|
|
def run_season(season: dict[str, Any]) -> None:
|
|
while True:
|
|
number = int(season.get("next_show_number", 1))
|
|
brand = season_show_brand(number)
|
|
current = str(season.get("current_card") or "")
|
|
if current and not Path(current).is_file():
|
|
season["current_card"] = ""
|
|
save_season(season)
|
|
current = ""
|
|
print(f"\nSeason: {season['name']} · {season.get('federation_name', federation_name(season['game_id']))}"
|
|
f" · {season.get('game_title', game_title(season['game_id']))}")
|
|
next_name = show_name_for_brand(brand, season.get("shows"))
|
|
print(f"Cycle {((number - 1) // 3) + 1} · Next: {next_name} ({brand})")
|
|
if current:
|
|
next_action = "Resume current show"
|
|
else:
|
|
next_action = f"Advance to Next Show ({next_name})"
|
|
choice = ask_choice(
|
|
"Season action", ["book/resume", "rankings", "back"],
|
|
labels=[next_action, "Power rankings / title holders", "Back to main menu"],
|
|
)
|
|
if choice == 0:
|
|
card = load_card(Path(current)) if current else book_next_season_show(season)
|
|
sync_season_card_results(card)
|
|
resume_card(card)
|
|
# Refresh the season's progression after returning from a card.
|
|
season_id = str(season["season_id"])
|
|
season.clear()
|
|
season.update(load_season(season_id))
|
|
elif choice == 1:
|
|
print_power_rankings(season)
|
|
input("\nPress Enter to return...")
|
|
else:
|
|
return
|
|
|
|
|
|
def season_mode() -> None:
|
|
try:
|
|
run_season(choose_or_create_season())
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
print(f"Could not open or run that season: {exc}")
|
|
|
|
|
|
PROMO_OPENERS = [
|
|
"The crowd may be watching, but I only have eyes for this fight.",
|
|
"You have had your moment. Now the spotlight is mine.",
|
|
"All the talking ends when that bell rings.",
|
|
"I came here to prove that I belong at the top.",
|
|
]
|
|
PROMO_CHALLENGES = [
|
|
"Bring your best, because I will not make this easy.",
|
|
"You will have to do more than make promises tonight.",
|
|
"I know exactly what you can do, and I am still not impressed.",
|
|
"One mistake is all I need to turn this match around.",
|
|
]
|
|
PROMO_RETORTS = [
|
|
"Keep that confidence. You will need something to hold onto.",
|
|
"I have heard it all before. Show me something new in the ring.",
|
|
"You can take the last word. I will take the win.",
|
|
"When the match is over, everyone will know who meant it.",
|
|
]
|
|
|
|
PROMO_PROFILES: dict[str, dict[str, list[str]]] = {
|
|
"rock": {
|
|
"openers": [
|
|
"Listen up, jabroni {opponent}—The Rock is ready to layeth the smackdown.",
|
|
"Finally, {opponent}, The Rock has come back to this ring.",
|
|
"The Rock is standing across from {opponent}, and the millions are watching.",
|
|
],
|
|
"challenges": [
|
|
"Know your role and shut your mouth, {opponent}, because The Rock is taking over.",
|
|
"Bring it, {opponent}, and The Rock will bring the thunder.",
|
|
"Jabroni, you can bring your very best. The Rock will still be better.",
|
|
],
|
|
"retorts": [
|
|
"The Rock says {opponent} is about to get checked into the SmackDown Hotel.",
|
|
"The Rock has one thing left to say—know your role, jabroni.",
|
|
"The millions have spoken, and they are chanting for The Rock.",
|
|
],
|
|
},
|
|
"austin": {
|
|
"openers": [
|
|
"Stone Cold is here, and {opponent} can like it or lump it.",
|
|
"Austin has one plan for {opponent}—open up a can and get to work.",
|
|
"The glass breaks, {opponent} looks nervous, and Stone Cold likes that.",
|
|
],
|
|
"challenges": [
|
|
"That's the bottom line, because Stone Cold said so.",
|
|
"You want a fight, {opponent}? Stone Cold doesn't do negotiations.",
|
|
"Austin doesn't care about your excuses, {opponent}. Ring the bell.",
|
|
],
|
|
"retorts": [
|
|
"Stone Cold will leave {opponent} seeing stars and hearing that bell.",
|
|
"Austin has heard enough. It's time to raise a little hell.",
|
|
"And that's the bottom line—{opponent} is in for a rough night.",
|
|
],
|
|
},
|
|
"tripleh": {
|
|
"openers": [
|
|
"I am the Game, {opponent}, and you are standing on my board.",
|
|
"The Game has arrived. {opponent}, you know how this ends.",
|
|
"I built my place at the top one victory at a time, {opponent}.",
|
|
],
|
|
"challenges": [
|
|
"You can play your part, {opponent}, while I decide how the story ends.",
|
|
"The Game doesn't hand out victories. It takes them.",
|
|
"Step into my ring, {opponent}, and find out why they call me the Game.",
|
|
],
|
|
"retorts": [
|
|
"I am the Game, and this is where your luck runs out.",
|
|
"When the dust settles, {opponent}, I will still be standing on top.",
|
|
"The Game is over when I say it's over.",
|
|
],
|
|
},
|
|
"mankind": {
|
|
"openers": ["{opponent} gets the spotlight, and the little guy gets blamed again."],
|
|
"challenges": ["{opponent}, they keep persecuting the little guy—but the little guy keeps getting back up."],
|
|
"retorts": ["Pain can knock all it wants; I keep getting up before it finds the right door."],
|
|
"sayings": [
|
|
"People say the early bird gets the worm. I say the worm gets the bird, the bird gets a clock, and the clock gets confused.",
|
|
"You can't have your cake and eat it too—so I gave the cake the fork and told the fork to find its way home.",
|
|
"A stitch in time saves nine; I lost the needle, tripped over time, and still made it to the ring.",
|
|
"They say every cloud has a silver lining. Mine had a receipt, and the rain returned it upside down.",
|
|
],
|
|
"pain": [
|
|
"Pain hit me like a ton of bricks; I apologized to the bricks and kept walking.",
|
|
"My body says stop, but I think the sign is facing the other way.",
|
|
"The hurt is loud. Little guys learn to sleep through the noise.",
|
|
],
|
|
},
|
|
"undertaker": {
|
|
"openers": [
|
|
"{opponent}, the lights will fade, and the bell will toll for you.",
|
|
"The Undertaker has walked through darkness far worse than this ring.",
|
|
],
|
|
"challenges": [
|
|
"You can run from the shadow, {opponent}, but you cannot outrun it.",
|
|
"When the bell tolls, {opponent}, there will be nowhere left to hide.",
|
|
],
|
|
"retorts": [
|
|
"Rest in peace, {opponent}—if you can.",
|
|
"The darkness is patient. So am I.",
|
|
],
|
|
},
|
|
"kane": {
|
|
"openers": ["The Big Red Machine has been unleashed, {opponent}."],
|
|
"challenges": ["You hear the flames, {opponent}? They are calling your name."],
|
|
"retorts": ["Kane doesn't back down. Kane brings the fire."],
|
|
},
|
|
"jericho": {
|
|
"openers": ["Welcome to the highlight reel, {opponent}. Chris Jericho is starring."],
|
|
"challenges": ["You are looking at the best in the world at what he does."],
|
|
"retorts": ["Now take a good look at the first-ever undisputed problem in your way."],
|
|
},
|
|
"kurtangle": {
|
|
"openers": ["I won an Olympic gold medal, {opponent}. What have you got?"],
|
|
"challenges": ["It's true, it's true—excellence is about to beat you."],
|
|
"retorts": ["You may cheer now, but you will be tapping soon enough."],
|
|
},
|
|
"vince": {
|
|
"openers": ["You are looking at the chairman, {opponent}, and this is my show."],
|
|
"challenges": ["You are one bad decision away from hearing, 'You're fired!'"],
|
|
"retorts": ["The boss always gets the last word."],
|
|
},
|
|
"edge": {
|
|
"openers": ["{opponent}, this is my chance to steal the spotlight."],
|
|
"challenges": ["I will take every shortcut and every opening you leave me."],
|
|
"retorts": ["You had your chance. I am taking the moment."],
|
|
},
|
|
"jeffhardy": {
|
|
"openers": ["{opponent}, I will take the risk nobody else dares to take."],
|
|
"challenges": ["I will fly, fall, and get right back up if that's what it takes."],
|
|
"retorts": ["The risk is worth it when the crowd is on its feet."],
|
|
},
|
|
"alsnow": {
|
|
"openers": ["Head says {opponent} is in trouble. Head is usually right."],
|
|
"challenges": ["I have talked this over with Head, and we're ready for you."],
|
|
"retorts": ["Head says that went exactly as planned."],
|
|
},
|
|
}
|
|
|
|
PROMO_PROFILE_ALIASES = {
|
|
"therock": "rock", "rock": "rock",
|
|
"stonecoldsteveaustin": "austin", "steveaustin": "austin", "austin": "austin",
|
|
"tripleh": "tripleh", "hunterhearsthelmsley": "tripleh",
|
|
"mankind": "mankind", "mickfoley": "mankind",
|
|
"undertaker": "undertaker", "theundertaker": "undertaker",
|
|
"kane": "kane",
|
|
"chrisjericho": "jericho", "jericho": "jericho",
|
|
"kurtangle": "kurtangle",
|
|
"vincemcmahon": "vince",
|
|
"edge": "edge",
|
|
"jeffhardy": "jeffhardy",
|
|
"alsnow": "alsnow",
|
|
}
|
|
|
|
|
|
def normalized_wrestler_name(name: str) -> str:
|
|
return re.sub(r"[^a-z0-9]", "", name.casefold())
|
|
|
|
|
|
def promo_line_for(wrestler: str, opponent: str, moment: str) -> str:
|
|
"""Return a short line shaped by a wrestler's voice, or use the generic bank."""
|
|
profile_key = PROMO_PROFILE_ALIASES.get(normalized_wrestler_name(wrestler))
|
|
if profile_key == "mankind":
|
|
profile = PROMO_PROFILES[profile_key]
|
|
parts = [
|
|
random.choice(profile["openers"]).format(opponent=opponent),
|
|
random.choice(profile["sayings"]),
|
|
random.choice(profile["pain"]),
|
|
]
|
|
return " ".join(parts)
|
|
if profile_key:
|
|
line = random.choice(PROMO_PROFILES[profile_key][moment])
|
|
else:
|
|
generic = {"openers": PROMO_OPENERS, "challenges": PROMO_CHALLENGES,
|
|
"retorts": PROMO_RETORTS}[moment]
|
|
line = random.choice(generic)
|
|
return line.format(opponent=opponent, wrestler=wrestler)
|
|
|
|
|
|
def generate_promo(card: dict[str, Any]) -> None:
|
|
pending = pending_indices(card)
|
|
if not pending:
|
|
print("Finish another match before adding an in-between promo.")
|
|
return
|
|
index = pending[0]
|
|
match = card["matches"][index]
|
|
if len(match["sides"]) < 2:
|
|
print("This match does not have two sides for a face-off promo.")
|
|
return
|
|
first = random.choice(match["sides"][0])
|
|
second = random.choice(match["sides"][1])
|
|
match["promo"] = (f"{first}: {promo_line_for(first, second, 'openers')}\n"
|
|
f"{second}: {promo_line_for(second, first, 'challenges')}\n"
|
|
f"{first}: {promo_line_for(first, second, 'retorts')}")
|
|
save_card(card)
|
|
print(f"Promo added before Match {index + 1} and saved.")
|
|
|
|
|
|
def resume_card(card: dict[str, Any]) -> bool:
|
|
if card.get("season_id"):
|
|
sync_season_card_results(card)
|
|
while True:
|
|
print(f"\n{show_location_text(card)} · {card.get('federation_name', federation_name(card['game_id']))}"
|
|
f" · {card.get('game_title', game_title(card['game_id']))} · {card['date']}")
|
|
if card.get("ppv_rating") is not None:
|
|
print(f"PPV Rating: {float(card['ppv_rating']):.1f}%")
|
|
print(f"Card: {card['path']}")
|
|
choice = ask_choice(
|
|
"Card action",
|
|
["view", "result", "edit", "promo", "image", "simulate all", "exit"],
|
|
labels=["View card", "Record a match result", "Edit an unfinished match",
|
|
"Generate a promo before the next match", "Attach/edit a match image",
|
|
"Auto-simulate all remaining matches", season_card_exit_label(card)],
|
|
)
|
|
if choice == 0:
|
|
view_card(card)
|
|
elif choice == 1:
|
|
record_result(card)
|
|
elif choice == 2:
|
|
edit_match(card)
|
|
elif choice == 3:
|
|
generate_promo(card)
|
|
elif choice == 4:
|
|
attach_match_image(card)
|
|
elif choice == 5:
|
|
simulate_pending_matches(card)
|
|
else:
|
|
return bool(card.get("season_id"))
|
|
|
|
|
|
def main() -> int:
|
|
refresh_custom_games()
|
|
print(color("WWF SmackDown! Match Card Maker", CYAN))
|
|
while True:
|
|
try:
|
|
choice = ask_choice(
|
|
"Main menu", ["new", "random", "season", "load", "byog", "exit"],
|
|
labels=["Create a new three-match card", "Create a fully randomized show",
|
|
"Season Mode", "Load / resume a saved card", "BYOG / Manage Games",
|
|
"Exit"],
|
|
)
|
|
if choice == 0:
|
|
resume_card(create_card())
|
|
elif choice == 1:
|
|
resume_card(create_random_card())
|
|
elif choice == 2:
|
|
season_mode()
|
|
elif choice == 3:
|
|
path = select_card_path()
|
|
if path is None:
|
|
continue
|
|
try:
|
|
card = load_card(path)
|
|
go_to_season = resume_card(card)
|
|
if go_to_season:
|
|
run_season(load_season(str(card["season_id"])))
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
print(f"Could not load card: {exc}")
|
|
elif choice == 4:
|
|
manage_games()
|
|
else:
|
|
return 0
|
|
except (KeyboardInterrupt, EOFError):
|
|
print("\nGoodbye.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|