Start code refactor, rename to LVPWB — Light Virtual Pro-Wrestling Booker
This commit is contained in:
parent
5f71ce8e6b
commit
82e77d2e56
21 changed files with 3228 additions and 3062 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
cards/
|
||||
seasons/
|
||||
edit/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
# SmackDown! Match Card Maker
|
||||
# LVPWB — Light Virtual Pro-Wrestling Booker
|
||||
|
||||
An interactive Python 3 terminal app for creating and running three-match cards
|
||||
using the uniform JSON roster files in this directory.
|
||||
|
||||
```sh
|
||||
python3 smackdown_card_maker.py
|
||||
python3 -m lvpwb
|
||||
```
|
||||
|
||||
Cards are saved as Markdown in `cards/`. Load a card to view it, record results,
|
||||
|
|
|
|||
30
lvpwb/__init__.py
Normal file
30
lvpwb/__init__.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Light Virtual Pro-Wrestling Booker (LVPWB).
|
||||
|
||||
The package re-exports the application's public functions and shared settings
|
||||
for convenient use from tests and interactive Python sessions.
|
||||
"""
|
||||
|
||||
from . import card_flows, cardfile, cli, constants, edit_mode, games, paths, promos
|
||||
from . import ratings, render, results, screenshots, season_booking, seasons
|
||||
from .paths import *
|
||||
from .constants import *
|
||||
from .prompts import *
|
||||
from .games import *
|
||||
from .edit_mode import *
|
||||
from .cardfile import *
|
||||
from .ratings import *
|
||||
from .results import *
|
||||
from .screenshots import *
|
||||
from .card_flows import *
|
||||
from .render import *
|
||||
from .seasons import *
|
||||
from .season_booking import *
|
||||
from .promos import *
|
||||
from .cli import *
|
||||
|
||||
# Retain convenient patch points for shared modules used by the application.
|
||||
import random
|
||||
import shutil
|
||||
|
||||
# Private rendering helper is intentionally patchable by the test suite.
|
||||
from .render import _render_image
|
||||
3
lvpwb/__main__.py
Normal file
3
lvpwb/__main__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
398
lvpwb/card_flows.py
Normal file
398
lvpwb/card_flows.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def show_name_for_brand(brand: str, shows: dict[str, str] | None = None) -> str:
|
||||
shows = shows or {}
|
||||
if brand == "Raw":
|
||||
return shows.get("Raw") or "Monday Night Raw"
|
||||
if brand == "SmackDown":
|
||||
return shows.get("SmackDown") or "Thursday Night SmackDown!"
|
||||
return shows.get("PPV") or "WWF PPV"
|
||||
|
||||
|
||||
def game_show_names(game_id: str) -> dict[str, str]:
|
||||
"""Resolved show names for a registered game (WWE names for built-ins)."""
|
||||
config = app.CUSTOM_GAME_CONFIGS.get(game_id, {})
|
||||
shows = config.get("shows") if isinstance(config, dict) else None
|
||||
return {brand: app.show_name_for_brand(brand, shows if isinstance(shows, dict) else None)
|
||||
for brand in app.SHOW_BRANDS}
|
||||
|
||||
|
||||
def is_ppv_card(card: dict[str, Any]) -> bool:
|
||||
if card.get("season_show") == "PPV":
|
||||
return True
|
||||
show = str(card.get("show", ""))
|
||||
folded = show.casefold()
|
||||
if "ppv" in folded or "pay-per-view" in folded:
|
||||
return True
|
||||
game_id = str(card.get("game_id", ""))
|
||||
if game_id:
|
||||
ppv_name = app.game_show_names(game_id).get("PPV", "")
|
||||
if ppv_name and folded == ppv_name.casefold():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def random_attendance(city: str) -> int:
|
||||
"""Pick an attendance within the city's typical venue range."""
|
||||
low, high = app.CITY_ATTENDANCE_RANGES.get(city, app.DEFAULT_ATTENDANCE_RANGE)
|
||||
return app.random.randint(low, high)
|
||||
|
||||
|
||||
def random_location() -> tuple[str, int]:
|
||||
city = app.random.choice(sorted(app.CITY_ATTENDANCE_RANGES))
|
||||
return city, app.random_attendance(city)
|
||||
|
||||
|
||||
def parse_attendance(raw: str) -> int | None:
|
||||
cleaned = re.sub(r"[,\s_]", "", raw)
|
||||
if not cleaned.isdigit():
|
||||
return None
|
||||
value = int(cleaned)
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def ask_location() -> tuple[str, int]:
|
||||
"""Prompt for city and attendance; 'r' randomizes both, or just attendance."""
|
||||
while True:
|
||||
city = app.ask("City (r randomizes city and attendance)").strip()
|
||||
if city.casefold() == "r":
|
||||
city, attendance = app.random_location()
|
||||
print(f"Random location: {city} · {attendance:,} fans")
|
||||
return city, attendance
|
||||
if city:
|
||||
break
|
||||
print("Enter a city, or 'r' to randomize.")
|
||||
while True:
|
||||
raw = app.ask(f"Attendance in {city} (r to randomize)").strip()
|
||||
if raw.casefold() == "r":
|
||||
attendance = app.random_attendance(city)
|
||||
print(f"Random attendance: {attendance:,} fans")
|
||||
return city, attendance
|
||||
attendance = app.parse_attendance(raw)
|
||||
if attendance is not None:
|
||||
return city, attendance
|
||||
print("Enter a whole number greater than zero, or 'r' to randomize.")
|
||||
|
||||
|
||||
def show_location_text(card: dict[str, Any]) -> str:
|
||||
"""Show name with its city and attendance, when the card has them."""
|
||||
parts = [card["show"]]
|
||||
city = str(card.get("city") or "").strip()
|
||||
attendance = card.get("attendance")
|
||||
if city:
|
||||
parts.append(city)
|
||||
if attendance:
|
||||
parts.append(f"{attendance:,} fans")
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def choose_game() -> str:
|
||||
app.refresh_custom_games()
|
||||
ids = list(app.GAMES)
|
||||
labels = [app.game_display_name(game_id) for game_id in ids]
|
||||
return ids[app.ask_choice("Choose a game", ids, labels=labels)]
|
||||
|
||||
|
||||
def choose_match_type(game_id: str) -> tuple[str, tuple[int, ...]]:
|
||||
options = app.match_type_definitions(game_id)
|
||||
labels = [f"{item['name']} ({' vs '.join(map(str, item['sides']))})"
|
||||
for item in options]
|
||||
selected = app.ask_choice("Choose match type", [item["name"] for item in options], labels=labels)
|
||||
definition = options[selected]
|
||||
return definition["name"], tuple(definition["sides"])
|
||||
|
||||
|
||||
def choose_sides(side_sizes: tuple[int, ...], roster: list[str]) -> list[list[str]]:
|
||||
total = sum(side_sizes)
|
||||
if total > len(roster):
|
||||
raise ValueError("This match needs more unique wrestlers than the roster provides.")
|
||||
roster = sorted(roster, key=str.casefold)
|
||||
print("\nParticipant selection: enter a roster number, 'r' for a random pick, or 'a' to "
|
||||
"randomly fill the remaining spots.")
|
||||
for index, name in enumerate(roster, 1):
|
||||
print(f" {index:>2}. {name}")
|
||||
selected: list[str] = []
|
||||
sides: list[list[str]] = []
|
||||
for side_number, size in enumerate(side_sizes, 1):
|
||||
side: list[str] = []
|
||||
for slot in range(1, size + 1):
|
||||
while True:
|
||||
choice = app.ask(f"Side {side_number}, wrestler {slot}/{size}")
|
||||
if choice.lower() == "a":
|
||||
remaining = [name for name in roster if name not in selected]
|
||||
needed = sum(side_sizes[side_number - 1:]) - len(side)
|
||||
picks = app.random.sample(remaining, needed)
|
||||
# Fill this side and all remaining sides, then return.
|
||||
take = size - len(side)
|
||||
side.extend(picks[:take])
|
||||
sides.append(side)
|
||||
rest = picks[take:]
|
||||
cursor = 0
|
||||
for remaining_size in side_sizes[side_number:]:
|
||||
sides.append(rest[cursor:cursor + remaining_size])
|
||||
cursor += remaining_size
|
||||
return sides
|
||||
if choice.lower() == "r":
|
||||
available = [name for name in roster if name not in selected]
|
||||
if available:
|
||||
pick = app.random.choice(available)
|
||||
break
|
||||
else:
|
||||
try:
|
||||
index = int(choice)
|
||||
pick = roster[index - 1]
|
||||
if index < 1 or index > len(roster):
|
||||
raise IndexError
|
||||
if pick in selected:
|
||||
print("Each wrestler can only be selected once in a match.")
|
||||
continue
|
||||
break
|
||||
except (ValueError, IndexError):
|
||||
print("Choose an available roster number, 'r', or 'a'.")
|
||||
side.append(pick)
|
||||
selected.append(pick)
|
||||
sides.append(side)
|
||||
return sides
|
||||
|
||||
|
||||
def create_match(number: int, game_id: str, roster: list[str]) -> dict[str, Any]:
|
||||
print(f"\n{'=' * 14} MATCH {number} {'=' * 14}")
|
||||
match_name, side_sizes = app.choose_match_type(game_id)
|
||||
title = app.ask("Optional match title (leave blank for none)")
|
||||
sides = app.choose_sides(side_sizes, roster)
|
||||
definition = app.match_type_definition(game_id, match_name)
|
||||
image = ""
|
||||
if app.game_has_screenshots(game_id) and app.ask("Attach a DuckStation screenshot? (y/N)").lower() == "y":
|
||||
chosen = app.choose_screenshot()
|
||||
if chosen is not None:
|
||||
image = str(chosen)
|
||||
return {"title": title, "type": match_name, "sides": sides,
|
||||
"winner_side": None, "finish": "", "promo": None, "image": image,
|
||||
"ranking_division": None, "title_defense": False, "rivalry_payoff": False,
|
||||
"match_time_minutes": None, "near_falls": 0, "finishers": 0, "rating": None,
|
||||
"base_rating": definition["base_rating"],
|
||||
"valid_outcomes": list(definition["outcomes"])}
|
||||
|
||||
|
||||
def select_card_path() -> Path | None:
|
||||
candidates = sorted(app.CARD_DIR.glob("*.md")) if app.CARD_DIR.is_dir() else []
|
||||
if candidates:
|
||||
print("\nSaved cards:")
|
||||
for index, path in enumerate(candidates, 1):
|
||||
print(f" {index}. {app.card_summary(path)} [{path.name}]")
|
||||
print(" Enter a number to load a listed card, or type a path.")
|
||||
raw = app.ask("Card path (blank to cancel)")
|
||||
if not raw:
|
||||
return None
|
||||
if raw.isdigit() and candidates and 1 <= int(raw) <= len(candidates):
|
||||
return candidates[int(raw) - 1]
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = app.ROOT / path
|
||||
return path
|
||||
|
||||
|
||||
def create_card() -> dict[str, Any]:
|
||||
game_id = app.choose_game()
|
||||
roster = app.load_roster(game_id)
|
||||
show = app.ask("Show name", app.game_show_names(game_id)["SmackDown"])
|
||||
while True:
|
||||
show_date = app.ask("Show date (YYYY-MM-DD)", date.today().isoformat())
|
||||
try:
|
||||
show_date = date.fromisoformat(show_date).isoformat()
|
||||
break
|
||||
except ValueError:
|
||||
print("Enter a valid date in YYYY-MM-DD format.")
|
||||
city, attendance = app.ask_location()
|
||||
card: dict[str, Any] = {
|
||||
"game_id": game_id, "game_title": app.game_title(game_id),
|
||||
"federation_name": app.federation_name(game_id),
|
||||
"title_definitions": app.title_definitions_for_game(game_id),
|
||||
"show": show, "date": show_date,
|
||||
"city": city, "attendance": attendance,
|
||||
"matches": [{"title": "", "type": "Singles", "sides": [], "winner_side": None,
|
||||
"finish": "", "promo": None, "image": "", "ranking_division": None,
|
||||
"title_defense": False, "rivalry_payoff": False,
|
||||
"match_time_minutes": None, "near_falls": 0, "finishers": 0,
|
||||
"rating": None} for _ in range(3)],
|
||||
"path": str(app.default_card_path(show, show_date)),
|
||||
}
|
||||
app.save_card(card)
|
||||
print(f"\nCard started: {card['path']}")
|
||||
for number in range(1, 4):
|
||||
card["matches"][number - 1] = app.create_match(number, game_id, roster)
|
||||
app.save_card(card)
|
||||
print(f"\nCard saved to {card['path']}")
|
||||
return card
|
||||
|
||||
|
||||
def random_match(game_id: str, roster: list[str], used: set[str],
|
||||
profile: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
definition = app.random.choice(app.match_type_definitions(game_id, profile))
|
||||
match_name, side_sizes = definition["name"], tuple(definition["sides"])
|
||||
used_folded = {name.casefold() for name in used}
|
||||
available = [name for name in roster if name.casefold() not in used_folded]
|
||||
if len(available) < sum(side_sizes):
|
||||
available = list(roster)
|
||||
picks = app.random.sample(available, sum(side_sizes))
|
||||
sides: list[list[str]] = []
|
||||
cursor = 0
|
||||
for size in side_sizes:
|
||||
sides.append(picks[cursor:cursor + size])
|
||||
cursor += size
|
||||
used.update(name.casefold() for name in picks)
|
||||
return {
|
||||
"title": app.random.choice(["", "Opening Match", "Grudge Match", "Main Event"]),
|
||||
"type": match_name, "sides": sides, "winner_side": None, "finish": "",
|
||||
"promo": None, "image": "", "ranking_division": None,
|
||||
"title_defense": False, "rivalry_payoff": False,
|
||||
"match_time_minutes": None, "near_falls": 0, "finishers": 0, "rating": None,
|
||||
"base_rating": definition["base_rating"],
|
||||
"valid_outcomes": list(definition["outcomes"]),
|
||||
}
|
||||
|
||||
|
||||
def create_random_card() -> dict[str, Any]:
|
||||
"""Create a randomized three-match show after the user selects a game."""
|
||||
game_id = app.choose_game()
|
||||
roster = app.load_roster(game_id)
|
||||
show = app.random.choice(list(app.game_show_names(game_id).values()))
|
||||
city, attendance = app.random_location()
|
||||
show_date = date.today().isoformat()
|
||||
used: set[str] = set()
|
||||
card: dict[str, Any] = {
|
||||
"game_id": game_id, "game_title": app.game_title(game_id),
|
||||
"federation_name": app.federation_name(game_id),
|
||||
"title_definitions": app.title_definitions_for_game(game_id),
|
||||
"show": show, "date": show_date,
|
||||
"city": city, "attendance": attendance,
|
||||
"matches": [app.random_match(game_id, roster, used) for _ in range(3)],
|
||||
"path": str(app.default_card_path(show, show_date)),
|
||||
}
|
||||
app.save_card(card)
|
||||
print(f"\nRandom card saved to {card['path']}")
|
||||
return card
|
||||
|
||||
|
||||
def pending_indices(card: dict[str, Any]) -> list[int]:
|
||||
return [index for index, match in enumerate(card["matches"])
|
||||
if match.get("sides") and match.get("winner_side") is None]
|
||||
|
||||
|
||||
def editable_indices(card: dict[str, Any]) -> list[int]:
|
||||
return [index for index, match in enumerate(card["matches"])
|
||||
if match.get("winner_side") is None]
|
||||
|
||||
|
||||
def choose_match_index(card: dict[str, Any], indices: list[int], prompt: str) -> int:
|
||||
labels: list[str] = []
|
||||
for index in indices:
|
||||
match = card["matches"][index]
|
||||
title = f" — {match['title']}" if match.get("title") else ""
|
||||
state = " · not configured" if not match.get("sides") else ""
|
||||
labels.append(f"Match {index + 1}: {match['type']}{title}{state}")
|
||||
selected = app.ask_choice(prompt, indices, labels=labels)
|
||||
return indices[selected]
|
||||
|
||||
|
||||
def season_card_exit_label(card: dict[str, Any]) -> str:
|
||||
if not card.get("season_id"):
|
||||
return "Back to main menu"
|
||||
complete = all(match.get("winner_side") is not None for match in card["matches"])
|
||||
return "Advance to Next Show" if complete else "Return to Season Menu"
|
||||
|
||||
|
||||
def choose_ranking_division(game_id: str, current: str | None = None,
|
||||
allow_unranked: bool = True,
|
||||
title_definitions: list[dict[str, str]] | None = None) -> str | None:
|
||||
titles = title_definitions or app.title_definitions_for_game(game_id)
|
||||
options: list[str | None] = ([None] if allow_unranked else []) + [title["name"] for title in titles]
|
||||
labels = ["Unranked / Others pool" if item is None else item for item in options]
|
||||
default = options.index(current) + 1 if current in options else None
|
||||
return options[app.ask_choice("Assign ranking division", options,
|
||||
default=default, labels=labels)]
|
||||
|
||||
|
||||
def edit_match(card: dict[str, Any]) -> None:
|
||||
editable = app.editable_indices(card)
|
||||
if not editable:
|
||||
print("There are no unfinished matches to edit.")
|
||||
return
|
||||
index = app.choose_match_index(card, editable, "Choose a match to edit")
|
||||
match = card["matches"][index]
|
||||
roster = app.load_roster(card["game_id"])
|
||||
options = app.match_type_definitions(card["game_id"])
|
||||
names = [item["name"] for item in options] + ["Keep current"]
|
||||
labels = [f"{item['name']} ({' vs '.join(map(str, item['sides']))})"
|
||||
for item in options]
|
||||
labels.append(f"Keep current: {match['type']}")
|
||||
selected = app.ask_choice("Choose match type", names, labels=labels)
|
||||
old_sizes = tuple(len(side) for side in match.get("sides", []))
|
||||
if selected < len(options):
|
||||
match["type"] = options[selected]["name"]
|
||||
side_sizes = tuple(options[selected]["sides"])
|
||||
else:
|
||||
found = next((entry for entry in options if entry["name"] == match["type"]), options[0])
|
||||
side_sizes = tuple(found["sides"])
|
||||
needs_participants = old_sizes != side_sizes
|
||||
if needs_participants or app.ask("Change participants too? (y/N)").lower() == "y":
|
||||
match["sides"] = app.choose_sides(side_sizes, roster)
|
||||
profile = app.match_type_definition(card["game_id"], match["type"])
|
||||
match["base_rating"] = profile["base_rating"]
|
||||
match["valid_outcomes"] = list(profile["outcomes"])
|
||||
current_title = match.get("title", "")
|
||||
match["title"] = app.ask(f"Match title (blank removes; current: {current_title or 'none'})")
|
||||
if card.get("season_id"):
|
||||
match["ranking_division"] = app.choose_ranking_division(
|
||||
card["game_id"], match.get("ranking_division"), allow_unranked=True,
|
||||
title_definitions=card.get("title_definitions"),
|
||||
)
|
||||
if match["ranking_division"]:
|
||||
match["title_defense"] = app.ask_yes_no(
|
||||
"Is this a title defense?", default=bool(match.get("title_defense"))
|
||||
)
|
||||
else:
|
||||
match["title_defense"] = False
|
||||
app.save_card(card)
|
||||
print(f"Match updated and saved to {card['path']}.")
|
||||
|
||||
|
||||
def resume_card(card: dict[str, Any]) -> bool:
|
||||
if card.get("season_id"):
|
||||
app.sync_season_card_results(card)
|
||||
while True:
|
||||
print(f"\n{app.show_location_text(card)} · {card.get('federation_name', app.federation_name(card['game_id']))}"
|
||||
f" · {card.get('game_title', app.game_title(card['game_id']))} · {card['date']}")
|
||||
if card.get("ppv_rating") is not None:
|
||||
print(f"PPV Rating: {float(card['ppv_rating']):.1f}%")
|
||||
print(f"Card: {card['path']}")
|
||||
choice = app.ask_choice(
|
||||
"Card action",
|
||||
["view", "result", "edit", "promo", "image", "simulate all", "exit"],
|
||||
labels=["View card", "Record a match result", "Edit an unfinished match",
|
||||
"Generate a promo before the next match", "Attach/edit a match image",
|
||||
"Auto-simulate all remaining matches", app.season_card_exit_label(card)],
|
||||
)
|
||||
if choice == 0:
|
||||
app.view_card(card)
|
||||
elif choice == 1:
|
||||
app.record_result(card)
|
||||
elif choice == 2:
|
||||
app.edit_match(card)
|
||||
elif choice == 3:
|
||||
app.generate_promo(card)
|
||||
elif choice == 4:
|
||||
app.attach_match_image(card)
|
||||
elif choice == 5:
|
||||
app.simulate_pending_matches(card)
|
||||
else:
|
||||
return bool(card.get("season_id"))
|
||||
|
||||
__all__ = ['ask_location', 'choose_game', 'choose_match_index', 'choose_match_type', 'choose_ranking_division', 'choose_sides', 'create_card', 'create_match', 'create_random_card', 'edit_match', 'editable_indices', 'game_show_names', 'is_ppv_card', 'parse_attendance', 'pending_indices', 'random_attendance', 'random_location', 'random_match', 'resume_card', 'season_card_exit_label', 'select_card_path', 'show_location_text', 'show_name_for_brand']
|
||||
191
lvpwb/cardfile.py
Normal file
191
lvpwb/cardfile.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def default_card_path(show: str, show_date: str) -> Path:
|
||||
app.CARD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"{show_date}-{app.slugify(show)}"
|
||||
path = app.CARD_DIR / f"{stem}.md"
|
||||
suffix = 2
|
||||
while path.exists():
|
||||
path = app.CARD_DIR / f"{stem}-{suffix}.md"
|
||||
suffix += 1
|
||||
return path
|
||||
|
||||
|
||||
def save_card(card: dict[str, Any]) -> None:
|
||||
path = Path(card["path"])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
card.setdefault("game_title", app.game_title(card["game_id"]))
|
||||
card.setdefault("federation_name", app.federation_name(card["game_id"]))
|
||||
card.setdefault("title_definitions", app.title_definitions_for_game(card["game_id"]))
|
||||
ppv_rating = app.calculate_ppv_rating(card)
|
||||
card["ppv_rating"] = ppv_rating
|
||||
lines = [
|
||||
f"# Show: {json.dumps(card['show'], ensure_ascii=False)}",
|
||||
f"- City: {json.dumps(card.get('city', ''), ensure_ascii=False)}",
|
||||
f"- Attendance: {json.dumps(card.get('attendance'))}",
|
||||
f"- Game ID: {json.dumps(card['game_id'])}",
|
||||
f"- Game: {json.dumps(card['game_title'], ensure_ascii=False)}",
|
||||
f"- Federation: {json.dumps(card['federation_name'], ensure_ascii=False)}",
|
||||
f"- Title Definitions: {json.dumps(card['title_definitions'], ensure_ascii=False)}",
|
||||
f"- Date: {json.dumps(card['date'])}",
|
||||
]
|
||||
if app.is_ppv_card(card):
|
||||
lines.append(f"- PPV Rating: {json.dumps(ppv_rating)}")
|
||||
if card.get("season_id"):
|
||||
lines.extend([
|
||||
f"- Season ID: {json.dumps(card['season_id'])}",
|
||||
f"- Season Show: {json.dumps(card.get('season_show', ''))}",
|
||||
f"- Season Show Number: {json.dumps(card.get('season_show_number'))}",
|
||||
])
|
||||
lines.append("")
|
||||
for number, match in enumerate(card["matches"], 1):
|
||||
lines.append(f"## Match {number}")
|
||||
lines.append(f"- Title: {json.dumps(match.get('title', ''), ensure_ascii=False)}")
|
||||
lines.append(f"- Type: {json.dumps(match['type'], ensure_ascii=False)}")
|
||||
lines.append(f"- Status: {json.dumps('Complete' if match.get('winner_side') is not None else 'Pending')}")
|
||||
lines.append(f"- Sides: {json.dumps(match['sides'], ensure_ascii=False)}")
|
||||
lines.append(f"- Winner side: {json.dumps(match.get('winner_side'))}")
|
||||
lines.append(f"- Finish: {json.dumps(match.get('finish', ''), ensure_ascii=False)}")
|
||||
lines.append(f"- Promo: {json.dumps(match.get('promo'), ensure_ascii=False)}")
|
||||
lines.append(f"- Image: {json.dumps(match.get('image', ''), ensure_ascii=False)}")
|
||||
lines.append(f"- Ranking Division: {json.dumps(match.get('ranking_division'))}")
|
||||
lines.append(f"- Title Defense: {json.dumps(bool(match.get('title_defense', False)))}")
|
||||
lines.append(f"- Rivalry Payoff: {json.dumps(bool(match.get('rivalry_payoff', False)))}")
|
||||
lines.append(f"- Match Time Minutes: {json.dumps(match.get('match_time_minutes'))}")
|
||||
lines.append(f"- Near Falls: {json.dumps(match.get('near_falls', 0))}")
|
||||
lines.append(f"- Finishers: {json.dumps(match.get('finishers', 0))}")
|
||||
lines.append(f"- Match Rating: {json.dumps(match.get('rating'))}")
|
||||
lines.append(f"- Base Rating: {json.dumps(match.get('base_rating'))}")
|
||||
lines.append(f"- Valid Outcomes: {json.dumps(match.get('valid_outcomes'), ensure_ascii=False)}")
|
||||
lines.append("")
|
||||
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
||||
card["path"] = str(path)
|
||||
|
||||
|
||||
def load_card(path: Path) -> dict[str, Any]:
|
||||
"""Load a card written in the app's readable Markdown format."""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
card: dict[str, Any] = {"path": str(path), "matches": []}
|
||||
current: dict[str, Any] | None = None
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# Show: "):
|
||||
card["show"] = json.loads(line[len("# Show: "):])
|
||||
continue
|
||||
match_header = re.fullmatch(r"## Match (\d+)", line)
|
||||
if match_header:
|
||||
current = {"title": "", "type": "Singles", "sides": [], "winner_side": None,
|
||||
"finish": "", "promo": None, "image": "", "ranking_division": None,
|
||||
"title_defense": False, "rivalry_payoff": False,
|
||||
"match_time_minutes": None, "near_falls": 0, "finishers": 0,
|
||||
"rating": None, "base_rating": None, "valid_outcomes": None}
|
||||
card["matches"].append(current)
|
||||
continue
|
||||
field = re.match(r"^- ([A-Za-z ]+): (.*)$", line)
|
||||
if not field:
|
||||
continue
|
||||
key, raw = field.group(1).strip().lower(), field.group(2)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid Markdown card field '{key}' in {path}") from exc
|
||||
if key in ("game id", "game", "federation", "title definitions", "date", "city", "attendance",
|
||||
"season id", "season show", "season show number", "ppv rating"):
|
||||
metadata_key = {"game": "game_title", "game id": "game_id"}.get(
|
||||
key, key.replace(" ", "_")
|
||||
)
|
||||
card[metadata_key] = value
|
||||
elif current is not None:
|
||||
mapping = {"title": "title", "type": "type", "sides": "sides",
|
||||
"winner side": "winner_side", "finish": "finish", "promo": "promo",
|
||||
"image": "image", "ranking division": "ranking_division",
|
||||
"title defense": "title_defense", "rivalry payoff": "rivalry_payoff",
|
||||
"match time minutes": "match_time_minutes", "near falls": "near_falls",
|
||||
"finishers": "finishers", "match rating": "rating",
|
||||
"base rating": "base_rating", "valid outcomes": "valid_outcomes"}
|
||||
if key in mapping:
|
||||
current[mapping[key]] = value
|
||||
if card.get("game_id") not in app.GAMES:
|
||||
raise ValueError(f"Unknown or missing game ID in {path}")
|
||||
if len(card["matches"]) > 3:
|
||||
raise ValueError(f"A card cannot contain more than three matches: {path}")
|
||||
while len(card["matches"]) < 3:
|
||||
card["matches"].append({"title": "", "type": "Singles", "sides": [],
|
||||
"winner_side": None, "finish": "", "promo": None,
|
||||
"image": "", "ranking_division": None,
|
||||
"title_defense": False, "rivalry_payoff": False,
|
||||
"match_time_minutes": None, "near_falls": 0,
|
||||
"finishers": 0, "rating": None, "base_rating": None,
|
||||
"valid_outcomes": None})
|
||||
if not card.get("show") or not card.get("date"):
|
||||
raise ValueError(f"Card is missing its show name or date: {path}")
|
||||
card.setdefault("city", "")
|
||||
card.setdefault("attendance", None)
|
||||
if card["attendance"] is not None and (
|
||||
not isinstance(card["attendance"], int) or card["attendance"] <= 0
|
||||
):
|
||||
raise ValueError(f"Card has an invalid attendance: {path}")
|
||||
for key in ("season_show_number",):
|
||||
value = card.get(key)
|
||||
if value is not None and (not isinstance(value, int) or value < 1):
|
||||
raise ValueError(f"Card has invalid season metadata: {path}")
|
||||
for match in card["matches"]:
|
||||
if any(not side for side in match.get("sides", [])):
|
||||
raise ValueError(f"A match is missing participants: {path}")
|
||||
winner = match.get("winner_side")
|
||||
if winner is not None and (not isinstance(winner, int) or not 0 <= winner < len(match["sides"])):
|
||||
raise ValueError(f"A match has an invalid winner side: {path}")
|
||||
division = match.get("ranking_division")
|
||||
card_titles = card.get("title_definitions") or app.title_definitions_for_game(card["game_id"])
|
||||
if division is not None and division not in [title["name"] for title in card_titles]:
|
||||
raise ValueError(f"A match has an unknown ranking division: {path}")
|
||||
if match.get("title_defense") and division is None:
|
||||
raise ValueError(f"A title defense must have a ranking division: {path}")
|
||||
minutes = match.get("match_time_minutes")
|
||||
if minutes is not None and (
|
||||
not isinstance(minutes, (int, float)) or not math.isfinite(minutes) or minutes < 0
|
||||
):
|
||||
raise ValueError(f"A match has an invalid duration: {path}")
|
||||
for counter in ("near_falls", "finishers"):
|
||||
value = match.get(counter, 0)
|
||||
if not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"A match has an invalid {counter.replace('_', ' ')} count: {path}")
|
||||
rating = match.get("rating")
|
||||
if rating is not None and (
|
||||
not isinstance(rating, (int, float)) or not math.isfinite(rating) or not 0 <= rating <= 100
|
||||
):
|
||||
raise ValueError(f"A match has an invalid rating: {path}")
|
||||
base_rating = match.get("base_rating")
|
||||
if base_rating is not None and (
|
||||
not isinstance(base_rating, (int, float)) or not math.isfinite(base_rating)
|
||||
or not 0 <= base_rating <= 100
|
||||
):
|
||||
raise ValueError(f"A match has an invalid base rating: {path}")
|
||||
outcomes = match.get("valid_outcomes")
|
||||
if outcomes is not None and (
|
||||
not isinstance(outcomes, list) or not outcomes
|
||||
or any(not isinstance(outcome, str) or not outcome.strip() for outcome in outcomes)
|
||||
):
|
||||
raise ValueError(f"A match has invalid outcome options: {path}")
|
||||
card.setdefault("game_title", app.game_title(card["game_id"]))
|
||||
card.setdefault("federation_name", app.federation_name(card["game_id"]))
|
||||
card.setdefault("title_definitions", app.title_definitions_for_game(card["game_id"]))
|
||||
card["ppv_rating"] = app.calculate_ppv_rating(card)
|
||||
return card
|
||||
|
||||
|
||||
def card_summary(path: Path) -> str:
|
||||
"""One-line listing for a saved card; falls back to the file stem."""
|
||||
try:
|
||||
return app.show_location_text(app.load_card(path))
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
return path.stem
|
||||
|
||||
__all__ = ['card_summary', 'default_card_path', 'load_card', 'save_card']
|
||||
43
lvpwb/cli.py
Normal file
43
lvpwb/cli.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def main() -> int:
|
||||
app.refresh_custom_games()
|
||||
print(app.color("LVPWB — Light Virtual Pro-Wrestling Booker", app.CYAN))
|
||||
while True:
|
||||
try:
|
||||
choice = app.ask_choice(
|
||||
"Main menu", ["new", "random", "season", "load", "byog", "exit"],
|
||||
labels=["Create a new three-match card", "Create a fully randomized show",
|
||||
"Season Mode", "Load / resume a saved card", "BYOG / Manage Games",
|
||||
"Exit"],
|
||||
)
|
||||
if choice == 0:
|
||||
app.resume_card(app.create_card())
|
||||
elif choice == 1:
|
||||
app.resume_card(app.create_random_card())
|
||||
elif choice == 2:
|
||||
app.season_mode()
|
||||
elif choice == 3:
|
||||
path = app.select_card_path()
|
||||
if path is None:
|
||||
continue
|
||||
try:
|
||||
card = app.load_card(path)
|
||||
go_to_season = app.resume_card(card)
|
||||
if go_to_season:
|
||||
app.run_season(app.load_season(str(card["season_id"])))
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"Could not load card: {exc}")
|
||||
elif choice == 4:
|
||||
app.manage_games()
|
||||
else:
|
||||
return 0
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nGoodbye.")
|
||||
return 0
|
||||
|
||||
__all__ = ['main']
|
||||
184
lvpwb/constants.py
Normal file
184
lvpwb/constants.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
PS1_GAMES = {"sd1", "sd2"}
|
||||
|
||||
SEASON_CYCLE = ("Raw", "SmackDown", "PPV")
|
||||
|
||||
SHOW_BRANDS = SEASON_CYCLE
|
||||
|
||||
DEFAULT_CUSTOM_SHOWS = {
|
||||
"Raw": "Weekly Show #1",
|
||||
"SmackDown": "Weekly Show #2",
|
||||
"PPV": "Big Pay-Per-View",
|
||||
}
|
||||
|
||||
CHAMPIONSHIPS = (
|
||||
"Tag Team",
|
||||
"World Wrestling Federation",
|
||||
"Intercontinental",
|
||||
"European",
|
||||
"Hardcore",
|
||||
"Light-Heavyweight",
|
||||
"Women's",
|
||||
)
|
||||
|
||||
PPV_TITLE_WEIGHT = 1.5
|
||||
|
||||
PPV_RIVALRY_WEIGHT = 1.5
|
||||
|
||||
MATCH_BASE_RATINGS = {
|
||||
"Singles": 4.5,
|
||||
"Tag Team": 4.5,
|
||||
"Tornado Tag": 4.5,
|
||||
"Handicap": 4.5,
|
||||
"Triple Threat": 5.5,
|
||||
"Fatal 4-Way": 5.5,
|
||||
"Battle Royal": 5.5,
|
||||
"Cage": 6.5,
|
||||
"Ladder": 6.5,
|
||||
"Hardcore": 7.5,
|
||||
"Table": 7.5,
|
||||
"TLC": 7.5,
|
||||
"Hell in a Cell": 9.5,
|
||||
"I Quit": 9.5,
|
||||
}
|
||||
|
||||
MATCH_TIME_BONUS_PER_MINUTE = 0.1
|
||||
|
||||
MATCH_TIME_BONUS_CAP = 5.0
|
||||
|
||||
MATCH_ACTION_BONUS_CAP = 10.0
|
||||
|
||||
FINISHER_BONUS = 0.5
|
||||
|
||||
NEAR_FALL_BONUS = 0.2
|
||||
|
||||
TITLE_MATCH_MULTIPLIER = 1.5
|
||||
|
||||
PPV_MATCH_WEIGHTS = (0.2, 0.3, 0.5)
|
||||
|
||||
MATCH_OUTCOME_OPTIONS = {
|
||||
"Ladder": ("Item retrieval",),
|
||||
"TLC": ("Item retrieval",),
|
||||
"Table": ("Table break",),
|
||||
"Battle Royal": ("Last wrestler standing",),
|
||||
"Cage": ("Escape the cage",),
|
||||
}
|
||||
|
||||
STANDARD_MATCH_OUTCOMES = ("Pinfall", "Submission")
|
||||
|
||||
WOMEN_ROSTERS = {
|
||||
"sd1": {"Debra", "Tori", "Chyna"},
|
||||
"sd2": {"Ivory", "Jacqueline", "The Kat", "Lita", "Chyna",
|
||||
"Stephanie McMahon", "Tori", "Trish Stratus"},
|
||||
"sd3": {"Ivory", "Lita", "Molly Holly", "Stephanie McMahon-Helmsley",
|
||||
"Trish Stratus"},
|
||||
}
|
||||
|
||||
GAMES: dict[str, dict[str, str]] = {
|
||||
"sd1": {"title": "WWF SmackDown!", "roster": "smackdown1roster.json"},
|
||||
"sd2": {"title": "WWF SmackDown! 2: Know Your Role", "roster": "smackdown2roster.json"},
|
||||
"sd3": {"title": "WWF SmackDown! Just Bring It", "roster": "smackdown3roster.json"},
|
||||
}
|
||||
|
||||
MATCH_TYPES: dict[str, list[tuple[str, tuple[int, ...]]]] = {
|
||||
"sd1": [
|
||||
("Singles", (1, 1)),
|
||||
("Tag Team", (2, 2)),
|
||||
("Handicap", (2, 1)),
|
||||
("Triple Threat", (1, 1, 1)),
|
||||
("Fatal 4-Way", (1, 1, 1, 1)),
|
||||
("Battle Royal", (1, 1, 1, 1)),
|
||||
("Cage", (1, 1)),
|
||||
("Hardcore", (1, 1)),
|
||||
],
|
||||
"sd2": [
|
||||
("Singles", (1, 1)),
|
||||
("Tag Team", (2, 2)),
|
||||
("Tornado Tag", (2, 2)),
|
||||
("Handicap", (2, 1)),
|
||||
("Triple Threat", (1, 1, 1)),
|
||||
("Fatal 4-Way", (1, 1, 1, 1)),
|
||||
("Battle Royal", (1, 1, 1, 1)),
|
||||
("Cage", (1, 1)),
|
||||
("Hardcore", (1, 1)),
|
||||
("Ladder", (1, 1)),
|
||||
("Table", (1, 1)),
|
||||
("Hell in a Cell", (1, 1)),
|
||||
],
|
||||
"sd3": [
|
||||
("Singles", (1, 1)),
|
||||
("Tag Team", (2, 2)),
|
||||
("Tornado Tag", (2, 2)),
|
||||
("Handicap", (2, 1)),
|
||||
("Triple Threat", (1, 1, 1)),
|
||||
("Fatal 4-Way", (1, 1, 1, 1)),
|
||||
("Battle Royal", (1, 1, 1, 1)),
|
||||
("Cage", (1, 1)),
|
||||
("Hardcore", (1, 1)),
|
||||
("Ladder", (1, 1)),
|
||||
("Table", (1, 1)),
|
||||
("TLC", (2, 2)),
|
||||
("Hell in a Cell", (1, 1)),
|
||||
],
|
||||
}
|
||||
|
||||
BUILTIN_GAME_IDS = frozenset(GAMES)
|
||||
|
||||
CUSTOM_GAME_CONFIGS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
CITY_ATTENDANCE_RANGES: dict[str, tuple[int, int]] = {
|
||||
# United States
|
||||
"New York, NY": (17000, 19800),
|
||||
"Boston, MA": (14500, 17500),
|
||||
"Philadelphia, PA": (15000, 19500),
|
||||
"Chicago, IL": (18000, 22000),
|
||||
"Detroit, MI": (15000, 20000),
|
||||
"Atlanta, GA": (12000, 17000),
|
||||
"Dallas, TX": (14000, 18000),
|
||||
"Los Angeles, CA": (15000, 19000),
|
||||
"San Francisco, CA": (13000, 16000),
|
||||
"Seattle, WA": (12000, 15500),
|
||||
"Minneapolis, MN": (12000, 16500),
|
||||
"Nashville, TN": (10000, 15000),
|
||||
"Miami, FL": (12500, 15500),
|
||||
# Canada
|
||||
"Toronto, ON": (15000, 19000),
|
||||
"Montreal, QC": (16000, 20000),
|
||||
"Vancouver, BC": (14000, 17000),
|
||||
"Calgary, AB": (9500, 13000),
|
||||
# Europe
|
||||
"London, England": (12000, 17000),
|
||||
"Manchester, England": (9000, 12500),
|
||||
"Dublin, Ireland": (9000, 13000),
|
||||
"Paris, France": (13000, 17000),
|
||||
"Berlin, Germany": (11000, 14500),
|
||||
"Amsterdam, Netherlands": (9000, 12000),
|
||||
"Madrid, Spain": (10000, 13500),
|
||||
"Milan, Italy": (9000, 12500),
|
||||
# Japan
|
||||
"Tokyo, Japan": (15000, 45000),
|
||||
"Yokohama, Japan": (12000, 18000),
|
||||
"Osaka, Japan": (11000, 17000),
|
||||
"Nagoya, Japan": (9000, 12000),
|
||||
"Sapporo, Japan": (8000, 11000),
|
||||
"Fukuoka, Japan": (8000, 11000),
|
||||
}
|
||||
|
||||
DEFAULT_ATTENDANCE_RANGE = (7000, 15000)
|
||||
|
||||
RESET = "\033[0m"
|
||||
|
||||
RED = "\033[31m"
|
||||
|
||||
YELLOW = "\033[33m"
|
||||
|
||||
CYAN = "\033[36m"
|
||||
|
||||
DIM = "\033[2m"
|
||||
|
||||
__all__ = ['BUILTIN_GAME_IDS', 'CHAMPIONSHIPS', 'CITY_ATTENDANCE_RANGES', 'CUSTOM_GAME_CONFIGS', 'CYAN', 'DEFAULT_ATTENDANCE_RANGE', 'DEFAULT_CUSTOM_SHOWS', 'DIM', 'FINISHER_BONUS', 'GAMES', 'MATCH_ACTION_BONUS_CAP', 'MATCH_BASE_RATINGS', 'MATCH_OUTCOME_OPTIONS', 'MATCH_TIME_BONUS_CAP', 'MATCH_TIME_BONUS_PER_MINUTE', 'MATCH_TYPES', 'NEAR_FALL_BONUS', 'PPV_MATCH_WEIGHTS', 'PPV_RIVALRY_WEIGHT', 'PPV_TITLE_WEIGHT', 'PS1_GAMES', 'RED', 'RESET', 'SEASON_CYCLE', 'SHOW_BRANDS', 'STANDARD_MATCH_OUTCOMES', 'TITLE_MATCH_MULTIPLIER', 'WOMEN_ROSTERS', 'YELLOW']
|
||||
323
lvpwb/edit_mode.py
Normal file
323
lvpwb/edit_mode.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def show_roster(config: dict[str, Any]) -> None:
|
||||
names = sorted(config["roster"], key=str.casefold)
|
||||
if not names:
|
||||
print("The roster is empty.")
|
||||
return
|
||||
print(f"\nRoster ({len(names)} wrestlers):")
|
||||
for position, name in enumerate(names, 1):
|
||||
print(f" {position}. {name}")
|
||||
|
||||
|
||||
def edit_custom_roster(config: dict[str, Any]) -> None:
|
||||
while True:
|
||||
choices = ["Add wrestler", "Remove wrestler", "View roster", "Done"]
|
||||
labels = ["Add a wrestler", "Remove a wrestler", "View roster (read-only)",
|
||||
f"Done ({len(config['roster'])} listed)"]
|
||||
action = app.ask_choice("Edit roster", choices, labels=labels)
|
||||
if action == 3:
|
||||
break
|
||||
if action == 0:
|
||||
name = app.ask("Wrestler name").strip()
|
||||
if not name:
|
||||
print("Wrestler name cannot be blank.")
|
||||
elif any(existing.casefold() == name.casefold() for existing in config["roster"]):
|
||||
print("That wrestler is already in this roster.")
|
||||
else:
|
||||
config["roster"].append(name)
|
||||
elif action == 2:
|
||||
app.show_roster(config)
|
||||
elif not config["roster"]:
|
||||
print("The roster is empty.")
|
||||
else:
|
||||
index = app.ask_choice("Remove wrestler", config["roster"])
|
||||
removed = config["roster"].pop(index)
|
||||
config["women_roster"] = [name for name in config["women_roster"]
|
||||
if name.casefold() != removed.casefold()]
|
||||
|
||||
|
||||
def edit_custom_match_types(config: dict[str, Any]) -> None:
|
||||
while True:
|
||||
existing = config["match_types"]
|
||||
choices = list(range(len(existing))) + ["add", "remove", "done"]
|
||||
labels = [f"{item['name']} ({' vs '.join(map(str, item['sides']))}, "
|
||||
f"base {item['base_rating']:.1f}%)" for item in existing]
|
||||
labels.extend(["Add a match type", "Remove a match type", "Done"])
|
||||
action = app.ask_choice("Edit match types", choices, labels=labels)
|
||||
if action == len(existing) + 2:
|
||||
return
|
||||
if action == len(existing) + 1:
|
||||
if not existing:
|
||||
print("Add at least one match type before removing one.")
|
||||
continue
|
||||
remove_index = app.ask_choice("Remove which match type", list(range(len(existing))),
|
||||
labels=[item["name"] for item in existing])
|
||||
existing.pop(remove_index)
|
||||
continue
|
||||
if action == len(existing):
|
||||
name = app.ask("New match type name").strip()
|
||||
if not name:
|
||||
print("Match type name cannot be blank.")
|
||||
continue
|
||||
if any(item["name"].casefold() == name.casefold() for item in existing):
|
||||
print("That match type already exists.")
|
||||
continue
|
||||
side_sizes = app.parse_side_sizes(app.ask("Wrestler counts per side, comma-separated (e.g. 1,1)"))
|
||||
if side_sizes is None:
|
||||
print("Enter at least two positive side sizes, such as 1,1 or 2,2.")
|
||||
continue
|
||||
if sum(side_sizes) > len(config["roster"]):
|
||||
print("This match type needs more wrestlers than the roster contains.")
|
||||
continue
|
||||
base_default = app.MATCH_BASE_RATINGS.get(name, 4.5)
|
||||
while True:
|
||||
try:
|
||||
base_rating = float(app.ask("Base rating percentage", str(base_default)))
|
||||
if math.isfinite(base_rating) and 0 <= base_rating <= 100:
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
print("Enter a rating from 0 to 100.")
|
||||
default_outcomes = app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES)
|
||||
raw_outcomes = app.ask("Legal outcomes, comma-separated", ", ".join(default_outcomes))
|
||||
outcomes = [outcome.strip() for outcome in raw_outcomes.split(",") if outcome.strip()]
|
||||
if not outcomes:
|
||||
print("At least one legal outcome is required.")
|
||||
continue
|
||||
existing.append({"name": name, "sides": side_sizes,
|
||||
"base_rating": base_rating, "outcomes": outcomes})
|
||||
|
||||
|
||||
def edit_custom_titles(config: dict[str, Any]) -> None:
|
||||
while True:
|
||||
existing = config["championships"]
|
||||
choices = list(range(len(existing))) + ["add", "remove", "done"]
|
||||
labels = [f"{item['name']} ({item['kind']})" for item in existing]
|
||||
labels.extend(["Add a championship", "Remove a championship", "Done"])
|
||||
action = app.ask_choice("Edit championship rankings", choices, labels=labels)
|
||||
if action == len(existing) + 2:
|
||||
return
|
||||
if action == len(existing) + 1:
|
||||
if not existing:
|
||||
print("There are no championships to remove.")
|
||||
continue
|
||||
remove_index = app.ask_choice("Remove which championship", list(range(len(existing))),
|
||||
labels=[item["name"] for item in existing])
|
||||
existing.pop(remove_index)
|
||||
continue
|
||||
if action == len(existing):
|
||||
name = app.ask("Championship name").strip()
|
||||
if not name or any(item["name"].casefold() == name.casefold() for item in existing):
|
||||
print("Enter a non-empty championship name not already listed.")
|
||||
continue
|
||||
kind_options = ["singles", "tag", "women"]
|
||||
kind_labels = ["Singles / open", "Tag team", "Women's division"]
|
||||
kind = kind_options[app.ask_choice("Championship type", kind_options, labels=kind_labels)]
|
||||
existing.append({"name": name, "kind": kind})
|
||||
|
||||
|
||||
def edit_women_roster(config: dict[str, Any]) -> None:
|
||||
while True:
|
||||
choices = ["add", "remove", "done"]
|
||||
labels = ["Add eligible wrestler", "Remove eligible wrestler", "Done"]
|
||||
action = app.ask_choice("Women's division eligibility", choices, labels=labels)
|
||||
eligible = config["women_roster"]
|
||||
if action == 2:
|
||||
return
|
||||
if action == 0:
|
||||
available = [name for name in config["roster"]
|
||||
if name.casefold() not in {item.casefold() for item in eligible}]
|
||||
if not available:
|
||||
print("Every roster member is already listed.")
|
||||
continue
|
||||
selected = app.choose_roster_member(available, "Add which wrestler")
|
||||
eligible.append(selected)
|
||||
elif not eligible:
|
||||
print("No eligible wrestlers are listed.")
|
||||
else:
|
||||
remove_index = app.ask_choice("Remove eligible wrestler", eligible)
|
||||
eligible.pop(remove_index)
|
||||
|
||||
|
||||
def edit_custom_shows(config: dict[str, Any]) -> None:
|
||||
while True:
|
||||
shows = config["shows"]
|
||||
labels = [f"{brand}: {shows[brand]}" for brand in app.SHOW_BRANDS] + ["Done"]
|
||||
action = app.ask_choice("Edit show names", list(app.SHOW_BRANDS) + ["done"], labels=labels)
|
||||
if action == len(app.SHOW_BRANDS):
|
||||
return
|
||||
brand = app.SHOW_BRANDS[action]
|
||||
while True:
|
||||
name = app.ask(f"{brand} show name", shows[brand]).strip()
|
||||
if not name:
|
||||
print("Show name cannot be blank.")
|
||||
continue
|
||||
others = [shows[other] for other in app.SHOW_BRANDS if other != brand]
|
||||
if any(other.casefold() == name.casefold() for other in others):
|
||||
print("Show names must be distinct from each other.")
|
||||
continue
|
||||
shows[brand] = name
|
||||
break
|
||||
|
||||
|
||||
def edit_custom_profile(config: dict[str, Any]) -> dict[str, Any]:
|
||||
while True:
|
||||
options = ["identity", "shows", "roster", "match_types", "titles", "women", "save"]
|
||||
labels = ["Game title and federation",
|
||||
f"Show names (Raw: {config['shows']['Raw']})",
|
||||
f"Edit roster ({len(config['roster'])} wrestlers)",
|
||||
f"Edit match types ({len(config['match_types'])})",
|
||||
f"Edit championships ({len(config['championships'])})",
|
||||
f"Edit Women's eligibility ({len(config['women_roster'])})", "Validate and save"]
|
||||
action = app.ask_choice("BYOG editor", options, labels=labels)
|
||||
if action == 0:
|
||||
config["game_title"] = app.ask("Game title", config["game_title"]).strip()
|
||||
config["federation_name"] = app.ask("Federation name", config["federation_name"]).strip()
|
||||
platform_options = ["ps1", "ps2", "other"]
|
||||
platform_labels = ["PlayStation 1", "PlayStation 2", "Other / platform agnostic"]
|
||||
config["platform"] = platform_options[
|
||||
app.ask_choice("Platform", platform_options, labels=platform_labels)
|
||||
]
|
||||
elif action == 1:
|
||||
app.edit_custom_shows(config)
|
||||
elif action == 2:
|
||||
app.edit_custom_roster(config)
|
||||
elif action == 3:
|
||||
app.edit_custom_match_types(config)
|
||||
elif action == 4:
|
||||
app.edit_custom_titles(config)
|
||||
elif action == 5:
|
||||
app.edit_women_roster(config)
|
||||
else:
|
||||
try:
|
||||
return app.save_custom_game(config)
|
||||
except ValueError as exc:
|
||||
print(f"Cannot save game profile: {exc}")
|
||||
|
||||
|
||||
def builtin_profile_for_copy(game_id: str, custom_id: str) -> dict[str, Any]:
|
||||
definitions = [
|
||||
{"name": name, "sides": list(sides),
|
||||
"base_rating": app.MATCH_BASE_RATINGS.get(name, 4.5),
|
||||
"outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES))}
|
||||
for name, sides in app.MATCH_TYPES[game_id]
|
||||
]
|
||||
return app.validate_custom_game({
|
||||
"schema_version": 1,
|
||||
"game_id": custom_id,
|
||||
"game_title": app.game_title(game_id) + " Custom Edition",
|
||||
"federation_name": app.federation_name(game_id),
|
||||
"platform": "ps1" if game_id in app.PS1_GAMES else "ps2",
|
||||
"roster": app.load_roster(game_id),
|
||||
"match_types": definitions,
|
||||
"championships": app.title_definitions_for_game(game_id),
|
||||
"women_roster": sorted(app.WOMEN_ROSTERS.get(game_id, set())),
|
||||
"shows": {"Raw": "Monday Night Raw", "SmackDown": "Thursday Night SmackDown!",
|
||||
"PPV": "WWF PPV"},
|
||||
})
|
||||
|
||||
|
||||
def create_custom_game() -> dict[str, Any]:
|
||||
title = app.ask("Game title").strip()
|
||||
while not title:
|
||||
title = app.ask("Game title cannot be blank; enter a game title").strip()
|
||||
federation = app.ask("Federation name", "My Wrestling Federation").strip()
|
||||
if not federation:
|
||||
federation = "My Wrestling Federation"
|
||||
platform_options = ["ps1", "ps2", "other"]
|
||||
platform_labels = ["PlayStation 1", "PlayStation 2", "Other / platform agnostic"]
|
||||
platform = platform_options[app.ask_choice("Platform", platform_options, labels=platform_labels)]
|
||||
shows: dict[str, str] = {}
|
||||
print("\nName this federation's three shows (weekly #1, weekly #2, and the big event).")
|
||||
for brand in app.SHOW_BRANDS:
|
||||
default = app.DEFAULT_CUSTOM_SHOWS[brand]
|
||||
while True:
|
||||
name = app.ask(f"{brand} show name", default).strip() or default
|
||||
if any(other.casefold() == name.casefold() for other in shows.values()):
|
||||
print("Show names must be distinct from each other.")
|
||||
continue
|
||||
shows[brand] = name
|
||||
break
|
||||
config: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"game_id": app.new_custom_game_id(federation, title),
|
||||
"game_title": title,
|
||||
"federation_name": federation,
|
||||
"platform": platform,
|
||||
"roster": [],
|
||||
"match_types": [],
|
||||
"championships": [],
|
||||
"women_roster": [],
|
||||
"shows": shows,
|
||||
}
|
||||
app.edit_custom_roster(config)
|
||||
config["match_types"] = app.default_custom_match_types(len(config["roster"]))
|
||||
if not config["match_types"]:
|
||||
config["match_types"] = [{"name": "Singles", "sides": [1, 1],
|
||||
"base_rating": 4.5,
|
||||
"outcomes": list(app.STANDARD_MATCH_OUTCOMES)}]
|
||||
print("\nAdd this federation's championship divisions (or finish with none).")
|
||||
app.edit_custom_titles(config)
|
||||
if any(title["kind"] == "women" for title in config["championships"]):
|
||||
app.edit_women_roster(config)
|
||||
return app.edit_custom_profile(config)
|
||||
|
||||
|
||||
def choose_builtin_game() -> str:
|
||||
ids = list(app.BUILTIN_GAME_IDS)
|
||||
return ids[app.ask_choice("Built-in game to copy", ids,
|
||||
labels=[app.GAMES[game_id]["title"] for game_id in ids])]
|
||||
|
||||
|
||||
def clone_builtin_game() -> dict[str, Any]:
|
||||
app.refresh_custom_games()
|
||||
source_id = app.choose_builtin_game()
|
||||
title = app.ask("Custom game title", app.game_title(source_id) + " Custom Edition").strip()
|
||||
federation = app.ask("Federation name", app.federation_name(source_id)).strip()
|
||||
config = app.builtin_profile_for_copy(source_id, app.new_custom_game_id(federation, title))
|
||||
config["game_title"] = title or config["game_title"]
|
||||
config["federation_name"] = federation or config["federation_name"]
|
||||
print(f"Copied {app.game_title(source_id)} into the editable BYOG directory.")
|
||||
return app.edit_custom_profile(config)
|
||||
|
||||
|
||||
def edit_existing_custom_game() -> None:
|
||||
app.refresh_custom_games()
|
||||
game_ids = sorted(app.CUSTOM_GAME_CONFIGS)
|
||||
if not game_ids:
|
||||
print("No custom games exist yet. Create or copy one first.")
|
||||
return
|
||||
labels = [app.game_display_name(game_id) for game_id in game_ids]
|
||||
game_id = game_ids[app.ask_choice("Choose custom game to edit", game_ids, labels=labels)]
|
||||
editable = json.loads(json.dumps(app.CUSTOM_GAME_CONFIGS[game_id]))
|
||||
app.edit_custom_profile(editable)
|
||||
app.refresh_custom_games()
|
||||
|
||||
|
||||
def manage_games() -> None:
|
||||
while True:
|
||||
app.refresh_custom_games()
|
||||
action = app.ask_choice(
|
||||
"BYOG / Manage Games",
|
||||
["create", "copy", "edit", "back"],
|
||||
labels=["Create a new game and federation", "Copy a built-in WWF game to edit",
|
||||
f"Edit a custom game ({len(app.CUSTOM_GAME_CONFIGS)} available)",
|
||||
"Back to main menu"],
|
||||
)
|
||||
if action == 0:
|
||||
app.create_custom_game()
|
||||
elif action == 1:
|
||||
app.clone_builtin_game()
|
||||
elif action == 2:
|
||||
app.edit_existing_custom_game()
|
||||
else:
|
||||
return
|
||||
|
||||
__all__ = ['builtin_profile_for_copy', 'choose_builtin_game', 'clone_builtin_game', 'create_custom_game', 'edit_custom_match_types', 'edit_custom_profile', 'edit_custom_roster', 'edit_custom_shows', 'edit_custom_titles', 'edit_existing_custom_game', 'edit_women_roster', 'manage_games', 'show_roster']
|
||||
338
lvpwb/games.py
Normal file
338
lvpwb/games.py
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def load_roster(game_id: str) -> list[str]:
|
||||
"""Load and validate a game's roster from the shared JSON schema."""
|
||||
if game_id in app.CUSTOM_GAME_CONFIGS:
|
||||
return list(app.CUSTOM_GAME_CONFIGS[game_id]["roster"])
|
||||
path = app.ROOT / app.GAMES[game_id]["roster"]
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Roster file not found: {path}")
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON roster file: {path}") from exc
|
||||
if not isinstance(payload, dict) or payload.get("game_id") != game_id:
|
||||
raise ValueError(f"Roster game_id does not match {game_id}: {path}")
|
||||
names = payload.get("wrestlers")
|
||||
if not isinstance(names, list) or any(not isinstance(name, str) or not name.strip() for name in names):
|
||||
raise ValueError(f"Roster must contain a list of non-empty wrestler names: {path}")
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError(f"Roster contains duplicate wrestler names: {path}")
|
||||
if payload.get("game") != app.GAMES[game_id]["title"]:
|
||||
raise ValueError(f"Roster game title does not match {game_id}: {path}")
|
||||
return names
|
||||
|
||||
|
||||
def game_title(game_id: str) -> str:
|
||||
return app.GAMES[game_id]["title"]
|
||||
|
||||
|
||||
def federation_name(game_id: str) -> str:
|
||||
if game_id in app.CUSTOM_GAME_CONFIGS:
|
||||
return app.CUSTOM_GAME_CONFIGS[game_id]["federation_name"]
|
||||
return "World Wrestling Federation"
|
||||
|
||||
|
||||
def game_display_name(game_id: str) -> str:
|
||||
if game_id in app.CUSTOM_GAME_CONFIGS:
|
||||
config = app.CUSTOM_GAME_CONFIGS[game_id]
|
||||
return f"{config['federation_name']} — {config['game_title']}"
|
||||
return app.GAMES[game_id]["title"]
|
||||
|
||||
|
||||
def is_wwf_federation(name: str | None) -> bool:
|
||||
folded = str(name or "").strip().casefold()
|
||||
return folded in ("world wrestling federation", "wwf")
|
||||
|
||||
|
||||
def title_definitions_for_game(game_id: str,
|
||||
profile: dict[str, Any] | None = None) -> list[dict[str, str]]:
|
||||
if profile and isinstance(profile.get("championships"), list):
|
||||
return [dict(title) for title in profile["championships"]]
|
||||
if game_id in app.CUSTOM_GAME_CONFIGS:
|
||||
return [dict(title) for title in app.CUSTOM_GAME_CONFIGS[game_id]["championships"]]
|
||||
return [
|
||||
{"name": name, "kind": "tag" if name == "Tag Team" else "women" if name == "Women's" else "singles"}
|
||||
for name in app.CHAMPIONSHIPS
|
||||
]
|
||||
|
||||
|
||||
def championship_names_for_game(game_id: str,
|
||||
profile: dict[str, Any] | None = None) -> list[str]:
|
||||
return [title["name"] for title in app.title_definitions_for_game(game_id, profile)]
|
||||
|
||||
|
||||
def championship_kind(game_id: str, title_name: str,
|
||||
profile: dict[str, Any] | None = None) -> str:
|
||||
for title in app.title_definitions_for_game(game_id, profile):
|
||||
if title["name"] == title_name:
|
||||
return title.get("kind", "singles")
|
||||
return "singles"
|
||||
|
||||
|
||||
def shows_snapshot(config: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""Resolved show names recorded with a season at creation time."""
|
||||
shows = (config or {}).get("shows")
|
||||
return {brand: app.show_name_for_brand(
|
||||
brand, shows if isinstance(shows, dict) else None) for brand in app.SHOW_BRANDS}
|
||||
|
||||
|
||||
def season_title_names(season: dict[str, Any]) -> list[str]:
|
||||
definitions = season.get("title_definitions")
|
||||
if isinstance(definitions, list):
|
||||
return [item["name"] for item in definitions]
|
||||
return list(season.get("champions", {})) or app.championship_names_for_game(season["game_id"])
|
||||
|
||||
|
||||
def season_championship_kind(season: dict[str, Any], title_name: str) -> str:
|
||||
for definition in season.get("title_definitions", []):
|
||||
if definition.get("name") == title_name:
|
||||
return definition.get("kind", "singles")
|
||||
return app.championship_kind(season["game_id"], title_name)
|
||||
|
||||
|
||||
def game_profile(game_id: str) -> dict[str, Any]:
|
||||
if game_id in app.CUSTOM_GAME_CONFIGS:
|
||||
return json.loads(json.dumps(app.CUSTOM_GAME_CONFIGS[game_id]))
|
||||
return app.builtin_game_profile(game_id)
|
||||
|
||||
|
||||
def season_game_profile(season: dict[str, Any]) -> dict[str, Any]:
|
||||
profile = season.get("game_config")
|
||||
return profile if isinstance(profile, dict) else app.game_profile(season["game_id"])
|
||||
|
||||
|
||||
def match_type_definitions(game_id: str, profile: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
if profile and isinstance(profile.get("match_types"), list):
|
||||
return [dict(item) for item in profile["match_types"]]
|
||||
if game_id in app.CUSTOM_GAME_CONFIGS:
|
||||
return [dict(item) for item in app.CUSTOM_GAME_CONFIGS[game_id]["match_types"]]
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"sides": list(sides),
|
||||
"base_rating": app.MATCH_BASE_RATINGS.get(name, 4.5),
|
||||
"outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES)),
|
||||
}
|
||||
for name, sides in app.MATCH_TYPES[game_id]
|
||||
]
|
||||
|
||||
|
||||
def match_type_definition(game_id: str, match_type: str,
|
||||
profile: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
for definition in app.match_type_definitions(game_id, profile):
|
||||
if definition["name"] == match_type:
|
||||
return definition
|
||||
return {"name": match_type, "sides": [1, 1],
|
||||
"base_rating": app.MATCH_BASE_RATINGS.get(match_type, 4.5),
|
||||
"outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(match_type, app.STANDARD_MATCH_OUTCOMES))}
|
||||
|
||||
|
||||
def refresh_custom_games(directory: Path | None = None) -> list[str]:
|
||||
"""Load user-managed game definitions without changing built-in profiles."""
|
||||
for game_id in list(app.CUSTOM_GAME_CONFIGS):
|
||||
app.GAMES.pop(game_id, None)
|
||||
app.MATCH_TYPES.pop(game_id, None)
|
||||
app.CUSTOM_GAME_CONFIGS.clear()
|
||||
directory = directory or app.CUSTOM_GAME_DIR
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
loaded: list[str] = []
|
||||
for path in sorted(directory.glob("*.json")):
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
config = app.validate_custom_game(raw)
|
||||
game_id = config["game_id"]
|
||||
if game_id in app.BUILTIN_GAME_IDS or game_id in app.CUSTOM_GAME_CONFIGS:
|
||||
raise ValueError(f"Game ID is already in use: {game_id}")
|
||||
app.CUSTOM_GAME_CONFIGS[game_id] = config
|
||||
app.GAMES[game_id] = {
|
||||
"title": config["game_title"],
|
||||
"roster": str(path),
|
||||
"federation": config["federation_name"],
|
||||
"platform": config["platform"],
|
||||
}
|
||||
app.MATCH_TYPES[game_id] = [
|
||||
(entry["name"], tuple(entry["sides"])) for entry in config["match_types"]
|
||||
]
|
||||
loaded.append(game_id)
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
print(f"Skipping custom game {path.name}: {exc}")
|
||||
return loaded
|
||||
|
||||
|
||||
def validate_custom_game(data: Any) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Game definition must be a JSON object.")
|
||||
game_id = data.get("game_id")
|
||||
if not isinstance(game_id, str) or not re.fullmatch(r"[a-z0-9][a-z0-9-]*", game_id):
|
||||
raise ValueError("game_id must use lowercase letters, digits, and hyphens.")
|
||||
if game_id in app.BUILTIN_GAME_IDS:
|
||||
raise ValueError("Custom definitions cannot overwrite built-in games.")
|
||||
game_title_value = data.get("game_title")
|
||||
federation = data.get("federation_name")
|
||||
if not isinstance(game_title_value, str) or not game_title_value.strip():
|
||||
raise ValueError("game_title is required.")
|
||||
if not isinstance(federation, str) or not federation.strip():
|
||||
raise ValueError("federation_name is required.")
|
||||
roster = data.get("roster")
|
||||
if (not isinstance(roster, list) or len(roster) < 2
|
||||
or any(not isinstance(name, str) or not name.strip() for name in roster)):
|
||||
raise ValueError("roster must contain at least two non-empty wrestler names.")
|
||||
if len({name.casefold() for name in roster}) != len(roster):
|
||||
raise ValueError("roster names must be unique, ignoring capitalization.")
|
||||
platform = str(data.get("platform", "other")).casefold()
|
||||
if platform not in {"ps1", "ps2", "other"}:
|
||||
raise ValueError("platform must be ps1, ps2, or other.")
|
||||
raw_match_types = data.get("match_types")
|
||||
if not isinstance(raw_match_types, list) or not raw_match_types:
|
||||
raise ValueError("match_types must contain at least one match type.")
|
||||
match_types: list[dict[str, Any]] = []
|
||||
seen_match_types: set[str] = set()
|
||||
for item in raw_match_types:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Each match type must be a JSON object.")
|
||||
name = item.get("name")
|
||||
sides = item.get("sides")
|
||||
if not isinstance(name, str) or not name.strip() or name.casefold() in seen_match_types:
|
||||
raise ValueError("Match type names must be non-empty and unique.")
|
||||
if (not isinstance(sides, list) or len(sides) < 2
|
||||
or any(not isinstance(count, int) or isinstance(count, bool) or count < 1
|
||||
for count in sides)
|
||||
or sum(sides) > len(roster)):
|
||||
raise ValueError(f"Match type '{name}' has invalid side sizes.")
|
||||
outcomes = item.get("outcomes", list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES)))
|
||||
if not isinstance(outcomes, list) or not outcomes or any(
|
||||
not isinstance(outcome, str) or not outcome.strip() for outcome in outcomes
|
||||
):
|
||||
raise ValueError(f"Match type '{name}' must have at least one valid outcome.")
|
||||
base_rating = item.get("base_rating", app.MATCH_BASE_RATINGS.get(name, 4.5))
|
||||
if (not isinstance(base_rating, (int, float)) or isinstance(base_rating, bool)
|
||||
or not math.isfinite(base_rating) or not 0 <= base_rating <= 100):
|
||||
raise ValueError(f"Match type '{name}' has an invalid base_rating.")
|
||||
match_types.append({"name": name.strip(), "sides": list(sides),
|
||||
"outcomes": [outcome.strip() for outcome in outcomes],
|
||||
"base_rating": float(base_rating)})
|
||||
seen_match_types.add(name.casefold())
|
||||
raw_titles = data.get("championships", [])
|
||||
if not isinstance(raw_titles, list):
|
||||
raise ValueError("championships must be a list.")
|
||||
championships: list[dict[str, str]] = []
|
||||
seen_titles: set[str] = set()
|
||||
for item in raw_titles:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Each championship must be a JSON object with name and kind.")
|
||||
name, kind = item.get("name"), str(item.get("kind", "singles")).casefold()
|
||||
if not isinstance(name, str) or not name.strip() or name.casefold() in seen_titles:
|
||||
raise ValueError("Championship names must be non-empty and unique.")
|
||||
if kind not in {"singles", "tag", "women"}:
|
||||
raise ValueError(f"Championship '{name}' kind must be singles, tag, or women.")
|
||||
championships.append({"name": name.strip(), "kind": kind})
|
||||
seen_titles.add(name.casefold())
|
||||
women = data.get("women_roster", [])
|
||||
if not isinstance(women, list) or any(name not in roster for name in women):
|
||||
raise ValueError("women_roster must be a subset of the roster.")
|
||||
raw_shows = data.get("shows")
|
||||
if raw_shows is not None and not isinstance(raw_shows, dict):
|
||||
raise ValueError("shows must be an object with Raw, SmackDown, and PPV names.")
|
||||
shows: dict[str, str] = {}
|
||||
seen_show_names: set[str] = set()
|
||||
for brand in app.SHOW_BRANDS:
|
||||
name = (raw_shows or {}).get(brand, app.DEFAULT_CUSTOM_SHOWS[brand])
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError(f"Show name for '{brand}' must be a non-empty string.")
|
||||
name = name.strip()
|
||||
if name.casefold() in seen_show_names:
|
||||
raise ValueError("Show names must be unique, ignoring capitalization.")
|
||||
seen_show_names.add(name.casefold())
|
||||
shows[brand] = name
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"game_id": game_id,
|
||||
"game_title": game_title_value.strip(),
|
||||
"federation_name": federation.strip(),
|
||||
"platform": platform,
|
||||
"roster": list(roster),
|
||||
"match_types": match_types,
|
||||
"championships": championships,
|
||||
"women_roster": list(dict.fromkeys(women)),
|
||||
"shows": shows,
|
||||
}
|
||||
|
||||
|
||||
def save_custom_game(data: dict[str, Any], directory: Path | None = None) -> dict[str, Any]:
|
||||
config = app.validate_custom_game(data)
|
||||
directory = directory or app.CUSTOM_GAME_DIR
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{config['game_id']}.json"
|
||||
temporary = path.with_suffix(".json.tmp")
|
||||
temporary.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
if directory.resolve() == app.CUSTOM_GAME_DIR.resolve():
|
||||
app.refresh_custom_games(directory)
|
||||
return config
|
||||
|
||||
|
||||
def builtin_game_profile(game_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"game_title": app.game_title(game_id),
|
||||
"federation_name": app.federation_name(game_id),
|
||||
"platform": "ps1" if game_id in app.PS1_GAMES else "ps2",
|
||||
"roster": app.load_roster(game_id),
|
||||
"match_types": app.match_type_definitions(game_id),
|
||||
"championships": app.title_definitions_for_game(game_id),
|
||||
"women_roster": sorted(app.WOMEN_ROSTERS.get(game_id, set())),
|
||||
"shows": {"Raw": "Monday Night Raw", "SmackDown": "Thursday Night SmackDown!",
|
||||
"PPV": "WWF PPV"},
|
||||
}
|
||||
|
||||
|
||||
def new_custom_game_id(federation: str, game_title_value: str) -> str:
|
||||
base = f"custom-{app.slugify(federation + '-' + game_title_value)}"
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while candidate in app.GAMES or (app.CUSTOM_GAME_DIR / f"{candidate}.json").exists():
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def parse_side_sizes(raw: str) -> list[int] | None:
|
||||
try:
|
||||
sizes = [int(part.strip()) for part in raw.split(",")]
|
||||
except ValueError:
|
||||
return None
|
||||
if len(sizes) < 2 or any(size < 1 for size in sizes):
|
||||
return None
|
||||
return sizes
|
||||
|
||||
|
||||
def default_custom_match_types(roster_size: int) -> list[dict[str, Any]]:
|
||||
defaults = [
|
||||
("Singles", [1, 1]),
|
||||
("Tag Team", [2, 2]),
|
||||
("Triple Threat", [1, 1, 1]),
|
||||
("Fatal 4-Way", [1, 1, 1, 1]),
|
||||
("Hardcore", [1, 1]),
|
||||
("Ladder", [1, 1]),
|
||||
("Cage", [1, 1]),
|
||||
]
|
||||
return [
|
||||
{"name": name, "sides": sides,
|
||||
"base_rating": app.MATCH_BASE_RATINGS.get(name, 4.5),
|
||||
"outcomes": list(app.MATCH_OUTCOME_OPTIONS.get(name, app.STANDARD_MATCH_OUTCOMES))}
|
||||
for name, sides in defaults if sum(sides) <= roster_size
|
||||
]
|
||||
|
||||
__all__ = ['builtin_game_profile', 'championship_kind', 'championship_names_for_game', 'default_custom_match_types', 'federation_name', 'game_display_name', 'game_profile', 'game_title', 'is_wwf_federation', 'load_roster', 'match_type_definition', 'match_type_definitions', 'new_custom_game_id', 'parse_side_sizes', 'refresh_custom_games', 'save_custom_game', 'season_championship_kind', 'season_game_profile', 'season_title_names', 'shows_snapshot', 'title_definitions_for_game', 'validate_custom_game']
|
||||
22
lvpwb/paths.py
Normal file
22
lvpwb/paths.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
CARD_DIR = ROOT / "cards"
|
||||
|
||||
SEASON_DIR = ROOT / "seasons"
|
||||
|
||||
EDIT_DIR = ROOT / "edit"
|
||||
|
||||
CUSTOM_GAME_DIR = EDIT_DIR / "games"
|
||||
|
||||
DUCKSTATION_SCREENSHOT_DIR = Path.home() / ".local/share/duckstation/screenshots"
|
||||
|
||||
SESSION_START = datetime.now().astimezone()
|
||||
|
||||
__all__ = ['CARD_DIR', 'CUSTOM_GAME_DIR', 'DUCKSTATION_SCREENSHOT_DIR', 'EDIT_DIR', 'ROOT', 'SEASON_DIR', 'SESSION_START']
|
||||
206
lvpwb/promos.py
Normal file
206
lvpwb/promos.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
PROMO_OPENERS = [
|
||||
"The crowd may be watching, but I only have eyes for this fight.",
|
||||
"You have had your moment. Now the spotlight is mine.",
|
||||
"All the talking ends when that bell rings.",
|
||||
"I came here to prove that I belong at the top.",
|
||||
]
|
||||
|
||||
PROMO_CHALLENGES = [
|
||||
"Bring your best, because I will not make this easy.",
|
||||
"You will have to do more than make promises tonight.",
|
||||
"I know exactly what you can do, and I am still not impressed.",
|
||||
"One mistake is all I need to turn this match around.",
|
||||
]
|
||||
|
||||
PROMO_RETORTS = [
|
||||
"Keep that confidence. You will need something to hold onto.",
|
||||
"I have heard it all before. Show me something new in the ring.",
|
||||
"You can take the last word. I will take the win.",
|
||||
"When the match is over, everyone will know who meant it.",
|
||||
]
|
||||
|
||||
PROMO_PROFILES: dict[str, dict[str, list[str]]] = {
|
||||
"rock": {
|
||||
"openers": [
|
||||
"Listen up, jabroni {opponent}—The Rock is ready to layeth the smackdown.",
|
||||
"Finally, {opponent}, The Rock has come back to this ring.",
|
||||
"The Rock is standing across from {opponent}, and the millions are watching.",
|
||||
],
|
||||
"challenges": [
|
||||
"Know your role and shut your mouth, {opponent}, because The Rock is taking over.",
|
||||
"Bring it, {opponent}, and The Rock will bring the thunder.",
|
||||
"Jabroni, you can bring your very best. The Rock will still be better.",
|
||||
],
|
||||
"retorts": [
|
||||
"The Rock says {opponent} is about to get checked into the SmackDown Hotel.",
|
||||
"The Rock has one thing left to say—know your role, jabroni.",
|
||||
"The millions have spoken, and they are chanting for The Rock.",
|
||||
],
|
||||
},
|
||||
"austin": {
|
||||
"openers": [
|
||||
"Stone Cold is here, and {opponent} can like it or lump it.",
|
||||
"Austin has one plan for {opponent}—open up a can and get to work.",
|
||||
"The glass breaks, {opponent} looks nervous, and Stone Cold likes that.",
|
||||
],
|
||||
"challenges": [
|
||||
"That's the bottom line, because Stone Cold said so.",
|
||||
"You want a fight, {opponent}? Stone Cold doesn't do negotiations.",
|
||||
"Austin doesn't care about your excuses, {opponent}. Ring the bell.",
|
||||
],
|
||||
"retorts": [
|
||||
"Stone Cold will leave {opponent} seeing stars and hearing that bell.",
|
||||
"Austin has heard enough. It's time to raise a little hell.",
|
||||
"And that's the bottom line—{opponent} is in for a rough night.",
|
||||
],
|
||||
},
|
||||
"tripleh": {
|
||||
"openers": [
|
||||
"I am the Game, {opponent}, and you are standing on my board.",
|
||||
"The Game has arrived. {opponent}, you know how this ends.",
|
||||
"I built my place at the top one victory at a time, {opponent}.",
|
||||
],
|
||||
"challenges": [
|
||||
"You can play your part, {opponent}, while I decide how the story ends.",
|
||||
"The Game doesn't hand out victories. It takes them.",
|
||||
"Step into my ring, {opponent}, and find out why they call me the Game.",
|
||||
],
|
||||
"retorts": [
|
||||
"I am the Game, and this is where your luck runs out.",
|
||||
"When the dust settles, {opponent}, I will still be standing on top.",
|
||||
"The Game is over when I say it's over.",
|
||||
],
|
||||
},
|
||||
"mankind": {
|
||||
"openers": ["{opponent} gets the spotlight, and the little guy gets blamed again."],
|
||||
"challenges": ["{opponent}, they keep persecuting the little guy—but the little guy keeps getting back up."],
|
||||
"retorts": ["Pain can knock all it wants; I keep getting up before it finds the right door."],
|
||||
"sayings": [
|
||||
"People say the early bird gets the worm. I say the worm gets the bird, the bird gets a clock, and the clock gets confused.",
|
||||
"You can't have your cake and eat it too—so I gave the cake the fork and told the fork to find its way home.",
|
||||
"A stitch in time saves nine; I lost the needle, tripped over time, and still made it to the ring.",
|
||||
"They say every cloud has a silver lining. Mine had a receipt, and the rain returned it upside down.",
|
||||
],
|
||||
"pain": [
|
||||
"Pain hit me like a ton of bricks; I apologized to the bricks and kept walking.",
|
||||
"My body says stop, but I think the sign is facing the other way.",
|
||||
"The hurt is loud. Little guys learn to sleep through the noise.",
|
||||
],
|
||||
},
|
||||
"undertaker": {
|
||||
"openers": [
|
||||
"{opponent}, the lights will fade, and the bell will toll for you.",
|
||||
"The Undertaker has walked through darkness far worse than this ring.",
|
||||
],
|
||||
"challenges": [
|
||||
"You can run from the shadow, {opponent}, but you cannot outrun it.",
|
||||
"When the bell tolls, {opponent}, there will be nowhere left to hide.",
|
||||
],
|
||||
"retorts": [
|
||||
"Rest in peace, {opponent}—if you can.",
|
||||
"The darkness is patient. So am I.",
|
||||
],
|
||||
},
|
||||
"kane": {
|
||||
"openers": ["The Big Red Machine has been unleashed, {opponent}."],
|
||||
"challenges": ["You hear the flames, {opponent}? They are calling your name."],
|
||||
"retorts": ["Kane doesn't back down. Kane brings the fire."],
|
||||
},
|
||||
"jericho": {
|
||||
"openers": ["Welcome to the highlight reel, {opponent}. Chris Jericho is starring."],
|
||||
"challenges": ["You are looking at the best in the world at what he does."],
|
||||
"retorts": ["Now take a good look at the first-ever undisputed problem in your way."],
|
||||
},
|
||||
"kurtangle": {
|
||||
"openers": ["I won an Olympic gold medal, {opponent}. What have you got?"],
|
||||
"challenges": ["It's true, it's true—excellence is about to beat you."],
|
||||
"retorts": ["You may cheer now, but you will be tapping soon enough."],
|
||||
},
|
||||
"vince": {
|
||||
"openers": ["You are looking at the chairman, {opponent}, and this is my show."],
|
||||
"challenges": ["You are one bad decision away from hearing, 'You're fired!'"],
|
||||
"retorts": ["The boss always gets the last word."],
|
||||
},
|
||||
"edge": {
|
||||
"openers": ["{opponent}, this is my chance to steal the spotlight."],
|
||||
"challenges": ["I will take every shortcut and every opening you leave me."],
|
||||
"retorts": ["You had your chance. I am taking the moment."],
|
||||
},
|
||||
"jeffhardy": {
|
||||
"openers": ["{opponent}, I will take the risk nobody else dares to take."],
|
||||
"challenges": ["I will fly, fall, and get right back up if that's what it takes."],
|
||||
"retorts": ["The risk is worth it when the crowd is on its feet."],
|
||||
},
|
||||
"alsnow": {
|
||||
"openers": ["Head says {opponent} is in trouble. Head is usually right."],
|
||||
"challenges": ["I have talked this over with Head, and we're ready for you."],
|
||||
"retorts": ["Head says that went exactly as planned."],
|
||||
},
|
||||
}
|
||||
|
||||
PROMO_PROFILE_ALIASES = {
|
||||
"therock": "rock", "rock": "rock",
|
||||
"stonecoldsteveaustin": "austin", "steveaustin": "austin", "austin": "austin",
|
||||
"tripleh": "tripleh", "hunterhearsthelmsley": "tripleh",
|
||||
"mankind": "mankind", "mickfoley": "mankind",
|
||||
"undertaker": "undertaker", "theundertaker": "undertaker",
|
||||
"kane": "kane",
|
||||
"chrisjericho": "jericho", "jericho": "jericho",
|
||||
"kurtangle": "kurtangle",
|
||||
"vincemcmahon": "vince",
|
||||
"edge": "edge",
|
||||
"jeffhardy": "jeffhardy",
|
||||
"alsnow": "alsnow",
|
||||
}
|
||||
|
||||
|
||||
def normalized_wrestler_name(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", name.casefold())
|
||||
|
||||
|
||||
def promo_line_for(wrestler: str, opponent: str, moment: str) -> str:
|
||||
"""Return a short line shaped by a wrestler's voice, or use the generic bank."""
|
||||
profile_key = app.PROMO_PROFILE_ALIASES.get(app.normalized_wrestler_name(wrestler))
|
||||
if profile_key == "mankind":
|
||||
profile = app.PROMO_PROFILES[profile_key]
|
||||
parts = [
|
||||
app.random.choice(profile["openers"]).format(opponent=opponent),
|
||||
app.random.choice(profile["sayings"]),
|
||||
app.random.choice(profile["pain"]),
|
||||
]
|
||||
return " ".join(parts)
|
||||
if profile_key:
|
||||
line = app.random.choice(app.PROMO_PROFILES[profile_key][moment])
|
||||
else:
|
||||
generic = {"openers": app.PROMO_OPENERS, "challenges": app.PROMO_CHALLENGES,
|
||||
"retorts": app.PROMO_RETORTS}[moment]
|
||||
line = app.random.choice(generic)
|
||||
return line.format(opponent=opponent, wrestler=wrestler)
|
||||
|
||||
|
||||
def generate_promo(card: dict[str, Any]) -> None:
|
||||
pending = app.pending_indices(card)
|
||||
if not pending:
|
||||
print("Finish another match before adding an in-between promo.")
|
||||
return
|
||||
index = pending[0]
|
||||
match = card["matches"][index]
|
||||
if len(match["sides"]) < 2:
|
||||
print("This match does not have two sides for a face-off promo.")
|
||||
return
|
||||
first = app.random.choice(match["sides"][0])
|
||||
second = app.random.choice(match["sides"][1])
|
||||
match["promo"] = (f"{first}: {app.promo_line_for(first, second, 'openers')}\n"
|
||||
f"{second}: {app.promo_line_for(second, first, 'challenges')}\n"
|
||||
f"{first}: {app.promo_line_for(first, second, 'retorts')}")
|
||||
app.save_card(card)
|
||||
print(f"Promo added before Match {index + 1} and saved.")
|
||||
|
||||
__all__ = ['PROMO_CHALLENGES', 'PROMO_OPENERS', 'PROMO_PROFILES', 'PROMO_PROFILE_ALIASES', 'PROMO_RETORTS', 'generate_promo', 'normalized_wrestler_name', 'promo_line_for']
|
||||
54
lvpwb/prompts.py
Normal file
54
lvpwb/prompts.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def ask(prompt: str, default: str | None = None) -> str:
|
||||
suffix = f" [{default}]" if default is not None else ""
|
||||
answer = input(f"{prompt}{suffix}: ").strip()
|
||||
return answer if answer else (default or "")
|
||||
|
||||
|
||||
def ask_yes_no(prompt: str, default: bool | None = None) -> bool:
|
||||
suffix = " [Y/n]" if default is True else " [y/N]" if default is False else " [y/n]"
|
||||
while True:
|
||||
answer = input(f"{prompt}{suffix}: ").strip().casefold()
|
||||
if not answer and default is not None:
|
||||
return default
|
||||
if answer in ("y", "yes"):
|
||||
return True
|
||||
if answer in ("n", "no"):
|
||||
return False
|
||||
print("Enter y/yes or n/no.")
|
||||
|
||||
|
||||
def ask_choice(
|
||||
prompt: str,
|
||||
choices: list[Any],
|
||||
default: int | None = None,
|
||||
labels: list[str] | None = None,
|
||||
) -> int:
|
||||
labels = labels or [str(choice) for choice in choices]
|
||||
if len(labels) != len(choices):
|
||||
raise ValueError("Choice labels must match the number of choices.")
|
||||
while True:
|
||||
print(f"\n{prompt}:")
|
||||
for number, label in enumerate(labels, 1):
|
||||
print(f" {number}. {label}")
|
||||
raw = app.ask("Selection", str(default) if default is not None else None)
|
||||
try:
|
||||
choice = int(raw)
|
||||
if 1 <= choice <= len(choices):
|
||||
return choice - 1
|
||||
except ValueError:
|
||||
pass
|
||||
print(f"Enter a number from 1 to {len(choices)}.")
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
||||
return slug or "smackdown-show"
|
||||
|
||||
__all__ = ['ask', 'ask_choice', 'ask_yes_no', 'slugify']
|
||||
63
lvpwb/ratings.py
Normal file
63
lvpwb/ratings.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def base_match_rating(match_type: str) -> float:
|
||||
return app.MATCH_BASE_RATINGS.get(match_type, 4.5)
|
||||
|
||||
|
||||
def calculate_match_rating(match: dict[str, Any]) -> float | None:
|
||||
minutes = match.get("match_time_minutes")
|
||||
if minutes is None:
|
||||
return None
|
||||
time_bonus = min(math.floor(max(0.0, float(minutes))) * app.MATCH_TIME_BONUS_PER_MINUTE,
|
||||
app.MATCH_TIME_BONUS_CAP)
|
||||
raw_action = (time_bonus
|
||||
+ max(0, int(match.get("near_falls", 0))) * app.NEAR_FALL_BONUS
|
||||
+ max(0, int(match.get("finishers", 0))) * app.FINISHER_BONUS)
|
||||
action_bonus = min(raw_action, app.MATCH_ACTION_BONUS_CAP)
|
||||
base_value = match.get("base_rating")
|
||||
if not isinstance(base_value, (int, float)) or isinstance(base_value, bool):
|
||||
base_value = app.base_match_rating(match.get("type", ""))
|
||||
earned = float(base_value) + action_bonus
|
||||
if match.get("title_defense"):
|
||||
earned *= app.TITLE_MATCH_MULTIPLIER
|
||||
return round(min(100.0, earned), 1)
|
||||
|
||||
|
||||
def calculate_ppv_rating(card: dict[str, Any]) -> float | None:
|
||||
if not app.is_ppv_card(card):
|
||||
return None
|
||||
matches = card.get("matches", [])
|
||||
if len(matches) != 3 or any(match.get("rating") is None for match in matches):
|
||||
return None
|
||||
return round(sum(float(match["rating"]) * weight
|
||||
for match, weight in zip(matches, app.PPV_MATCH_WEIGHTS)), 1)
|
||||
|
||||
|
||||
def parse_match_minutes(raw: str) -> float | None:
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return value if math.isfinite(value) and value >= 0 else None
|
||||
|
||||
|
||||
def parse_action_count(raw: str) -> int | None:
|
||||
if not raw.isdigit():
|
||||
return None
|
||||
return int(raw)
|
||||
|
||||
|
||||
def allowed_match_outcomes(match_type: str | dict[str, Any]) -> tuple[str, ...]:
|
||||
if isinstance(match_type, dict):
|
||||
outcomes = match_type.get("valid_outcomes")
|
||||
if isinstance(outcomes, list) and outcomes:
|
||||
return tuple(outcomes)
|
||||
match_type = str(match_type.get("type", ""))
|
||||
return app.MATCH_OUTCOME_OPTIONS.get(match_type, app.STANDARD_MATCH_OUTCOMES)
|
||||
|
||||
__all__ = ['allowed_match_outcomes', 'base_match_rating', 'calculate_match_rating', 'calculate_ppv_rating', 'parse_action_count', 'parse_match_minutes']
|
||||
469
lvpwb/render.py
Normal file
469
lvpwb/render.py
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import sys
|
||||
import textwrap
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def color(text: str, code: str, enabled: bool = True) -> str:
|
||||
return f"{code}{text}{app.RESET}" if enabled else text
|
||||
|
||||
|
||||
def short(text: str, width: int) -> str:
|
||||
if len(text) <= width:
|
||||
return text
|
||||
if width < 2:
|
||||
return text[:width]
|
||||
return text[:width - 1] + "…"
|
||||
|
||||
|
||||
def match_lines(
|
||||
match: dict[str, Any],
|
||||
number: int,
|
||||
width: int,
|
||||
flash: bool = False,
|
||||
promo_selected: bool = False,
|
||||
promo_expanded: bool = False,
|
||||
) -> list[str]:
|
||||
box_width = max(36, min(width - 4, 100))
|
||||
inner = box_width - 4
|
||||
heading = f"Match {number}" + (f" — {match['title']}" if match.get("title") else "")
|
||||
heading = app.short(heading, inner)
|
||||
heading_text = f" {heading} "
|
||||
top_fill = max(0, box_width - 2 - len(heading_text))
|
||||
lines = [f"┌{heading_text}{'─' * top_fill}┐", f"│ {match['type'].center(inner)} │"]
|
||||
sides = match["sides"]
|
||||
winner = match.get("winner_side")
|
||||
if len(sides) == 2:
|
||||
left_width = max(10, (inner - 8) // 2)
|
||||
right_width = inner - left_width - 6
|
||||
labels: list[str] = []
|
||||
for side_number, side in enumerate(sides):
|
||||
label = " & ".join(side)
|
||||
if winner is not None:
|
||||
if side_number == winner:
|
||||
label = "WIN!" if flash else app.short(label, left_width if side_number == 0 else right_width)
|
||||
label = app.color(label, app.RED)
|
||||
else:
|
||||
label = "LOSE" if flash else app.short(label, left_width if side_number == 0 else right_width)
|
||||
label = app.color(label, app.YELLOW)
|
||||
else:
|
||||
label = app.short(label, left_width if side_number == 0 else right_width)
|
||||
labels.append(label)
|
||||
left = labels[0]
|
||||
right = labels[1]
|
||||
# ANSI color sequences are zero-width; pad using the original label lengths.
|
||||
left_plain_width = left_width
|
||||
right_plain_width = right_width
|
||||
lines.append(f"│ {left}{' ' * max(0, left_plain_width - app.visible_length(left))} VS "
|
||||
f"{right}{' ' * max(0, right_plain_width - app.visible_length(right))} │")
|
||||
else:
|
||||
pieces: list[str] = []
|
||||
for side_number, side in enumerate(sides):
|
||||
label = " & ".join(side)
|
||||
if winner is not None:
|
||||
if side_number == winner:
|
||||
label = "WIN!" if flash else label
|
||||
label = app.color(label, app.RED)
|
||||
else:
|
||||
label = "LOSE" if flash else label
|
||||
label = app.color(label, app.YELLOW)
|
||||
pieces.append(label)
|
||||
separator = " VS "
|
||||
required = sum(app.visible_length(piece) for piece in pieces) + len(separator) * (len(pieces) - 1)
|
||||
if required > inner and len(pieces) > 1:
|
||||
name_budget = max(1, (inner - len(separator) * (len(pieces) - 1)) // len(pieces))
|
||||
pieces = [app.short_ansi(piece, name_budget) for piece in pieces]
|
||||
joined = separator.join(pieces)
|
||||
padding = max(0, inner - app.visible_length(joined))
|
||||
lines.append(f"│ {' ' * (padding // 2)}{joined}{' ' * (padding - padding // 2)} │")
|
||||
status = "COMPLETE" if winner is not None else "UP NEXT"
|
||||
if match.get("title_defense"):
|
||||
division = str(match.get("ranking_division") or "Championship").upper()
|
||||
vacant = str(match.get("title", "")).startswith("Vacant ")
|
||||
status = f"{'VACANT ' if vacant else ''}{division} TITLE MATCH · {status}"
|
||||
else:
|
||||
status = f"NON-TITLE MATCH · {status}"
|
||||
if match.get("finish"):
|
||||
status += f" · {match['finish']}"
|
||||
if match.get("match_time_minutes") is not None:
|
||||
status += f" · {float(match['match_time_minutes']):g} min"
|
||||
if match.get("rating") is not None:
|
||||
status += f" · {float(match['rating']):.1f}%"
|
||||
status = app.short(status, inner)
|
||||
if match.get("promo"):
|
||||
if promo_expanded:
|
||||
control = "▼ Promo (Enter to collapse)"
|
||||
else:
|
||||
preview = " / ".join(str(match["promo"]).splitlines())
|
||||
control = app.short(f"▶ Promo: {preview}", inner)
|
||||
control_line = f"│ {control:<{inner}} │"
|
||||
lines.append(app.color(control_line, "\033[7m", enabled=promo_selected))
|
||||
if promo_expanded:
|
||||
wrap_width = max(8, inner - 2)
|
||||
for turn in str(match["promo"]).splitlines():
|
||||
wrapped_turn = textwrap.wrap(
|
||||
turn, width=wrap_width, break_long_words=True, break_on_hyphens=False
|
||||
) or [""]
|
||||
for wrapped in wrapped_turn:
|
||||
lines.append(f"│ {wrapped:<{wrap_width}} │")
|
||||
status_text = f" {status} "
|
||||
status_padding = max(0, box_width - 2 - len(status_text))
|
||||
left_padding = status_padding // 2
|
||||
right_padding = status_padding - left_padding
|
||||
lines.append(f"└{'─' * left_padding}{status_text}{'─' * right_padding}┘")
|
||||
return lines
|
||||
|
||||
ANSI_RE = re.compile(r"\033\[[0-9;]*m")
|
||||
|
||||
|
||||
def visible_length(text: str) -> int:
|
||||
return len(app.ANSI_RE.sub("", text))
|
||||
|
||||
|
||||
def short_ansi(text: str, width: int) -> str:
|
||||
plain = app.ANSI_RE.sub("", text)
|
||||
if len(plain) <= width:
|
||||
return text
|
||||
# Multi-way names are shortened only in the uncolored portion; retain the
|
||||
# match color by applying it to the shortened result.
|
||||
code = ""
|
||||
if text.startswith(app.RED):
|
||||
code = app.RED
|
||||
elif text.startswith(app.YELLOW):
|
||||
code = app.YELLOW
|
||||
return app.color(app.short(plain, width), code, bool(code))
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _render_image(path: Path, max_columns: int, max_rows: int) -> tuple[list[str], int]:
|
||||
"""Render an image as ANSI true-color half-blocks when Pillow is installed."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return [], 0
|
||||
try:
|
||||
with Image.open(path) as source:
|
||||
image = source.convert("RGBA")
|
||||
image.thumbnail((max_columns, max_rows * 2), Image.Resampling.LANCZOS)
|
||||
image_width, image_height = image.size
|
||||
pixels = image.load()
|
||||
output: list[str] = []
|
||||
for y in range(0, image_height, 2):
|
||||
row: list[str] = []
|
||||
for x in range(image_width):
|
||||
top = pixels[x, y]
|
||||
bottom = pixels[x, y + 1] if y + 1 < image_height else (0, 0, 0, 255)
|
||||
# Composite transparent logo pixels onto the terminal background.
|
||||
top = tuple(round(top[i] * top[3] / 255) for i in range(3))
|
||||
bottom = tuple(round(bottom[i] * bottom[3] / 255) for i in range(3))
|
||||
row.append(f"\033[38;2;{top[0]};{top[1]};{top[2]}m"
|
||||
f"\033[48;2;{bottom[0]};{bottom[1]};{bottom[2]}m▀")
|
||||
output.append("".join(row) + app.RESET)
|
||||
return output, image_width
|
||||
except Exception:
|
||||
return [], 0
|
||||
|
||||
|
||||
def show_logo_path(show_name: str) -> Path | None:
|
||||
"""Choose a centered show logo from the show name, ignoring case."""
|
||||
normalized = show_name.casefold()
|
||||
if "smackdown" in normalized:
|
||||
return app.ROOT / "smackdown-logo-source.png"
|
||||
if re.search(r"\braw\b", normalized):
|
||||
return app.ROOT / "raw-is-war-logo.png"
|
||||
return None
|
||||
|
||||
|
||||
def header_lines(card: dict[str, Any], width: int) -> list[str]:
|
||||
card_federation = str(card.get("federation_name")
|
||||
or app.federation_name(card["game_id"]))
|
||||
is_wwf = app.is_wwf_federation(card_federation)
|
||||
if is_wwf:
|
||||
left, left_width = app._render_image(app.ROOT / "wwfcomlogo.gif", 23, 6)
|
||||
else:
|
||||
left, left_width = [], 0
|
||||
logo_path = app.show_logo_path(card["show"])
|
||||
if logo_path:
|
||||
center, center_width = app._render_image(logo_path, 38, 6)
|
||||
if not center:
|
||||
brand = "SMACKDOWN!" if logo_path.name == "smackdown-logo-source.png" else "RAW IS WAR"
|
||||
center = [app.color(brand, app.CYAN)]
|
||||
center_width = len(brand)
|
||||
else:
|
||||
center, center_width = [], 0
|
||||
if is_wwf and not left:
|
||||
left = [app.color("WWF.com", app.RED)]
|
||||
left_width = 7
|
||||
row_count = max(len(left), len(center), 2)
|
||||
if width < 62:
|
||||
title = card.get("game_title", app.game_title(card["game_id"]))
|
||||
heading = f"{app.color('WWF.com', app.RED)} {app.color(title, app.CYAN)}" if is_wwf \
|
||||
else app.color(title, app.CYAN)
|
||||
return [heading,
|
||||
f"{card['date']} · {app.show_location_text(card)}", ""]
|
||||
left_x = 1
|
||||
if left:
|
||||
center_x = max(left_x + left_width + 3, (width - center_width) // 2)
|
||||
else:
|
||||
center_x = (width - center_width) // 2
|
||||
date_text = card["date"]
|
||||
date_x = width - len(date_text) - 2
|
||||
output: list[str] = []
|
||||
for index in range(row_count):
|
||||
segments: list[str] = []
|
||||
cursor = 0
|
||||
if index < len(left):
|
||||
segments.append(" " * (left_x - cursor) + left[index])
|
||||
cursor = left_x + left_width
|
||||
if index < len(center):
|
||||
if center_x < cursor:
|
||||
center_x = cursor + 2
|
||||
segments.append(" " * (center_x - cursor) + center[index])
|
||||
cursor = center_x + center_width
|
||||
if index == 0 and date_x > cursor:
|
||||
segments.append(" " * (date_x - cursor) + app.color(date_text, app.DIM))
|
||||
output.append("".join(segments))
|
||||
output.append(app.color(app.short(app.show_location_text(card), width - 2), app.CYAN))
|
||||
return output
|
||||
|
||||
|
||||
def render_card_sections(
|
||||
card: dict[str, Any],
|
||||
flash: bool = False,
|
||||
selected_promo: int | None = None,
|
||||
expanded_promos: set[int] | None = None,
|
||||
width: int | None = None,
|
||||
) -> tuple[list[str], list[str], dict[int, tuple[int, int]]]:
|
||||
width = width or app.shutil.get_terminal_size((100, 28)).columns
|
||||
expanded_promos = expanded_promos or set()
|
||||
header = app.header_lines(card, width)
|
||||
body: list[str] = []
|
||||
promo_spans: dict[int, tuple[int, int]] = {}
|
||||
for match_index in reversed(range(len(card["matches"]))):
|
||||
index = match_index + 1
|
||||
match = card["matches"][match_index]
|
||||
start = len(body)
|
||||
block = app.match_lines(
|
||||
match,
|
||||
index,
|
||||
width,
|
||||
flash=flash,
|
||||
promo_selected=(match_index == selected_promo),
|
||||
promo_expanded=(match_index in expanded_promos),
|
||||
)
|
||||
if match.get("promo"):
|
||||
control_row = start + 3
|
||||
promo_spans[match_index] = (control_row, start + len(block) - 1)
|
||||
body.extend(block)
|
||||
body.append(app.color(f"Saved: {card['path']}", app.DIM))
|
||||
ppv_rating = app.calculate_ppv_rating(card)
|
||||
if ppv_rating is not None:
|
||||
body.append(app.color(f"WWF PPV Rating: {ppv_rating:.1f}%", app.CYAN))
|
||||
instructions = "↑/↓ select promo · Enter expand/collapse · PgUp/PgDn scroll · q close"
|
||||
body.append(app.color(app.short(instructions, max(1, width - 2)), app.DIM))
|
||||
return header, body, promo_spans
|
||||
|
||||
|
||||
def render_card_lines(
|
||||
card: dict[str, Any],
|
||||
flash: bool = False,
|
||||
selected_promo: int | None = None,
|
||||
expanded_promos: set[int] | None = None,
|
||||
) -> list[str]:
|
||||
header, body, _ = app.render_card_sections(
|
||||
card, flash=flash, selected_promo=selected_promo, expanded_promos=expanded_promos
|
||||
)
|
||||
return header + body
|
||||
|
||||
|
||||
def render_card(card: dict[str, Any], flash: bool = False) -> str:
|
||||
return "\n".join(app.render_card_lines(card, flash=flash))
|
||||
|
||||
|
||||
def viewport_frame(
|
||||
header: list[str], body: list[str], height: int, scroll_offset: int
|
||||
) -> tuple[list[str], int, int]:
|
||||
"""Keep the header fixed while clipping the scrollable card body to the screen."""
|
||||
height = max(1, height)
|
||||
header_count = min(len(header), max(0, height - 1))
|
||||
fixed_header = header[:header_count]
|
||||
body_capacity = max(1, height - header_count)
|
||||
max_offset = max(0, len(body) - body_capacity)
|
||||
offset = min(max(0, scroll_offset), max_offset)
|
||||
visible_body = body[offset:offset + body_capacity]
|
||||
frame = fixed_header + visible_body
|
||||
frame.extend([""] * (height - len(frame)))
|
||||
return frame, offset, body_capacity
|
||||
|
||||
|
||||
def keep_promo_visible(
|
||||
scroll_offset: int,
|
||||
promo_span: tuple[int, int] | None,
|
||||
body_capacity: int,
|
||||
body_length: int,
|
||||
) -> int:
|
||||
if promo_span is None:
|
||||
return min(scroll_offset, max(0, body_length - body_capacity))
|
||||
start, end = promo_span
|
||||
span_length = end - start
|
||||
if span_length <= body_capacity:
|
||||
if start < scroll_offset:
|
||||
scroll_offset = start
|
||||
elif end > scroll_offset + body_capacity:
|
||||
scroll_offset = end - body_capacity
|
||||
elif start < scroll_offset or start >= scroll_offset + body_capacity:
|
||||
scroll_offset = start
|
||||
return min(max(0, scroll_offset), max(0, body_length - body_capacity))
|
||||
|
||||
|
||||
def write_changed_lines(previous: list[str], current: list[str], stream: Any = None) -> None:
|
||||
"""Update only changed screen rows; static branding and card text stay put."""
|
||||
stream = stream or sys.stdout
|
||||
changed = False
|
||||
for index, line in enumerate(current):
|
||||
if index >= len(previous) or line != previous[index]:
|
||||
stream.write(f"\033[{index + 1};1H\033[2K{line}")
|
||||
changed = True
|
||||
if changed:
|
||||
stream.flush()
|
||||
|
||||
|
||||
def interactive_card_frame(
|
||||
card: dict[str, Any],
|
||||
terminal_width: int,
|
||||
terminal_height: int,
|
||||
flash: bool = False,
|
||||
selected_promo: int | None = None,
|
||||
expanded_promos: set[int] | None = None,
|
||||
scroll_offset: int = 0,
|
||||
keep_selection_visible: bool = False,
|
||||
) -> tuple[list[str], int, int, dict[int, tuple[int, int]]]:
|
||||
header, body, promo_spans = app.render_card_sections(
|
||||
card,
|
||||
flash=flash,
|
||||
selected_promo=selected_promo,
|
||||
expanded_promos=expanded_promos,
|
||||
width=terminal_width,
|
||||
)
|
||||
_, _, body_capacity = app.viewport_frame(header, body, terminal_height, scroll_offset)
|
||||
if keep_selection_visible and selected_promo is not None:
|
||||
scroll_offset = app.keep_promo_visible(
|
||||
scroll_offset, promo_spans.get(selected_promo), body_capacity, len(body)
|
||||
)
|
||||
frame, scroll_offset, body_capacity = app.viewport_frame(
|
||||
header, body, terminal_height, scroll_offset
|
||||
)
|
||||
return frame, scroll_offset, body_capacity, promo_spans
|
||||
|
||||
|
||||
def read_view_key(fd: int) -> str:
|
||||
"""Read one key from a cbreak terminal, translating common navigation keys."""
|
||||
first = os.read(fd, 1)
|
||||
if first in (b"\r", b"\n"):
|
||||
return "enter"
|
||||
if first in (b"q", b"Q"):
|
||||
return "quit"
|
||||
if first in (b"j", b"J"):
|
||||
return "down"
|
||||
if first in (b"k", b"K"):
|
||||
return "up"
|
||||
if first != b"\x1b":
|
||||
return "other"
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if not ready:
|
||||
return "quit"
|
||||
if os.read(fd, 1) != b"[":
|
||||
return "other"
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if not ready:
|
||||
return "other"
|
||||
code = os.read(fd, 1)
|
||||
if code == b"A":
|
||||
return "up"
|
||||
if code == b"B":
|
||||
return "down"
|
||||
if code in (b"5", b"6"):
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if ready and os.read(fd, 1) == b"~":
|
||||
return "pageup" if code == b"5" else "pagedown"
|
||||
return "other"
|
||||
|
||||
|
||||
def view_card(card: dict[str, Any]) -> None:
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
print("\033[2J\033[H" + app.render_card(card))
|
||||
input("\nPress Enter to continue...")
|
||||
return
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
tty.setcbreak(fd)
|
||||
try:
|
||||
terminal = app.shutil.get_terminal_size((100, 28))
|
||||
promo_indices = [index for index in reversed(range(len(card["matches"])))
|
||||
if card["matches"][index].get("promo")]
|
||||
selected_promo = promo_indices[0] if promo_indices else None
|
||||
expanded_promos: set[int] = set()
|
||||
scroll_offset = 0
|
||||
has_results = any(match.get("winner_side") is not None for match in card["matches"])
|
||||
flash = False
|
||||
frame, scroll_offset, body_capacity, promo_spans = app.interactive_card_frame(
|
||||
card, terminal.columns, terminal.lines, flash=flash,
|
||||
selected_promo=selected_promo, expanded_promos=expanded_promos,
|
||||
scroll_offset=scroll_offset, keep_selection_visible=True,
|
||||
)
|
||||
# Paint the static header and collapsed card once. Subsequent frames
|
||||
# update changed rows only, with the header pinned above the viewport.
|
||||
sys.stdout.write("\033[2J\033[H" + "\n".join(frame))
|
||||
sys.stdout.flush()
|
||||
while True:
|
||||
ready, _, _ = select.select([sys.stdin], [], [], 0.65)
|
||||
key = app.read_view_key(fd) if ready else ""
|
||||
keep_selection_visible = False
|
||||
if key == "quit":
|
||||
break
|
||||
elif key in ("up", "down") and promo_indices:
|
||||
current = promo_indices.index(selected_promo) if selected_promo in promo_indices else 0
|
||||
delta = -1 if key == "up" else 1
|
||||
selected_promo = promo_indices[(current + delta) % len(promo_indices)]
|
||||
keep_selection_visible = True
|
||||
elif key == "enter" and selected_promo is not None:
|
||||
if selected_promo in expanded_promos:
|
||||
expanded_promos.remove(selected_promo)
|
||||
else:
|
||||
expanded_promos.add(selected_promo)
|
||||
keep_selection_visible = True
|
||||
elif key == "pageup":
|
||||
scroll_offset -= max(1, body_capacity // 2)
|
||||
elif key == "pagedown":
|
||||
scroll_offset += max(1, body_capacity // 2)
|
||||
elif not key and has_results:
|
||||
flash = not flash
|
||||
elif key:
|
||||
continue
|
||||
next_frame, scroll_offset, body_capacity, promo_spans = app.interactive_card_frame(
|
||||
card, terminal.columns, terminal.lines, flash=flash,
|
||||
selected_promo=selected_promo, expanded_promos=expanded_promos,
|
||||
scroll_offset=scroll_offset,
|
||||
keep_selection_visible=keep_selection_visible,
|
||||
)
|
||||
app.write_changed_lines(frame, next_frame)
|
||||
frame = next_frame
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
sys.stdout.write("\033[0m\033[2J\033[H")
|
||||
sys.stdout.flush()
|
||||
except (ImportError, OSError, ValueError):
|
||||
print("\033[2J\033[H" + app.render_card(card))
|
||||
input("\nPress Enter to continue...")
|
||||
|
||||
__all__ = ['ANSI_RE', '_render_image', 'color', 'header_lines', 'interactive_card_frame', 'keep_promo_visible', 'match_lines', 'read_view_key', 'render_card', 'render_card_lines', 'render_card_sections', 'short', 'short_ansi', 'show_logo_path', 'view_card', 'viewport_frame', 'visible_length', 'write_changed_lines']
|
||||
154
lvpwb/results.py
Normal file
154
lvpwb/results.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def choose_match_outcome(match_type: str | dict[str, Any]) -> str:
|
||||
outcomes = app.allowed_match_outcomes(match_type)
|
||||
if len(outcomes) == 1:
|
||||
print(f"Match outcome: {outcomes[0]}")
|
||||
return outcomes[0]
|
||||
return outcomes[app.ask_choice("Winning method", list(outcomes))]
|
||||
|
||||
|
||||
def prompt_match_minutes() -> float:
|
||||
while True:
|
||||
raw = app.ask("Match time in minutes")
|
||||
value = app.parse_match_minutes(raw)
|
||||
if value is not None:
|
||||
return value
|
||||
print("Enter a non-negative number of minutes (decimals are okay).")
|
||||
|
||||
|
||||
def prompt_action_count(label: str) -> int:
|
||||
while True:
|
||||
raw = app.ask(label, "0")
|
||||
value = app.parse_action_count(raw)
|
||||
if value is not None:
|
||||
return value
|
||||
print("Enter a whole number zero or greater.")
|
||||
|
||||
|
||||
def season_simulation_weights(season: dict[str, Any] | None,
|
||||
match: dict[str, Any]) -> list[float]:
|
||||
"""Higher-ranked division entrants get an edge, but lower ranks can upset."""
|
||||
sides = match.get("sides", [])
|
||||
division = match.get("ranking_division")
|
||||
if not season or not division or division not in app.season_title_names(season):
|
||||
return [1.0] * len(sides)
|
||||
entries = app.season_ranking_entries(season, division)
|
||||
rank_positions = {app.side_identity(entry.get("members", [])): position
|
||||
for position, entry in enumerate(entries)}
|
||||
total = len(entries)
|
||||
weights: list[float] = []
|
||||
holder = season.get("champions", {}).get(division, {}).get("holder")
|
||||
for side in sides:
|
||||
key = app.side_identity(side)
|
||||
position = rank_positions.get(key)
|
||||
if position is None or total <= 1:
|
||||
weight = 1.0
|
||||
else:
|
||||
normalized_rank = (total - 1 - position) / (total - 1)
|
||||
weight = 1.0 + 1.5 * normalized_rank
|
||||
record = next((entry for entry in entries
|
||||
if app.side_identity(entry.get("members", [])) == key), None)
|
||||
if record:
|
||||
wins, losses = record.get("wins", 0), record.get("losses", 0)
|
||||
total_results = wins + losses
|
||||
if total_results:
|
||||
form = wins / total_results
|
||||
weight += (form - 0.5) * 0.3
|
||||
if match.get("title_defense") and holder and app.side_identity(holder) == key:
|
||||
weight *= 1.15
|
||||
weights.append(max(0.5, weight))
|
||||
return weights
|
||||
|
||||
|
||||
def simulate_match_result(card: dict[str, Any], index: int,
|
||||
season: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Apply an auto-simulated winner, a legal finish, and plausible rating inputs."""
|
||||
match = card["matches"][index]
|
||||
sides = match.get("sides", [])
|
||||
if not sides:
|
||||
raise ValueError("Cannot simulate a match before its participants are configured.")
|
||||
weights = app.season_simulation_weights(season, match) if season else [1.0] * len(sides)
|
||||
match["winner_side"] = app.random.choices(range(len(sides)), weights=weights, k=1)[0]
|
||||
match["finish"] = app.random.choice(app.allowed_match_outcomes(match["type"]))
|
||||
gimmick = match["type"] in {"Cage", "Ladder", "Hardcore", "Table", "TLC", "Hell in a Cell"}
|
||||
low, high = (8, 28) if gimmick or match.get("title_defense") else (5, 20)
|
||||
if index == 2:
|
||||
high += 5
|
||||
match["match_time_minutes"] = app.random.randint(low, high)
|
||||
match["near_falls"] = app.random.randint(0, 5)
|
||||
match["finishers"] = app.random.randint(1, 3)
|
||||
match["rating"] = app.calculate_match_rating(match)
|
||||
return match
|
||||
|
||||
|
||||
def record_match_result(card: dict[str, Any], index: int, auto_simulate: bool = False) -> bool:
|
||||
match = card["matches"][index]
|
||||
season: dict[str, Any] | None = None
|
||||
if card.get("season_id"):
|
||||
try:
|
||||
season = app.load_season(str(card["season_id"]))
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"Cannot verify season data: {exc}")
|
||||
return False
|
||||
if match.get("title_defense") and season and not app.validate_title_defense(season, match):
|
||||
print("Edit this match's participants or title status before recording its result.")
|
||||
return False
|
||||
|
||||
if auto_simulate:
|
||||
app.simulate_match_result(card, index, season)
|
||||
print(f"Auto-simulated Match {index + 1}: {' & '.join(match['sides'][match['winner_side']])} "
|
||||
f"wins by {match['finish'].lower()} in {match['match_time_minutes']} minutes.")
|
||||
else:
|
||||
sides = match["sides"]
|
||||
side_labels = [f"Side {side_number}: {' & '.join(side)}"
|
||||
for side_number, side in enumerate(sides, 1)]
|
||||
match["winner_side"] = app.ask_choice(
|
||||
"Winning side", list(range(len(sides))), labels=side_labels
|
||||
)
|
||||
match["finish"] = app.choose_match_outcome(match)
|
||||
match["match_time_minutes"] = app.prompt_match_minutes()
|
||||
match["near_falls"] = app.prompt_action_count("Near falls (two-count kickouts)")
|
||||
match["finishers"] = app.prompt_action_count("Finishers executed")
|
||||
match["rating"] = app.calculate_match_rating(match)
|
||||
app.save_card(card)
|
||||
if card.get("season_id"):
|
||||
app.sync_season_card_results(card)
|
||||
print(f"Result saved to {card['path']} · Match rating: {match['rating']:.1f}%.")
|
||||
ppv_rating = app.calculate_ppv_rating(card)
|
||||
if ppv_rating is not None:
|
||||
print(f"PPV rating (20/30/50 match weighting): {ppv_rating:.1f}%")
|
||||
return True
|
||||
|
||||
|
||||
def record_result(card: dict[str, Any], auto_simulate: bool | None = None,
|
||||
match_index: int | None = None) -> None:
|
||||
pending = app.pending_indices(card)
|
||||
if not pending:
|
||||
print("All three matches are complete.")
|
||||
return
|
||||
index = match_index if match_index in pending else app.choose_match_index(
|
||||
card, pending, "Choose a match to finish"
|
||||
)
|
||||
if auto_simulate is None:
|
||||
auto_simulate = app.ask_choice(
|
||||
"Result mode", ["Enter result manually", "Auto-simulate result"]
|
||||
) == 1
|
||||
app.record_match_result(card, index, auto_simulate=auto_simulate)
|
||||
|
||||
|
||||
def simulate_pending_matches(card: dict[str, Any]) -> None:
|
||||
pending = app.pending_indices(card)
|
||||
if not pending:
|
||||
print("All three matches are complete.")
|
||||
return
|
||||
for index in pending:
|
||||
if not app.record_match_result(card, index, auto_simulate=True):
|
||||
break
|
||||
|
||||
__all__ = ['choose_match_outcome', 'prompt_action_count', 'prompt_match_minutes', 'record_match_result', 'record_result', 'season_simulation_weights', 'simulate_match_result', 'simulate_pending_matches']
|
||||
104
lvpwb/screenshots.py
Normal file
104
lvpwb/screenshots.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
SCREENSHOT_STAMP_RE = re.compile(
|
||||
r"(?P<y>\d{4})-(?P<mo>\d{2})-(?P<d>\d{2})-(?P<h>\d{2})-(?P<mi>\d{2})-(?P<s>\d{2})"
|
||||
)
|
||||
|
||||
|
||||
def parse_screenshot_stamp(filename: str) -> datetime | None:
|
||||
"""Parse 'GameTitle yyyy-mm-dd-hh-mm-ss.png' into an aware local datetime."""
|
||||
match = app.SCREENSHOT_STAMP_RE.search(filename)
|
||||
if not match:
|
||||
return None
|
||||
parts = {key: int(value) for key, value in match.groupdict().items()}
|
||||
try:
|
||||
return datetime(parts["y"], parts["mo"], parts["d"],
|
||||
parts["h"], parts["mi"], parts["s"]).astimezone()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def screenshot_taken_at(path: Path) -> datetime:
|
||||
"""Screenshot time from the filename stamp, falling back to file mtime."""
|
||||
stamp = app.parse_screenshot_stamp(path.name)
|
||||
if stamp is not None:
|
||||
return stamp
|
||||
return datetime.fromtimestamp(path.stat().st_mtime).astimezone()
|
||||
|
||||
|
||||
def session_screenshots(since: datetime | None = None,
|
||||
directory: Path | None = None) -> list[Path]:
|
||||
"""DuckStation screenshots taken after `since` (default: app session start)."""
|
||||
since = since or app.SESSION_START
|
||||
directory = directory or app.DUCKSTATION_SCREENSHOT_DIR
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
found: list[tuple[datetime, Path]] = []
|
||||
for path in directory.glob("*.png"):
|
||||
try:
|
||||
taken = app.screenshot_taken_at(path)
|
||||
except OSError:
|
||||
continue
|
||||
if taken >= since:
|
||||
found.append((taken, path))
|
||||
found.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
||||
return [path for _, path in found]
|
||||
|
||||
|
||||
def choose_screenshot(directory: Path | None = None) -> Path | None:
|
||||
"""Pick a screenshot from those captured since the app was opened."""
|
||||
directory = directory or app.DUCKSTATION_SCREENSHOT_DIR
|
||||
candidates = app.session_screenshots(directory=directory)
|
||||
if candidates:
|
||||
print(f"\nScreenshots taken since the app opened ({directory}):")
|
||||
for index, path in enumerate(candidates, 1):
|
||||
taken = app.screenshot_taken_at(path)
|
||||
print(f" {index}. {taken:%Y-%m-%d %H:%M:%S} — {path.name}")
|
||||
else:
|
||||
print(f"\nNo screenshots found since the app opened in {directory}.")
|
||||
while True:
|
||||
raw = app.ask("Screenshot number or path (blank to cancel)").strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw.isdigit() and candidates and 1 <= int(raw) <= len(candidates):
|
||||
return candidates[int(raw) - 1]
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = directory / path
|
||||
if path.is_file():
|
||||
return path
|
||||
print(f"No such screenshot: {path}")
|
||||
|
||||
|
||||
def attach_match_image(card: dict[str, Any]) -> None:
|
||||
"""Attach or change a screenshot for any match on a PS1-game card."""
|
||||
if not app.game_has_screenshots(card["game_id"]):
|
||||
print("Screenshot attachments are available for the PS1 games only.")
|
||||
return
|
||||
index = app.choose_match_index(card, list(range(len(card["matches"]))), "Choose a match")
|
||||
match = card["matches"][index]
|
||||
current = str(match.get("image") or "")
|
||||
if current:
|
||||
print(f"Current image: {current}")
|
||||
chosen = app.choose_screenshot()
|
||||
if chosen is None:
|
||||
print("Keep or skip the image; card unchanged.")
|
||||
return
|
||||
match["image"] = str(chosen)
|
||||
app.save_card(card)
|
||||
print(f"Image saved for Match {index + 1}: {match['image']}")
|
||||
|
||||
|
||||
def game_has_screenshots(game_id: str) -> bool:
|
||||
return game_id in app.PS1_GAMES or (
|
||||
game_id in app.CUSTOM_GAME_CONFIGS and app.CUSTOM_GAME_CONFIGS[game_id]["platform"] == "ps1"
|
||||
)
|
||||
|
||||
__all__ = ['SCREENSHOT_STAMP_RE', 'attach_match_image', 'choose_screenshot', 'game_has_screenshots', 'parse_screenshot_stamp', 'screenshot_taken_at', 'session_screenshots']
|
||||
305
lvpwb/season_booking.py
Normal file
305
lvpwb/season_booking.py
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def title_defense_weight(kind: str, brand: str) -> float:
|
||||
base = {"title": 1.4, "rivalry": 1.2, "ranked": 1.3, "pool": 1.0}[kind]
|
||||
if brand == "PPV" and kind == "title":
|
||||
return base * app.PPV_TITLE_WEIGHT
|
||||
if brand == "PPV" and kind == "rivalry":
|
||||
return base * app.PPV_RIVALRY_WEIGHT
|
||||
return base
|
||||
|
||||
|
||||
def make_season_match(game_id: str, sides: list[list[str]], division: str | None,
|
||||
title: str = "", title_defense: bool = False,
|
||||
rivalry_payoff: bool = False,
|
||||
profile: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
sizes = tuple(len(side) for side in sides)
|
||||
options = [item for item in app.match_type_definitions(game_id, profile)
|
||||
if tuple(item["sides"]) == sizes]
|
||||
definition = app.random.choice(options) if options else app.match_type_definition(game_id, "Singles", profile)
|
||||
match_type = definition["name"]
|
||||
return {
|
||||
"title": title,
|
||||
"type": match_type,
|
||||
"sides": [list(side) for side in sides],
|
||||
"winner_side": None,
|
||||
"finish": "",
|
||||
"promo": None,
|
||||
"image": "",
|
||||
"ranking_division": division,
|
||||
"title_defense": title_defense,
|
||||
"rivalry_payoff": rivalry_payoff,
|
||||
"match_time_minutes": None,
|
||||
"near_falls": 0,
|
||||
"finishers": 0,
|
||||
"rating": None,
|
||||
"base_rating": definition["base_rating"],
|
||||
"valid_outcomes": list(definition["outcomes"]),
|
||||
}
|
||||
|
||||
|
||||
def division_roster(game_id: str, division: str, roster: list[str], used: set[str],
|
||||
holder: list[str] | None = None,
|
||||
profile: dict[str, Any] | None = None) -> list[str]:
|
||||
profile = profile or app.game_profile(game_id)
|
||||
candidates = app.eligible_roster_for_division(game_id, division, roster, profile)
|
||||
is_tag = app.championship_kind(game_id, division, profile) == "tag"
|
||||
excluded = {name.casefold() for name in used}
|
||||
if holder:
|
||||
excluded.update(name.casefold() for name in holder)
|
||||
available = [name for name in candidates if name.casefold() not in excluded]
|
||||
if len(available) < (4 if is_tag else 2):
|
||||
available = [name for name in candidates if name.casefold() not in {n.casefold() for n in used}]
|
||||
return available if len(available) >= (4 if is_tag else 2) else candidates
|
||||
|
||||
|
||||
def seed_division_sides(game_id: str, division: str, roster: list[str], used: set[str],
|
||||
holder: list[str] | None = None,
|
||||
profile: dict[str, Any] | None = None) -> list[list[str]] | None:
|
||||
profile = profile or app.game_profile(game_id)
|
||||
available = app.division_roster(game_id, division, roster, used, holder, profile)
|
||||
is_tag = app.championship_kind(game_id, division, profile) == "tag"
|
||||
needed = 4 if is_tag else 2
|
||||
if len(available) < needed:
|
||||
return None
|
||||
picks = app.random.sample(available, needed)
|
||||
if is_tag:
|
||||
return [picks[:2], picks[2:]]
|
||||
return [[picks[0]], [picks[1]]]
|
||||
|
||||
|
||||
def season_booking_candidates(season: dict[str, Any], roster: list[str], brand: str,
|
||||
used: set[str]) -> list[dict[str, Any]]:
|
||||
game_id = season["game_id"]
|
||||
profile = app.season_game_profile(season)
|
||||
candidates: list[dict[str, Any]] = []
|
||||
used_folded = {name.casefold() for name in used}
|
||||
|
||||
def add(kind: str, match: dict[str, Any]) -> None:
|
||||
members = {name.casefold() for side in match["sides"] for name in side}
|
||||
participant_count = sum(len(side) for side in match["sides"])
|
||||
if len(members) == participant_count and not members.intersection(used_folded):
|
||||
candidates.append({"kind": kind, "weight": app.title_defense_weight(kind, brand),
|
||||
"match": match})
|
||||
|
||||
# A division's top-ranked non-champion is the natural challenger.
|
||||
for division in app.season_title_names(season):
|
||||
championship = season["champions"][division]
|
||||
holder = championship.get("holder")
|
||||
entries = app.season_ranking_entries(season, division)
|
||||
eligible_size = 2 if app.season_championship_kind(season, division) == "tag" else 1
|
||||
valid_entries = [entry for entry in entries
|
||||
if len(entry.get("members", [])) == eligible_size]
|
||||
if holder:
|
||||
challengers = [entry for entry in valid_entries
|
||||
if app.side_identity(entry["members"]) != app.side_identity(holder)
|
||||
and not ({name.casefold() for name in entry["members"]} & used_folded)]
|
||||
if challengers:
|
||||
challenger = challengers[0]["members"]
|
||||
rivalry_id = app.season_rivalry_id([list(holder), list(challenger)])
|
||||
prior_rivalry = season.get("rivalries", {}).get(rivalry_id or "", {})
|
||||
payoff = (brand == "PPV" and not prior_rivalry.get("resolved")
|
||||
and prior_rivalry.get("meetings", 0) >= 1)
|
||||
match = app.make_season_match(game_id, [list(holder), list(challenger)], division,
|
||||
title=(f"{division} Championship — Feud Finale"
|
||||
if payoff else f"{division} Championship"),
|
||||
title_defense=True, rivalry_payoff=payoff,
|
||||
profile=profile)
|
||||
add("title", match)
|
||||
elif len(valid_entries) >= 2:
|
||||
challengers = [entry["members"] for entry in valid_entries
|
||||
if not ({name.casefold() for name in entry["members"]} & used_folded)]
|
||||
if len(challengers) >= 2:
|
||||
match = app.make_season_match(
|
||||
game_id, [list(challengers[0]), list(challengers[1])], division,
|
||||
title=f"Vacant {division} Championship", title_defense=True,
|
||||
profile=profile,
|
||||
)
|
||||
add("title", match)
|
||||
|
||||
# Non-title contender bouts seed or improve the division rankings.
|
||||
ranked_sides = [list(entry["members"]) for entry in valid_entries
|
||||
if not ({name.casefold() for name in entry.get("members", [])} & used_folded)]
|
||||
sides: list[list[str]] | None = None
|
||||
if len(ranked_sides) >= 2:
|
||||
sides = ranked_sides[:2]
|
||||
elif len(ranked_sides) == 1:
|
||||
available = app.division_roster(game_id, division, roster, used, holder, profile)
|
||||
available = [name for name in available
|
||||
if name.casefold() not in {n.casefold() for n in ranked_sides[0]}]
|
||||
needed = 2 if app.season_championship_kind(season, division) == "tag" else 1
|
||||
if len(available) >= needed:
|
||||
new_side = app.random.sample(available, needed)
|
||||
sides = [ranked_sides[0], new_side]
|
||||
if sides is None:
|
||||
sides = app.seed_division_sides(game_id, division, roster, used, holder, profile)
|
||||
if sides:
|
||||
add("ranked", app.make_season_match(game_id, sides, division, profile=profile))
|
||||
|
||||
# Repeated head-to-head meetings become active rivalries; PPVs can resolve them.
|
||||
for rivalry in season.get("rivalries", {}).values():
|
||||
sides = rivalry.get("sides", [])
|
||||
if rivalry.get("resolved") or rivalry.get("meetings", 0) < 2 or len(sides) != 2:
|
||||
continue
|
||||
division = rivalry.get("ranking_division")
|
||||
payoff = brand == "PPV"
|
||||
title = "Rivalry Finale" if payoff else "Rivalry Match"
|
||||
add("rivalry", app.make_season_match(game_id, sides, division, title=title,
|
||||
rivalry_payoff=payoff, profile=profile))
|
||||
|
||||
# The Others pool supplies unranked, non-title bouts.
|
||||
pool = [name for name in app.season_other_pool(season, roster)
|
||||
if name.casefold() not in used_folded]
|
||||
if len(pool) < 2:
|
||||
pool = [name for name in roster if name.casefold() not in used_folded]
|
||||
if len(pool) >= 2:
|
||||
available_types = [item for item in app.match_type_definitions(game_id, profile)
|
||||
if tuple(item["sides"]) in ((1, 1), (2, 2), (1, 1, 1))]
|
||||
sizes = tuple(app.random.choice(available_types)["sides"])
|
||||
needed = sum(sizes)
|
||||
choices = pool if len(pool) >= needed else [name for name in roster
|
||||
if name.casefold() not in used_folded]
|
||||
if len(choices) < needed:
|
||||
choices = list(roster)
|
||||
picks = app.random.sample(choices, needed)
|
||||
sides: list[list[str]] = []
|
||||
cursor = 0
|
||||
for size in sizes:
|
||||
sides.append(picks[cursor:cursor + size])
|
||||
cursor += size
|
||||
add("pool", app.make_season_match(game_id, sides, None, title="Non-Title Match",
|
||||
profile=profile))
|
||||
return candidates
|
||||
|
||||
|
||||
def build_season_matches(season: dict[str, Any], brand: str) -> list[dict[str, Any]]:
|
||||
profile = app.season_game_profile(season)
|
||||
roster = list(profile.get("roster", app.load_roster(season["game_id"])))
|
||||
used: set[str] = set()
|
||||
matches: list[dict[str, Any]] = []
|
||||
title_defenses = 0
|
||||
max_title_defenses = 2 if brand == "PPV" else 1
|
||||
for _ in range(3):
|
||||
candidates = app.season_booking_candidates(season, roster, brand, used)
|
||||
if title_defenses >= max_title_defenses:
|
||||
candidates = [candidate for candidate in candidates if candidate["kind"] != "title"]
|
||||
if not candidates:
|
||||
fallback = app.random_match(season["game_id"], roster, used, profile=profile)
|
||||
matches.append(fallback)
|
||||
used.update(name.casefold() for side in fallback["sides"] for name in side)
|
||||
continue
|
||||
picked = app.random.choices(candidates, weights=[item["weight"] for item in candidates], k=1)[0]
|
||||
match = picked["match"]
|
||||
matches.append(match)
|
||||
used.update(name.casefold() for side in match["sides"] for name in side)
|
||||
if match.get("title_defense"):
|
||||
title_defenses += 1
|
||||
return matches
|
||||
|
||||
|
||||
def show_for_season_number(show_number: int) -> str:
|
||||
return app.season_show_brand(show_number)
|
||||
|
||||
|
||||
def book_next_season_show(season: dict[str, Any]) -> dict[str, Any]:
|
||||
current_path = str(season.get("current_card") or "")
|
||||
if current_path and Path(current_path).is_file():
|
||||
card = app.load_card(Path(current_path))
|
||||
app.sync_season_card_results(card)
|
||||
updated = app.load_season(str(season["season_id"]))
|
||||
if updated.get("current_card"):
|
||||
raise ValueError("Finish the current season show before booking the next one.")
|
||||
season.clear()
|
||||
season.update(updated)
|
||||
number = int(season.get("next_show_number", 1))
|
||||
brand = app.show_for_season_number(number)
|
||||
show = app.show_name_for_brand(brand, season.get("shows"))
|
||||
city, attendance = app.random_location()
|
||||
show_date = date.today().isoformat()
|
||||
card: dict[str, Any] = {
|
||||
"game_id": season["game_id"], "show": show, "date": show_date,
|
||||
"city": city, "attendance": attendance,
|
||||
"season_id": season["season_id"], "season_show": brand,
|
||||
"season_show_number": number,
|
||||
"matches": app.build_season_matches(season, brand),
|
||||
"path": str(app.default_card_path(show, show_date)),
|
||||
}
|
||||
app.save_card(card)
|
||||
season["current_card"] = card["path"]
|
||||
app.save_season(season)
|
||||
print(f"\nBooked Season Show {number}: {show} ({brand})")
|
||||
return card
|
||||
|
||||
|
||||
def season_files() -> list[Path]:
|
||||
return sorted(app.SEASON_DIR.glob("*.json"), reverse=True) if app.SEASON_DIR.is_dir() else []
|
||||
|
||||
|
||||
def choose_or_create_season() -> dict[str, Any]:
|
||||
paths = app.season_files()
|
||||
labels = ["Start a new season"]
|
||||
for path in paths:
|
||||
try:
|
||||
saved = app.load_season(path)
|
||||
next_brand = app.season_show_brand(saved.get("next_show_number", 1))
|
||||
next_show = app.show_name_for_brand(next_brand, saved.get("shows"))
|
||||
labels.append(f"{saved['name']} · {saved.get('federation_name', app.federation_name(saved['game_id']))}"
|
||||
f" · {saved.get('game_title', app.game_title(saved['game_id']))} · next: {next_show}")
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
labels.append(f"{path.stem} (invalid save)")
|
||||
selected = app.ask_choice("Season Mode", ["new"] + [path.name for path in paths], labels=labels)
|
||||
if selected == 0:
|
||||
return app.create_season()
|
||||
return app.load_season(paths[selected - 1])
|
||||
|
||||
|
||||
def run_season(season: dict[str, Any]) -> None:
|
||||
while True:
|
||||
number = int(season.get("next_show_number", 1))
|
||||
brand = app.season_show_brand(number)
|
||||
current = str(season.get("current_card") or "")
|
||||
if current and not Path(current).is_file():
|
||||
season["current_card"] = ""
|
||||
app.save_season(season)
|
||||
current = ""
|
||||
print(f"\nSeason: {season['name']} · {season.get('federation_name', app.federation_name(season['game_id']))}"
|
||||
f" · {season.get('game_title', app.game_title(season['game_id']))}")
|
||||
next_name = app.show_name_for_brand(brand, season.get("shows"))
|
||||
print(f"Cycle {((number - 1) // 3) + 1} · Next: {next_name} ({brand})")
|
||||
if current:
|
||||
next_action = "Resume current show"
|
||||
else:
|
||||
next_action = f"Advance to Next Show ({next_name})"
|
||||
choice = app.ask_choice(
|
||||
"Season action", ["book/resume", "rankings", "back"],
|
||||
labels=[next_action, "Power rankings / title holders", "Back to main menu"],
|
||||
)
|
||||
if choice == 0:
|
||||
card = app.load_card(Path(current)) if current else app.book_next_season_show(season)
|
||||
app.sync_season_card_results(card)
|
||||
app.resume_card(card)
|
||||
# Refresh the season's progression after returning from a card.
|
||||
season_id = str(season["season_id"])
|
||||
season.clear()
|
||||
season.update(app.load_season(season_id))
|
||||
elif choice == 1:
|
||||
app.print_power_rankings(season)
|
||||
input("\nPress Enter to return...")
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
def season_mode() -> None:
|
||||
try:
|
||||
app.run_season(app.choose_or_create_season())
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"Could not open or run that season: {exc}")
|
||||
|
||||
__all__ = ['book_next_season_show', 'build_season_matches', 'choose_or_create_season', 'division_roster', 'make_season_match', 'run_season', 'season_booking_candidates', 'season_files', 'season_mode', 'seed_division_sides', 'show_for_season_number', 'title_defense_weight']
|
||||
334
lvpwb/seasons.py
Normal file
334
lvpwb/seasons.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import lvpwb as app
|
||||
|
||||
def season_path(season_id: str) -> Path:
|
||||
return app.SEASON_DIR / f"{app.slugify(season_id)}.json"
|
||||
|
||||
|
||||
def new_season_state(name: str, game_id: str,
|
||||
champions: dict[str, list[str] | None] | None = None,
|
||||
season_id: str | None = None) -> dict[str, Any]:
|
||||
champions = champions or {}
|
||||
profile = app.game_profile(game_id)
|
||||
title_definitions = [dict(item) for item in profile["championships"]]
|
||||
title_state: dict[str, Any] = {}
|
||||
for definition in title_definitions:
|
||||
division = definition["name"]
|
||||
holder = champions.get(division)
|
||||
title_state[division] = {
|
||||
"holder": list(holder) if holder else None,
|
||||
"successful_defenses": 0,
|
||||
"defense_history": {},
|
||||
}
|
||||
if holder:
|
||||
key = app.side_identity(holder)
|
||||
title_state[division]["defense_history"][key] = {
|
||||
"members": list(holder), "successful_defenses": 0,
|
||||
}
|
||||
if season_id is None:
|
||||
season_id = f"{app.slugify(name)}-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}"
|
||||
return {
|
||||
"season_id": season_id,
|
||||
"name": name,
|
||||
"game_id": game_id,
|
||||
"game_title": profile["game_title"],
|
||||
"federation_name": profile["federation_name"],
|
||||
"game_config": profile,
|
||||
"title_definitions": title_definitions,
|
||||
"shows": app.shows_snapshot(profile),
|
||||
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"next_show_number": 1,
|
||||
"current_card": "",
|
||||
"champions": title_state,
|
||||
"rankings": {definition["name"]: {} for definition in title_definitions},
|
||||
"rivalries": {},
|
||||
"processed_results": [],
|
||||
"show_history": [],
|
||||
}
|
||||
|
||||
|
||||
def save_season(season: dict[str, Any]) -> None:
|
||||
app.SEASON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = app.season_path(season["season_id"])
|
||||
path.write_text(json.dumps(season, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def load_season(path_or_id: Path | str) -> dict[str, Any]:
|
||||
path = path_or_id if isinstance(path_or_id, Path) else app.season_path(path_or_id)
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict) or payload.get("game_id") not in app.GAMES:
|
||||
raise ValueError(f"Invalid season save: {path}")
|
||||
if not payload.get("season_id") or not payload.get("name"):
|
||||
raise ValueError(f"Season save is missing its identity: {path}")
|
||||
payload.setdefault("game_config", app.game_profile(payload["game_id"]))
|
||||
payload.setdefault("game_title", payload["game_config"].get("game_title", app.game_title(payload["game_id"])))
|
||||
payload.setdefault("federation_name", payload["game_config"].get(
|
||||
"federation_name", app.federation_name(payload["game_id"])
|
||||
))
|
||||
payload.setdefault("title_definitions", payload["game_config"].get(
|
||||
"championships", app.title_definitions_for_game(payload["game_id"])
|
||||
))
|
||||
payload.setdefault("shows", app.shows_snapshot(payload.get("game_config")))
|
||||
title_names = app.season_title_names(payload)
|
||||
payload.setdefault("next_show_number", 1)
|
||||
payload.setdefault("current_card", "")
|
||||
payload.setdefault("rankings", {division: {} for division in title_names})
|
||||
payload.setdefault("champions", {})
|
||||
payload.setdefault("rivalries", {})
|
||||
payload.setdefault("processed_results", [])
|
||||
payload.setdefault("show_history", [])
|
||||
for division in title_names:
|
||||
payload.setdefault("rankings", {}).setdefault(division, {})
|
||||
payload.setdefault("champions", {}).setdefault(
|
||||
division, {"holder": None, "successful_defenses": 0, "defense_history": {}}
|
||||
)
|
||||
payload["champions"][division].setdefault("successful_defenses", 0)
|
||||
payload["champions"][division].setdefault("defense_history", {})
|
||||
return payload
|
||||
|
||||
|
||||
def side_identity(side: list[str]) -> str:
|
||||
return json.dumps(sorted(side, key=str.casefold), ensure_ascii=False)
|
||||
|
||||
|
||||
def side_display(side: list[str] | None) -> str:
|
||||
return "Vacant" if not side else " & ".join(side)
|
||||
|
||||
|
||||
def eligible_roster_for_division(game_id: str, division: str,
|
||||
roster: list[str] | None = None,
|
||||
profile: dict[str, Any] | None = None) -> list[str]:
|
||||
profile = profile or app.game_profile(game_id)
|
||||
roster = list(profile.get("roster", [])) if roster is None else roster
|
||||
if app.championship_kind(game_id, division, profile) == "women":
|
||||
eligible_names = profile.get("women_roster", [])
|
||||
eligible = {name.casefold() for name in eligible_names}
|
||||
return [name for name in roster if name.casefold() in eligible]
|
||||
return list(roster)
|
||||
|
||||
|
||||
def choose_roster_member(names: list[str], prompt: str) -> str:
|
||||
if not names:
|
||||
raise ValueError("No eligible wrestlers are available for that championship.")
|
||||
return names[app.ask_choice(prompt, names)]
|
||||
|
||||
|
||||
def choose_initial_champion(division: str, game_id: str, used: set[str],
|
||||
profile: dict[str, Any] | None = None) -> list[str] | None:
|
||||
profile = profile or app.game_profile(game_id)
|
||||
roster = app.eligible_roster_for_division(game_id, division, profile=profile)
|
||||
is_tag_title = app.championship_kind(game_id, division, profile) == "tag"
|
||||
print(f"\nInitial {division} champion:")
|
||||
mode = app.ask_choice("Choose initial champion option", ["choose", "random", "vacant"],
|
||||
labels=["Choose wrestler/team", "Random", "Vacant"])
|
||||
if mode == 2: # ask_choice is zero-based: option 3 is Vacant.
|
||||
return None
|
||||
choose_manually = mode == 0 # option 1 is Choose; option 2 is Random.
|
||||
available = [name for name in roster if name.casefold() not in used]
|
||||
if len(available) < (2 if is_tag_title else 1):
|
||||
available = roster
|
||||
if is_tag_title:
|
||||
if choose_manually:
|
||||
first = app.choose_roster_member(available, "First tag champion")
|
||||
remaining = [name for name in available if name != first] or available
|
||||
second = app.choose_roster_member(remaining, "Second tag champion")
|
||||
return [first, second]
|
||||
return app.random.sample(available, 2)
|
||||
if choose_manually:
|
||||
return [app.choose_roster_member(available, f"{division} champion")]
|
||||
return [app.random.choice(available)]
|
||||
|
||||
|
||||
def create_season() -> dict[str, Any]:
|
||||
game_id = app.choose_game()
|
||||
profile = app.game_profile(game_id)
|
||||
name = app.ask("Season name", f"{app.game_display_name(game_id)} Season")
|
||||
champions: dict[str, list[str] | None] = {}
|
||||
used: set[str] = set()
|
||||
for definition in profile["championships"]:
|
||||
division = definition["name"]
|
||||
holder = app.choose_initial_champion(division, game_id, used, profile)
|
||||
champions[division] = holder
|
||||
if holder:
|
||||
used.update(name.casefold() for name in holder)
|
||||
season = app.new_season_state(name, game_id, champions)
|
||||
app.save_season(season)
|
||||
print(f"Season saved: {app.season_path(season['season_id'])}")
|
||||
return season
|
||||
|
||||
|
||||
def season_show_brand(show_number: int) -> str:
|
||||
return app.SEASON_CYCLE[(show_number - 1) % len(app.SEASON_CYCLE)]
|
||||
|
||||
|
||||
def season_ranking_entries(season: dict[str, Any], division: str) -> list[dict[str, Any]]:
|
||||
entries = list(season.get("rankings", {}).get(division, {}).values())
|
||||
|
||||
def ranking_key(entry: dict[str, Any]) -> tuple[Any, ...]:
|
||||
wins, losses = entry.get("wins", 0), entry.get("losses", 0)
|
||||
total = wins + losses
|
||||
percentage = wins / total if total else 0.0
|
||||
recent_wins = sum(1 for result in entry.get("recent", [])[-5:] if result == "W")
|
||||
return (wins, percentage, recent_wins, -losses,
|
||||
app.side_display(entry.get("members", [])).casefold())
|
||||
|
||||
return sorted(entries, key=ranking_key, reverse=True)
|
||||
|
||||
|
||||
def season_other_pool(season: dict[str, Any], roster: list[str] | None = None) -> list[str]:
|
||||
"""Unassigned wrestlers for non-title booking; this is not a title division."""
|
||||
profile = app.season_game_profile(season)
|
||||
roster = roster or list(profile.get("roster", app.load_roster(season["game_id"])))
|
||||
involved: set[str] = set()
|
||||
for division in app.season_title_names(season):
|
||||
for entry in season.get("rankings", {}).get(division, {}).values():
|
||||
involved.update(name.casefold() for name in entry.get("members", []))
|
||||
holder = season.get("champions", {}).get(division, {}).get("holder")
|
||||
if holder:
|
||||
involved.update(name.casefold() for name in holder)
|
||||
return [name for name in roster if name.casefold() not in involved]
|
||||
|
||||
|
||||
def print_power_rankings(season: dict[str, Any]) -> None:
|
||||
print(f"\nPOWER RANKINGS — {season['name']} ({app.game_display_name(season['game_id'])})")
|
||||
for division in app.season_title_names(season):
|
||||
championship = season["champions"][division]
|
||||
holder = championship.get("holder")
|
||||
print(f"\n{division} Champion: {app.side_display(holder)}")
|
||||
if holder:
|
||||
holder_stats = championship.get("defense_history", {}).get(app.side_identity(holder), {})
|
||||
print(f" Successful defenses this reign: {championship.get('successful_defenses', 0)}"
|
||||
f" · Career defenses: {holder_stats.get('successful_defenses', 0)}")
|
||||
history = championship.get("defense_history", {})
|
||||
if history:
|
||||
print(" Title history:")
|
||||
for defense_record in history.values():
|
||||
print(f" {app.side_display(defense_record.get('members', []))}: "
|
||||
f"{defense_record.get('successful_defenses', 0)} successful defenses")
|
||||
entries = app.season_ranking_entries(season, division)
|
||||
if not entries:
|
||||
print(" No division results yet.")
|
||||
continue
|
||||
for position, entry in enumerate(entries[:10], 1):
|
||||
wins, losses = entry.get("wins", 0), entry.get("losses", 0)
|
||||
total = wins + losses
|
||||
percentage = wins / total if total else 0.0
|
||||
print(f" {position:>2}. {app.side_display(entry.get('members', []))}"
|
||||
f" — {wins}-{losses} ({percentage:.0%})")
|
||||
pool = app.season_other_pool(season)
|
||||
print(f"\nOthers pool (not currently in a title division): {len(pool)} wrestlers")
|
||||
print(" " + ", ".join(pool[:20]) + (" …" if len(pool) > 20 else ""))
|
||||
|
||||
|
||||
def season_rivalry_id(sides: list[list[str]]) -> str | None:
|
||||
if len(sides) != 2:
|
||||
return None
|
||||
return json.dumps(sorted((app.side_identity(sides[0]), app.side_identity(sides[1]))),
|
||||
ensure_ascii=False)
|
||||
|
||||
|
||||
def record_season_match(season: dict[str, Any], card: dict[str, Any],
|
||||
match: dict[str, Any], match_index: int) -> None:
|
||||
division = match.get("ranking_division")
|
||||
winner = match.get("winner_side")
|
||||
if division in app.season_title_names(season) and winner is not None:
|
||||
table = season["rankings"].setdefault(division, {})
|
||||
for side_index, side in enumerate(match.get("sides", [])):
|
||||
key = app.side_identity(side)
|
||||
entry = table.setdefault(key, {"members": list(side), "wins": 0, "losses": 0,
|
||||
"recent": []})
|
||||
result = "W" if side_index == winner else "L"
|
||||
entry["wins" if result == "W" else "losses"] += 1
|
||||
entry.setdefault("recent", []).append(result)
|
||||
entry["recent"] = entry["recent"][-10:]
|
||||
|
||||
if match.get("title_defense") and division in app.season_title_names(season) and winner is not None:
|
||||
championship = season["champions"][division]
|
||||
winner_side = list(match["sides"][winner])
|
||||
holder = championship.get("holder")
|
||||
defense_history = championship.setdefault("defense_history", {})
|
||||
if holder and app.side_identity(holder) == app.side_identity(winner_side):
|
||||
championship["successful_defenses"] = championship.get("successful_defenses", 0) + 1
|
||||
lifetime = defense_history.setdefault(app.side_identity(holder), {
|
||||
"members": list(holder), "successful_defenses": 0,
|
||||
})
|
||||
lifetime["successful_defenses"] += 1
|
||||
else:
|
||||
championship["holder"] = winner_side
|
||||
championship["successful_defenses"] = 0
|
||||
defense_history.setdefault(app.side_identity(winner_side), {
|
||||
"members": winner_side, "successful_defenses": 0,
|
||||
})
|
||||
|
||||
rivalry_id = app.season_rivalry_id(match.get("sides", []))
|
||||
if rivalry_id:
|
||||
ordered_sides = sorted((list(match["sides"][0]), list(match["sides"][1])),
|
||||
key=app.side_identity)
|
||||
rivalry = season["rivalries"].setdefault(rivalry_id, {
|
||||
"sides": ordered_sides, "meetings": 0, "winner_counts": {},
|
||||
"resolved": False, "ranking_division": division,
|
||||
})
|
||||
rivalry["meetings"] += 1
|
||||
if winner is not None:
|
||||
winner_key = app.side_identity(match["sides"][winner])
|
||||
counts = rivalry.setdefault("winner_counts", {})
|
||||
counts[winner_key] = counts.get(winner_key, 0) + 1
|
||||
rivalry["last_winner"] = winner_key
|
||||
if match.get("rivalry_payoff"):
|
||||
rivalry["resolved"] = True
|
||||
rivalry["resolved_at"] = card.get("season_show", "")
|
||||
|
||||
|
||||
def validate_title_defense(season: dict[str, Any], match: dict[str, Any]) -> bool:
|
||||
division = match.get("ranking_division")
|
||||
if not match.get("title_defense"):
|
||||
return True
|
||||
if division not in app.season_title_names(season):
|
||||
print("A title defense must be assigned to a championship division.")
|
||||
return False
|
||||
holder = season["champions"][division].get("holder")
|
||||
if holder and not any(app.side_identity(side) == app.side_identity(holder)
|
||||
for side in match.get("sides", [])):
|
||||
print(f"The current {division} champion ({app.side_display(holder)}) must be in this match.")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def sync_season_card_results(card: dict[str, Any]) -> None:
|
||||
season_id = card.get("season_id")
|
||||
if not season_id:
|
||||
return
|
||||
try:
|
||||
season = app.load_season(str(season_id))
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"Season ranking update skipped: {exc}")
|
||||
return
|
||||
processed = set(season.get("processed_results", []))
|
||||
for index, match in enumerate(card["matches"]):
|
||||
if match.get("winner_side") is None:
|
||||
continue
|
||||
result_id = f"{season_id}:{card.get('season_show_number', 0)}:{index + 1}"
|
||||
if result_id in processed:
|
||||
continue
|
||||
if match.get("title_defense") and not app.validate_title_defense(season, match):
|
||||
continue
|
||||
app.record_season_match(season, card, match, index)
|
||||
processed.add(result_id)
|
||||
season["processed_results"] = sorted(processed)
|
||||
complete = all(match.get("winner_side") is not None for match in card["matches"])
|
||||
if complete:
|
||||
if Path(str(season.get("current_card", ""))) == Path(str(card.get("path", ""))):
|
||||
season["current_card"] = ""
|
||||
show_number = int(card.get("season_show_number", season.get("next_show_number", 1)))
|
||||
season["next_show_number"] = max(season.get("next_show_number", 1), show_number + 1)
|
||||
if card.get("path") not in season.setdefault("show_history", []):
|
||||
season["show_history"].append(card["path"])
|
||||
app.save_season(season)
|
||||
|
||||
__all__ = ['choose_initial_champion', 'choose_roster_member', 'create_season', 'eligible_roster_for_division', 'load_season', 'new_season_state', 'print_power_rankings', 'record_season_match', 'save_season', 'season_other_pool', 'season_path', 'season_ranking_entries', 'season_rivalry_id', 'season_show_brand', 'side_display', 'side_identity', 'sync_season_card_results', 'validate_title_defense']
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@ from datetime import datetime
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import smackdown_card_maker as app
|
||||
import lvpwb as app
|
||||
|
||||
|
||||
class RosterTests(unittest.TestCase):
|
||||
Loading…
Add table
Add a link
Reference in a new issue