From a15acac00fb85885bc4a9a1ad937e9d912042443 Mon Sep 17 00:00:00 2001 From: Ryan Schmidt Date: Tue, 24 Dec 2024 11:34:45 -0700 Subject: [PATCH] Minor tweaks - In pactbreaker, drawing a clue card at square now always gives 2 tokens, rather than 1 if only two people drew clues. - Remove !time's phase restrictions to allow it to work with custom phases. - Set up timers as part of phase transitions rather than independently. - Dullahan can no longer kill targets not on their list. - Maybe some other things too, dunno. Probably not important ;) --- messages/en.json | 41 ++-- src/defaultsettings.yml | 29 +-- src/gamecmds.py | 10 +- src/gamemodes/pactbreaker.py | 24 +- src/gamemodes/sleepy.py | 446 +++++++++++++++++++---------------- src/gamestate.py | 29 ++- src/roles/dullahan.py | 8 +- src/roles/timelord.py | 18 +- src/trans.py | 95 +++----- 9 files changed, 375 insertions(+), 325 deletions(-) diff --git a/messages/en.json b/messages/en.json index ac127a0d..f10ec394 100644 --- a/messages/en.json +++ b/messages/en.json @@ -226,10 +226,10 @@ "whoami": ["whoami"], "wiki": ["wiki"], - "north": ["north", "n"], - "east": ["east", "e"], - "south": ["south", "s"], - "west": ["west", "w"], + "hide": ["hide"], + "run": ["run"], + "search": ["search"], + "chase": ["chase"], "feed": ["feed"], "forest": ["forest"], "square": ["square"], @@ -1167,6 +1167,7 @@ "doomsayer_notify": "You are {=doomsayer!role:article} {=doomsayer!role:bold}. You can see how bad luck will befall someone at night by using \"{=see!command} \" on them. You may also use \"{=kill!command} \" to kill a villager.", "prophet_notify": "You are {=prophet!role:article} {=prophet!role:bold}. Each night you may pray to learn one player who has a particular role. Use \"{=pray!command} \" in PM to learn who has that role.", "dullahan_targets_dead": "All your targets are already dead!", + "dullahan_not_target": "{0:@} isn't one of your targets, so killing them would be a waste.", "dullahan_notify": "You are {=dullahan!role:article} {=dullahan!role:bold}. Every night, you may kill someone by using \"{=kill!command} \". You win when all your targets are dead.", "dullahan_targets": "Targets: {0:join}", "dullahan_remaining_targets": "Remaining targets: {0:join}", @@ -1218,21 +1219,31 @@ "endgame_role_player_short": "{0:@}", "endgame_role_player_long": "{0:@} ({1:join})", "endgame_role_msg": "The {0!role:plural({1})} {=was,were:plural({1})} {1:join}.", - "sleepy_nightmare_begin": "While walking through the woods, you hear the clopping of hooves behind you. Turning around, you see a large black horse with dark red eyes and flames where its mane and tail would be. After a brief period of time, it starts chasing after you! You think if you can cross the bridge over the nearby river you'll be safe, but your surroundings are almost unrecognizable in this darkness.", - "sleepy_nightmare_navigate": "You can pm me \"{=north!command}\", \"{=east!command}\", \"{=south!command}\", and \"{=west!command}\", or their abbreviations \"n\", \"e\", \"s\", and \"w\" to navigate.", - "sleepy_nightmare_0": "You find yourself deep in the heart of the woods, with imposing trees covering up what little light exists with their dense canopy. The paths here are very twisty, and it's easy to wind up going in circles if one is not careful. Directions are {0}.", - "sleepy_nightmare_1": "You come across a small creek, the water babbling softly in the night as if nothing is amiss. As you approach, a flock of ravens bathing there disperses into all directions. Directions are {0}.", - "sleepy_nightmare_2": "The treeline starts thinning and you start feeling fresh air for the first time in a while, you must be getting close to the edge of the woods! Directions are {0}.", - "sleepy_nightmare_wake": "You break clear of the woods and see a roaring river ahead with a rope bridge going over it. You sprint to the bridge with the beast hot on your tail, your adrenaline overcoming your tired legs as you push yourself for one final burst. You make it across the bridge, and not a moment too soon as the sun starts rising up, causing you to wake from your dream in a cold sweat.", - "sleepy_nightmare_fake_1": "You break clear of the woods and see a roaring river ahead. However, look as you may you are unable to find any means of crossing it. Knowing how expansive the river is, and how fast the beast can chase you if it isn't being slowed down by the foliage, you think it's best to look for the correct side of the woods again by going back in. Cursing your bad luck, you head back into the woods.", - "sleepy_nightmare_fake_2": "You break clear of the woods only to find an expansive plains ahead of you, with no river in sight. You must have found your way out through the wrong side of the woods! Attempting to circle around the woods would result in the beast catching you in short order, so you softly curse at your bad luck as you head back into the woods to find the correct path.", - "sleepy_nightmare_invalid_direction": "That way lies madness and certain death.", - "sleepy_nightmare_restart": "You find yourself back where you started...", - "sleepy_nightmare_death": "As the sun starts rising, your legs give out, causing the beast to descend upon you and snuff out your life.", + "sleepy_nightmare_start_dullahan": "You track down {0:@} in the woods during the night and begin chasing down your quarry! Use \"{=chase!command}\" to give chase at full speed or \"{=search!command}\" to slow your pace and detect if your target has attempted to hide.", + "sleepy_nightmare_start_target": "While walking through the woods, you hear the clopping of hooves behind you. Turning around, you see a headless horseman beginning to chase after you! You think if you can cross the bridge over the nearby river you'll be safe, or you could try hiding and hoping the horseman passes you by. Use \"{=run!command}\" to run towards the river or \"{=hide!command}\" to attempt to hide.", + "sleepy_nightmare_start_target_multiple": "While walking through the woods, you hear the clopping of hooves behind you. Turning around, you see {0:bold} headless horsemen beginning to chase after you! You think if you can cross the bridge over the nearby river you'll be safe, or you could try hiding and hoping the horsemen pass you by. Use \"{=run!command}\" to run towards the river or \"{=hide!command}\" to attempt to hide.", + "sleepy_nightmare_timer_notify": "You only have {0} seconds for each decision, so act quickly!", + "sleepy_nightmare_target_step_0": "You find yourself deep in the heart of the woods, with imposing trees covering up what little moonlight exists with their dense canopy. The path ahead seems to strech on as far as you can see, and you think that you would need to run [b]4[/b] more times to make it to safety.", + "sleepy_nightmare_target_step_1": "You come across a small creek, the water babbling softly in the night as if nothing is amiss. As you approach, a flock of ravens bathing there disperses into all directions. You think that you would need to run [b]3[/b] more times to make it to safety.", + "sleepy_nightmare_target_step_2": "The treeline starts thinning and you start feeling fresh air for the first time in a while, you must be getting close to the edge of the woods! You think that you would need to run [b]2[/b] more times to make it to safety.", + "sleepy_nightmare_target_step_3": "You break clear of the woods into an expansive plains and see a roaring river ahead with a rope bridge going over it. The tall grass of the plains could provide some cover if you lay low, or you could make one final push in hopes that you make it to safety.", + "sleepy_nightmare_dullahan_step": "Your target remains ahead of you. You know that a river lies in the direction they are running, but the supernatural curse that gives you life also forbids you from crossing running water. You estimate your quarry will reach the bridge over the river should they run {0:bold} more {=time,times:plural({0})}.", + "sleepy_nightmare_escape_run": "You sprint to the bridge with the {=rider,riders:plural({0})} hot on your tail, your adrenaline overcoming your tired legs as you push yourself for one final burst. You make it across the bridge while the {=horseman remains,horsemen remain:plural({0})} behind, seemingly unable to cross over the running water.", + "sleepy_nightmare_escape_hide": "You momentarily break line of sight with your {=pursuer,pursuers:plural({0})} and take the opportunity to dive for cover. You hold your breath for what feels like an eternity as they finally rush past you. After a few more minutes, you gather enough courage to sneak back to the village.", + "sleepy_nightmare_caught": "Your attempts to evade your {=pursuer,pursuers:plural({0})} fail as they catch you...", + "sleepy_nightmare_kill": "You catch up with {0:@} with an evil grin on your face as you raise your whip...", + "sleepy_nightmare_fail_river": "Certain that your quarry has chosen to lay down in the grassy plains, you slow your pace to check for signs of movement or unnaturally flat grass. Looking up, you spot your target on the bridge, crossing to the other side of the river. Now that your target has successfully evaded you, you feel no compulsion to attack them in the future.", + "sleepy_nightmare_fail_hide": "As you continue thundering down the path, you realise something is amiss as your quarry is nowhere to be seen! You search around for some time, but are unable to find them. Now that your target has successfully evaded you, you feel no compulsion to attack them in the future.", + "sleepy_nightmare_success": "You have chosen to {0!command:bold}.", + "sleepy_nightmare_acted": "You have already made your choice!", + "sleepy_nightmare_dullahan_idle": "Because you did not act in time, you are consumed by the thrill of the hunt as you give chase to your target.", + "sleepy_nightmare_target_idle": "Because you did not act in time, your fight-or-flight response urges you to run away as fast as you can.", "sleepy_priest_death": "The sky suddenly darkens as a thunderstorm appears from nowhere. The bell on the newly-abandoned church starts ringing in sinister tones before the building is struck repeatedly by lightning, setting it alight in a raging inferno...", "sleepy_doomsayer_turn": "You feel something rushing into you and taking control over your mind and body. It causes you to rapidly start transforming into a werewolf, and you realize your vision powers can now be used to inflict malady on the unwary. You are now {=doomsayer!role:article} {=doomsayer!role:bold}.", "sleepy_succubus_turn": "You feel something rushing into you and taking control over your mind and body. You are now {=succubus!role:article} {=succubus!role:bold}. Your job is to entrance the village, bringing them all under your absolute control.", "sleepy_demoniac_turn": "You feel something rushing into you and taking control over your mind and body, showing you your new purpose in life. There are far greater evils than the wolves lurking in the shadows, and by sacrificing all of the wolves, you can unleash those evils upon the world. You are now {=demoniac!role:article} {=demoniac!role:bold}.", + "sleepy_jester_turn": "You hear dark whispers promising you power and the ability to fight back against all that is wrong with the world. You need only to shuffle off your mortal coil, they say. You cackle madly, thinking that to be a small price to pay. You are now {=jester!role:article} {=jester!role:bold}.", + "sleepy_monster_turn": "You feel something rushing into you and taking control over your mind and body. You don't need the wolves in order to ruin the village, you can do it all yourself! You are now {=monster!role:article} {=monster!role:bold}.", "fquit_fail": "Forcing a live player to leave must be done in channel.", "demoniac_win": "Game over! All the wolves are dead! As the villagers start preparing the BBQ, a sudden flash illuminates the sky. Demonic spirits emerge around the sacrificed wolves and possess all villagers, causing the {=demoniac,demoniacs:plural({0})} to win.", "succubus_win": "Game over! The {=succubus,succubi:plural({0})} {=has,have:plural({0})} completely enthralled the village, making them officers in an ever-growing army set on spreading their {=master's,masters':plural({0})} control and influence throughout the entire world.", diff --git a/src/defaultsettings.yml b/src/defaultsettings.yml index 7216b37f..a5b59813 100644 --- a/src/defaultsettings.yml +++ b/src/defaultsettings.yml @@ -1214,28 +1214,29 @@ gameplay: &gameplay _desc: Settings related to the nightmare mechanic in the sleepy mode. _type: dict _default: - chance: + steps: _desc: > - How often a nightmare will occur. Set to 0 to disable nightmares. 1 or above will guarantee - a nightmare will occur each night. A value between 0 and 1 is the percentage chance a nightmare - will occur (e.g. 0.42 is a 42% chance a nightmare will occur). If multiple people are allowed to - have nightmares (see gameplay.modes.sleepy.nightmare.max), this is rolled once per allowed player. - _type: - - float - - int - _default: 0.2 - max: - _desc: The maximum number of people that can have a nightmare each night. + How many steps the chased needs to move forwards in order to escape the nightmare. + Reducing this makes it easier for chased to escape, increasing it makes it more difficult. + The value must be between 1 and 4, inclusive. _type: int - _default: 1 + _default: 3 + time: + _desc: > + How long each round of the nightmare lasts at most. Chased who do not lock in a command in time + will default to run, whereas dullahans who do not lock in a command in time will default to chase. + No warning points are given for failing to meet time limits. The nightmare will take at most + time * steps seconds in total. Setting this to 0 disables timers during nightmares. + _type: int + _default: 20 turn: _desc: > - How likely certain roles (seer, harlot, cultist) are to change into other roles when priest dies. + How likely certain roles (seer, harlot) are to change into other roles when priest dies. A value between 0 and 1 is the percentage chance for these roles to turn (e.g. 0.42 is a 42% chance). _type: - float - int - _default: 0.6 + _default: 0.4 weight: _desc: How likely this mode is to appear if there are no votes for gamemodes. _type: int diff --git a/src/gamecmds.py b/src/gamecmds.py index 3a4806ea..ef14c5cf 100644 --- a/src/gamecmds.py +++ b/src/gamecmds.py @@ -183,11 +183,13 @@ def on_reconfigure_stats(evt: Event, var: GameState, roleset: Counter, reason: s global LAST_STATS LAST_STATS = None -@command("time", pm=True, phases=("join", "day", "night")) +@command("time", pm=True) def timeleft(wrapper: MessageDispatcher, message: str): """Returns the time left until the next day/night transition.""" global LAST_TIME var = wrapper.game_state + if var is None: + return if wrapper.public: if LAST_TIME and LAST_TIME + timedelta(seconds=config.Main.get("ratelimits.time")) > datetime.now(): @@ -198,12 +200,8 @@ def timeleft(wrapper: MessageDispatcher, message: str): if var.current_phase == "join": dur = int((pregame.CAN_START_TIME - datetime.now()).total_seconds()) - msg = None if dur > 0: - msg = messages["start_timer"].format(dur) - - if msg is not None: - wrapper.reply(msg) + wrapper.reply(messages["start_timer"].format(dur)) if var.current_phase in trans.TIMERS or f"{var.current_phase}_limit" in trans.TIMERS: if var.current_phase == "day": diff --git a/src/gamemodes/pactbreaker.py b/src/gamemodes/pactbreaker.py index b4b3317e..8817ed59 100644 --- a/src/gamemodes/pactbreaker.py +++ b/src/gamemodes/pactbreaker.py @@ -108,17 +108,6 @@ def __init__(self, arg=""): def startup(self): super().startup() - self.night_kill_messages.clear() - self.active_players.clear() - self.in_stocks = None - self.voted.clear() - self.drained.clear() - self.protected.clear() - self.turned.clear() - self.collected_evidence.clear() - self.visiting.clear() - self.killing.clear() - self.clue_tokens.clear() # register !visit, !id, and !kill, remove all role commands self.visit_command.register() self.id_command.register() @@ -152,6 +141,16 @@ def teardown(self): vigilante_kill.register() vigilante_retract.register() vigilante_pass.register() + # clear user containers + self.visiting.clear() + self.killing.clear() + self.drained.clear() + self.protected.clear() + self.turned.clear() + self.voted.clear() + self.active_players.clear() + self.collected_evidence.clear() + self.clue_tokens.clear() def on_del_player(self, evt: Event, var: GameState, player, all_roles, death_triggers): # self.night_kills isn't updated because it is short-lived @@ -432,8 +431,7 @@ def on_night_kills(self, evt: Event, var: GameState): loc = self.visiting[visitor].name visitor.send(messages[f"pactbreaker_{loc}_empty"]) elif len(shares) > 1: - num_tokens = min(len(shares) - 1, - math.floor(self.clue_pool / len(shares)), + num_tokens = min(math.floor(self.clue_pool / len(shares)), config.Main.get("gameplay.modes.pactbreaker.clue.square")) for visitor in shares: loc = self.visiting[visitor].name diff --git a/src/gamemodes/sleepy.py b/src/gamemodes/sleepy.py index d361c65b..a5873731 100644 --- a/src/gamemodes/sleepy.py +++ b/src/gamemodes/sleepy.py @@ -1,265 +1,299 @@ import random -import threading -import functools -from collections import Counter +from collections import Counter, defaultdict + +from src.cats import Wolf +from src.dispatcher import MessageDispatcher from src.gamemodes import game_mode, GameMode from src.messages import messages -from src.containers import UserList, UserDict +from src.containers import UserDict, UserSet from src.decorators import command, handle_error -from src.functions import get_players, change_role +from src.functions import get_players, change_role, get_main_role from src.gamestate import GameState -from src.status import add_dying +from src.status import remove_all_protections from src.events import EventListener, Event -from src import channels, config, locks +from src import channels, config +from src.users import User -@game_mode("sleepy", minp=10, maxp=24) +@game_mode("sleepy", minp=8, maxp=24) class SleepyMode(GameMode): """A small village has become the playing ground for all sorts of supernatural beings.""" def __init__(self, arg=""): super().__init__(arg) self.ROLE_GUIDE = { - 10: ["wolf", "werecrow", "cultist", "seer", "prophet", "priest", "dullahan", "cursed villager", "blessed villager"], - 12: ["wolf(2)", "vigilante"], - 15: ["wolf(3)", "detective", "vengeful ghost"], - 18: ["wolf(4)", "harlot", "monster"], - 21: ["wolf(5)", "village drunk", "monster(2)", "gunner"], + 8: ["werecrow", "traitor", "mystic", "seer", "priest", "dullahan", "cursed villager"], + 9: ["amnesiac"], + 10: ["-traitor", "wolf", "blessed villager", "cursed villager(2)"], + 11: ["village drunk"], + 12: ["wolf(2)"], + 13: ["vengeful ghost"], + 14: ["sorcerer", "prophet"], + 15: ["gunner"], + 16: ["-wolf", "fallen angel", "vigilante"], + 17: ["succubus"], + 18: ["detective"], + 20: ["werecrow(2)"], + 21: ["monster", "hunter"], + 22: ["augur", "amnesiac(2)"], + 23: ["insomniac", "cultist"], + 24: ["wolf(3)", "harlot"] } - self.NIGHTMARE_CHANCE = config.Main.get("gameplay.modes.sleepy.nightmare.chance") - self.NIGHTMARE_MAX = config.Main.get("gameplay.modes.sleepy.nightmare.max") + self.TURN_CHANCE = config.Main.get("gameplay.modes.sleepy.turn") - # Make sure priest is always prophet AND blessed, and that drunk is always gunner + # Force secondary roles self.SECONDARY_ROLES["blessed villager"] = {"priest"} - self.SECONDARY_ROLES["prophet"] = {"priest"} self.SECONDARY_ROLES["gunner"] = {"village drunk"} + self.SECONDARY_ROLES["hunter"] = {"monster"} self.EVENTS = { "dullahan_targets": EventListener(self.dullahan_targets), - "transition_night_begin": EventListener(self.setup_nightmares), - "chk_nightdone": EventListener(self.prolong_night), - "transition_day_begin": EventListener(self.nightmare_kill), + "chk_nightdone": EventListener(self.setup_nightmares, priority=10), "del_player": EventListener(self.happy_fun_times), "revealroles": EventListener(self.on_revealroles), - "night_idled": EventListener(self.on_night_idled) + "remove_protection": EventListener(self.on_remove_protection), } - self.having_nightmare = UserList() - cmd_params = dict(chan=False, pm=True, playing=True, phases=("night",), - users=self.having_nightmare, register=False) - self.north_cmd = command("north", **cmd_params)(functools.partial(self.move, "n")) - self.east_cmd = command("east", **cmd_params)(functools.partial(self.move, "e")) - self.south_cmd = command("south", **cmd_params)(functools.partial(self.move, "s")) - self.west_cmd = command("west", **cmd_params)(functools.partial(self.move, "w")) - - self.correct = UserDict() - self.fake1 = UserDict() - self.fake2 = UserDict() - self.step = UserDict() - self.prev_direction = UserDict() - self.start_direction = UserDict() - self.on_path = UserDict() + self.MESSAGE_OVERRIDES = { + "mystic_info_nightmare": "mystic_info_night" + } + + self.having_nightmare: UserDict[User, User] = UserDict() + self.nightmare_progress: UserDict[User, int] = UserDict() + self.nightmare_acted = UserSet() + # nightmare commands for the person being chased + cmd_params = dict(chan=False, pm=True, playing=True, phases=("nightmare",), + users=self.having_nightmare.values(), register=False) + self.hide_cmd = command("hide", **cmd_params)(self.hide) + self.run_cmd = command("run", **cmd_params)(self.run) + + # nightmare commands for dulla + cmd_params = dict(chan=False, pm=True, playing=True, phases=("nightmare",), + roles=("dullahan",), register=False) + self.search_cmd = command("search", **cmd_params)(self.search) + self.chase_cmd = command("chase", **cmd_params)(self.chase) def startup(self): super().startup() - self.north_cmd.register() - self.east_cmd.register() - self.south_cmd.register() - self.west_cmd.register() + self.hide_cmd.register() + self.run_cmd.register() + self.search_cmd.register() + self.chase_cmd.register() def teardown(self): super().teardown() - self.north_cmd.remove() - self.east_cmd.remove() - self.south_cmd.remove() - self.west_cmd.remove() + self.hide_cmd.remove() + self.run_cmd.remove() + self.search_cmd.remove() + self.chase_cmd.remove() + # clear user containers self.having_nightmare.clear() - self.correct.clear() - self.fake1.clear() - self.fake2.clear() - self.step.clear() - self.prev_direction.clear() - self.start_direction.clear() - self.on_path.clear() + self.nightmare_progress.clear() + self.nightmare_acted.clear() def dullahan_targets(self, evt: Event, var: GameState, dullahan, max_targets): evt.data["targets"].update(get_players(var, ("priest",))) - evt.data["exclude"].update(get_players(var, ("werecrow",))) - # also exclude half the wolves (counting crow, rounded down) to ensure dulla doesn't just completely murder wolfteam - wolves = set(get_players(var, ("wolf",))) - num_exclusions = int(len(wolves) / 2) - if num_exclusions > 0: - evt.data["exclude"].update(random.sample(list(wolves), num_exclusions)) - wolves.difference_update(evt.data["exclude"]) + evt.data["exclude"].update(get_players(var, Wolf)) + # dulla needs 1 fewer target to win than normal + evt.data["num_targets"] = max_targets - 1 def setup_nightmares(self, evt: Event, var: GameState): - pl = get_players(var) - for i in range(self.NIGHTMARE_MAX): - if not pl: - break - if random.random() < self.NIGHTMARE_CHANCE: - with locks.join_timer: - target = random.choice(pl) - pl.remove(target) - t = threading.Timer(60, self.do_nightmare, (var, target, var.night_count)) - t.daemon = True - t.start() + from src.roles.dullahan import KILLS + dullahans = get_players(var, ("dullahan",)) + # don't give nightmares to other dullas, because that'd just be awkward + filtered = [x for x in KILLS.values() if x not in dullahans] + if filtered: + evt.data["transition_day"] = self.do_nightmares + # called from trans.py when night ends and dulla has kills @handle_error - def do_nightmare(self, var: GameState, target, night): - if var.current_phase != "night" or var.night_count != night: - return - if target not in get_players(var): - return - self.having_nightmare.append(target) - target.send(messages["sleepy_nightmare_begin"]) - target.send(messages["sleepy_nightmare_navigate"]) - self.correct[target] = [None, None, None] - self.fake1[target] = [None, None, None] - self.fake2[target] = [None, None, None] - directions = ["n", "e", "s", "w"] - self.step[target] = 0 - self.prev_direction[target] = None - opposite = {"n": "s", "e": "w", "s": "n", "w": "e"} - for i in range(3): - corrdir = directions[:] - f1dir = directions[:] - f2dir = directions[:] - if i > 0: - corrdir.remove(opposite[self.correct[target][i-1]]) - f1dir.remove(opposite[self.fake1[target][i-1]]) - f2dir.remove(opposite[self.fake2[target][i-1]]) + def do_nightmares(self, var: GameState): + from src.roles.dullahan import KILLS + self.having_nightmare.update(KILLS) + self.nightmare_progress.clear() + steps = config.Main.get("gameplay.modes.sleepy.nightmare.steps") + + counts = defaultdict(int) + time_limit = config.Main.get("gameplay.modes.sleepy.nightmare.time") + timers_enabled = config.Main.get("timers.enabled") + for dulla, target in self.having_nightmare.items(): + if get_main_role(var, target) == "dullahan": + continue + # ensure regular dullahan kill logic doesn't fire since we do it specially + # (except for dullahans targeting themselves or other dullahans) + del KILLS[dulla] + self.nightmare_progress[dulla] = 3 - steps + self.nightmare_progress[target] = 4 - steps + counts[target] += 1 + dulla.send(messages["sleepy_nightmare_start_dullahan"].format(target)) + if timers_enabled and time_limit: + dulla.queue_message(messages["sleepy_nightmare_timer_notify"].format(time_limit)) + + # send the initial messages to targets too + for target, count in counts.items(): + if count == 1: + target.send(messages["sleepy_nightmare_start_target"]) else: - corrdir.remove("s") - f1dir.remove("s") - f2dir.remove("s") - self.correct[target][i] = random.choice(corrdir) - # ensure fake1 and correct share the first choice but have different second choices - # and ensure fake2 has a different first choice from everything else - if i == 0: - self.fake1[target][i] = self.correct[target][i] - f2dir.remove(self.correct[target][i]) - self.fake2[target][i] = random.choice(f2dir) - elif i == 1: - f1dir.remove(self.correct[target][i]) - self.fake1[target][i] = random.choice(f1dir) - self.fake2[target][i] = random.choice(f2dir) + target.send(messages["sleepy_nightmare_start_target_multiple"].format(count)) + if timers_enabled and time_limit: + target.queue_message(messages["sleepy_nightmare_timer_notify"].format(time_limit)) + + # send timer_notify messages + User.send_messages() + + # kick it all off + self.nightmare_step(var) + + @handle_error + def nightmare_timer(self, timer_type: str, var: GameState): + idlers = False + for dulla, target in self.having_nightmare.items(): + if dulla not in self.nightmare_acted: + idlers = True + self.nightmare_progress[dulla] += 2 + dulla.send(messages["sleepy_nightmare_dullahan_idle"]) + if target not in self.nightmare_acted: + idlers = True + self.nightmare_progress[target] += 1 + target.send(messages["sleepy_nightmare_target_idle"]) + + if idlers: + self.nightmare_step(var) + + def nightmare_step(self, var: GameState): + from src.roles.dullahan import KILLS, TARGETS + # keep track of who was already sent messages in case they're being chased by multiple dullahans + notified = set() + dulla_counts = defaultdict(int) + for target in self.having_nightmare.values(): + dulla_counts[target] += 1 + + for dulla, target in list(self.having_nightmare.items()): + if self.nightmare_progress[dulla] == self.nightmare_progress[target]: + # dulla caught up and target dies + del self.having_nightmare[dulla] + if target not in notified: + notified.add(target) + target.send(messages["sleepy_nightmare_caught"].format(dulla_counts[target])) + dulla.send(messages["sleepy_nightmare_kill"].format(target)) + KILLS[dulla] = target + remove_all_protections(var, target, dulla, "dullahan", "nightmare") + elif self.nightmare_progress[dulla] > self.nightmare_progress[target]: + # dulla passed the target by (maybe escaping or maybe a different dulla catches them) + del self.having_nightmare[dulla] + remaining = sum(1 for x in self.having_nightmare.values() if x is target) + if remaining == 0 and target not in notified: + # target escapes fully + notified.add(target) + target.send(messages["sleepy_nightmare_escape_hide"].format(dulla_counts[target])) + dulla.send(messages["sleepy_nightmare_fail_hide"]) + TARGETS[dulla].discard(target) + elif self.nightmare_progress[target] == 4: + # target escapes + del self.having_nightmare[dulla] + if target not in notified: + notified.add(target) + target.send(messages["sleepy_nightmare_escape_run"].format(dulla_counts[target])) + dulla.send(messages["sleepy_nightmare_fail_river"]) + TARGETS[dulla].discard(target) else: - self.fake1[target][i] = random.choice(f1dir) - self.fake2[target][i] = random.choice(f2dir) - self.prev_direction[target] = "n" - self.start_direction[target] = "n" - self.on_path[target] = set() - self.nightmare_step(target) - - def nightmare_step(self, target): - # FIXME: hardcoded English - if self.prev_direction[target] == "n": - directions = "north, east, and west" - elif self.prev_direction[target] == "e": - directions = "north, east, and south" - elif self.prev_direction[target] == "s": - directions = "east, south, and west" - elif self.prev_direction[target] == "w": - directions = "north, south, and west" + # target still being chased + if target not in notified: + notified.add(target) + target.send(messages["sleepy_nightmare_target_step_{0}".format(self.nightmare_progress[target])]) + dulla.send(messages["sleepy_nightmare_dullahan_step"].format(4 - self.nightmare_progress[target])) + + self.nightmare_acted.clear() + if self.having_nightmare: + # need another round of nightmares + var.begin_phase_transition("nightmare") + time_limit = config.Main.get("gameplay.modes.sleepy.nightmare.time") + var.end_phase_transition(time_limit, timer_cb=self.nightmare_timer, cb_args=(var,)) else: - # wat? reset them - self.step[target] = 0 - self.prev_direction[target] = self.start_direction[target] - self.on_path[target] = set() - directions = "north, east, and west" - - if self.step[target] == 0: - target.send(messages["sleepy_nightmare_0"].format(directions)) - elif self.step[target] == 1: - target.send(messages["sleepy_nightmare_1"].format(directions)) - elif self.step[target] == 2: - target.send(messages["sleepy_nightmare_2"].format(directions)) - elif self.step[target] == 3: - if "correct" in self.on_path[target]: - target.send(messages["sleepy_nightmare_wake"]) - self.having_nightmare.remove(target) - elif "fake1" in self.on_path[target]: - target.send(messages["sleepy_nightmare_fake_1"]) - self.step[target] = 0 - self.on_path[target] = set() - self.prev_direction[target] = self.start_direction[target] - self.nightmare_step(target) - elif "fake2" in self.on_path[target]: - target.send(messages["sleepy_nightmare_fake_2"]) - self.step[target] = 0 - self.on_path[target] = set() - self.prev_direction[target] = self.start_direction[target] - self.nightmare_step(target) - - def move(self, direction, wrapper, message): - opposite = {"n": "s", "e": "w", "s": "n", "w": "e"} - target = wrapper.source - if self.prev_direction[target] == opposite[direction]: - wrapper.pm(messages["sleepy_nightmare_invalid_direction"]) + # all nightmares resolved, can finally make it daytime + from src.trans import transition_day + self.nightmare_progress.clear() + transition_day(var) + + def _resolve_nightmare_command(self, wrapper: MessageDispatcher, cmd: str): + self.nightmare_acted.add(wrapper.source) + wrapper.reply(messages["sleepy_nightmare_success"].format(cmd)) + need_act = set(self.having_nightmare.keys()) | set(self.having_nightmare.values()) + if need_act == set(self.nightmare_acted): + self.nightmare_step(wrapper.game_state) + + def hide(self, wrapper: MessageDispatcher, message: str): + """Attempt to hide from the dullahan chasing you.""" + if wrapper.source in self.nightmare_acted: + wrapper.reply(messages["sleepy_nightmare_acted"]) return - advance = False - step = self.step[target] - if ("correct" in self.on_path[target] or step == 0) and self.correct[target][step] == direction: - self.on_path[target].add("correct") - advance = True - else: - self.on_path[target].discard("correct") - if ("fake1" in self.on_path[target] or step == 0) and self.fake1[target][step] == direction: - self.on_path[target].add("fake1") - advance = True - else: - self.on_path[target].discard("fake1") - if ("fake2" in self.on_path[target] or step == 0) and self.fake2[target][step] == direction: - self.on_path[target].add("fake2") - advance = True - else: - self.on_path[target].discard("fake2") - if advance: - self.step[target] += 1 - self.prev_direction[target] = direction - else: - self.step[target] = 0 - self.on_path[target] = set() - self.prev_direction[target] = self.start_direction[target] - wrapper.pm(messages["sleepy_nightmare_restart"]) - self.nightmare_step(target) - - def prolong_night(self, evt: Event, var: GameState): - evt.data["nightroles"].extend(self.having_nightmare) - - def on_night_idled(self, evt: Event, var: GameState, player): - # don't give warning points if the person having a nightmare idled out night - if player in self.having_nightmare: - evt.prevent_default = True - - def nightmare_kill(self, evt: Event, var: GameState): - pl = get_players(var) - for player in self.having_nightmare: - if player not in pl: - continue - add_dying(var, player, "bot", "night_kill") - player.send(messages["sleepy_nightmare_death"]) - self.having_nightmare.clear() + + self._resolve_nightmare_command(wrapper, "hide") + + def run(self, wrapper: MessageDispatcher, message: str): + """Attempt to run from the dullahan chasing you.""" + if wrapper.source in self.nightmare_acted: + wrapper.reply(messages["sleepy_nightmare_acted"]) + return + + self.nightmare_progress[wrapper.source] += 1 + self._resolve_nightmare_command(wrapper, "run") + + def search(self, wrapper: MessageDispatcher, message: str): + """Chase at a slower pace to catch hiding targets.""" + if wrapper.source in self.nightmare_acted: + wrapper.reply(messages["sleepy_nightmare_acted"]) + return + + self.nightmare_progress[wrapper.source] += 1 + self._resolve_nightmare_command(wrapper, "search") + + def chase(self, wrapper: MessageDispatcher, message: str): + """Chase at a faster pace to catch running targets.""" + if wrapper.source in self.nightmare_acted: + wrapper.reply(messages["sleepy_nightmare_acted"]) + return + + self.nightmare_progress[wrapper.source] += 2 + self._resolve_nightmare_command(wrapper, "chase") def happy_fun_times(self, evt: Event, var: GameState, player, all_roles, death_triggers): if death_triggers and evt.params.main_role == "priest": channels.Main.send(messages["sleepy_priest_death"]) - mapping = {"seer": "doomsayer", "harlot": "succubus", "cultist": "demoniac"} + mapping = {"seer": "doomsayer", + "harlot": "succubus", + "cultist": "demoniac", + "vengeful ghost": "jester"} for old, new in mapping.items(): turn = [p for p in get_players(var, (old,)) if random.random() < self.TURN_CHANCE] for t in turn: - # messages: sleepy_doomsayer_turn, sleepy_succubus_turn, sleepy_demoniac_turn + if new == "doomsayer": + # so game doesn't just end immediately at 8-9p, make the traitor into a monster too + # do this before seer turns to doomsayer so the traitor doesn't know the new wolf + for traitor in get_players(var, ("traitor",)): + change_role(var, traitor, "traitor", "monster", message="sleepy_monster_turn") + # messages: sleepy_doomsayer_turn, sleepy_succubus_turn, sleepy_demoniac_turn, sleepy_jester_turn change_role(var, t, old, new, message="sleepy_{0}_turn".format(new)) + if new == "jester": + # VGs turned into jesters remain spicy + var.roles["vengeful ghost"].add(t) newstats = set() for rs in var.get_role_stats(): d = Counter(dict(rs)) newstats.add(rs) if old in d and d[old] >= 1: - d[old] -= 1 - d[new] += 1 - newstats.add(frozenset(d.items())) + for i in range(1, d[old] + 1): + d[old] -= i + d[new] += i + if new == "doomsayer" and "traitor" in d and d["traitor"] >= 1: + d["monster"] += d["traitor"] + d["traitor"] = 0 + newstats.add(frozenset(d.items())) var.set_role_stats(newstats) + def on_remove_protection(self, evt: Event, var: GameState, target: User, attacker: User, attacker_role: str, protector: User, protector_role: str, reason: str): + if reason == "nightmare": + evt.data["remove"] = True + def on_revealroles(self, evt: Event, var: GameState): if self.having_nightmare: - evt.data["output"].append(messages["sleepy_revealroles"].format(self.having_nightmare)) + evt.data["output"].append(messages["sleepy_revealroles"].format(self.having_nightmare.values())) diff --git a/src/gamestate.py b/src/gamestate.py index e2cd0bda..5ca1b259 100644 --- a/src/gamestate.py +++ b/src/gamestate.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import threading from typing import Any, Optional, Callable, ClassVar, TYPE_CHECKING import time @@ -143,19 +144,41 @@ def in_game(self): return self.setup_completed and not self._torndown def begin_phase_transition(self, phase: str): + from src.trans import TIMERS if self.next_phase is not None: raise RuntimeError("already in phase transition") self.next_phase = phase # this is a bit convoluted, but this lets external code plug in their own phases # for grep: var.day_count and var.night_count get incremented here - setattr(self, f"{self.next_phase}_count", getattr(self, f"{self.next_phase}_count") + 1) - - def end_phase_transition(self): + attr = f"{self.next_phase}_count" + setattr(self, attr, getattr(self, attr, 0) + 1) + + if f"{self.current_phase}_limit" in TIMERS: + TIMERS[f"{self.current_phase}_limit"][0].cancel() + del TIMERS[f"{self.current_phase}_limit"] + if f"{self.current_phase}_warn" in TIMERS: + TIMERS[f"{self.current_phase}_warn"][0].cancel() + del TIMERS[f"{self.current_phase}_warn"] + + def end_phase_transition(self, time_limit: int = 0, time_warn: int = 0, timer_cb=None, cb_args=()): + from src.trans import TIMERS if self.next_phase is None: raise RuntimeError("not in phase transition") self.current_phase = self.next_phase self.next_phase = None + if config.Main.get("timers.enabled"): + if time_limit: + timer = threading.Timer(time_limit, timer_cb, ("limit",) + tuple(cb_args)) + timer.daemon = True + timer.start() + TIMERS[f"{self.current_phase}_limit"] = (timer, time.time(), time_limit) + + if time_warn: + timer = threading.Timer(time_warn, timer_cb, ("warn",) + tuple(cb_args)) + timer.daemon = True + timer.start() + TIMERS[f"{self.current_phase}_warn"] = (timer, time.time(), time_warn) @property def in_phase_transition(self): diff --git a/src/roles/dullahan.py b/src/roles/dullahan.py index 3d573fee..ae0bb359 100644 --- a/src/roles/dullahan.py +++ b/src/roles/dullahan.py @@ -31,13 +31,16 @@ def dullahan_kill(wrapper: MessageDispatcher, message: str): if not target: return + if target not in TARGETS[wrapper.source]: + wrapper.pm(messages["dullahan_not_target"].format(target)) + return + orig = target target = try_misdirection(var, wrapper.source, target) if try_exchange(var, wrapper.source, target): return KILLS[wrapper.source] = target - wrapper.pm(messages["player_kill"].format(orig)) @command("retract", chan=False, pm=True, playing=True, phases=("night",), roles=("dullahan",)) @@ -98,9 +101,10 @@ def on_new_role(evt: Event, var: GameState, player: User, old_role: Optional[str max_targets = math.ceil(8.1 * math.log(len(pl), 10) - 5) TARGETS[player] = UserSet() - dull_targets = Event("dullahan_targets", {"targets": set(), "exclude": set()}) + dull_targets = Event("dullahan_targets", {"targets": set(), "exclude": set(), "num_targets": max_targets}) dull_targets.dispatch(var, player, max_targets) TARGETS[player].update(dull_targets.data["targets"] - dull_targets.data["exclude"]) + max_targets = dull_targets.data["num_targets"] pl = list(set(pl) - dull_targets.data["exclude"] - {player}) while pl and len(TARGETS[player]) < max_targets: diff --git a/src/roles/timelord.py b/src/roles/timelord.py index b5f3ae3e..5ba88e04 100644 --- a/src/roles/timelord.py +++ b/src/roles/timelord.py @@ -38,21 +38,19 @@ def on_del_player(evt: Event, var: GameState, player: User, all_roles: set[str], values = dict(TIME_ATTRIBUTES) channels.Main.send(messages["time_lord_dead"].format(values["day_time_limit"], values["night_time_limit"])) - from src.trans import hurry_up, night_warn, night_timeout, DAY_ID, NIGHT_ID, TIMERS + from src.trans import hurry_up, night_timeout, DAY_ID, NIGHT_ID, TIMERS if var.current_phase == "day": time_limit = var.day_time_limit - limit_cb = hurry_up - limit_args = [var, DAY_ID, True] + cb = hurry_up + limit_args = ["limit", var, DAY_ID] time_warn = var.day_time_warn - warn_cb = hurry_up - warn_args = [var, DAY_ID, False] + warn_args = ["warn", var, DAY_ID, False] timer_name = "day_warn" elif var.current_phase == "night": time_limit = var.night_time_limit - limit_cb = night_timeout + cb = night_timeout limit_args = [var, NIGHT_ID] time_warn = var.night_time_warn - warn_cb = night_warn warn_args = [var, NIGHT_ID] timer_name = "night_warn" else: @@ -62,7 +60,7 @@ def on_del_player(evt: Event, var: GameState, player: User, all_roles: set[str], time_left = int((TIMERS[f"{var.current_phase}_limit"][1] + TIMERS[f"{var.current_phase}_limit"][2]) - time.time()) if time_left > time_limit > 0: - t = threading.Timer(time_limit, limit_cb, limit_args) + t = threading.Timer(time_limit, cb, limit_args) TIMERS[f"{var.current_phase}_limit"] = (t, time.time(), time_limit) t.daemon = True t.start() @@ -70,9 +68,9 @@ def on_del_player(evt: Event, var: GameState, player: User, all_roles: set[str], # Don't duplicate warnings, i.e. only set the warning timer if a warning was not already given if timer_name in TIMERS and time_warn > 0: timer = TIMERS[timer_name][0] - if timer.isAlive(): + if not timer.finished.is_set(): timer.cancel() - t = threading.Timer(time_warn, warn_cb, warn_args) + t = threading.Timer(time_warn, cb, warn_args) TIMERS[timer_name] = (t, time.time(), time_warn) t.daemon = True t.start() diff --git a/src/trans.py b/src/trans.py index d341ab6e..1eedc736 100644 --- a/src/trans.py +++ b/src/trans.py @@ -45,14 +45,14 @@ ORIGINAL_ACCOUNTS: UserDict[User, str] = UserDict() @handle_error -def hurry_up(var: GameState, game_id: int, change: bool, *, admin_forced: bool = False): +def hurry_up(timer_type: str, var: GameState, phase_id: float, *, admin_forced: bool = False): global DAY_ID if var.current_phase != "day" or var.in_phase_transition: return - if game_id and game_id != DAY_ID: + if phase_id and phase_id != DAY_ID: return - if not change: + if timer_type == "warn": event = Event("daylight_warning", {"message": "daylight_warning"}) event.dispatch(var) channels.Main.send(messages[event.data["message"]]) @@ -67,7 +67,7 @@ def fnight(wrapper: MessageDispatcher, message: str): if wrapper.game_state.current_phase != "day": wrapper.pm(messages["not_daytime"]) else: - hurry_up(wrapper.game_state, 0, True, admin_forced=True) + hurry_up("limit", wrapper.game_state, 0, admin_forced=True) @command("fday", flag="N") def fday(wrapper: MessageDispatcher, message: str): @@ -78,37 +78,34 @@ def fday(wrapper: MessageDispatcher, message: str): transition_day(wrapper.game_state) def begin_day(var: GameState): - # Reset nighttime variables - var.end_phase_transition() - msg = messages["villagers_vote"].format(len(get_players(var)) // 2 + 1) - channels.Main.send(msg) - global DAY_ID DAY_ID = time.time() - if config.Main.get("timers.enabled"): - value = None - if config.Main.get("timers.day.enabled"): - value = "day_time_{0}" - if config.Main.get("timers.shortday.enabled") and len(get_players(var)) <= config.Main.get("timers.shortday.players"): - value = "short_day_time_{0}" - if value is not None: - for s in ("warn", "limit"): - if getattr(var, value.format(s)): - timer = threading.Timer(getattr(var, value.format(s)), hurry_up, (var, DAY_ID, (s == "limit"))) - timer.daemon = True - timer.start() - TIMERS[f"day_{s}"] = (timer, DAY_ID, getattr(var, value.format(s))) + pl = get_players(var) + msg = messages["villagers_vote"].format(len(pl) // 2 + 1) + channels.Main.send(msg) + + if config.Main.get("timers.shortday.enabled") and len(pl) <= config.Main.get("timers.shortday.players"): + warn = var.short_day_time_warn + limit = var.short_day_time_limit + elif config.Main.get("timers.day.enabled"): + warn = var.day_time_warn + limit = var.day_time_limit + else: + warn = 0 + limit = 0 + + var.end_phase_transition(limit, warn, hurry_up, (var, DAY_ID)) 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.nick)) channels.Main.mode(*modes) # move everyone to the village square (or home if they're absent) absent = get_absent(var) - for p in get_players(var): + for p in pl: if p in absent: move_player_home(var, p) else: @@ -119,11 +116,7 @@ def begin_day(var: GameState): # induce a lynch if we need to (due to lots of pacifism/impatience totems or whatever) chk_decision(var) -@handle_error -def night_warn(var: GameState, gameid: int): - if gameid != NIGHT_ID or var.current_phase != "night" or var.in_phase_transition: - return - +def _night_warn(var: GameState): channels.Main.send(messages["twilight_warning"]) # determine who hasn't acted yet and remind them to act @@ -144,8 +137,12 @@ def night_warn(var: GameState, gameid: int): users.User.send_messages() @handle_error -def night_timeout(var: GameState, gameid: int): - if gameid != NIGHT_ID or var.current_phase != "night" or var.in_phase_transition: +def night_timeout(timer_type: str, var: GameState, phase_id: int): + if phase_id != NIGHT_ID or var.current_phase != "night" or var.in_phase_transition: + return + + if timer_type == "warn": + _night_warn(var) return # determine which roles idled out night and give them warnings @@ -154,7 +151,7 @@ def night_timeout(var: GameState, gameid: int): # if night idle warnings are disabled, head straight to day if not config.Main.get("reaper.night_idle.enabled"): - event.data["transition_day"](var, gameid) + event.data["transition_day"](var, phase_id) return # remove all instances of them if they are silenced (makes implementing the event easier) @@ -173,7 +170,7 @@ def night_timeout(var: GameState, gameid: int): # 2. warning is deferred to end of game so admins can't !fwarn list to cheat and determine who idled reaper.NIGHT_IDLED.add(player) - event.data["transition_day"](var, gameid) + event.data["transition_day"](var, phase_id) @event_listener("night_idled") def on_night_idled(evt: Event, var: GameState, player): @@ -409,10 +406,6 @@ def transition_night(var: GameState): modes.append(("-v", player)) channels.Main.mode(*modes) - for x, tmr in TIMERS.items(): # cancel daytime timer - tmr[0].cancel() - TIMERS.clear() - dmsg = [] NIGHT_ID = time.time() @@ -424,18 +417,6 @@ def transition_night(var: GameState): min, sec = td.seconds // 60, td.seconds % 60 dmsg.append(messages["day_lasted"].format(min, sec)) - if config.Main.get("timers.enabled"): - value = None - if config.Main.get("timers.night.enabled"): - value = "night_time_{0}" - if value is not None: - for s, fn in (("warn", night_warn), ("limit", night_timeout)): - if getattr(var, value.format(s)): - timer = threading.Timer(getattr(var, value.format(s)), fn, (var, NIGHT_ID)) - timer.daemon = True - timer.start() - TIMERS[f"night_{s}"] = (timer, NIGHT_ID, getattr(var, value.format(s))) - event_role = Event("send_role", {}) event_role.dispatch(var) @@ -449,7 +430,14 @@ def transition_night(var: GameState): channels.Main.send(*dmsg, sep=" ") # it's now officially nighttime - var.end_phase_transition() + if config.Main.get("timers.night.enabled"): + warn = var.night_time_warn + limit = var.night_time_limit + else: + warn = 0 + limit = 0 + + var.end_phase_transition(limit, warn, night_timeout, (var, NIGHT_ID)) event_night = Event("begin_night", {"messages": []}) event_night.dispatch(var) @@ -470,12 +458,7 @@ def chk_nightdone(var: GameState): nightroles = [p for p in event.data["nightroles"] if not is_silent(var, p)] if var.current_phase == "night" and actedcount >= len(nightroles): - for x, t in TIMERS.items(): - t[0].cancel() - - TIMERS.clear() - if var.current_phase == "night": # Double check - event.data["transition_day"](var) + event.data["transition_day"](var) def stop_game(var: Optional[GameState | PregameState], winner="", abort=False, additional_winners=None, log=True): global DAY_TIMEDELTA, NIGHT_TIMEDELTA, ENDGAME_COMMAND