458 lines
21 KiB
Python
458 lines
21 KiB
Python
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,
|
|
profile: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
champions = champions or {}
|
|
profile = profile or 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": "",
|
|
"control_mode": "booker",
|
|
"player_wrestler": None,
|
|
"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"]))
|
|
if payload["game_id"] in app.BUILTIN_GAME_IDS:
|
|
for definitions in (payload.get("title_definitions"),
|
|
payload["game_config"].get("championships")):
|
|
if isinstance(definitions, list):
|
|
for definition in definitions:
|
|
if (definition.get("name") == "Light-Heavyweight"
|
|
and definition.get("kind") == "singles"):
|
|
definition["kind"] = "light_heavyweight"
|
|
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("control_mode", "booker")
|
|
payload.setdefault("player_wrestler", None)
|
|
if payload["control_mode"] not in {"booker", "wrestler"}:
|
|
payload["control_mode"] = "booker"
|
|
payload["player_wrestler"] = None
|
|
if payload["control_mode"] == "wrestler":
|
|
roster_names = {
|
|
name.casefold() for name in payload["game_config"].get("roster", [])
|
|
if isinstance(name, str)
|
|
}
|
|
selected = payload.get("player_wrestler")
|
|
if not isinstance(selected, str) or selected.casefold() not in roster_names:
|
|
payload["control_mode"] = "booker"
|
|
payload["player_wrestler"] = None
|
|
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 season_control_label(season: dict[str, Any]) -> str:
|
|
if season.get("control_mode") == "wrestler":
|
|
return f"Wrestler ({season.get('player_wrestler') or 'unselected'})"
|
|
return "Booker"
|
|
|
|
|
|
def choose_season_control_mode(season: dict[str, Any]) -> None:
|
|
"""Select and persist Booker or single-wrestler control for a season."""
|
|
current_mode = season.get("control_mode", "booker")
|
|
current_wrestler = season.get("player_wrestler")
|
|
options = ["Booker", "Wrestler"]
|
|
labels = ["Booker — full control", "Wrestler — control one roster member"]
|
|
if current_mode == "wrestler" and current_wrestler:
|
|
labels[1] = f"Wrestler — continue as {current_wrestler}"
|
|
default = 2 if current_mode == "wrestler" else 1
|
|
selected = app.ask_choice("Season control mode", options, default=default, labels=labels)
|
|
if selected == 0:
|
|
season["control_mode"] = "booker"
|
|
season["player_wrestler"] = None
|
|
else:
|
|
profile = app.season_game_profile(season)
|
|
roster = sorted(profile.get("roster") or app.load_roster(season["game_id"]), key=str.casefold)
|
|
if current_mode == "wrestler" and current_wrestler in roster:
|
|
default_member = roster.index(current_wrestler) + 1
|
|
else:
|
|
default_member = None
|
|
season["control_mode"] = "wrestler"
|
|
season["player_wrestler"] = app.choose_roster_member(
|
|
roster, "Choose your wrestler"
|
|
) if default_member is None else roster[
|
|
app.ask_choice("Choose your wrestler", roster, default=default_member)
|
|
]
|
|
app.save_season(season)
|
|
|
|
|
|
def toggle_season_control_mode(season: dict[str, Any]) -> None:
|
|
"""Toggle between full Booker control and control of one wrestler."""
|
|
if season.get("control_mode") == "wrestler":
|
|
season["control_mode"] = "booker"
|
|
season["player_wrestler"] = None
|
|
app.save_season(season)
|
|
print("Control mode changed to Booker.")
|
|
return
|
|
profile = app.season_game_profile(season)
|
|
roster = sorted(profile.get("roster") or app.load_roster(season["game_id"]), key=str.casefold)
|
|
wrestler = app.choose_roster_member(roster, "Choose your wrestler")
|
|
season["control_mode"] = "wrestler"
|
|
season["player_wrestler"] = wrestler
|
|
app.save_season(season)
|
|
print(f"Control mode changed to Wrestler ({wrestler}).")
|
|
|
|
|
|
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
|
|
eligibility_field = {
|
|
"women": "women_roster",
|
|
"light_heavyweight": "light_heavyweight_roster",
|
|
}.get(app.championship_kind(game_id, division, profile))
|
|
if eligibility_field:
|
|
eligible_names = profile.get(eligibility_field)
|
|
# Older seasons do not have a Light-Heavyweight eligibility snapshot.
|
|
if eligible_names is None:
|
|
return list(roster)
|
|
eligible = {name.casefold() for name in eligible_names}
|
|
return [name for name in roster if name.casefold() in eligible]
|
|
return list(roster)
|
|
|
|
|
|
def validate_match_division_eligibility(season: dict[str, Any],
|
|
match: dict[str, Any]) -> bool:
|
|
division = match.get("ranking_division")
|
|
if division not in app.season_title_names(season):
|
|
return True
|
|
if app.season_championship_kind(season, division) not in {"women", "light_heavyweight"}:
|
|
return True
|
|
profile = app.season_game_profile(season)
|
|
eligible = {
|
|
name.casefold()
|
|
for name in app.eligible_roster_for_division(
|
|
season["game_id"], division, profile=profile
|
|
)
|
|
}
|
|
excluded = [
|
|
name for side in match.get("sides", []) for name in side
|
|
if name.casefold() not in eligible
|
|
]
|
|
if excluded:
|
|
names = ", ".join(dict.fromkeys(excluded))
|
|
print(f"{names} is not eligible for the {division} division.")
|
|
return False
|
|
return True
|
|
|
|
|
|
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")
|
|
has_light_heavyweight = any(
|
|
definition.get("kind") == "light_heavyweight"
|
|
for definition in profile["championships"]
|
|
)
|
|
if has_light_heavyweight and game_id in app.BUILTIN_GAME_IDS:
|
|
profile["light_heavyweight_roster"] = []
|
|
app.edit_light_heavyweight_roster(profile)
|
|
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, profile=profile)
|
|
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) -> bool:
|
|
if not app.validate_match_division_eligibility(season, match):
|
|
return False
|
|
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", "")
|
|
return True
|
|
|
|
|
|
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
|
|
if not app.validate_match_division_eligibility(season, match):
|
|
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 not app.validate_match_division_eligibility(season, match):
|
|
continue
|
|
if match.get("title_defense") and not app.validate_title_defense(season, match):
|
|
continue
|
|
if not app.record_season_match(season, card, match, index):
|
|
continue
|
|
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', 'choose_season_control_mode', 'create_season', 'eligible_roster_for_division', 'load_season', 'new_season_state', 'print_power_rankings', 'record_season_match', 'save_season', 'season_control_label', 'season_other_pool', 'season_path', 'season_ranking_entries', 'season_rivalry_id', 'season_show_brand', 'side_display', 'side_identity', 'sync_season_card_results', 'toggle_season_control_mode', 'validate_match_division_eligibility', 'validate_title_defense']
|