LVPWB/lvpwb/seasons.py

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']