Skip to content

Commit

Permalink
Various tweaks
Browse files Browse the repository at this point in the history
- In pactbreaker, don't tell villagers visiting streets that a villager
  is actually a villager (when they're vigilante instead). Give them the
  correct role.
- Add vampire support to vengeful ghost. VGs now attain team wins as
  long as the team they are against lost. For example, if a vampire
  kills a VG, they win with either villagers or wolves.
- Lock in vengeful ghost target list at the beginning of night, so that
  role swaps during night (e.g. exchange totem) do not cause unnecessary
  confusion with VG being unable to target someone they are told they
  could target. Fixes #507.
- Defer master of teleportation's action to the beginning of day,
  letting them change their mind to their heart's content during night.
  Fixes #508.
- Triggering an exchange totem now extends the current phase timer to be
  at least 30 seconds remaining at the time of the swap. If the timer
  was already longer than this, it remains unchanged. The exact duration
  is configurable in botconfig.yml, and can be disabled by setting it to
  0. No warning points are given to role-swapped users regardless of the
  timer duration configuration. Fixes #475.
- Fix !myrole display of cursed to account for the various wolfchat
  configuration settings in botconfig.yml. Cursed status is only
  revealed if the user is able to view wolfchat.
  • Loading branch information
skizzerz committed Feb 8, 2025
1 parent 3dfc82e commit 18eb7d4
Show file tree
Hide file tree
Showing 7 changed files with 107 additions and 59 deletions.
14 changes: 14 additions & 0 deletions src/defaultsettings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,20 @@ gameplay: &gameplay
_desc: The amnesiac will get reminded of their true identity on this night.
_type: int
_default: 3
totems:
_desc: Determines how certain shaman totems interact with the game.
_type: dict
_default:
exchange:
_desc: Configuration related to the exchange totem, which can cause players to swap roles at any time
_type: dict
_default:
minimum_time:
_desc: >
Minimum amount of time (in seconds) to allow the exchanged user to act after the role swap.
If the current phase would end before this, the timer is extended to the minimum.
_type: int
_default: 30
nightchat:
_desc: >
Whether or not nightchat is enabled in channel. If disabled, speaking privileges in the channel
Expand Down
2 changes: 1 addition & 1 deletion src/gamemodes/pactbreaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ def on_night_kills(self, evt: Event, var: GameState):
else:
target_role = get_main_role(var, evidence_target)
# also hide vigi evidence (or vigi fake evidence) from vills
if target_role == "vigilante" and visitor_role == "villager":
if num_evidence == 2 and target_role == "vigilante" and visitor_role == "villager":
target_role = "villager"
self.collected_evidence[visitor][target_role].add(evidence_target)
visitor.send(messages[f"pactbreaker_{loc}_evidence"].format(evidence_target, target_role))
Expand Down
17 changes: 17 additions & 0 deletions src/gamestate.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import copy
import math
import threading
from typing import Any, Optional, Callable, ClassVar, TYPE_CHECKING
import time
Expand Down Expand Up @@ -166,6 +167,7 @@ def end_phase_transition(self, time_limit: int = 0, time_warn: int = 0, timer_cb
if self.next_phase is None:
raise RuntimeError("not in phase transition")

assert timer_cb is not None
self.current_phase = self.next_phase
self.next_phase = None
if config.Main.get("timers.enabled"):
Expand All @@ -181,6 +183,21 @@ def end_phase_transition(self, time_limit: int = 0, time_warn: int = 0, timer_cb
timer.start()
TIMERS[f"{self.current_phase}_warn"] = (timer, time.time(), time_warn)

def extend_phase_limit(self, minimum: int = 0):
"""Ensure that the phase limit timer has a minimum amount of seconds remaining."""
from src.trans import TIMERS
if minimum <= 0:
return
if config.Main.get("timers.enabled"):
(timer, started, limit) = TIMERS[f"{self.current_phase}_limit"]
elapsed = math.ceil(time.time() - started)
if elapsed + minimum > limit:
timer.cancel()
extended = threading.Timer(minimum, timer.function, timer.args, timer.kwargs)
extended.daemon = True
extended.start()
TIMERS[f"{self.current_phase}_limit"] = (extended, started, elapsed + minimum)

@property
def in_phase_transition(self):
return self.next_phase is not None
Expand Down
8 changes: 4 additions & 4 deletions src/roles/cursed.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ def on_get_role_metadata(evt: Event, var: Optional[GameState], kind: str):
@event_listener("send_role")
def on_send_role(evt: Event, var: GameState):
cursed = get_all_players(var, ("cursed villager",))
wolves = get_all_players(var, Wolfchat)
from src.roles.helper.wolves import is_known_wolf_ally
for player in cursed:
if get_main_role(var, player) == "cursed villager" or player in wolves:
if get_main_role(var, player) == "cursed villager" or is_known_wolf_ally(var, player, player):
player.send(messages["cursed_notify"])

@event_listener("myrole")
def on_myrole(evt: Event, var: GameState, player: User):
wolves = get_all_players(var, Wolfchat)
if player not in wolves:
from src.roles.helper.wolves import is_known_wolf_ally
if not is_known_wolf_ally(var, player, player):
evt.data["secondary"].discard("cursed villager")
18 changes: 14 additions & 4 deletions src/roles/masterofteleportation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
import re
from typing import Optional

from src.containers import UserSet
from src.containers import UserSet, UserDict
from src.decorators import command
from src.dispatcher import MessageDispatcher
from src.events import Event, event_listener
from src.functions import get_target, get_players, get_all_players
from src.gamestate import GameState
from src.messages import messages
from src.random import random
from src.users import User

ACTED = UserSet()
SWAPS: UserDict[User, tuple[int, int]] = UserDict()

@command("choose", chan=False, pm=True, playing=True, silenced=True, phases=("night",), roles=("master of teleportation",))
def choose(wrapper: MessageDispatcher, message: str):
Expand All @@ -29,11 +31,10 @@ def choose(wrapper: MessageDispatcher, message: str):
wrapper.send(messages["choose_different_people"])
return

ACTED.add(wrapper.source)
index1 = var.players.index(target1)
index2 = var.players.index(target2)
var.players[index2] = target1
var.players[index1] = target2
SWAPS[wrapper.source] = (index1, index2)
ACTED.add(wrapper.source)
wrapper.send(messages["master_of_teleportation_success"].format(target1, target2))

@event_listener("send_role")
Expand All @@ -55,11 +56,20 @@ def on_player_win(evt: Event, var: GameState, player: User, main_role: str, all_

@event_listener("transition_day_begin")
def on_transition_day_begin(evt: Event, var: GameState):
swaps = list(SWAPS.values())
random.shuffle(swaps)
for (index1, index2) in swaps:
target1 = var.players[index1]
target2 = var.players[index2]
var.players[index2] = target1
var.players[index1] = target2
ACTED.clear()
SWAPS.clear()

@event_listener("reset")
def on_reset(evt: Event, var: GameState):
ACTED.clear()
SWAPS.clear()

@event_listener("get_role_metadata")
def on_get_role_metadata(evt: Event, var: Optional[GameState], kind: str):
Expand Down
105 changes: 55 additions & 50 deletions src/roles/vengefulghost.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from typing import Optional

from src import users
from src.cats import All, Wolfteam
from src.containers import UserDict
from src.cats import All, Wolfteam, Vampire_Team, Win_Stealer
from src.containers import UserDict, UserSet
from src.decorators import command
from src.events import Event, event_listener
from src.functions import get_players, get_target, get_all_roles
Expand All @@ -18,6 +18,7 @@

KILLS: UserDict[users.User, users.User] = UserDict()
GHOSTS: UserDict[users.User, str] = UserDict()
TARGETS: UserDict[users.User, UserSet] = UserDict()

# temporary holding variable, only non-empty during transition_day
drivenoff: UserDict[users.User, str] = UserDict()
Expand All @@ -38,12 +39,9 @@ def vg_kill(wrapper: MessageDispatcher, message: str):
wrapper.pm(messages["player_dead"])
return

wolves = get_players(var, Wolfteam)
if GHOSTS[wrapper.source] == "wolf" and target not in wolves:
wrapper.pm(messages["vengeful_ghost_wolf"])
return
elif GHOSTS[wrapper.source] == "villager" and target in wolves:
wrapper.pm(messages["vengeful_ghost_villager"])
if target not in TARGETS[wrapper.source]:
# keys: vengeful_ghost_wolf vengeful_ghost_villager
wrapper.pm(messages["vengeful_ghost_{0}".format(GHOSTS[wrapper.source])])
return

orig = target
Expand Down Expand Up @@ -79,18 +77,14 @@ def on_gun_shoot(evt: Event, var: GameState, user: User, target: User, role: str
# VGs automatically die if hit by a gun to make gunner a bit more dangerous in some modes
evt.data["kill"] = True

# needs to happen after regular team win is determined, but before succubus
# FIXME: I hate priorities, did I mention that?
@event_listener("team_win", priority=6)
@event_listener("team_win")
def on_team_win(evt: Event, var: GameState, player: User, main_role: str, all_roles: set[str], winner: str):
team_map = {"wolf": "wolves", "village": "villagers", "vampire": "vampires"}
if player in GHOSTS:
against = GHOSTS[player].lstrip("!")
if against == "villager" and winner == "wolves":
evt.data["team_win"] = True
elif against == "wolf" and winner == "villagers":
evt.data["team_win"] = True
else:
evt.data["team_win"] = False
against_team = team_map[against]
# VG wins as long as an actual team (not a win stealer) won and the team they are against lost
evt.data["team_win"] = winner in team_map.values() and winner != against_team

@event_listener("player_win")
def on_player_win(evt: Event, var: GameState, player: User, main_role: str, all_roles: set[str], winner: str, team_win: bool, survived: bool):
Expand All @@ -102,38 +96,36 @@ def on_player_win(evt: Event, var: GameState, player: User, main_role: str, all_
# VG gets an individual win while dead if they haven't been driven off and their team wins
evt.data["individual_win"] = True

@event_listener("del_player", priority=6)
@event_listener("del_player")
def on_del_player(evt: Event, var: GameState, player: User, all_roles: set[str], death_triggers: bool):
for h, v in list(KILLS.items()):
if player is v:
h.send(messages["hunter_discard"])
del KILLS[h]
# extending VG to work with new teams can be done by registering a listener
# at priority < 6, importing src.roles.vengefulghost, and setting
# GHOSTS[user] to something; if that is done then this logic is not run.
for ghost, victim in list(KILLS.items()):
if player is victim:
ghost.send(messages["hunter_discard"])
del KILLS[ghost]
del KILLS[:player:]

for targets in TARGETS.values():
targets.discard(player)
del TARGETS[:player:]

if death_triggers and "vengeful ghost" in all_roles and player not in GHOSTS:
if evt.params.killer_role in Wolfteam:
GHOSTS[player] = "wolf"
elif evt.params.killer_role in Vampire_Team:
GHOSTS[player] = "vampire"
else:
GHOSTS[player] = "villager"
player.send(messages["vengeful_turn"].format(GHOSTS[player]))

@event_listener("transition_day_begin", priority=6)
@event_listener("transition_day_begin")
def on_transition_day_begin(evt: Event, var: GameState):
# select a random target for VG if they didn't kill
wolves = get_players(var, Wolfteam)
villagers = get_players(var, All - Wolfteam)
for ghost, target in GHOSTS.items():
if target[0] == "!" or is_silent(var, ghost):
continue
if ghost not in KILLS:
choice = None
if target == "wolf":
choice = wolves.copy()
elif target == "villager":
choice = villagers.copy()
if choice:
KILLS[ghost] = random.choice(choice)
if ghost not in KILLS and TARGETS.get(ghost, None):
KILLS[ghost] = random.choice(list(TARGETS[ghost]))
TARGETS.clear()

@event_listener("night_kills")
def on_night_kills(evt: Event, var: GameState):
Expand All @@ -146,7 +138,7 @@ def on_night_kills(evt: Event, var: GameState):
# prevent VGs from being messaged in del_player that they can choose someone else
KILLS.clear()

@event_listener("retribution_kill", priority=6)
@event_listener("retribution_kill")
def on_retribution_kill(evt: Event, var: GameState, victim: User, orig_target: User):
target = evt.data["target"]
if target in GHOSTS:
Expand All @@ -164,38 +156,50 @@ def on_get_participant_role(evt: Event, var: GameState, user: User):
else:
against = GHOSTS[user]
if against == "villager":
evt.data["role"] = "wolf"
elif against == "wolf":
orig_wolves = len(get_players(var, Wolfteam, mainroles=var.original_main_roles))
orig_vamps = len(get_players(var, Vampire_Team, mainroles=var.original_main_roles))
# if vampires are the ONLY evil team, make against-village VG aligned with them instead of wolves
# and if we somehow lack both wolves and vampires, default to wolf
evt.data["role"] = "wolf" if orig_wolves or not orig_vamps else "vampire"
else:
evt.data["role"] = "villager"

@event_listener("chk_nightdone")
def on_chk_nightdone(evt: Event, var: GameState):
evt.data["acted"].extend(KILLS)
evt.data["nightroles"].extend([p for p in GHOSTS if GHOSTS[p][0] != "!"])
evt.data["nightroles"].extend([p for p in GHOSTS if GHOSTS[p][0] != "!" and TARGETS.get(p, None)])

@event_listener("send_role")
def on_transition_night_end(evt: Event, var: GameState):
# alive VGs are messaged as part of villager.py, this handles dead ones
villagers = get_players(var, All - Wolfteam)
wolves = get_players(var, Wolfteam)
targets = {
"villager": get_players(var, All - Wolfteam - Vampire_Team),
"wolf": get_players(var, Wolfteam),
"vampire": get_players(var, Vampire_Team)
}

for v_ghost, who in GHOSTS.items():
if who[0] == "!":
continue
if who == "wolf":
pl = wolves[:]
else:
pl = villagers[:]

pl = targets[who][:]
random.shuffle(pl)

v_ghost.send(messages["vengeful_ghost_notify"].format(who), messages["vengeful_ghost_team"].format(who, pl), sep="\n")
TARGETS[v_ghost] = UserSet(pl)
v_ghost.send(messages["vengeful_ghost_notify"].format(who),
messages["vengeful_ghost_team"].format(who, pl),
sep="\n")

@event_listener("myrole")
def on_myrole(evt: Event, var: GameState, user: User):
if user in GHOSTS:
evt.prevent_default = True
m = []
if GHOSTS[user][0] != "!":
user.send(messages["vengeful_role"].format(GHOSTS[user]))
m.append(messages["vengeful_role"].format(GHOSTS[user]))
if TARGETS.get(user, None):
pl = list(TARGETS[user])
random.shuffle(pl)
m.append(messages["vengeful_ghost_team"].format(GHOSTS[user], pl))
user.send(m, sep="\n")

@event_listener("revealroles")
def on_revealroles(evt: Event, var: GameState):
Expand All @@ -218,6 +222,7 @@ def on_begin_day(evt: Event, var: GameState):
def on_reset(evt: Event, var: GameState):
KILLS.clear()
GHOSTS.clear()
TARGETS.clear()

@event_listener("get_role_metadata")
def on_get_role_metadata(evt: Event, var: Optional[GameState], kind: str):
Expand Down
2 changes: 2 additions & 0 deletions src/status/exchange.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from src import config
from src.containers import UserSet
from src.functions import get_main_role, change_role, get_players
from src.messages import messages
Expand Down Expand Up @@ -38,6 +39,7 @@ def try_exchange(var: GameState, actor: User, target: User):

actor.send(*evt.data["actor_messages"])
target.send(*evt.data["target_messages"])
var.extend_phase_limit(config.Main.get("gameplay.totems.exchange.minimum_time"))

return True

Expand Down

0 comments on commit 18eb7d4

Please sign in to comment.