From 82e77d2e56cd8cd39068df585709757c99657c94 Mon Sep 17 00:00:00 2001 From: markmental Date: Mon, 2 Nov 2026 16:34:59 -0500 Subject: [PATCH] =?UTF-8?q?Start=20code=20refactor,=20rename=20to=20LVPWB?= =?UTF-8?q?=20=E2=80=94=20Light=20Virtual=20Pro-Wrestling=20Booker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + README.md | 4 +- lvpwb/__init__.py | 30 + lvpwb/__main__.py | 3 + lvpwb/card_flows.py | 398 +++ lvpwb/cardfile.py | 191 + lvpwb/cli.py | 43 + lvpwb/constants.py | 184 + lvpwb/edit_mode.py | 323 ++ lvpwb/games.py | 338 ++ lvpwb/paths.py | 22 + lvpwb/promos.py | 206 ++ lvpwb/prompts.py | 54 + lvpwb/ratings.py | 63 + lvpwb/render.py | 469 +++ lvpwb/results.py | 154 + lvpwb/screenshots.py | 104 + lvpwb/season_booking.py | 305 ++ lvpwb/seasons.py | 334 ++ smackdown_card_maker.py | 3059 ----------------- test_smackdown_card_maker.py => test_lvpwb.py | 2 +- 21 files changed, 3228 insertions(+), 3062 deletions(-) create mode 100644 lvpwb/__init__.py create mode 100644 lvpwb/__main__.py create mode 100644 lvpwb/card_flows.py create mode 100644 lvpwb/cardfile.py create mode 100644 lvpwb/cli.py create mode 100644 lvpwb/constants.py create mode 100644 lvpwb/edit_mode.py create mode 100644 lvpwb/games.py create mode 100644 lvpwb/paths.py create mode 100644 lvpwb/promos.py create mode 100644 lvpwb/prompts.py create mode 100644 lvpwb/ratings.py create mode 100644 lvpwb/render.py create mode 100644 lvpwb/results.py create mode 100644 lvpwb/screenshots.py create mode 100644 lvpwb/season_booking.py create mode 100644 lvpwb/seasons.py delete mode 100644 smackdown_card_maker.py rename test_smackdown_card_maker.py => test_lvpwb.py (99%) diff --git a/.gitignore b/.gitignore index 1f3e29b..2df7299 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ cards/ seasons/ edit/ + +# Python bytecode +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 5534858..65a9d26 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# SmackDown! Match Card Maker +# LVPWB — Light Virtual Pro-Wrestling Booker An interactive Python 3 terminal app for creating and running three-match cards using the uniform JSON roster files in this directory. ```sh -python3 smackdown_card_maker.py +python3 -m lvpwb ``` Cards are saved as Markdown in `cards/`. Load a card to view it, record results, diff --git a/lvpwb/__init__.py b/lvpwb/__init__.py new file mode 100644 index 0000000..da21e6e --- /dev/null +++ b/lvpwb/__init__.py @@ -0,0 +1,30 @@ +"""Light Virtual Pro-Wrestling Booker (LVPWB). + +The package re-exports the application's public functions and shared settings +for convenient use from tests and interactive Python sessions. +""" + +from . import card_flows, cardfile, cli, constants, edit_mode, games, paths, promos +from . import ratings, render, results, screenshots, season_booking, seasons +from .paths import * +from .constants import * +from .prompts import * +from .games import * +from .edit_mode import * +from .cardfile import * +from .ratings import * +from .results import * +from .screenshots import * +from .card_flows import * +from .render import * +from .seasons import * +from .season_booking import * +from .promos import * +from .cli import * + +# Retain convenient patch points for shared modules used by the application. +import random +import shutil + +# Private rendering helper is intentionally patchable by the test suite. +from .render import _render_image diff --git a/lvpwb/__main__.py b/lvpwb/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/lvpwb/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/lvpwb/card_flows.py b/lvpwb/card_flows.py new file mode 100644 index 0000000..3c7101a --- /dev/null +++ b/lvpwb/card_flows.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import re +from datetime import date +from pathlib import Path +from typing import Any + +import lvpwb as app + +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 = app.CUSTOM_GAME_CONFIGS.get(game_id, {}) + shows = config.get("shows") if isinstance(config, dict) else None + return {brand: app.show_name_for_brand(brand, shows if isinstance(shows, dict) else None) + for brand in app.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 = app.game_show_names(game_id).get("PPV", "") + if ppv_name and folded == ppv_name.casefold(): + return True + return False + + +def random_attendance(city: str) -> int: + """Pick an attendance within the city's typical venue range.""" + low, high = app.CITY_ATTENDANCE_RANGES.get(city, app.DEFAULT_ATTENDANCE_RANGE) + return app.random.randint(low, high) + + +def random_location() -> tuple[str, int]: + city = app.random.choice(sorted(app.CITY_ATTENDANCE_RANGES)) + return city, app.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 = app.ask("City (r randomizes city and attendance)").strip() + if city.casefold() == "r": + city, attendance = app.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 = app.ask(f"Attendance in {city} (r to randomize)").strip() + if raw.casefold() == "r": + attendance = app.random_attendance(city) + print(f"Random attendance: {attendance:,} fans") + return city, attendance + attendance = app.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 choose_game() -> str: + app.refresh_custom_games() + ids = list(app.GAMES) + labels = [app.game_display_name(game_id) for game_id in ids] + return ids[app.ask_choice("Choose a game", ids, labels=labels)] + + +def choose_match_type(game_id: str) -> tuple[str, tuple[int, ...]]: + options = app.match_type_definitions(game_id) + labels = [f"{item['name']} ({' vs '.join(map(str, item['sides']))})" + for item in options] + selected = app.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 = app.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 = app.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 = app.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 = app.choose_match_type(game_id) + title = app.ask("Optional match title (leave blank for none)") + sides = app.choose_sides(side_sizes, roster) + definition = app.match_type_definition(game_id, match_name) + image = "" + if app.game_has_screenshots(game_id) and app.ask("Attach a DuckStation screenshot? (y/N)").lower() == "y": + chosen = app.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(app.CARD_DIR.glob("*.md")) if app.CARD_DIR.is_dir() else [] + if candidates: + print("\nSaved cards:") + for index, path in enumerate(candidates, 1): + print(f" {index}. {app.card_summary(path)} [{path.name}]") + print(" Enter a number to load a listed card, or type a path.") + raw = app.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 = app.ROOT / path + return path + + +def create_card() -> dict[str, Any]: + game_id = app.choose_game() + roster = app.load_roster(game_id) + show = app.ask("Show name", app.game_show_names(game_id)["SmackDown"]) + while True: + show_date = app.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 = app.ask_location() + card: dict[str, Any] = { + "game_id": game_id, "game_title": app.game_title(game_id), + "federation_name": app.federation_name(game_id), + "title_definitions": app.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(app.default_card_path(show, show_date)), + } + app.save_card(card) + print(f"\nCard started: {card['path']}") + for number in range(1, 4): + card["matches"][number - 1] = app.create_match(number, game_id, roster) + app.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 = app.random.choice(app.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 = app.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": app.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 = app.choose_game() + roster = app.load_roster(game_id) + show = app.random.choice(list(app.game_show_names(game_id).values())) + city, attendance = app.random_location() + show_date = date.today().isoformat() + used: set[str] = set() + card: dict[str, Any] = { + "game_id": game_id, "game_title": app.game_title(game_id), + "federation_name": app.federation_name(game_id), + "title_definitions": app.title_definitions_for_game(game_id), + "show": show, "date": show_date, + "city": city, "attendance": attendance, + "matches": [app.random_match(game_id, roster, used) for _ in range(3)], + "path": str(app.default_card_path(show, show_date)), + } + app.save_card(card) + print(f"\nRandom card saved to {card['path']}") + return card + + +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 = app.ask_choice(prompt, indices, labels=labels) + return indices[selected] + + +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 app.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[app.ask_choice("Assign ranking division", options, + default=default, labels=labels)] + + +def edit_match(card: dict[str, Any]) -> None: + editable = app.editable_indices(card) + if not editable: + print("There are no unfinished matches to edit.") + return + index = app.choose_match_index(card, editable, "Choose a match to edit") + match = card["matches"][index] + roster = app.load_roster(card["game_id"]) + options = app.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 = app.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 app.ask("Change participants too? (y/N)").lower() == "y": + match["sides"] = app.choose_sides(side_sizes, roster) + profile = app.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"] = app.ask(f"Match title (blank removes; current: {current_title or 'none'})") + if card.get("season_id"): + match["ranking_division"] = app.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"] = app.ask_yes_no( + "Is this a title defense?", default=bool(match.get("title_defense")) + ) + else: + match["title_defense"] = False + app.save_card(card) + print(f"Match updated and saved to {card['path']}.") + + +def resume_card(card: dict[str, Any]) -> bool: + if card.get("season_id"): + app.sync_season_card_results(card) + while True: + print(f"\n{app.show_location_text(card)} · {card.get('federation_name', app.federation_name(card['game_id']))}" + f" · {card.get('game_title', app.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 = app.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", app.season_card_exit_label(card)], + ) + if choice == 0: + app.view_card(card) + elif choice == 1: + app.record_result(card) + elif choice == 2: + app.edit_match(card) + elif choice == 3: + app.generate_promo(card) + elif choice == 4: + app.attach_match_image(card) + elif choice == 5: + app.simulate_pending_matches(card) + else: + return bool(card.get("season_id")) + +__all__ = ['ask_location', 'choose_game', 'choose_match_index', 'choose_match_type', 'choose_ranking_division', 'choose_sides', 'create_card', 'create_match', 'create_random_card', 'edit_match', 'editable_indices', 'game_show_names', 'is_ppv_card', 'parse_attendance', 'pending_indices', 'random_attendance', 'random_location', 'random_match', 'resume_card', 'season_card_exit_label', 'select_card_path', 'show_location_text', 'show_name_for_brand'] diff --git a/lvpwb/cardfile.py b/lvpwb/cardfile.py new file mode 100644 index 0000000..06eabb3 --- /dev/null +++ b/lvpwb/cardfile.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import json +import math +import re +from pathlib import Path +from typing import Any + +import lvpwb as app + +def default_card_path(show: str, show_date: str) -> Path: + app.CARD_DIR.mkdir(parents=True, exist_ok=True) + stem = f"{show_date}-{app.slugify(show)}" + path = app.CARD_DIR / f"{stem}.md" + suffix = 2 + while path.exists(): + path = app.CARD_DIR / f"{stem}-{suffix}.md" + suffix += 1 + return path + + +def save_card(card: dict[str, Any]) -> None: + path = Path(card["path"]) + path.parent.mkdir(parents=True, exist_ok=True) + card.setdefault("game_title", app.game_title(card["game_id"])) + card.setdefault("federation_name", app.federation_name(card["game_id"])) + card.setdefault("title_definitions", app.title_definitions_for_game(card["game_id"])) + ppv_rating = app.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 app.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 app.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 app.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", app.game_title(card["game_id"])) + card.setdefault("federation_name", app.federation_name(card["game_id"])) + card.setdefault("title_definitions", app.title_definitions_for_game(card["game_id"])) + card["ppv_rating"] = app.calculate_ppv_rating(card) + return card + + +def card_summary(path: Path) -> str: + """One-line listing for a saved card; falls back to the file stem.""" + try: + return app.show_location_text(app.load_card(path)) + except (OSError, ValueError, json.JSONDecodeError): + return path.stem + +__all__ = ['card_summary', 'default_card_path', 'load_card', 'save_card'] diff --git a/lvpwb/cli.py b/lvpwb/cli.py new file mode 100644 index 0000000..01b0a5c --- /dev/null +++ b/lvpwb/cli.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import json + +import lvpwb as app + +def main() -> int: + app.refresh_custom_games() + print(app.color("LVPWB — Light Virtual Pro-Wrestling Booker", app.CYAN)) + while True: + try: + choice = app.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: + app.resume_card(app.create_card()) + elif choice == 1: + app.resume_card(app.create_random_card()) + elif choice == 2: + app.season_mode() + elif choice == 3: + path = app.select_card_path() + if path is None: + continue + try: + card = app.load_card(path) + go_to_season = app.resume_card(card) + if go_to_season: + app.run_season(app.load_season(str(card["season_id"]))) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"Could not load card: {exc}") + elif choice == 4: + app.manage_games() + else: + return 0 + except (KeyboardInterrupt, EOFError): + print("\nGoodbye.") + return 0 + +__all__ = ['main'] diff --git a/lvpwb/constants.py b/lvpwb/constants.py new file mode 100644 index 0000000..29ea2d0 --- /dev/null +++ b/lvpwb/constants.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from typing import Any + +import lvpwb as app + +PS1_GAMES = {"sd1", "sd2"} + +SEASON_CYCLE = ("Raw", "SmackDown", "PPV") + +SHOW_BRANDS = SEASON_CYCLE + +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"}, +} + +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]] = {} + +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" + +__all__ = ['BUILTIN_GAME_IDS', 'CHAMPIONSHIPS', 'CITY_ATTENDANCE_RANGES', 'CUSTOM_GAME_CONFIGS', 'CYAN', 'DEFAULT_ATTENDANCE_RANGE', 'DEFAULT_CUSTOM_SHOWS', 'DIM', 'FINISHER_BONUS', 'GAMES', 'MATCH_ACTION_BONUS_CAP', 'MATCH_BASE_RATINGS', 'MATCH_OUTCOME_OPTIONS', 'MATCH_TIME_BONUS_CAP', 'MATCH_TIME_BONUS_PER_MINUTE', 'MATCH_TYPES', 'NEAR_FALL_BONUS', 'PPV_MATCH_WEIGHTS', 'PPV_RIVALRY_WEIGHT', 'PPV_TITLE_WEIGHT', 'PS1_GAMES', 'RED', 'RESET', 'SEASON_CYCLE', 'SHOW_BRANDS', 'STANDARD_MATCH_OUTCOMES', 'TITLE_MATCH_MULTIPLIER', 'WOMEN_ROSTERS', 'YELLOW'] diff --git a/lvpwb/edit_mode.py b/lvpwb/edit_mode.py new file mode 100644 index 0000000..88a1678 --- /dev/null +++ b/lvpwb/edit_mode.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import json +import math +from typing import Any + +import lvpwb as app + +def show_roster(config: dict[str, Any]) -> None: + names = sorted(config["roster"], key=str.casefold) + if not names: + print("The roster is empty.") + return + print(f"\nRoster ({len(names)} wrestlers):") + for position, name in enumerate(names, 1): + print(f" {position}. {name}") + + +def edit_custom_roster(config: dict[str, Any]) -> None: + while True: + choices = ["Add wrestler", "Remove wrestler", "View roster", "Done"] + labels = ["Add a wrestler", "Remove a wrestler", "View roster (read-only)", + f"Done ({len(config['roster'])} listed)"] + action = app.ask_choice("Edit roster", choices, labels=labels) + if action == 3: + break + if action == 0: + name = app.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 action == 2: + app.show_roster(config) + elif not config["roster"]: + print("The roster is empty.") + else: + index = app.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 = app.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 = app.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 = app.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 = app.parse_side_sizes(app.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 = app.MATCH_BASE_RATINGS.get(name, 4.5) + while True: + try: + base_rating = float(app.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 = app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES) + raw_outcomes = app.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 = app.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 = app.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 = app.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[app.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 = app.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 = app.choose_roster_member(available, "Add which wrestler") + eligible.append(selected) + elif not eligible: + print("No eligible wrestlers are listed.") + else: + remove_index = app.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 app.SHOW_BRANDS] + ["Done"] + action = app.ask_choice("Edit show names", list(app.SHOW_BRANDS) + ["done"], labels=labels) + if action == len(app.SHOW_BRANDS): + return + brand = app.SHOW_BRANDS[action] + while True: + name = app.ask(f"{brand} show name", shows[brand]).strip() + if not name: + print("Show name cannot be blank.") + continue + others = [shows[other] for other in app.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 = app.ask_choice("BYOG editor", options, labels=labels) + if action == 0: + config["game_title"] = app.ask("Game title", config["game_title"]).strip() + config["federation_name"] = app.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[ + app.ask_choice("Platform", platform_options, labels=platform_labels) + ] + elif action == 1: + app.edit_custom_shows(config) + elif action == 2: + app.edit_custom_roster(config) + elif action == 3: + app.edit_custom_match_types(config) + elif action == 4: + app.edit_custom_titles(config) + elif action == 5: + app.edit_women_roster(config) + else: + try: + return app.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": app.MATCH_BASE_RATINGS.get(name, 4.5), + "outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES))} + for name, sides in app.MATCH_TYPES[game_id] + ] + return app.validate_custom_game({ + "schema_version": 1, + "game_id": custom_id, + "game_title": app.game_title(game_id) + " Custom Edition", + "federation_name": app.federation_name(game_id), + "platform": "ps1" if game_id in app.PS1_GAMES else "ps2", + "roster": app.load_roster(game_id), + "match_types": definitions, + "championships": app.title_definitions_for_game(game_id), + "women_roster": sorted(app.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 = app.ask("Game title").strip() + while not title: + title = app.ask("Game title cannot be blank; enter a game title").strip() + federation = app.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[app.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 app.SHOW_BRANDS: + default = app.DEFAULT_CUSTOM_SHOWS[brand] + while True: + name = app.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": app.new_custom_game_id(federation, title), + "game_title": title, + "federation_name": federation, + "platform": platform, + "roster": [], + "match_types": [], + "championships": [], + "women_roster": [], + "shows": shows, + } + app.edit_custom_roster(config) + config["match_types"] = app.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(app.STANDARD_MATCH_OUTCOMES)}] + print("\nAdd this federation's championship divisions (or finish with none).") + app.edit_custom_titles(config) + if any(title["kind"] == "women" for title in config["championships"]): + app.edit_women_roster(config) + return app.edit_custom_profile(config) + + +def choose_builtin_game() -> str: + ids = list(app.BUILTIN_GAME_IDS) + return ids[app.ask_choice("Built-in game to copy", ids, + labels=[app.GAMES[game_id]["title"] for game_id in ids])] + + +def clone_builtin_game() -> dict[str, Any]: + app.refresh_custom_games() + source_id = app.choose_builtin_game() + title = app.ask("Custom game title", app.game_title(source_id) + " Custom Edition").strip() + federation = app.ask("Federation name", app.federation_name(source_id)).strip() + config = app.builtin_profile_for_copy(source_id, app.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 {app.game_title(source_id)} into the editable BYOG directory.") + return app.edit_custom_profile(config) + + +def edit_existing_custom_game() -> None: + app.refresh_custom_games() + game_ids = sorted(app.CUSTOM_GAME_CONFIGS) + if not game_ids: + print("No custom games exist yet. Create or copy one first.") + return + labels = [app.game_display_name(game_id) for game_id in game_ids] + game_id = game_ids[app.ask_choice("Choose custom game to edit", game_ids, labels=labels)] + editable = json.loads(json.dumps(app.CUSTOM_GAME_CONFIGS[game_id])) + app.edit_custom_profile(editable) + app.refresh_custom_games() + + +def manage_games() -> None: + while True: + app.refresh_custom_games() + action = app.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(app.CUSTOM_GAME_CONFIGS)} available)", + "Back to main menu"], + ) + if action == 0: + app.create_custom_game() + elif action == 1: + app.clone_builtin_game() + elif action == 2: + app.edit_existing_custom_game() + else: + return + +__all__ = ['builtin_profile_for_copy', 'choose_builtin_game', 'clone_builtin_game', 'create_custom_game', 'edit_custom_match_types', 'edit_custom_profile', 'edit_custom_roster', 'edit_custom_shows', 'edit_custom_titles', 'edit_existing_custom_game', 'edit_women_roster', 'manage_games', 'show_roster'] diff --git a/lvpwb/games.py b/lvpwb/games.py new file mode 100644 index 0000000..6ab9116 --- /dev/null +++ b/lvpwb/games.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import json +import math +import os +import re +from pathlib import Path +from typing import Any + +import lvpwb as app + +def load_roster(game_id: str) -> list[str]: + """Load and validate a game's roster from the shared JSON schema.""" + if game_id in app.CUSTOM_GAME_CONFIGS: + return list(app.CUSTOM_GAME_CONFIGS[game_id]["roster"]) + path = app.ROOT / app.GAMES[game_id]["roster"] + if not path.is_file(): + raise FileNotFoundError(f"Roster file not found: {path}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON roster file: {path}") from exc + if not isinstance(payload, dict) or payload.get("game_id") != game_id: + raise ValueError(f"Roster game_id does not match {game_id}: {path}") + names = payload.get("wrestlers") + if not isinstance(names, list) or any(not isinstance(name, str) or not name.strip() for name in names): + raise ValueError(f"Roster must contain a list of non-empty wrestler names: {path}") + if len(names) != len(set(names)): + raise ValueError(f"Roster contains duplicate wrestler names: {path}") + if payload.get("game") != app.GAMES[game_id]["title"]: + raise ValueError(f"Roster game title does not match {game_id}: {path}") + return names + + +def game_title(game_id: str) -> str: + return app.GAMES[game_id]["title"] + + +def federation_name(game_id: str) -> str: + if game_id in app.CUSTOM_GAME_CONFIGS: + return app.CUSTOM_GAME_CONFIGS[game_id]["federation_name"] + return "World Wrestling Federation" + + +def game_display_name(game_id: str) -> str: + if game_id in app.CUSTOM_GAME_CONFIGS: + config = app.CUSTOM_GAME_CONFIGS[game_id] + return f"{config['federation_name']} — {config['game_title']}" + return app.GAMES[game_id]["title"] + + +def is_wwf_federation(name: str | None) -> bool: + folded = str(name or "").strip().casefold() + return folded in ("world wrestling federation", "wwf") + + +def title_definitions_for_game(game_id: str, + profile: dict[str, Any] | None = None) -> list[dict[str, str]]: + if profile and isinstance(profile.get("championships"), list): + return [dict(title) for title in profile["championships"]] + if game_id in app.CUSTOM_GAME_CONFIGS: + return [dict(title) for title in app.CUSTOM_GAME_CONFIGS[game_id]["championships"]] + return [ + {"name": name, "kind": "tag" if name == "Tag Team" else "women" if name == "Women's" else "singles"} + for name in app.CHAMPIONSHIPS + ] + + +def championship_names_for_game(game_id: str, + profile: dict[str, Any] | None = None) -> list[str]: + return [title["name"] for title in app.title_definitions_for_game(game_id, profile)] + + +def championship_kind(game_id: str, title_name: str, + profile: dict[str, Any] | None = None) -> str: + for title in app.title_definitions_for_game(game_id, profile): + if title["name"] == title_name: + return title.get("kind", "singles") + return "singles" + + +def shows_snapshot(config: dict[str, Any] | None) -> dict[str, str]: + """Resolved show names recorded with a season at creation time.""" + shows = (config or {}).get("shows") + return {brand: app.show_name_for_brand( + brand, shows if isinstance(shows, dict) else None) for brand in app.SHOW_BRANDS} + + +def season_title_names(season: dict[str, Any]) -> list[str]: + definitions = season.get("title_definitions") + if isinstance(definitions, list): + return [item["name"] for item in definitions] + return list(season.get("champions", {})) or app.championship_names_for_game(season["game_id"]) + + +def season_championship_kind(season: dict[str, Any], title_name: str) -> str: + for definition in season.get("title_definitions", []): + if definition.get("name") == title_name: + return definition.get("kind", "singles") + return app.championship_kind(season["game_id"], title_name) + + +def game_profile(game_id: str) -> dict[str, Any]: + if game_id in app.CUSTOM_GAME_CONFIGS: + return json.loads(json.dumps(app.CUSTOM_GAME_CONFIGS[game_id])) + return app.builtin_game_profile(game_id) + + +def season_game_profile(season: dict[str, Any]) -> dict[str, Any]: + profile = season.get("game_config") + return profile if isinstance(profile, dict) else app.game_profile(season["game_id"]) + + +def match_type_definitions(game_id: str, profile: dict[str, Any] | None = None) -> list[dict[str, Any]]: + if profile and isinstance(profile.get("match_types"), list): + return [dict(item) for item in profile["match_types"]] + if game_id in app.CUSTOM_GAME_CONFIGS: + return [dict(item) for item in app.CUSTOM_GAME_CONFIGS[game_id]["match_types"]] + return [ + { + "name": name, + "sides": list(sides), + "base_rating": app.MATCH_BASE_RATINGS.get(name, 4.5), + "outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES)), + } + for name, sides in app.MATCH_TYPES[game_id] + ] + + +def match_type_definition(game_id: str, match_type: str, + profile: dict[str, Any] | None = None) -> dict[str, Any]: + for definition in app.match_type_definitions(game_id, profile): + if definition["name"] == match_type: + return definition + return {"name": match_type, "sides": [1, 1], + "base_rating": app.MATCH_BASE_RATINGS.get(match_type, 4.5), + "outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(match_type, app.STANDARD_MATCH_OUTCOMES))} + + +def refresh_custom_games(directory: Path | None = None) -> list[str]: + """Load user-managed game definitions without changing built-in profiles.""" + for game_id in list(app.CUSTOM_GAME_CONFIGS): + app.GAMES.pop(game_id, None) + app.MATCH_TYPES.pop(game_id, None) + app.CUSTOM_GAME_CONFIGS.clear() + directory = directory or app.CUSTOM_GAME_DIR + if not directory.is_dir(): + return [] + loaded: list[str] = [] + for path in sorted(directory.glob("*.json")): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + config = app.validate_custom_game(raw) + game_id = config["game_id"] + if game_id in app.BUILTIN_GAME_IDS or game_id in app.CUSTOM_GAME_CONFIGS: + raise ValueError(f"Game ID is already in use: {game_id}") + app.CUSTOM_GAME_CONFIGS[game_id] = config + app.GAMES[game_id] = { + "title": config["game_title"], + "roster": str(path), + "federation": config["federation_name"], + "platform": config["platform"], + } + app.MATCH_TYPES[game_id] = [ + (entry["name"], tuple(entry["sides"])) for entry in config["match_types"] + ] + loaded.append(game_id) + except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: + print(f"Skipping custom game {path.name}: {exc}") + return loaded + + +def validate_custom_game(data: Any) -> dict[str, Any]: + if not isinstance(data, dict): + raise ValueError("Game definition must be a JSON object.") + game_id = data.get("game_id") + if not isinstance(game_id, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", game_id): + raise ValueError("game_id must use lowercase letters, digits, and hyphens.") + if game_id in app.BUILTIN_GAME_IDS: + raise ValueError("Custom definitions cannot overwrite built-in games.") + game_title_value = data.get("game_title") + federation = data.get("federation_name") + if not isinstance(game_title_value, str) or not game_title_value.strip(): + raise ValueError("game_title is required.") + if not isinstance(federation, str) or not federation.strip(): + raise ValueError("federation_name is required.") + roster = data.get("roster") + if (not isinstance(roster, list) or len(roster) < 2 + or any(not isinstance(name, str) or not name.strip() for name in roster)): + raise ValueError("roster must contain at least two non-empty wrestler names.") + if len({name.casefold() for name in roster}) != len(roster): + raise ValueError("roster names must be unique, ignoring capitalization.") + platform = str(data.get("platform", "other")).casefold() + if platform not in {"ps1", "ps2", "other"}: + raise ValueError("platform must be ps1, ps2, or other.") + raw_match_types = data.get("match_types") + if not isinstance(raw_match_types, list) or not raw_match_types: + raise ValueError("match_types must contain at least one match type.") + match_types: list[dict[str, Any]] = [] + seen_match_types: set[str] = set() + for item in raw_match_types: + if not isinstance(item, dict): + raise ValueError("Each match type must be a JSON object.") + name = item.get("name") + sides = item.get("sides") + if not isinstance(name, str) or not name.strip() or name.casefold() in seen_match_types: + raise ValueError("Match type names must be non-empty and unique.") + if (not isinstance(sides, list) or len(sides) < 2 + or any(not isinstance(count, int) or isinstance(count, bool) or count < 1 + for count in sides) + or sum(sides) > len(roster)): + raise ValueError(f"Match type '{name}' has invalid side sizes.") + outcomes = item.get("outcomes", list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES))) + if not isinstance(outcomes, list) or not outcomes or any( + not isinstance(outcome, str) or not outcome.strip() for outcome in outcomes + ): + raise ValueError(f"Match type '{name}' must have at least one valid outcome.") + base_rating = item.get("base_rating", app.MATCH_BASE_RATINGS.get(name, 4.5)) + if (not isinstance(base_rating, (int, float)) or isinstance(base_rating, bool) + or not math.isfinite(base_rating) or not 0 <= base_rating <= 100): + raise ValueError(f"Match type '{name}' has an invalid base_rating.") + match_types.append({"name": name.strip(), "sides": list(sides), + "outcomes": [outcome.strip() for outcome in outcomes], + "base_rating": float(base_rating)}) + seen_match_types.add(name.casefold()) + raw_titles = data.get("championships", []) + if not isinstance(raw_titles, list): + raise ValueError("championships must be a list.") + championships: list[dict[str, str]] = [] + seen_titles: set[str] = set() + for item in raw_titles: + if not isinstance(item, dict): + raise ValueError("Each championship must be a JSON object with name and kind.") + name, 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 app.SHOW_BRANDS: + name = (raw_shows or {}).get(brand, app.DEFAULT_CUSTOM_SHOWS[brand]) + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"Show name for '{brand}' must be a non-empty string.") + name = name.strip() + if name.casefold() in seen_show_names: + raise ValueError("Show names must be unique, ignoring capitalization.") + seen_show_names.add(name.casefold()) + shows[brand] = name + return { + "schema_version": 1, + "game_id": game_id, + "game_title": game_title_value.strip(), + "federation_name": federation.strip(), + "platform": platform, + "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 = app.validate_custom_game(data) + directory = directory or app.CUSTOM_GAME_DIR + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{config['game_id']}.json" + temporary = path.with_suffix(".json.tmp") + temporary.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8") + os.replace(temporary, path) + if directory.resolve() == app.CUSTOM_GAME_DIR.resolve(): + app.refresh_custom_games(directory) + return config + + +def builtin_game_profile(game_id: str) -> dict[str, Any]: + return { + "game_id": game_id, + "game_title": app.game_title(game_id), + "federation_name": app.federation_name(game_id), + "platform": "ps1" if game_id in app.PS1_GAMES else "ps2", + "roster": app.load_roster(game_id), + "match_types": app.match_type_definitions(game_id), + "championships": app.title_definitions_for_game(game_id), + "women_roster": sorted(app.WOMEN_ROSTERS.get(game_id, set())), + "shows": {"Raw": "Monday Night Raw", "SmackDown": "Thursday Night SmackDown!", + "PPV": "WWF PPV"}, + } + + +def new_custom_game_id(federation: str, game_title_value: str) -> str: + base = f"custom-{app.slugify(federation + '-' + game_title_value)}" + candidate = base + suffix = 2 + while candidate in app.GAMES or (app.CUSTOM_GAME_DIR / f"{candidate}.json").exists(): + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + +def parse_side_sizes(raw: str) -> list[int] | None: + try: + sizes = [int(part.strip()) for part in raw.split(",")] + except ValueError: + return None + if len(sizes) < 2 or any(size < 1 for size in sizes): + return None + return sizes + + +def default_custom_match_types(roster_size: int) -> list[dict[str, Any]]: + defaults = [ + ("Singles", [1, 1]), + ("Tag Team", [2, 2]), + ("Triple Threat", [1, 1, 1]), + ("Fatal 4-Way", [1, 1, 1, 1]), + ("Hardcore", [1, 1]), + ("Ladder", [1, 1]), + ("Cage", [1, 1]), + ] + return [ + {"name": name, "sides": sides, + "base_rating": app.MATCH_BASE_RATINGS.get(name, 4.5), + "outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES))} + for name, sides in defaults if sum(sides) <= roster_size + ] + +__all__ = ['builtin_game_profile', 'championship_kind', 'championship_names_for_game', 'default_custom_match_types', 'federation_name', 'game_display_name', 'game_profile', 'game_title', 'is_wwf_federation', 'load_roster', 'match_type_definition', 'match_type_definitions', 'new_custom_game_id', 'parse_side_sizes', 'refresh_custom_games', 'save_custom_game', 'season_championship_kind', 'season_game_profile', 'season_title_names', 'shows_snapshot', 'title_definitions_for_game', 'validate_custom_game'] diff --git a/lvpwb/paths.py b/lvpwb/paths.py new file mode 100644 index 0000000..ae48136 --- /dev/null +++ b/lvpwb/paths.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import lvpwb as app + +ROOT = Path(__file__).resolve().parent.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() + +__all__ = ['CARD_DIR', 'CUSTOM_GAME_DIR', 'DUCKSTATION_SCREENSHOT_DIR', 'EDIT_DIR', 'ROOT', 'SEASON_DIR', 'SESSION_START'] diff --git a/lvpwb/promos.py b/lvpwb/promos.py new file mode 100644 index 0000000..5741ba3 --- /dev/null +++ b/lvpwb/promos.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import re +from typing import Any + +import lvpwb as app + +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 = app.PROMO_PROFILE_ALIASES.get(app.normalized_wrestler_name(wrestler)) + if profile_key == "mankind": + profile = app.PROMO_PROFILES[profile_key] + parts = [ + app.random.choice(profile["openers"]).format(opponent=opponent), + app.random.choice(profile["sayings"]), + app.random.choice(profile["pain"]), + ] + return " ".join(parts) + if profile_key: + line = app.random.choice(app.PROMO_PROFILES[profile_key][moment]) + else: + generic = {"openers": app.PROMO_OPENERS, "challenges": app.PROMO_CHALLENGES, + "retorts": app.PROMO_RETORTS}[moment] + line = app.random.choice(generic) + return line.format(opponent=opponent, wrestler=wrestler) + + +def generate_promo(card: dict[str, Any]) -> None: + pending = app.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 = app.random.choice(match["sides"][0]) + second = app.random.choice(match["sides"][1]) + match["promo"] = (f"{first}: {app.promo_line_for(first, second, 'openers')}\n" + f"{second}: {app.promo_line_for(second, first, 'challenges')}\n" + f"{first}: {app.promo_line_for(first, second, 'retorts')}") + app.save_card(card) + print(f"Promo added before Match {index + 1} and saved.") + +__all__ = ['PROMO_CHALLENGES', 'PROMO_OPENERS', 'PROMO_PROFILES', 'PROMO_PROFILE_ALIASES', 'PROMO_RETORTS', 'generate_promo', 'normalized_wrestler_name', 'promo_line_for'] diff --git a/lvpwb/prompts.py b/lvpwb/prompts.py new file mode 100644 index 0000000..a34c034 --- /dev/null +++ b/lvpwb/prompts.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import re +from typing import Any + +import lvpwb as app + +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 = app.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 slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or "smackdown-show" + +__all__ = ['ask', 'ask_choice', 'ask_yes_no', 'slugify'] diff --git a/lvpwb/ratings.py b/lvpwb/ratings.py new file mode 100644 index 0000000..33d7f8c --- /dev/null +++ b/lvpwb/ratings.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import math +from typing import Any + +import lvpwb as app + +def base_match_rating(match_type: str) -> float: + return app.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))) * app.MATCH_TIME_BONUS_PER_MINUTE, + app.MATCH_TIME_BONUS_CAP) + raw_action = (time_bonus + + max(0, int(match.get("near_falls", 0))) * app.NEAR_FALL_BONUS + + max(0, int(match.get("finishers", 0))) * app.FINISHER_BONUS) + action_bonus = min(raw_action, app.MATCH_ACTION_BONUS_CAP) + base_value = match.get("base_rating") + if not isinstance(base_value, (int, float)) or isinstance(base_value, bool): + base_value = app.base_match_rating(match.get("type", "")) + earned = float(base_value) + action_bonus + if match.get("title_defense"): + earned *= app.TITLE_MATCH_MULTIPLIER + return round(min(100.0, earned), 1) + + +def calculate_ppv_rating(card: dict[str, Any]) -> float | None: + if not app.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, app.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 app.MATCH_OUTCOME_OPTIONS.get(match_type, app.STANDARD_MATCH_OUTCOMES) + +__all__ = ['allowed_match_outcomes', 'base_match_rating', 'calculate_match_rating', 'calculate_ppv_rating', 'parse_action_count', 'parse_match_minutes'] diff --git a/lvpwb/render.py b/lvpwb/render.py new file mode 100644 index 0000000..5d50da6 --- /dev/null +++ b/lvpwb/render.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +import os +import re +import select +import sys +import textwrap +from functools import lru_cache +from pathlib import Path +from typing import Any + +import lvpwb as app + +def color(text: str, code: str, enabled: bool = True) -> str: + return f"{code}{text}{app.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 = app.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 app.short(label, left_width if side_number == 0 else right_width) + label = app.color(label, app.RED) + else: + label = "LOSE" if flash else app.short(label, left_width if side_number == 0 else right_width) + label = app.color(label, app.YELLOW) + else: + label = app.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 - app.visible_length(left))} VS " + f"{right}{' ' * max(0, right_plain_width - app.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 = app.color(label, app.RED) + else: + label = "LOSE" if flash else label + label = app.color(label, app.YELLOW) + pieces.append(label) + separator = " VS " + required = sum(app.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 = [app.short_ansi(piece, name_budget) for piece in pieces] + joined = separator.join(pieces) + padding = max(0, inner - app.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 = app.short(status, inner) + if match.get("promo"): + if promo_expanded: + control = "▼ Promo (Enter to collapse)" + else: + preview = " / ".join(str(match["promo"]).splitlines()) + control = app.short(f"▶ Promo: {preview}", inner) + control_line = f"│ {control:<{inner}} │" + lines.append(app.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(app.ANSI_RE.sub("", text)) + + +def short_ansi(text: str, width: int) -> str: + plain = app.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(app.RED): + code = app.RED + elif text.startswith(app.YELLOW): + code = app.YELLOW + return app.color(app.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) + app.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 app.ROOT / "smackdown-logo-source.png" + if re.search(r"\braw\b", normalized): + return app.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 app.federation_name(card["game_id"])) + is_wwf = app.is_wwf_federation(card_federation) + if is_wwf: + left, left_width = app._render_image(app.ROOT / "wwfcomlogo.gif", 23, 6) + else: + left, left_width = [], 0 + logo_path = app.show_logo_path(card["show"]) + if logo_path: + center, center_width = app._render_image(logo_path, 38, 6) + if not center: + brand = "SMACKDOWN!" if logo_path.name == "smackdown-logo-source.png" else "RAW IS WAR" + center = [app.color(brand, app.CYAN)] + center_width = len(brand) + else: + center, center_width = [], 0 + if is_wwf and not left: + left = [app.color("WWF.com", app.RED)] + left_width = 7 + row_count = max(len(left), len(center), 2) + if width < 62: + title = card.get("game_title", app.game_title(card["game_id"])) + heading = f"{app.color('WWF.com', app.RED)} {app.color(title, app.CYAN)}" if is_wwf \ + else app.color(title, app.CYAN) + return [heading, + f"{card['date']} · {app.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) + app.color(date_text, app.DIM)) + output.append("".join(segments)) + output.append(app.color(app.short(app.show_location_text(card), width - 2), app.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 app.shutil.get_terminal_size((100, 28)).columns + expanded_promos = expanded_promos or set() + header = app.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 = app.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(app.color(f"Saved: {card['path']}", app.DIM)) + ppv_rating = app.calculate_ppv_rating(card) + if ppv_rating is not None: + body.append(app.color(f"WWF PPV Rating: {ppv_rating:.1f}%", app.CYAN)) + instructions = "↑/↓ select promo · Enter expand/collapse · PgUp/PgDn scroll · q close" + body.append(app.color(app.short(instructions, max(1, width - 2)), app.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, _ = app.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(app.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 = app.render_card_sections( + card, + flash=flash, + selected_promo=selected_promo, + expanded_promos=expanded_promos, + width=terminal_width, + ) + _, _, body_capacity = app.viewport_frame(header, body, terminal_height, scroll_offset) + if keep_selection_visible and selected_promo is not None: + scroll_offset = app.keep_promo_visible( + scroll_offset, promo_spans.get(selected_promo), body_capacity, len(body) + ) + frame, scroll_offset, body_capacity = app.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" + app.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 = app.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 = app.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 = app.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 = app.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, + ) + app.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" + app.render_card(card)) + input("\nPress Enter to continue...") + +__all__ = ['ANSI_RE', '_render_image', 'color', 'header_lines', 'interactive_card_frame', 'keep_promo_visible', 'match_lines', 'read_view_key', 'render_card', 'render_card_lines', 'render_card_sections', 'short', 'short_ansi', 'show_logo_path', 'view_card', 'viewport_frame', 'visible_length', 'write_changed_lines'] diff --git a/lvpwb/results.py b/lvpwb/results.py new file mode 100644 index 0000000..111e8b6 --- /dev/null +++ b/lvpwb/results.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +from typing import Any + +import lvpwb as app + +def choose_match_outcome(match_type: str | dict[str, Any]) -> str: + outcomes = app.allowed_match_outcomes(match_type) + if len(outcomes) == 1: + print(f"Match outcome: {outcomes[0]}") + return outcomes[0] + return outcomes[app.ask_choice("Winning method", list(outcomes))] + + +def prompt_match_minutes() -> float: + while True: + raw = app.ask("Match time in minutes") + value = app.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 = app.ask(label, "0") + value = app.parse_action_count(raw) + if value is not None: + return value + print("Enter a whole number zero or greater.") + + +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 app.season_title_names(season): + return [1.0] * len(sides) + entries = app.season_ranking_entries(season, division) + rank_positions = {app.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 = app.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 app.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 app.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 = app.season_simulation_weights(season, match) if season else [1.0] * len(sides) + match["winner_side"] = app.random.choices(range(len(sides)), weights=weights, k=1)[0] + match["finish"] = app.random.choice(app.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"] = app.random.randint(low, high) + match["near_falls"] = app.random.randint(0, 5) + match["finishers"] = app.random.randint(1, 3) + match["rating"] = app.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 = app.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 app.validate_title_defense(season, match): + print("Edit this match's participants or title status before recording its result.") + return False + + if auto_simulate: + app.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"] = app.ask_choice( + "Winning side", list(range(len(sides))), labels=side_labels + ) + match["finish"] = app.choose_match_outcome(match) + match["match_time_minutes"] = app.prompt_match_minutes() + match["near_falls"] = app.prompt_action_count("Near falls (two-count kickouts)") + match["finishers"] = app.prompt_action_count("Finishers executed") + match["rating"] = app.calculate_match_rating(match) + app.save_card(card) + if card.get("season_id"): + app.sync_season_card_results(card) + print(f"Result saved to {card['path']} · Match rating: {match['rating']:.1f}%.") + ppv_rating = app.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 = app.pending_indices(card) + if not pending: + print("All three matches are complete.") + return + index = match_index if match_index in pending else app.choose_match_index( + card, pending, "Choose a match to finish" + ) + if auto_simulate is None: + auto_simulate = app.ask_choice( + "Result mode", ["Enter result manually", "Auto-simulate result"] + ) == 1 + app.record_match_result(card, index, auto_simulate=auto_simulate) + + +def simulate_pending_matches(card: dict[str, Any]) -> None: + pending = app.pending_indices(card) + if not pending: + print("All three matches are complete.") + return + for index in pending: + if not app.record_match_result(card, index, auto_simulate=True): + break + +__all__ = ['choose_match_outcome', 'prompt_action_count', 'prompt_match_minutes', 'record_match_result', 'record_result', 'season_simulation_weights', 'simulate_match_result', 'simulate_pending_matches'] diff --git a/lvpwb/screenshots.py b/lvpwb/screenshots.py new file mode 100644 index 0000000..2a74282 --- /dev/null +++ b/lvpwb/screenshots.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path +from typing import Any + +import lvpwb as app + +SCREENSHOT_STAMP_RE = re.compile( + r"(?P\d{4})-(?P\d{2})-(?P\d{2})-(?P\d{2})-(?P\d{2})-(?P\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 = app.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 = app.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 app.SESSION_START + directory = directory or app.DUCKSTATION_SCREENSHOT_DIR + if not directory.is_dir(): + return [] + found: list[tuple[datetime, Path]] = [] + for path in directory.glob("*.png"): + try: + taken = app.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 app.DUCKSTATION_SCREENSHOT_DIR + candidates = app.session_screenshots(directory=directory) + if candidates: + print(f"\nScreenshots taken since the app opened ({directory}):") + for index, path in enumerate(candidates, 1): + taken = app.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 = app.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 app.game_has_screenshots(card["game_id"]): + print("Screenshot attachments are available for the PS1 games only.") + return + index = app.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 = app.choose_screenshot() + if chosen is None: + print("Keep or skip the image; card unchanged.") + return + match["image"] = str(chosen) + app.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 app.PS1_GAMES or ( + game_id in app.CUSTOM_GAME_CONFIGS and app.CUSTOM_GAME_CONFIGS[game_id]["platform"] == "ps1" + ) + +__all__ = ['SCREENSHOT_STAMP_RE', 'attach_match_image', 'choose_screenshot', 'game_has_screenshots', 'parse_screenshot_stamp', 'screenshot_taken_at', 'session_screenshots'] diff --git a/lvpwb/season_booking.py b/lvpwb/season_booking.py new file mode 100644 index 0000000..51f82b9 --- /dev/null +++ b/lvpwb/season_booking.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path +from typing import Any + +import lvpwb as app + +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 * app.PPV_TITLE_WEIGHT + if brand == "PPV" and kind == "rivalry": + return base * app.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 app.match_type_definitions(game_id, profile) + if tuple(item["sides"]) == sizes] + definition = app.random.choice(options) if options else app.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 app.game_profile(game_id) + candidates = app.eligible_roster_for_division(game_id, division, roster, profile) + is_tag = app.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 app.game_profile(game_id) + available = app.division_roster(game_id, division, roster, used, holder, profile) + is_tag = app.championship_kind(game_id, division, profile) == "tag" + needed = 4 if is_tag else 2 + if len(available) < needed: + return None + picks = app.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 = app.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": app.title_defense_weight(kind, brand), + "match": match}) + + # A division's top-ranked non-champion is the natural challenger. + for division in app.season_title_names(season): + championship = season["champions"][division] + holder = championship.get("holder") + entries = app.season_ranking_entries(season, division) + eligible_size = 2 if app.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 app.side_identity(entry["members"]) != app.side_identity(holder) + and not ({name.casefold() for name in entry["members"]} & used_folded)] + if challengers: + challenger = challengers[0]["members"] + rivalry_id = app.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 = app.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 = app.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 = app.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 app.season_championship_kind(season, division) == "tag" else 1 + if len(available) >= needed: + new_side = app.random.sample(available, needed) + sides = [ranked_sides[0], new_side] + if sides is None: + sides = app.seed_division_sides(game_id, division, roster, used, holder, profile) + if sides: + add("ranked", app.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", app.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 app.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 app.match_type_definitions(game_id, profile) + if tuple(item["sides"]) in ((1, 1), (2, 2), (1, 1, 1))] + sizes = tuple(app.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 = app.random.sample(choices, needed) + sides: list[list[str]] = [] + cursor = 0 + for size in sizes: + sides.append(picks[cursor:cursor + size]) + cursor += size + add("pool", app.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 = app.season_game_profile(season) + roster = list(profile.get("roster", app.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 = app.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 = app.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 = app.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 app.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 = app.load_card(Path(current_path)) + app.sync_season_card_results(card) + updated = app.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 = app.show_for_season_number(number) + show = app.show_name_for_brand(brand, season.get("shows")) + city, attendance = app.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": app.build_season_matches(season, brand), + "path": str(app.default_card_path(show, show_date)), + } + app.save_card(card) + season["current_card"] = card["path"] + app.save_season(season) + print(f"\nBooked Season Show {number}: {show} ({brand})") + return card + + +def season_files() -> list[Path]: + return sorted(app.SEASON_DIR.glob("*.json"), reverse=True) if app.SEASON_DIR.is_dir() else [] + + +def choose_or_create_season() -> dict[str, Any]: + paths = app.season_files() + labels = ["Start a new season"] + for path in paths: + try: + saved = app.load_season(path) + next_brand = app.season_show_brand(saved.get("next_show_number", 1)) + next_show = app.show_name_for_brand(next_brand, saved.get("shows")) + labels.append(f"{saved['name']} · {saved.get('federation_name', app.federation_name(saved['game_id']))}" + f" · {saved.get('game_title', app.game_title(saved['game_id']))} · next: {next_show}") + except (OSError, ValueError, json.JSONDecodeError): + labels.append(f"{path.stem} (invalid save)") + selected = app.ask_choice("Season Mode", ["new"] + [path.name for path in paths], labels=labels) + if selected == 0: + return app.create_season() + return app.load_season(paths[selected - 1]) + + +def run_season(season: dict[str, Any]) -> None: + while True: + number = int(season.get("next_show_number", 1)) + brand = app.season_show_brand(number) + current = str(season.get("current_card") or "") + if current and not Path(current).is_file(): + season["current_card"] = "" + app.save_season(season) + current = "" + print(f"\nSeason: {season['name']} · {season.get('federation_name', app.federation_name(season['game_id']))}" + f" · {season.get('game_title', app.game_title(season['game_id']))}") + next_name = app.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 = app.ask_choice( + "Season action", ["book/resume", "rankings", "back"], + labels=[next_action, "Power rankings / title holders", "Back to main menu"], + ) + if choice == 0: + card = app.load_card(Path(current)) if current else app.book_next_season_show(season) + app.sync_season_card_results(card) + app.resume_card(card) + # Refresh the season's progression after returning from a card. + season_id = str(season["season_id"]) + season.clear() + season.update(app.load_season(season_id)) + elif choice == 1: + app.print_power_rankings(season) + input("\nPress Enter to return...") + else: + return + + +def season_mode() -> None: + try: + app.run_season(app.choose_or_create_season()) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"Could not open or run that season: {exc}") + +__all__ = ['book_next_season_show', 'build_season_matches', 'choose_or_create_season', 'division_roster', 'make_season_match', 'run_season', 'season_booking_candidates', 'season_files', 'season_mode', 'seed_division_sides', 'show_for_season_number', 'title_defense_weight'] diff --git a/lvpwb/seasons.py b/lvpwb/seasons.py new file mode 100644 index 0000000..687a08f --- /dev/null +++ b/lvpwb/seasons.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any + +import lvpwb as app + +def season_path(season_id: str) -> Path: + return app.SEASON_DIR / f"{app.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 = app.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 = app.side_identity(holder) + title_state[division]["defense_history"][key] = { + "members": list(holder), "successful_defenses": 0, + } + if season_id is None: + season_id = f"{app.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": app.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: + app.SEASON_DIR.mkdir(parents=True, exist_ok=True) + path = app.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 app.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 app.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", app.game_profile(payload["game_id"])) + payload.setdefault("game_title", payload["game_config"].get("game_title", app.game_title(payload["game_id"]))) + payload.setdefault("federation_name", payload["game_config"].get( + "federation_name", app.federation_name(payload["game_id"]) + )) + payload.setdefault("title_definitions", payload["game_config"].get( + "championships", app.title_definitions_for_game(payload["game_id"]) + )) + payload.setdefault("shows", app.shows_snapshot(payload.get("game_config"))) + title_names = app.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 app.game_profile(game_id) + roster = list(profile.get("roster", [])) if roster is None else roster + if app.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[app.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 app.game_profile(game_id) + roster = app.eligible_roster_for_division(game_id, division, profile=profile) + is_tag_title = app.championship_kind(game_id, division, profile) == "tag" + print(f"\nInitial {division} champion:") + mode = app.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 = app.choose_roster_member(available, "First tag champion") + remaining = [name for name in available if name != first] or available + second = app.choose_roster_member(remaining, "Second tag champion") + return [first, second] + return app.random.sample(available, 2) + if choose_manually: + return [app.choose_roster_member(available, f"{division} champion")] + return [app.random.choice(available)] + + +def create_season() -> dict[str, Any]: + game_id = app.choose_game() + profile = app.game_profile(game_id) + name = app.ask("Season name", f"{app.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 = app.choose_initial_champion(division, game_id, used, profile) + champions[division] = holder + if holder: + used.update(name.casefold() for name in holder) + season = app.new_season_state(name, game_id, champions) + app.save_season(season) + print(f"Season saved: {app.season_path(season['season_id'])}") + return season + + +def season_show_brand(show_number: int) -> str: + return app.SEASON_CYCLE[(show_number - 1) % len(app.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, + app.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 = app.season_game_profile(season) + roster = roster or list(profile.get("roster", app.load_roster(season["game_id"]))) + involved: set[str] = set() + for division in app.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']} ({app.game_display_name(season['game_id'])})") + for division in app.season_title_names(season): + championship = season["champions"][division] + holder = championship.get("holder") + print(f"\n{division} Champion: {app.side_display(holder)}") + if holder: + holder_stats = championship.get("defense_history", {}).get(app.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" {app.side_display(defense_record.get('members', []))}: " + f"{defense_record.get('successful_defenses', 0)} successful defenses") + entries = app.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}. {app.side_display(entry.get('members', []))}" + f" — {wins}-{losses} ({percentage:.0%})") + pool = app.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((app.side_identity(sides[0]), app.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 app.season_title_names(season) and winner is not None: + table = season["rankings"].setdefault(division, {}) + for side_index, side in enumerate(match.get("sides", [])): + key = app.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 app.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 app.side_identity(holder) == app.side_identity(winner_side): + championship["successful_defenses"] = championship.get("successful_defenses", 0) + 1 + lifetime = defense_history.setdefault(app.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(app.side_identity(winner_side), { + "members": winner_side, "successful_defenses": 0, + }) + + rivalry_id = app.season_rivalry_id(match.get("sides", [])) + if rivalry_id: + ordered_sides = sorted((list(match["sides"][0]), list(match["sides"][1])), + key=app.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 = app.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 app.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(app.side_identity(side) == app.side_identity(holder) + for side in match.get("sides", [])): + print(f"The current {division} champion ({app.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 = app.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 app.validate_title_defense(season, match): + continue + app.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"]) + app.save_season(season) + +__all__ = ['choose_initial_champion', 'choose_roster_member', 'create_season', 'eligible_roster_for_division', 'load_season', 'new_season_state', 'print_power_rankings', 'record_season_match', 'save_season', 'season_other_pool', 'season_path', 'season_ranking_entries', 'season_rivalry_id', 'season_show_brand', 'side_display', 'side_identity', 'sync_season_card_results', 'validate_title_defense'] diff --git a/smackdown_card_maker.py b/smackdown_card_maker.py deleted file mode 100644 index e856736..0000000 --- a/smackdown_card_maker.py +++ /dev/null @@ -1,3059 +0,0 @@ -#!/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 show_roster(config: dict[str, Any]) -> None: - names = sorted(config["roster"], key=str.casefold) - if not names: - print("The roster is empty.") - return - print(f"\nRoster ({len(names)} wrestlers):") - for position, name in enumerate(names, 1): - print(f" {position}. {name}") - - -def edit_custom_roster(config: dict[str, Any]) -> None: - while True: - choices = ["Add wrestler", "Remove wrestler", "View roster", "Done"] - labels = ["Add a wrestler", "Remove a wrestler", "View roster (read-only)", - f"Done ({len(config['roster'])} listed)"] - action = ask_choice("Edit roster", choices, labels=labels) - if action == 3: - 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 action == 2: - show_roster(config) - 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\d{4})-(?P\d{2})-(?P\d{2})-(?P\d{2})-(?P\d{2})-(?P\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()) diff --git a/test_smackdown_card_maker.py b/test_lvpwb.py similarity index 99% rename from test_smackdown_card_maker.py rename to test_lvpwb.py index 0275105..a3e8cc7 100644 --- a/test_smackdown_card_maker.py +++ b/test_lvpwb.py @@ -8,7 +8,7 @@ from datetime import datetime from pathlib import Path from unittest.mock import patch -import smackdown_card_maker as app +import lvpwb as app class RosterTests(unittest.TestCase):