Add two control modes: Booker and Wrestler for Season mode

This commit is contained in:
markmental 2026-11-02 17:33:52 -05:00
commit 2da4f0447c
6 changed files with 336 additions and 17 deletions

View file

@ -23,6 +23,13 @@ champion. Successful defenses are tracked by champion across reigns. The
“Others” pool contains wrestlers not currently involved in title divisions; it
is not a championship.
Season Mode offers **Booker** control (full card control) and **Wrestler**
control. In Wrestler control, choose one roster member: you record results only
for matches featuring that wrestler, while the remaining matches are simulated.
The booker may leave your wrestler off a show, in which case the entire card is
simulated. Rankings and booking still progress, and control mode can be toggled
from the Season action menu.
After all matches on a season card are complete, choose **Advance to Next Show**
to return to the season hub. From there you can inspect power rankings, exit, or
book the next show. While matches remain, the card menu offers **Return to Season

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
@ -321,6 +322,15 @@ def choose_ranking_division(game_id: str, current: str | None = None,
def edit_match(card: dict[str, Any]) -> 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 control mode: {exc}")
season = None
if season and season.get("control_mode") == "wrestler":
print("Matches cannot be edited in Wrestler control.")
return
editable = app.editable_indices(card)
if not editable:
print("There are no unfinished matches to edit.")
@ -365,7 +375,14 @@ def edit_match(card: dict[str, Any]) -> None:
def resume_card(card: dict[str, Any]) -> bool:
season = None
if card.get("season_id"):
try:
season = app.load_season(str(card["season_id"]))
except (OSError, ValueError, json.JSONDecodeError):
season = None
if season and season.get("control_mode") == "wrestler":
app.simulate_non_player_matches(card, season)
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']))}"
@ -373,24 +390,29 @@ def resume_card(card: dict[str, Any]) -> bool:
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)],
)
wrestler_mode = bool(season and season.get("control_mode") == "wrestler")
if wrestler_mode:
actions = ["view", "result", "promo", "image", "exit"]
labels = ["View card", "Record your match result",
"Generate a promo before the next match", "Attach/edit a match image",
app.season_card_exit_label(card)]
else:
actions = ["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)]
choice = app.ask_choice("Card action", actions, labels=labels)
if choice == 0:
app.view_card(card)
elif choice == 1:
app.record_result(card)
elif choice == 2:
elif choice == 2 and not wrestler_mode:
app.edit_match(card)
elif choice == 3:
elif choice == (2 if wrestler_mode else 3):
app.generate_promo(card)
elif choice == 4:
elif choice == (3 if wrestler_mode else 4):
app.attach_match_image(card)
elif choice == 5:
elif choice == 5 and not wrestler_mode:
app.simulate_pending_matches(card)
else:
return bool(card.get("season_id"))

View file

@ -87,6 +87,39 @@ def simulate_match_result(card: dict[str, Any], index: int,
return match
def match_features_wrestler(match: dict[str, Any], wrestler: str) -> bool:
selected = wrestler.casefold()
return any(
isinstance(name, str) and name.casefold() == selected
for side in match.get("sides", [])
for name in side
)
def simulate_non_player_matches(
card: dict[str, Any], season: dict[str, Any] | None = None
) -> None:
"""Auto-sim pending matches that do not feature the season's player wrestler."""
if season is None:
try:
season = app.load_season(str(card["season_id"]))
except (KeyError, OSError, ValueError, json.JSONDecodeError) as exc:
print(f"Cannot verify season control mode: {exc}")
return
if season.get("control_mode") != "wrestler":
return
wrestler = season.get("player_wrestler")
if not isinstance(wrestler, str) or not wrestler:
print("Wrestler control has no selected wrestler; change control mode in the season menu.")
return
pending = app.pending_indices(card)
for index in pending:
match = card["matches"][index]
if not match_features_wrestler(match, wrestler):
if not app.record_match_result(card, index, auto_simulate=True):
return
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
@ -96,6 +129,18 @@ def record_match_result(card: dict[str, Any], index: int, auto_simulate: bool =
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"Cannot verify season data: {exc}")
return False
if season and season.get("control_mode") == "wrestler":
wrestler = season.get("player_wrestler")
if not isinstance(wrestler, str) or not wrestler:
print("Wrestler control has no selected wrestler; change control mode in the season menu.")
return False
is_player_match = match_features_wrestler(match, wrestler)
if is_player_match and auto_simulate:
print(f"{wrestler}'s match must be recorded manually in Wrestler control.")
return False
if not is_player_match and not auto_simulate:
print(f"Only {wrestler}'s matches can be recorded manually in Wrestler control.")
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
@ -132,10 +177,32 @@ def record_result(card: dict[str, Any], auto_simulate: bool | None = None,
if not pending:
print("All three matches are complete.")
return
season = 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
if season and season.get("control_mode") == "wrestler":
app.simulate_non_player_matches(card, season)
wrestler = season.get("player_wrestler")
if not isinstance(wrestler, str) or not wrestler:
print("Wrestler control has no selected wrestler; change control mode in the season menu.")
return
pending = [
index for index in app.pending_indices(card)
if app.match_features_wrestler(card["matches"][index], wrestler)
]
if not pending:
print(f"No pending matches featuring {wrestler}.")
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:
if season and season.get("control_mode") == "wrestler":
auto_simulate = False
elif auto_simulate is None:
auto_simulate = app.ask_choice(
"Result mode", ["Enter result manually", "Auto-simulate result"]
) == 1
@ -143,6 +210,16 @@ def record_result(card: dict[str, Any], auto_simulate: bool | None = None,
def simulate_pending_matches(card: dict[str, Any]) -> 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
if season.get("control_mode") == "wrestler":
app.simulate_non_player_matches(card, season)
print("Pending matches not featuring your wrestler were simulated.")
return
pending = app.pending_indices(card)
if not pending:
print("All three matches are complete.")
@ -151,4 +228,4 @@ def simulate_pending_matches(card: dict[str, Any]) -> None:
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']
__all__ = ['choose_match_outcome', 'match_features_wrestler', 'prompt_action_count', 'prompt_match_minutes', 'record_match_result', 'record_result', 'season_simulation_weights', 'simulate_match_result', 'simulate_non_player_matches', 'simulate_pending_matches']

View file

@ -278,8 +278,13 @@ def run_season(season: dict[str, Any]) -> None:
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"],
"Season action", ["book/resume", "rankings", "toggle control", "back"],
labels=[
next_action,
"Power rankings / title holders",
f"Toggle Control Mode — Current: {app.season_control_label(season)}",
"Back to main menu",
],
)
if choice == 0:
card = app.load_card(Path(current)) if current else app.book_next_season_show(season)
@ -292,13 +297,17 @@ def run_season(season: dict[str, Any]) -> None:
elif choice == 1:
app.print_power_rankings(season)
input("\nPress Enter to return...")
elif choice == 2:
app.toggle_season_control_mode(season)
else:
return
def season_mode() -> None:
try:
app.run_season(app.choose_or_create_season())
season = app.choose_or_create_season()
app.choose_season_control_mode(season)
app.run_season(season)
except (OSError, ValueError, json.JSONDecodeError) as exc:
print(f"Could not open or run that season: {exc}")

View file

@ -45,6 +45,8 @@ def new_season_state(name: str, game_id: str,
"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": {},
@ -78,6 +80,20 @@ def load_season(path_or_id: Path | str) -> dict[str, Any]:
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", {})
@ -93,6 +109,58 @@ def load_season(path_or_id: Path | str) -> dict[str, Any]:
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)
@ -331,4 +399,4 @@ def sync_season_card_results(card: dict[str, Any]) -> None:
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']
__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_title_defense']

View file

@ -760,6 +760,142 @@ class SeasonModeTests(unittest.TestCase):
self.assertEqual(len(after_second["processed_results"]), 3)
class SeasonControlTests(unittest.TestCase):
def season(self, season_id="control-test"):
return app.new_season_state("Control Test", "sd2", season_id=season_id)
def test_new_and_legacy_seasons_default_to_booker_control(self):
season = self.season()
self.assertEqual(season["control_mode"], "booker")
self.assertIsNone(season["player_wrestler"])
with tempfile.TemporaryDirectory() as folder, \
patch.object(app, "SEASON_DIR", Path(folder)):
season.pop("control_mode")
season.pop("player_wrestler")
app.save_season(season)
loaded = app.load_season(season["season_id"])
self.assertEqual(loaded["control_mode"], "booker")
self.assertIsNone(loaded["player_wrestler"])
def test_startup_choice_and_toggle_persist_wrestler_then_booker(self):
season = self.season()
expected_wrestler = sorted(app.load_roster("sd2"), key=str.casefold)[0]
with tempfile.TemporaryDirectory() as folder, \
patch.object(app, "SEASON_DIR", Path(folder)), \
patch.object(app, "ask_choice", side_effect=[1, 0]):
app.choose_season_control_mode(season)
self.assertEqual(season["control_mode"], "wrestler")
self.assertEqual(season["player_wrestler"], expected_wrestler)
self.assertEqual(app.load_season(season["season_id"])["player_wrestler"],
expected_wrestler)
app.toggle_season_control_mode(season)
self.assertEqual(app.load_season(season["season_id"])["control_mode"], "booker")
with patch.object(app, "ask_choice", return_value=0):
app.toggle_season_control_mode(season)
saved = app.load_season(season["season_id"])
self.assertEqual(saved["control_mode"], "wrestler")
self.assertEqual(saved["player_wrestler"], expected_wrestler)
def test_season_menu_displays_current_control_mode_and_toggles_back(self):
season = self.season()
season["control_mode"] = "wrestler"
season["player_wrestler"] = "The Rock"
with tempfile.TemporaryDirectory() as folder, \
patch.object(app, "SEASON_DIR", Path(folder)), \
patch.object(app, "ask_choice", side_effect=[2, 3]) as choose, \
contextlib.redirect_stdout(io.StringIO()):
app.save_season(season)
app.run_season(season)
saved = app.load_season(season["season_id"])
self.assertIn("Toggle Control Mode — Current: Wrestler (The Rock)",
choose.call_args_list[0].kwargs["labels"])
self.assertEqual(season["control_mode"], "booker")
self.assertEqual(saved["control_mode"], "booker")
def test_wrestler_control_simulates_non_player_matches_and_preserves_player_match(self):
with tempfile.TemporaryDirectory() as folder, \
patch.object(app, "SEASON_DIR", Path(folder) / "seasons"):
season = self.season("wrestler-control-results")
season["control_mode"] = "wrestler"
season["player_wrestler"] = "The Rock"
card = MarkdownTests().make_card(Path(folder) / "card.md")
card["season_id"] = season["season_id"]
card["season_show_number"] = 1
card["title_definitions"] = season["title_definitions"]
card["matches"][0].update({
"sides": [["The Rock"], ["Kane"]], "winner_side": None,
})
card["matches"][1].update({
"sides": [["Edge"], ["Christian"]], "winner_side": None,
})
card["matches"][2].update({
"sides": [["Tazz"], ["Undertaker"]], "winner_side": None,
})
season["current_card"] = card["path"]
app.save_season(season)
app.save_card(card)
with contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(app.record_match_result(card, 1, auto_simulate=False))
app.simulate_non_player_matches(card, season)
self.assertIsNone(card["matches"][0]["winner_side"])
self.assertIsNotNone(card["matches"][1]["winner_side"])
self.assertIsNotNone(card["matches"][2]["winner_side"])
with contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(app.record_match_result(card, 0, auto_simulate=True))
captured = {}
def exit_card(_prompt, choices, labels=None):
captured["choices"] = choices
captured["labels"] = labels
return 4
with patch.object(app, "ask_choice", side_effect=exit_card), \
contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(app.resume_card(card))
self.assertNotIn("edit", captured["choices"])
self.assertNotIn("simulate all", captured["choices"])
self.assertIn("Record your match result", captured["labels"])
with patch.object(app, "ask_choice", return_value=0), \
patch.object(app, "choose_match_outcome", return_value="Pinfall"), \
patch.object(app, "prompt_match_minutes", return_value=10), \
patch.object(app, "prompt_action_count", return_value=1), \
contextlib.redirect_stdout(io.StringIO()):
app.record_result(card, match_index=0)
self.assertEqual(card["matches"][0]["winner_side"], 0)
def test_absent_wrestler_causes_all_pending_matches_to_be_simulated(self):
with tempfile.TemporaryDirectory() as folder, \
patch.object(app, "SEASON_DIR", Path(folder) / "seasons"):
season = self.season("wrestler-absent")
season["control_mode"] = "wrestler"
season["player_wrestler"] = "The Rock"
card = MarkdownTests().make_card(Path(folder) / "card.md")
card["season_id"] = season["season_id"]
card["season_show_number"] = 1
card["title_definitions"] = season["title_definitions"]
for match, sides in zip(card["matches"], [
[["Kane"], ["Undertaker"]],
[["Edge"], ["Christian"]],
[["Tazz"], ["Big Show"]],
]):
match.update({"sides": sides, "winner_side": None})
season["current_card"] = card["path"]
app.save_season(season)
app.save_card(card)
with contextlib.redirect_stdout(io.StringIO()):
app.simulate_non_player_matches(card, season)
self.assertTrue(all(match["winner_side"] is not None for match in card["matches"]))
updated = app.load_season(season["season_id"])
self.assertEqual(updated["current_card"], "")
self.assertEqual(updated["next_show_number"], 2)
class BYOGTests(unittest.TestCase):
def setUp(self):
self._configs = app.CUSTOM_GAME_CONFIGS