local textplus = require("textplus")

local lineguide = require("lineguide")

local battlePlayer
local onlinePlay,onlinePlayPlayers

local onlinePlayNPC = {}


onlinePlayNPC.SYNCHRONISATION = {
    FULL = 0,
    IGNORED = 1,
    NO_UPDATES = 2,
}


onlinePlayNPC.uidCounter = 0
onlinePlayNPC.uidPredictiveCounter = 0

onlinePlayNPC.spawnUIDCounter = 0


onlinePlayNPC.onlineHandlingConfig = {}


onlinePlayNPC.npcUpdateFrequency = 1/10 -- how frequently to send NPC updates
onlinePlayNPC.npcPredictionWaitTime = 1 -- number of seconds to wait before giving up on a prediction
onlinePlayNPC.npcAliveCheckTime = 1.5 -- how long an NPC must not be updated for in order for an alive check to be sent
onlinePlayNPC.stealWaitTime = 1 -- how long to wait before requesting to steal an NPC again
onlinePlayNPC.fairCollectionTimeout = 0.4 -- how long to wait before giving up on fair collection claims


-- Player projectiles
onlinePlayNPC.playerProjectileMap = table.map{13,265,171,291,292,266}

-- Player projectiles that die upon hitting another player
onlinePlayNPC.weakPlayerProjectileMap = table.map{13,265,108,291}

-- 1.3 NPCs that are transformed by jumping on them (e.g. koopas) without calling onNPCHarm
onlinePlayNPC.stompTransformMap = table.map{
    3, -- SMB3 red paragoomba
    4,6, -- SMB3 koopas
    23, -- buzzy beetle
    72, -- giant SMB3 koopa
    76, -- SMB3 green paratroopa
    109,110,111,112, -- SMW koopas
    121,122,123,124, -- SMW paratroopas
    136, -- bob-omb
    161, -- SMB3 red paratroopa
    162, -- rex
    165,167, -- galoomba
    173,175, -- SMB koopas
    176,177, -- SMB paratroopas
    243, -- SML2 paragoomba
    244, -- SMB3 paragoomba
}


local npcsWaitingForResponse = {}
local npcOnlineUIDMap = {}

local npcUpdateCommand,npcSeenCommand,npcUnseenCommand
local npcDespawnCommand,npcRemoveOwnerCommand,npcHarmCommand,npcKillCommand
local npcCollectCommand,npcFairCollectionClaimCommand
local npcClaimCommand,npcGrantCommand,npcStealCommand
local npcSpawnCommand
local npcCheckAliveCommand
local customCommand

local handleCustomCommand

local npcJoinPacket

local validImportanceValues = table.map{0,1,2}


function onlinePlayNPC.getUIDFromNPC(v)
    if v == nil then
        return nil
    end

    local data = onlinePlayNPC.getData(v)

    if data.onlineUID >= 0 then
        return data.onlineUID
    else
        return nil
    end
end

function onlinePlayNPC.getNPCFromUID(onlineUID)
    if onlineUID == nil or onlineUID < 0 then
        return nil
    end

    local v = npcOnlineUIDMap[onlineUID]

    if v ~= nil then
        if v.isValid then
            return v
        else
            npcOnlineUIDMap[onlineUID] = nil
        end
    end
end


function onlinePlayNPC.getOwner(v)
    local data = onlinePlayNPC.getData(v)

    return data.ownerIdx
end

function onlinePlayNPC.getSynchronisationType(v)
    local data = onlinePlayNPC.getData(v)

    return data.synchronisationType
end

function onlinePlayNPC.ownsNPC(v)
    if onlinePlay.currentMode == onlinePlay.MODE_OFFLINE then
        return true
    end

    local data = onlinePlayNPC.getData(v)

    return (data.ownerIdx == onlinePlay.playerIdx)
end


local function setNPCUID(v,data,newUID)
    if data.onlineUID >= 0 then
        npcOnlineUIDMap[data.onlineUID] = nil
    end

    if newUID >= 0 then
        if npcOnlineUIDMap[newUID] ~= nil then
            v.spawnId = 0
            v:kill(HARM_TYPE_VANISH)

            return
        end

        npcOnlineUIDMap[newUID] = v
    end

    data.onlineUID = newUID
end

local function assignNPCUID(v,data)
    setNPCUID(v,data,onlinePlayNPC.uidCounter)

    onlinePlayNPC.uidCounter = onlinePlayNPC.uidCounter + 1
end

local function setNPCOwner(v,data,ownerIdx)
    data.ownerIdx = ownerIdx
    
    data.predictedOwnerIdx = 0
    data.predictedOnlineUID = 0
    data.predictionTime = 0

    data.lastUpdatedTime = nil
    data.lastSeenTime = nil
    data.stealRequestTime = nil
    data.sentDespawnRequest = false
    data.hasSentForcedStealRequest = false

    data.lastUpdateTick = 0
end


onlinePlayNPC.npcIdentifierType = "sint32"

onlinePlayNPC.npcUIDClientStart = 2^30
onlinePlayNPC.npcUIDClientGap = math.floor((2^30)/10)


onlinePlayNPC.npcSendProperties = {
    {
        function(v)
            return onlinePlay.encodeValue("uint16",v.id)
        end,
        function(v,str,start)
            local newID

            newID,start = onlinePlay.decodeValue("uint16",str,start)

            if newID ~= v.id then
                v:transform(newID,true,false)
            end

            return start
        end,
    },

    {"sint32","x"},
    {"sint32","y"},
    {"sint16","width",32},
    {"sint16","height",32},

    {"uint16","spawnId"},
    {"sint32","spawnX"},
    {"sint32","spawnY"},
    {"sint16","spawnWidth",32},
    {"sint16","spawnHeight",32},

    {"sint8","direction",-1},
    {"sint8","spawnDirection",-1},

    {"number","speedX",0},
    {"number","speedY",0},

    {"string","layerName","Default"},
    {"boolean","isHidden",false},
    {"string","attachedLayerName",""},
    {"string","activateEventName",""},
    {"string","deathEventName",""},
    {"string","talkEventName",""},
    {"string","noMoreObjInLayer",""},
    {"string","msg",""},

    {"number","spawnSpeedX",0},
    {"number","spawnSpeedY",0},

    {"boolean","legacyBoss",false},
    {"boolean","friendly",false},
    {"boolean","dontMove",false},
    {"boolean","noblockcollision",false},
    {"string","collisionGroup",""},

    {"number","spawnAi1",0},
    {"number","spawnAi2",0},
    {"number","ai1",0},
    {"number","ai2",0},
    {"number","ai3",0},
    {"number","ai4",0},
    {"number","ai5",0},
    {"number","ai6",0},

    {"number","animationFrame",0},
    {"number","animationTimer",0},
    
    {"uint8","section",0},

    {"boolean",0x124,FIELD_BOOL,true},
    {"uint16",0x06,FIELD_WORD,0},
    {
        function(v)
            return ""
        end,
        function(v,str,start)
            -- Set despawn timer
            if v:mem(0x124,FIELD_BOOL) then
                v.despawnTimer = 100
            else
                v.despawnTimer = -1
            end

            return start
        end,
    },

    --{"sint8",0x12C,FIELD_WORD,0}, -- holding player index
    {"sint16",0x12E,FIELD_WORD,0}, -- don't hurt player timer
    {"sint8",0x130,FIELD_WORD,0}, -- don't hurt player index
    {"sint8",0x132,FIELD_WORD,0}, -- "battle owner" player index
    {"boolean",0x136,FIELD_BOOL,false}, -- projectile flag

    {"uint16",0x138,FIELD_WORD,0}, -- forced state
    {"number",0x13C,FIELD_DFLOAT,0}, -- forced state counter 1
    {"sint16",0x144,FIELD_WORD,0}, -- forced state counter 2

    {"number",0x148,FIELD_FLOAT,0}, -- boss hit count

    -- Lineguide data syncing
    {
        function(v)
            local lineguideData = v.data._basegame.lineguide

            if lineguideData == nil then
                return onlinePlay.encodeValue("boolean",false)
            end

            return onlinePlay.encodeValue("boolean",true).. onlinePlay.encodeObject(lineguideData,onlinePlayNPC.npcLineguideSendProperties)
        end,
        function(v,str,start)
            -- Does this NPC actually have lineguide data?
            local hasLineguideData
            hasLineguideData,start = onlinePlay.decodeValue("boolean",str,start)

            if not hasLineguideData then
                return start
            end

            -- There is lineguide data, so decode it
            if v.data._basegame.lineguide == nil then
                lineguide.onStartNPC(v)
            end

            start = onlinePlay.decodeObject(v.data._basegame.lineguide,str,start,onlinePlayNPC.npcLineguideSendProperties)
            return start
        end,
    },

    -- Extra settings syncing
    {
        function(v)
            return onlinePlayNPC.encodeSettings(v.id,v.data._settings)
        end,
        function(v,str,start)
            v.data._settings,start = onlinePlayNPC.decodeSettings(v.id,str,start)
            return start
        end,
    },

    -- Extra sync data from config
    {
        function(v)
            local handlingConfig = onlinePlayNPC.onlineHandlingConfig[v.id]

            if handlingConfig ~= nil and handlingConfig.getExtraData ~= nil then
                return onlinePlay.encodeValue("any",handlingConfig.getExtraData(v))
            end

            return onlinePlay.encodeValue("any",nil)
        end,
        function(v,str,start)
            local value,start = onlinePlay.decodeValue("any",str,start)
            if value == nil then
                return start
            end

            local handlingConfig = onlinePlayNPC.onlineHandlingConfig[v.id]

            if handlingConfig ~= nil and handlingConfig.setExtraData ~= nil then
                handlingConfig.setExtraData(v,value)
            end

            return start
        end,
    },
}

onlinePlayNPC.npcLineguideSendProperties = {
    -- Config settings
    {"number","lineSpeed",2},
	{"number","jumpSpeed",4},
	{"boolean","useHiddenLines",false},
	{"boolean","fallWhenInactive",false},
	{"boolean","activeByDefault",true},
	{"boolean","activateOnStanding",false},
	{"boolean","activateNearby",false},
	{"number","sensorAlignH",0.5},
	{"number","sensorAlignV",0.5},
	{"number","sensorOffsetX",0},
	{"number","sensorOffsetY",0},
	{"boolean","extendedDespawnTimer",false},
	{"boolean","buoyant",false},
    {"boolean","activeByDefault",true},

    -- State
    {"uint8","state",0},
    {"boolean","active",true},
    {"boolean","reversed",false},
    {"boolean","despawned",false},
    {"number","attachCooldown",0},

    {"any","lastLineSpeed",nil},
    {"any","angle",nil},
    {"any","omega",nil},
    {"any","bgoTimer",nil},

    {"any","adjustmentX",nil},
    {"any","adjustmentY",nil},
    {"any","storedSpeedX",nil},
    {"any","storedSpeedY",nil},
    {"any","velocity",nil},
    {"any","dir",nil},

    {"any","startDir",nil},
    {"any","destDir",nil},

    {
        function(lineguideData)
            local lineData = lineguideData.lineData
            if lineData == nil then
                return onlinePlay.encodeValue("sint16",-1)
            end

            -- There is... a shocking amount of data for this!
            local output = onlinePlay.encodeValue("sint16",lineData.line.idx)
            output = output.. onlinePlay.encodeValue("sint32",lineData.start.x)
            output = output.. onlinePlay.encodeValue("sint32",lineData.start.y)
            output = output.. onlinePlay.encodeValue("sint32",lineData.dest.x)
            output = output.. onlinePlay.encodeValue("sint32",lineData.dest.y)
            output = output.. onlinePlay.encodeValue("uint8",lineData.startDir)
            output = output.. onlinePlay.encodeValue("uint8",lineData.destDir)
            output = output.. onlinePlay.encodeValue("boolean",lineData.swapped)

            return output
        end,
        function(lineguideData,str,start)
            local bgoIndex
            bgoIndex,start = onlinePlay.decodeValue("sint16",str,start)

            if bgoIndex < 0 then
                lineguideData.lineData = nil
                return start
            end

            local lineData = {
                line = BGO(bgoIndex),
                start = Colliders.Point(0,0),
                dest = Colliders.Point(0,0),
            }

            lineData.start.x,start = onlinePlay.decodeValue("sint32",str,start)
            lineData.start.y,start = onlinePlay.decodeValue("sint32",str,start)
            lineData.dest.x,start = onlinePlay.decodeValue("sint32",str,start)
            lineData.dest.y,start = onlinePlay.decodeValue("sint32",str,start)
            lineData.startDir,start = onlinePlay.decodeValue("uint8",str,start)
            lineData.destDir,start = onlinePlay.decodeValue("uint8",str,start)
            lineData.swapped,start = onlinePlay.decodeValue("boolean",str,start)

            --lineguideData.start = lineguideData.start or lineData.start
            --lineguideData.dest = lineguideData.dest or lineData.dest
            --lineguideData.startDir = lineData.startDir
            --lineguideData.destDir = lineData.destDir
            lineguideData.lineData = lineData

            return start
        end,
    },
    {
        function(lineguideData)
            if lineguideData.start == nil or lineguideData.dest == nil then
                return onlinePlay.encodeValue("boolean",false)
            end

            local output = onlinePlay.encodeValue("boolean",true)

            output = output.. onlinePlay.encodeValue("sint32",lineguideData.start.x)
            output = output.. onlinePlay.encodeValue("sint32",lineguideData.start.y)
            output = output.. onlinePlay.encodeValue("sint32",lineguideData.dest.x)
            output = output.. onlinePlay.encodeValue("sint32",lineguideData.dest.y)

            return output
        end,
        function(lineguideData,str,start)
            local hasPoints
            hasPoints,start = onlinePlay.decodeValue("boolean",str,start)

            if not hasPoints then
                lineguideData.start = nil
                lineguideData.dest = nil
                return start
            end

            local startX,startY,destX,destY

            startX,start = onlinePlay.decodeValue("sint32",str,start)
            startY,start = onlinePlay.decodeValue("sint32",str,start)
            destX,start = onlinePlay.decodeValue("sint32",str,start)
            destY,start = onlinePlay.decodeValue("sint32",str,start)

            lineguideData.start = Colliders.Point(startX,startY)
            lineguideData.dest = Colliders.Point(destX,destY)

            return start
        end,
    },
    {
        function(lineguideData)
            if lineguideData.lastBGO ~= nil then
                return onlinePlay.encodeValue("sint16",lineguideData.lastBGO.idx)
            else
                return onlinePlay.encodeValue("sint16",-1)
            end
        end,
        function(lineguideData,str,start)
            local bgoIndex
            bgoIndex,start = onlinePlay.decodeValue("sint16",str,start)

            if bgoIndex >= 0 then
                lineguideData.lastBGO = BGO(bgoIndex)
            else
                lineguideData.lastBGO = nil
            end

            return start
        end,
    },
}


function onlinePlayNPC.getData(v)
    local data = v.data._onlinePlay

    if data == nil then
        data = {}
        v.data._onlinePlay = data

        data.fromSpawn = false

        data.ownerIdx = 0
        data.onlineUID = -1

        data.lastUpdateTick = 0

        data.synchronisationType = onlinePlayNPC.SYNCHRONISATION.FULL

        data.seenByUserMap = {}
        data.onScreen = false -- this is very much client-side!
        data.seenByAnyone = false
        data.seenByAnyoneElse = false

        data.isDoingFairCollection = false
        data.fairCollectionTimeMap = {}

        data.predictedOnlineUID = -1
        data.predictedOwnerIdx = 0
        data.predictionTime = 0

        data.spawnForcedState = v:mem(0x138,FIELD_WORD)
        data.spawnBattleOwner = v:mem(0x132,FIELD_WORD)
        data.spawnTime = onlinePlay.syncedTime
        data.spawnLayerName = v.layerName
        data.spawnID = v.id
        data.spawnX = v.x + v.width*0.5
        data.spawnY = v.y + v.height*0.5
        data.spawnSection = v.section


        data.isDead = false

        data.lastUpdatedTime = nil
        data.lastSeenTime = nil
        data.sentDespawnRequest = false
        data.sentAliveCheck = false
        data.hasSentForcedStealRequest = false

        data.allowedToHarm = false
        data.allowedToDie = false
        data.forceHarmClaim = false
        data.forceKillClaim = false
        data.allowedToCollectPlayer = nil

        data.handlingConfig = nil

        data.justUpdated = false -- for debug purposes

        
        -- Call initialisation function
        local handlingConfig = onlinePlayNPC.getConfig(v)

        if handlingConfig ~= nil then
            if handlingConfig.initFunc ~= nil then
                handlingConfig.initFunc(v)
            end

            if handlingConfig.synchronisationType ~= nil then
                data.synchronisationType = handlingConfig.synchronisationType
            end
        end
    end

    return data
end


local function npcIsBlacklisted(v)
    local data = onlinePlayNPC.getData(v)

    -- Already has an owner
    if data.ownerIdx > 0 then
        return false
    end

    -- In a state where it shouldn't be blacklisted
    if not data.fromSpawn or v:mem(0x12C,FIELD_WORD) > 0 or v:mem(0x138,FIELD_WORD) > 0 or v:mem(0x136,FIELD_BOOL) then
        return false
    end

    -- Blacklisted by config
    local handlingConfig = onlinePlayNPC.getConfig(v)

    if handlingConfig ~= nil and handlingConfig.blacklisted then
        return true
    end

    -- Coins and vines
    local config = NPC.config[v.id]

    if (config.iscoin and v.ai1 == 0) or config.isvine then
        return true
    end

    return false
end

local function playerCanBeOwner(index)
    return (onlinePlay.getUserByPlayer(index) ~= nil)
end

local function findSuitablePlayer(v)
    local handlingConfig = onlinePlayNPC.getConfig(v)

    if handlingConfig ~= nil and handlingConfig.findSuitableOwnerFunc ~= nil then
        local playerIdx = handlingConfig.findSuitableOwnerFunc(v)

        if playerIdx ~= nil and playerCanBeOwner(playerIdx) then
            return playerIdx
        end
    end

    -- Holding player
    local holderIdx = v:mem(0x12C,FIELD_WORD)

    if holderIdx > 0 and playerCanBeOwner(holderIdx) then
        return holderIdx
    end

    -- Battle owner value
    local battleOwnerIdx = v:mem(0x132,FIELD_WORD)

    if battleOwnerIdx > 0 and playerCanBeOwner(battleOwnerIdx) then
        return battleOwnerIdx
    end

    -- Don't hurt value
    if v:mem(0x12E,FIELD_WORD) > 0 then
        local dontHurtIdx = v:mem(0x130,FIELD_WORD)

        if dontHurtIdx > 0 and playerCanBeOwner(dontHurtIdx) then
            return dontHurtIdx
        end
    end


    -- Use closest player
    local closestDistance = math.huge
    local closestIndex

    for _,p in ipairs(Player.get()) do
        if playerCanBeOwner(p.idx) then
            local distanceX = (v.x + v.width *0.5) - (p.x + p.width *0.5)
            local distanceY = (v.y + v.height*0.5) - (p.y + p.height*0.5)
            local distanceExtra = p.idx*27.13820 -- slight bias for earlier players

            local squareDistance = distanceX*distanceX + distanceY*distanceY + distanceExtra*distanceExtra

            if squareDistance < closestDistance then
                closestDistance = squareDistance
                closestIndex = p.idx
            end
        end
    end

    if closestIndex ~= nil then
        return closestIndex
    end

    -- Well, this should never really run, but just in case.
    return onlinePlay.playerIdx
end


local function distanceFromPlayerToNPC(v,p)
    local distX = (v.x + v.width*0.5) - (p.x + p.width*0.5)
    local distY = (v.y + v.height*0.5) - (p.y + p.height*0.5)

    return math.sqrt(distX*distX + distY*distY)
end

local function shouldStealNPCOwnership(v,data)
    -- No owner/we already own it
    if data.ownerIdx <= 0 or data.ownerIdx == onlinePlay.playerIdx then
        return false
    end

    -- Owner disconnected
    if onlinePlay.currentMode == onlinePlay.MODE_HOST and not playerCanBeOwner(data.ownerIdx) then
        return true
    end

    -- Handling config function
    local handlingConfig = onlinePlayNPC.getConfig(v)

    if handlingConfig ~= nil and handlingConfig.shouldStealFunc ~= nil then
        local result = handlingConfig.shouldStealFunc(v)

        if result ~= nil then
            return result
        end
    end

    -- Don't steal if another player should take it
    if v:mem(0x12C,FIELD_WORD) ~= 0 or v:mem(0x132,FIELD_WORD) ~= 0 then
        return false
    end

    -- Thrown/holding it
    if v:mem(0x12C,FIELD_WORD) == onlinePlay.playerIdx or v:mem(0x132,FIELD_WORD) == onlinePlay.playerIdx then
        return true
    end

    -- In Yoshi's mouth/on his tongue
    if (v:mem(0x138,FIELD_WORD) == 5 or v:mem(0x138,FIELD_WORD) == 6) and v:mem(0x13C,FIELD_DFLOAT) == onlinePlay.playerIdx then
        return true
    end

    -- Standing on it
    local ownPlayer = Player(onlinePlay.playerIdx)

    if ownPlayer:mem(0x176,FIELD_WORD) == (v.idx + 1) then
        -- No other players and standing on it
        for _,otherPlayer in ipairs(Player.get()) do
            if otherPlayer.idx ~= onlinePlay.playerIdx and playerCanBeOwner(otherPlayer.idx) and otherPlayer:mem(0x176,FIELD_WORD) == (v.idx + 1) then
                return false
            end
        end

        return true
    end

    -- Close to it
    if distanceFromPlayerToNPC(v,ownPlayer) < 128 then
        -- No other players are nearby
        for _,otherPlayer in ipairs(Player.get()) do
            if otherPlayer.idx ~= onlinePlay.playerIdx and playerCanBeOwner(otherPlayer.idx) and distanceFromPlayerToNPC(v,otherPlayer) < 256 then
                return false
            end
        end

        return true
    end

    return false
end


local function npcIsOnCamera(v) -- client-side
    if v.isGenerator or v:mem(0x124,FIELD_BOOL) then
        return battleCamera.isOnScreen(v.x,v.y,v.width,v.height)
    else
        return battleCamera.isOnScreen(v.spawnX,v.spawnY,v.spawnWidth,v.spawnHeight)
    end
end

local function npcIsSeenByAnyone(v)
    local data = onlinePlayNPC.getData(v)

    for _,user in ipairs(onlinePlay.getUsers()) do
        if data.seenByUserMap[user.playerIdx] then
            return true
        end
    end

    return false
end

local function npcIsSeenByAnotherUser(v)
    local data = onlinePlayNPC.getData(v)

    for _,user in ipairs(onlinePlay.getUsers()) do
        if data.seenByUserMap[user.playerIdx] and user.playerIdx ~= onlinePlay.playerIdx then
            return true
        end
    end

    return false
end

local function hasCollectionTimesFromEveryone(v)
    local data = onlinePlayNPC.getData(v)

    for _,user in ipairs(onlinePlay.getUsers()) do
        if data.fairCollectionTimeMap[user.playerIdx] == nil then
            return false
        end
    end

    return true
end

local function grantFairCollection(v)
    local data = onlinePlayNPC.getData(v)

    -- Find the player who's given the earlier collection time
    local earliestPlayerIdx,earliestTime

    for _,user in ipairs(onlinePlay.getUsers()) do
        local collectionTime = data.fairCollectionTimeMap[user.playerIdx]

        if collectionTime ~= nil and collectionTime >= 0 and (earliestTime == nil or collectionTime < earliestTime) then
            earliestPlayerIdx = user.playerIdx
            earliestTime = collectionTime
        end
    end

    -- Let that player collect the NPC
    if earliestPlayerIdx ~= nil then
        -- Tell everyone else who got it
        local npcProperties
        if data.synchronisationType ~= onlinePlayNPC.SYNCHRONISATION.NO_UPDATES then
            npcProperties = onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties)
        end

        npcCollectCommand:send(0, data.onlineUID,earliestPlayerIdx,npcProperties)

        -- Collect it on our end
        data.allowedToCollectPlayer = earliestPlayerIdx
        v:collect(Player(earliestPlayerIdx))
    end

    data.fairCollectionTimeMap = {}
    data.isDoingFairCollection = false
end

local function npcIsActive(v)
    return (v:mem(0x124,FIELD_BOOL) or v.spawnId <= 0)
end

local function destroyNPC(v)
    local data = onlinePlayNPC.getData(v)

    data.allowedToDie = true

    -- Get it out of here
    local b = v.sectionObj.boundary

    v.x = b.left - 1024
    v.y = b.top - 1024

    -- Despawn it
    v:mem(0x124,FIELD_BOOL,false)
    v.despawnTimer = -1

    -- Set stuff up so that nothing really happens when it dies
    v.deathEventName = ""
    v.isGenerator = false
    v.id = 58

    v.spawnId = 0
    
    v.animationFrame = -999
    v.animationTimer = -999

    v:kill(HARM_TYPE_VANISH)
end


local function constructNPCClaimProperties(v)
    local data = onlinePlayNPC.getData(v)

    local props = {}

    props.fromSpawn = data.fromSpawn

    if onlinePlay.currentMode == onlinePlay.MODE_HOST then
        -- Send over the owner and onlineUID
        props.uidCounter = onlinePlayNPC.uidCounter

        props.onlineUID = data.onlineUID
        props.ownerIdx = data.ownerIdx
    else
        -- Send over a client-side UID, and if it's from spawn, its online UID
        if data.fromSpawn then
            props.onlineUID = data.onlineUID
        end

        props.clientUID = v.uid
    end

    if not data.fromSpawn then
        -- Properties to help identify it
        props.predictedOnlineUID = data.predictedOnlineUID

        props.spawnForcedState = data.spawnForcedState
        props.spawnBattleOwner = data.spawnBattleOwner
        props.spawnTime        = data.spawnTime
        props.spawnLayerName   = data.spawnLayerName
        props.spawnID          = data.spawnID
        props.spawnX           = data.spawnX
        props.spawnY           = data.spawnY
        props.spawnSection     = data.spawnSection

        props.isGenerator = v.isGenerator
    end

    -- Encode object properties
    props.npcProperties = onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties)

    return props
end


local function sendNPCDespawn(v,data)
    if onlinePlay.currentMode == onlinePlay.MODE_HOST then
        setNPCOwner(v,data,0)
    end

    npcDespawnCommand:send(0, data.onlineUID,data.isDead,onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties))
    data.sentDespawnRequest = true
end

local function sendNPCStealRequest(v,data)
    if onlinePlay.currentMode == onlinePlay.MODE_HOST then
        setNPCOwner(v,data,onlinePlay.playerIdx)

        npcStealCommand:send(0, data.onlineUID,data.ownerIdx)
    else
        npcStealCommand:send(onlinePlay.hostPlayerIdx, data.onlineUID)
    end

    data.stealRequestTime = onlinePlay.localTime
end


function onlinePlayNPC.tryClaimNPC(v)
    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED then
        return
    end

    if data.ownerIdx > 0 then
        -- If this already has an owner, we can't claim it!
        return
    end

    -- Set prediction stuff
    if data.predictedOnlineUID < 0 then
        data.predictedOnlineUID = onlinePlayNPC.uidPredictiveCounter
        onlinePlayNPC.uidPredictiveCounter = onlinePlayNPC.uidPredictiveCounter + 1
    end

    data.predictedOwnerIdx = onlinePlay.playerIdx
    data.predictionTime = onlinePlay.localTime


    if onlinePlay.currentMode == onlinePlay.MODE_HOST then
        -- Claim it for ourselves
        if data.onlineUID < 0 then
            assignNPCUID(v,data)
        end

        setNPCOwner(v,data,onlinePlay.playerIdx)

        -- Tell everyone about it
        npcClaimCommand:send(0, constructNPCClaimProperties(v))
    else
        -- The host should send us back an onlineUID for it, so just wait for it
        -- Uses the regular, client-side UID to keep track of it. Mostly meaningless to the host.
        npcClaimCommand:send(onlinePlay.hostPlayerIdx, constructNPCClaimProperties(v))
        npcsWaitingForResponse[v.uid] = v
    end
end

function onlinePlayNPC.claimNPCSpawn(v)
    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED then
        return
    end

    if data.onlineUID >= 0 then
        -- If this already has a UID, it must not be newly-spawned
        return
    end

    local npcProperties = onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties)

    -- NB: This is the only case where a client gets to directly assign a UID/owner!
    assignNPCUID(v,data)
    setNPCOwner(v,data,onlinePlay.playerIdx)

    -- Tell everyone about the new NPC
    npcSpawnCommand:send(0, data.onlineUID,onlinePlayNPC.uidCounter,v.id,npcProperties)
end

function onlinePlayNPC.tryStealNPC(v)
    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED then
        return
    end

    if data.ownerIdx == 0 then
        onlinePlayNPC.tryClaimNPC(v)
        return
    end

    data.hasSentForcedStealRequest = true
    sendNPCStealRequest(v,data)
end

function onlinePlayNPC.assignNPCToOwner(v,ownerIdx)
    if onlinePlay.currentMode == onlinePlay.MODE_CLIENT then
        error("Cannot assign NPC owners as client",2)
    end

    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.NO_UPDATES then
        return
    end

    if data.ownerIdx <= 0 then
        onlinePlayNPC.tryClaimNPC(v)
    end

    if data.ownerIdx == ownerIdx then
        return
    end

    setNPCOwner(v,data,ownerIdx)
    npcStealCommand:send(0, data.onlineUID,data.ownerIdx)
end

function onlinePlayNPC.tryMarkAsUnsynchronised(v)
    local data = onlinePlayNPC.getData(v)

    if data.onlineUID < 0 then
        data.synchronisationType = onlinePlayNPC.SYNCHRONISATION.IGNORED
    end
end

function onlinePlayNPC.markAsNoUpdates(v)
    local data = onlinePlayNPC.getData(v)

    data.synchronisationType = onlinePlayNPC.SYNCHRONISATION.NO_UPDATES
end


local function handleNPC(v)
    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED then
        return
    end

    data.allowedToDie = (data.allowedToDie and v.killFlag > 0)
    data.allowedToHarm = false
    data.allowedToCollectPlayer = nil

    data.forceKillClaim = (data.forceKillClaim and v.killFlag > 0)
    data.forceHarmClaim = false

    -- Can we see this NPC?
    data.onScreen = npcIsOnCamera(v)

    if data.onlineUID >= 0 then
        if data.onScreen then
            if not data.seenByUserMap[onlinePlay.playerIdx] then
                data.seenByUserMap[onlinePlay.playerIdx] = true
                data.seenByAnyone = true
                npcSeenCommand:send(0, data.onlineUID)
            end
        else
            if data.seenByUserMap[onlinePlay.playerIdx] then
                data.seenByUserMap[onlinePlay.playerIdx] = nil
                data.seenByAnyone = npcIsSeenByAnyone(v)
                npcUnseenCommand:send(0, data.onlineUID)
            end
        end
    end

    if v.isGenerator then
        if onlinePlay.currentMode == onlinePlay.MODE_HOST and data.seenByAnyone then
            v:mem(0x74,FIELD_BOOL,true)
        else
            v:mem(0x74,FIELD_BOOL,false)
        end
        
        return
    end


    if not npcIsActive(v) then
        if data.ownerIdx == onlinePlay.playerIdx then
            if not data.sentDespawnRequest then
                sendNPCDespawn(v,data)
            end
        else
            data.sentDespawnRequest = false
        end

        if data.isDoingFairCollection then
            data.isDoingFairCollection = false
            data.fairCollectionTimeMap = {}
        end

        if data.seenByAnyoneElse and not npcIsBlacklisted(v) then
            -- Prevent it spawning
            v:mem(0x126,FIELD_BOOL,false)
            v:mem(0x128,FIELD_BOOL,false)
        end

        data.isDead = (data.isDead and v:mem(0x06,FIELD_WORD) > 0)

        return
    else
        data.sentDespawnRequest = false
        data.isDead = false
    end


    local onlineConfig = onlinePlayNPC.getConfig(v)

    if onlineConfig ~= nil and onlineConfig.activeUpdate ~= nil then
        onlineConfig.activeUpdate(v)
    end

    
    if npcIsBlacklisted(v) then
        return
    end


    if data.ownerIdx == 0 and data.predictedOwnerIdx == 0 then
        -- Predict some properties about it first
        data.predictedOwnerIdx = findSuitablePlayer(v)
        data.predictedOnlineUID = onlinePlayNPC.uidPredictiveCounter

        data.predictionTime = onlinePlay.localTime

        onlinePlayNPC.uidPredictiveCounter = onlinePlayNPC.uidPredictiveCounter + 1

        -- If the predicted player is ourselves... try to claim it!
        if data.predictedOwnerIdx == onlinePlay.playerIdx then
            if onlinePlayNPC.playerProjectileMap[v.id] then
                onlinePlayNPC.claimNPCSpawn(v)
            else
                onlinePlayNPC.tryClaimNPC(v)
            end
        end
    elseif data.predictedOwnerIdx > 0 and data.predictedOwnerIdx ~= onlinePlay.playerIdx and data.predictionTime <= (onlinePlay.localTime - onlinePlayNPC.npcPredictionWaitTime) then
        -- The player we predicted would take this hasn't done anything yet, so...
        if data.fromSpawn then
            -- Claim for ourselves!
            onlinePlayNPC.tryClaimNPC(v)
        else
            -- Death
            destroyNPC(v)
        end
    end

    -- Spawning
    if (data.ownerIdx ~= onlinePlay.playerIdx and v.despawnTimer > 0) or data.seenByAnyone then
        v.despawnTimer = math.max(v.despawnTimer,100)
    end

    -- Send updates
    if data.ownerIdx == onlinePlay.playerIdx then
        if data.synchronisationType ~= onlinePlayNPC.SYNCHRONISATION.NO_UPDATES then
            -- We are the owner, so send updates about the NPC to everyone
            local updateFrequency = (handlingConfig ~= nil and handlingConfig.updateFrequency) or onlinePlayNPC.npcUpdateFrequency

            if updateFrequency > 0 and (data.lastUpdatedTime == nil or data.lastUpdatedTime <= (onlinePlay.localTime - updateFrequency)) then
                npcUpdateCommand:send(0, data.onlineUID,onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties),lunatime.tick())

                data.lastUpdatedTime = onlinePlay.localTime
                --data.justUpdated = true
            end
        end

        data.sentAliveCheck = false
        data.lastSeenTime = nil
    elseif data.ownerIdx > 0 then
        -- Failsafe for if an NPC is not being updated
        if data.lastUpdatedTime ~= nil and data.lastUpdatedTime <= (onlinePlay.localTime - onlinePlayNPC.npcAliveCheckTime) then
            if not data.sentAliveCheck then
                npcCheckAliveCommand:send(data.ownerIdx, data.onlineUID)
                data.sentAliveCheck = true
            end
        else
            data.sentAliveCheck = false
        end
    end

    -- Attempt to steal ownership if applicable
    if shouldStealNPCOwnership(v,data) then
        if data.stealRequestTime == nil or data.stealRequestTime <= (onlinePlay.localTime - onlinePlayNPC.stealWaitTime) then
            sendNPCStealRequest(v,data)
        end
    else
        if data.hasSentForcedStealRequest then
            data.hasSentForcedStealRequest = (data.stealRequestTime > (onlinePlay.localTime - onlinePlayNPC.stealWaitTime))
        else
            data.stealRequestTime = nil
        end
    end

    -- Give up on fair collection if it's taking too long
    if onlinePlay.currentMode == onlinePlay.MODE_HOST and data.isDoingFairCollection and data.fairCollectionStartTime <= (onlinePlay.localTime - onlinePlayNPC.fairCollectionTimeout) then
        grantFairCollection(v)
    end
end


function onlinePlayNPC.handleNPCs()
    for _,v in NPC.iterate() do
        handleNPC(v)
    end
end


onlinePlayNPC.maxNPCDistance = 128 -- pixels
onlinePlayNPC.maxNPCTimeDifference = 1 -- number of seconds

local function npcIsUsable(v,props,playerIdx)
    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED then
        return false
    end

    -- Do some basic things match?
    if data.fromSpawn or data.ownerIdx == playerIdx then
        return false
    end

    if data.spawnForcedState ~= props.spawnForcedState or data.spawnBattleOwner ~= props.spawnBattleOwner
    or data.spawnLayerName ~= props.spawnLayerName or data.spawnID ~= props.spawnID
    or v.isGenerator ~= props.isGenerator or data.spawnSection ~= props.spawnSection
    then
        return false
    end

    -- Time that it spawned
    local distTime = math.abs(data.spawnTime - props.spawnTime)

    if distTime > onlinePlayNPC.maxNPCTimeDifference then
        return false
    end

    -- Place that it spawned
    local distX = (data.spawnX - props.spawnX)
    local distY = (data.spawnY - props.spawnY)
    local distTotal = math.sqrt(distX*distX + distY*distY)

    if distTotal > onlinePlayNPC.maxNPCDistance then
        return false
    end

    -- Assign a viability score
    local score = distTotal + distTime*128

    return true,score
end

local function findNewlySpawnedNPC(props,playerIdx)
    -- Is there an NPC that matches the predicted UID?
    local v = onlinePlayNPC.getNPCFromUID(props.predictedOnlineUID)

    if v ~= nil and v.isValid then
        local usable,score = npcIsUsable(v,props,playerIdx)

        if usable then
            return v
        end
    end

    -- Look for viable NPC's
    local bestNPC,bestScore

    for _,v in NPC.iterate() do
        local usable,score = npcIsUsable(v,props,playerIdx)

        if usable and (bestNPC == nil or score < bestScore) then
            bestScore = score
            bestNPC = v
        end
    end

    if bestNPC ~= nil then
        return bestNPC,start
    end

    -- If there's nothing, make one!
    local npc = NPC.spawn(props.spawnID,props.spawnX,props.spawnY,props.spawnSection,false,true)

    return npc,start    
end

local function sendNPCGrant(v,data,clientUID,sourcePlayerIdx)
    npcGrantCommand:send(sourcePlayerIdx, onlinePlayNPC.uidCounter,clientUID,data.onlineUID,data.ownerIdx)
end


function onlinePlayNPC.initCommands()
    -- Request to own an NPC
    npcClaimCommand = onlinePlay.createCommand("_npc_claim",onlinePlay.IMPORTANCE_MAJOR)

    function npcClaimCommand.onReceive(sourcePlayerIdx, props)
        if onlinePlay.currentMode == onlinePlay.MODE_CLIENT then
            if sourcePlayerIdx ~= onlinePlay.hostPlayerIdx then
                return
            end

            -- Update UID counter
            onlinePlayNPC.uidPredictiveCounter = props.uidCounter
        end

        -- Find NPC being referred to
        if props.fromSpawn then
            v = onlinePlayNPC.getNPCFromUID(props.onlineUID)
        else
            v = findNewlySpawnedNPC(props,sourcePlayerIdx)
        end

        if v == nil or not v.isValid then
            return
        end

        -- Start! Stuff!
        local data = onlinePlayNPC.getData(v)

        if onlinePlay.currentMode == onlinePlay.MODE_CLIENT then
            -- Set properties
            onlinePlay.decodeObject(v,props.npcProperties,1,onlinePlayNPC.npcSendProperties)

            -- Set UID and owner
            setNPCOwner(v,data,props.ownerIdx)
            setNPCUID(v,data,props.onlineUID)

            return
        end

        if data.ownerIdx > 0 then
            -- Already has an owner, just tell the client
            sendNPCGrant(v,data,props.clientUID,sourcePlayerIdx)
            return
        end

        -- Register the client as owner
        if not data.fromSpawn then
            assignNPCUID(v,data)
        end
        
        setNPCOwner(v,data,sourcePlayerIdx)

        -- Set properties
        onlinePlay.decodeObject(v,props.npcProperties,1,onlinePlayNPC.npcSendProperties)
        data.lastUpdatedTime = onlinePlay.localTime

        -- Tell everyone
        local claimProps

        for _,user in ipairs(onlinePlay.getUsers()) do
            if user.playerIdx == sourcePlayerIdx then
                -- Tell the client that it was accepted
                sendNPCGrant(v,data,props.clientUID,sourcePlayerIdx)
            elseif user.playerIdx ~= onlinePlay.playerIdx then
                -- Forward it on to everyone else
                claimProps = claimProps or constructNPCClaimProperties(v)

                npcClaimCommand:send(user.playerIdx, claimProps)
            end
        end
    end

    -- Give the NPC to a player
    npcGrantCommand = onlinePlay.createCommand("_npc_grant",onlinePlay.IMPORTANCE_MAJOR)

    function npcGrantCommand.onReceive(sourcePlayerIdx, uidCounter,clientUID,onlineUID,ownerIdx)
        if onlinePlay.currentMode == onlinePlay.MODE_HOST or sourcePlayerIdx ~= onlinePlay.hostPlayerIdx then
            return
        end

        onlinePlayNPC.uidPredictiveCounter = uidCounter

        -- Apply UID/owner
        local v = npcsWaitingForResponse[clientUID]

        if v ~= nil and v.isValid then
            local data = onlinePlayNPC.getData(v)

            setNPCOwner(v,data,ownerIdx)
            setNPCUID(v,data,onlineUID)
        else
            npcKillCommand:send(0, onlineUID,HARM_TYPE_VANISH)
        end

        npcsWaitingForResponse[clientUID] = nil
    end

    -- Notification from the hosts to clients of a new NPC spawn (only used in specific cases)
    npcSpawnCommand = onlinePlay.createCommand("_npc_spawn",onlinePlay.IMPORTANCE_MAJOR)

    function npcSpawnCommand.onReceive(sourcePlayerIdx, onlineUID,uidCounter,npcID,npcProperties)
        if sourcePlayerIdx == onlinePlay.hostPlayerIdx then
            -- Update UID counter
            onlinePlayNPC.uidPredictiveCounter = uidCounter
        end

        -- Spawn the NPC
        local v = NPC.spawn(npcID,0,0,0,false,false)

        onlinePlay.decodeObject(v,npcProperties,1,onlinePlayNPC.npcSendProperties)

        -- Assign the UID and owner to the new NPC
        local data = onlinePlayNPC.getData(v)

        setNPCUID(v,data,onlineUID)
        setNPCOwner(v,data,sourcePlayerIdx)
    end


    -- Update the state of an NPC
    npcUpdateCommand = onlinePlay.createCommand("_npc_update",onlinePlay.IMPORTANCE_MINOR)

    function npcUpdateCommand.onReceive(sourcePlayerIdx, onlineUID,npcProperties,updateTick)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        -- Don't allow if they're not the owner
        local data = onlinePlayNPC.getData(v)
        if data.ownerIdx ~= sourcePlayerIdx or data.isDead then
            return
        end

        -- Don't allow if we're holding it
        if v:mem(0x12C,FIELD_WORD) == onlinePlay.playerIdx then
            return
        end

        -- Don't allow it if it's older than the last update we got
        if updateTick ~= nil and updateTick < data.lastUpdateTick then
            return
        end

        -- Don't allow if we want to steal it
        if data.ownerIdx ~= onlinePlay.playerIdx and data.stealRequestTime ~= nil and data.stealRequestTime > (onlinePlay.localTime - onlinePlayNPC.stealWaitTime) then
            return
        end

        -- Decode properties
        onlinePlay.decodeObject(v,npcProperties,1,onlinePlayNPC.npcSendProperties)

        data.lastUpdateTick = updateTick or data.lastUpdateTick
        data.lastUpdatedTime = onlinePlay.localTime
    end

    -- NPC has just come on our screen
    npcSeenCommand = onlinePlay.createCommand("_npc_seen",onlinePlay.IMPORTANCE_MAJOR)

    function npcSeenCommand.onReceive(sourcePlayerIdx, onlineUID)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        local data = onlinePlayNPC.getData(v)

        data.seenByUserMap[sourcePlayerIdx] = true
        data.seenByAnyone = true
        data.seenByAnyoneElse = true
    end

    -- NPC has just left our screen
    npcUnseenCommand = onlinePlay.createCommand("_npc_unseen",onlinePlay.IMPORTANCE_MAJOR)

    function npcUnseenCommand.onReceive(sourcePlayerIdx, onlineUID)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        local data = onlinePlayNPC.getData(v)

        data.seenByUserMap[sourcePlayerIdx] = nil
        data.seenByAnyone = npcIsSeenByAnyone(v)
        data.seenByAnyoneElse = npcIsSeenByAnotherUser(v)
    end

    -- NPC was harmed
    npcHarmCommand = onlinePlay.createCommand("_npc_harm",onlinePlay.IMPORTANCE_MAJOR)

    function npcHarmCommand.onReceive(sourcePlayerIdx, onlineUID,reason,npcProperties,necessaryID)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        -- In one specific case, it should only go through if the ID matches
        if necessaryID ~= nil and v.id ~= necessaryID then
            return
        end

        local data = onlinePlayNPC.getData(v)

        -- Decode properties
        if npcProperties ~= nil then
            onlinePlay.decodeObject(v,npcProperties,1,onlinePlayNPC.npcSendProperties)
        end

        data.lastUpdatedTime = onlinePlay.localTime

        -- KILL!!!
        data.allowedToHarm = true
        v:harm(reason)
    end

    -- NPC was killed
    npcKillCommand = onlinePlay.createCommand("_npc_kill",onlinePlay.IMPORTANCE_MAJOR)

    function npcKillCommand.onReceive(sourcePlayerIdx, onlineUID,reason,npcProperties)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        local data = onlinePlayNPC.getData(v)

        if data.isDead then
            return
        end

        -- Decode properties
        if npcProperties ~= nil then
            onlinePlay.decodeObject(v,npcProperties,1,onlinePlayNPC.npcSendProperties)
        end

        data.lastUpdatedTime = onlinePlay.localTime

        -- Call an event
        local handlingConfig = onlinePlayNPC.getConfig(v)

        if handlingConfig ~= nil and handlingConfig.onKillCommand ~= nil then
            handlingConfig.onKillCommand(v,reason)
        end

        -- KILL!!!
        data.allowedToDie = true
        v:kill(reason)
    end

    -- NPC was collected
    npcCollectCommand = onlinePlay.createCommand("_npc_collect",onlinePlay.IMPORTANCE_MAJOR)

    function npcCollectCommand.onReceive(sourcePlayerIdx, onlineUID,playerIdx,npcProperties)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        local data = onlinePlayNPC.getData(v)

        -- Decode properties
        if npcProperties ~= nil then
            onlinePlay.decodeObject(v,npcProperties,1,onlinePlayNPC.npcSendProperties)
        end

        data.lastUpdatedTime = onlinePlay.localTime

        -- Colllect....
        data.fairCollectionTimeMap = {}
        data.isDoingFairCollection = false

        data.allowedToCollectPlayer = playerIdx
        v:collect(Player(playerIdx))
    end

    -- Claims for fair collection
    npcFairCollectionClaimCommand = onlinePlay.createCommand("_npc_fairCollection",onlinePlay.IMPORTANCE_MAJOR)

    function npcFairCollectionClaimCommand.onReceive(sourcePlayerIdx, onlineUID,collectionTime)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        local data = onlinePlayNPC.getData(v)

        if onlinePlay.currentMode == onlinePlay.MODE_HOST then
            if not data.isDoingFairCollection then
                data.fairCollectionStartTime = onlinePlay.localTime
                data.fairCollectionTimeMap[onlinePlay.playerIdx] = -1
            end

            data.fairCollectionTimeMap[sourcePlayerIdx] = collectionTime

            if hasCollectionTimesFromEveryone(v) then
                grantFairCollection(v)
            end
        else
            if not data.isDoingFairCollection then
                npcFairCollectionClaimCommand:send(onlinePlay.hostPlayerIdx, onlineUID,-1)
            end
        end

        data.isDoingFairCollection = true
    end


    -- Request to take ownership of an NPC that already has an owner (e.g., after grabbing it)
    npcStealCommand = onlinePlay.createCommand("_npc_steal",onlinePlay.IMPORTANCE_MAJOR)

    function npcStealCommand.onReceive(sourcePlayerIdx, onlineUID,newOwnerIdx)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        local data = onlinePlayNPC.getData(v)

        if onlinePlay.currentMode == onlinePlay.MODE_HOST then
            -- A client is requesting to steal an NPC
            if data.ownerIdx ~= sourcePlayerIdx then
                setNPCOwner(v,data,sourcePlayerIdx)
            end

            -- Forward it on to everyone else
            npcStealCommand:send(0, onlineUID,data.ownerIdx)
        else
            -- Only accept from the host
            if sourcePlayerIdx ~= onlinePlay.hostPlayerIdx then
                return
            end

            -- The host is reassigning an NPC
            if data.ownerIdx ~= newOwnerIdx then
                setNPCOwner(v,data,newOwnerIdx)
            end
        end
    end

    -- NPC has just despawned
    npcDespawnCommand = onlinePlay.createCommand("_npc_despawn",onlinePlay.IMPORTANCE_MAJOR)

    function npcDespawnCommand.onReceive(sourcePlayerIdx, onlineUID,isDead,npcProperties)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        local data = onlinePlayNPC.getData(v)

        if not isDead and not data.isDead and v.despawnTimer > 0 and npcIsOnCamera(v) then
            -- If the NPC is on our screen, don't despawn on our end and claim it instead
            v.despawnTimer = math.max(180,v.despawnTimer)
            onlinePlayNPC.tryStealNPC(v)
        else
            onlinePlay.decodeObject(v,npcProperties,1,onlinePlayNPC.npcSendProperties)
        end
        
        data.lastUpdatedTime = onlinePlay.localTime

        -- Remove owner
        if onlinePlay.currentMode == onlinePlay.MODE_HOST then
            npcRemoveOwnerCommand:send(0, onlineUID)
            setNPCOwner(v,data,0)
        elseif sourcePlayerIdx == onlinePlay.hostPlayerIdx then
            setNPCOwner(v,data,0)
        end
    end

    -- Command to tell a user to remove the owner from an NPC
    npcRemoveOwnerCommand = onlinePlay.createCommand("_npc_removeOwner",onlinePlay.IMPORTANCE_MAJOR)

    function npcRemoveOwnerCommand.onReceive(sourcePlayerIdx, onlineUID)
        if sourcePlayerIdx ~= onlinePlay.hostPlayerIdx then
            return
        end

        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return
        end

        local data = onlinePlayNPC.getData(v)

        setNPCOwner(v,data,0)
    end

    -- Failsafe to prevent NPCs from sticking around if they're meant to be dead
    npcCheckAliveCommand = onlinePlay.createCommand("_npc_checkAlive",onlinePlay.IMPORTANCE_MAJOR)

    local function npcIsDeadOrMissing(onlineUID)
        local v = onlinePlayNPC.getNPCFromUID(onlineUID)
        if v == nil or not v.isValid then
            return true
        end

        local data = onlinePlayNPC.getData(v)

        return data.isDead
    end

    function npcCheckAliveCommand.onReceive(sourcePlayerIdx, onlineUID)
        if npcIsDeadOrMissing(onlineUID) then
            -- NPC is not alive, tell 'em
            npcKillCommand:send(sourcePlayerIdx, onlineUID,HARM_TYPE_VANISH)
        end
    end

    -- Used for custom NPC commands from onlinePlayNPC.createNPCCommand
    customCommand = onlinePlay.createCommand("_npc_custom")

    function customCommand.onReceive(...)
        handleCustomCommand(...)
    end


    -- Join packet for all NPCs
    npcJoinPacket = onlinePlay.createJoinPacket("_npc",0)

    local function encodeUserMap(seenByUserMap)
        local userCount = 0
        local data = ""

        for _,user in ipairs(onlinePlay.getUsers()) do
            if seenByUserMap[user.playerIdx] then
                data = data.. onlinePlay.encodeValue("uint8",user.playerIdx)
                userCount = userCount + 1
            end
        end

        return onlinePlay.encodeValue("uint8",userCount).. data
    end

    local function decodeUserMap(data,pos)
        local seenByUserMap = {}

        local userCount
        userCount,pos = onlinePlay.decodeValue("uint8",data,pos)

        for i = 1,userCount do
            local playerIdx
            playerIdx,pos = onlinePlay.decodeValue("uint8",data,pos)

            seenByUserMap[playerIdx] = true
        end

        return seenByUserMap,pos
    end

    function npcJoinPacket.encodeData(targetPlayerIdx)
        local encodededData = ""

        for _,npc in NPC.iterate() do
            local data = onlinePlayNPC.getData(npc)

            if data.onlineUID >= 0 then
                local npcData = ""

                npcData = npcData.. onlinePlay.encodeValue("uint16",npc.id)
                npcData = npcData.. onlinePlay.encodeObject(npc,onlinePlayNPC.npcSendProperties)

                npcData = npcData.. onlinePlay.encodeValue(onlinePlayNPC.npcIdentifierType,data.onlineUID)
                npcData = npcData.. onlinePlay.encodeValue("uint8",data.ownerIdx)
                npcData = npcData.. onlinePlay.encodeValue("boolean",data.fromSpawn)
                npcData = npcData.. encodeUserMap(data.seenByUserMap)

                encodededData = encodededData.. npcData
            end
        end

        return onlinePlayNPC.uidCounter,encodededData
    end

    function npcJoinPacket.onReceive(uidCounter,encodedData)
        onlinePlayNPC.uidPredictiveCounter = uidCounter

        npcsWaitingForResponse = {}
        npcOnlineUIDMap = {}

        -- Remove all existing NPCs
        for _,npc in NPC.iterate() do
            destroyNPC(npc)
        end

        -- Decode data
        local pos = 1

        while (pos <= #encodedData) do
            local npcID,onlineUID

            npcID,pos = onlinePlay.decodeValue("uint16",encodedData,pos)

            local npc = NPC.spawn(npcID,0,0,0,false,false)

            pos = onlinePlay.decodeObject(npc,encodedData,pos,onlinePlayNPC.npcSendProperties)

            -- Data properties
            local data = onlinePlayNPC.getData(npc)

            onlineUID,pos = onlinePlay.decodeValue(onlinePlayNPC.npcIdentifierType,encodedData,pos)

            if onlineUID >= 0 then
                local ownerIdx,fromSpawn

                ownerIdx,pos = onlinePlay.decodeValue("uint8",encodedData,pos)
                data.fromSpawn,pos = onlinePlay.decodeValue("boolean",encodedData,pos)

                setNPCUID(npc,data,onlineUID)
                setNPCOwner(npc,data,ownerIdx)

                data.seenByUserMap,pos = decodeUserMap(encodedData,pos)
                data.seenByAnyone = npcIsSeenByAnyone(npc)
                data.seenByAnyoneElse = npcIsSeenByAnotherUser(npc)
            end
        end
    end
end


local debugFont = textplus.loadFont("textplus/font/6.ini")

local function drawDebugText(v,text,color,yOffset)
    local x = (v.x + v.width*0.5) - camera.x
    local y = v.y - camera.y + yOffset - 32

    textplus.print{
        text = text,color = color*0.75,
        font = debugFont,smooth = false,
        plaintext = true,align = "center",

        pivot = vector(0.5,0),
        x = math.floor(x),
        y = math.floor(y),
    }
end

local function drawDebugTextForNPC(v)
    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED then
        drawDebugText(v,"Unsynchronised",Color.pink,0)
        return
    end

    if not data.onScreen then
        return
    end

    if data.ownerIdx > 0 then
        drawDebugText(v,"Owner:\n".. battlePlayer.getName(data.ownerIdx),battlePlayer.getColor(data.ownerIdx),-24)
    elseif data.predictedOwnerIdx > 0 then
        drawDebugText(v,"Predicted:\n".. battlePlayer.getName(data.predictedOwnerIdx),battlePlayer.getColor(data.predictedOwnerIdx),-24)
    elseif npcIsActive(v) then
        if npcIsBlacklisted(v) then
            drawDebugText(v,"Blacklisted",Color.lightgrey,-24)
        else
            drawDebugText(v,"Unclaimed",Color.white,-24)
        end
    end

    if data.stealAttemptTime ~= nil then
        drawDebugText(v,"Theft",Color.lightgrey,0)
    elseif data.onlineUID >= 0 then
        --drawDebugText(v,"Timer: ".. v.despawnTimer,Color.white,0)
        drawDebugText(v,string.format("UID: %.8X",data.onlineUID).. ((data.fromSpawn and " s") or ""),Color.white,0)
    else
        drawDebugText(v,"No UID",Color.white,0)
    end

    if v:mem(0x12C,FIELD_WORD) > 0 then
        drawDebugText(v,"Held by: ".. battlePlayer.getName(v:mem(0x12C,FIELD_WORD)),battlePlayer.getColor(v:mem(0x12C,FIELD_WORD)),8)
    elseif data.isDead then
        drawDebugText(v,"Dead ".. v:mem(0x06,FIELD_WORD),Color.lightgrey,8)
    elseif data.justUpdated then
        drawDebugText(v,"Updated",Color.lightgrey,8)
    elseif npcIsSeenByAnotherUser(v) then
        drawDebugText(v,"Seen",Color.lightgrey,8)
    end

    if v.isGenerator then
        drawDebugText(v,"Generator",Color.lightgrey,24)
    end

    drawDebugText(v,#onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties),Color.lightgrey,16)
end

function onlinePlayNPC.drawNPCDebugText()
    for _,v in NPC.iterate() do
        local data = onlinePlayNPC.getData(v)

        drawDebugTextForNPC(v)

        data.justUpdated = false
    end
end


local function canClaimNPCHarm(v,reason,culprit)
    local data = onlinePlayNPC.getData(v)

    if data.forceHarmClaim then
        return true
    end

    -- In the case of something like a fireball hitting a player, the one to claim the kill should be the player that got hit
    if onlinePlayNPC.weakPlayerProjectileMap[v.id] and reason == HARM_TYPE_NPC and culprit == v then
        for _,p in ipairs(Player.getIntersecting(v.x,v.y,v.x + v.width,v.y + v.height)) do
            if p.forcedState == FORCEDSTATE_NONE
            and p.invincibilityTimer == 0
            and not p.nonpcinteraction
            and v:mem(0x130,FIELD_WORD) ~= p.idx
            and Misc.canCollideWith(v,p)
            then
                return (p.idx == onlinePlay.playerIdx)
            end
        end
    end

    -- Allow it if it's our own NPC
    if data.ownerIdx == onlinePlay.playerIdx then
        return true
    end

    local config = NPC.config[v.id]

    -- Harmed by us
    if type(culprit) == "Player" and culprit.idx == onlinePlay.playerIdx then
        return true
    end

    if type(culprit) == "NPC" then
        -- Harmed by something we threw
        if culprit:mem(0x12C,FIELD_WORD) == onlinePlay.playerIdx
        or culprit:mem(0x132,FIELD_WORD) == onlinePlay.playerIdx
        then
            return true
        end
    end

    -- Very close to us
    local ownPlayer = Player(onlinePlay.playerIdx)

    if battlePlayer.getPlayerIsActive(ownPlayer)
    and (ownPlayer.x - 8) < (v.x + v.width)
    and (ownPlayer.x + ownPlayer.width + 8) > v.x
    and (ownPlayer.y - 8) < (v.y + v.height)
    and (ownPlayer.y + ownPlayer.height + 8) > v.y
    then
        return true
    end


    local handlingConfig = onlinePlayNPC.getConfig(v)

    if handlingConfig ~= nil and handlingConfig.canClaimHarmFunc ~= nil and handlingConfig.canClaimHarmFunc(v,reason,culprit) then
        return true
    end

    return false
end

local function canClaimNPCKill(v,reason)
    local data = onlinePlayNPC.getData(v)

    if data.ownerIdx == onlinePlay.playerIdx or data.forceKillClaim then
        return true
    end

    local handlingConfig = onlinePlayNPC.getConfig(v)

    if handlingConfig ~= nil and handlingConfig.canClaimKillFunc ~= nil and handlingConfig.canClaimKillFunc(v,reason) then
        return true
    end


    local config = NPC.config[v.id]

    -- Boots and Yoshies can be killed by anybody, fixes some duplication issues
    if config ~= nil and (config.isyoshi or config.isshoe) and reason == HARM_TYPE_VANISH then
        return true
    end

    return false
end


function onlinePlayNPC.forceHarmNPC(v,...)
    if onlinePlay.currentMode ~= onlinePlay.MODE_OFFLINE then
        local data = onlinePlayNPC.getData(v)

        data.forceHarmClaim = true
    end

    v:harm(...)
end

function onlinePlayNPC.forceKillNPC(v,...)
    if onlinePlay.currentMode ~= onlinePlay.MODE_OFFLINE then
        local data = onlinePlayNPC.getData(v)

        data.forceKillClaim = true
    end

    v:kill(...)
end


function onlinePlayNPC.onNPCHarm(eventObj,v,reason,culprit)
    if onlinePlay.currentMode == onlinePlay.MODE_OFFLINE or eventObj.cancelled then
        return
    end
    
    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED or data.allowedToHarm or data.onlineUID < 0 then
        -- Just let the NPC get hurt normally
        return
    end

    if not canClaimNPCHarm(v,reason,culprit) then
        -- We cannot claim that the NPC was hurt, so just cancel the event
        eventObj.cancelled = true
        return
    end

    -- Tell everyone about the death, and allow ourselves to claim the death if necessary
    local npcProperties
    if data.synchronisationType ~= onlinePlayNPC.SYNCHRONISATION.NO_UPDATES then
        npcProperties = onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties)
    end

    npcHarmCommand:send(0, data.onlineUID,reason,npcProperties)
    data.forceKillClaim = true
end

function onlinePlayNPC.onNPCKill(eventObj,v,reason)
    if onlinePlay.currentMode == onlinePlay.MODE_OFFLINE or eventObj.cancelled then
        return
    end
    
    local data = onlinePlayNPC.getData(v)

    if data.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED or data.allowedToDie or data.onlineUID < 0 then
        -- Just let the NPC die normally
        return
    end


    if npcIsBlacklisted(v) then
        -- Note: blacklisted NPCs always have fromSpawn set and therefore will have a
        -- UID. If this wasn't the case, then this would not be safe
        onlinePlayNPC.tryClaimNPC(v)
    elseif not canClaimNPCKill(v,reason) then
        -- We cannot claim that the NPC was killed, so just cancel the event
        eventObj.cancelled = true
        return
    end

    -- Tell everyone about the death
    local npcProperties
    if data.synchronisationType ~= onlinePlayNPC.SYNCHRONISATION.NO_UPDATES then
        npcProperties = onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties)
    end

    npcKillCommand:send(0, data.onlineUID,reason,npcProperties)
end

function onlinePlayNPC.onPostNPCKill(v,reason)
    if onlinePlay.currentMode == onlinePlay.MODE_OFFLINE then
        return
    end
    
    local data = onlinePlayNPC.getData(v)

    data.isDead = true
end

function onlinePlayNPC.onNPCCollect(eventObj,v,playerObj)
    if onlinePlay.currentMode == onlinePlay.MODE_OFFLINE or eventObj.cancelled then
        return
    end
    
    local data = onlinePlayNPC.getData(v)

    if data.allowedToCollectPlayer == playerObj.idx then
        -- Let the NPC get collected normally
        data.allowedToDie = true
        return
    end

    if not onlinePlayPlayers.ownsPlayer(playerObj) then
        eventObj.cancelled = true
        return
    end

    -- Fair handling
    local handlingConfig = onlinePlayNPC.getConfig(v)

    if handlingConfig ~= nil and handlingConfig.useFairCollection then
        if data.onlineUID < 0 then
            eventObj.cancelled = true
            return
        end

        if not data.isDoingFairCollection then
            data.fairCollectionStartTime = onlinePlay.localTime
            data.isDoingFairCollection = true

            if onlinePlay.currentMode == onlinePlay.MODE_HOST then
                data.fairCollectionTimeMap[onlinePlay.playerIdx] = onlinePlay.syncedTime

                if hasCollectionTimesFromEveryone(v) then
                    grantFairCollection(v)
                end
            end

            npcFairCollectionClaimCommand:send(0, data.onlineUID,onlinePlay.syncedTime)
        end
        
        eventObj.cancelled = true
        return
    end


    if data.onlineUID >= 0 then
        local npcProperties
        if data.synchronisationType ~= onlinePlayNPC.SYNCHRONISATION.NO_UPDATES then
            npcProperties = onlinePlay.encodeObject(v,onlinePlayNPC.npcSendProperties)
        end

        npcCollectCommand:send(0, data.onlineUID,playerObj.idx,npcProperties)
    end

    data.allowedToDie = true
end

function onlinePlayNPC.onNPCTransform(v,oldID,reason)
    if onlinePlay.currentMode == onlinePlay.MODE_OFFLINE then
        return
    end

    local data = onlinePlayNPC.getData(v)

    if data.onlineUID < 0 then
        return
    end

    -- 1.3 NPCs that transform when stomped on (e.g. koopas) will not call onNPCHarm. This is to account for that.
    if onlinePlayNPC.stompTransformMap[oldID] and reason == NPC_TFCAUSE_HIT then
        for _,p in ipairs(Player.getIntersecting(v.x,v.y,v.x + v.width,v.y + v.height)) do
            if p.idx == onlinePlay.playerIdx
            and p.forcedState == FORCEDSTATE_NONE
            and not p.nonpcinteraction
            and Misc.canCollideWith(v,p)
            and (p.y + p.height + p.speedY) >= (v.y - v.speedY)
            then
                onlinePlayNPC.tryStealNPC(v)
                npcHarmCommand:send(0, data.onlineUID,HARM_TYPE_JUMP,nil,oldID)

                break
            end
        end
    end
end

function onlinePlayNPC.onNPCGenerated(generatorNPC,generatedNPC)
    if onlinePlay.currentMode == onlinePlay.MODE_HOST then
        onlinePlayNPC.claimNPCSpawn(generatedNPC)
    end
end


function onlinePlayNPC.onStart()
    -- Assign each NPC a unique UID
    for _,v in NPC.iterate() do
        local data = onlinePlayNPC.getData(v)

        data.fromSpawn = true

        if data.synchronisationType ~= onlinePlayNPC.SYNCHRONISATION.IGNORED then
            assignNPCUID(v,data)
        end
    end

    onlinePlayNPC.spawnUIDCounter = onlinePlayNPC.uidCounter
end


-- Encoding/decoding NPC settings
do
    local cachedDefaultSettings = {}
    local settingsKeys = {}

    local function getKeysInTable(tbl)
        local keysTable = {subKeys = {}}

        for key,value in pairs(tbl) do
            table.insert(keysTable,key)

            if type(value) == "table" then
                keysTable.subKeys[key] = getKeysInTable(value)
            end
        end

        return keysTable
    end

    local function encodeTableWithKeys(tbl,defaultsTable,keysTable)
        local output = ""
        local keyCount = 0

        for _,key in ipairs(keysTable) do
            local defaultValue = defaultsTable[key]
            local value = tbl[key]

            if keysTable.subKeys[key] ~= nil then
                output = output.. onlinePlay.encodeValue("any",key).. encodeTableWithKeys(value,defaultValue,keysTable.subKeys[key])
                keyCount = keyCount + 1
            elseif value ~= defaultValue then
                output = output.. onlinePlay.encodeValue("any",key).. onlinePlay.encodeValue("any",value)
                keyCount = keyCount + 1
            end
        end

        output = onlinePlay.encodeValue("uint16",keyCount).. output

        return output
    end

    local function loadNPCSettings(npcID)
        if cachedDefaultSettings[npcID] == nil then
            cachedDefaultSettings[npcID] = NPC.makeDefaultSettings(npcID)
            settingsKeys[npcID] = getKeysInTable(cachedDefaultSettings[npcID])
        end
    end

    function onlinePlayNPC.encodeSettings(npcID,settings)
        loadNPCSettings(npcID)

        return encodeTableWithKeys(settings,cachedDefaultSettings[npcID],settingsKeys[npcID])
    end

    local function decodeTableWithKeys(str,start,defaultsTable,keysTable)
        -- Decode the settings included within the string
        local keyCount,key
        keyCount,start = onlinePlay.decodeValue("uint16",str,start)

        local settings = {}

        for i = 1,keyCount do
            key,start = onlinePlay.decodeValue("any",str,start)

            local defaultValue = defaultsTable[key]

            if keysTable.subKeys[key] ~= nil then
                settings[key],start = decodeTableWithKeys(str,start,defaultValue,keysTable.subKeys[key])
            else
                settings[key],start = onlinePlay.decodeValue("any",str,start)
            end
        end

        -- Fill out defaults
        for _,key in ipairs(keysTable) do
            if settings[key] == nil then
                settings[key] = defaultsTable[key]
            end
        end

        return settings,start
    end

    function onlinePlayNPC.decodeSettings(npcID,str,start)
        loadNPCSettings(npcID)

        local settings
        settings,start = decodeTableWithKeys(str,start,cachedDefaultSettings[npcID],settingsKeys[npcID])

        return settings,start
    end
end


-- Online handling configs
-- These allow you to better control how specific NPCs are handled online
do
    local configNames = {
        "findSuitableOwnerFunc","shouldStealFunc","canClaimHarmFunc","canClaimKillFunc",
        "initFunc",
        "getExtraData","setExtraData",
        "updateFrequency","useFairCollection","synchronisationType",
        "onKillCommand",
    }

    local function onConfigUpdate(v)
        local handlingConfig = onlinePlayNPC.getConfig(v)
        
        if handlingConfig ~= nil then
            local data = onlinePlayNPC.getData(v)

            if handlingConfig.initFunc ~= nil then
                handlingConfig.initFunc(v)
            end

            if handlingConfig.synchronisationType == onlinePlayNPC.SYNCHRONISATION.IGNORED then
                onlinePlayNPC.tryMarkAsUnsynchronised(v)
            elseif handlingConfig.synchronisationType ~= nil then
                data.synchronisationType = handlingConfig.synchronisationType
            end
        end
    end

    function onlinePlayNPC.getConfig(v)
        if v.data._onlinePlay ~= nil and v.data._onlinePlay.handlingConfig ~= nil then
            return v.data._onlinePlay.handlingConfig
        end

        return onlinePlayNPC.onlineHandlingConfig[v.id]
    end

    function onlinePlayNPC.overwriteConfig(v,newConfig)
        local data = onlinePlayNPC.getData(v)

        data.handlingConfig = newConfig

        onConfigUpdate(v)
    end

    function onlinePlayNPC.resetConfig(v)
        local data = onlinePlayNPC.getData(v)

        data.handlingConfig = nil

        onConfigUpdate(v)
    end

    function onlinePlayNPC.mergeConfig(v,newConfig)
        local idConfig = onlinePlayNPC.onlineHandlingConfig[v.id]
        local data = onlinePlayNPC.getData(v)

        if data.handlingConfig ~= nil then
            for _,key in ipairs(configNames) do
                if newConfig[key] ~= nil then
                    data.handlingConfig[key] = newConfig[key]
                end
            end
        else
            data.handlingConfig = {}

            for _,key in ipairs(configNames) do
                if newConfig[key] ~= nil then
                    data.handlingConfig[key] = newConfig[key]
                elseif idConfig ~= nil and idConfig[key] ~= nil then
                    data.handlingConfig[key] = idConfig[key]
                end
            end
        end

        onConfigUpdate(v)
    end
end

-- NPC commands
-- Like regular commands from onlinePlay.createCommand, but specifically for NPCs
do
    local commandMap = {}

    local commandMT = {}

    commandMT.__type = "OnlineNPCCommand"
    commandMT.__index = commandMT


    function commandMT:sendWithImportance(npc, targetClientIdx,importance, ...)
        if not validImportanceValues[importance] then
            error("Importance value is not valid",2)
        end
    
        local onlineUID = onlinePlayNPC.getUIDFromNPC(npc)
        if onlineUID == nil then
            return false
        end

        customCommand:sendWithImportance(targetClientIdx,importance, self.name,onlineUID, ...)

        return true
    end

    function commandMT:send(npc, targetClientIdx, ...)
        if self.defaultImportance == nil then
            error("Default importance value is not set",2)
        end

        self:sendWithImportance(npc, targetClientIdx,self.defaultImportance, ...)
    end

    
    function onlinePlayNPC.createNPCCommand(name,defaultImportance)
        if commandMap[name] ~= nil then
            error("Command name '".. name.. "' is already in use",2)
        end

        if defaultImportance ~= nil and not validImportanceValues[defaultImportance] then
            error("Importance value is not valid",2)
        end

        if type(name) ~= "string" then
            error("Command name is not valid",2)
        end


        local command = setmetatable({},commandMT)

        command.name = name
        command.defaultImportance = defaultImportance

        commandMap[name] = command

        return command
    end

    function onlinePlayNPC.getNPCCommand(name)
        return commandMap[name]
    end

    function handleCustomCommand(sourcePlayerIdx, commandName,onlineUID, ...)
        local command = commandMap[commandName]
        if command == nil then
            return
        end

        local npc = onlinePlayNPC.getNPCFromUID(onlineUID)
        if npc == nil then
            return
        end

        if command.onReceive ~= nil then
            command.onReceive(npc,sourcePlayerIdx, ...)
        end
    end
end


-- NPC-specific things
do
    -- Swinging platforms
    local swingingPlatform = require("npcs/ai/swingingPlatform")

    local function isOnController(v)
        local controller = v.data._swingingPlatformController
        if controller == nil or not controller.isValid then
            return false
        end

        local controllerData = controller.data._basegame
        if not controllerData.initialized then
            return false
        end

        for i = 1,controllerData.platformCount do
            if controllerData.platforms[i] == v then
                return true
            end
        end

        return false
    end

    local swingingPlatformConfig = {
        shouldStealFunc = function(v)
            if not isOnController(v) then
                return nil
            end

            -- If we own the controller, take control of the platform, too
            -- Otherwise, leave it be
            local controller = v.data._swingingPlatformController

            return (onlinePlayNPC.getOwner(controller) == onlinePlay.playerIdx)
        end,
        findSuitableOwnerFunc = function(v)
            if not isOnController(v) then
                return nil
            end

            local controller = v.data._swingingPlatformController
            local controllerOwnerIdx = onlinePlayNPC.getOwner(controller)

            if controllerOwnerIdx > 0 then
                return controllerOwnerIdx
            end

            return nil
        end,
        initFunc = function(v)
            if swingingPlatform.platformIDMap[v.id] or swingingPlatform.ballIDMap[v.id] then
                onlinePlayNPC.tryMarkAsUnsynchronised(v)
            end
        end,
    }

    local swingingControllerConfig = {
        activeUpdate = function(v)
            local data = v.data._basegame

            if not data.initialized then
                return
            end

            for platformIndex = 1,data.platformCount do
                local platform = data.platforms[platformIndex]

                if platform ~= nil and platform.isValid and platform.data._swingingPlatformController == nil then
                    platform.data._swingingPlatformController = v
                    onlinePlayNPC.mergeConfig(platform,swingingPlatformConfig)
                end
            end
        end,
        shouldStealFunc = function(v)
            local data = v.data._basegame

            if not data.initialized then
                return
            end

            -- Steal if standing on any of the platforms
            local ownPlayer = Player(onlinePlay.playerIdx)
            local standingNPC = ownPlayer.standingNPC

            if standingNPC ~= nil and standingNPC.data._swingingPlatformController == v then
                -- Is another player also standing on a platform?
                for _,otherPlayer in ipairs(Player.get()) do
                    local standingNPC = otherPlayer.standingNPC

                    if otherPlayer.idx ~= onlinePlay.playerIdx and playerCanBeOwner(otherPlayer.idx) and standingNPC ~= nil and standingNPC.data._swingingPlatformController == v then
                        return false
                    end
                end

                return true
            end

            return false
        end,
        getExtraData = function(v)
            local data = v.data._basegame
            if not data.initialized then
                return nil
            end

            local platformUIDs = {}
            for platformIndex = 1,data.platformCount do
                platformUIDs[platformIndex] = onlinePlayNPC.getUIDFromNPC(data.platforms[platformIndex])
            end

            return {
                platformUIDs = platformUIDs,
                platformCount = data.platformCount,
                rotationSpeed = data.rotationSpeed,
                rotation = data.rotation,
            }
        end,
        setExtraData = function(v, receivedData)
            local data = v.data._basegame
            if not data.initialized then
                return nil
            end

            data.platformCount = receivedData.platformCount

            for platformIndex = 1,data.platformCount do
                local npc = onlinePlayNPC.getNPCFromUID(receivedData.platformUIDs[platformIndex])

                if npc ~= nil then
                    data.platforms[platformIndex] = npc
                end
            end

            data.rotationSpeed = receivedData.rotationSpeed
            data.rotation = receivedData.rotation

            data.initialized = true
        end,
    }

    for _,npcID in ipairs(swingingPlatform.controllerIDList) do
        onlinePlayNPC.onlineHandlingConfig[npcID] = swingingControllerConfig
    end

    -- Burners
    local burner = require("npcs/ai/burner")

    local burnerConfig = {
        getExtraData = function(v)
            local data = v.data._basegame
            if not data.initialized then
                return nil
            end

            --[[local fireUIDs

            if data.fires ~= nil then
                fireUIDs = {}

                for _,fire in ipairs(data.fires) do
                    table.insert(fireUIDs,onlinePlayNPC.getUIDFromNPC(fire) or -1)
                end
            end]]

            return {
                --fireUIDs = fireUIDs,
                bop = data.bop,
                angle = data.angle,
                spawnFireCooldown = data.spawnFireCooldown,
                spawndelay = data.spawndelay,
                timer = data.timer,
            }
        end,
        setExtraData = function(v, receivedData)
            local data = v.data._basegame
            if not data.initialized then
                return nil
            end

            --[[if receivedData.fireUIDs ~= nil then
                data.fires = {}

                for _,fireUID in ipairs(receivedData.fireUIDs) do
                    local fire = onlinePlayNPC.getNPCFromUID(fireUID)

                    if fire ~= nil then
                        table.insert(data.fires,fire)
                    end
                end
            else
                data.fires = nil
            end]]

            data.bop = receivedData.bop
            data.angle = receivedData.angle
            data.spawnFireCooldown = receivedData.spawnFireCooldown
            data.timer = receivedData.timer
        end,
    }

    local originalOnTickBurner = burner.onTickBurner

    function burner.onTickBurner(v)
        if onlinePlay.currentMode == onlinePlay.MODE_OFFLINE then
            originalOnTickBurner(v)
            return
        end

        -- This is INCREDIBLY stupid but it works
        local originalLunatimeTick = lunatime.tick

        function lunatime.tick()
            return lunatime.toTicks(onlinePlay.syncedTime)
        end

        originalOnTickBurner(v)

        lunatime.tick = originalLunatimeTick
    end

    for _,npcID in ipairs{544,545,546,548,549} do
        onlinePlayNPC.onlineHandlingConfig[npcID] = burnerConfig
    end

    onlinePlayNPC.onlineHandlingConfig[547] = {
        synchronisationType = onlinePlayNPC.SYNCHRONISATION.IGNORED,
    }

    -- Cannons
    local cannonConfig = {
        getExtraData = function(v)
            local data = v.data._basegame
            if not data.initialized then
                return nil
            end

            return {
                shootTimer = data.shootTimer,
                shotsFired = data.shotsFired,
                rotation = data.rotation,
                angle = data.angle,
            }
        end,
        setExtraData = function(v, receivedData)
            local data = v.data._basegame
            if not data.initialized then
                return nil
            end

            data.shootTimer = receivedData.shootTimer
            data.shotsFired = receivedData.shotsFired
            data.rotation = receivedData.rotation
            data.angle = receivedData.angle
        end,
    }

    for _,npcID in ipairs{685,686,687,688,689,690,691,692,693,694} do
        onlinePlayNPC.onlineHandlingConfig[npcID] = burnerConfig
    end

    -- Piranha plant
    local piranhaPlant = require("npcs/ai/piranhaPlant")

    local piranhaPlantConfig = {
        getExtraData = function(v)
            local data = v.data._basegame
            if data.state == nil then
                return nil
            end

            return {
                state = data.state,
                timer = data.timer,
                animationTimer = data.animationTimer,
                jumpSpeed = data.jumpSpeed,
                direction = data.direction,
                home = data.home,
                friendly = data.originallyFriendly,
            }
        end,
        setExtraData = function(v,receivedData)
            local data = v.data._basegame
            if receivedData.state == nil then
                return nil
            end

            data.state = receivedData.state
            data.timer = receivedData.timer
            data.animationTimer = receivedData.animationTimer
            data.jumpSpeed = receivedData.jumpSpeed
            data.direction = receivedData.direction
            data.home = receivedData.home
            data.friendly = receivedData.originallyFriendly
        end,
    }

    for _,npcID in ipairs(piranhaPlant.idList) do
        onlinePlayNPC.onlineHandlingConfig[npcID] = piranhaPlantConfig
    end

    -- SMM Spikes
    local spikeConfig = {
        getExtraData = function(v)
            local data = v.data._basegame
            if data.state == nil then
                return nil
            end

            return {
                state = data.state,
                timer = data.timer,
                animationBall = data.animationBall,
                throwID = data.throwID,
            }
        end,
        setExtraData = function(v,receivedData)
            local data = v.data._basegame
            if receivedData.state == nil then
                return nil
            end

            data.state = receivedData.state
            data.timer = receivedData.timer
            data.animationBall = receivedData.animationBall
            data.throwID = receivedData.throwID
        end,
    }

    local spikeBallConfig = {
        getExtraData = function(v)
            local data = v.data._basegame
            if data.rotation == nil then
                return nil
            end

            return {
                rotation = data.rotation,
                isthrown = data.isthrown,
                bounced = data.bounced,
                excessSpeed = data.excessSpeed,
                wasContained = data.wasContained,
            }
        end,
        setExtraData = function(v,receivedData)
            local data = v.data._basegame
            if receivedData.rotation == nil then
                return nil
            end

            data.rotation = data.rotation or receivedData.rotation

            data.isthrown = receivedData.isthrown
            data.bounced = receivedData.bounced
            data.excessSpeed = receivedData.excessSpeed
            data.wasContained = receivedData.wasContained
        end,
    }

    for _,npcID in ipairs{640,642,644,646} do
        onlinePlayNPC.onlineHandlingConfig[npcID] = spikeConfig
    end

    for _,npcID in ipairs{641,643,645,647} do
        onlinePlayNPC.onlineHandlingConfig[npcID] = spikeBallConfig
    end

    -- Starman
    onlinePlayNPC.onlineHandlingConfig[293] = {
        useFairCollection = true,
    }

    -- Peach bomb
    onlinePlayNPC.onlineHandlingConfig[291] = {
        canClaimKillFunc = function(v,reason)
            -- Allow it if ai4 is set (this indicates that it's blowing up)
            return (v.ai4 ~= 0 and reason == HARM_TYPE_VANISH)
        end,
        onKillCommand = function(v,reason)
            -- When being told that the NPC has died, spawn an explosion
            if v.ai4 ~= 0 and reason == HARM_TYPE_VANISH then
                Explosion.create(v.x + v.width*0.5,v.y + v.height*0.5,0,v:mem(0x132,FIELD_WORD),true)
            end
        end,
    }

    -- SMB2 bomb
    onlinePlayNPC.onlineHandlingConfig[134] = {
        canClaimKillFunc = function(v,reason)
            -- Allow it if ai1 is high enough (this indicates that it's blowing up)
            return ((v.ai1 >= 350 or v.ai1 < 0) and reason == HARM_TYPE_VANISH)
        end,
        onKillCommand = function(v,reason)
            -- When being told that the NPC has died, spawn an explosion
            if (v.ai1 >= 350 or v.ai1 < 0) and reason == HARM_TYPE_VANISH then
                Explosion.create(v.x + v.width*0.5,v.y + v.height*0.5,2,0,true)
            end
        end,
    }

    -- SMB2 Bob-omb
    onlinePlayNPC.onlineHandlingConfig[135] = {
        canClaimKillFunc = function(v,reason)
            -- Allow it if ai1 is high enough (this indicates that it's blowing up)
            return ((v.ai1 >= 550 or v.ai1 < 0) and reason == HARM_TYPE_VANISH)
        end,
        onKillCommand = function(v,reason)
            -- When being told that the NPC has died, spawn an explosion
            if (v.ai1 >= 550 or v.ai1 < 0) and reason == HARM_TYPE_VANISH then
                Explosion.create(v.x + v.width*0.5,v.y + v.height*0.5,2,0,true)
            end
        end,
    }

    -- SMB3 Bob-omb
    onlinePlayNPC.onlineHandlingConfig[137] = {
        canClaimKillFunc = function(v,reason)
            -- Allow it if ai1 is high enough (this indicates that it's blowing up)
            return ((v.ai1 >= 350 or v.ai1 < 0) and reason == HARM_TYPE_NPC)
        end,
        onKillCommand = function(v,reason)
            -- When being told that the NPC has died, spawn an explosion
            if (v.ai1 >= 350 or v.ai1 < 0) and reason == HARM_TYPE_NPC then
                Explosion.create(v.x + v.width*0.5,v.y + v.height*0.5,3,0,true)
            end
        end,
    }

    -- YI countdown platform
    onlinePlayNPC.onlineHandlingConfig[340] = {
        getExtraData = function(v)
            local data = v.data._basegame
            if not data.initialized then
                return nil
            end

            return {
                counter = data.counter,
            }
        end,
        setExtraData = function(v,receivedData)
            local data = v.data._basegame
            if not data.initialized then
                return nil
            end

            data.counter = receivedData.counter
        end,
    }

    -- Prevent powerups spawned by powerup blocks from causing issues
    local powerupConfig = {
        initFunc = function(v)
            if v.data._fromPowerBlock ~= nil then
                onlinePlayNPC.tryMarkAsUnsynchronised(v)
            end
        end,
    }

    for _,npcID in ipairs{14,34,169,170,264} do
        onlinePlayNPC.onlineHandlingConfig[npcID] = powerupConfig
    end

    -- Bros
    local broConfig = {
        getExtraData = function(v)
            if v.ai1 == 0 then
                return nil
            end

            local data = v.data._basegame

            return {
                walkTimer = data.walkTimer,
                jumpTimer = data.jumpTimer,
                throwTimer = data.throwTimer,
                throwingID = data.throwingID,
                walkDirection = data.walkDirection,
                initialDirection = data.initialDirection,
                facingDirection = data.facingDirection,
                stallTimer = data.stallTimer,
                animationFrame = data.animationFrame,
                held = data.held,
            }
        end,
        setExtraData = function(v,receivedData)
            local data = v.data._basegame

            data.walkTimer = receivedData.walkTimer
            data.jumpTimer = receivedData.jumpTimer
            data.throwTimer = receivedData.throwTimer
            data.throwingID = receivedData.throwingID
            data.walkDirection = receivedData.walkDirection
            data.initialDirection = receivedData.initialDirection
            data.facingDirection = receivedData.facingDirection
            data.stallTimer = receivedData.stallTimer
            data.animationFrame = receivedData.animationFrame
            data.held = receivedData.held
        end,
    }

    for _,npcID in ipairs{616,614,389,618} do
        onlinePlayNPC.onlineHandlingConfig[npcID] = broConfig
    end

    -- Clear pipe NPC
    -- Very important so that random King Bills don't spawn!
    onlinePlayNPC.onlineHandlingConfig[468] = {
        getExtraData = function(v)
            local data = v.data._basegame

            return {
                id = data.id,
                animationFrame = data.animationFrame,
                animationTimer = data.animationTimer,
                speedX = data.speedX,
                speedY = data.speedY,
            }
        end,
        setExtraData = function(v,receivedData)
            local data = v.data._basegame

            data._clearpipe_storage = data._clearpipe_storage or {}

            data.id = receivedData.id
            data.animationFrame = receivedData.animationFrame
            data.animationTimer = receivedData.animationTimer
            data.speedX = receivedData.speedX
            data.speedY = receivedData.speedY
        end,
    }
end


function onlinePlayNPC.onInitAPI()
    registerEvent(onlinePlayNPC,"onNPCHarm","onNPCHarm",false)
    registerEvent(onlinePlayNPC,"onNPCKill","onNPCKill",false)
    registerEvent(onlinePlayNPC,"onPostNPCKill")
    registerEvent(onlinePlayNPC,"onNPCCollect","onNPCCollect",false)
    registerEvent(onlinePlayNPC,"onNPCTransform")
    registerEvent(onlinePlayNPC,"onNPCGenerated")

    registerEvent(onlinePlayNPC,"onStart")


    onlinePlay = require("scripts/onlinePlay")
    onlinePlayPlayers = require("scripts/onlinePlay_players")
    
    battlePlayer = require("scripts/battlePlayer")

    onlinePlayNPC.initCommands()


    function onlinePlay.onInitialise()
        if onlinePlay.currentMode == onlinePlay.MODE_HOST then
            onlinePlayNPC.uidCounter = onlinePlayNPC.spawnUIDCounter
        else
            onlinePlayNPC.uidCounter = onlinePlayNPC.npcUIDClientStart + (onlinePlay.playerIdx - 2)*onlinePlayNPC.npcUIDClientGap
        end

        onlinePlayNPC.uidPredictiveCounter = onlinePlayNPC.spawnUIDCounter
    end

    function onlinePlay.onUninitialise()
        onlinePlayNPC.uidCounter = onlinePlayNPC.spawnUIDCounter
        onlinePlayNPC.uidPredictiveCounter = onlinePlayNPC.spawnUIDCounter

        npcsWaitingForResponse = {}
        npcOnlineUIDMap = {}
    end
end


return onlinePlayNPC