diff --git a/src/functions.py b/src/functions.py index 2b56f19a..ad0f1348 100644 --- a/src/functions.py +++ b/src/functions.py @@ -20,7 +20,6 @@ "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" ] 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 dae0face..728ef6b5 100644 --- a/src/gamestate.py +++ b/src/gamestate.py @@ -1,10 +1,12 @@ from __future__ import annotations -import copy from typing import Any, Optional, TYPE_CHECKING + +import copy import time from src.containers import UserSet, UserDict, UserList +from src.locations import Location, Square, Graveyard, House, Reason from src.messages import messages from src.cats import All from src import config @@ -57,6 +59,7 @@ def __init__(self, pregame_state: PregameState): self.setup_started: bool = False self.setup_completed: bool = False self._torndown: bool = False + self.tearing_down: bool = False self.current_mode: GameMode = pregame_state.current_mode self.game_settings: dict[str, Any] = {} self.game_id: float = pregame_state.game_id @@ -71,7 +74,9 @@ 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() + self.village_square = Square(self) + self.graveyard = Graveyard(self) + self._locations: set[Location] = {self.village_square, self.graveyard} def begin_setup(self): if self.setup_completed: @@ -91,15 +96,26 @@ def finish_setup(self): assert not self._original_roles and not self._original_main_roles self._original_roles = copy.deepcopy(self.roles) self._original_main_roles = self.main_roles.copy() + for i, player in enumerate(self.players): + house = House(self, player, i) + house.users[player] = (Reason.home, None) + self._locations.add(house) self.setup_completed = True def teardown(self): - self.roles.clear() - self._original_roles.clear() - self._original_main_roles.clear() - self._rolestats.clear() - self.current_mode.teardown() - self._torndown = True + assert not self._torndown, "cannot tear down already torn-down GameState" + self.tearing_down = True + try: + self.roles.clear() + self._original_roles.clear() + self._original_main_roles.clear() + self._rolestats.clear() + self.current_mode.teardown() + self._torndown = True + for loc in self._locations: + loc.teardown() + finally: + self.tearing_down = False def _get_value(self, key: str) -> Any: # we don't actually need to complete setup before this can be used @@ -115,6 +131,41 @@ def _get_value(self, key: str) -> Any: def in_game(self): return self.setup_completed and not self._torndown + @property + def locations(self) -> dict[User, Location]: + value = {} + for loc in self._locations: + for user in loc.users: + value[user] = loc + return value + + def get_user_location(self, user: User): + for x in self._locations: + if user in x.users: + return (x,) + x.users[user] + raise ValueError(f"User {user} is not anywhere") + + def set_user_location(self, user: User, loc: Location, reason: Reason | None = None, key: str | None = None): + if user not in loc.users: + for x in self._locations: + if user in x.users: + old_r, old_k = x.users.pop(user) + if reason is None: + reason = old_r + key = old_k + loc.users[user] = (reason, key) + break + else: + raise RuntimeError(f"Failed setting user {user} to location {loc}") + + def find_house(self, user: User): + for x in self._locations: + if not isinstance(x, House): + continue + if x.owner is user: + return x + raise ValueError(f"Could not find house for {user}") + def begin_phase_transition(self, phase: str): if self.next_phase is not None: raise RuntimeError("already in phase transition") diff --git a/src/locations.py b/src/locations.py new file mode 100644 index 00000000..653bb976 --- /dev/null +++ b/src/locations.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from enum import Enum + +from src.users import User +from src.containers import UserDict, UserList + +if TYPE_CHECKING: + from src.gamestate import GameState + +class Reason(Enum): + home = 0 + day = 1 + visiting = 2 + killing = 3 + dead = 4 + special = 5 + prison = 6 + +class Location: + """Base class for locations.""" + def __init__(self, var: GameState, name: str): + self._gs = var + self.name = name + self.users: UserDict[User, tuple[Reason, str | None]] = UserDict() + + def __contains__(self, item): + return item in self.users + + def __iter__(self): + return iter(self.users) + + def __len__(self): + return len(self.users) + + def __str__(self): + return self.name + + def __hash__(self): + return hash(self.name) + + def _teardown(self): + """Method for subclasses to define if needed.""" + + def teardown(self): + assert self._gs.tearing_down, "cannot tear down locations from outside of GameState.teardown()" + self._teardown() + self.name = "" + self.users.clear() + +class Square(Location): + def __init__(self, var: GameState): + super().__init__(var, "Village Square") + +class Graveyard(Location): + def __init__(self, var: GameState): + super().__init__(var, "Graveyard") + +class House(Location): + def __init__(self, var: GameState, player: User, pos: int): + super().__init__(var, f"{player.account}'s house") + self._owner = UserList([player]) + self.pos = pos + + @property + def owner(self) -> User: + return self._owner[0] + + def _teardown(self): + self._owner.clear() diff --git a/src/roles/doomsayer.py b/src/roles/doomsayer.py index 7d6ade2f..c384c336 100644 --- a/src/roles/doomsayer.py +++ b/src/roles/doomsayer.py @@ -8,6 +8,7 @@ from src.cats import All from src.containers import UserSet, UserDict from src.decorators import command +from src.locations import Reason from src.events import Event, event_listener from src.functions import get_all_players, get_target from src.messages import messages @@ -109,7 +110,7 @@ def on_transition_night_end(evt: Event, var: GameState): @event_listener("begin_day") def on_begin_day(evt: Event, var: GameState): for sick in SICK.values(): - status.add_absent(var, sick, "illness") + var.set_user_location(sick, var.find_house(sick), Reason.prison, "illness") status.add_silent(var, sick) # clear out LASTSEEN for people that didn't see last night diff --git a/src/roles/harlot.py b/src/roles/harlot.py index d0a7be7b..27a81f85 100644 --- a/src/roles/harlot.py +++ b/src/roles/harlot.py @@ -9,6 +9,7 @@ from src.containers import UserSet, UserDict from src.decorators import command from src.dispatcher import MessageDispatcher +from src.locations import Reason 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.gamestate import GameState @@ -43,8 +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}" + var.set_user_location(wrapper.source, var.find_house(target), Reason.visiting) wrapper.pm(messages["harlot_success"].format(target)) if target is not wrapper.source: diff --git a/src/roles/helper/gunners.py b/src/roles/helper/gunners.py index e5ee1254..3b637691 100644 --- a/src/roles/helper/gunners.py +++ b/src/roles/helper/gunners.py @@ -9,10 +9,11 @@ from src.cats import Wolf, Killer from src.containers import UserDict from src.decorators import command +from src.locations import Reason from src.events import Event, event_listener from src.functions import get_players, get_all_players, get_target, get_main_role, get_reveal_role from src.messages import messages -from src.status import try_misdirection, try_exchange, add_dying, kill_players, add_absent, try_protection, is_dying +from src.status import try_misdirection, try_exchange, add_dying, kill_players, try_protection, is_dying from src.trans import chk_win from src.dispatcher import MessageDispatcher from src.gamestate import GameState @@ -88,7 +89,7 @@ def shoot(wrapper: MessageDispatcher, message: str): kill_players(var) else: wrapper.send(messages["gunner_victim_injured"].format(target)) - add_absent(var, target, "wounded") + var.set_user_location(target, var.find_house(target), Reason.prison, "wounded") 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 @@ -139,7 +140,7 @@ def on_del_player(evt: Event, var: GameState, victim: User, all_roles: set[str], elif event.data["hit"]: # shot hit, but didn't kill channels.Main.send(messages["gunner_shoot_overnight_hit"].format(victim)) - add_absent(var, shot, "wounded") + var.set_user_location(shot, var.find_house(shot), Reason.prison, "wounded") 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..d64cb789 100644 --- a/src/roles/helper/shamans.py +++ b/src/roles/helper/shamans.py @@ -9,6 +9,7 @@ from src import channels, users, status from src.cats import All, Wolf, Killer from src.containers import UserList, UserSet, UserDict, DefaultUserDict +from src.locations import Reason from src.events import Event, event_listener from src.functions import (get_players, get_all_players, get_main_role, get_all_roles, get_reveal_role, get_target, match_totem) @@ -512,7 +513,7 @@ def on_transition_night_end(evt: Event, var: GameState): 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") + var.set_user_location(player, var.find_house(player), Reason.prison, "totem") 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..a0855f6a 100644 --- a/src/roles/priest.py +++ b/src/roles/priest.py @@ -8,8 +8,9 @@ from src.decorators import command from src.events import Event, event_listener from src.functions import get_players, get_all_players, get_target +from src.locations import Reason from src.messages import messages -from src.status import try_misdirection, try_exchange, add_absent +from src.status import try_misdirection, try_exchange from src.trans import chk_win from src.dispatcher import MessageDispatcher from src.gamestate import GameState @@ -64,7 +65,7 @@ def consecrate(wrapper: MessageDispatcher, message: str): evt.dispatch(var, wrapper.source, target) wrapper.pm(messages["consecrate_success"].format(target)) - add_absent(var, wrapper.source, "consecrating") + var.set_user_location(wrapper.source, var.graveyard, Reason.special, "consecrating") 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..cea7e86c 100644 --- a/src/roles/succubus.py +++ b/src/roles/succubus.py @@ -8,6 +8,7 @@ from src.containers import UserSet, UserDict from src.decorators import command from src.dispatcher import MessageDispatcher +from src.locations import Reason from src.events import Event, event_listener from src.functions import get_players, get_all_players, get_reveal_role, get_target from src.gamestate import GameState @@ -44,8 +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}" + var.set_user_location(wrapper.source, var.find_house(target), Reason.visiting) if target not in get_all_players(var, ("succubus",)): ENTRANCED.add(target) diff --git a/src/status/absent.py b/src/status/absent.py deleted file mode 100644 index f14d97ac..00000000 --- a/src/status/absent.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from src.events import Event, event_listener -from src.containers import UserDict -from src.functions import get_players -from src.messages import messages -from src.gamestate import GameState -from src.users import User - -__all__ = ["add_absent", "try_absent", "get_absent"] - -ABSENT: UserDict[User, str] = UserDict() - -def add_absent(var: GameState, target: User, reason: str): - if target not in get_players(var): - return - - ABSENT[target] = reason - from src.votes import VOTES - - for votee, voters in list(VOTES.items()): - if target in voters: - voters.remove(target) - if not voters: - del VOTES[votee] - break - -def try_absent(var: GameState, user: User): - if user in ABSENT: - user.send(messages[ABSENT[user] + "_absent"]) - return True - return False - -def get_absent(var: GameState): - return set(ABSENT) - -@event_listener("del_player") -def on_del_player(evt: Event, var: GameState, player: User, allroles: set[str], death_triggers: bool): - del ABSENT[:player:] - -@event_listener("revealroles") -def on_revealroles(evt: Event, var: GameState): - if ABSENT: - evt.data["output"].append(messages["absent_revealroles"].format(ABSENT)) - -@event_listener("transition_night_begin") -def on_transition_night_begin(evt: Event, var: GameState): - ABSENT.clear() - -@event_listener("reset") -def on_reset(evt: Event, var: GameState): - ABSENT.clear() diff --git a/src/status/dying.py b/src/status/dying.py index 685cb6c7..ef5d6376 100644 --- a/src/status/dying.py +++ b/src/status/dying.py @@ -8,6 +8,7 @@ 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.locations import Reason from src.events import Event, event_listener from src.users import User from src import locks, channels @@ -106,7 +107,7 @@ def kill_players(var: Optional[GameState | PregameState], *, end_game: bool = Tr DEAD.add(player) # move their body to the graveyard - var.locations[player] = "graveyard" + var.set_user_location(player, var.graveyard, Reason.dead) else: # left during join phase var.players.remove(player) diff --git a/src/trans.py b/src/trans.py index 7785ff8b..de6a4fa5 100644 --- a/src/trans.py +++ b/src/trans.py @@ -10,10 +10,11 @@ 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.locations import Location, Reason +from src.functions import get_players, get_main_role, get_reveal_role 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 +from src.status import is_silent, is_dying, try_protection, add_dying, kill_players, try_lycanthropy from src.users import User from src.events import Event, event_listener from src.votes import chk_decision @@ -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() @@ -106,7 +107,9 @@ def begin_day(var: GameState): # move everyone to the village square for p in get_players(var): - var.locations[p] = "square" + loc, reason, key = var.get_user_location(p) + if reason is not Reason.prison: + var.set_user_location(p, var.village_square, Reason.day) event = Event("begin_day", {}) event.dispatch(var) @@ -228,8 +231,8 @@ 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): - pl = get_players_in_location(var, v) + if isinstance(v, Location): + pl = set(get_players(var)) & v.users # 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 if not pl and "@wolves" in killers[v]: @@ -377,17 +380,17 @@ def transition_night(var: GameState): NIGHT_START_TIME = datetime.now() - # move everyone back to their house (indexed by join order) + # move every alive player 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: + var.set_user_location(p, var.find_house(p), Reason.home) event_begin = Event("transition_night_begin", {}) event_begin.dispatch(var) if not config.Main.get("gameplay.nightchat"): modes = [] - for player in get_players(var): + for player in pl: if not player.is_fake: modes.append(("-v", player)) channels.Main.mode(*modes) @@ -676,7 +679,7 @@ def chk_win_conditions(var: GameState, rolemap: dict[str, set[User]] | UserDict[ """Internal handler for the chk_win function.""" with locks.reaper: if var.current_phase == "day": - pl = set(get_players(var)) - get_absent(var) + pl = set(var.village_square) lpl = len(pl) else: pl = set(get_players(var, mainroles=mainroles)) diff --git a/src/votes.py b/src/votes.py index 8f41b72c..f29aab8f 100644 --- a/src/votes.py +++ b/src/votes.py @@ -7,9 +7,10 @@ from src.containers import UserDict, UserList, UserSet from src.decorators import command +from src.locations import Reason from src.functions import get_players, get_target, get_reveal_role from src.messages import messages -from src.status import (try_absent, get_absent, get_forced_votes, get_all_forced_votes, get_forced_abstains, +from src.status import (get_forced_votes, get_all_forced_votes, get_forced_abstains, get_vote_weight, try_lynch_immunity, add_dying, kill_players) from src.events import Event, event_listener from src import channels, pregame, reaper, locks, config @@ -42,7 +43,10 @@ def lynch(wrapper: MessageDispatcher, message: str): if not voted: return - if try_absent(var, wrapper.source): + location, reason, key = var.get_user_location(wrapper.source) + if location is not var.village_square: + if key is not None: + wrapper.source.send(messages[key + "_absent"]) return ABSTAINS.discard(wrapper.source) @@ -80,7 +84,9 @@ def no_lynch(wrapper: MessageDispatcher, message: str): elif var.limit_abstain and var.day_count == 1: wrapper.pm(messages["no_abstain_day_one"]) return - elif try_absent(var, wrapper.source): + elif (n := var.get_user_location(wrapper.source))[0] is not var.village_square: + if n[2] is not None: + wrapper.source.send(messages[n[2] + "_absent"]) return for voter in list(VOTES): if wrapper.source in VOTES[voter]: @@ -118,6 +124,12 @@ def retract(wrapper: MessageDispatcher, message: str): else: wrapper.pm(messages["pending_vote"]) +def chk_voters(var: GameState): + for votee, voters in VOTES.items(): + for voter in list(voters): + if voter not in var.village_square: + voters.remove(voter) + @command("votes", pm=True, phases=("join", "day", "night")) def show_votes(wrapper: MessageDispatcher, message: str): """Show the current votes.""" @@ -176,6 +188,7 @@ def show_votes(wrapper: MessageDispatcher, message: str): LAST_VOTES = None # reset else: + chk_voters(var) votelist = [] for votee, voters in VOTES.items(): votelist.append("{0}: {1} ({2})".format(votee, len(voters), ", ".join(p.nick for p in voters))) @@ -183,7 +196,7 @@ def show_votes(wrapper: MessageDispatcher, message: str): wrapper.reply(msg, prefix_nick=True) - avail = len(pl) - len(get_absent(var)) + avail = len(var.village_square) votesneeded = avail // 2 + 1 abstaining = len(ABSTAINS) if abstaining == 1: # *i18n* hardcoded English @@ -212,7 +225,8 @@ def vote(wrapper: MessageDispatcher, message: str): def chk_decision(var: GameState, *, timeout=False, admin_forced=False): from src.trans import chk_win with locks.reaper: - players = set(get_players(var)) - get_absent(var) + chk_voters(var) + players = set(var.village_square) avail = len(players) needed = avail // 2 + 1