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:
Ryan Schmidt 2017-03-24 13:31:08 -07:00 committed by Emanuel Barry
parent 0df627fcca
commit 1cc38e54b2
6 changed files with 178 additions and 25 deletions

View File

@ -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:"
}

View File

@ -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:

View File

@ -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
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)
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):]
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
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 not h or h[0] == " ":
for fn in decorators.COMMANDS.get(x, []):
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:
fn.caller(cli, rawnick, chan, h.lstrip())
# 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, []):

View File

@ -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

View File

@ -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,6 +4551,9 @@ 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:
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
@ -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

View File

@ -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,