From aa5290bac3f4f91318e419c66668c655cb2e66a6 Mon Sep 17 00:00:00 2001 From: Ryan Schmidt Date: Fri, 6 Oct 2023 20:19:37 -0700 Subject: [PATCH 1/2] Refactor locations (version 2) This is a significantly lighter refactor than #518. To wit, - The absent status remains. We move people to the square or their house depending on their absent status at beginning of day, but otherwise location during daytime does not impact ability to vote by itself. - Locations are singleton objects based on their name and do not carry any game state in the object itself. All game state is stored in GameState itself. - There are no location subclasses; everything is just directly a Location. - All access to location data is done via the locations APIs rather than directly modifying the GameState variables. This allows for easier future refactors of how we are storing data. --- src/__init__.py | 2 +- src/functions.py | 22 +------- src/gamestate.py | 1 - src/locations.py | 102 ++++++++++++++++++++++++++++++++++++ src/pregame.py | 15 ++++-- src/roles/doomsayer.py | 2 + src/roles/harlot.py | 7 ++- src/roles/helper/gunners.py | 4 ++ src/roles/helper/shamans.py | 1 + src/roles/priest.py | 2 + src/roles/succubus.py | 7 ++- src/status/dying.py | 4 -- src/trans.py | 21 +++++--- 13 files changed, 143 insertions(+), 47 deletions(-) create mode 100644 src/locations.py diff --git a/src/__init__.py b/src/__init__.py index 9dc53a7b..3084a663 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -22,7 +22,7 @@ from src import users from src import channels, containers from src import dispatcher, gamestate -from src import decorators +from src import decorators, locations from src import game_stats, handler, hooks, status, warnings, relay from src import reaper from src import gamejoin, pregame diff --git a/src/functions.py b/src/functions.py index 2b56f19a..2399a5b1 100644 --- a/src/functions.py +++ b/src/functions.py @@ -19,8 +19,7 @@ "get_players", "get_all_players", "get_participants", "get_target", "change_role", "get_main_role", "get_all_roles", "get_reveal_role", - "match_role", "match_mode", "match_totem", - "get_players_in_location", "get_location" + "match_role", "match_mode", "match_totem" ] def get_players(var: Optional[GameState | PregameState], roles=None, *, mainroles=None) -> list[User]: @@ -307,22 +306,3 @@ def match_totem(totem: str, scope: Optional[Iterable[str]] = None) -> Match[Loca filtered_matches.add(LocalTotem(totem_map[match], match)) return Match(filtered_matches) - -def get_players_in_location(var: GameState, location: str) -> set[User]: - """ Get all players in a particular location. - - :param var: Game state - :param location: Location to check - :return: All users present in the given location, or an empty set if the location is vacant - """ - pl = get_players(var) - return {p for p, loc in var.locations.items() if loc == location and p in pl} - -def get_location(var: GameState, player: User) -> str: - """ Get the location this player is present in. - - :param var: Game state - :param player: Player to check - :return: Location player is present in - """ - return var.locations[player] diff --git a/src/gamestate.py b/src/gamestate.py index 55528700..e2cd0bda 100644 --- a/src/gamestate.py +++ b/src/gamestate.py @@ -76,7 +76,6 @@ def __init__(self, pregame_state: PregameState): self.next_phase: Optional[str] = None self.night_count: int = 0 self.day_count: int = 0 - self.locations: UserDict[User, str] = UserDict() def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) diff --git a/src/locations.py b/src/locations.py new file mode 100644 index 00000000..143c0f4c --- /dev/null +++ b/src/locations.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from src import gamestate +from src.containers import UserDict +from src.users import User +from src.events import Event, event_listener + +__all__ = ["Location", "VillageSquare", "Graveyard", "Forest", + "get_players_in_location", "get_location", "get_home", + "move_player", "move_player_home", "set_home"] + +# singleton cache of known locations; persisted between games +LOCATION_CACHE: dict[str, Location] = {} + +# GameState extension to store location data for internal use +# Other modules **must** use the location API exposed in __all__ +# instead of directly accessing these members +class GameState(gamestate.GameState): + def __init__(self): + self.home_locations: UserDict[User, Location] = UserDict() + self.current_locations: UserDict[User, Location] = UserDict() + +class Location: + __slots__ = ("_name",) + + def __init__(self, name: str): + self._name = name + + def __new__(cls, name: str): + if name not in LOCATION_CACHE: + obj = super().__new__(cls) + obj.__init__(name) + LOCATION_CACHE[name] = obj + + return LOCATION_CACHE[name] + + @property + def name(self): + return self._name + +# default locations, always defined +VillageSquare = Location("square") +Graveyard = Location("graveyard") +Forest = Location("forest") + +def get_players_in_location(var: GameState, location: Location) -> set[User]: + """ Get all players in a particular location. + + :param var: Game state + :param location: Location to check + :return: All users present in the given location, or an empty set if the location is vacant + """ + return {p for p, loc in var.current_locations.items() if loc is location} + +def get_location(var: GameState, player: User) -> Location: + """ Get the location this player is present in. + + :param var: Game state + :param player: Player to check + :return: Location player is present in + """ + return var.current_locations[player] + +def get_home(var: GameState, player: User) -> Location: + """ Get the player's home location. + + :param var: Game state + :param player: Player to check + :return: Player's home location + """ + return var.home_locations[player] + +def move_player(var: GameState, player: User, to: Location): + """ Move player to a new location. + + :param var: Game state + :param player: Player to move + :param to: Player's new location + """ + var.current_locations[player] = to + +def move_player_home(var: GameState, player: User): + """ Move player to their home location. + + :param var: Game state + :param player: Player to move + """ + var.current_locations[player] = var.home_locations[player] + +def set_home(var: GameState, player: User, home: Location): + """ Set player's home location. + + :param var: Game state + :param player: Player to set + :param home: New home + """ + var.home_locations[player] = home + +@event_listener("del_player") +def on_del_player(evt: Event, var: GameState, player: User, allroles: set[str], death_triggers: bool): + del var.home_locations[:player:] + del var.current_locations[:player:] diff --git a/src/pregame.py b/src/pregame.py index 14190ee6..ab524064 100644 --- a/src/pregame.py +++ b/src/pregame.py @@ -23,6 +23,7 @@ from src.users import User from src.dispatcher import MessageDispatcher from src.channels import Channel +from src.locations import Location, set_home WAIT_TOKENS = 0 WAIT_LAST = 0 @@ -31,7 +32,7 @@ LAST_WAIT: UserDict[User, datetime] = UserDict() START_VOTES: UserSet = UserSet() CAN_START_TIME: datetime = datetime.now() -FORCE_ROLES: DefaultUserDict[UserSet] = DefaultUserDict(UserSet) +FORCE_ROLES: DefaultUserDict[str, UserSet] = DefaultUserDict(UserSet) @command("wait", playing=True, phases=("join",)) def wait(wrapper: MessageDispatcher, message: str): @@ -430,6 +431,12 @@ def _isvalid(mode, allow_vote_only): else: raise KeyError("Invalid action for role_attribution_end") + # set default location for each player to a unique house + for i, p in enumerate(get_players(ingame_state)): + home_event = Event("player_home", {"home": Location("house_{0}".format(i))}) + home_event.dispatch(ingame_state, p) + set_home(ingame_state, p, home_event.data["home"]) + with locks.join_timer: # cancel timers for name in ("join", "join_pinger", "start_votes"): if name in TIMERS: @@ -469,11 +476,11 @@ def _isvalid(mode, allow_vote_only): wrapper.send(messages[key].format(villagers, gamemode, options)) wrapper.target.mode("+m") - if not ingame_state.start_with_day: + if start_event.data["custom_game_callback"]: + start_event.data["custom_game_callback"](ingame_state) + elif not ingame_state.start_with_day: from src.trans import transition_night transition_night(ingame_state) - elif start_event.data["custom_game_callback"]: - start_event.data["custom_game_callback"](ingame_state) else: # send role messages evt = Event("send_role", {}) diff --git a/src/roles/doomsayer.py b/src/roles/doomsayer.py index 7d6ade2f..acb79352 100644 --- a/src/roles/doomsayer.py +++ b/src/roles/doomsayer.py @@ -16,6 +16,7 @@ from src.users import User from src.dispatcher import MessageDispatcher from src.gamestate import GameState +from src.locations import move_player_home register_wolf("doomsayer") @@ -111,6 +112,7 @@ def on_begin_day(evt: Event, var: GameState): for sick in SICK.values(): status.add_absent(var, sick, "illness") status.add_silent(var, sick) + move_player_home(var, sick) # clear out LASTSEEN for people that didn't see last night for doom in list(LASTSEEN.keys()): diff --git a/src/roles/harlot.py b/src/roles/harlot.py index d0a7be7b..3d4a018d 100644 --- a/src/roles/harlot.py +++ b/src/roles/harlot.py @@ -10,11 +10,12 @@ from src.decorators import command from src.dispatcher import MessageDispatcher from src.events import Event, event_listener -from src.functions import get_players, get_all_players, get_main_role, get_reveal_role, get_target +from src.functions import get_players, get_all_players, get_target from src.gamestate import GameState from src.messages import messages from src.status import try_misdirection, try_exchange from src.users import User +from src.locations import move_player, get_home VISITED: UserDict[users.User, users.User] = UserDict() PASSED = UserSet() @@ -43,9 +44,7 @@ def hvisit(wrapper: MessageDispatcher, message: str): VISITED[wrapper.source] = target PASSED.discard(wrapper.source) - house = var.players.index(target) - var.locations[wrapper.source] = f"house_{house}" - + move_player(var, wrapper.source, get_home(var, target)) wrapper.pm(messages["harlot_success"].format(target)) if target is not wrapper.source: target.send(messages["harlot_success"].format(wrapper.source)) diff --git a/src/roles/helper/gunners.py b/src/roles/helper/gunners.py index e5ee1254..29676b76 100644 --- a/src/roles/helper/gunners.py +++ b/src/roles/helper/gunners.py @@ -17,6 +17,7 @@ from src.dispatcher import MessageDispatcher from src.gamestate import GameState from src.users import User +from src.locations import move_player_home _rolestate: dict[str, dict[str, Any]] = {} @@ -89,6 +90,7 @@ def shoot(wrapper: MessageDispatcher, message: str): else: wrapper.send(messages["gunner_victim_injured"].format(target)) add_absent(var, target, "wounded") + move_player_home(var, target) from src.votes import chk_decision if not chk_win(var): # game didn't immediately end due to injury, see if we should force through a vote @@ -140,6 +142,8 @@ def on_del_player(evt: Event, var: GameState, victim: User, all_roles: set[str], # shot hit, but didn't kill channels.Main.send(messages["gunner_shoot_overnight_hit"].format(victim)) add_absent(var, shot, "wounded") + # player will be moved back to home after daytime locations are fixed; + # doing it here will simply get overwritten else: # shot was fired and missed channels.Main.send(messages["gunner_shoot_overnight_missed"].format(victim)) diff --git a/src/roles/helper/shamans.py b/src/roles/helper/shamans.py index 7758b019..6917c2e0 100644 --- a/src/roles/helper/shamans.py +++ b/src/roles/helper/shamans.py @@ -513,6 +513,7 @@ def on_begin_day(evt: Event, var: GameState): # Apply totem effects that need to begin on day proper for player in NARCOLEPSY: status.add_absent(var, player, "totem") + move_player_home(var, player) for player in IMPATIENCE: status.add_force_vote(var, player, get_all_players(var) - {player}) for player in PACIFISM: diff --git a/src/roles/priest.py b/src/roles/priest.py index febbc00e..ac7776ed 100644 --- a/src/roles/priest.py +++ b/src/roles/priest.py @@ -13,6 +13,7 @@ from src.trans import chk_win from src.dispatcher import MessageDispatcher from src.gamestate import GameState +from src.locations import move_player, Graveyard PRIESTS = UserSet() @@ -65,6 +66,7 @@ def consecrate(wrapper: MessageDispatcher, message: str): wrapper.pm(messages["consecrate_success"].format(target)) add_absent(var, wrapper.source, "consecrating") + move_player(var, wrapper.source, Graveyard) from src.votes import chk_decision if not chk_win(var): # game didn't immediately end due to marking as absent, see if we should force through a lynch diff --git a/src/roles/succubus.py b/src/roles/succubus.py index 19ae335b..5fe29470 100644 --- a/src/roles/succubus.py +++ b/src/roles/succubus.py @@ -9,11 +9,12 @@ from src.decorators import command from src.dispatcher import MessageDispatcher from src.events import Event, event_listener -from src.functions import get_players, get_all_players, get_reveal_role, get_target +from src.functions import get_players, get_all_players, get_target from src.gamestate import GameState from src.messages import messages from src.status import try_misdirection, try_exchange, is_dead from src.users import User +from src.locations import move_player, get_home ENTRANCED = UserSet() VISITED: UserDict[users.User, users.User] = UserDict() @@ -44,9 +45,7 @@ def hvisit(wrapper: MessageDispatcher, message: str): VISITED[wrapper.source] = target PASSED.discard(wrapper.source) - house = var.players.index(target) - var.locations[wrapper.source] = f"house_{house}" - + move_player(var, wrapper.source, get_home(var, target)) if target not in get_all_players(var, ("succubus",)): ENTRANCED.add(target) wrapper.send(messages["succubus_target_success"].format(target)) diff --git a/src/status/dying.py b/src/status/dying.py index 685cb6c7..5bcdc24b 100644 --- a/src/status/dying.py +++ b/src/status/dying.py @@ -6,7 +6,6 @@ from src.containers import UserDict, UserSet from src.functions import get_main_role, get_all_roles, get_reveal_role -from src.messages import messages from src.gamestate import GameState, PregameState from src.events import Event, event_listener from src.users import User @@ -104,9 +103,6 @@ def kill_players(var: Optional[GameState | PregameState], *, end_game: bool = Tr var.roles[role].remove(player) dead.add(player) DEAD.add(player) - - # move their body to the graveyard - var.locations[player] = "graveyard" else: # left during join phase var.players.remove(player) diff --git a/src/trans.py b/src/trans.py index c58aa1b4..d452a1cf 100644 --- a/src/trans.py +++ b/src/trans.py @@ -10,7 +10,8 @@ from src.transport.irc import get_ircd from src.decorators import command, handle_error from src.containers import UserSet, UserDict, UserList -from src.functions import get_players, get_main_role, get_reveal_role, get_players_in_location +from src.functions import get_players, get_main_role, get_reveal_role +from src.locations import Location, VillageSquare, get_players_in_location, move_player, move_player_home from src.warnings import expire_tempbans from src.messages import messages from src.status import is_silent, is_dying, try_protection, add_dying, kill_players, get_absent, try_lycanthropy @@ -23,7 +24,7 @@ from src.gamestate import GameState, PregameState # some type aliases to make things clearer later -UserOrLocation = Union[User, str] +UserOrLocation = Union[User, Location] UserOrSpecialTag = Union[User, str] NIGHT_IDLE_EXEMPT = UserSet() @@ -104,9 +105,13 @@ def begin_day(var: GameState): modes.append(("+v", player.nick)) channels.Main.mode(*modes) - # move everyone to the village square + # move everyone to the village square (or home if they're absent) + absent = get_absent(var) for p in get_players(var): - var.locations[p] = "square" + if p in absent: + move_player_home(var, p) + else: + move_player(var, p, VillageSquare) event = Event("begin_day", {}) event.dispatch(var) @@ -228,7 +233,7 @@ def transition_day(var: GameState, game_id: int = 0): # expand locations to encompass everyone at that location for v in set(victims): - if isinstance(v, str): + if isinstance(v, Location): pl = get_players_in_location(var, v) # Play the "target not home" message if the wolves attacked an empty location # This also suppresses the "no victims" message if nobody ends up dying tonight @@ -379,10 +384,10 @@ def transition_night(var: GameState): NIGHT_START_TIME = datetime.now() - # move everyone back to their house (indexed by join order) + # move everyone back to their house pl = get_players(var) - for i, p in enumerate(var.players): - var.locations[p] = f"house_{i}" + for p in pl: + move_player_home(var, p) event_begin = Event("transition_night_begin", {}) event_begin.dispatch(var) From 946c19eeff6cdb45a22db53ebe30f429073435a1 Mon Sep 17 00:00:00 2001 From: Ryan Schmidt Date: Fri, 6 Oct 2023 20:42:46 -0700 Subject: [PATCH 2/2] Update wolf behavior Missed this in my initial passthrough. This PR is now tested though ;) --- src/roles/alphawolf.py | 7 ++++--- src/roles/helper/wolves.py | 13 +++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/roles/alphawolf.py b/src/roles/alphawolf.py index 42449ccc..53dce3e5 100644 --- a/src/roles/alphawolf.py +++ b/src/roles/alphawolf.py @@ -14,6 +14,7 @@ from src.users import User from src.dispatcher import MessageDispatcher from src.gamestate import GameState +from src.locations import get_home register_wolf("alpha wolf") @@ -70,9 +71,9 @@ def on_night_kills(evt: Event, var: GameState): # simplify a lot of the code by offloading it to relevant pieces add_lycanthropy(var, target, "bitten") add_lycanthropy_scope(var, All) - house = var.players.index(target) - evt.data["victims"].add(f"house_{house}") - evt.data["killers"][f"house_{house}"].append("@wolves") + house = get_home(var, target) + evt.data["victims"].add(house) + evt.data["killers"][house].append("@wolves") # reset ENABLED here instead of begin_day so that night deaths can enable alpha wolf the next night ENABLED = False diff --git a/src/roles/helper/wolves.py b/src/roles/helper/wolves.py index 826a28bd..aa569e0d 100644 --- a/src/roles/helper/wolves.py +++ b/src/roles/helper/wolves.py @@ -16,6 +16,7 @@ from src.dispatcher import MessageDispatcher from src.gamestate import GameState from src.users import User +from src.locations import get_home KILLS: UserDict[users.User, UserList] = UserDict() @@ -134,9 +135,9 @@ def on_night_kills(evt: Event, var: GameState): # wolfchat such as sorcerer or traitor, unlike main role wolves) for victim in victims: if victim not in wolves: - house = var.players.index(victim) - evt.data["victims"].add(f"house_{house}") - evt.data["killers"][f"house_{house}"].append(wolf) + house = get_home(var, victim) + evt.data["victims"].add(house) + evt.data["killers"][house].append(wolf) # for wolves in wolfchat, determine who had the most kill votes and kill them, # choosing randomly in case of ties for i in range(total_kills): @@ -150,10 +151,10 @@ def on_night_kills(evt: Event, var: GameState): dups.append(v) if maxc and dups: target = random.choice(dups) - house = var.players.index(target) - evt.data["victims"].add(f"house_{house}") + house = get_home(var, target) + evt.data["victims"].add(house) # special key to let us know to randomly select a wolf in case of retribution totem - evt.data["killers"][f"house_{house}"].append("@wolves") + evt.data["killers"][house].append("@wolves") del found[target] @event_listener("retribution_kill")