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