Add attendance count and city listing for shows

This commit is contained in:
markmental 2026-10-26 15:25:08 -04:00
commit a0e8575ab2
3 changed files with 207 additions and 5 deletions

View file

@ -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": ["..."]}`.

View file

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

View file

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