1185 lines
47 KiB
Python
1185 lines
47 KiB
Python
#!/usr/bin/env python3
|
|
"""Interactive WWF SmackDown! match-card maker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import select
|
|
import shutil
|
|
import sys
|
|
import textwrap
|
|
import time
|
|
from datetime import date
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
CARD_DIR = ROOT / "cards"
|
|
GAMES: dict[str, dict[str, str]] = {
|
|
"sd1": {"title": "WWF SmackDown!", "roster": "smackdown1roster.json"},
|
|
"sd2": {"title": "WWF SmackDown! 2: Know Your Role", "roster": "smackdown2roster.json"},
|
|
"sd3": {"title": "WWF SmackDown! Just Bring It", "roster": "smackdown3roster.json"},
|
|
}
|
|
|
|
# Each tuple describes the number of wrestlers on each side. Multi-way matches
|
|
# have one wrestler per side; handicap matches have two on the first side.
|
|
MATCH_TYPES: dict[str, list[tuple[str, tuple[int, ...]]]] = {
|
|
"sd1": [
|
|
("Singles", (1, 1)),
|
|
("Tag Team", (2, 2)),
|
|
("Handicap", (2, 1)),
|
|
("Triple Threat", (1, 1, 1)),
|
|
("Fatal 4-Way", (1, 1, 1, 1)),
|
|
("Battle Royal", (1, 1, 1, 1)),
|
|
("Cage", (1, 1)),
|
|
("Hardcore", (1, 1)),
|
|
],
|
|
"sd2": [
|
|
("Singles", (1, 1)),
|
|
("Tag Team", (2, 2)),
|
|
("Tornado Tag", (2, 2)),
|
|
("Handicap", (2, 1)),
|
|
("Triple Threat", (1, 1, 1)),
|
|
("Fatal 4-Way", (1, 1, 1, 1)),
|
|
("Battle Royal", (1, 1, 1, 1)),
|
|
("Cage", (1, 1)),
|
|
("Hardcore", (1, 1)),
|
|
("Ladder", (1, 1)),
|
|
("Table", (1, 1)),
|
|
("Hell in a Cell", (1, 1)),
|
|
],
|
|
"sd3": [
|
|
("Singles", (1, 1)),
|
|
("Tag Team", (2, 2)),
|
|
("Tornado Tag", (2, 2)),
|
|
("Handicap", (2, 1)),
|
|
("Triple Threat", (1, 1, 1)),
|
|
("Fatal 4-Way", (1, 1, 1, 1)),
|
|
("Battle Royal", (1, 1, 1, 1)),
|
|
("Cage", (1, 1)),
|
|
("Hardcore", (1, 1)),
|
|
("Ladder", (1, 1)),
|
|
("Table", (1, 1)),
|
|
("TLC", (2, 2)),
|
|
("Hell in a Cell", (1, 1)),
|
|
],
|
|
}
|
|
|
|
# Typical live-event attendance per city; randomization picks a city then an
|
|
# attendance within its venue range. Unknown cities use the default range.
|
|
CITY_ATTENDANCE_RANGES: dict[str, tuple[int, int]] = {
|
|
# United States
|
|
"New York, NY": (17000, 19800),
|
|
"Boston, MA": (14500, 17500),
|
|
"Philadelphia, PA": (15000, 19500),
|
|
"Chicago, IL": (18000, 22000),
|
|
"Detroit, MI": (15000, 20000),
|
|
"Atlanta, GA": (12000, 17000),
|
|
"Dallas, TX": (14000, 18000),
|
|
"Los Angeles, CA": (15000, 19000),
|
|
"San Francisco, CA": (13000, 16000),
|
|
"Seattle, WA": (12000, 15500),
|
|
"Minneapolis, MN": (12000, 16500),
|
|
"Nashville, TN": (10000, 15000),
|
|
"Miami, FL": (12500, 15500),
|
|
# Canada
|
|
"Toronto, ON": (15000, 19000),
|
|
"Montreal, QC": (16000, 20000),
|
|
"Vancouver, BC": (14000, 17000),
|
|
"Calgary, AB": (9500, 13000),
|
|
# Europe
|
|
"London, England": (12000, 17000),
|
|
"Manchester, England": (9000, 12500),
|
|
"Dublin, Ireland": (9000, 13000),
|
|
"Paris, France": (13000, 17000),
|
|
"Berlin, Germany": (11000, 14500),
|
|
"Amsterdam, Netherlands": (9000, 12000),
|
|
"Madrid, Spain": (10000, 13500),
|
|
"Milan, Italy": (9000, 12500),
|
|
# Japan
|
|
"Tokyo, Japan": (15000, 45000),
|
|
"Yokohama, Japan": (12000, 18000),
|
|
"Osaka, Japan": (11000, 17000),
|
|
"Nagoya, Japan": (9000, 12000),
|
|
"Sapporo, Japan": (8000, 11000),
|
|
"Fukuoka, Japan": (8000, 11000),
|
|
}
|
|
DEFAULT_ATTENDANCE_RANGE = (7000, 15000)
|
|
|
|
RESET = "\033[0m"
|
|
RED = "\033[31m"
|
|
YELLOW = "\033[33m"
|
|
CYAN = "\033[36m"
|
|
DIM = "\033[2m"
|
|
|
|
|
|
def ask(prompt: str, default: str | None = None) -> str:
|
|
suffix = f" [{default}]" if default is not None else ""
|
|
answer = input(f"{prompt}{suffix}: ").strip()
|
|
return answer if answer else (default or "")
|
|
|
|
|
|
def ask_choice(prompt: str, choices: list[str], default: int | None = None) -> int:
|
|
while True:
|
|
raw = ask(prompt, str(default) if default is not None else None)
|
|
try:
|
|
choice = int(raw)
|
|
if 1 <= choice <= len(choices):
|
|
return choice - 1
|
|
except ValueError:
|
|
pass
|
|
print(f"Enter a number from 1 to {len(choices)}.")
|
|
|
|
|
|
def load_roster(game_id: str) -> list[str]:
|
|
"""Load and validate a game's roster from the shared JSON schema."""
|
|
path = ROOT / GAMES[game_id]["roster"]
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"Roster file not found: {path}")
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid JSON roster file: {path}") from exc
|
|
if not isinstance(payload, dict) or payload.get("game_id") != game_id:
|
|
raise ValueError(f"Roster game_id does not match {game_id}: {path}")
|
|
names = payload.get("wrestlers")
|
|
if not isinstance(names, list) or any(not isinstance(name, str) or not name.strip() for name in names):
|
|
raise ValueError(f"Roster must contain a list of non-empty wrestler names: {path}")
|
|
if len(names) != len(set(names)):
|
|
raise ValueError(f"Roster contains duplicate wrestler names: {path}")
|
|
if payload.get("game") != GAMES[game_id]["title"]:
|
|
raise ValueError(f"Roster game title does not match {game_id}: {path}")
|
|
return names
|
|
|
|
|
|
def slugify(value: str) -> str:
|
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
return slug or "smackdown-show"
|
|
|
|
|
|
def default_card_path(show: str, show_date: str) -> Path:
|
|
CARD_DIR.mkdir(parents=True, exist_ok=True)
|
|
stem = f"{show_date}-{slugify(show)}"
|
|
path = CARD_DIR / f"{stem}.md"
|
|
suffix = 2
|
|
while path.exists():
|
|
path = CARD_DIR / f"{stem}-{suffix}.md"
|
|
suffix += 1
|
|
return path
|
|
|
|
|
|
def save_card(card: dict[str, Any]) -> None:
|
|
path = Path(card["path"])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
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(GAMES[card['game_id']]['title'], ensure_ascii=False)}",
|
|
f"- Date: {json.dumps(card['date'])}",
|
|
"",
|
|
]
|
|
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("")
|
|
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}
|
|
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", "date", "city", "attendance"):
|
|
card[key.replace(" ", "_")] = value
|
|
elif current is not None:
|
|
mapping = {"title": "title", "type": "type", "sides": "sides",
|
|
"winner side": "winner_side", "finish": "finish", "promo": "promo"}
|
|
if key in mapping:
|
|
current[mapping[key]] = value
|
|
if card.get("game_id") not in GAMES:
|
|
raise ValueError(f"Unknown or missing game ID in {path}")
|
|
if len(card["matches"]) > 3:
|
|
raise ValueError(f"A card cannot contain more than three matches: {path}")
|
|
while len(card["matches"]) < 3:
|
|
card["matches"].append({"title": "", "type": "Singles", "sides": [],
|
|
"winner_side": None, "finish": "", "promo": None})
|
|
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 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}")
|
|
return card
|
|
|
|
|
|
def random_attendance(city: str) -> int:
|
|
"""Pick an attendance within the city's typical venue range."""
|
|
low, high = CITY_ATTENDANCE_RANGES.get(city, DEFAULT_ATTENDANCE_RANGE)
|
|
return random.randint(low, high)
|
|
|
|
|
|
def random_location() -> tuple[str, int]:
|
|
city = random.choice(sorted(CITY_ATTENDANCE_RANGES))
|
|
return city, random_attendance(city)
|
|
|
|
|
|
def parse_attendance(raw: str) -> int | None:
|
|
cleaned = re.sub(r"[,\s_]", "", raw)
|
|
if not cleaned.isdigit():
|
|
return None
|
|
value = int(cleaned)
|
|
return value if value > 0 else None
|
|
|
|
|
|
def ask_location() -> tuple[str, int]:
|
|
"""Prompt for city and attendance; 'r' randomizes both, or just attendance."""
|
|
while True:
|
|
city = ask("City (r randomizes city and attendance)").strip()
|
|
if city.casefold() == "r":
|
|
city, attendance = random_location()
|
|
print(f"Random location: {city} · {attendance:,} fans")
|
|
return city, attendance
|
|
if city:
|
|
break
|
|
print("Enter a city, or 'r' to randomize.")
|
|
while True:
|
|
raw = ask(f"Attendance in {city} (r to randomize)").strip()
|
|
if raw.casefold() == "r":
|
|
attendance = random_attendance(city)
|
|
print(f"Random attendance: {attendance:,} fans")
|
|
return city, attendance
|
|
attendance = parse_attendance(raw)
|
|
if attendance is not None:
|
|
return city, attendance
|
|
print("Enter a whole number greater than zero, or 'r' to randomize.")
|
|
|
|
|
|
def show_location_text(card: dict[str, Any]) -> str:
|
|
"""Show name with its city and attendance, when the card has them."""
|
|
parts = [card["show"]]
|
|
city = str(card.get("city") or "").strip()
|
|
attendance = card.get("attendance")
|
|
if city:
|
|
parts.append(city)
|
|
if attendance:
|
|
parts.append(f"{attendance:,} fans")
|
|
return " · ".join(parts)
|
|
|
|
|
|
def card_summary(path: Path) -> str:
|
|
"""One-line listing for a saved card; falls back to the file stem."""
|
|
try:
|
|
return show_location_text(load_card(path))
|
|
except (OSError, ValueError, json.JSONDecodeError):
|
|
return path.stem
|
|
|
|
|
|
def choose_game() -> str:
|
|
ids = list(GAMES)
|
|
print("\nChoose a game:")
|
|
for index, game_id in enumerate(ids, 1):
|
|
print(f" {index}. {GAMES[game_id]['title']}")
|
|
return ids[ask_choice("Game", ids)]
|
|
|
|
|
|
def choose_match_type(game_id: str) -> tuple[str, tuple[int, ...]]:
|
|
options = MATCH_TYPES[game_id]
|
|
print("\nAvailable match types:")
|
|
for index, (name, sides) in enumerate(options, 1):
|
|
format_text = " vs ".join(str(count) for count in sides)
|
|
print(f" {index}. {name} ({format_text})")
|
|
return options[ask_choice("Match type", [item[0] for item in options])]
|
|
|
|
|
|
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.")
|
|
print("\nParticipant selection: enter a roster number, 'r' for a random pick, or 'a' to "
|
|
"randomly fill the remaining spots.")
|
|
for index, name in enumerate(roster, 1):
|
|
print(f" {index:>2}. {name}")
|
|
selected: list[str] = []
|
|
sides: list[list[str]] = []
|
|
for side_number, size in enumerate(side_sizes, 1):
|
|
side: list[str] = []
|
|
for slot in range(1, size + 1):
|
|
while True:
|
|
choice = ask(f"Side {side_number}, wrestler {slot}/{size}")
|
|
if choice.lower() == "a":
|
|
remaining = [name for name in roster if name not in selected]
|
|
needed = sum(side_sizes[side_number - 1:]) - len(side)
|
|
picks = random.sample(remaining, needed)
|
|
# Fill this side and all remaining sides, then return.
|
|
take = size - len(side)
|
|
side.extend(picks[:take])
|
|
sides.append(side)
|
|
rest = picks[take:]
|
|
cursor = 0
|
|
for remaining_size in side_sizes[side_number:]:
|
|
sides.append(rest[cursor:cursor + remaining_size])
|
|
cursor += remaining_size
|
|
return sides
|
|
if choice.lower() == "r":
|
|
available = [name for name in roster if name not in selected]
|
|
if available:
|
|
pick = random.choice(available)
|
|
break
|
|
else:
|
|
try:
|
|
index = int(choice)
|
|
pick = roster[index - 1]
|
|
if index < 1 or index > len(roster):
|
|
raise IndexError
|
|
if pick in selected:
|
|
print("Each wrestler can only be selected once in a match.")
|
|
continue
|
|
break
|
|
except (ValueError, IndexError):
|
|
print("Choose an available roster number, 'r', or 'a'.")
|
|
side.append(pick)
|
|
selected.append(pick)
|
|
sides.append(side)
|
|
return sides
|
|
|
|
|
|
def create_match(number: int, game_id: str, roster: list[str]) -> dict[str, Any]:
|
|
print(f"\n{'=' * 14} MATCH {number} {'=' * 14}")
|
|
match_name, side_sizes = choose_match_type(game_id)
|
|
title = ask("Optional match title (leave blank for none)")
|
|
sides = choose_sides(side_sizes, roster)
|
|
return {"title": title, "type": match_name, "sides": sides,
|
|
"winner_side": None, "finish": "", "promo": None}
|
|
|
|
|
|
def select_card_path() -> Path | None:
|
|
candidates = sorted(CARD_DIR.glob("*.md")) if CARD_DIR.is_dir() else []
|
|
if candidates:
|
|
print("\nSaved cards:")
|
|
for index, path in enumerate(candidates, 1):
|
|
print(f" {index}. {card_summary(path)} [{path.name}]")
|
|
print(" Enter a number to load a listed card, or type a path.")
|
|
raw = ask("Card path (blank to cancel)")
|
|
if not raw:
|
|
return None
|
|
if raw.isdigit() and candidates and 1 <= int(raw) <= len(candidates):
|
|
return candidates[int(raw) - 1]
|
|
path = Path(raw).expanduser()
|
|
if not path.is_absolute():
|
|
path = ROOT / path
|
|
return path
|
|
|
|
|
|
def create_card() -> dict[str, Any]:
|
|
game_id = choose_game()
|
|
roster = load_roster(game_id)
|
|
show = ask("Show name", "Friday Night SmackDown")
|
|
while True:
|
|
show_date = ask("Show date (YYYY-MM-DD)", date.today().isoformat())
|
|
try:
|
|
show_date = date.fromisoformat(show_date).isoformat()
|
|
break
|
|
except ValueError:
|
|
print("Enter a valid date in YYYY-MM-DD format.")
|
|
city, attendance = ask_location()
|
|
card: dict[str, Any] = {
|
|
"game_id": game_id, "show": show, "date": show_date,
|
|
"city": city, "attendance": attendance,
|
|
"matches": [{"title": "", "type": "Singles", "sides": [], "winner_side": None,
|
|
"finish": "", "promo": None} for _ in range(3)],
|
|
"path": str(default_card_path(show, show_date)),
|
|
}
|
|
save_card(card)
|
|
print(f"\nCard started: {card['path']}")
|
|
for number in range(1, 4):
|
|
card["matches"][number - 1] = create_match(number, game_id, roster)
|
|
save_card(card)
|
|
print(f"\nCard saved to {card['path']}")
|
|
return card
|
|
|
|
|
|
def color(text: str, code: str, enabled: bool = True) -> str:
|
|
return f"{code}{text}{RESET}" if enabled else text
|
|
|
|
|
|
def short(text: str, width: int) -> str:
|
|
if len(text) <= width:
|
|
return text
|
|
if width < 2:
|
|
return text[:width]
|
|
return text[:width - 1] + "…"
|
|
|
|
|
|
def match_lines(
|
|
match: dict[str, Any],
|
|
number: int,
|
|
width: int,
|
|
flash: bool = False,
|
|
promo_selected: bool = False,
|
|
promo_expanded: bool = False,
|
|
) -> list[str]:
|
|
box_width = max(36, min(width - 4, 100))
|
|
inner = box_width - 4
|
|
heading = f"Match {number}" + (f" — {match['title']}" if match.get("title") else "")
|
|
heading = short(heading, inner)
|
|
heading_text = f" {heading} "
|
|
top_fill = max(0, box_width - 2 - len(heading_text))
|
|
lines = [f"┌{heading_text}{'─' * top_fill}┐", f"│ {match['type'].center(inner)} │"]
|
|
sides = match["sides"]
|
|
winner = match.get("winner_side")
|
|
if len(sides) == 2:
|
|
left_width = max(10, (inner - 8) // 2)
|
|
right_width = inner - left_width - 6
|
|
labels: list[str] = []
|
|
for side_number, side in enumerate(sides):
|
|
label = " & ".join(side)
|
|
if winner is not None:
|
|
if side_number == winner:
|
|
label = "WIN!" if flash else short(label, left_width if side_number == 0 else right_width)
|
|
label = color(label, RED)
|
|
else:
|
|
label = "LOSE" if flash else short(label, left_width if side_number == 0 else right_width)
|
|
label = color(label, YELLOW)
|
|
else:
|
|
label = short(label, left_width if side_number == 0 else right_width)
|
|
labels.append(label)
|
|
left = labels[0]
|
|
right = labels[1]
|
|
# ANSI color sequences are zero-width; pad using the original label lengths.
|
|
left_plain_width = left_width
|
|
right_plain_width = right_width
|
|
lines.append(f"│ {left}{' ' * max(0, left_plain_width - visible_length(left))} VS "
|
|
f"{right}{' ' * max(0, right_plain_width - visible_length(right))} │")
|
|
else:
|
|
pieces: list[str] = []
|
|
for side_number, side in enumerate(sides):
|
|
label = " & ".join(side)
|
|
if winner is not None:
|
|
if side_number == winner:
|
|
label = "WIN!" if flash else label
|
|
label = color(label, RED)
|
|
else:
|
|
label = "LOSE" if flash else label
|
|
label = color(label, YELLOW)
|
|
pieces.append(label)
|
|
separator = " VS "
|
|
required = sum(visible_length(piece) for piece in pieces) + len(separator) * (len(pieces) - 1)
|
|
if required > inner and len(pieces) > 1:
|
|
name_budget = max(1, (inner - len(separator) * (len(pieces) - 1)) // len(pieces))
|
|
pieces = [short_ansi(piece, name_budget) for piece in pieces]
|
|
joined = separator.join(pieces)
|
|
padding = max(0, inner - visible_length(joined))
|
|
lines.append(f"│ {' ' * (padding // 2)}{joined}{' ' * (padding - padding // 2)} │")
|
|
status = "COMPLETE" if winner is not None else "UP NEXT"
|
|
if match.get("finish"):
|
|
status += f" · {match['finish']}"
|
|
status = short(status, inner)
|
|
if match.get("promo"):
|
|
if promo_expanded:
|
|
control = "▼ Promo (Enter to collapse)"
|
|
else:
|
|
preview = " / ".join(str(match["promo"]).splitlines())
|
|
control = short(f"▶ Promo: {preview}", inner)
|
|
control_line = f"│ {control:<{inner}} │"
|
|
lines.append(color(control_line, "\033[7m", enabled=promo_selected))
|
|
if promo_expanded:
|
|
wrap_width = max(8, inner - 2)
|
|
for turn in str(match["promo"]).splitlines():
|
|
wrapped_turn = textwrap.wrap(
|
|
turn, width=wrap_width, break_long_words=True, break_on_hyphens=False
|
|
) or [""]
|
|
for wrapped in wrapped_turn:
|
|
lines.append(f"│ {wrapped:<{wrap_width}} │")
|
|
status_text = f" {status} "
|
|
status_padding = max(0, box_width - 2 - len(status_text))
|
|
left_padding = status_padding // 2
|
|
right_padding = status_padding - left_padding
|
|
lines.append(f"└{'─' * left_padding}{status_text}{'─' * right_padding}┘")
|
|
return lines
|
|
|
|
|
|
ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
|
|
|
|
|
def visible_length(text: str) -> int:
|
|
return len(ANSI_RE.sub("", text))
|
|
|
|
|
|
def short_ansi(text: str, width: int) -> str:
|
|
plain = ANSI_RE.sub("", text)
|
|
if len(plain) <= width:
|
|
return text
|
|
# Multi-way names are shortened only in the uncolored portion; retain the
|
|
# match color by applying it to the shortened result.
|
|
code = ""
|
|
if text.startswith(RED):
|
|
code = RED
|
|
elif text.startswith(YELLOW):
|
|
code = YELLOW
|
|
return color(short(plain, width), code, bool(code))
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def _render_image(path: Path, max_columns: int, max_rows: int) -> tuple[list[str], int]:
|
|
"""Render an image as ANSI true-color half-blocks when Pillow is installed."""
|
|
try:
|
|
from PIL import Image
|
|
except ImportError:
|
|
return [], 0
|
|
try:
|
|
with Image.open(path) as source:
|
|
image = source.convert("RGBA")
|
|
image.thumbnail((max_columns, max_rows * 2), Image.Resampling.LANCZOS)
|
|
image_width, image_height = image.size
|
|
pixels = image.load()
|
|
output: list[str] = []
|
|
for y in range(0, image_height, 2):
|
|
row: list[str] = []
|
|
for x in range(image_width):
|
|
top = pixels[x, y]
|
|
bottom = pixels[x, y + 1] if y + 1 < image_height else (0, 0, 0, 255)
|
|
# Composite transparent logo pixels onto the terminal background.
|
|
top = tuple(round(top[i] * top[3] / 255) for i in range(3))
|
|
bottom = tuple(round(bottom[i] * bottom[3] / 255) for i in range(3))
|
|
row.append(f"\033[38;2;{top[0]};{top[1]};{top[2]}m"
|
|
f"\033[48;2;{bottom[0]};{bottom[1]};{bottom[2]}m▀")
|
|
output.append("".join(row) + RESET)
|
|
return output, image_width
|
|
except Exception:
|
|
return [], 0
|
|
|
|
|
|
def show_logo_path(show_name: str) -> Path | None:
|
|
"""Choose a centered show logo from the show name, ignoring case."""
|
|
normalized = show_name.casefold()
|
|
if "smackdown" in normalized:
|
|
return ROOT / "smackdown-logo-source.png"
|
|
if re.search(r"\braw\b", normalized):
|
|
return ROOT / "raw-is-war-logo.png"
|
|
return None
|
|
|
|
|
|
def header_lines(card: dict[str, Any], width: int) -> list[str]:
|
|
left, left_width = _render_image(ROOT / "wwfcomlogo.gif", 23, 6)
|
|
logo_path = show_logo_path(card["show"])
|
|
if logo_path:
|
|
center, center_width = _render_image(logo_path, 38, 6)
|
|
if not center:
|
|
brand = "SMACKDOWN!" if logo_path.name == "smackdown-logo-source.png" else "RAW IS WAR"
|
|
center = [color(brand, CYAN)]
|
|
center_width = len(brand)
|
|
else:
|
|
center, center_width = [], 0
|
|
if not left:
|
|
left = [color("WWF.com", RED)]
|
|
left_width = 7
|
|
row_count = max(len(left), len(center), 2)
|
|
if width < 62:
|
|
title = GAMES[card["game_id"]]["title"]
|
|
return [f"{color('WWF.com', RED)} {color(title, CYAN)}",
|
|
f"{card['date']} · {show_location_text(card)}", ""]
|
|
left_x = 1
|
|
center_x = max(left_x + left_width + 3, (width - center_width) // 2)
|
|
date_text = card["date"]
|
|
date_x = width - len(date_text) - 2
|
|
output: list[str] = []
|
|
for index in range(row_count):
|
|
segments: list[str] = []
|
|
cursor = 0
|
|
if index < len(left):
|
|
segments.append(" " * (left_x - cursor) + left[index])
|
|
cursor = left_x + left_width
|
|
if index < len(center):
|
|
if center_x < cursor:
|
|
center_x = cursor + 2
|
|
segments.append(" " * (center_x - cursor) + center[index])
|
|
cursor = center_x + center_width
|
|
if index == 0 and date_x > cursor:
|
|
segments.append(" " * (date_x - cursor) + color(date_text, DIM))
|
|
output.append("".join(segments))
|
|
output.append(color(short(show_location_text(card), width - 2), CYAN))
|
|
return output
|
|
|
|
|
|
def render_card_sections(
|
|
card: dict[str, Any],
|
|
flash: bool = False,
|
|
selected_promo: int | None = None,
|
|
expanded_promos: set[int] | None = None,
|
|
width: int | None = None,
|
|
) -> tuple[list[str], list[str], dict[int, tuple[int, int]]]:
|
|
width = width or shutil.get_terminal_size((100, 28)).columns
|
|
expanded_promos = expanded_promos or set()
|
|
header = header_lines(card, width)
|
|
body: list[str] = []
|
|
promo_spans: dict[int, tuple[int, int]] = {}
|
|
for index, match in enumerate(card["matches"], 1):
|
|
match_index = index - 1
|
|
start = len(body)
|
|
block = match_lines(
|
|
match,
|
|
index,
|
|
width,
|
|
flash=flash,
|
|
promo_selected=(match_index == selected_promo),
|
|
promo_expanded=(match_index in expanded_promos),
|
|
)
|
|
if match.get("promo"):
|
|
control_row = start + 3
|
|
promo_spans[match_index] = (control_row, start + len(block) - 1)
|
|
body.extend(block)
|
|
body.append(color(f"Saved: {card['path']}", DIM))
|
|
instructions = "↑/↓ select promo · Enter expand/collapse · PgUp/PgDn scroll · q close"
|
|
body.append(color(short(instructions, max(1, width - 2)), DIM))
|
|
return header, body, promo_spans
|
|
|
|
|
|
def render_card_lines(
|
|
card: dict[str, Any],
|
|
flash: bool = False,
|
|
selected_promo: int | None = None,
|
|
expanded_promos: set[int] | None = None,
|
|
) -> list[str]:
|
|
header, body, _ = render_card_sections(
|
|
card, flash=flash, selected_promo=selected_promo, expanded_promos=expanded_promos
|
|
)
|
|
return header + body
|
|
|
|
|
|
def render_card(card: dict[str, Any], flash: bool = False) -> str:
|
|
return "\n".join(render_card_lines(card, flash=flash))
|
|
|
|
|
|
def viewport_frame(
|
|
header: list[str], body: list[str], height: int, scroll_offset: int
|
|
) -> tuple[list[str], int, int]:
|
|
"""Keep the header fixed while clipping the scrollable card body to the screen."""
|
|
height = max(1, height)
|
|
header_count = min(len(header), max(0, height - 1))
|
|
fixed_header = header[:header_count]
|
|
body_capacity = max(1, height - header_count)
|
|
max_offset = max(0, len(body) - body_capacity)
|
|
offset = min(max(0, scroll_offset), max_offset)
|
|
visible_body = body[offset:offset + body_capacity]
|
|
frame = fixed_header + visible_body
|
|
frame.extend([""] * (height - len(frame)))
|
|
return frame, offset, body_capacity
|
|
|
|
|
|
def keep_promo_visible(
|
|
scroll_offset: int,
|
|
promo_span: tuple[int, int] | None,
|
|
body_capacity: int,
|
|
body_length: int,
|
|
) -> int:
|
|
if promo_span is None:
|
|
return min(scroll_offset, max(0, body_length - body_capacity))
|
|
start, end = promo_span
|
|
span_length = end - start
|
|
if span_length <= body_capacity:
|
|
if start < scroll_offset:
|
|
scroll_offset = start
|
|
elif end > scroll_offset + body_capacity:
|
|
scroll_offset = end - body_capacity
|
|
elif start < scroll_offset or start >= scroll_offset + body_capacity:
|
|
scroll_offset = start
|
|
return min(max(0, scroll_offset), max(0, body_length - body_capacity))
|
|
|
|
|
|
def write_changed_lines(previous: list[str], current: list[str], stream: Any = None) -> None:
|
|
"""Update only changed screen rows; static branding and card text stay put."""
|
|
stream = stream or sys.stdout
|
|
changed = False
|
|
for index, line in enumerate(current):
|
|
if index >= len(previous) or line != previous[index]:
|
|
stream.write(f"\033[{index + 1};1H\033[2K{line}")
|
|
changed = True
|
|
if changed:
|
|
stream.flush()
|
|
|
|
|
|
def interactive_card_frame(
|
|
card: dict[str, Any],
|
|
terminal_width: int,
|
|
terminal_height: int,
|
|
flash: bool = False,
|
|
selected_promo: int | None = None,
|
|
expanded_promos: set[int] | None = None,
|
|
scroll_offset: int = 0,
|
|
keep_selection_visible: bool = False,
|
|
) -> tuple[list[str], int, int, dict[int, tuple[int, int]]]:
|
|
header, body, promo_spans = render_card_sections(
|
|
card,
|
|
flash=flash,
|
|
selected_promo=selected_promo,
|
|
expanded_promos=expanded_promos,
|
|
width=terminal_width,
|
|
)
|
|
_, _, body_capacity = viewport_frame(header, body, terminal_height, scroll_offset)
|
|
if keep_selection_visible and selected_promo is not None:
|
|
scroll_offset = keep_promo_visible(
|
|
scroll_offset, promo_spans.get(selected_promo), body_capacity, len(body)
|
|
)
|
|
frame, scroll_offset, body_capacity = viewport_frame(
|
|
header, body, terminal_height, scroll_offset
|
|
)
|
|
return frame, scroll_offset, body_capacity, promo_spans
|
|
|
|
|
|
def read_view_key(fd: int) -> str:
|
|
"""Read one key from a cbreak terminal, translating common navigation keys."""
|
|
first = os.read(fd, 1)
|
|
if first in (b"\r", b"\n"):
|
|
return "enter"
|
|
if first in (b"q", b"Q"):
|
|
return "quit"
|
|
if first in (b"j", b"J"):
|
|
return "down"
|
|
if first in (b"k", b"K"):
|
|
return "up"
|
|
if first != b"\x1b":
|
|
return "other"
|
|
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
if not ready:
|
|
return "quit"
|
|
if os.read(fd, 1) != b"[":
|
|
return "other"
|
|
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
if not ready:
|
|
return "other"
|
|
code = os.read(fd, 1)
|
|
if code == b"A":
|
|
return "up"
|
|
if code == b"B":
|
|
return "down"
|
|
if code in (b"5", b"6"):
|
|
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
if ready and os.read(fd, 1) == b"~":
|
|
return "pageup" if code == b"5" else "pagedown"
|
|
return "other"
|
|
|
|
|
|
def view_card(card: dict[str, Any]) -> None:
|
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
print("\033[2J\033[H" + render_card(card))
|
|
input("\nPress Enter to continue...")
|
|
return
|
|
try:
|
|
import termios
|
|
import tty
|
|
fd = sys.stdin.fileno()
|
|
old_settings = termios.tcgetattr(fd)
|
|
tty.setcbreak(fd)
|
|
try:
|
|
terminal = shutil.get_terminal_size((100, 28))
|
|
promo_indices = [index for index, match in enumerate(card["matches"])
|
|
if match.get("promo")]
|
|
selected_promo = promo_indices[0] if promo_indices else None
|
|
expanded_promos: set[int] = set()
|
|
scroll_offset = 0
|
|
has_results = any(match.get("winner_side") is not None for match in card["matches"])
|
|
flash = False
|
|
frame, scroll_offset, body_capacity, promo_spans = interactive_card_frame(
|
|
card, terminal.columns, terminal.lines, flash=flash,
|
|
selected_promo=selected_promo, expanded_promos=expanded_promos,
|
|
scroll_offset=scroll_offset, keep_selection_visible=True,
|
|
)
|
|
# Paint the static header and collapsed card once. Subsequent frames
|
|
# update changed rows only, with the header pinned above the viewport.
|
|
sys.stdout.write("\033[2J\033[H" + "\n".join(frame))
|
|
sys.stdout.flush()
|
|
while True:
|
|
ready, _, _ = select.select([sys.stdin], [], [], 0.65)
|
|
key = read_view_key(fd) if ready else ""
|
|
keep_selection_visible = False
|
|
if key == "quit":
|
|
break
|
|
elif key in ("up", "down") and promo_indices:
|
|
current = promo_indices.index(selected_promo) if selected_promo in promo_indices else 0
|
|
delta = -1 if key == "up" else 1
|
|
selected_promo = promo_indices[(current + delta) % len(promo_indices)]
|
|
keep_selection_visible = True
|
|
elif key == "enter" and selected_promo is not None:
|
|
if selected_promo in expanded_promos:
|
|
expanded_promos.remove(selected_promo)
|
|
else:
|
|
expanded_promos.add(selected_promo)
|
|
keep_selection_visible = True
|
|
elif key == "pageup":
|
|
scroll_offset -= max(1, body_capacity // 2)
|
|
elif key == "pagedown":
|
|
scroll_offset += max(1, body_capacity // 2)
|
|
elif not key and has_results:
|
|
flash = not flash
|
|
elif key:
|
|
continue
|
|
next_frame, scroll_offset, body_capacity, promo_spans = interactive_card_frame(
|
|
card, terminal.columns, terminal.lines, flash=flash,
|
|
selected_promo=selected_promo, expanded_promos=expanded_promos,
|
|
scroll_offset=scroll_offset,
|
|
keep_selection_visible=keep_selection_visible,
|
|
)
|
|
write_changed_lines(frame, next_frame)
|
|
frame = next_frame
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
sys.stdout.write("\033[0m\033[2J\033[H")
|
|
sys.stdout.flush()
|
|
except (ImportError, OSError, ValueError):
|
|
print("\033[2J\033[H" + render_card(card))
|
|
input("\nPress Enter to continue...")
|
|
|
|
|
|
def pending_indices(card: dict[str, Any]) -> list[int]:
|
|
return [index for index, match in enumerate(card["matches"])
|
|
if match.get("sides") and match.get("winner_side") is None]
|
|
|
|
|
|
def editable_indices(card: dict[str, Any]) -> list[int]:
|
|
return [index for index, match in enumerate(card["matches"])
|
|
if match.get("winner_side") is None]
|
|
|
|
|
|
def choose_match_index(card: dict[str, Any], indices: list[int], prompt: str) -> int:
|
|
print()
|
|
for menu_number, index in enumerate(indices, 1):
|
|
match = card["matches"][index]
|
|
title = f" — {match['title']}" if match.get("title") else ""
|
|
state = " · not configured" if not match.get("sides") else ""
|
|
print(f" {menu_number}. Match {index + 1}: {match['type']}{title}{state}")
|
|
selected = ask_choice(prompt, [str(number) for number in range(1, len(indices) + 1)])
|
|
return indices[selected]
|
|
|
|
|
|
def record_result(card: dict[str, Any]) -> None:
|
|
pending = pending_indices(card)
|
|
if not pending:
|
|
print("All three matches are complete.")
|
|
return
|
|
index = choose_match_index(card, pending, "Choose a match to finish")
|
|
match = card["matches"][index]
|
|
print("\nMatch sides:")
|
|
for side_number, side in enumerate(match["sides"], 1):
|
|
print(f" {side_number}. {' & '.join(side)}")
|
|
match["winner_side"] = ask_choice("Winning side", [" & ".join(side) for side in match["sides"]])
|
|
match["finish"] = ask("Finish (optional, e.g. pinfall or submission)")
|
|
save_card(card)
|
|
print(f"Result saved to {card['path']}.")
|
|
|
|
|
|
def edit_match(card: dict[str, Any]) -> None:
|
|
editable = editable_indices(card)
|
|
if not editable:
|
|
print("There are no unfinished matches to edit.")
|
|
return
|
|
index = choose_match_index(card, editable, "Choose a match to edit")
|
|
match = card["matches"][index]
|
|
roster = load_roster(card["game_id"])
|
|
print("Choose the new match type, or keep the current type.")
|
|
options = MATCH_TYPES[card["game_id"]]
|
|
for option_index, (name, side_sizes) in enumerate(options, 1):
|
|
print(f" {option_index}. {name} ({' vs '.join(map(str, side_sizes))})")
|
|
print(f" {len(options) + 1}. Keep {match['type']}")
|
|
selected = ask_choice("Match type", [item[0] for item in options] + ["Keep current"])
|
|
old_sizes = tuple(len(side) for side in match.get("sides", []))
|
|
if selected < len(options):
|
|
match["type"], side_sizes = options[selected]
|
|
else:
|
|
found = next((entry for entry in options if entry[0] == match["type"]), options[0])
|
|
side_sizes = found[1]
|
|
needs_participants = old_sizes != side_sizes
|
|
if needs_participants or ask("Change participants too? (y/N)").lower() == "y":
|
|
match["sides"] = choose_sides(side_sizes, roster)
|
|
current_title = match.get("title", "")
|
|
match["title"] = ask(f"Match title (blank removes; current: {current_title or 'none'})")
|
|
save_card(card)
|
|
print(f"Match updated and saved to {card['path']}.")
|
|
|
|
|
|
PROMO_OPENERS = [
|
|
"The crowd may be watching, but I only have eyes for this fight.",
|
|
"You have had your moment. Now the spotlight is mine.",
|
|
"All the talking ends when that bell rings.",
|
|
"I came here to prove that I belong at the top.",
|
|
]
|
|
PROMO_CHALLENGES = [
|
|
"Bring your best, because I will not make this easy.",
|
|
"You will have to do more than make promises tonight.",
|
|
"I know exactly what you can do, and I am still not impressed.",
|
|
"One mistake is all I need to turn this match around.",
|
|
]
|
|
PROMO_RETORTS = [
|
|
"Keep that confidence. You will need something to hold onto.",
|
|
"I have heard it all before. Show me something new in the ring.",
|
|
"You can take the last word. I will take the win.",
|
|
"When the match is over, everyone will know who meant it.",
|
|
]
|
|
|
|
PROMO_PROFILES: dict[str, dict[str, list[str]]] = {
|
|
"rock": {
|
|
"openers": [
|
|
"Listen up, jabroni {opponent}—The Rock is ready to layeth the smackdown.",
|
|
"Finally, {opponent}, The Rock has come back to this ring.",
|
|
"The Rock is standing across from {opponent}, and the millions are watching.",
|
|
],
|
|
"challenges": [
|
|
"Know your role and shut your mouth, {opponent}, because The Rock is taking over.",
|
|
"Bring it, {opponent}, and The Rock will bring the thunder.",
|
|
"Jabroni, you can bring your very best. The Rock will still be better.",
|
|
],
|
|
"retorts": [
|
|
"The Rock says {opponent} is about to get checked into the SmackDown Hotel.",
|
|
"The Rock has one thing left to say—know your role, jabroni.",
|
|
"The millions have spoken, and they are chanting for The Rock.",
|
|
],
|
|
},
|
|
"austin": {
|
|
"openers": [
|
|
"Stone Cold is here, and {opponent} can like it or lump it.",
|
|
"Austin has one plan for {opponent}—open up a can and get to work.",
|
|
"The glass breaks, {opponent} looks nervous, and Stone Cold likes that.",
|
|
],
|
|
"challenges": [
|
|
"That's the bottom line, because Stone Cold said so.",
|
|
"You want a fight, {opponent}? Stone Cold doesn't do negotiations.",
|
|
"Austin doesn't care about your excuses, {opponent}. Ring the bell.",
|
|
],
|
|
"retorts": [
|
|
"Stone Cold will leave {opponent} seeing stars and hearing that bell.",
|
|
"Austin has heard enough. It's time to raise a little hell.",
|
|
"And that's the bottom line—{opponent} is in for a rough night.",
|
|
],
|
|
},
|
|
"tripleh": {
|
|
"openers": [
|
|
"I am the Game, {opponent}, and you are standing on my board.",
|
|
"The Game has arrived. {opponent}, you know how this ends.",
|
|
"I built my place at the top one victory at a time, {opponent}.",
|
|
],
|
|
"challenges": [
|
|
"You can play your part, {opponent}, while I decide how the story ends.",
|
|
"The Game doesn't hand out victories. It takes them.",
|
|
"Step into my ring, {opponent}, and find out why they call me the Game.",
|
|
],
|
|
"retorts": [
|
|
"I am the Game, and this is where your luck runs out.",
|
|
"When the dust settles, {opponent}, I will still be standing on top.",
|
|
"The Game is over when I say it's over.",
|
|
],
|
|
},
|
|
"mankind": {
|
|
"openers": ["{opponent} gets the spotlight, and the little guy gets blamed again."],
|
|
"challenges": ["{opponent}, they keep persecuting the little guy—but the little guy keeps getting back up."],
|
|
"retorts": ["Pain can knock all it wants; I keep getting up before it finds the right door."],
|
|
"sayings": [
|
|
"People say the early bird gets the worm. I say the worm gets the bird, the bird gets a clock, and the clock gets confused.",
|
|
"You can't have your cake and eat it too—so I gave the cake the fork and told the fork to find its way home.",
|
|
"A stitch in time saves nine; I lost the needle, tripped over time, and still made it to the ring.",
|
|
"They say every cloud has a silver lining. Mine had a receipt, and the rain returned it upside down.",
|
|
],
|
|
"pain": [
|
|
"Pain hit me like a ton of bricks; I apologized to the bricks and kept walking.",
|
|
"My body says stop, but I think the sign is facing the other way.",
|
|
"The hurt is loud. Little guys learn to sleep through the noise.",
|
|
],
|
|
},
|
|
"undertaker": {
|
|
"openers": [
|
|
"{opponent}, the lights will fade, and the bell will toll for you.",
|
|
"The Undertaker has walked through darkness far worse than this ring.",
|
|
],
|
|
"challenges": [
|
|
"You can run from the shadow, {opponent}, but you cannot outrun it.",
|
|
"When the bell tolls, {opponent}, there will be nowhere left to hide.",
|
|
],
|
|
"retorts": [
|
|
"Rest in peace, {opponent}—if you can.",
|
|
"The darkness is patient. So am I.",
|
|
],
|
|
},
|
|
"kane": {
|
|
"openers": ["The Big Red Machine has been unleashed, {opponent}."],
|
|
"challenges": ["You hear the flames, {opponent}? They are calling your name."],
|
|
"retorts": ["Kane doesn't back down. Kane brings the fire."],
|
|
},
|
|
"jericho": {
|
|
"openers": ["Welcome to the highlight reel, {opponent}. Chris Jericho is starring."],
|
|
"challenges": ["You are looking at the best in the world at what he does."],
|
|
"retorts": ["Now take a good look at the first-ever undisputed problem in your way."],
|
|
},
|
|
"kurtangle": {
|
|
"openers": ["I won an Olympic gold medal, {opponent}. What have you got?"],
|
|
"challenges": ["It's true, it's true—excellence is about to beat you."],
|
|
"retorts": ["You may cheer now, but you will be tapping soon enough."],
|
|
},
|
|
"vince": {
|
|
"openers": ["You are looking at the chairman, {opponent}, and this is my show."],
|
|
"challenges": ["You are one bad decision away from hearing, 'You're fired!'"],
|
|
"retorts": ["The boss always gets the last word."],
|
|
},
|
|
"edge": {
|
|
"openers": ["{opponent}, this is my chance to steal the spotlight."],
|
|
"challenges": ["I will take every shortcut and every opening you leave me."],
|
|
"retorts": ["You had your chance. I am taking the moment."],
|
|
},
|
|
"jeffhardy": {
|
|
"openers": ["{opponent}, I will take the risk nobody else dares to take."],
|
|
"challenges": ["I will fly, fall, and get right back up if that's what it takes."],
|
|
"retorts": ["The risk is worth it when the crowd is on its feet."],
|
|
},
|
|
"alsnow": {
|
|
"openers": ["Head says {opponent} is in trouble. Head is usually right."],
|
|
"challenges": ["I have talked this over with Head, and we're ready for you."],
|
|
"retorts": ["Head says that went exactly as planned."],
|
|
},
|
|
}
|
|
|
|
PROMO_PROFILE_ALIASES = {
|
|
"therock": "rock", "rock": "rock",
|
|
"stonecoldsteveaustin": "austin", "steveaustin": "austin", "austin": "austin",
|
|
"tripleh": "tripleh", "hunterhearsthelmsley": "tripleh",
|
|
"mankind": "mankind", "mickfoley": "mankind",
|
|
"undertaker": "undertaker", "theundertaker": "undertaker",
|
|
"kane": "kane",
|
|
"chrisjericho": "jericho", "jericho": "jericho",
|
|
"kurtangle": "kurtangle",
|
|
"vincemcmahon": "vince",
|
|
"edge": "edge",
|
|
"jeffhardy": "jeffhardy",
|
|
"alsnow": "alsnow",
|
|
}
|
|
|
|
|
|
def normalized_wrestler_name(name: str) -> str:
|
|
return re.sub(r"[^a-z0-9]", "", name.casefold())
|
|
|
|
|
|
def promo_line_for(wrestler: str, opponent: str, moment: str) -> str:
|
|
"""Return a short line shaped by a wrestler's voice, or use the generic bank."""
|
|
profile_key = PROMO_PROFILE_ALIASES.get(normalized_wrestler_name(wrestler))
|
|
if profile_key == "mankind":
|
|
profile = PROMO_PROFILES[profile_key]
|
|
parts = [
|
|
random.choice(profile["openers"]).format(opponent=opponent),
|
|
random.choice(profile["sayings"]),
|
|
random.choice(profile["pain"]),
|
|
]
|
|
return " ".join(parts)
|
|
if profile_key:
|
|
line = random.choice(PROMO_PROFILES[profile_key][moment])
|
|
else:
|
|
generic = {"openers": PROMO_OPENERS, "challenges": PROMO_CHALLENGES,
|
|
"retorts": PROMO_RETORTS}[moment]
|
|
line = random.choice(generic)
|
|
return line.format(opponent=opponent, wrestler=wrestler)
|
|
|
|
|
|
def generate_promo(card: dict[str, Any]) -> None:
|
|
pending = pending_indices(card)
|
|
if not pending:
|
|
print("Finish another match before adding an in-between promo.")
|
|
return
|
|
index = pending[0]
|
|
match = card["matches"][index]
|
|
if len(match["sides"]) < 2:
|
|
print("This match does not have two sides for a face-off promo.")
|
|
return
|
|
first = random.choice(match["sides"][0])
|
|
second = random.choice(match["sides"][1])
|
|
match["promo"] = (f"{first}: {promo_line_for(first, second, 'openers')}\n"
|
|
f"{second}: {promo_line_for(second, first, 'challenges')}\n"
|
|
f"{first}: {promo_line_for(first, second, 'retorts')}")
|
|
save_card(card)
|
|
print(f"Promo added before Match {index + 1} and saved.")
|
|
|
|
|
|
def resume_card(card: dict[str, Any]) -> None:
|
|
while True:
|
|
print(f"\n{show_location_text(card)} · {GAMES[card['game_id']]['title']} · {card['date']}")
|
|
print(f"Card: {card['path']}")
|
|
print(" 1. View card")
|
|
print(" 2. Record a match result")
|
|
print(" 3. Edit an unfinished match")
|
|
print(" 4. Generate a promo before the next match")
|
|
print(" 5. Back to main menu")
|
|
choice = ask_choice("Action", ["view", "result", "edit", "promo", "back"])
|
|
if choice == 0:
|
|
view_card(card)
|
|
elif choice == 1:
|
|
record_result(card)
|
|
elif choice == 2:
|
|
edit_match(card)
|
|
elif choice == 3:
|
|
generate_promo(card)
|
|
else:
|
|
return
|
|
|
|
|
|
def main() -> int:
|
|
print(color("WWF SmackDown! Match Card Maker", CYAN))
|
|
while True:
|
|
print("\n 1. Create a new three-match card")
|
|
print(" 2. Load / resume a saved card")
|
|
print(" 3. Exit")
|
|
try:
|
|
choice = ask_choice("Choose", ["new", "load", "exit"])
|
|
if choice == 0:
|
|
resume_card(create_card())
|
|
elif choice == 1:
|
|
path = select_card_path()
|
|
if path is None:
|
|
continue
|
|
try:
|
|
resume_card(load_card(path))
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
print(f"Could not load card: {exc}")
|
|
else:
|
|
return 0
|
|
except (KeyboardInterrupt, EOFError):
|
|
print("\nGoodbye.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|