diff --git a/README.md b/README.md index c3709e0..fb2f5d6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ Cards are saved as Markdown in `cards/`. Load a card to view it, record results, edit unfinished matches, or add a randomized promo before the next match. A card is saved after each edit or result, so you can stop and resume later. +When creating a show, enter a city and attendance or press `r` to randomize +both; entering `r` at the attendance prompt after typing a city randomizes only +the attendance. Venues span the US, Canada, Europe, and Japan. The location is +stored in the card and listed next to the show name in the card header and the +saved-card listing. + The promo generator gives selected wrestlers distinct voices and uses a generic style for wrestlers without a dedicated profile. Roster files share the schema `{"game_id": "sd1", "game": "...", "wrestlers": ["..."]}`. diff --git a/smackdown_card_maker.py b/smackdown_card_maker.py index d8a5f6a..a5b5374 100644 --- a/smackdown_card_maker.py +++ b/smackdown_card_maker.py @@ -70,6 +70,47 @@ MATCH_TYPES: dict[str, list[tuple[str, tuple[int, ...]]]] = { ], } +# 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" @@ -137,6 +178,8 @@ def save_card(card: dict[str, Any]) -> None: 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'])}", @@ -179,7 +222,7 @@ def load_card(path: Path) -> dict[str, Any]: 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"): + if key in ("game id", "date", "city", "attendance"): card[key.replace(" ", "_")] = value elif current is not None: mapping = {"title": "title", "type": "type", "sides": "sides", @@ -195,6 +238,12 @@ def load_card(path: Path) -> dict[str, Any]: "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}") @@ -204,6 +253,68 @@ def load_card(path: Path) -> dict[str, Any]: 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:") @@ -287,7 +398,7 @@ def select_card_path() -> Path | None: if candidates: print("\nSaved cards:") for index, path in enumerate(candidates, 1): - print(f" {index}. {path.name}") + 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: @@ -311,8 +422,10 @@ def create_card() -> dict[str, Any]: 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)), @@ -505,7 +618,7 @@ def header_lines(card: dict[str, Any], width: int) -> list[str]: if width < 62: title = GAMES[card["game_id"]]["title"] return [f"{color('WWF.com', RED)} {color(title, CYAN)}", - f"{card['date']} · {card['show']}", ""] + 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"] @@ -525,7 +638,7 @@ def header_lines(card: dict[str, Any], width: int) -> list[str]: if index == 0 and date_x > cursor: segments.append(" " * (date_x - cursor) + color(date_text, DIM)) output.append("".join(segments)) - output.append(color(short(card["show"], width - 2), CYAN)) + output.append(color(short(show_location_text(card), width - 2), CYAN)) return output @@ -1023,7 +1136,7 @@ def generate_promo(card: dict[str, Any]) -> None: def resume_card(card: dict[str, Any]) -> None: while True: - print(f"\n{card['show']} · {GAMES[card['game_id']]['title']} · {card['date']}") + 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") diff --git a/test_smackdown_card_maker.py b/test_smackdown_card_maker.py index 663d109..bfd0c05 100644 --- a/test_smackdown_card_maker.py +++ b/test_smackdown_card_maker.py @@ -1,3 +1,4 @@ +import contextlib import io import json import os @@ -113,6 +114,88 @@ class PromoVoiceTests(unittest.TestCase): self.assertNotIn(":", line) +class LocationTests(unittest.TestCase): + def ask_location(self, answers: list[str]) -> tuple[str, int]: + with contextlib.redirect_stdout(io.StringIO()), \ + patch("builtins.input", side_effect=answers): + return app.ask_location() + + def test_random_location_uses_known_city_range(self): + city, attendance = app.random_location() + self.assertIn(city, app.CITY_ATTENDANCE_RANGES) + low, high = app.CITY_ATTENDANCE_RANGES[city] + self.assertGreaterEqual(attendance, low) + self.assertLessEqual(attendance, high) + + def test_manual_city_and_attendance_are_used_as_entered(self): + self.assertEqual(self.ask_location(["Boston, MA", "15,450"]), ("Boston, MA", 15450)) + + def test_random_city_prompt_skips_attendance_question(self): + with contextlib.redirect_stdout(io.StringIO()), \ + patch("builtins.input", side_effect=["r"]): + city, attendance = app.ask_location() + self.assertIn(city, app.CITY_ATTENDANCE_RANGES) + low, high = app.CITY_ATTENDANCE_RANGES[city] + self.assertTrue(low <= attendance <= high) + + def test_random_attendance_follows_entered_city_range(self): + city, attendance = self.ask_location(["Osaka, Japan", "r"]) + self.assertEqual(city, "Osaka, Japan") + low, high = app.CITY_ATTENDANCE_RANGES["Osaka, Japan"] + self.assertTrue(low <= attendance <= high) + + def test_invalid_attendance_reprompts(self): + self.assertEqual(self.ask_location(["Tokyo, Japan", "abc", "0", "18000"]), + ("Tokyo, Japan", 18000)) + + def test_unknown_city_random_attendance_uses_default_range(self): + low, high = app.DEFAULT_ATTENDANCE_RANGE + for _ in range(30): + self.assertTrue(low <= app.random_attendance("Springfield, OR") <= high) + + def test_markdown_preserves_city_and_attendance(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "card.md" + card = MarkdownTests().make_card(path) + card["city"] = "Toronto, ON" + card["attendance"] = 17500 + app.save_card(card) + loaded = app.load_card(path) + self.assertEqual(loaded["city"], "Toronto, ON") + self.assertEqual(loaded["attendance"], 17500) + + def test_legacy_card_without_location_still_loads(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "legacy.md" + card = MarkdownTests().make_card(path) + app.save_card(card) + text = path.read_text(encoding="utf-8") + path.write_text("\n".join( + line for line in text.splitlines() + if not line.startswith(("- City:", "- Attendance:")) + ) + "\n", encoding="utf-8") + loaded = app.load_card(path) + self.assertEqual(loaded["city"], "") + self.assertIsNone(loaded["attendance"]) + + def test_header_and_listing_show_location_next_to_show_name(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "card.md" + card = MarkdownTests().make_card(path) + card["city"] = "Osaka, Japan" + card["attendance"] = 12000 + app.save_card(card) + with patch.object(app, "_render_image", return_value=([], 0)): + header = "\n".join(app.header_lines(card, 100)) + self.assertIn('The "Big" Show', header) + self.assertIn("Osaka, Japan", header) + self.assertIn("12,000 fans", header) + summary = app.card_summary(path) + self.assertIn('The "Big" Show', summary) + self.assertIn("Osaka, Japan", summary) + self.assertIn("12,000 fans", summary) + + class FlashRenderingTests(unittest.TestCase): def test_flash_updates_only_changed_rows_not_static_header(self): card = MarkdownTests().make_card(Path("card.md"))