Start code refactor, rename to LVPWB — Light Virtual Pro-Wrestling Booker

This commit is contained in:
markmental 2026-11-02 16:34:59 -05:00
commit 82e77d2e56
21 changed files with 3228 additions and 3062 deletions

4
.gitignore vendored
View file

@ -1,3 +1,7 @@
cards/
seasons/
edit/
# Python bytecode
__pycache__/
*.py[cod]

View file

@ -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,

30
lvpwb/__init__.py Normal file
View file

@ -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

3
lvpwb/__main__.py Normal file
View file

@ -0,0 +1,3 @@
from .cli import main
raise SystemExit(main())

398
lvpwb/card_flows.py Normal file
View file

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

191
lvpwb/cardfile.py Normal file
View file

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

43
lvpwb/cli.py Normal file
View file

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

184
lvpwb/constants.py Normal file
View file

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

323
lvpwb/edit_mode.py Normal file
View file

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

338
lvpwb/games.py Normal file
View file

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

22
lvpwb/paths.py Normal file
View file

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

206
lvpwb/promos.py Normal file
View file

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

54
lvpwb/prompts.py Normal file
View file

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

63
lvpwb/ratings.py Normal file
View file

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

469
lvpwb/render.py Normal file
View file

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

154
lvpwb/results.py Normal file
View file

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

104
lvpwb/screenshots.py Normal file
View file

@ -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<y>\d{4})-(?P<mo>\d{2})-(?P<d>\d{2})-(?P<h>\d{2})-(?P<mi>\d{2})-(?P<s>\d{2})"
)
def parse_screenshot_stamp(filename: str) -> datetime | None:
"""Parse 'GameTitle yyyy-mm-dd-hh-mm-ss.png' into an aware local datetime."""
match = 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']

305
lvpwb/season_booking.py Normal file
View file

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

334
lvpwb/seasons.py Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

@ -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):