From 2da4f0447c3ddc9680562208ef3ff5b414467db8 Mon Sep 17 00:00:00 2001 From: markmental Date: Mon, 2 Nov 2026 17:33:52 -0500 Subject: [PATCH] Add two control modes: Booker and Wrestler for Season mode --- README.md | 7 +++ lvpwb/card_flows.py | 44 +++++++++---- lvpwb/results.py | 81 +++++++++++++++++++++++- lvpwb/season_booking.py | 15 ++++- lvpwb/seasons.py | 70 ++++++++++++++++++++- test_lvpwb.py | 136 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 336 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 65a9d26..e63c295 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lvpwb/card_flows.py b/lvpwb/card_flows.py index 3c7101a..d023cb6 100644 --- a/lvpwb/card_flows.py +++ b/lvpwb/card_flows.py @@ -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")) diff --git a/lvpwb/results.py b/lvpwb/results.py index 111e8b6..13fd322 100644 --- a/lvpwb/results.py +++ b/lvpwb/results.py @@ -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'] diff --git a/lvpwb/season_booking.py b/lvpwb/season_booking.py index 51f82b9..8b8ef9f 100644 --- a/lvpwb/season_booking.py +++ b/lvpwb/season_booking.py @@ -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}") diff --git a/lvpwb/seasons.py b/lvpwb/seasons.py index 687a08f..8eaaa71 100644 --- a/lvpwb/seasons.py +++ b/lvpwb/seasons.py @@ -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'] diff --git a/test_lvpwb.py b/test_lvpwb.py index a3e8cc7..14cf630 100644 --- a/test_lvpwb.py +++ b/test_lvpwb.py @@ -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