Skip to content

Commit

Permalink
Refactor locations (version 2)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
skizzerz committed Oct 7, 2023
1 parent 5b2a7fa commit aa5290b
Show file tree
Hide file tree
Showing 13 changed files with 143 additions and 47 deletions.
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 1 addition & 21 deletions src/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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]
1 change: 0 additions & 1 deletion src/gamestate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
102 changes: 102 additions & 0 deletions src/locations.py
Original file line number Diff line number Diff line change
@@ -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:]
15 changes: 11 additions & 4 deletions src/pregame.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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", {})
Expand Down
2 changes: 2 additions & 0 deletions src/roles/doomsayer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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()):
Expand Down
7 changes: 3 additions & 4 deletions src/roles/harlot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions src/roles/helper/gunners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions src/roles/helper/shamans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/roles/priest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions src/roles/succubus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))
Expand Down
4 changes: 0 additions & 4 deletions src/status/dying.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 13 additions & 8 deletions src/trans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit aa5290b

Please sign in to comment.