open-toontown/otpgo/lua/ToontownClient.lua

751 lines
23 KiB
Lua

package.path = package.path .. ";lua/?.lua"
local inspect = require('inspect')
local date = require('date')
-- Load Utils:
dofile("lua/Utils.lua")
-- Read vismap:
function readVismap()
local json = require("json")
local io = require("io")
-- TODO: Custom path.
f, err = io.open("config/vismap.json", "r")
assert(not err, err)
decoder = json.new_decoder(f)
result, err = decoder:decode()
f:close()
assert(not err, err)
return result
end
VISMAP = readVismap()
print("ToontownClient: Vismap successfully loaded.")
-- Read account bridge:
function readAccountBridge()
local json = require("json")
local io = require("io")
-- TODO: Custom path.
f, err = io.open("databases/accounts.json", "r")
if err then
print("ToontownClient: Returning empty table for account bridge")
return {}
end
decoder = json.new_decoder(f)
result, err = decoder:decode()
f:close()
assert(not err, err)
print("ToontownClient: Account bridge successfully loaded.")
return result
end
ACCOUNT_BRIDGE = readAccountBridge()
function saveAccountBridge()
local json = require("json")
local io = require("io")
-- TODO: Custom path.
f, err = io.open("databases/accounts.json", "w")
assert(not err, err)
encoder = json.new_encoder(f)
err = encoder:encode(ACCOUNT_BRIDGE)
assert(not err, err)
end
NAME_PATTERNS = {}
-- Read name patterns:
function readNamePatterns()
local io = require("io")
local f, err = io.open("../resources/phase_3/etc/NameMasterEnglish.txt")
assert(not err, err)
for line in f:lines() do
if string.starts(line, "#") then
goto continue
end
local parsed = {}
-- Match any character except "*"
for w in string.gmatch(line, "([^*]+)") do
table.insert(parsed, w)
end
if #parsed ~= 3 then
print(string.format("readNamePatterns: Invalid entry: %s", inspect(parsed)))
goto continue
end
table.insert(NAME_PATTERNS, parsed[3])
::continue::
end
print("ToontownClient: Name patterns successfully loaded.")
end
readNamePatterns()
-- Load message types:
dofile("lua/MsgTypes.lua")
-- Load Toon DNA helpers:
dofile("lua/ToonDNA.lua")
function receiveDatagram(client, dgi)
-- Client received datagrams
msgType = dgi:readUint16()
if msgType == CLIENT_HEARTBEAT then
client:handleHeartbeat()
elseif msgType == CLIENT_DISCONNECT then
client:handleDisconnect()
elseif msgType == CLIENT_LOGIN_TOONTOWN then
handleLoginToontown(client, dgi)
-- We have reached the only message types unauthenticated clients can use.
elseif not client:authenticated() then
client:sendDisconnect(CLIENT_DISCONNECT_GENERIC, "First datagram is not CLIENT_LOGIN_TOONTOWN", true)
elseif msgType == CLIENT_ADD_INTEREST then
handleAddInterest(client, dgi)
elseif msgType == CLIENT_REMOVE_INTEREST then
client:handleRemoveInterest(dgi)
elseif msgType == CLIENT_GET_AVATARS then
handleGetAvatars(client, false)
elseif msgType == CLIENT_OBJECT_UPDATE_FIELD then
client:handleUpdateField(dgi)
elseif msgType == CLIENT_CREATE_AVATAR then
handleCreateAvatar(client, dgi)
elseif msgType == CLIENT_SET_NAME_PATTERN then
handleSetNamePattern(client, dgi)
elseif msgType == CLIENT_SET_AVATAR then
handleSetAvatar(client, dgi)
elseif msgType == CLIENT_GET_FRIEND_LIST then
handleGetFriendList(client)
elseif msgType == CLIENT_OBJECT_LOCATION then
client:setLocation(dgi)
else
client:sendDisconnect(CLIENT_DISCONNECT_GENERIC, string.format("Unknown message type: %d", msgType), true)
end
if dgi:getRemainingSize() ~= 0 then
client:sendDisconnect(CLIENT_DISCONNECT_OVERSIZED_DATAGRAM, string.format("Datagram contains excess data.\n%s", tostring(dgi)), true)
end
end
function handleLoginToontown(client, dgi)
local playToken = dgi:readString()
local version = dgi:readString()
local hash = dgi:readUint32()
local tokenType = dgi:readInt32()
local wantMagicWords = dgi:readString()
if client:authenticated() then
client:sendDisconnect(CLIENT_DISCONNECT_RELOGIN, "Authenticated client tried to login twice!", true)
return
end
-- Check if version and hash matches
if version ~= SERVER_VERSION then
client:sendDisconnect(CLIENT_DISCONNECT_BAD_VERSION, string.format("Client version mismatch: client=%s, server=%s", version, SERVER_VERSION), true)
return
end
if hash ~= DC_HASH then
client:sendDisconnect(CLIENT_DISCONNECT_BAD_VERSION, string.format("Client DC hash mismatch: client=%d, server=%d", hash, DC_HASH), true)
return
end
-- TODO: Make these configurable.
local speedChatPlus = true
local openChat = true
local isPaid = true
local dislId = 1
local linkedToParent = false
local accountId = ACCOUNT_BRIDGE[playToken]
if accountId ~= nil then
-- Query the account object
client:getDatabaseValues(accountId, "Account", {"ACCOUNT_AV_SET", "CREATED", "LAST_LOGIN"}, function (doId, success, fields)
if not success then
client:sendDisconnect(CLIENT_DISCONNECT_ACCOUNT_ERROR, "The Account object was unable to be queried.", true)
return
end
-- Update LAST_LOGIN
fields.LAST_LOGIN = os.date("%a %b %d %H:%M:%S %Y")
client:setDatabaseValues(accountId, "Account", {
LAST_LOGIN = fields.LAST_LOGIN,
})
loginAccount(client, fields, accountId, playToken, openChat, isPaid, dislId, linkedToParent, speedChatPlus)
end)
else
-- Create a new account object
local account = {
-- The rest of the values are defined in the dc file.
CREATED = os.date("%a %b %d %H:%M:%S %Y"),
LAST_LOGIN = os.date("%a %b %d %H:%M:%S %Y"),
}
client:createDatabaseObject("Account", account, DATABASE_OBJECT_TYPE_ACCOUNT, function (accountId)
if accountId == 0 then
client:sendDisconnect(CLIENT_DISCONNECT_ACCOUNT_ERROR, "The Account object was unable to be created.", false)
return
end
-- Store the account into the bridge
ACCOUNT_BRIDGE[playToken] = accountId
saveAccountBridge()
account.ACCOUNT_AV_SET = {0, 0, 0, 0, 0, 0}
client:writeServerEvent("account-created", "ToontownClient", string.format("%d", accountId))
loginAccount(client, account, accountId, playToken, openChat, isPaid, dislId, linkedToParent, speedChatPlus)
end)
end
end
function loginAccount(client, account, accountId, playToken, openChat, isPaid, dislId, linkedToParent, speedChatPlus)
-- Eject other client if already logged in.
local ejectDg = datagram:new()
client:addServerHeaderWithAccountId(ejectDg, accountId, CLIENTAGENT_EJECT)
ejectDg:addUint16(100)
ejectDg:addString("You have been disconnected because someone else just logged in using your account on another computer.")
client:routeDatagram(ejectDg)
-- Subscribe to our puppet channel.
client:subscribePuppetChannel(accountId, 3)
-- Set our channel containing our account id
client:setChannel(accountId, 0)
client:authenticated(true)
-- Store the account id and avatar list into our client's user table:
local userTable = client:userTable()
userTable.accountId = accountId
userTable.avatars = account.ACCOUNT_AV_SET
userTable.playToken = playToken
userTable.isPaid = isPaid
userTable.speedChatPlus = speedChatPlus
userTable.openChat = openChat
client:userTable(userTable)
-- Log the event
client:writeServerEvent("account-login", "ToontownClient", string.format("%d", accountId))
-- Prepare the login response.
local resp = datagram:new()
resp:addUint16(CLIENT_LOGIN_TOONTOWN_RESP)
resp:addUint8(0) -- Return code
resp:addString("All Ok")
resp:addUint32(dislId) -- accountNumber
resp:addString(playToken) -- accountName
resp:addUint8(1) -- accountNameApproved
if openChat then
resp:addString('YES') -- openChatEnabled, does not seem to be used
else
resp:addString('NO') -- openChatEnabled, does not seem to be used
end
resp:addString('YES') -- createFriendsWithChat
resp:addString('YES') -- chatCodeCreationRule
resp:addUint32(os.time()) -- sec
resp:addUint32(os.clock()) -- usec
if isPaid then
resp:addString("FULL") -- access
else
resp:addString("VELVET") -- access
end
if speedChatPlus then
resp:addString("YES") -- WhiteListResponse
else
resp:addString("NO") -- WhiteListResponse
end
resp:addString(os.date("%Y-%m-%d %H:%M:%S")) -- lastLoggedInStr
resp:addInt32(math.floor(date.diff(account.LAST_LOGIN, account.CREATED):spandays())) -- accountDays
if linkedToParent then
resp:addString("WITH_PARENT_ACCOUNT") -- toonAccountType
else
resp:addString("NO_PARENT_ACCOUNT") -- toonAccountType
end
resp:addString(playToken) -- userName
-- Dispatch the response to the client.
client:sendDatagram(resp)
end
function handleAddInterest(client, dgi)
local handle = dgi:readUint16()
local context = dgi:readUint32()
local parent = dgi:readUint32()
local zones = {}
while dgi:getRemainingSize() > 0 do
local zone = dgi:readUint32()
if zone == 1 then
-- We don't want quiet zone.
goto continue
end
table.insert(zones, zone)
::continue::
end
-- Replace street zone with vismap if exists
if #zones == 1 then
if VISMAP[tostring(zones[1])] ~= nil then
zones = VISMAP[tostring(zones[1])]
elseif zones[1] >= 22000 and zones[1] < 61000 then
-- Handle Welcome Valley zones
local welcomeValleyZone = zones[1]
local hoodId = zones[1] - math.fmod(zones[1], 1000)
local offset = math.fmod(welcomeValleyZone, 2000)
-- Get original vismap
if VISMAP[tostring(offset + 2000)] ~= nil then
zones = table.shallow_copy(VISMAP[tostring(offset + 2000)])
for i, v in ipairs(zones) do
local offset = math.fmod(zones[i], 2000)
zones[i] = offset + hoodId
end
end
end
end
client:handleAddInterest(handle, context, parent, zones)
end
function handleGetAvatars(client, deletion)
local userTable = client:userTable()
local avatarList = userTable.avatars
local retreivedFields = {}
local expectingAvatars = {}
local gotAvatars = {}
local function gotAllAvatars()
dg = datagram:new()
local msgType = CLIENT_GET_AVATARS_RESP
if deletion then
msgType = CLIENT_DELETE_AVATAR_RESP
end
dg:addUint16(msgType)
dg:addUint8(0) -- returnCode
dg:addUint16(#gotAvatars) -- avatarTotal
for index, fields in pairs(retreivedFields) do
local wishNameState = fields.WishNameState[1]
local wishName = fields.WishName[1]
local aName = 0
local wantName = ""
local approvedName = ""
local rejectedName = ""
if wishNameState == "OPEN" then
aName = 1
end
if wishNameState == "PENDING" then
wantName = wishName
end
if wishNameState == "APPROVED" then
approvedName = wishName
end
if wishNameState == "REJECTED" then
rejectedName = wishName
end
dg:addUint32(fields.avatarId)
dg:addString(fields.setName[1]) -- name
dg:addString(wantName) -- wantName
dg:addString(approvedName) -- approvedName
dg:addString(rejectedName) -- rejectedName
dg:addString(fields.setDNAString[1]) -- avDNA
dg:addUint8(index - 1) -- avPosition
dg:addUint8(aName) -- aName
end
client:sendDatagram(dg)
end
for index, avatarId in ipairs(avatarList) do
if avatarId ~= 0 then
-- Query the avatar object
client:getDatabaseValues(avatarId, "DistributedToon", {"setName", "setDNAString", "WishNameState", "WishName"}, function (doId, success, fields)
if not success then
client:sendDisconnect(CLIENT_DISCONNECT_ACCOUNT_ERROR, string.format("The DistributedToon object %d was unable to be queried.", avatarId), false)
return
end
fields.avatarId = avatarId
retreivedFields[index] = fields
table.insert(gotAvatars, {})
if #gotAvatars == #expectingAvatars then
gotAllAvatars()
end
end)
table.insert(expectingAvatars, avatarId)
end
if index == 6 and #expectingAvatars == 0 then
-- We got nothing to do.
gotAllAvatars()
end
end
end
function handleCreateAvatar(client, reader)
local userTable = client:userTable()
local accountId = userTable.accountId
local contextId = reader:readUint16()
local dnaString = reader:readString()
local avPosition = reader:readUint8()
if avPosition > 6 then
-- Client sent an invalid av position
client:sendDisconnect(CLIENT_DISCONNECT_GENERIC, "Invalid Avatar index chosen.", true)
return
end
if userTable.avatars[avPosition + 1] ~= 0 then
-- This index isn't available.
client:sendDisconnect(CLIENT_DISCONNECT_ACCOUNT_ERROR, "The Avatar index chosen is not available.", true)
return
end
local result, dna = isValidNetString(dnaString)
if not result then
client:sendDisconnect(CLIENT_DISCONNECT_ACCOUNT_ERROR, "Invalid Avatar DNA sent.", true)
return
end
-- Create a new DistributedToon object
local avatar = {
-- The rest of the values are defined in the dc file.
setName = {NumToColor[dna[1]] .. " " .. AnimalToSpecies[dna[2]]},
setDISLid = {accountId},
setDNAString = {dnaString},
setPosIndex = {avPosition},
setAccountName = {userTable.playToken},
WishNameState = {"OPEN"},
}
client:createDatabaseObject("DistributedToon", avatar, DATABASE_OBJECT_TYPE_TOON, function (avatarId)
if avatarId == 0 then
client:sendDisconnect(CLIENT_DISCONNECT_ACCOUNT_ERROR, "The DistributedToon object was unable to be created.", false)
return
end
userTable.avatars[avPosition + 1] = avatarId
client:setDatabaseValues(accountId, "Account", {
ACCOUNT_AV_SET = userTable.avatars,
})
client:writeServerEvent("avatar-created", "ToontownClient", string.format("%d|%d", accountId, avatarId))
-- Prepare the create avatar response.
local resp = datagram:new()
resp:addUint16(CLIENT_CREATE_AVATAR_RESP)
resp:addUint16(contextId)
resp:addUint8(0) -- returnCode
resp:addUint32(avatarId)
-- Dispatch the response to the client.
client:sendDatagram(resp)
end)
end
function handleSetNamePattern(client, dgi)
local avId = dgi:readUint32()
client:getDatabaseValues(avId, "DistributedToon", {"WishNameState"}, function (doId, success, fields)
if not success then
client:sendDisconnect(CLIENT_DISCONNECT_ACCOUNT_ERROR, string.format("The DistributedToon object %d was unable to be queried.", avId), false)
return
end
if fields.WishNameState[1] ~= "OPEN" then
-- Only one name allowed!
client:sendDisconnect(CLIENT_DISCONNECT_ACCOUNT_ERROR, string.format("The DistributedToon object %d is unable to be named.", avId), true)
return
end
end)
local p1 = dgi:readInt16()
local f1 = dgi:readInt16()
local p2 = dgi:readInt16()
local f2 = dgi:readInt16()
local p3 = dgi:readInt16()
local f3 = dgi:readInt16()
local p4 = dgi:readInt16()
local f4 = dgi:readInt16()
-- Construct a pattern.
local pattern = {{p1, f1}, {p2, f2},
{p3, f3}, {p4, f4}}
local parts = {}
for _, pair in ipairs(pattern) do
local p, f = pair[1], pair[2]
local part = NAME_PATTERNS[p + 1]
if part == nil then
part = ""
end
if f then
string.upperFirst(part)
else
string.lower(part)
end
table.insert(parts, part)
end
-- Merge 3&4 (the last name) as there should be no space.
parts[3] = parts[3] .. table.remove(parts, 4)
for i = #parts, 1, -1 do
if parts[i] == "" then
table.remove(parts, i)
end
end
local name = table.concat(parts, " ")
client:setDatabaseValues(avId, "DistributedToon", {
WishNameState = {"LOCKED"},
WishName = {""},
setName = {name}
})
resp = datagram:new()
resp:addUint16(CLIENT_SET_NAME_PATTERN_ANSWER)
resp:addUint32(avId)
resp:addUint8(0)
client:sendDatagram(resp)
end
function checkIsAvInList(avatarId, avatarList)
local isAvInList = false
for _, avId in ipairs(avatarList) do
if avatarId == avId then
isAvInList = true
break
end
end
return isAvInList
end
function handleSetAvatar(client, dgi)
local userTable = client:userTable()
local accountId = userTable.accountId
local avatarId = dgi:readUint32()
if avatarId == 0 then
clearAvatar(client)
return
end
local isAvInList = checkIsAvInList(avatarId, userTable.avatars)
if not isAvInList then
client:sendDisconnect(CLIENT_DISCONNECT_GENERIC, string.format("Avatar %d not in list.", avatarId), true)
return
end
userTable.avatarId = avatarId
userTable.friendQueue = {}
client:userTable(userTable)
client:setChannel(accountId, avatarId)
local setAccess = 1
if userTable.isPaid then
setAccess = 2
end
client:sendActivateObject(avatarId, "DistributedToon", {
setAccess = {setAccess},
})
client:objectSetOwner(avatarId, true)
-- Let the UberDOG know about our avatar usage.
sendUsage(client, userTable.playToken, userTable.openChat, 0, avatarId, accountId, userTable.isPaid, false)
-- Let the UberDOG know about our avatar usage (going offline).
sendUsage(client, userTable.playToken, userTable.openChat, avatarId, 0, accountId, userTable.isPaid, true)
end
function sendUsage(client, playToken, openChat, priorAvatar, newAvatar, accountId, isPaid, postRemove)
-- Log avatar usage
local playerName = playToken
local playerNameApproved = 1
local openChatEnabled = "NO"
if openChat then
openChatEnabled = "YES"
end
local createFriendsWithChat = "YES"
local chatCodeCreation = "YES"
local avatarId
if priorAvatar ~= 0 then
avatarId = priorAvatar
else
avatarId = newAvatar
end
local dg = datagram:new()
dg:addServerHeader(CHANNEL_PUPPET_ACTION, avatarId, ACCOUNT_AVATAR_USAGE)
dg:addUint32(priorAvatar) -- priorAvatar
dg:addUint32(newAvatar) -- newAvatar
dg:addUint16(0) -- newAvatarType
dg:addUint32(accountId) -- accountId
dg:addString(openChatEnabled) -- openChatEnabled
dg:addString(createFriendsWithChat) -- createFriendsWithChat
dg:addString(chatCodeCreation) -- chatCodeCreation
if isPaid then
dg:addString("FULL") -- piratesAccess
else
dg:addString("VELVET") -- piratesAccess
end
dg:addInt32(0) -- familyAccountId
dg:addInt32(accountId) -- playerAccountId
dg:addString(playerName) -- playerName
dg:addInt8(playerNameApproved) -- playerNameApproved
-- maxAvatars
local maxAvatars
if isPaid then
maxAvatars = 6
else
maxAvatars = 1
end
dg:addInt32(maxAvatars) -- maxAvatars
dg:addInt16(0) -- numFamilyMembers
if postRemove then
client:addPostRemove(dg)
else
client:routeDatagram(dg)
end
end
function clearAvatar(client)
local userTable = client:userTable()
if userTable.avatarId == nil then
return
end
client:removeSessionObject(userTable.avatarId)
client:unsubscribePuppetChannel(userTable.avatarId, 1)
dg = datagram:new()
client:addServerHeader(dg, userTable.avatarId, STATESERVER_OBJECT_DELETE_RAM)
dg:addUint32(userTable.avatarId)
client:routeDatagram(dg)
client:clearPostRemoves()
userTable.avatarId = nil
userTable.friendsList = nil
client:userTable(userTable)
client:setChannel(userTable.accountId, 0)
end
function handleAddOwnership(client, doId, parent, zone, dc, dgi)
local userTable = client:userTable()
local accountId = userTable.accountId
local avatarId = userTable.avatarId
if doId ~= avatarId then
client:warn(string.format("Got AddOwnership for object %d, our avatarId is %d", doId, avatarId))
return
end
client:addSessionObject(doId)
client:subscribePuppetChannel(avatarId, 1)
-- Store name for SpeedChat Plus
local name = dgi:readString()
userTable.avatarName = name
client:userTable(userTable)
local remainder = dgi:readRemainder()
client:writeServerEvent("selected-avatar", "ToontownClient", string.format("%d|%d", accountId, avatarId))
local resp = datagram:new()
resp:addUint16(CLIENT_GET_AVATAR_DETAILS_RESP)
resp:addUint32(doId) -- avatarId
resp:addUint8(0) -- returnCode
resp:addString(name) -- setName
resp:addData(remainder)
client:sendDatagram(resp)
-- Update common chat flags:
local dg = datagram:new()
dg:addServerHeader(avatarId, avatarId, STATESERVER_OBJECT_UPDATE_FIELD)
dg:addUint32(avatarId)
client:packFieldToDatagram(dg, "DistributedToon", "setCommonChatFlags", {0}, true)
client:routeDatagram(dg)
-- Update whitelist chat flags:
local dg = datagram:new()
dg:addServerHeader(avatarId, avatarId, STATESERVER_OBJECT_UPDATE_FIELD)
dg:addUint32(avatarId)
if userTable.speedChatPlus then
client:packFieldToDatagram(dg, "DistributedToon", "setWhitelistChatFlags", {1}, true)
else
client:packFieldToDatagram(dg, "DistributedToon", "setWhitelistChatFlags", {0}, true)
end
client:routeDatagram(dg)
end
function handleGetFriendList(client)
-- TODO: Friends
local resp = datagram:new()
resp:addUint16(CLIENT_GET_FRIEND_LIST_RESP)
resp:addUint8(0) -- errorCode
resp:addUint16(0) -- count
client:sendDatagram(resp)
end