LVPWB/smackdown_card_maker.py

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())