From 95dab2e383b0b0fe8d9d2186704e516961285851 Mon Sep 17 00:00:00 2001 From: Ryan Schmidt Date: Mon, 24 Jul 2023 00:11:13 -0700 Subject: [PATCH] Migrate module state to GameState (POC) This involves some crimes with dynamically creating a type that extends all known game state extensions (such as that found in matchmaker, which was converted as a proof of concept). Some boilerplate is needed so that static type analysis properly registers the combined game state class, at least insofar as they need to care. This was tested on PyCharm, VS Code, and MyPy and works on all 3. --- mypy.ini | 2 +- src/debug/decorators.py | 3 +- src/gamemodes/__init__.py | 3 +- src/gamestate.py | 45 +++++++++++++++- src/roles/matchmaker.py | 107 ++++++++++++++++++-------------------- src/wolfgame.py | 1 + 6 files changed, 99 insertions(+), 62 deletions(-) diff --git a/mypy.ini b/mypy.ini index d4d9da1a..8911adff 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.7 +python_version = 3.9 # ignore generated files [mypy-src.messages.message_lexer] diff --git a/src/debug/decorators.py b/src/debug/decorators.py index 9018fa7b..5bfca10b 100644 --- a/src/debug/decorators.py +++ b/src/debug/decorators.py @@ -8,6 +8,7 @@ import traceback import urllib.request import logging +import inspect from typing import Optional from types import TracebackType, FrameType @@ -145,7 +146,7 @@ def __exit__(self, exc_type: Optional[type], exc_value: Optional[BaseException], # dump game state if we found it in our traceback if game_state is not None: variables.append("\nGame state:\n") - for key, value in game_state.__dict__.items(): + for key, value in inspect.getmembers(game_state): # Skip over things like __module__, __dict__, and __weakrefs__ if key.startswith("__") and key.endswith("__"): continue diff --git a/src/gamemodes/__init__.py b/src/gamemodes/__init__.py index bbbb4351..25bc6729 100644 --- a/src/gamemodes/__init__.py +++ b/src/gamemodes/__init__.py @@ -290,12 +290,13 @@ def set_default_totem_chances(self): chances[role] = value # Here so any game mode can use it + # FIXME: lovers should be a status or something more generic so we don't need to import matchmaker here def lovers_chk_win(self, evt: Event, var: GameState, rolemap, mainroles, lpl, lwolves, lrealwolves): winner = evt.data["winner"] if winner in Win_Stealer: return # fool won, lovers can't win even if they would from src.roles.matchmaker import get_all_lovers - all_lovers = get_all_lovers(var) + all_lovers = get_all_lovers(var) # type: ignore if len(all_lovers) != 1: return # we need exactly one cluster alive for this to trigger diff --git a/src/gamestate.py b/src/gamestate.py index dae0face..92b93894 100644 --- a/src/gamestate.py +++ b/src/gamestate.py @@ -1,7 +1,7 @@ from __future__ import annotations import copy -from typing import Any, Optional, TYPE_CHECKING +from typing import Any, Optional, TypeVar, TYPE_CHECKING import time from src.containers import UserSet, UserDict, UserList @@ -14,7 +14,23 @@ if TYPE_CHECKING: from src.gamemodes import GameMode -__all__ = ["GameState", "PregameState", "set_gamemode"] +__all__ = ["GameState", "PregameState", "set_gamemode", "extend_state"] + +_bases: list[type] = [] +T = TypeVar("T") + +def extend_state(cls: T) -> T: + # These classes extending GameState is purely for type checking; + # it will break runtime if left as-is. Create a new class that + # has no base classes, and use that for dynamic extensions. + stripped_dict = cls.__dict__.copy() + if "__dict__" in stripped_dict: + del stripped_dict["__dict__"] + stripped_cls = type(cls.__name__, (), stripped_dict) + _bases.append(stripped_cls) + + # But return the original class for type-checking/static analysis purposes + return cls def set_gamemode(var: PregameState, arg: str) -> bool: from src.gamemodes import GAME_MODES, InvalidModeException @@ -53,6 +69,11 @@ def teardown(self): self.current_mode.teardown() class GameState: + def __new__(cls, pregame_state: PregameState): + # If we add any locals here, PyCharm exposes them in the IDE as if they were class attributes, + # hence deferring all logic to a helper function + return _make_game_state(pregame_state) + def __init__(self, pregame_state: PregameState): self.setup_started: bool = False self.setup_completed: bool = False @@ -237,3 +258,23 @@ def get_role_stats(self) -> frozenset[frozenset[tuple[str, int]]]: def set_role_stats(self, value) -> None: self._rolestats.clear() self._rolestats.update(value) + +def _pass(*args, **kwargs): + pass + +def _make_game_state(pregame_state: PregameState) -> GameState: + # Dynamically create a new type that extends GameState and every class decorated with @extend_state, + # and calls their __init__() methods. Classes with @extend_state will **not** be passed the PregameState! + all_bases = [GameState] + all_bases.extend(_bases) + # Avoid recursion + new_copy = GameState.__new__ + del GameState.__new__ + state_cls = type("GameState", tuple(all_bases), {"__init__": _pass}) + self = state_cls() + GameState.__init__(self, pregame_state) + for base in _bases: + base.__init__(self) + + GameState.__new__ = new_copy + return self diff --git a/src/roles/matchmaker.py b/src/roles/matchmaker.py index aa4704e7..b901ff03 100644 --- a/src/roles/matchmaker.py +++ b/src/roles/matchmaker.py @@ -5,45 +5,44 @@ import re from typing import Optional -from src import channels +from src import channels, gamestate from src.containers import UserSet, UserDict from src.decorators import command from src.events import Event, event_listener from src.functions import get_players, get_all_players, get_reveal_role, get_target from src.messages import messages from src.status import add_dying -from src.gamestate import GameState from src.dispatcher import MessageDispatcher from src.users import User -MATCHMAKERS = UserSet() -ACTED = UserSet() +@gamestate.extend_state +class GameState(gamestate.GameState): + matchmaker_acted: UserSet = UserSet() + matchmaker_acted_tonight: UserSet = UserSet() + # active lover pairings (no dead players), contains forward and reverse mappings + matchmaker_lovers: UserDict[User, UserSet] = UserDict() + # all lover pairings (for revealroles/endgame stats), contains forward mappings only + matchmaker_pairings: UserDict[User, UserSet] = UserDict() -# active lover pairings (no dead players), contains forward and reverse mappings -LOVERS: UserDict[User, UserSet] = UserDict() - -# all lover pairings (for revealroles/endgame stats), contains forward mappings only -PAIRINGS: UserDict[User, UserSet] = UserDict() - -def _set_lovers(target1: User, target2: User): +def _set_lovers(var: GameState, target1: User, target2: User): # ensure that PAIRINGS maps lower id to higher ids if target2 < target1: target1, target2 = target2, target1 - if target1 in PAIRINGS: - PAIRINGS[target1].add(target2) + if target1 in var.matchmaker_pairings: + var.matchmaker_pairings[target1].add(target2) else: - PAIRINGS[target1] = UserSet({target2}) + var.matchmaker_pairings[target1] = UserSet({target2}) - if target1 in LOVERS: - LOVERS[target1].add(target2) + if target1 in var.matchmaker_lovers: + var.matchmaker_lovers[target1].add(target2) else: - LOVERS[target1] = UserSet({target2}) + var.matchmaker_lovers[target1] = UserSet({target2}) - if target2 in LOVERS: - LOVERS[target2].add(target1) + if target2 in var.matchmaker_lovers: + var.matchmaker_lovers[target2].add(target1) else: - LOVERS[target2] = UserSet({target1}) + var.matchmaker_lovers[target2] = UserSet({target1}) target1.send(messages["matchmaker_target_notify"].format(target2)) target2.send(messages["matchmaker_target_notify"].format(target1)) @@ -59,7 +58,7 @@ def get_all_lovers(var: GameState) -> list[set[User]]: Each member of the set is either directly or indirectly matched to every other member of that set. """ lovers = [] - all_lovers = set(LOVERS.keys()) + all_lovers = set(var.matchmaker_lovers.keys()) while all_lovers: visited = get_lovers(var, all_lovers.pop(), include_player=True) all_lovers -= visited @@ -78,23 +77,23 @@ def get_lovers(var: GameState, player: User, *, include_player: bool = False) -> whether directly or indirectly. If ``include_player=True``, the set additionally includes ``player``. """ - - if player not in LOVERS: + if player not in var.matchmaker_lovers: return set() visited = {player} - queue = set(LOVERS[player]) + queue = set(var.matchmaker_lovers[player]) while queue: cur = queue.pop() visited.add(cur) - queue |= LOVERS[cur] - visited + queue |= var.matchmaker_lovers[cur] - visited return visited if include_player else visited - {player} @command("match", chan=False, pm=True, playing=True, phases=("night",), roles=("matchmaker",)) def choose(wrapper: MessageDispatcher, message: str): """Select two players to fall in love. You may select yourself as one of the lovers.""" - if wrapper.source in MATCHMAKERS: + var = wrapper.game_state + if wrapper.source in var.matchmaker_acted: wrapper.send(messages["already_matched"]) return @@ -111,29 +110,29 @@ def choose(wrapper: MessageDispatcher, message: str): wrapper.send(messages["choose_different_people"]) return - MATCHMAKERS.add(wrapper.source) - ACTED.add(wrapper.source) + var.matchmaker_acted.add(wrapper.source) + var.matchmaker_acted_tonight.add(wrapper.source) - _set_lovers(target1, target2) + _set_lovers(var, target1, target2) wrapper.send(messages["matchmaker_success"].format(target1, target2)) @event_listener("transition_day_begin") def on_transition_day_begin(evt: Event, var: GameState): - ACTED.clear() + var.matchmaker_acted_tonight.clear() pl = get_players(var) for mm in get_all_players(var, ("matchmaker",)): - if mm not in MATCHMAKERS: + if mm not in var.matchmaker_acted: lovers = random.sample(pl, 2) - MATCHMAKERS.add(mm) - _set_lovers(*lovers) + var.matchmaker_acted.add(mm) + _set_lovers(var, *lovers) mm.send(messages["random_matchmaker"]) @event_listener("send_role") def on_send_role(evt: Event, var: GameState): ps = get_players(var) for mm in get_all_players(var, ("matchmaker",)): - if mm in MATCHMAKERS and not var.always_pm_role: + if mm in var.matchmaker_acted and not var.always_pm_role: continue pl = ps[:] random.shuffle(pl) @@ -143,10 +142,10 @@ def on_send_role(evt: Event, var: GameState): @event_listener("del_player") def on_del_player(evt: Event, var: GameState, player, all_roles, death_triggers): - MATCHMAKERS.discard(player) - ACTED.discard(player) - if player in LOVERS: - lovers = set(LOVERS[player]) + var.matchmaker_acted.discard(player) + var.matchmaker_acted_tonight.discard(player) + if player in var.matchmaker_lovers: + lovers = set(var.matchmaker_lovers[player]) pl = get_players(var) if death_triggers: for lover in lovers: @@ -159,16 +158,16 @@ def on_del_player(evt: Event, var: GameState, player, all_roles, death_triggers) add_dying(var, lover, killer_role=evt.params.killer_role, reason="lover_suicide", killer=evt.params.killer) for lover in lovers: - LOVERS[lover].discard(player) - if not LOVERS[lover]: - del LOVERS[lover] + var.matchmaker_lovers[lover].discard(player) + if not var.matchmaker_lovers[lover]: + del var.matchmaker_lovers[lover] - del LOVERS[player] + del var.matchmaker_lovers[player] @event_listener("game_end_messages") def on_game_end_messages(evt: Event, var: GameState): lovers = [] - for lover1, lset in PAIRINGS.items(): + for lover1, lset in var.matchmaker_pairings.items(): for lover2 in lset: lovers.append(messages["lover_pair_endgame"].format(lover1, lover2)) @@ -177,12 +176,12 @@ def on_game_end_messages(evt: Event, var: GameState): @event_listener("team_win") def on_team_win(evt: Event, var: GameState, player, main_role, allroles, winner): - if winner == "lovers" and player in LOVERS: + if winner == "lovers" and player in var.matchmaker_lovers: evt.data["team_win"] = True @event_listener("player_win") def on_player_win(evt: Event, var: GameState, player: User, main_role: str, all_roles: set[str], winner: str, team_win: bool, survived: bool): - if player in PAIRINGS or player in itertools.chain.from_iterable(PAIRINGS.values()): + if player in var.matchmaker_pairings or player in itertools.chain.from_iterable(var.matchmaker_pairings.values()): evt.data["special"].append("lover") # grant lover a win if any of the other lovers in their polycule got a team win if team_win or get_lovers(var, player) & evt.params.team_wins: @@ -190,27 +189,27 @@ def on_player_win(evt: Event, var: GameState, player: User, main_role: str, all_ @event_listener("chk_nightdone") def on_chk_nightdone(evt: Event, var: GameState): - mms = (get_all_players(var, ("matchmaker",)) - MATCHMAKERS) | ACTED - evt.data["acted"].extend(ACTED) + mms = (get_all_players(var, ("matchmaker",)) - var.matchmaker_acted) | var.matchmaker_acted_tonight + evt.data["acted"].extend(var.matchmaker_acted_tonight) evt.data["nightroles"].extend(mms) @event_listener("get_team_affiliation") def on_get_team_affiliation(evt: Event, var: GameState, target1, target2): - if target1 in LOVERS and target2 in get_lovers(var, target1): + if target1 in var.matchmaker_lovers and target2 in get_lovers(var, target1): evt.data["same"] = True @event_listener("myrole") def on_myrole(evt: Event, var: GameState, user): # Remind lovers of each other - if user in get_players(var) and user in LOVERS: - evt.data["messages"].append(messages["matched_info"].format(LOVERS[user])) + if user in get_players(var) and user in var.matchmaker_lovers: + evt.data["messages"].append(messages["matched_info"].format(var.matchmaker_lovers[user])) @event_listener("revealroles") def on_revealroles(evt: Event, var: GameState): # print out lovers pl = get_players(var) lovers = [] - for lover1, lset in PAIRINGS.items(): + for lover1, lset in var.matchmaker_pairings.items(): if lover1 not in pl: continue for lover2 in lset: @@ -220,12 +219,6 @@ def on_revealroles(evt: Event, var: GameState): if lovers: evt.data["output"].append(messages["lovers_revealroles"].format(lovers)) -@event_listener("reset") -def on_reset(evt: Event, var: GameState): - MATCHMAKERS.clear() - ACTED.clear() - LOVERS.clear() - @event_listener("get_role_metadata") def on_get_role_metadata(evt: Event, var: Optional[GameState], kind: str): if kind == "role_categories": diff --git a/src/wolfgame.py b/src/wolfgame.py index 89906b15..5fc2dc48 100644 --- a/src/wolfgame.py +++ b/src/wolfgame.py @@ -1371,6 +1371,7 @@ def fgame_help(args=""): @command("eval", owner_only=True, flag="d", pm=True) def pyeval(wrapper: MessageDispatcher, message: str): """Evaluate a Python expression.""" + import inspect # for more expressive debugging var = wrapper.game_state try: wrapper.send(str(eval(message))[:500])