From 44a90f07f8e0f84354daa1c20180b3fb6d34378a Mon Sep 17 00:00:00 2001 From: Little Cat Date: Wed, 7 Jul 2021 03:51:22 -0300 Subject: [PATCH] spellbook: Implement magic word system --- etc/toon.dc | 9 +- otp/avatar/DistributedPlayer.py | 3 + otp/avatar/DistributedPlayerAI.py | 3 + otp/chat/TalkAssistant.py | 2 +- toontown/ai/ToontownAIRepository.py | 6 + .../distributed/ToontownClientRepository.py | 1 + toontown/spellbook/MagicWordConfig.py | 86 +++++ toontown/spellbook/MagicWordIndex.py | 253 +++++++++++++++ .../spellbook/ToontownMagicWordManager.py | 262 +++++++++++++++ .../spellbook/ToontownMagicWordManagerAI.py | 298 ++++++++++++++++++ toontown/spellbook/__init__.py | 0 toontown/toon/DistributedToon.py | 4 + 12 files changed, 922 insertions(+), 5 deletions(-) create mode 100644 toontown/spellbook/MagicWordConfig.py create mode 100644 toontown/spellbook/MagicWordIndex.py create mode 100644 toontown/spellbook/ToontownMagicWordManager.py create mode 100644 toontown/spellbook/ToontownMagicWordManagerAI.py create mode 100644 toontown/spellbook/__init__.py diff --git a/etc/toon.dc b/etc/toon.dc index 970286e..cd12430 100755 --- a/etc/toon.dc +++ b/etc/toon.dc @@ -70,7 +70,7 @@ from toontown.estate import DistributedHouse/AI from toontown.estate import DistributedHouseInterior/AI from toontown.estate import DistributedGarden/AI from toontown.shtiker import DeleteManager/AI -from toontown.ai import ToontownMagicWordManager/AI +from toontown.spellbook import ToontownMagicWordManager/AI from toontown.ai import NewsManager/AI from toontown.shtiker import PurchaseManager/AI from toontown.shtiker import NewbiePurchaseManager/AI @@ -1406,8 +1406,10 @@ dclass DeleteManager : DistributedObject { setInventory(blob) airecv clsend; }; -dclass ToontownMagicWordManager : MagicWordManager { - requestTeleport(string, string, uint32, uint32, uint32); +dclass ToontownMagicWordManager : DistributedObject { + requestExecuteMagicWord(int8, int8, int16, uint32, string) airecv clsend; + executeMagicWord(string, string, uint32[], blob, int8, int8, int16, uint32); + generateResponse(string, string, blob, string, int8, int8, int16, uint32, string); }; struct weeklyCalendarHoliday { @@ -3265,4 +3267,3 @@ dclass DistributedTrashcanZeroMgr : DistributedPhaseEventMgr { dclass DistributedSillyMeterMgr : DistributedPhaseEventMgr { }; - diff --git a/otp/avatar/DistributedPlayer.py b/otp/avatar/DistributedPlayer.py index 21e4d63..af0f226 100644 --- a/otp/avatar/DistributedPlayer.py +++ b/otp/avatar/DistributedPlayer.py @@ -457,3 +457,6 @@ class DistributedPlayer(DistributedAvatar.DistributedAvatar, PlayerBase.PlayerBa def setAccessLevel(self, accessLevel): self.accessLevel = accessLevel + + def getAccessLevel(self): + return self.accessLevel diff --git a/otp/avatar/DistributedPlayerAI.py b/otp/avatar/DistributedPlayerAI.py index de694b0..0bde086 100644 --- a/otp/avatar/DistributedPlayerAI.py +++ b/otp/avatar/DistributedPlayerAI.py @@ -113,6 +113,9 @@ class DistributedPlayerAI(DistributedAvatarAI.DistributedAvatarAI, PlayerBase.Pl def setAccessLevel(self, accessLevel): self.accessLevel = accessLevel + def getAccessLevel(self): + return self.accessLevel + def d_setFriendsList(self, friendsList): self.sendUpdate('setFriendsList', [friendsList]) diff --git a/otp/chat/TalkAssistant.py b/otp/chat/TalkAssistant.py index 0506da2..afe4dfd 100644 --- a/otp/chat/TalkAssistant.py +++ b/otp/chat/TalkAssistant.py @@ -617,7 +617,7 @@ class TalkAssistant(DirectObject.DirectObject): def sendOpenTalk(self, message): error = None - if base.cr.wantMagicWords and len(message) > 0 and message[0] == '~': + if base.cr.magicWordManager and base.localAvatar.getAccessLevel() > OTPGlobals.AccessLevelName2Int.get('NO_ACCESS') and len(message) > 0 and message[0] == base.cr.magicWordManager.chatPrefix: messenger.send('magicWord', [message]) self.receiveDeveloperMessage(message) else: diff --git a/toontown/ai/ToontownAIRepository.py b/toontown/ai/ToontownAIRepository.py index 2b306f5..fe70c56 100644 --- a/toontown/ai/ToontownAIRepository.py +++ b/toontown/ai/ToontownAIRepository.py @@ -44,6 +44,7 @@ from toontown.racing.DistributedViewPadAI import DistributedViewPadAI from toontown.racing.RaceManagerAI import RaceManagerAI from toontown.safezone.SafeZoneManagerAI import SafeZoneManagerAI from toontown.shtiker.CogPageManagerAI import CogPageManagerAI +from toontown.spellbook.ToontownMagicWordManagerAI import ToontownMagicWordManagerAI from toontown.suit.SuitInvasionManagerAI import SuitInvasionManagerAI from toontown.toon import NPCToons from toontown.toonbase import ToontownGlobals @@ -84,6 +85,7 @@ class ToontownAIRepository(ToontownInternalRepository): self.catalogManager = None self.trophyMgr = None self.safeZoneManager = None + self.magicWordManager = None self.zoneTable = {} self.dnaStoreMap = {} self.dnaDataMap = {} @@ -202,6 +204,10 @@ class ToontownAIRepository(ToontownInternalRepository): self.safeZoneManager = SafeZoneManagerAI(self) self.safeZoneManager.generateWithRequired(OTP_ZONE_ID_MANAGEMENT) + # Generate our magic word manager... + self.magicWordManager = ToontownMagicWordManagerAI(self) + self.magicWordManager.generateWithRequired(OTP_ZONE_ID_MANAGEMENT) + def generateHood(self, hoodConstructor, zoneId): # Bossbot HQ doesn't use DNA, so we skip over that. if zoneId != ToontownGlobals.BossbotHQ: diff --git a/toontown/distributed/ToontownClientRepository.py b/toontown/distributed/ToontownClientRepository.py index b60102c..813029d 100644 --- a/toontown/distributed/ToontownClientRepository.py +++ b/toontown/distributed/ToontownClientRepository.py @@ -106,6 +106,7 @@ class ToontownClientRepository(OTPClientRepository.OTPClientRepository): self.streetSign = None self.furnitureManager = None self.objectManager = None + self.magicWordManager = None self.friendsMap = {} self.friendsOnline = {} self.friendsMapPending = 0 diff --git a/toontown/spellbook/MagicWordConfig.py b/toontown/spellbook/MagicWordConfig.py new file mode 100644 index 0000000..99796ce --- /dev/null +++ b/toontown/spellbook/MagicWordConfig.py @@ -0,0 +1,86 @@ +################################################## +# The Toontown Offline Magic Word Manager +################################################## +# Author: Benjamin Frisby +# Copyright: Copyright 2020, Toontown Offline +# Credits: Benjamin Frisby, John Cote, Ruby Lord, Frank, Nick, Little Cat, Ooowoo +# License: MIT +# Version: 1.0.0 +# Email: belloqzafarian@gmail.com +################################################## + +OUTGOING_CHAT_MESSAGE_NAME = 'magicWord' +CLICKED_NAMETAG_MESSAGE_NAME = 'clickedNametag' +FOCUS_OUT_MESSAGE_NAME = 'focusOut' + +PREFIX_DEFAULT = '~' +PREFIX_ALLOWED = ['~', '?', '/', '<', ':', ';'] +if config.GetBool('exec-chat', False): + PREFIX_ALLOWED.append('>') + +WIZARD_DEFAULT = 'Spellbook' + +MAGIC_WORD_SUCCESS_PHRASES = ['Alakazam!', 'Voila!', 'Ta-da!', 'Presto!', 'Abracadabra!'] +MAGIC_WORD_RESPONSES = { + "SuccessNoResp": 'response will be randomly selected from MAGIC_WORD_SUCCESS_PHRASES', + "Success": 'response will be provided by magic word', + "Teleporting": 'Yikes! Don\'t use Magic words while switching zones!', + "OtherTeleporting": 'Your target is currently switching zones!', + "BadWord": 'Uh-oh! This Magic Word doesn\'t exist!', + "CloseWord": 'This Magic Word doesn\'t exist! Did you mean {}?', + "NoEffect": "This word doesn't affect anybody!", + "BadTarget": 'Invalid target specified!', + "NoAccessToTarget": "You don't have a high enough Access Level to target them!", + "NoAccessSingleTarget": "You don't have a high enough Access Level to target {}! Their Access Level: {}. Your Access Level: {}.", + "NoTarget": 'Unable to find a target!', + "NoAccess": 'Your Toon does not have enough power to use this Magic Word!', + "NotEnoughArgs": 'This command takes at least {}!', + "TooManyArgs": 'This command takes at most {}!', + "BadArgs": 'These arguments are of the wrong type!', + "CannotTarget": 'You cannot target other players with this Magic Word!', + "Locked": 'You are temporarily locked down and cannot use Magic Words.', + "RestrictionOther": 'You may only target one other player with this Magic Word!', + 'NonCheaty': 'You cannot use cheaty Magic Words at this time!', + 'Tutorial': 'You cannot use Magic Words in the Toontorial!' +} +MAGIC_WORD_NO_RESPONSE = "...I don't know how to respond!" +HAS_EXTRA_MESSAGE_DATA = ["NotEnoughArgs", "TooManyArgs", "CloseWord"] + +MAGIC_WORD_DEFAULT_DESC = 'A simple Magic Word.' +MAGIC_WORD_DEFAULT_ADV_DESC = 'This Magic Word does a lot of things, because reasons.' + +AFFECT_TYPES = ['singular', 'zone', 'server', 'rank'] +AFFECT_TYPES_NAMES = ['Everyone in this zone', 'The entire server', 'Everyone with an Access Level of'] +AFFECT_NONE = -1 +AFFECT_SELF = 0 +AFFECT_OTHER = 1 +AFFECT_BOTH = 2 +AFFECT_NORMAL = 0 +AFFECT_ZONE = 1 +AFFECT_SERVER = 2 +AFFECT_RANK = 3 +GROUP_AFFECTS = [AFFECT_ZONE, AFFECT_SERVER, AFFECT_RANK] + +EXEC_LOC_INVALID = -1 +EXEC_LOC_CLIENT = 0 +EXEC_LOC_SERVER = 1 + +ARGUMENT_NAME = 0 +ARGUMENT_TYPE = 1 +ARGUMENT_REQUIRED = 2 +ARGUMENT_DEFAULT = 3 + +CUSTOM_SPELLBOOK_DEFAULT = '''{ + "words": + [ + { + "name": "SetPos", + "access": "MODERATOR" + }, + { + "name": "GetPos", + "access": "MODERATOR" + } + ] +} +''' diff --git a/toontown/spellbook/MagicWordIndex.py b/toontown/spellbook/MagicWordIndex.py new file mode 100644 index 0000000..3d60cf8 --- /dev/null +++ b/toontown/spellbook/MagicWordIndex.py @@ -0,0 +1,253 @@ +################################################## +# The Toontown Offline Magic Word Manager +################################################## +# Author: Benjamin Frisby +# Copyright: Copyright 2020, Toontown Offline +# Credits: Benjamin Frisby, John Cote, Ruby Lord, Frank, Nick, Little Cat, Ooowoo +# License: MIT +# Version: 1.0.0 +# Email: belloqzafarian@gmail.com +################################################## + +import collections, types + +from direct.distributed.ClockDelta import * +from direct.interval.IntervalGlobal import * + +from libotp import NametagGroup, WhisperPopup + +from otp.otpbase import OTPLocalizer +from otp.otpbase import OTPGlobals + +from . import MagicWordConfig +import time, random, re, json + +magicWordIndex = collections.OrderedDict() + + +class MagicWord: + notify = DirectNotifyGlobal.directNotify.newCategory('MagicWord') + + # Whether this Magic word should be considered "hidden" + # If your Toontown source has a page for Magic Words in the Sthickerbook, this will be useful for that + hidden = False + + # Whether this Magic Word is an administrative command or not + # Good for config settings where you want to disable cheaty Magic Words, but still want moderation ones + administrative = False + + # List of names that will also invoke this word - a setHP magic word might have "hp", for example + # A Magic Word will always be callable with its class name, so you don't have to put that in the aliases + aliases = None + + # Description of the Magic Word + # If your Toontown source has a page for Magic Words in the Sthickerbook, this will be useful for that + desc = MagicWordConfig.MAGIC_WORD_DEFAULT_DESC + + # Advanced description that gives the user a lot more information than normal + # If your Toontown source has a page for Magic Words in the Sthickerbook, this will be useful for that + advancedDesc = MagicWordConfig.MAGIC_WORD_DEFAULT_ADV_DESC + + # Default example with for commands with no arguments set + # If your Toontown source has a page for Magic Words in the Sthickerbook, this will be useful for that + example = "" + + # The minimum access level required to use this Magic Word + accessLevel = 'MODERATOR' + + # A restriction on the Magic Word which sets what kind or set of Distributed Objects it can be used on + # By default, a Magic Word can affect everyone + affectRange = [MagicWordConfig.AFFECT_SELF, MagicWordConfig.AFFECT_OTHER, MagicWordConfig.AFFECT_BOTH] + + # Where the magic word will be executed -- EXEC_LOC_CLIENT or EXEC_LOC_SERVER + execLocation = MagicWordConfig.EXEC_LOC_INVALID + + # List of all arguments for this word, with the format [(type, isRequired), (type, isRequired)...] + # If the parameter is not required, you must provide a default argument: (type, False, default) + arguments = None + + def __init__(self): + if self.__class__.__name__ != "MagicWord": + self.aliases = self.aliases if self.aliases is not None else [] + self.aliases.insert(0, self.__class__.__name__) + self.aliases = [x.lower() for x in self.aliases] + self.arguments = self.arguments if self.arguments is not None else [] + + if len(self.arguments) > 0: + for arg in self.arguments: + argInfo = "" + if not arg[MagicWordConfig.ARGUMENT_REQUIRED]: + argInfo += "(default: {0})".format(arg[MagicWordConfig.ARGUMENT_DEFAULT]) + self.example += "[{0}{1}] ".format(arg[MagicWordConfig.ARGUMENT_NAME], argInfo) + + self.__register() + + def __register(self): + for wordName in self.aliases: + if wordName in magicWordIndex: + self.notify.error('Duplicate Magic Word name or alias detected! Invalid name: {}'. format(wordName)) + magicWordIndex[wordName] = {'class': self, + 'classname': self.__class__.__name__, + 'hidden': self.hidden, + 'administrative': self.administrative, + 'aliases': self.aliases, + 'desc': self.desc, + 'advancedDesc': self.advancedDesc, + 'example': self.example, + 'execLocation': self.execLocation, + 'access': self.accessLevel, + 'affectRange': self.affectRange, + 'args': self.arguments} + + def loadWord(self, air=None, cr=None, invokerId=None, targets=None, args=None): + self.air = air + self.cr = cr + self.invokerId = invokerId + self.targets = targets + self.args = args + + def executeWord(self): + executedWord = None + validTargets = len(self.targets) + for avId in self.targets: + invoker = None + toon = None + if self.air: + invoker = self.air.doId2do.get(self.invokerId) + toon = self.air.doId2do.get(avId) + elif self.cr: + invoker = self.cr.doId2do.get(self.invokerId) + toon = self.cr.doId2do.get(avId) + if hasattr(toon, "getName"): + name = toon.getName() + else: + name = avId + + if not self.validateTarget(toon): + if len(self.targets) > 1: + validTargets -= 1 + continue + return "{} is not a valid target!".format(name) + + # TODO: Should we implement locking? + # if toon.getLocked() and not self.administrative: + # if len(self.targets) > 1: + # validTargets -= 1 + # continue + # return "{} is currently locked. You can only use administrative commands on them.".format(name) + + if invoker.getAccessLevel() <= toon.getAccessLevel() and toon != invoker: + if len(self.targets) > 1: + validTargets -= 1 + continue + targetAccess = OTPGlobals.AccessLevelDebug2Name.get(OTPGlobals.AccessLevelInt2Name.get(toon.getAccessLevel())) + invokerAccess = OTPGlobals.AccessLevelDebug2Name.get(OTPGlobals.AccessLevelInt2Name.get(invoker.getAccessLevel())) + return "You don't have a high enough Access Level to target {0}! Their Access Level: {1}. Your Access Level: {2}.".format(name, targetAccess, invokerAccess) + + if self.execLocation == MagicWordConfig.EXEC_LOC_CLIENT: + self.args = json.loads(self.args) + + executedWord = self.handleWord(invoker, avId, toon, *self.args) + # If you're only using the Magic Word on one person and there is a response, return that response + if executedWord and len(self.targets) == 1: + return executedWord + # If the amount of targets is higher than one... + elif validTargets > 0: + # And it's only 1, and that's yourself, return None + if validTargets == 1 and self.invokerId in self.targets: + return None + # Otherwise, state how many targets you executed it on + return "Magic Word successfully executed on %s target(s)." % validTargets + else: + return "Magic Word unable to execute on any targets." + + def validateTarget(self, target): + if self.air: + from toontown.toon.DistributedToonAI import DistributedToonAI + return isinstance(target, DistributedToonAI) + elif self.cr: + from toontown.toon.DistributedToon import DistributedToon + return isinstance(target, DistributedToon) + return False + + def handleWord(self, invoker, avId, toon, *args): + raise NotImplementedError + + +class SetHP(MagicWord): + aliases = ["hp", "setlaff", "laff"] + desc = "Sets the target's current laff." + advancedDesc = "This Magic Word will change the current amount of laff points the target has to whichever " \ + "value you specify. You are only allowed to specify a value between -1 and the target's maximum " \ + "laff points. If you specify a value less than 1, the target will instantly go sad unless they " \ + "are in Immortal Mode." + execLocation = MagicWordConfig.EXEC_LOC_SERVER + arguments = [("hp", int, True)] + + def handleWord(self, invoker, avId, toon, *args): + hp = args[0] + + if not -1 <= hp <= toon.getMaxHp(): + return "Can't set {0}'s laff to {1}! Specify a value between -1 and {0}'s max laff ({2}).".format( + toon.getName(), hp, toon.getMaxHp()) + + if hp <= 0 and toon.immortalMode: + return "Can't set {0}'s laff to {1} because they are in Immortal Mode!".format(toon.getName(), hp) + + toon.b_setHp(hp) + return "{}'s laff has been set to {}.".format(toon.getName(), hp) + + +class SetMaxHP(MagicWord): + aliases = ["maxhp", "setmaxlaff", "maxlaff"] + desc = "Sets the target's max laff." + advancedDesc = "This Magic Word will change the maximum amount of laff points the target has to whichever value " \ + "you specify. You are only allowed to specify a value between 15 and 137 laff points." + execLocation = MagicWordConfig.EXEC_LOC_SERVER + arguments = [("maxhp", int, True)] + + def handleWord(self, invoker, avId, toon, *args): + maxhp = args[0] + + if not 15 <= maxhp <= 137: + return "Can't set {}'s max laff to {}! Specify a value between 15 and 137.".format(toon.getName(), maxhp) + + toon.b_setMaxHp(maxhp) + toon.toonUp(maxhp) + return "{}'s max laff has been set to {}.".format(toon.getName(), maxhp) + + +class ToggleOobe(MagicWord): + aliases = ["oobe"] + desc = "Toggles the out of body experience mode, which lets you move the camera freely." + advancedDesc = "This Magic Word will toggle what is known as 'Out Of Body Experience' Mode, hence the name " \ + "'Oobe'. When this mode is active, you are able to move the camera around with your mouse- " \ + "though your camera will still follow your Toon. You can also toggle this mode by pressing the " \ + "'F4' key, or whichever other keybind you have set." + execLocation = MagicWordConfig.EXEC_LOC_CLIENT + + def handleWord(self, invoker, avId, toon, *args): + base.oobe() + return "Oobe mode has been toggled." + + +class ToggleRun(MagicWord): + aliases = ["run"] + desc = "Toggles run mode, which gives you a faster running speed." + advancedDesc = "This Magic Word will toggle Run Mode. When this mode is active, the target can run around at a " \ + "very fast speed. This running speed stacks with other speed multipliers, such as the one given" \ + "by the 'SetSpeed' Magic Word. You will automatically toggle Run Mode by using the 'EnableGod' " \ + "Magic Word." + execLocation = MagicWordConfig.EXEC_LOC_CLIENT + + def handleWord(self, invoker, avId, toon, *args): + from direct.showbase.InputStateGlobal import inputState + inputState.set('debugRunning', not inputState.isSet('debugRunning')) + return "Run mode has been toggled." + + +# Instantiate all classes defined here to register them. +# A bit hacky, but better than the old system +for item in list(globals().values()): + if isinstance(item, type) and issubclass(item, MagicWord): + i = item() diff --git a/toontown/spellbook/ToontownMagicWordManager.py b/toontown/spellbook/ToontownMagicWordManager.py new file mode 100644 index 0000000..04ef3fd --- /dev/null +++ b/toontown/spellbook/ToontownMagicWordManager.py @@ -0,0 +1,262 @@ +################################################## +# The Toontown Offline Magic Word Manager +################################################## +# Author: Benjamin Frisby +# Copyright: Copyright 2020, Toontown Offline +# Credits: Benjamin Frisby, John Cote, Ruby Lord, Frank, Nick, Little Cat, Ooowoo +# License: MIT +# Version: 1.0.0 +# Email: belloqzafarian@gmail.com +################################################## + +from direct.directnotify import DirectNotifyGlobal +from direct.distributed import DistributedObject + +from libotp import WhisperPopup +from otp.otpbase.OTPGlobals import * + +from toontown.friends import FriendHandle +from toontown.spellbook.MagicWordConfig import * +from toontown.spellbook.MagicWordIndex import * +from toontown.toon import Toon + +import json +import random +import string + + +MagicWordIndex = magicWordIndex.copy() + + +class ToontownMagicWordManager(DistributedObject.DistributedObject): + notify = DirectNotifyGlobal.directNotify.newCategory('ToontownMagicWordManager') + neverDisable = 1 + + def __init__(self, cr): + DistributedObject.DistributedObject.__init__(self, cr) + base.cr.magicWordManager = self + + # The default chat prefix we use to determine if said phrase is a Magic Word + self.chatPrefix = PREFIX_DEFAULT + + # The default name of the "wizard" that returns responses when executing Magic Words + self.wizardName = WIZARD_DEFAULT + + # Keep track of the last clicked avatar for targeting purposes + self.lastClickedAvId = 0 + + def announceGenerate(self): + DistributedObject.DistributedObject.announceGenerate(self) + + # Only use a custom Magic Word activator if the index is allowed + # TODO: Uncomment after adding settings support + activatorIndex = 0 # base.settings.getInt('game', 'magic-word-activator', 0) + if 0 <= activatorIndex <= (len(PREFIX_ALLOWED) - 1): + self.chatPrefix = PREFIX_ALLOWED[activatorIndex] + + # Accept events such as outgoing chat messages and clicking on nametags + self.accept(OUTGOING_CHAT_MESSAGE_NAME, self.checkMagicWord) + self.accept(CLICKED_NAMETAG_MESSAGE_NAME, self.__handleClickedNametag) + self.accept(FOCUS_OUT_MESSAGE_NAME, self.__handleFocusOutNametag) + + def disable(self): + DistributedObject.DistributedObject.disable(self) + + # Ignore the events we were accepting earlier + self.ignore(OUTGOING_CHAT_MESSAGE_NAME) + self.ignore(CLICKED_NAMETAG_MESSAGE_NAME) + self.ignore(FOCUS_OUT_MESSAGE_NAME) + + def setChatPrefix(self, chatPrefix): + self.chatPrefix = chatPrefix + + def __handleClickedNametag(self, avatar): + if avatar: + # Make sure the nametag we clicked on is a Toon + if isinstance(avatar, Toon.Toon) or isinstance(avatar, FriendHandle.FriendHandle): + # Store the avId of our target + self.lastClickedAvId = avatar.getDoId() + return + # Clear our target avId + self.lastClickedAvId = 0 + + def __handleFocusOutNametag(self): + # We've clicked off of a nametag, so reset the target avId + self.lastClickedAvId = 0 + + def checkMagicWord(self, magicWord): + # Well, this obviously isn't a Magic Word if it doesn't begin with our prefix + if not magicWord.startswith(self.chatPrefix): + return + + # We don't even have access to be using Magic Words in the first place + if base.localAvatar.getAccessLevel() < OTPGlobals.AccessLevelName2Int.get('MODERATOR'): + self.generateResponse(responseType="NoAccess") + return + + # Using Magic Words while teleporting or going through tunnels is scary + if base.localAvatar.getTransitioning(): + self.generateResponse(responseType="Teleporting") + return + + # We're allowed to use the Magic Word then, so let's proceed + self.handleMagicWord(magicWord) + + def generateResponse(self, responseType, magicWord='', args=None, returnValue=None, affectRange=None, + affectType=None, affectExtra=None, lastClickedAvId=None, extraMessageData = None): + # Generate and send the response to the use of our Magic Word + response = self.generateMagicWordResponse(responseType, magicWord, args, returnValue, affectRange, affectType, + affectExtra, lastClickedAvId, extraMessageData) + base.localAvatar.setSystemMessage(0, self.wizardName + ': ' + response, WhisperPopup.WTSystem) + self.notify.info(response) + + def generateMagicWordResponse(self, responseType, magicWord, args, returnValue, affectRange, affectType, + affectExtra, lastClickedAvId, extraMessageData): + # Start out with a blank response + response = '' + + # If our Magic Word was a success but has no return value, just send a randomized success phrase + if responseType == "SuccessNoResp" and magicWord: + successExclaim = random.choice(MAGIC_WORD_SUCCESS_PHRASES) + response += successExclaim + return response + # We had a successful Magic Word and also got a return value, so let's just use that + elif responseType == "Success": + response += returnValue + return response + + # Guess it wasn't a success, so let's grab our response via the given code + response += MAGIC_WORD_RESPONSES.get(responseType, MAGIC_WORD_NO_RESPONSE) + + # If we want to provide extra info, format the response + if responseType in MagicWordConfig.HAS_EXTRA_MESSAGE_DATA: + response = response.format(extraMessageData) + + return response + + def handleMagicWord(self, magicWord): + # By default, our Magic Word affects nobody- not even ourself! + affectRange = AFFECT_NONE + + # A normal affect type- we aren't trying to target all Toons in the zone, server, or a specific Access Level + affectType = AFFECT_NORMAL + + # Only used for determining what Access Level will be targeted, if we decide to target a specific one + affectExtra = -1 + + # Used for determining what the affectRange is- it counts the amount of activators uses (ranges 1-3) + for x in range(3): + if magicWord.startswith(self.chatPrefix * (3 - x)): + affectRange = 2 - x + break + + # If so some reason our affectRange is still none, we can't go any further + if affectRange == AFFECT_NONE: + self.generateResponse(responseType="NoEffect") + return + # Our affectRange is other, meaning we want to target someone- let's make sure we're allowed to + elif affectRange == AFFECT_OTHER: + # If they don't exist, why would we want to continue? + toon = base.cr.doId2do.get(self.lastClickedAvId) + if not toon: + return + + # Like earlier, Magic Words are no good if used while moving between zones + if toon.getTransitioning(): + self.generateResponse(responseType="OtherTeleporting") + return + + # Get how many activators were used in this Magic Word execution + activatorLength = affectRange + 1 + + # The Magic word without the activators + magicWordNoPrefix = magicWord[activatorLength:] + + # Iterate through the affectType strings and see if any of them were used (e.g. zone, server, or rank) + for type in AFFECT_TYPES: + if magicWordNoPrefix.startswith(type): + magicWordNoPrefix = magicWordNoPrefix[len(type):] + affectType = AFFECT_TYPES.index(type) + break + + # Calculate the Access Level to affect if affectType is RANK + if affectType == AFFECT_RANK: + # Iterate over all the possible Access Level integers and see if any match with the one provided + for level in list(OTPGlobals.AccessLevelName2Int.values()): + # It matches, woohoo! + if magicWordNoPrefix.startswith(str(level)): + # Sorry, I'm commenting this way after the fact, so not even I know why there is a try/except here + # My guess is that sometimes this doesn't work for whatever reason, but I'm not too sure + # It typically works fine for me but I will keep it here just in-case + try: + int(magicWordNoPrefix[len(str(level)):][:1]) + self.generateResponse(responseType="BadTarget") + return + except: + pass + + # Strip the Access Level integer from the Magic Word string + magicWordNoPrefix = magicWordNoPrefix[len(str(level)):] + + # Store the Access Level integer here instead + affectExtra = level + break + + # The invoker wanted to target an Access Level but provided an invalid integer, so let them know + if affectExtra == -1: + self.generateResponse(responseType="BadTarget") + return + + # Finally, we can get the name of the Magic Word used + word = magicWordNoPrefix.split(' ', 1)[0].lower() + + # The Magic Word the invoker used doesn't exist + if word not in MagicWordIndex: + # Iterate over all Magic Word names and see if the one provided is close to any of them + for magicWord in list(MagicWordIndex.keys()): + # If it is close, suggest to the invoker that they made a typo + if word in magicWord: + self.generateResponse(responseType="CloseWord", extraMessageData=magicWord) + return + # Couldn't find any Magic Word close to what was provided, so let them know the word doesn't exist + self.generateResponse(responseType="BadWord") + return + + # Grab the Magic Word info based off of it's name + magicWordInfo = MagicWordIndex[word] + + # The location of the Magic Word's execution was not specified, so raise an error + if magicWordInfo['execLocation'] == EXEC_LOC_INVALID: + raise ValueError("execLocation not set for magic word {}!".format(magicWordInfo['classname'])) + # The execLocation is valid, so let's finally send data over to the server to execute our Magic Word + elif magicWordInfo['execLocation'] in (EXEC_LOC_SERVER, EXEC_LOC_CLIENT): + self.sendUpdate('requestExecuteMagicWord', [affectRange, affectType, affectExtra, self.lastClickedAvId, + magicWordNoPrefix]) + + def executeMagicWord(self, word, commandName, targetIds, args, affectRange, affectType, affectExtra, lastClickedAvId): + # We have have a target avId and the affectRange isn't ourself, we want to execute this Magic Word on the target + # This is alright, but we should only execute it on the target if they are visible on our client + if self.lastClickedAvId and affectRange != AFFECT_SELF: + toon = base.cr.doId2do.get(self.lastClickedAvId) + if not toon: + self.generateResponse(responseType="NoTarget") + return + + # Get the Magic Word info based off of it's name + magicWord = commandName.lower() + magicWordInfo = MagicWordIndex[magicWord] + + # Load the class tied to the Magic Word + command = magicWordInfo['class'] + command.loadWord(None, self.cr, base.localAvatar.getDoId(), targetIds, args) + + # Execute the Magic Word and store the return value + returnValue = command.executeWord() + + # If we have a return value, route it through + if returnValue: + self.generateResponse(responseType="Success", returnValue=returnValue) + # If not just route a generic response through + else: + self.generateResponse(responseType="SuccessNoResp", magicWord=word, args=args, affectRange=affectRange, + affectType=affectType, affectExtra=affectExtra, lastClickedAvId=lastClickedAvId) diff --git a/toontown/spellbook/ToontownMagicWordManagerAI.py b/toontown/spellbook/ToontownMagicWordManagerAI.py new file mode 100644 index 0000000..0973ff2 --- /dev/null +++ b/toontown/spellbook/ToontownMagicWordManagerAI.py @@ -0,0 +1,298 @@ +################################################## +# The Toontown Offline Magic Word Manager +################################################## +# Author: Benjamin Frisby +# Copyright: Copyright 2020, Toontown Offline +# Credits: Benjamin Frisby, John Cote, Ruby Lord, Frank, Nick, Little Cat, Ooowoo +# License: MIT +# Version: 1.0.0 +# Email: belloqzafarian@gmail.com +################################################## + +from direct.directnotify import DirectNotifyGlobal +from direct.distributed import DistributedObjectAI + +from otp.avatar.DistributedPlayerAI import DistributedPlayerAI + +from toontown.spellbook.MagicWordConfig import * +from toontown.spellbook.MagicWordIndex import * + +import json +import os + +# All of the data regarding our Magic Words +MagicWordIndex = magicWordIndex.copy() + +# You should only concern yourself with the following code if you want to add customization features to Magic Words +# This is only really useful in Toontown Offline, so other projects shouldn't have to really worry about it + +# We allow server hosters to change a few things about the Magic Words on their server +# These are the default values generated with spellbook.json to help get them started +spellbookJsonDefaultValues = CUSTOM_SPELLBOOK_DEFAULT + +# If we don't have a config directory, make it +if not os.path.exists('config/'): + os.mkdir('config/') + +# If spellbook.json doesn't exist, make it +if not os.path.isfile('config/spellbook.json'): + with open('config/spellbook.json', 'w') as data: + data.write(spellbookJsonDefaultValues) + data.close() + +# Now load the data from spellbook.json +with open('config/spellbook.json') as data: + spellbook = json.load(data) + +# Make changes to all the Magic Words based on the data in spellbook.json +for word in spellbook['words']: + name = word['name'] + accessLevel = word['access'] + + if accessLevel not in list(OTPGlobals.AccessLevelName2Int.keys()): + break + + try: + wordInfo = MagicWordIndex[str(name.lower())] + for alias in wordInfo['aliases']: + MagicWordIndex[alias]['access'] = accessLevel + except: + pass + + +class ToontownMagicWordManagerAI(DistributedObjectAI.DistributedObjectAI): + notify = DirectNotifyGlobal.directNotify.newCategory('ToontownMagicWordManagerAI') + + def requestExecuteMagicWord(self, affectRange, affectType, affectExtra, lastClickedAvId, magicWord): + avId = self.air.getAvatarIdFromSender() + + # It's not good if we can't get the avId for whatever reason + if not avId: + self.notify.warning('requestExecuteMagicWord: Magic Word use requested but invoker avId is non-existent!') + return + + # We also need the Toon as well, or else this Magic Word isn't going to do much good + toon = self.air.doId2do.get(avId) + if not toon: + self.notify.warning('requestExecuteMagicWord: Magic Word use requested but invoker avatar is non-existent!') + return + + # Same thing with the Toontorial. Magic Words are strictly forbidden here + # Tell the user they can't use it because they're in the Toontorial + if hasattr(self.air, 'tutorialManager') and avId in list(self.air.tutorialManager.avId2fsm.keys()): + self.generateResponse(avId=avId, responseType="Tutorial") + return + + # Our Magic Word affectRange is either SELF (the invoker) or BOTH (invoker and a target) + # Because of this, we should add the invoker to the target list + targetIds = [] + if affectRange in (AFFECT_SELF, AFFECT_BOTH): + targetIds.append(avId) + + # This Magic Word's affectRange is either OTHER (a target) or BOTH (invoker and a target) + # However, it's also a NORMAL affectType, so it's not as if we're targeting a zone or the whole server + # In that case, let's try to grab the single target by the lastClickedAvId provided by the invoker + lastClickedToon = None + if (affectRange in (AFFECT_OTHER, AFFECT_BOTH)) and affectType == AFFECT_NORMAL: + if lastClickedAvId: + lastClickedToon = self.air.doId2do.get(lastClickedAvId) + if lastClickedToon: + targetIds.append(lastClickedAvId) + else: + self.generateResponse(avId=avId, responseType="NoTarget") + return + + # The affectType is ZONE (zone the invoker is in), SERVER (the entire server), or RANK (specified access level) + # Gather all of the Toons using whichever method this Magic Word requests + if affectType in (AFFECT_ZONE, AFFECT_SERVER, AFFECT_RANK): + toonIds = [] + # Iterate over a copy of every single doId on the server + for doId in list(self.air.doId2do.keys())[:]: + do = self.air.doId2do.get(doId) + # We only care if our DistributedObject is a player that is NOT our invoker (we dealt with that earlier) + if isinstance(do, DistributedPlayerAI) and do.isPlayerControlled() and do != toon: + # Only add the Toons that are in the same zone as the invoker + if affectType == AFFECT_ZONE and do.zoneId == toon.zoneId: + toonIds.append(doId) + # Add every Toon regardless of zone + elif affectType == AFFECT_SERVER: + toonIds.append(doId) + # Only add the Toons that have the Access Level specified when the Magic Word was used + elif affectType == AFFECT_RANK and do.getAccessLevel() == affectExtra: + toonIds.append(doId) + + # There were no Toons we could perform this Magic Word on, so let the invoker know that + if not toonIds and not targetIds: + self.generateResponse(avId=avId, responseType="NoTarget") + return + + # Add the found Toons to the targetId list + targetIds += toonIds + + # If, at this point, we still don't have any targets somehow, then let the invoker know that + if not targetIds: + self.generateResponse(avId=avId, responseType="NoTarget") + return + + # Access level of the invoker + invokerAccess = int(round(toon.getAccessLevel(), -2)) + + # Access level of the selected target, if we have one + targetAccess = 0 + if lastClickedAvId and lastClickedToon: + targetAccess = lastClickedToon.getAccessLevel() + + # Now that we have all the avIds of who we want to target with this Magic Word, let's run some sanity checks + # First things first, let's make sure the invoker is allowed to target who they want to target + for targetId in targetIds: + # Of course the invoker has access to target themselves + if targetId == avId: + continue + # If our target DistributedObject doesn't exist anymore for whatever reason, just ignore them + targetToon = self.air.doId2do.get(targetId) + if not targetToon: + continue + # Get the Access Level of the target and round it to the nearest 100th + # This kind of thing is useful for roles like BUILDER, that are technically higher than USER + # They should have more perms than USERS, but shouldn't be allowed to target them + targetAccess = int(round(targetToon.getAccessLevel(), -2)) + # If the Access Level of the target is greater than or equal to than that of the invoker, remove them + if targetAccess >= invokerAccess: + targetIds.remove(targetId) + continue + + # Function that returns a readable name in place of the Toon's Access Level + def getAccessName(accessLevel): + return OTPGlobals.AccessLevelDebug2Name.get(OTPGlobals.AccessLevelInt2Name.get(accessLevel)) + + # If, after the previous check, we don't have any more targets, let's inform the invoker about it + if len(targetIds) == 0: + # If affectType is NORMAL, let the invoker know what their Access Level is compared to their target + if (affectRange in (AFFECT_OTHER, AFFECT_BOTH)) and affectType == AFFECT_NORMAL: + # Parse the Access Level of the invoker and target + parsedTargetAccess = getAccessName(targetAccess) + parsedInvokerAccess = getAccessName(invokerAccess) + # Create a nice little message to tell the invoker the difference between the Access Levels + returnValue = MAGIC_WORD_RESPONSES.get("NoAccessSingleTarget") + returnValue = returnValue.format(lastClickedToon.getName(), parsedTargetAccess, parsedInvokerAccess) + self.generateResponse(avId=avId, responseType="Success", returnValue=returnValue) + # Otherwise, just let the invoker know that everyone who was targeted was not allowed to be + else: + self.generateResponse(avId=avId, responseType="NoAccessToTarget") + return + + # We're finally done determining everything related to the targets. Finally, let's get into the word itself + # We start by separating the word used from it's arguments + magicWord, args = (magicWord.split(' ', 1) + [''])[:2] + + # Get the name of the word in lowercase + magicWord = magicWord.lower() + + # Lookup the info for this word + magicWordInfo = MagicWordIndex[magicWord] + + # Make sure the invoker has a high enough Access Level to use this Magic Word in the first place + # If they don't, them let them know about it + if toon.getAccessLevel() < OTPGlobals.AccessLevelName2Int.get(magicWordInfo['access']): + self.generateResponse(avId=avId, responseType="NoAccess") + return + + # If a config option disables cheaty Magic Words and ours is deemed cheaty, let the invoker know + if hasattr(self.air, 'nonCheaty') and self.air.nonCheaty: + if not magicWordInfo['administrative']: + self.generateResponse(avId=avId, responseType="NonCheaty") + return + + # If the affectRange circumstance made by the invoker is not allowed, let them know about it + # This kind of thing is good to make sure that words that shouldn't really have a particular target don't end + # up getting used in mass. For example, you don't want to use a word intended to kill a Cog Boss on other Toons + if affectRange not in magicWordInfo['affectRange']: + self.generateResponse(avId=avId, responseType="RestrictionOther") + return + + # Get the arguments the Magic Word will accept + commandArgs = magicWordInfo['args'] + + # Determine the max and min amount of arguments the word will accept + maxArgs = len(commandArgs) + minArgs = 0 + argList = args.split(None, maxArgs-1) + for argSet in commandArgs: + isRequired = argSet[ARGUMENT_REQUIRED] + if isRequired: + minArgs += 1 + + # If we have less arguments provided than are required, let the invoker know that + messageData = "{} argument{}" + if len(argList) < minArgs: + messageData = messageData.format(minArgs, "s" if minArgs != 1 else '') + self.generateResponse(avId=avId, responseType="NotEnoughArgs", extraMessageData=messageData) + return + # On the other hand, if we have more than what we need, tell them that instead + elif len(argList) > maxArgs: + messageData = messageData.format(maxArgs, "s" if maxArgs != 1 else '') + self.generateResponse(avId=avId, responseType="TooManyArgs", extraMessageData=messageData) + return + + # If we have less arguments provided than the max, use the defaults of the ones not provided + if len(argList) < maxArgs: + for x in range(minArgs, maxArgs): + if commandArgs[x][ARGUMENT_REQUIRED] or len(argList) >= x + 1: + continue + argList.append(commandArgs[x][ARGUMENT_DEFAULT]) + + # Parse through all the args we had provided + parsedArgList = [] + for x in range(len(argList)): + arg = argList[x] + argType = commandArgs[x][ARGUMENT_TYPE] + try: + parsedArg = argType(arg) + except: + self.generateResponse(avId=avId, responseType="BadArgs") + return + + parsedArgList.append(parsedArg) + + # If this is a client-sided Magic Word, execute it on the client + if magicWordInfo['execLocation'] == EXEC_LOC_CLIENT: + # We are only allowed to target ourselves with client-sided Magic Words + if len(targetIds) == 1 and avId in targetIds: + self.sendClientCommand(avId, magicWord, magicWordInfo['classname'], targetIds=targetIds, + parsedArgList=parsedArgList, affectRange=affectRange, affectType=affectType, + affectExtra=affectExtra, lastClickedAvId=lastClickedAvId) + else: + self.generateResponse(avId=avId, responseType="CannotTarget") + return + # But if it's a server-sided one, execute it on the server + else: + # Find the class associated with our Magic Word and load it + command = magicWordInfo['class'] + command.loadWord(self.air, None, avId, targetIds, parsedArgList) + # Execute the Magic Word and grab a return value + returnValue = command.executeWord() + # If we have a return value, pass it over to the invoker + if returnValue: + self.generateResponse(avId=avId, responseType="Success", returnValue=returnValue) + # Otherwise just throw a default response to them + else: + self.generateResponse(avId=avId, responseType="SuccessNoResp", magicWord=magicWord, + parsedArgList=parsedArgList, affectRange=affectRange, affectType=affectType, + affectExtra=affectExtra, lastClickedAvId=lastClickedAvId) + + def generateResponse(self, avId, responseType="BadWord", magicWord='', parsedArgList=(), returnValue='', + affectRange=0, affectType=0, affectExtra=0, lastClickedAvId=0, extraMessageData=''): + # Pack up the arg list so it's ready to ship to the client + parsedArgList = json.dumps(parsedArgList) + # Send the invoker a response to their use of the word + self.sendUpdateToAvatarId(avId, 'generateResponse', + [responseType, magicWord, parsedArgList, returnValue, affectRange, affectType, + affectExtra, lastClickedAvId, extraMessageData]) + + def sendClientCommand(self, avId, word, commandName, targetIds=(), parsedArgList=(), affectRange=0, affectType=0, + affectExtra=0, lastClickedAvId=0): + # Pack up the arg list so it's ready to ship to the client + parsedArgList = json.dumps(parsedArgList) + # Execute the Magic Word on the client, because it's a client-sided Magic Word + self.sendUpdateToAvatarId(avId, "executeMagicWord", [word, commandName, targetIds, parsedArgList, affectRange, + affectType, affectExtra, lastClickedAvId]) diff --git a/toontown/spellbook/__init__.py b/toontown/spellbook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toontown/toon/DistributedToon.py b/toontown/toon/DistributedToon.py index 23129e5..c67a546 100644 --- a/toontown/toon/DistributedToon.py +++ b/toontown/toon/DistributedToon.py @@ -186,6 +186,7 @@ class DistributedToon(DistributedPlayer.DistributedPlayer, Toon.Toon, Distribute self.gmNameTagColor = 'whiteGM' self.gmNameTagString = '' self._lastZombieContext = None + self.transitioning = False return def disable(self): @@ -2610,3 +2611,6 @@ class DistributedToon(DistributedPlayer.DistributedPlayer, Toon.Toon, Distribute if not present: self.notify.warning('hiding av %s because they are not on the district!' % self.doId) self.setParent(OTPGlobals.SPHidden) + + def getTransitioning(self): + return self.transitioning