Skip to content

Commit

Permalink
Migrate module state to GameState (POC)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
skizzerz committed Jul 24, 2023
1 parent 42a98bf commit 95dab2e
Show file tree
Hide file tree
Showing 6 changed files with 99 additions and 62 deletions.
2 changes: 1 addition & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[mypy]
python_version = 3.7
python_version = 3.9

# ignore generated files
[mypy-src.messages.message_lexer]
Expand Down
3 changes: 2 additions & 1 deletion src/debug/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import traceback
import urllib.request
import logging
import inspect
from typing import Optional
from types import TracebackType, FrameType

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/gamemodes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 43 additions & 2 deletions src/gamestate.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
107 changes: 50 additions & 57 deletions src/roles/matchmaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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))

Expand All @@ -177,40 +176,40 @@ 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:
evt.data["individual_win"] = True

@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:
Expand All @@ -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":
Expand Down
1 change: 1 addition & 0 deletions src/wolfgame.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down

0 comments on commit 95dab2e

Please sign in to comment.