LVPWB/smackdown_card_maker.py

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