mirror of
https://github.com/Sneed-Group/Poodletooth-iLand
synced 2024-12-23 11:42:39 -06:00
1202 lines
42 KiB
Python
1202 lines
42 KiB
Python
from direct.directnotify.DirectNotifyGlobal import directNotify
|
|
from direct.distributed.DistributedObjectGlobalUD import DistributedObjectGlobalUD
|
|
from direct.distributed.PyDatagram import *
|
|
from direct.fsm.FSM import FSM
|
|
|
|
from otp.ai.MagicWordGlobal import *
|
|
from otp.distributed import OtpDoGlobals
|
|
|
|
from toontown.makeatoon.NameGenerator import NameGenerator
|
|
from toontown.toon.ToonDNA import ToonDNA
|
|
from toontown.toonbase import TTLocalizer
|
|
from toontown.uberdog import NameJudgeBlacklist
|
|
|
|
from panda3d.core import *
|
|
|
|
import hashlib, hmac, json
|
|
import anydbm, math, os
|
|
import urllib2, time, urllib
|
|
import cookielib, socket
|
|
|
|
def rejectConfig(issue, securityIssue=True, retarded=True):
|
|
print
|
|
print
|
|
print 'Lemme get this straight....'
|
|
print 'You are trying to use remote account database type...'
|
|
print 'However,', issue + '!!!!'
|
|
if securityIssue:
|
|
print 'Do you want this server to get hacked?'
|
|
if retarded:
|
|
print '"Either down\'s or autism"\n - JohnnyDaPirate, 2015'
|
|
print 'Go fix that!'
|
|
exit()
|
|
|
|
def entropy(string):
|
|
prob = [float(string.count(c)) / len(string) for c in dict.fromkeys(list(string))]
|
|
entropy = -sum([p * math.log(p) / math.log(2.0) for p in prob])
|
|
return entropy
|
|
|
|
def entropyIdeal(length):
|
|
prob = 1.0 / length
|
|
return -length * prob * math.log(prob) / math.log(2.0)
|
|
|
|
accountDBType = config.GetString('accountdb-type', 'developer')
|
|
accountServerSecret = config.GetString('account-server-secret', 'dev')
|
|
accountServerHashAlgo = config.GetString('account-server-hash-algo', 'sha512')
|
|
apiSecret = accountServerSecret = config.GetString('api-key', 'dev')
|
|
if accountDBType == 'remote':
|
|
if accountServerSecret == 'dev':
|
|
rejectConfig('you have not changed the secret in config/local.prc')
|
|
|
|
if apiSecret == 'dev':
|
|
rejectConfig('you have not changed the api key in config/local.prc')
|
|
|
|
if len(accountServerSecret) < 16:
|
|
rejectConfig('the secret is too small! Make it 16+ bytes', retarded=False)
|
|
|
|
secretLength = len(accountServerSecret)
|
|
ideal = entropyIdeal(secretLength) / 2
|
|
entropy = entropy(accountServerSecret)
|
|
if entropy < ideal:
|
|
rejectConfig('the secret entropy is too low! For %d bytes,'
|
|
' it should be %d. Currently it is %d' % (secretLength, ideal, entropy),
|
|
retarded=False)
|
|
|
|
hashAlgo = getattr(hashlib, accountServerHashAlgo, None)
|
|
if not hashAlgo:
|
|
rejectConfig('%s is not a valid hash algo' % accountServerHashAlgo, securityIssue=False)
|
|
|
|
hashSize = len(hashAlgo('').digest())
|
|
|
|
minAccessLevel = config.GetInt('min-access-level', 100)
|
|
|
|
def executeHttpRequest(url, **extras):
|
|
# TO DO: THIS IS QUITE DISGUSTING
|
|
# MOVE THIS TO ToontownInternalRepository (this might be interesting for AI)
|
|
##### USE PYTHON 2.7.9 ON PROD WITH SSL AND CLOUDFLARE #####
|
|
_data = {}
|
|
if len(extras.items()) != 0:
|
|
for k, v in extras.items():
|
|
_data[k] = v
|
|
signature = hashlib.sha512(json.dumps(_data) + apiSecret).hexdigest()
|
|
data = urllib.urlencode({'data': json.dumps(_data), 'hmac': signature})
|
|
cookie_jar = cookielib.LWPCookieJar()
|
|
cookie = urllib2.HTTPCookieProcessor(cookie_jar)
|
|
opener = urllib2.build_opener(cookie)
|
|
req = urllib2.Request('http://192.168.1.212/api/' + url, data,
|
|
headers={"Content-Type" : "application/x-www-form-urlencoded"})
|
|
req.get_method = lambda: "POST"
|
|
try:
|
|
return opener.open(req).read()
|
|
except:
|
|
return None
|
|
|
|
notify = directNotify.newCategory('ClientServicesManagerUD')
|
|
|
|
def executeHttpRequestAndLog(url, **extras):
|
|
# SEE ABOVE
|
|
response = executeHttpRequest(url, extras)
|
|
|
|
if response is None:
|
|
notify.error('A request to ' + url + ' went wrong.')
|
|
return None
|
|
|
|
try:
|
|
data = json.loads(response)
|
|
except:
|
|
notify.error('Malformed response from ' + url + '.')
|
|
return None
|
|
|
|
if 'error' in data:
|
|
notify.warning('Error from ' + url + ':' + data['error'])
|
|
|
|
return data
|
|
|
|
#blacklist = executeHttpRequest('names/blacklist.json') # todo; create a better system for this
|
|
blacklist = json.dumps(["none"])
|
|
if blacklist:
|
|
blacklist = json.loads(blacklist)
|
|
|
|
def judgeName(name):
|
|
if not name:
|
|
return False
|
|
if blacklist:
|
|
for namePart in name.split(' '):
|
|
namePart = namePart.lower()
|
|
if len(namePart) < 1:
|
|
return False
|
|
for banned in blacklist:
|
|
if banned in namePart:
|
|
return False
|
|
return True
|
|
|
|
# --- ACCOUNT DATABASES ---
|
|
# These classes make up the available account databases for Toontown Stride.
|
|
# DeveloperAccountDB is a special database that accepts a username, and assigns
|
|
# each user with 700 access automatically upon login.
|
|
|
|
class AccountDB:
|
|
notify = directNotify.newCategory('AccountDB')
|
|
|
|
def __init__(self, csm):
|
|
self.csm = csm
|
|
|
|
filename = config.GetString('account-bridge-filename', 'account-bridge.db')
|
|
filename = os.path.join('dependencies', filename)
|
|
|
|
self.dbm = anydbm.open(filename, 'c')
|
|
|
|
def addNameRequest(self, avId, name, accountID = None):
|
|
return True
|
|
|
|
def getNameStatus(self, accountId, callback = None):
|
|
return 'APPROVED'
|
|
|
|
def removeNameRequest(self, avId):
|
|
pass
|
|
|
|
def lookup(self, data, callback):
|
|
userId = data['userId']
|
|
|
|
data['success'] = True
|
|
data['accessLevel'] = max(data['accessLevel'], minAccessLevel)
|
|
|
|
if str(userId) not in self.dbm:
|
|
data['accountId'] = 0
|
|
|
|
else:
|
|
data['accountId'] = int(self.dbm[str(userId)])
|
|
|
|
callback(data)
|
|
return data
|
|
|
|
def storeAccountID(self, userId, accountId, callback):
|
|
self.dbm[str(userId)] = str(accountId) # anydbm only allows strings.
|
|
if getattr(self.dbm, 'sync', None):
|
|
self.dbm.sync()
|
|
callback(True)
|
|
else:
|
|
self.notify.warning('Unable to associate user %s with account %d!' % (userId, accountId))
|
|
callback(False)
|
|
|
|
class DeveloperAccountDB(AccountDB):
|
|
notify = directNotify.newCategory('DeveloperAccountDB')
|
|
|
|
def lookup(self, userId, callback):
|
|
return AccountDB.lookup(self, {'userId': userId,
|
|
'accessLevel': 700,
|
|
'notAfter': 0},
|
|
callback)
|
|
|
|
class RemoteAccountDB:
|
|
# TO DO FOR NAMES:
|
|
# CURRENTLY IT MAKES n REQUESTS FOR EACH AVATAR
|
|
# IN THE FUTURE, MAKE ONLY 1 REQUEST
|
|
# WHICH RETURNS ALL PENDING AVS
|
|
# ^ done, check before removing todo note
|
|
notify = directNotify.newCategory('RemoteAccountDB')
|
|
|
|
def __init__(self, csm):
|
|
self.csm = csm
|
|
|
|
def addNameRequest(self, avId, name, accountID = None):
|
|
username = avId
|
|
if accountID is not None:
|
|
username = accountID
|
|
|
|
res = executeHttpRequest('names', action='set', username=str(username),
|
|
avId=str(avId), wantedName=name)
|
|
if res is not None:
|
|
return True
|
|
return False
|
|
|
|
def getNameStatus(self, accountId, callback = None):
|
|
r = executeHttpRequest('names', action='get', username=str(accountId))
|
|
try:
|
|
r = json.loads(r)
|
|
if callback is not None:
|
|
callback(r)
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
def removeNameRequest(self, avId):
|
|
r = executeHttpRequest('names', action='del', avId=str(avId))
|
|
if r:
|
|
return 'SUCCESS'
|
|
return 'FAILURE'
|
|
|
|
def lookup(self, token, callback):
|
|
'''
|
|
Token format:
|
|
The token is obfuscated a bit, but nothing too hard to read.
|
|
Most of the security is based on the hash.
|
|
|
|
I. Data contained in a token:
|
|
A json-encoded dict, which contains timestamp, userid and extra info
|
|
|
|
II. Token format
|
|
X = BASE64(ROT13(DATA)[::-1])
|
|
H = HASH(X)[::-1]
|
|
Token = BASE64(H + X)
|
|
'''
|
|
|
|
cookie_check = executeHttpRequest('cookie', cookie=token)
|
|
|
|
try:
|
|
check = json.loads(cookie_check)
|
|
if check['success'] is not True:
|
|
raise ValueError(check['error'])
|
|
token = token.decode('base64')
|
|
hash, token = token[:hashSize], token[hashSize:]
|
|
correctHash = hashAlgo(token + accountServerSecret).digest()
|
|
if len(hash) != len(correctHash):
|
|
raise ValueError('Invalid hash.')
|
|
|
|
value = 0
|
|
for x, y in zip(hash[::-1], correctHash):
|
|
value |= ord(x) ^ ord(y)
|
|
|
|
if value:
|
|
raise ValueError('Invalid hash.')
|
|
|
|
token = json.loads(token.decode('base64')[::-1].decode('rot13'))
|
|
|
|
if token['notAfter'] < int(time.time()):
|
|
raise ValueError('Expired token.')
|
|
except:
|
|
resp = {'success': False}
|
|
callback(resp)
|
|
return resp
|
|
|
|
return self.account_lookup(token, callback)
|
|
|
|
def account_lookup(self, data, callback):
|
|
data['success'] = True
|
|
data['accessLevel'] = max(data['accessLevel'], minAccessLevel)
|
|
data['accountId'] = int(data['accountId'])
|
|
|
|
callback(data)
|
|
return data
|
|
|
|
def storeAccountID(self, userId, accountId, callback):
|
|
r = executeHttpRequest('associateuser', username=str(userId), accountId=str(accountId))
|
|
try:
|
|
r = json.loads(r)
|
|
if r['success']:
|
|
callback(True)
|
|
else:
|
|
self.notify.warning('Unable to associate user %s with account %d, got the return message of %s!' % (userId, accountId, r['error']))
|
|
callback(False)
|
|
except:
|
|
self.notify.warning('Unable to associate user %s with account %d!' % (userId, accountId))
|
|
callback(False)
|
|
|
|
|
|
# --- FSMs ---
|
|
class OperationFSM(FSM):
|
|
TARGET_CONNECTION = False
|
|
|
|
def __init__(self, csm, target):
|
|
self.csm = csm
|
|
self.target = target
|
|
|
|
FSM.__init__(self, self.__class__.__name__)
|
|
|
|
def enterKill(self, reason=''):
|
|
if self.TARGET_CONNECTION:
|
|
self.csm.killConnection(self.target, reason)
|
|
else:
|
|
self.csm.killAccount(self.target, reason)
|
|
self.demand('Off')
|
|
|
|
def enterOff(self):
|
|
if self.TARGET_CONNECTION:
|
|
del self.csm.connection2fsm[self.target]
|
|
else:
|
|
del self.csm.account2fsm[self.target]
|
|
|
|
class LoginAccountFSM(OperationFSM):
|
|
notify = directNotify.newCategory('LoginAccountFSM')
|
|
TARGET_CONNECTION = True
|
|
|
|
def enterStart(self, token):
|
|
self.token = token
|
|
self.demand('QueryAccountDB')
|
|
|
|
def enterQueryAccountDB(self):
|
|
self.csm.accountDB.lookup(self.token, self.__handleLookup)
|
|
|
|
def __handleLookup(self, result):
|
|
if not result.get('success'):
|
|
self.csm.air.writeServerEvent('tokenRejected', self.target, self.token)
|
|
self.demand('Kill', result.get('reason', 'The account server rejected your token.'))
|
|
return
|
|
|
|
self.userId = result.get('userId', 0)
|
|
self.accountId = result.get('accountId', 0)
|
|
self.accessLevel = result.get('accessLevel', 0)
|
|
self.notAfter = result.get('notAfter', 0)
|
|
if self.accountId:
|
|
self.demand('RetrieveAccount')
|
|
else:
|
|
self.demand('CreateAccount')
|
|
|
|
def enterRetrieveAccount(self):
|
|
self.csm.air.dbInterface.queryObject(
|
|
self.csm.air.dbId, self.accountId, self.__handleRetrieve)
|
|
|
|
def __handleRetrieve(self, dclass, fields):
|
|
if dclass != self.csm.air.dclassesByName['AccountUD']:
|
|
self.demand('Kill', 'Your account object was not found in the database!')
|
|
return
|
|
|
|
self.account = fields
|
|
|
|
if self.notAfter:
|
|
if self.account.get('LAST_LOGIN_TS', 0) > self.notAfter:
|
|
self.notify.debug('Rejecting old token: %d, notAfter=%d' % (self.account.get('LAST_LOGIN_TS', 0), self.notAfter))
|
|
return self.__handleLookup({'success': False})
|
|
|
|
self.demand('SetAccount')
|
|
|
|
def enterCreateAccount(self):
|
|
self.account = {
|
|
'ACCOUNT_AV_SET': [0] * 6,
|
|
'ESTATE_ID': 0,
|
|
'ACCOUNT_AV_SET_DEL': [],
|
|
'CREATED': time.ctime(),
|
|
'LAST_LOGIN': time.ctime(),
|
|
'LAST_LOGIN_TS': time.time(),
|
|
'ACCOUNT_ID': str(self.userId),
|
|
'ACCESS_LEVEL': self.accessLevel,
|
|
'CHAT_SETTINGS': [1, 1]
|
|
}
|
|
self.csm.air.dbInterface.createObject(
|
|
self.csm.air.dbId,
|
|
self.csm.air.dclassesByName['AccountUD'],
|
|
self.account,
|
|
self.__handleCreate)
|
|
|
|
def __handleCreate(self, accountId):
|
|
if self.state != 'CreateAccount':
|
|
self.notify.warning('Received a create account response outside of the CreateAccount state.')
|
|
return
|
|
|
|
if not accountId:
|
|
self.notify.warning('Database failed to construct an account object!')
|
|
self.demand('Kill', 'Your account object could not be created in the game database.')
|
|
return
|
|
|
|
self.accountId = accountId
|
|
self.csm.air.writeServerEvent('accountCreated', accountId)
|
|
self.demand('StoreAccountID')
|
|
|
|
def enterStoreAccountID(self):
|
|
self.csm.accountDB.storeAccountID(
|
|
self.userId,
|
|
self.accountId,
|
|
self.__handleStored)
|
|
|
|
def __handleStored(self, success=True):
|
|
if not success:
|
|
self.demand('Kill', 'The account server could not save your user ID!')
|
|
return
|
|
|
|
self.demand('SetAccount')
|
|
|
|
def enterSetAccount(self):
|
|
# If necessary, update their account information:
|
|
if self.accessLevel:
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
self.accountId,
|
|
self.csm.air.dclassesByName['AccountUD'],
|
|
{'ACCESS_LEVEL': self.accessLevel})
|
|
|
|
# If there's anybody on the account, kill them for redundant login:
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
self.csm.GetAccountConnectionChannel(self.accountId),
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_EJECT)
|
|
datagram.addUint16(100)
|
|
datagram.addString('This account has been logged in from elsewhere.')
|
|
self.csm.air.send(datagram)
|
|
|
|
# Next, add this connection to the account channel.
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
self.target,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_OPEN_CHANNEL)
|
|
datagram.addChannel(self.csm.GetAccountConnectionChannel(self.accountId))
|
|
self.csm.air.send(datagram)
|
|
|
|
# Subscribe to any "staff" channels that the account has access to.
|
|
access = self.account.get('ADMIN_ACCESS', 0)
|
|
if access >= 200:
|
|
# Subscribe to the moderator channel.
|
|
dg = PyDatagram()
|
|
dg.addServerHeader(self.target, self.csm.air.ourChannel, CLIENTAGENT_OPEN_CHANNEL)
|
|
dg.addChannel(OtpDoGlobals.OTP_MOD_CHANNEL)
|
|
self.csm.air.send(dg)
|
|
if access >= 400:
|
|
# Subscribe to the administrator channel.
|
|
dg = PyDatagram()
|
|
dg.addServerHeader(self.target, self.csm.air.ourChannel, CLIENTAGENT_OPEN_CHANNEL)
|
|
dg.addChannel(OtpDoGlobals.OTP_ADMIN_CHANNEL)
|
|
self.csm.air.send(dg)
|
|
if access >= 500:
|
|
# Subscribe to the system administrator channel.
|
|
dg = PyDatagram()
|
|
dg.addServerHeader(self.target, self.csm.air.ourChannel, CLIENTAGENT_OPEN_CHANNEL)
|
|
dg.addChannel(OtpDoGlobals.OTP_SYSADMIN_CHANNEL)
|
|
self.csm.air.send(dg)
|
|
|
|
# Now set their sender channel to represent their account affiliation:
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
self.target,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_SET_CLIENT_ID)
|
|
# Account ID in high 32 bits, 0 in low (no avatar):
|
|
datagram.addChannel(self.accountId << 32)
|
|
self.csm.air.send(datagram)
|
|
|
|
# Un-sandbox them!
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
self.target,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_SET_STATE)
|
|
datagram.addUint16(2) # ESTABLISHED
|
|
self.csm.air.send(datagram)
|
|
|
|
# Update the last login timestamp:
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
self.accountId,
|
|
self.csm.air.dclassesByName['AccountUD'],
|
|
{'LAST_LOGIN': time.ctime(),
|
|
'LAST_LOGIN_TS': time.time(),
|
|
'ACCOUNT_ID': str(self.userId)})
|
|
|
|
# We're done.
|
|
self.csm.air.writeServerEvent('accountLogin', self.target, self.accountId, self.userId)
|
|
self.csm.sendUpdateToChannel(self.target, 'acceptLogin', [int(time.time())])
|
|
self.demand('Off')
|
|
|
|
class CreateAvatarFSM(OperationFSM):
|
|
notify = directNotify.newCategory('CreateAvatarFSM')
|
|
|
|
def enterStart(self, dna, index):
|
|
# Basic sanity-checking:
|
|
if index >= 6:
|
|
self.demand('Kill', 'Invalid index specified!')
|
|
return
|
|
|
|
if not ToonDNA().isValidNetString(dna):
|
|
self.demand('Kill', 'Invalid DNA specified!')
|
|
return
|
|
|
|
self.index = index
|
|
self.dna = dna
|
|
|
|
# Okay, we're good to go, let's query their account.
|
|
self.demand('RetrieveAccount')
|
|
|
|
def enterRetrieveAccount(self):
|
|
self.csm.air.dbInterface.queryObject(
|
|
self.csm.air.dbId, self.target, self.__handleRetrieve)
|
|
|
|
def __handleRetrieve(self, dclass, fields):
|
|
if dclass != self.csm.air.dclassesByName['AccountUD']:
|
|
self.demand('Kill', 'Your account object was not found in the database!')
|
|
return
|
|
|
|
self.account = fields
|
|
|
|
# For use in calling name requests:
|
|
self.accountID = self.account['ACCOUNT_ID']
|
|
|
|
self.avList = self.account['ACCOUNT_AV_SET']
|
|
# Sanitize:
|
|
self.avList = self.avList[:6]
|
|
self.avList += [0] * (6-len(self.avList))
|
|
|
|
# Make sure the index is open:
|
|
if self.avList[self.index]:
|
|
self.demand('Kill', 'This avatar slot is already taken by another avatar!')
|
|
return
|
|
|
|
# Okay, there's space. Let's create the avatar!
|
|
self.demand('CreateAvatar')
|
|
|
|
def enterCreateAvatar(self):
|
|
dna = ToonDNA()
|
|
dna.makeFromNetString(self.dna)
|
|
colorString = TTLocalizer.ColorfulToon
|
|
animalType = TTLocalizer.AnimalToSpecies[dna.getAnimal()]
|
|
name = ' '.join((colorString, animalType))
|
|
toonFields = {
|
|
'setName': (name,),
|
|
'setWishNameState': ('OPEN',),
|
|
'setWishName': ('',),
|
|
'setDNAString': (self.dna,),
|
|
'setDISLid': (self.target,),
|
|
}
|
|
self.csm.air.dbInterface.createObject(
|
|
self.csm.air.dbId,
|
|
self.csm.air.dclassesByName['DistributedToonUD'],
|
|
toonFields,
|
|
self.__handleCreate)
|
|
|
|
def __handleCreate(self, avId):
|
|
if not avId:
|
|
self.demand('Kill', 'Database failed to create the new avatar object!')
|
|
return
|
|
|
|
self.avId = avId
|
|
self.demand('StoreAvatar')
|
|
|
|
def enterStoreAvatar(self):
|
|
# Associate the avatar with the account...
|
|
self.avList[self.index] = self.avId
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
self.target,
|
|
self.csm.air.dclassesByName['AccountUD'],
|
|
{'ACCOUNT_AV_SET': self.avList},
|
|
{'ACCOUNT_AV_SET': self.account['ACCOUNT_AV_SET']},
|
|
self.__handleStoreAvatar)
|
|
|
|
self.accountID = self.account['ACCOUNT_ID']
|
|
|
|
def __handleStoreAvatar(self, fields):
|
|
if fields:
|
|
self.demand('Kill', 'Database failed to associate the new avatar to your account!')
|
|
return
|
|
|
|
# Otherwise, we're done!
|
|
self.csm.air.writeServerEvent('avatarCreated', self.avId, self.target, self.dna.encode('hex'), self.index)
|
|
self.csm.sendUpdateToAccountId(self.target, 'createAvatarResp', [self.avId])
|
|
self.demand('Off')
|
|
|
|
class AvatarOperationFSM(OperationFSM):
|
|
POST_ACCOUNT_STATE = 'Off' # This needs to be overridden.
|
|
|
|
def enterRetrieveAccount(self):
|
|
# Query the account:
|
|
self.csm.air.dbInterface.queryObject(
|
|
self.csm.air.dbId, self.target, self.__handleRetrieve)
|
|
|
|
def __handleRetrieve(self, dclass, fields):
|
|
if dclass != self.csm.air.dclassesByName['AccountUD']:
|
|
self.demand('Kill', 'Your account object was not found in the database!')
|
|
return
|
|
|
|
self.account = fields
|
|
|
|
# For use in calling name requests:
|
|
self.accountID = self.account['ACCOUNT_ID']
|
|
|
|
self.avList = self.account['ACCOUNT_AV_SET']
|
|
# Sanitize:
|
|
self.avList = self.avList[:6]
|
|
self.avList += [0] * (6-len(self.avList))
|
|
|
|
self.demand(self.POST_ACCOUNT_STATE)
|
|
|
|
class GetAvatarsFSM(AvatarOperationFSM):
|
|
notify = directNotify.newCategory('GetAvatarsFSM')
|
|
POST_ACCOUNT_STATE = 'QueryAvatars'
|
|
|
|
def enterStart(self):
|
|
self.demand('RetrieveAccount')
|
|
self.nameStateData = None
|
|
|
|
def enterQueryAvatars(self):
|
|
self.pendingAvatars = set()
|
|
self.avatarFields = {}
|
|
for avId in self.avList:
|
|
if avId:
|
|
self.pendingAvatars.add(avId)
|
|
|
|
def response(dclass, fields, avId=avId):
|
|
if self.state != 'QueryAvatars':
|
|
return
|
|
if dclass != self.csm.air.dclassesByName['DistributedToonUD']:
|
|
self.demand('Kill', "One of the account's avatars is invalid!")
|
|
return
|
|
self.avatarFields[avId] = fields
|
|
self.pendingAvatars.remove(avId)
|
|
if not self.pendingAvatars:
|
|
self.demand('SendAvatars')
|
|
|
|
self.csm.air.dbInterface.queryObject(
|
|
self.csm.air.dbId,
|
|
avId,
|
|
response)
|
|
|
|
if not self.pendingAvatars:
|
|
self.demand('SendAvatars')
|
|
|
|
def enterSendAvatars(self):
|
|
potentialAvs = []
|
|
|
|
for avId, fields in self.avatarFields.items():
|
|
index = self.avList.index(avId)
|
|
wishNameState = fields.get('setWishNameState', [''])[0]
|
|
name = fields['setName'][0]
|
|
nameState = 0
|
|
|
|
if wishNameState == 'OPEN':
|
|
nameState = 1
|
|
elif wishNameState == 'PENDING':
|
|
if accountDBType == 'remote':
|
|
if self.nameStateData is None:
|
|
self.demand('QueryNameState')
|
|
return
|
|
actualNameState = self.nameStateData[str(avId)]
|
|
else:
|
|
actualNameState = self.csm.accountDB.getNameStatus(self.account['ACCOUNT_ID'])
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
avId,
|
|
self.csm.air.dclassesByName['DistributedToonUD'],
|
|
{'setWishNameState': [actualNameState]}
|
|
)
|
|
if actualNameState == 'PENDING':
|
|
nameState = 2
|
|
if actualNameState == 'APPROVED':
|
|
nameState = 3
|
|
name = fields['setWishName'][0]
|
|
elif actualNameState == 'REJECTED':
|
|
nameState = 4
|
|
elif wishNameState == 'APPROVED':
|
|
nameState = 3
|
|
elif wishNameState == 'REJECTED':
|
|
nameState = 4
|
|
|
|
potentialAvs.append([avId, name, fields['setDNAString'][0],
|
|
index, nameState])
|
|
|
|
self.csm.sendUpdateToAccountId(self.target, 'setAvatars', [self.account['CHAT_SETTINGS'], potentialAvs])
|
|
self.demand('Off')
|
|
|
|
def enterQueryNameState(self):
|
|
def gotStates(data):
|
|
self.nameStateData = data
|
|
taskMgr.doMethodLater(0, GetAvatarsFSM.demand, 'demand-QueryAvatars',
|
|
extraArgs=[self, 'QueryAvatars'])
|
|
|
|
self.csm.accountDB.getNameStatus(self.account['ACCOUNT_ID'], gotStates)
|
|
# We should've called the taskMgr action by now.
|
|
|
|
|
|
# This inherits from GetAvatarsFSM, because the delete operation ends in a
|
|
# setAvatars message being sent to the client.
|
|
class DeleteAvatarFSM(GetAvatarsFSM):
|
|
notify = directNotify.newCategory('DeleteAvatarFSM')
|
|
POST_ACCOUNT_STATE = 'ProcessDelete'
|
|
|
|
def enterStart(self, avId):
|
|
self.avId = avId
|
|
GetAvatarsFSM.enterStart(self)
|
|
|
|
def enterProcessDelete(self):
|
|
if self.avId not in self.avList:
|
|
self.demand('Kill', 'Tried to delete an avatar not in the account!')
|
|
return
|
|
|
|
index = self.avList.index(self.avId)
|
|
self.avList[index] = 0
|
|
|
|
avsDeleted = list(self.account.get('ACCOUNT_AV_SET_DEL', []))
|
|
avsDeleted.append([self.avId, int(time.time())])
|
|
|
|
estateId = self.account.get('ESTATE_ID', 0)
|
|
|
|
if estateId != 0:
|
|
# This assumes that the house already exists, but it shouldn't
|
|
# be a problem if it doesn't.
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
estateId,
|
|
self.csm.air.dclassesByName['DistributedEstateAI'],
|
|
{'setSlot%dToonId' % index: [0],
|
|
'setSlot%dGarden' % index: [[]]}
|
|
)
|
|
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
self.target,
|
|
self.csm.air.dclassesByName['AccountUD'],
|
|
{'ACCOUNT_AV_SET': self.avList,
|
|
'ACCOUNT_AV_SET_DEL': avsDeleted},
|
|
{'ACCOUNT_AV_SET': self.account['ACCOUNT_AV_SET'],
|
|
'ACCOUNT_AV_SET_DEL': self.account['ACCOUNT_AV_SET_DEL']},
|
|
self.__handleDelete)
|
|
self.csm.accountDB.removeNameRequest(self.avId)
|
|
|
|
def __handleDelete(self, fields):
|
|
if fields:
|
|
self.demand('Kill', 'Database failed to mark the avatar as deleted!')
|
|
return
|
|
|
|
self.csm.air.friendsManager.clearList(self.avId)
|
|
self.csm.air.writeServerEvent('avatarDeleted', self.avId, self.target)
|
|
self.demand('QueryAvatars')
|
|
|
|
class SetNameTypedFSM(AvatarOperationFSM):
|
|
notify = directNotify.newCategory('SetNameTypedFSM')
|
|
POST_ACCOUNT_STATE = 'RetrieveAvatar'
|
|
|
|
def enterStart(self, avId, name):
|
|
self.avId = avId
|
|
self.name = name
|
|
self.set_account_id = None
|
|
|
|
if self.avId:
|
|
self.demand('RetrieveAccount')
|
|
return
|
|
|
|
# Hmm, self.avId was 0. Okay, let's just cut to the judging:
|
|
self.demand('JudgeName')
|
|
|
|
def enterRetrieveAvatar(self):
|
|
if self.accountID:
|
|
self.set_account_id = self.accountID
|
|
if self.avId and self.avId not in self.avList:
|
|
self.demand('Kill', 'Tried to name an avatar not in the account!')
|
|
return
|
|
|
|
self.csm.air.dbInterface.queryObject(self.csm.air.dbId, self.avId,
|
|
self.__handleAvatar)
|
|
|
|
def __handleAvatar(self, dclass, fields):
|
|
if dclass != self.csm.air.dclassesByName['DistributedToonUD']:
|
|
self.demand('Kill', "One of the account's avatars is invalid!")
|
|
return
|
|
|
|
if fields['setWishNameState'][0] != 'OPEN':
|
|
self.demand('Kill', 'Avatar is not in a namable state!')
|
|
return
|
|
|
|
self.demand('JudgeName')
|
|
|
|
def enterJudgeName(self):
|
|
# Let's see if the name is valid:
|
|
status = judgeName(self.name)
|
|
|
|
if self.avId and status:
|
|
if self.csm.accountDB.addNameRequest(self.avId, self.name, accountID=self.set_account_id):
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
self.avId,
|
|
self.csm.air.dclassesByName['DistributedToonUD'],
|
|
{'setWishNameState': ('PENDING',),
|
|
'setWishName': (self.name,)})
|
|
else:
|
|
status = False
|
|
|
|
if self.avId:
|
|
self.csm.air.writeServerEvent('avatarWishname', self.avId, self.name)
|
|
|
|
self.csm.sendUpdateToAccountId(self.target, 'setNameTypedResp', [self.avId, status])
|
|
self.demand('Off')
|
|
|
|
class SetNamePatternFSM(AvatarOperationFSM):
|
|
notify = directNotify.newCategory('SetNamePatternFSM')
|
|
POST_ACCOUNT_STATE = 'RetrieveAvatar'
|
|
|
|
def enterStart(self, avId, pattern):
|
|
self.avId = avId
|
|
self.pattern = pattern
|
|
|
|
self.demand('RetrieveAccount')
|
|
|
|
def enterRetrieveAvatar(self):
|
|
if self.avId and self.avId not in self.avList:
|
|
self.demand('Kill', 'Tried to name an avatar not in the account!')
|
|
return
|
|
|
|
self.csm.air.dbInterface.queryObject(self.csm.air.dbId, self.avId,
|
|
self.__handleAvatar)
|
|
|
|
def __handleAvatar(self, dclass, fields):
|
|
if dclass != self.csm.air.dclassesByName['DistributedToonUD']:
|
|
self.demand('Kill', "One of the account's avatars is invalid!")
|
|
return
|
|
|
|
if fields['setWishNameState'][0] != 'OPEN':
|
|
self.demand('Kill', 'Avatar is not in a namable state!')
|
|
return
|
|
|
|
self.demand('SetName')
|
|
|
|
def enterSetName(self):
|
|
# Render the pattern into a string:
|
|
parts = []
|
|
for p, f in self.pattern:
|
|
part = self.csm.nameGenerator.nameDictionary.get(p, ('', ''))[1]
|
|
if f:
|
|
part = part[:1].upper() + part[1:]
|
|
else:
|
|
part = part.lower()
|
|
parts.append(part)
|
|
|
|
parts[2] += parts.pop(3) # Merge 2&3 (the last name) as there should be no space.
|
|
while '' in parts:
|
|
parts.remove('')
|
|
name = ' '.join(parts)
|
|
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
self.avId,
|
|
self.csm.air.dclassesByName['DistributedToonUD'],
|
|
{'setWishNameState': ('',),
|
|
'setWishName': ('',),
|
|
'setName': (name,)})
|
|
|
|
self.csm.air.writeServerEvent('avatarNamed', self.avId, name)
|
|
self.csm.sendUpdateToAccountId(self.target, 'setNamePatternResp', [self.avId, 1])
|
|
self.demand('Off')
|
|
|
|
class AcknowledgeNameFSM(AvatarOperationFSM):
|
|
notify = directNotify.newCategory('AcknowledgeNameFSM')
|
|
POST_ACCOUNT_STATE = 'GetTargetAvatar'
|
|
|
|
def enterStart(self, avId):
|
|
self.avId = avId
|
|
self.demand('RetrieveAccount')
|
|
|
|
def enterGetTargetAvatar(self):
|
|
# Make sure the target avatar is part of the account:
|
|
if self.avId not in self.avList:
|
|
self.demand('Kill', 'Tried to acknowledge name on an avatar not in the account!')
|
|
return
|
|
|
|
self.csm.air.dbInterface.queryObject(self.csm.air.dbId, self.avId,
|
|
self.__handleAvatar)
|
|
|
|
def __handleAvatar(self, dclass, fields):
|
|
if dclass != self.csm.air.dclassesByName['DistributedToonUD']:
|
|
self.demand('Kill', "One of the account's avatars is invalid!")
|
|
return
|
|
|
|
# Process the WishNameState change.
|
|
wishNameState = fields['setWishNameState'][0]
|
|
wishName = fields['setWishName'][0]
|
|
name = fields['setName'][0]
|
|
|
|
if wishNameState == 'APPROVED':
|
|
wishNameState = ''
|
|
name = wishName
|
|
wishName = ''
|
|
self.csm.accountDB.removeNameRequest(self.avId)
|
|
elif wishNameState == 'REJECTED':
|
|
wishNameState = 'OPEN'
|
|
wishName = ''
|
|
self.csm.accountDB.removeNameRequest(self.avId)
|
|
else:
|
|
self.demand('Kill', "Tried to acknowledge name on an avatar in %s state!" % wishNameState)
|
|
return
|
|
|
|
# Push the change back through:
|
|
self.csm.air.dbInterface.updateObject(
|
|
self.csm.air.dbId,
|
|
self.avId,
|
|
self.csm.air.dclassesByName['DistributedToonUD'],
|
|
{'setWishNameState': (wishNameState,),
|
|
'setWishName': (wishName,),
|
|
'setName': (name,)},
|
|
{'setWishNameState': fields['setWishNameState'],
|
|
'setWishName': fields['setWishName'],
|
|
'setName': fields['setName']})
|
|
|
|
self.csm.sendUpdateToAccountId(self.target, 'acknowledgeAvatarNameResp', [])
|
|
self.demand('Off')
|
|
|
|
class LoadAvatarFSM(AvatarOperationFSM):
|
|
notify = directNotify.newCategory('LoadAvatarFSM')
|
|
POST_ACCOUNT_STATE = 'GetTargetAvatar'
|
|
|
|
def enterStart(self, avId):
|
|
self.avId = avId
|
|
self.demand('RetrieveAccount')
|
|
|
|
def enterGetTargetAvatar(self):
|
|
# Make sure the target avatar is part of the account:
|
|
if self.avId not in self.avList:
|
|
self.demand('Kill', 'Tried to play an avatar not in the account!')
|
|
return
|
|
|
|
self.csm.air.dbInterface.queryObject(self.csm.air.dbId, self.avId,
|
|
self.__handleAvatar)
|
|
|
|
def __handleAvatar(self, dclass, fields):
|
|
if dclass != self.csm.air.dclassesByName['DistributedToonUD']:
|
|
self.demand('Kill', "One of the account's avatars is invalid!")
|
|
return
|
|
|
|
self.avatar = fields
|
|
self.demand('SetAvatar')
|
|
|
|
def enterSetAvatarTask(self, channel, task):
|
|
# Finally, grant ownership and shut down.
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
self.avId,
|
|
self.csm.air.ourChannel,
|
|
STATESERVER_OBJECT_SET_OWNER)
|
|
datagram.addChannel(self.target<<32 | self.avId)
|
|
self.csm.air.send(datagram)
|
|
|
|
# Tell the GlobalPartyManager as well:
|
|
self.csm.air.globalPartyMgr.avatarJoined(self.avId)
|
|
|
|
fields = self.avatar
|
|
fields.update({'setAdminAccess': [self.account.get('ACCESS_LEVEL', 100)]})
|
|
self.csm.air.friendsManager.addToonData(self.avId, fields)
|
|
|
|
self.csm.air.writeServerEvent('avatarChosen', self.avId, self.target)
|
|
self.demand('Off')
|
|
return task.done
|
|
|
|
def enterSetAvatar(self):
|
|
channel = self.csm.GetAccountConnectionChannel(self.target)
|
|
|
|
# First, give them a POSTREMOVE to unload the avatar, just in case they
|
|
# disconnect while we're working.
|
|
datagramCleanup = PyDatagram()
|
|
datagramCleanup.addServerHeader(
|
|
self.avId,
|
|
channel,
|
|
STATESERVER_OBJECT_DELETE_RAM)
|
|
datagramCleanup.addUint32(self.avId)
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
channel,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_ADD_POST_REMOVE)
|
|
datagram.addString(datagramCleanup.getMessage())
|
|
self.csm.air.send(datagram)
|
|
|
|
# Activate the avatar on the DBSS:
|
|
self.csm.air.sendActivate(
|
|
self.avId, 0, 0, self.csm.air.dclassesByName['DistributedToonUD'],
|
|
{'setAdminAccess': [self.account.get('ACCESS_LEVEL', 100)]})
|
|
|
|
# Next, add them to the avatar channel:
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
channel,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_OPEN_CHANNEL)
|
|
datagram.addChannel(self.csm.GetPuppetConnectionChannel(self.avId))
|
|
self.csm.air.send(datagram)
|
|
|
|
# Now set their sender channel to represent their account affiliation:
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
channel,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_SET_CLIENT_ID)
|
|
datagram.addChannel(self.target<<32 | self.avId)
|
|
self.csm.air.send(datagram)
|
|
|
|
# Eliminate race conditions.
|
|
taskMgr.doMethodLater(0.2, self.enterSetAvatarTask,
|
|
'avatarTask-%s' % self.avId, extraArgs=[channel],
|
|
appendTask=True)
|
|
|
|
class UnloadAvatarFSM(OperationFSM):
|
|
notify = directNotify.newCategory('UnloadAvatarFSM')
|
|
|
|
def enterStart(self, avId):
|
|
self.avId = avId
|
|
|
|
# We don't even need to query the account, we know the avatar is being played!
|
|
self.demand('UnloadAvatar')
|
|
|
|
def enterUnloadAvatar(self):
|
|
channel = self.csm.GetAccountConnectionChannel(self.target)
|
|
|
|
# Tell TTSFriendsManager somebody is logging off:
|
|
self.csm.air.friendsManager.toonOffline(self.avId)
|
|
|
|
# Clear off POSTREMOVE:
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
channel,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_CLEAR_POST_REMOVES)
|
|
self.csm.air.send(datagram)
|
|
|
|
# Remove avatar channel:
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
channel,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_CLOSE_CHANNEL)
|
|
datagram.addChannel(self.csm.GetPuppetConnectionChannel(self.avId))
|
|
self.csm.air.send(datagram)
|
|
|
|
# Reset sender channel:
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
channel,
|
|
self.csm.air.ourChannel,
|
|
CLIENTAGENT_SET_CLIENT_ID)
|
|
datagram.addChannel(self.target<<32)
|
|
self.csm.air.send(datagram)
|
|
|
|
# Unload avatar object:
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
self.avId,
|
|
channel,
|
|
STATESERVER_OBJECT_DELETE_RAM)
|
|
datagram.addUint32(self.avId)
|
|
self.csm.air.send(datagram)
|
|
|
|
# Done!
|
|
self.csm.air.writeServerEvent('avatarUnload', self.avId)
|
|
self.demand('Off')
|
|
|
|
# --- CLIENT SERVICES MANAGER UBERDOG ---
|
|
class ClientServicesManagerUD(DistributedObjectGlobalUD):
|
|
notify = directNotify.newCategory('ClientServicesManagerUD')
|
|
|
|
def announceGenerate(self):
|
|
DistributedObjectGlobalUD.announceGenerate(self)
|
|
|
|
# These keep track of the connection/account IDs currently undergoing an
|
|
# operation on the CSM. This is to prevent (hacked) clients from firing up more
|
|
# than one operation at a time, which could potentially lead to exploitation
|
|
# of race conditions.
|
|
self.connection2fsm = {}
|
|
self.account2fsm = {}
|
|
|
|
# For processing name patterns.
|
|
self.nameGenerator = NameGenerator()
|
|
|
|
# Temporary HMAC key:
|
|
self.key = 'c603c5833021ce79f734943f6e662250fd4ecf7432bf85905f71707dc4a9370c6ae15a8716302ead43810e5fba3cf0876bbbfce658e2767b88d916f5d89fd31'
|
|
|
|
# Instantiate our account DB interface:
|
|
if accountDBType == 'developer':
|
|
self.accountDB = DeveloperAccountDB(self)
|
|
elif accountDBType == 'remote':
|
|
self.accountDB = RemoteAccountDB(self)
|
|
else:
|
|
self.notify.error('Invalid accountdb-type: ' + accountDBType)
|
|
|
|
def killConnection(self, connId, reason):
|
|
datagram = PyDatagram()
|
|
datagram.addServerHeader(
|
|
connId,
|
|
self.air.ourChannel,
|
|
CLIENTAGENT_EJECT)
|
|
datagram.addUint16(101)
|
|
datagram.addString(reason)
|
|
self.air.send(datagram)
|
|
|
|
def killConnectionFSM(self, connId):
|
|
fsm = self.connection2fsm.get(connId)
|
|
|
|
if not fsm:
|
|
self.notify.warning('Tried to kill connection %d for duplicate FSM, but none exists!' % connId)
|
|
return
|
|
|
|
self.killConnection(connId, 'An operation is already underway: ' + fsm.name)
|
|
|
|
def killAccount(self, accountId, reason):
|
|
self.killConnection(self.GetAccountConnectionChannel(accountId), reason)
|
|
|
|
def killAccountFSM(self, accountId):
|
|
fsm = self.account2fsm.get(accountId)
|
|
if not fsm:
|
|
|
|
self.notify.warning('Tried to kill account %d for duplicate FSM, but none exists!' % accountId)
|
|
return
|
|
|
|
self.killAccount(accountId, 'An operation is already underway: ' + fsm.name)
|
|
|
|
def runAccountFSM(self, fsmtype, *args):
|
|
sender = self.air.getAccountIdFromSender()
|
|
|
|
if not sender:
|
|
self.killAccount(sender, 'Client is not logged in.')
|
|
|
|
if sender in self.account2fsm:
|
|
self.killAccountFSM(sender)
|
|
return
|
|
|
|
self.account2fsm[sender] = fsmtype(self, sender)
|
|
self.account2fsm[sender].request('Start', *args)
|
|
|
|
def login(self, cookie, authKey):
|
|
self.notify.debug('Received login cookie %r from %d' % (cookie, self.air.getMsgSender()))
|
|
|
|
sender = self.air.getMsgSender()
|
|
|
|
# Time to check this login to see if its authentic
|
|
digest_maker = hmac.new(self.key)
|
|
digest_maker.update(cookie)
|
|
serverKey = digest_maker.hexdigest()
|
|
if serverKey == authKey:
|
|
# This login is authentic!
|
|
pass
|
|
else:
|
|
# This login is not authentic.
|
|
self.killConnection(sender, ' ')
|
|
|
|
if sender >> 32:
|
|
self.killConnection(sender, 'Client is already logged in.')
|
|
return
|
|
|
|
if sender in self.connection2fsm:
|
|
self.killConnectionFSM(sender)
|
|
return
|
|
|
|
self.connection2fsm[sender] = LoginAccountFSM(self, sender)
|
|
self.connection2fsm[sender].request('Start', cookie)
|
|
|
|
def requestAvatars(self):
|
|
self.notify.debug('Received avatar list request from %d' % (self.air.getMsgSender()))
|
|
self.runAccountFSM(GetAvatarsFSM)
|
|
|
|
def createAvatar(self, dna, index):
|
|
self.runAccountFSM(CreateAvatarFSM, dna, index)
|
|
|
|
def deleteAvatar(self, avId):
|
|
self.runAccountFSM(DeleteAvatarFSM, avId)
|
|
|
|
def setNameTyped(self, avId, name):
|
|
self.runAccountFSM(SetNameTypedFSM, avId, name)
|
|
|
|
def setNamePattern(self, avId, p1, f1, p2, f2, p3, f3, p4, f4):
|
|
self.runAccountFSM(SetNamePatternFSM, avId, [(p1, f1), (p2, f2),
|
|
(p3, f3), (p4, f4)])
|
|
|
|
def acknowledgeAvatarName(self, avId):
|
|
self.runAccountFSM(AcknowledgeNameFSM, avId)
|
|
|
|
def chooseAvatar(self, avId):
|
|
currentAvId = self.air.getAvatarIdFromSender()
|
|
accountId = self.air.getAccountIdFromSender()
|
|
if currentAvId and avId:
|
|
self.killAccount(accountId, 'A Toon is already chosen!')
|
|
return
|
|
elif not currentAvId and not avId:
|
|
# This isn't really an error, the client is probably just making sure
|
|
# none of its Toons are active.
|
|
return
|
|
|
|
if avId:
|
|
self.runAccountFSM(LoadAvatarFSM, avId)
|
|
else:
|
|
self.runAccountFSM(UnloadAvatarFSM, currentAvId)
|