Make potato an alias for villager (#290)
Also allow prefixing commands by their role name to remove ambiguity should a person be multiple roles. For example, "seer see foo" and "augur see foo" will now work if a person is both seer and augur (whereas normal see foo would be ambiguous). A player will be directed to use the unambiguous prefixed version if we detect that a role command will fire multiple times for them (note: coming soon). For sanity reasons, these role prefixes are implemented as exclusive commands, meaning no other commands or command aliases may use the same name. Clone needs to be special-cased in this regard, as clone is both a role name and a command name.
This commit is contained in:
parent
0df627fcca
commit
1cc38e54b2
@ -417,6 +417,7 @@
|
||||
"curse_success_wolfchat": "\u0002{0}\u0002 has cast a curse on \u0002{1}\u0002.",
|
||||
"already_cloned": "You have already chosen to clone someone.",
|
||||
"clone_target_success": "You have chosen to clone \u0002{0}\u0002.",
|
||||
"clone_clone_clone": "Ambiguous command; if you would like to clone someone whose name is or starts with \"clone\", please use \"clone clone clone\".",
|
||||
"must_charm_multiple": "You must choose two different people.",
|
||||
"no_charm_self": "You may not charm yourself.",
|
||||
"targets_already_charmed": "\u0002{0}\u0002 and \u0002{1}\u0002 are already charmed!",
|
||||
@ -867,6 +868,7 @@
|
||||
"error_pastebin": "(Unable to pastebin traceback; please check the console)",
|
||||
"channel_rules": "{0} channel rules: {1}",
|
||||
"no_channel_rules": "No rules are defined for {0}. Set RULES in botconfig.py to configure this.",
|
||||
"ambiguous_command": "Ambiguous command; more than one role you belong to has a \"{0}\" command. Please prefix this command with a role name, for example \"{1} {0} ...\" or \"{2} {0} ...\".",
|
||||
|
||||
"_": " vim: set sw=4 expandtab:"
|
||||
}
|
||||
|
@ -203,7 +203,8 @@ class handle_error:
|
||||
|
||||
class command:
|
||||
def __init__(self, *commands, flag=None, owner_only=False, chan=True, pm=False,
|
||||
playing=False, silenced=False, phases=(), roles=(), users=None):
|
||||
playing=False, silenced=False, phases=(), roles=(), users=None,
|
||||
exclusive=False):
|
||||
|
||||
self.commands = frozenset(commands)
|
||||
self.flag = flag
|
||||
@ -219,13 +220,19 @@ class command:
|
||||
self.aftergame = False
|
||||
self.name = commands[0]
|
||||
self.alt_allowed = bool(flag or owner_only)
|
||||
self.exclusive = exclusive
|
||||
|
||||
alias = False
|
||||
self.aliases = []
|
||||
for name in commands:
|
||||
if exclusive and name in COMMANDS:
|
||||
raise ValueError("exclusive command already exists for {0}".format(name))
|
||||
|
||||
for func in COMMANDS[name]:
|
||||
if func.owner_only != owner_only or func.flag != flag:
|
||||
raise ValueError("unmatching access levels for {0}".format(func.name))
|
||||
if func.exclusive:
|
||||
raise ValueError("exclusive command already exists for {0}".format(name))
|
||||
|
||||
COMMANDS[name].append(self)
|
||||
if name in botconfig.ALLOWED_ALT_CHANNELS_COMMANDS:
|
||||
@ -339,6 +346,7 @@ class cmd:
|
||||
self.func = None
|
||||
self.aftergame = False
|
||||
self.name = cmds[0]
|
||||
self.exclusive = False # for compatibility with new command API
|
||||
|
||||
alias = False
|
||||
self.aliases = []
|
||||
@ -347,6 +355,8 @@ class cmd:
|
||||
if (func.owner_only != owner_only or
|
||||
func.flag != flag):
|
||||
raise ValueError("unmatching protection levels for " + func.name)
|
||||
if func.exclusive:
|
||||
raise ValueError("exclusive command already exists for {0}".format(name))
|
||||
|
||||
COMMANDS[name].append(self)
|
||||
if alias:
|
||||
|
116
src/handler.py
116
src/handler.py
@ -12,39 +12,115 @@ import botconfig
|
||||
import src.settings as var
|
||||
from src import decorators, wolfgame, events, channels, hooks, users, errlog as log, stream_handler as alog
|
||||
from src.messages import messages
|
||||
from src.utilities import reply
|
||||
from src.utilities import reply, list_participants, get_role, get_templates
|
||||
from src.dispatcher import MessageDispatcher
|
||||
from src.decorators import handle_error
|
||||
|
||||
cmd = decorators.cmd
|
||||
hook = decorators.hook
|
||||
|
||||
def on_privmsg(cli, rawnick, chan, msg, *, notice=False):
|
||||
@handle_error
|
||||
def on_privmsg(cli, rawnick, chan, msg, *, notice=False, force_role=None):
|
||||
if notice and "!" not in rawnick or not rawnick: # server notice; we don't care about those
|
||||
return
|
||||
|
||||
if not users.equals(chan, users.Bot.nick) and botconfig.IGNORE_HIDDEN_COMMANDS and not chan.startswith(tuple(hooks.Features["CHANTYPES"])):
|
||||
user = users._get(rawnick, allow_none=True) # FIXME
|
||||
|
||||
if users.equals(chan, users.Bot.nick): # PM
|
||||
target = users.Bot
|
||||
else:
|
||||
target = channels.get(chan, allow_none=True)
|
||||
|
||||
if user is None or target is None:
|
||||
return
|
||||
|
||||
if (notice and ((not users.equals(chan, users.Bot.nick) and not botconfig.ALLOW_NOTICE_COMMANDS) or
|
||||
(users.equals(chan, users.Bot.nick) and not botconfig.ALLOW_PRIVATE_NOTICE_COMMANDS))):
|
||||
wrapper = MessageDispatcher(user, target)
|
||||
|
||||
if wrapper.public and botconfig.IGNORE_HIDDEN_COMMANDS and not chan.startswith(tuple(hooks.Features["CHANTYPES"])):
|
||||
return
|
||||
|
||||
if (notice and ((wrapper.public and not botconfig.ALLOW_NOTICE_COMMANDS) or
|
||||
(wrapper.private and not botconfig.ALLOW_PRIVATE_NOTICE_COMMANDS))):
|
||||
return # not allowed in settings
|
||||
|
||||
for fn in decorators.COMMANDS[""]:
|
||||
fn.caller(cli, rawnick, chan, msg)
|
||||
if force_role is None: # if force_role isn't None, that indicates recursion; don't fire these off twice
|
||||
for fn in decorators.COMMANDS[""]:
|
||||
fn.caller(cli, rawnick, chan, msg)
|
||||
|
||||
parts = msg.split(sep=" ", maxsplit=1)
|
||||
key = parts[0].lower()
|
||||
if len(parts) > 1:
|
||||
message = parts[1].lstrip()
|
||||
else:
|
||||
message = ""
|
||||
|
||||
if wrapper.public and not key.startswith(botconfig.CMD_CHAR):
|
||||
return # channel message but no prefix; ignore
|
||||
|
||||
if key.startswith(botconfig.CMD_CHAR):
|
||||
key = key[len(botconfig.CMD_CHAR):]
|
||||
|
||||
if not key: # empty key ("") already handled above
|
||||
return
|
||||
|
||||
# Don't change this into decorators.COMMANDS[key] even though it's a defaultdict,
|
||||
# as we don't want to insert bogus command keys into the dict.
|
||||
cmds = []
|
||||
phase = var.PHASE
|
||||
for x in list(decorators.COMMANDS.keys()):
|
||||
if not users.equals(chan, users.Bot.nick) and not msg.lower().startswith(botconfig.CMD_CHAR):
|
||||
break # channel message but no prefix; ignore
|
||||
if msg.lower().startswith(botconfig.CMD_CHAR+x):
|
||||
h = msg[len(x)+len(botconfig.CMD_CHAR):]
|
||||
elif not x or msg.lower().startswith(x):
|
||||
h = msg[len(x):]
|
||||
else:
|
||||
continue
|
||||
if not h or h[0] == " ":
|
||||
for fn in decorators.COMMANDS.get(x, []):
|
||||
if phase == var.PHASE:
|
||||
fn.caller(cli, rawnick, chan, h.lstrip())
|
||||
if user.nick in list_participants():
|
||||
roles = {get_role(user.nick)} | set(get_templates(user.nick))
|
||||
if force_role is not None:
|
||||
roles &= {force_role} # only fire off role commands for the forced role
|
||||
|
||||
common_roles = set(roles) # roles shared by every eligible role command
|
||||
have_role_cmd = False
|
||||
for fn in decorators.COMMANDS.get(key, []):
|
||||
if not fn.roles:
|
||||
cmds.append(fn)
|
||||
continue
|
||||
if roles.intersection(fn.roles):
|
||||
have_role_cmd = True
|
||||
cmds.append(fn)
|
||||
common_roles.intersection_update(fn.roles)
|
||||
|
||||
if force_role is not None and not have_role_cmd:
|
||||
# Trying to force a non-role command with a role.
|
||||
# We allow non-role commands to execute if a role is forced if a role
|
||||
# command is also executed, as this would allow (for example) a bot admin
|
||||
# to add extra effects to all "kill" commands without needing to continually
|
||||
# update the list of roles which can use "kill". However, we don't want to
|
||||
# allow things like "wolf pstats" because that just doesn't make sense.
|
||||
return
|
||||
|
||||
if not common_roles:
|
||||
# getting here means that at least one of the role_cmds is disjoint
|
||||
# from the others. For example, augur see vs seer see when a bare see
|
||||
# is executed. In this event, display a helpful error message instructing
|
||||
# the user to resolve the ambiguity.
|
||||
common_roles = set(roles)
|
||||
info = [0,0]
|
||||
for fn in cmds:
|
||||
fn_roles = roles.intersection(fn.roles)
|
||||
if not fn_roles:
|
||||
continue
|
||||
for role1 in common_roles:
|
||||
info[0] = role1
|
||||
break
|
||||
for role2 in fn_roles:
|
||||
info[1] = role2
|
||||
break
|
||||
common_roles &= fn_roles
|
||||
if not common_roles:
|
||||
break
|
||||
wrapper.pm(messages["ambiguous_command"].format(key, info[0], info[1]))
|
||||
return
|
||||
elif force_role is None:
|
||||
cmds = decorators.COMMANDS.get(key, [])
|
||||
|
||||
for fn in cmds:
|
||||
if phase == var.PHASE:
|
||||
# FIXME: pass in var, wrapper, message instead of cli, rawnick, chan, message
|
||||
fn.caller(cli, rawnick, chan, message)
|
||||
|
||||
def unhandled(cli, prefix, cmd, *args):
|
||||
for fn in decorators.HOOKS.get(cmd, []):
|
||||
|
@ -208,6 +208,15 @@ TRACEBACK_VERBOSITY = 2 # 0 = no locals at all, 1 = innermost frame's locals, 2
|
||||
# How often to ping the server (in seconds) to detect unclean disconnection
|
||||
SERVER_PING_INTERVAL = 120
|
||||
|
||||
# Shorthand for naming roles, used to set up command aliases as well as be valid targets when
|
||||
# specifying role names for things (such as !pstats or prophet's !pray)
|
||||
ROLE_ALIASES = {
|
||||
"ga": "guardian angel",
|
||||
"drunk": "village drunk",
|
||||
"cs": "crazed shaman",
|
||||
"potato": "villager",
|
||||
}
|
||||
|
||||
# TODO: move this to a game mode called "fixed" once we implement a way to randomize roles (and have that game mode be called "random")
|
||||
DEFAULT_ROLE = "villager"
|
||||
ROLE_INDEX = ( 4 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , 13 , 15 , 16 , 18 , 20 , 21 , 23 , 24 )
|
||||
@ -322,6 +331,12 @@ DISABLED_ROLES = frozenset()
|
||||
# Game modes that cannot be randomly picked or voted for
|
||||
DISABLED_GAMEMODES = frozenset()
|
||||
|
||||
# Roles which have a command equivalent to the role name need to implement special handling for being
|
||||
# passed their command again as a prefix and strip it out. For example, both !clone foo and !clone clone foo
|
||||
# should be valid. Failure to add such a command to this set will result in the bot not starting
|
||||
# with the error "ValueError: exclusive command already exists for ..."
|
||||
ROLE_COMMAND_EXCEPTIONS = set()
|
||||
|
||||
GIF_CHANCE = 1/50
|
||||
FORTUNE_CHANCE = 1/25
|
||||
|
||||
|
@ -22,6 +22,7 @@
|
||||
import copy
|
||||
import fnmatch
|
||||
import itertools
|
||||
import functools
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
@ -2875,6 +2876,32 @@ def update_last_said(cli, nick, chan, rest):
|
||||
|
||||
fullstring = "".join(rest)
|
||||
|
||||
def dispatch_role_prefix(var, wrapper, message, *, role):
|
||||
from src import handler
|
||||
_ignore_locals_ = True
|
||||
handler.on_privmsg(wrapper.client, wrapper.source.rawnick, wrapper.target.name, message, force_role=role)
|
||||
|
||||
def setup_role_commands(evt):
|
||||
aliases = defaultdict(set)
|
||||
for alias, role in var.ROLE_ALIASES.items():
|
||||
aliases[role].add(alias)
|
||||
for role in var.ROLE_GUIDE.keys() - var.ROLE_COMMAND_EXCEPTIONS:
|
||||
keys = ["".join(c for c in role if c.isalpha())]
|
||||
keys.extend(aliases[role])
|
||||
fn = functools.partial(dispatch_role_prefix, role=role)
|
||||
fn.__doc__ = "Execute {0} command".format(role)
|
||||
# don't allow these in-channel, as it could be used to prove that someone is a particular role
|
||||
# (there are no examples of this right now, but it could be possible in the future). For example,
|
||||
# if !shoot was rewritten so that there was a "gunner" and "sharpshooter" template, one could
|
||||
# prove they are sharpshooter -- and therefore prove should they miss that the target is werekitten,
|
||||
# as opposed to the possiblity of them being a wolf with 1 bullet who stole the gun from a dead gunner --
|
||||
# by using !sharpshooter shoot target.
|
||||
command(*keys, exclusive=True, pm=True, chan=False, playing=True)(fn)
|
||||
|
||||
# event_listener decorator wraps callback in handle_error, which we don't want for the init event
|
||||
# (as no IRC connection exists at this point)
|
||||
events.add_listener("init", setup_role_commands, priority=10000)
|
||||
|
||||
@hook("join")
|
||||
def on_join(cli, raw_nick, chan, acc="*", rname=""):
|
||||
nick, _, ident, host = parse_nick(raw_nick)
|
||||
@ -4524,9 +4551,12 @@ def pray(cli, nick, chan, rest):
|
||||
# complete this as a match with other roles (so "cursed" can match "cursed villager" for instance)
|
||||
role = complete_one_match(what.lower(), var.ROLE_GUIDE.keys())
|
||||
if role is None:
|
||||
# typo, let them fix it
|
||||
pm(cli, nick, messages["specific_invalid_role"].format(what))
|
||||
return
|
||||
if what.lower() in var.ROLE_ALIASES:
|
||||
role = var.ROLE_ALIASES[what.lower()]
|
||||
else:
|
||||
# typo, let them fix it
|
||||
pm(cli, nick, messages["specific_invalid_role"].format(what))
|
||||
return
|
||||
|
||||
# get a list of all roles actually in the game, including roles that amnesiacs will be turning into
|
||||
# (amnesiacs are special since they're also listed as amnesiac; that way a prophet can see both who the
|
||||
@ -4920,11 +4950,24 @@ def clone(cli, nick, chan, rest):
|
||||
if nick in var.CLONED.keys():
|
||||
pm(cli, nick, messages["already_cloned"])
|
||||
return
|
||||
|
||||
params = re.split(" +", rest)
|
||||
# allow for role-prefixed command such as !clone clone target
|
||||
# if we get !clone clone (with no 3rd arg), we give preference to prefixed version;
|
||||
# meaning if the person wants to clone someone named clone, they must type !clone clone clone
|
||||
# (or just !clone clon, !clone clo, etc. assuming thos would be unambiguous matches)
|
||||
if params[0] == "clone":
|
||||
if len(params) > 1:
|
||||
del params[0]
|
||||
else:
|
||||
pm(cli, nick, messages["clone_clone_clone"])
|
||||
return
|
||||
|
||||
# no var.SILENCED check for night 1 only roles; silence should only apply for the night after
|
||||
# but just in case, it also sucks if the one night you're allowed to act is when you are
|
||||
# silenced, so we ignore it here anyway.
|
||||
|
||||
victim = get_victim(cli, nick, re.split(" +",rest)[0], False)
|
||||
victim = get_victim(cli, nick, params[0], False)
|
||||
if not victim:
|
||||
return
|
||||
|
||||
@ -4938,6 +4981,8 @@ def clone(cli, nick, chan, rest):
|
||||
debuglog("{0} ({1}) CLONE: {2} ({3})".format(nick, get_role(nick), victim, get_role(victim)))
|
||||
chk_nightdone(cli)
|
||||
|
||||
var.ROLE_COMMAND_EXCEPTIONS.add("clone")
|
||||
|
||||
@cmd("charm", chan=False, pm=True, playing=True, silenced=True, phases=("night",), roles=("piper",))
|
||||
def charm(cli, nick, chan, rest):
|
||||
"""Charm a player, slowly leading to your win!"""
|
||||
@ -6738,6 +6783,8 @@ def player_stats(cli, nick, chan, rest):
|
||||
role = " ".join(params[1:])
|
||||
if role not in var.ROLE_GUIDE.keys():
|
||||
matches = complete_match(role, var.ROLE_GUIDE.keys() | {"lover"})
|
||||
if not matches and role.lower() in var.ROLE_ALIASES:
|
||||
matches = (var.ROLE_ALIASES[role.lower()],)
|
||||
if not matches:
|
||||
reply(cli, nick, chan, messages["no_such_role"].format(role))
|
||||
return
|
||||
|
@ -48,8 +48,11 @@ from oyoyo.client import IRCClient
|
||||
|
||||
import src
|
||||
from src import handler
|
||||
from src.events import Event
|
||||
|
||||
def main():
|
||||
evt = Event("init", {})
|
||||
evt.dispatch()
|
||||
src.plog("Connecting to {0}:{1}{2}".format(botconfig.HOST, "+" if botconfig.USE_SSL else "", botconfig.PORT))
|
||||
cli = IRCClient(
|
||||
{"privmsg": lambda *s: None,
|
||||
|
Loading…
Reference in New Issue
Block a user