-- Bundled by luabundle {"version":"1.6.0"}
local __bundle_require, __bundle_loaded, __bundle_register, __bundle_modules = (function(superRequire)
	local loadingPlaceholder = {[{}] = true}

	local register
	local modules = {}

	local require
	local loaded = {}

	register = function(name, body)
		if not modules[name] then
			modules[name] = body
		end
	end

	require = function(name)
		local loadedModule = loaded[name]

		if loadedModule then
			if loadedModule == loadingPlaceholder then
				return nil
			end
		else
			if not modules[name] then
				if not superRequire then
					local identifier = type(name) == 'string' and '\"' .. name .. '\"' or tostring(name)
					error('Tried to require ' .. identifier .. ', but no such module has been registered')
				else
					return superRequire(name)
				end
			end

			loaded[name] = loadingPlaceholder
			loadedModule = modules[name](require, loaded, register, modules)
			loaded[name] = loadedModule
		end

		return loadedModule
	end

	return require, loaded, register, modules
end)(nil)
__bundle_register("core/token/TokenManager", function(require, _LOADED, __bundle_register, __bundle_modules)
do
  local tokenSpawnTracker = require("core/token/TokenSpawnTrackerApi")
  local playArea = require("core/PlayAreaApi")

  local PLAYER_CARD_TOKEN_OFFSETS = {
    [1] = {
      Vector(0, 3, -0.2)
    },
    [2] = {
      Vector(0.4, 3, -0.2),
      Vector(-0.4, 3, -0.2)
    },
    [3] = {
      Vector(0, 3, -0.9),
      Vector(0.4, 3, -0.2),
      Vector(-0.4, 3, -0.2)
    },
    [4] = {
      Vector(0.4, 3, -0.9),
      Vector(-0.4, 3, -0.9),
      Vector(0.4, 3, -0.2),
      Vector(-0.4, 3, -0.2)
    },
    [5] = {
      Vector(0.7, 3, -0.9),
      Vector(0, 3, -0.9),
      Vector(-0.7, 3, -0.9),
      Vector(0.4, 3, -0.2),
      Vector(-0.4, 3, -0.2)
    },
    [6] = {
      Vector(0.7, 3, -0.9),
      Vector(0, 3, -0.9),
      Vector(-0.7, 3, -0.9),
      Vector(0.7, 3, -0.2),
      Vector(0, 3, -0.2),
      Vector(-0.7, 3, -0.2)
    },
    [7] = {
      Vector(0.7, 3, -0.9),
      Vector(0, 3, -0.9),
      Vector(-0.7, 3, -0.9),
      Vector(0.7, 3, -0.2),
      Vector(0, 3, -0.2),
      Vector(-0.7, 3, -0.2),
      Vector(0, 3, 0.5)
    },
    [8] = {
      Vector(0.7, 3, -0.9),
      Vector(0, 3, -0.9),
      Vector(-0.7, 3, -0.9),
      Vector(0.7, 3, -0.2),
      Vector(0, 3, -0.2),
      Vector(-0.7, 3, -0.2),
      Vector(-0.35, 3, 0.5),
      Vector(0.35, 3, 0.5)
    },
    [9] = {
      Vector(0.7, 3, -0.9),
      Vector(0, 3, -0.9),
      Vector(-0.7, 3, -0.9),
      Vector(0.7, 3, -0.2),
      Vector(0, 3, -0.2),
      Vector(-0.7, 3, -0.2),
      Vector(0.7, 3, 0.5),
      Vector(0, 3, 0.5),
      Vector(-0.7, 3, 0.5)
    },
    [10] = {
      Vector(0.7, 3, -0.9),
      Vector(0, 3, -0.9),
      Vector(-0.7, 3, -0.9),
      Vector(0.7, 3, -0.2),
      Vector(0, 3, -0.2),
      Vector(-0.7, 3, -0.2),
      Vector(0.7, 3, 0.5),
      Vector(0, 3, 0.5),
      Vector(-0.7, 3, 0.5),
      Vector(0, 3, 1.2)
    },
    [11] = {
      Vector(0.7, 3, -0.9),
      Vector(0, 3, -0.9),
      Vector(-0.7, 3, -0.9),
      Vector(0.7, 3, -0.2),
      Vector(0, 3, -0.2),
      Vector(-0.7, 3, -0.2),
      Vector(0.7, 3, 0.5),
      Vector(0, 3, 0.5),
      Vector(-0.7, 3, 0.5),
      Vector(-0.35, 3, 1.2),
      Vector(0.35, 3, 1.2)
    },
    [12] = {
      Vector(0.7, 3, -0.9),
      Vector(0, 3, -0.9),
      Vector(-0.7, 3, -0.9),
      Vector(0.7, 3, -0.2),
      Vector(0, 3, -0.2),
      Vector(-0.7, 3, -0.2),
      Vector(0.7, 3, 0.5),
      Vector(0, 3, 0.5),
      Vector(-0.7, 3, 0.5),
      Vector(0.7, 3, 1.2),
      Vector(0, 3, 1.2),
      Vector(-0.7, 3, 1.2)
    }
  }

  -- Source for tokens
  local TOKEN_SOURCE_GUID = "124381"

  -- Table of data extracted from the token source bag, keyed by the Memo on each token which
  -- should match the token type keys ("resource", "clue", etc)
  local tokenTemplates

  local DATA_HELPER_GUID = "708279"

  local playerCardData
  local locationData

  local TokenManager = { }
  local internal = { }

  -- Spawns tokens for the card.  This function is built to just throw a card at it and let it do
  -- the work once a card has hit an area where it might spawn tokens.  It will check to see if
  -- the card has already spawned, find appropriate data from either the uses metadata or the Data
  -- Helper, and spawn the tokens.
  ---@param card Object Card to maybe spawn tokens for
  ---@param extraUses Table A table of <use type>=<count> which will modify the number of tokens
  ---    spawned for that type.  e.g. Akachi's playmat should pass "Charge"=1
  TokenManager.spawnForCard = function(card, extraUses)
    if tokenSpawnTracker.hasSpawnedTokens(card.getGUID()) then
      return
    end
    local metadata = JSON.decode(card.getGMNotes())
    if metadata ~= nil then
      internal.spawnTokensFromUses(card, extraUses)
    else
      internal.spawnTokensFromDataHelper(card)
    end
  end

  -- Spawns a set of tokens on the given card.
  ---@param card Object Card to spawn tokens on
  ---@param tokenType String type of token to spawn, valid values are "damage", "horror",
  --     "resource", "doom", or "clue"
  ---@param tokenCount Number How many tokens to spawn.  For damage or horror this value will be set to the
  --     spawned state object rather than spawning multiple tokens
  ---@param shiftDown Number An offset for the z-value of this group of tokens
  TokenManager.spawnTokenGroup = function(card, tokenType, tokenCount, shiftDown)
    local optionPanel = Global.getTable("optionPanel")

    if tokenType == "damage" or tokenType == "horror" then
      TokenManager.spawnCounterToken(card, tokenType, tokenCount, shiftDown)
    elseif tokenType == "resource" and optionPanel["useResourceCounters"] then
      TokenManager.spawnResourceCounterToken(card, tokenCount)
    else
      TokenManager.spawnMultipleTokens(card, tokenType, tokenCount, shiftDown)
    end
  end

  -- Spawns a single counter token and sets the value to tokenValue.  Used for damage and horror
  -- tokens.
  ---@param card Object Card to spawn tokens on
  ---@param tokenType String type of token to spawn, valid values are "damage" and "horror".  Other
  --     types should use spawnMultipleTokens()
  ---@param tokenValue Number Value to set the damage/horror to
  TokenManager.spawnCounterToken = function(card, tokenType, tokenValue, shiftDown)
    if tokenValue < 1 or tokenValue > 50 then return end

    local pos = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[1][1] + Vector(0, 0, shiftDown))
    local rot = card.getRotation()
    TokenManager.spawnToken(pos, tokenType, rot, function(spawned) spawned.setState(tokenValue) end)
  end

  TokenManager.spawnResourceCounterToken = function(card, tokenCount)
    local pos = card.positionToWorld(card.positionToLocal(card.getPosition()) + Vector(0, 0.2, -0.5))
    local rot = card.getRotation()
    TokenManager.spawnToken(pos, "resourceCounter", rot, function(spawned)
      spawned.call("updateVal", tokenCount)
    end)
  end

  -- Spawns a number of tokens.
  ---@param tokenType String type of token to spawn, valid values are resource", "doom", or "clue".
  --     Other types should use spawnCounterToken()
  ---@param tokenCount Number How many tokens to spawn
  ---@param shiftDown Number An offset for the z-value of this group of tokens
  TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown)
    if tokenCount < 1 or tokenCount > 12 then
      return
    end

    local offsets = {}
    if tokenType == "clue" then
      offsets = internal.buildClueOffsets(card, tokenCount)
    else
      for i = 1, tokenCount do
        offsets[i] = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[tokenCount][i])
        -- Fix the y-position for the spawn, since positionToWorld considers rotation which can
        -- have bad results for face up/down differences
        offsets[i].y = card.getPosition().y + 0.15
      end
    end

    if shiftDown ~= nil then
      -- Copy the offsets to make sure we don't change the static values
      local baseOffsets = offsets
      offsets = { }
      for i, baseOffset in ipairs(baseOffsets) do
        offsets[i] = baseOffset
        offsets[i][3] = offsets[i][3] + shiftDown
      end
    end

    if offsets == nil then
      error("couldn't find offsets for " .. tokenCount .. ' tokens')
      return
    end

    for i = 1, tokenCount do
      TokenManager.spawnToken(offsets[i], tokenType, card.getRotation())
    end
  end

  -- Spawns a single token at the given global position by copying it from the template bag.
  ---@param position Global position to spawn the token
  ---@param tokenType String type of token to spawn, valid values are "damage", "horror",
  --     "resource", "doom", or "clue"
  ---@param rotation Vector Rotation to be used for the new token.  Only the y-value will be used,
  --     x and z will use the default rotation from the source bag
  ---@param callback function A callback function triggered after the new token is spawned
  TokenManager.spawnToken = function(position, tokenType, rotation, callback)
    internal.initTokenTemplates()
    local loadTokenType = tokenType
    if tokenType == "clue" or tokenType == "doom" then
      loadTokenType = "clueDoom"
    end
    if tokenTemplates[loadTokenType] == nil then
      error("Unknown token type '" .. tokenType .. "'")
      return
    end
    local tokenTemplate = tokenTemplates[loadTokenType]

    -- Take ONLY the Y-value for rotation, so we don't flip the token coming out of the bag
    local rot = Vector(tokenTemplate.Transform.rotX,
        270,
        tokenTemplate.Transform.rotZ)
    if rotation ~= nil then
      rot.y = rotation.y
    end
    if tokenType == "doom" then
      rot.z = 180
    end

    tokenTemplate.Nickname = ""
    return spawnObjectData({
      data = tokenTemplate,
      position = position,
      rotation = rot,
      callback_function = callback
    })
  end

  -- Delegate function to the token spawn tracker.  Exists to avoid circular dependencies in some
  -- callers.
  ---@param card Object Card object to reset the tokens for
  TokenManager.resetTokensSpawned = function(card)
    tokenSpawnTracker.resetTokensSpawned(card.getGUID())
  end

  -- Pushes new player card data into the local copy of the Data Helper player data.
  ---@param dataTable Table Key/Value pairs following the DataHelper style
  TokenManager.addPlayerCardData = function(dataTable)
    internal.initDataHelperData()
    for k, v in pairs(dataTable) do
      playerCardData[k] = v
    end
  end

  -- Pushes new location data into the local copy of the Data Helper location data.
  ---@param dataTable Table Key/Value pairs following the DataHelper style
  TokenManager.addLocationData = function(dataTable)
    internal.initDataHelperData()
    for k, v in pairs(dataTable) do
      locationData[k] = v
    end
  end

  -- Checks to see if the given card has location data in the DataHelper
  ---@param card Object Card to check for data
  ---@return Boolean True if this card has data in the helper, false otherwise
  TokenManager.hasLocationData = function(card)
    internal.initDataHelperData()
    return internal.getLocationData(card) ~= nil
  end

  internal.initTokenTemplates = function()
    if tokenTemplates ~= nil then
      return
    end
    tokenTemplates = { }
    local tokenSource = getObjectFromGUID(TOKEN_SOURCE_GUID)
    for _, tokenTemplate in ipairs(tokenSource.getData().ContainedObjects) do
      local tokenName = tokenTemplate.Memo
      tokenTemplates[tokenName] = tokenTemplate
    end
  end

  -- Copies the data from the DataHelper.  Will only happen once.
  internal.initDataHelperData = function()
    if playerCardData ~= nil then
      return
    end
    local dataHelper = getObjectFromGUID(DATA_HELPER_GUID)
    playerCardData = dataHelper.getTable('PLAYER_CARD_DATA')
    locationData = dataHelper.getTable('LOCATIONS_DATA')
  end

  -- Spawn tokens for a card based on the uses metadata.  This will consider the face up/down state
  -- of the card for both locations and standard cards.
  ---@param card Object Card to maybe spawn tokens for
  ---@param extraUses Table A table of <use type>=<count> which will modify the number of tokens
  ---    spawned for that type.  e.g. Akachi's playmat should pass "Charge"=1
  internal.spawnTokensFromUses = function(card, extraUses)
    local uses = internal.getUses(card)
    if uses == nil then
      return
    end
    local type = nil
    local token = nil
    local tokenCount = 0
    -- Uses structure underwent a breaking change in 2.4.0, have to check to see if this is
    -- a single entry or an array.  This is ugly and duplicated, but impossible to replicate the
    -- multi-spawn vs. single spawn otherwise.  TODO: Clean this up when 2.4.0 has been out long
    -- enough that saved decks don't have old data
    if uses.count != nil then
      type = cardMetadata.uses.type
      token = cardMetadata.uses.token
      tokenCount = cardMetadata.uses.count
      if extraUses ~= nil and extraUses[type] ~= nil then
        tokenCount = tokenCount + extraUses[type]
      end
      log("Spawning single use tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token)
      TokenManager.spawnTokenGroup(card, token, tokenCount)
    else
      for i, useInfo in ipairs(uses) do
        type = useInfo.type
        token = useInfo.token
        tokenCount = (useInfo.count or 0)
            + (useInfo.countPerInvestigator or 0) * playArea.getInvestigatorCount()
        if extraUses ~= nil and extraUses[type] ~= nil then
          tokenCount = tokenCount + extraUses[type]
        end
        log("Spawning use array tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token)
        -- Shift each spawned group after the first down so they don't pile on each other
        TokenManager.spawnTokenGroup(card, token, tokenCount, (i - 1) * 0.8)
      end
    end
    tokenSpawnTracker.markTokensSpawned(card.getGUID())
  end

  -- Spawn tokens for a card based on the data helper data.  This will consider the face up/down state
  -- of the card for both locations and standard cards.
  ---@param card Object Card to maybe spawn tokens for
  internal.spawnTokensFromDataHelper = function(card)
    internal.initDataHelperData()
    local playerData = internal.getPlayerCardData(card)
    if playerData ~= nil then
      internal.spawnPlayerCardTokensFromDataHelper(card, playerData)
    end
    local locationData = internal.getLocationData(card)
    if locationData ~= nil then
      internal.spawnLocationTokensFromDataHelper(card, locationData)
    end
  end

  -- Spawn tokens for a player card using data retrieved from the Data Helper.
  ---@param card Object Card to maybe spawn tokens for
  ---@param playerData Table Player card data structure retrieved from the DataHelper.  Should be
  --     the right data for this card.
  internal.spawnPlayerCardTokensFromDataHelper = function(card, playerData)
    token = playerData.tokenType
    tokenCount = playerData.tokenCount
    log("Spawning data helper tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token)
    TokenManager.spawnTokenGroup(card, token, tokenCount)
    tokenSpawnTracker.markTokensSpawned(card.getGUID())
  end

  -- Spawn tokens for a location using data retrieved from the Data Helper.
  ---@param card Object Card to maybe spawn tokens for
  ---@param playerData Table Location data structure retrieved from the DataHelper.  Should be
  --     the right data for this card.
  internal.spawnLocationTokensFromDataHelper = function(card, locationData)
    local clueCount = internal.getClueCountFromData(card, locationData)
    if clueCount > 0 then
      TokenManager.spawnTokenGroup(card, "clue", clueCount)
      tokenSpawnTracker.markTokensSpawned(card.getGUID())
    end
  end

  internal.getPlayerCardData = function(card)
    return playerCardData[card.getName() .. ':' .. card.getDescription()]
        or playerCardData[card.getName()]
  end

  internal.getLocationData = function(card)
    return locationData[card.getName() .. '_' .. card.getGUID()] or locationData[card.getName()]
  end

  internal.getClueCountFromData = function(card, locationData)
    -- Return the number of clues to spawn on this location
    if locationData == nil then
      error('attempted to get clue for unexpected object: ' .. card.getName())
      return 0
    end

    log(card.getName() .. ' : ' .. locationData.type .. ' : ' .. locationData.value .. ' : ' .. locationData.clueSide)
    if ((card.is_face_down and locationData.clueSide == 'back')
        or (not card.is_face_down and locationData.clueSide == 'front')) then
      if locationData.type == 'fixed' then
        return locationData.value
      elseif locationData.type == 'perPlayer' then
        return locationData.value * playArea.getInvestigatorCount()
      end
      error('unexpected location type: ' .. locationData.type)
    end
    return 0
  end

  -- Gets the right uses structure for this card, based on metadata and face up/down state
  ---@param card Object Card to pull the uses from
  internal.getUses = function(card)
    local metadata = JSON.decode(card.getGMNotes()) or { }
    if metadata.type == "Location" then
      if card.is_face_down and metadata.locationBack ~= nil then
        return metadata.locationBack.uses
      elseif not card.is_face_down and metadata.locationFront ~= nil then
        return metadata.locationFront.uses
      end
    elseif not card.is_face_down then
      return metadata.uses
    end

    return nil
  end

  -- Dynamically create positions for clues on a card.
  ---@param card Object Card the clues will be placed on
  ---@param count Integer How many clues?
  ---@return Table Array of global positions to spawn the clues at
  internal.buildClueOffsets = function(card, count)
    local pos = card.getPosition()
    local cluePositions = { }
    for i = 1, count do
      local column = math.floor((i - 1) / 4)
      local row = (i - 1) % 4
      table.insert(cluePositions, Vector(pos.x - 1 + 0.55 * row, pos.y, pos.z - 1.4 - 0.55 * column))
    end

    return cluePositions
  end

  return TokenManager

end
end)
__bundle_register("playermat/PlaymatApi", function(require, _LOADED, __bundle_register, __bundle_modules)
do
  local PlaymatApi = { }
  local internal = { }

  local MAT_IDS = {
    White  = "8b081b",
    Orange = "bd0ff4",
    Green  = "383d8b",
    Red    = "0840d5"
  }

  local CLUE_COUNTER_GUIDS = {
    White  = "37be78",
    Orange = "1769ed",
    Green  = "032300",
    Red    = "d86b7c"
  }

  local CLUE_CLICKER_GUIDS = {
    White  = "db85d6",
    Orange = "3f22e5",
    Green  = "891403",
    Red    = "4111de"
  }

  -- Returns the color of the by position requested playermat as string
  ---@param startPos Table Position of the search, table get's roughly cut into 4 quarters to assign a playermat
  PlaymatApi.getMatColorByPosition = function(startPos)
    if startPos.x < -42 then
      if startPos.z > 0 then
        return "White"
      else
        return "Orange"
      end
    else
      if startPos.z > 0 then
        return "Green"
      else
        return "Red"
      end
    end
  end

  -- Returns the draw deck of the requested playmat
  ---@param matColor String Color of the playermat
  PlaymatApi.getDrawDeck = function(matColor)
    local mat = getObjectFromGUID(MAT_IDS[matColor])
    mat.call("getDrawDiscardDecks")
    return mat.getVar("drawDeck")
  end

  -- Returns the position of the discard pile of the requested playmat
  ---@param matColor String Color of the playermat
  PlaymatApi.getDiscardPosition = function(matColor)
    local mat = getObjectFromGUID(MAT_IDS[matColor])
    return mat.call("returnGlobalDiscardPosition")
  end

  -- Sets the requested playermat's snap points to limit snapping to matching card types or not.  If
  -- matchTypes is true, the main card slot snap points will only snap assets, while the
  -- investigator area point will only snap Investigators.  If matchTypes is false, snap points will
  -- be reset to snap all cards.
  ---@param matchCardTypes Boolean.  Whether snap points should only snap for the matching card
  --     types.
  ---@param matColor String for one of the active player colors - White, Orange, Green, Red.  Also
  --     accepts "All" as a special value which will apply the setting to all four mats.
  PlaymatApi.setLimitSnapsByType = function(matchCardTypes, matColor)
    for _, mat in ipairs(internal.getMatForColor(matColor)) do
      mat.call("setLimitSnapsByType", matchCardTypes)
    end
  end

  -- Sets the requested playermat's draw 1 button to visible
  ---@param isDrawButtonVisible Boolean.  Whether the draw 1 button should be visible or not
  ---@param matColor String for one of the active player colors - White, Orange, Green, Red.  Also
  --     accepts "All" as a special value which will apply the setting to all four mats.
  PlaymatApi.showDrawButton = function(isDrawButtonVisible, matColor)
    for _, mat in ipairs(internal.getMatForColor(matColor)) do
      mat.call("showDrawButton", isDrawButtonVisible)
    end
  end

  -- Shows or hides the clickable clue counter for the requested playermat
  ---@param showCounter Boolean.  Whether the clickable counter should be present or not
  ---@param matColor String for one of the active player colors - White, Orange, Green, Red.  Also
  --     accepts "All" as a special value which will apply the setting to all four mats.
  PlaymatApi.clickableClues = function(showCounter, matColor)
    for _, mat in ipairs(internal.getMatForColor(matColor)) do
      mat.call("clickableClues", showCounter)
    end
  end

  -- Removes all clues (to the trash for tokens and counters set to 0) for the requested playermat
  ---@param matColor String for one of the active player colors - White, Orange, Green, Red.  Also
  --     accepts "All" as a special value which will apply the setting to all four mats.
  PlaymatApi.removeClues = function(matColor)
    for _, mat in ipairs(internal.getMatForColor(matColor)) do
      mat.call("removeClues")
    end
  end

  -- Reports the clue count for the requested playermat
  ---@param useClickableCounters Boolean Controls which type of counter is getting checked
  PlaymatApi.getClueCount = function(useClickableCounters, matColor)
    local count = 0
    for _, mat in ipairs(internal.getMatForColor(matColor)) do
      count = count + tonumber(mat.call("getClueCount", useClickableCounters))
    end
    return count
  end

  -- Convenience function to look up a mat's object by color, or get all mats.
  ---@param matColor String for one of the active player colors - White, Orange, Green, Red.  Also
  --     accepts "All" as a special value which will return all four mats.
  ---@return: Array of playermat objects.  If a single mat is requested, will return a single-element
  --     array to simplify processing by consumers.
  internal.getMatForColor = function(matColor)
    local targetMatGuid = MAT_IDS[matColor]
    if targetMatGuid != nil then
      return { getObjectFromGUID(targetMatGuid) }
    end
    if matColor == "All" then
      return {
        getObjectFromGUID(MAT_IDS.White),
        getObjectFromGUID(MAT_IDS.Orange),
        getObjectFromGUID(MAT_IDS.Green),
        getObjectFromGUID(MAT_IDS.Red),
      }
    end
  end

  return PlaymatApi
end
end)
__bundle_register("core/token/TokenSpawnTrackerApi", function(require, _LOADED, __bundle_register, __bundle_modules)
do
  local TokenSpawnTracker = { }

  local SPAWN_TRACKER_GUID = "e3ffc9"

  TokenSpawnTracker.hasSpawnedTokens = function(cardGuid)
    return getObjectFromGUID(SPAWN_TRACKER_GUID).call("hasSpawnedTokens", cardGuid)
  end

  TokenSpawnTracker.markTokensSpawned = function(cardGuid)
    return getObjectFromGUID(SPAWN_TRACKER_GUID).call("markTokensSpawned", cardGuid)
  end

  TokenSpawnTracker.resetTokensSpawned = function(cardGuid)
    return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetTokensSpawned", cardGuid)
  end

  TokenSpawnTracker.resetAllAssetAndEvents = function()
    return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAllAssetAndEvents")
  end

  TokenSpawnTracker.resetAllLocations = function()
    return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAllLocations")
  end

  TokenSpawnTracker.resetAll = function()
    return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAll")
  end

  return TokenSpawnTracker
end
end)
__bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules)
require("core/Global")
end)
__bundle_register("core/Global", function(require, _LOADED, __bundle_register, __bundle_modules)
---------------------------------------------------------
-- general setup
---------------------------------------------------------

ENCOUNTER_DECK_POS = {-3.93, 1, 5.76}
ENCOUNTER_DECK_DISCARD_POSITION = {-3.85, 1, 10.38}

-- optionPanel data
optionPanel = {}

-- GUID of data helper
DATA_HELPER_GUID = "708279"

-- GUID of fan-made accessories bag (also just called "barrel")
BARREL_GUID = "aa8b38"

-- GUIDs that will not be interactable (e.g. parts of the table)
local NOT_INTERACTABLE = {
  "6161b4", -- Decoration-Map
  "721ba2", -- PlayArea
  "9f334f", -- MythosArea
  "463022", -- Panel behind tentacle stand
  "f182ee", -- InvestigatorCount
  "7bff34", -- Tentacle stand
  "8646eb", -- horizontal border left
  "75937e", -- horizontal border right
  "612072", -- vertical border left
  "975c39", -- vertical border right
}

local chaosTokens = {}
local chaosTokensLastMat = nil
local IS_RESHUFFLING = false
local bagSearchers = {}
local hideTitleSplashWaitFunctionId = nil
local playmatAPI = require("playermat/PlaymatApi")
local tokenManager = require("core/token/TokenManager")
local playAreaAPI = require("core/PlayAreaApi")

---------------------------------------------------------
-- data for tokens
---------------------------------------------------------

TOKEN_DATA = {
  damage   = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357115146/903D11AAE7BD5C254C8DC136E9202EE516289DEA/", scale = {0.17, 0.17, 0.17}},
  horror   = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357163535/6D9E0756503664D65BDB384656AC6D4BD713F5FC/", scale = {0.17, 0.17, 0.17}},
  resource = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357192910/11DDDC7EF621320962FDCF3AE3211D5EDC3D1573/", scale = {0.17, 0.17, 0.17}},
  doom     = {image = "https://i.imgur.com/EoL7yaZ.png", scale = {0.17, 0.17, 0.17}},
  clue     = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357164917/1D06F1DC4D6888B6F57124BD2AFE20D0B0DA15A8/", scale = {0.15, 0.15, 0.15}}
}

IMAGE_TOKEN_MAP = {
  ["https://i.imgur.com/nEmqjmj.png"] = "Elder Sign",
  ["https://i.imgur.com/uIx8jbY.png"] = "+1",
  ["https://i.imgur.com/btEtVfd.png"] = "0",
  ["https://i.imgur.com/w3XbrCC.png"] = "-1",
  ["https://i.imgur.com/bfTg2hb.png"] = "-2",
  ["https://i.imgur.com/yfs8gHq.png"] = "-3",
  ["https://i.imgur.com/qrgGQRD.png"] = "-4",
  ["https://i.imgur.com/3Ym1IeG.png"] = "-5",
  ["https://i.imgur.com/c9qdSzS.png"] = "-6",
  ["https://i.imgur.com/4WRD42n.png"] = "-7",
  ["https://i.imgur.com/9t3rPTQ.png"] = "-8",
  ["https://i.imgur.com/stbBxtx.png"] = "Skull",
  ["https://i.imgur.com/VzhJJaH.png"] = "Cultist",
  ["https://i.imgur.com/1plY463.png"] = "Tablet",
  ["https://i.imgur.com/ttnspKt.png"] = "Elder Thing",
  ["https://i.imgur.com/lns4fhz.png"] = "Auto-fail",
  ["http://cloud-3.steamusercontent.com/ugc/1655601092778627699/339FB716CB25CA6025C338F13AFDFD9AC6FA8356/"] = "Bless",
  ["http://cloud-3.steamusercontent.com/ugc/1655601092778636039/2A25BD38E8C44701D80DD96BF0121DA21843672E/"] = "Curse",
  ["http://cloud-3.steamusercontent.com/ugc/1858293462583104677/195F93C063A8881B805CE2FD4767A9718B27B6AE/"] = "Frost"
}

---------------------------------------------------------
-- data for chaos token stat tracker
---------------------------------------------------------

local maxSquid = 0

MAT_GUID_TO_COLOUR = {
  ["8b081b"] = "White",
  ["bd0ff4"] = "Orange",
  ["383d8b"] = "Green",
  ["0840d5"] = "Red"
}

local personalStats = {
  ["8b081b"] = {},
  ["bd0ff4"] = {},
  ["383d8b"] = {},
  ["0840d5"] = {}
}

local overallStats = {
  -- cultist
  ["https://i.imgur.com/VzhJJaH.png"] = 0,
  -- skull
  ["https://i.imgur.com/stbBxtx.png"] = 0,
  -- tablet
  ["https://i.imgur.com/1plY463.png"] = 0,
  -- curse
  ["http://cloud-3.steamusercontent.com/ugc/1655601092778636039/2A25BD38E8C44701D80DD96BF0121DA21843672E/"] = 0,
  -- tentacle
  ["https://i.imgur.com/lns4fhz.png"] = 0,
  -- minus eight
  ["https://i.imgur.com/9t3rPTQ.png"] = 0,
  -- minus seven
  ["https://i.imgur.com/4WRD42n.png"] = 0,
  -- minus six
  ["https://i.imgur.com/c9qdSzS.png"] = 0,
  -- minus five
  ["https://i.imgur.com/3Ym1IeG.png"] = 0,
  -- minus four
  ["https://i.imgur.com/qrgGQRD.png"] = 0,
  -- minus three
  ["https://i.imgur.com/yfs8gHq.png"] = 0,
  -- minus two
  ["https://i.imgur.com/bfTg2hb.png"] = 0,
  -- minus one
  ["https://i.imgur.com/w3XbrCC.png"] = 0,
  -- zero
  ["https://i.imgur.com/btEtVfd.png"] = 0,
  -- plus one
  ["https://i.imgur.com/uIx8jbY.png"] = 0,
  -- elder thing
  ["https://i.imgur.com/ttnspKt.png"] = 0,
  -- bless
  ["http://cloud-3.steamusercontent.com/ugc/1655601092778627699/339FB716CB25CA6025C338F13AFDFD9AC6FA8356/"] = 0,
  -- elder sign
  ["https://i.imgur.com/nEmqjmj.png"] = 0,
  -- frost
  ["http://cloud-3.steamusercontent.com/ugc/1858293462583104677/195F93C063A8881B805CE2FD4767A9718B27B6AE/"] = 0,
}

---------------------------------------------------------
-- general code
---------------------------------------------------------

-- saving state of optionPanel to restore later
function onSave() return JSON.encode({ optionPanel = optionPanel }) end

function onLoad(savedData)
  if savedData then
    loadedData = JSON.decode(savedData)
    optionPanel = loadedData.optionPanel
    -- hack to disable on load
    playmatAPI.clickableClues(false, "All")
  else
    print("Saved state could not be found!")
  end

  for _, guid in ipairs(NOT_INTERACTABLE) do
    local obj = getObjectFromGUID(guid)
    if obj ~= nil then obj.interactable = false end
  end

  math.randomseed(os.time())
end

---------------------------------------------------------
-- encounter card drawing
---------------------------------------------------------

function isDeck(x) return x.tag == 'Deck' end

function isCardOrDeck(x) return x.tag == 'Card' or x.tag == 'Deck' end

-- Event hook for any object search.  When chaos tokens are manipulated while the chaos bag
-- container is being searched, a TTS bug can cause tokens to duplicate or vanish.  We lock the
-- chaos bag during search operations to avoid this.
function onObjectSearchStart(object, playerColor)
  chaosbag = findChaosBag()
  if object == chaosbag then
    bagSearchers[playerColor] = true
  end
end

-- Event hook for any object search.  When chaos tokens are manipulated while the chaos bag
-- container is being searched, a TTS bug can cause tokens to duplicate or vanish.  We lock the
-- chaos bag during search operations to avoid this.
function onObjectSearchEnd(object, playerColor)
  chaosbag = findChaosBag()
  if object == chaosbag then
    bagSearchers[playerColor] = nil
  end
end

-- Pass object enter container events to the PlayArea to clear vector lines from dragged cards.
-- This requires the try method as cards won't exist any more after they enter a deck, so the lines
-- can't be cleared.
function tryObjectEnterContainer(container, object)
  playAreaAPI.tryObjectEnterContainer(container, object)
  return true
end

function drawEncountercard(color) --[[ Parameter Player color ]]
  local card
  local items = findInRadiusBy(ENCOUNTER_DECK_POS, 4, isCardOrDeck)
  if #items > 0 then
    for _, v in ipairs(items) do
      if v.tag == 'Deck' then
        v.deal(1, color)
        return
      end
    end
    -- we didn't find the deck so just pull the first thing we did find
    items[1].deal(1, color)
  else
    -- nothing here, time to reshuffle
    reshuffleEncounterDeck(color)
  end
end

function actualEncounterCardDraw(card, params)
  local position = params[1]
  local rotation = params[2]
  local alwaysFaceUp = params[3]
  local faceUpRotation = 0
  if not alwaysFaceUp then
    if getObjectFromGUID(DATA_HELPER_GUID).call('checkHiddenCard', card.getName()) then
      faceUpRotation = 180
    end
  end
  card.setPositionSmooth(position, false, false)
  card.setRotationSmooth({0, rotation.y, faceUpRotation}, false, false)
end

function reshuffleEncounterDeck(color)
  -- finishes moving the deck back and draws a card
  local function move(deck)
    deck.setPositionSmooth({ENCOUNTER_DECK_POS[1], ENCOUNTER_DECK_POS[2] + 2, ENCOUNTER_DECK_POS[3]}, false, true)
    deck.deal(1, color)
    Wait.time(function() IS_RESHUFFLING = false end, 1)
  end
  -- bail out if we're mid reshuffle
  if IS_RESHUFFLING then return end
  local discarded = findInRadiusBy(ENCOUNTER_DECK_DISCARD_POSITION, 4, isDeck)
  if #discarded > 0 then
    IS_RESHUFFLING = true
    local deck = discarded[1]
    if not deck.is_face_down then deck.flip() end
    deck.shuffle()
    Wait.time(|| move(deck), 0.3)
  else
    printToAll("Couldn't find encounter discard pile to reshuffle.", {1, 0, 0})
  end
end

function findInRadiusBy(pos, radius, filter)
  local objList = Physics.cast({
    origin = pos,
    direction = {0, 1, 0},
    type = 2,
    size = {radius, radius, radius},
    max_distance = 0
  })

  local filteredList = {}
  for _, obj in ipairs(objList) do
    if filter and filter(obj.hit_object) then
      table.insert(filteredList, obj.hit_object)
    end
  end
  return filteredList
end

---------------------------------------------------------
-- chaos token drawing
---------------------------------------------------------

-- checks scripting zone for chaos bag
function findChaosBag()
  for _, item in ipairs(getObjectFromGUID("83ef06").getObjects()) do
    if item.getDescription() == "Chaos Bag" then
      return item
    end
  end
end

function returnChaosTokens()
  for _, token in pairs(chaosTokens) do
    if token ~= nil then chaosbag.putObject(token) end
  end
  chaosTokens = {}
end

-- Checks to see if the chaos bag can be manipulated.  If a player is searching the bag when tokens
-- are drawn or replaced a TTS bug can cause those tokens to vanish.  Any functions which change the
-- contents of the bag should check this method before doing so.
-- This method will broadcast a message to all players if the bag is being searched.
-- @return Boolean.  True if the bag is manipulated, false if it should be blocked.
function canTouchChaosTokens()
  for color, searching in pairs(bagSearchers) do
    if searching then
      broadcastToAll("Someone is searching the chaos bag, can't touch the tokens", "Red")
      return false
    end
  end

  return true
end

function drawChaostoken(params)
  if not canTouchChaosTokens() then
    return
  end

  local mat = params[1]
  local tokenOffset = params[2]
  local isRightClick = params[3]
  chaosbag = findChaosBag()

  -- return token(s) on other playmat first
  if chaosTokensLastMat ~= nil and chaosTokensLastMat ~= mat and #chaosTokens ~= 0 then
    returnChaosTokens()
    chaosTokensLastMat = nil
    return
  end

  chaosTokensLastMat = mat
  -- if we have left clicked and have no tokens OR if we have right clicked
  if isRightClick or #chaosTokens == 0 then
    if #chaosbag.getObjects() == 0 then return end
    chaosbag.shuffle()

    -- add the token to the list, compute new position based on list length
    tokenOffset[1] = tokenOffset[1] + (0.17 * #chaosTokens)
    local token = chaosbag.takeObject({
      index = 0,
      position = mat.positionToWorld(tokenOffset),
      rotation = mat.getRotation(),
	    callback_function = function(obj) trackChaosToken(obj, mat.getGUID()) end
    })
    chaosTokens[#chaosTokens + 1] = token
    return
  else
    returnChaosTokens()
  end
end

---------------------------------------------------------
-- token spawning
---------------------------------------------------------

-- DEPRECATED.  Use TokenManager instead.
-- Spawns a single token.
---@param params Table.  Array with arguments to the method.  1 = position, 2 = type, 3 = rotation
function spawnToken(params)
  return tokenManager.spawnToken(params[1], params[2], params[3])
end

---------------------------------------------------------
-- chaos token stat tracker
---------------------------------------------------------

function trackChaosToken(token, matGUID)
  local image = token.getCustomObject().image
  overallStats[image]           = (overallStats[image] or 0) + 1
  personalStats[matGUID][image] = (personalStats[matGUID][image] or 0) + 1
end

function handleStatTrackerClick(_, _, isRightClick)
  if isRightClick then
    resetChaosTokenStats()
  else
    printChaosTokenStats()
  end
end

function resetChaosTokenStats()
  for key, _ in pairs(overallStats) do
    overallStats[key] = 0
  end
  for playerKey, _ in pairs(personalStats) do
    for key, value in pairs(overallStats) do
      personalStats[playerKey][key] = value
    end
  end
end

function printChaosTokenStats()
  local squidKing = "Nobody"
  printToAll("")
  printToAll("Overall Stats")
  printToAll("------------------------------")
  printNonZeroTokenPairs(overallStats)
  printToAll("")
  printToAll("Individual Stats")
  printToAll("------------------------------")
  for matGUID, _ in pairs(personalStats) do
    local playerColour = MAT_GUID_TO_COLOUR[matGUID]
    local playerSquidCount = personalStats[matGUID]["https://i.imgur.com/lns4fhz.png"] or 0
    local playerName = playerColour
    if Player[playerColour].seated then
      playerName = Player[playerColour].steam_name
    end

    printToAll(playerName ..  " Stats", playerColour)
    printNonZeroTokenPairs(personalStats[matGUID])

    if playerSquidCount > maxSquid then
      squidKing = playerName
      maxSquid = playerSquidCount
    end
  end
  printToAll(squidKing .. " is an auto-fail magnet.", {255, 0, 0})
end

function printNonZeroTokenPairs(theTable)
  for key, value in pairs(theTable) do
    if value ~= 0 then
      printToAll(IMAGE_TOKEN_MAP[key] .. ': ' .. tostring(value))
    end
  end
end

---------------------------------------------------------
-- Difficulty selector script
---------------------------------------------------------

-- called for button creation on the difficulty selectors
---@param object object Usually "self"
---@param key string Name of the scenario
function createSetupButtons(args)
  local data = getDataValue('modeData', args.key)
  if data ~= nil then
    local buttonParameters          = {}
    buttonParameters.function_owner = args.object
    buttonParameters.position       = {0, 0.1, -0.15}
    buttonParameters.scale          = {0.47, 1, 0.47}
    buttonParameters.height         = 200
    buttonParameters.width          = 1150
    buttonParameters.color          = {0.87, 0.8, 0.7}

    if data.easy ~= nil then
      buttonParameters.label = "Easy"
      buttonParameters.click_function = "easyClick"
      args.object.createButton(buttonParameters)
      buttonParameters.position[3] = buttonParameters.position[3] + 0.20
    end

    if data.normal ~= nil then
      buttonParameters.label = "Standard"
      buttonParameters.click_function = "normalClick"
      args.object.createButton(buttonParameters)
      buttonParameters.position[3] = buttonParameters.position[3] + 0.20
    end

    if data.hard ~= nil then
      buttonParameters.label = "Hard"
      buttonParameters.click_function = "hardClick"
      args.object.createButton(buttonParameters)
      buttonParameters.position[3] = buttonParameters.position[3] + 0.20
    end

    if data.expert ~= nil then
      buttonParameters.label = "Expert"
      buttonParameters.click_function = "expertClick"
      args.object.createButton(buttonParameters)
      buttonParameters.position[3] = buttonParameters.position[3] + 0.20
    end

    if data.standalone ~= nil then
      buttonParameters.label = "Standalone"
      buttonParameters.click_function = "standaloneClick"
      args.object.createButton(buttonParameters)
    end
  end
end

-- called for adding chaos tokens
---@param object object Usually "self"
---@param key string Name of the scenario
---@param mode string diffculty (e.g. "hard" or "expert")
function fillContainer(args)
  chaosbag = findChaosBag()

  if chaosbag ~= nil then
    local data = getDataValue('modeData', args.key)
    if data == nil then return end

    local value = data[args.mode]
    if value == nil or value.token == nil then return end

    local pos = chaosbag.getPosition()
    if args.object ~= nil then
      pos = args.object.getPosition()
    end

    -- empty the chaos bag
    for _, item in ipairs(chaosbag.getObjects()) do
      destroyObject(chaosbag.takeObject({}))
    end

    for _, token in ipairs(value.token) do
      local obj = spawnChaosToken(token, pos)
      if obj ~= nil then
        chaosbag.putObject(obj)
      end
    end

    if value.append ~= nil then
      for _, token in ipairs(value.append) do
        local obj = spawnChaosToken(token, pos)
        if obj ~= nil then
          chaosbag.putObject(obj)
        end
      end
    end

    -- randomly choose tokens for specific Carcosa scenarios in standalone
    if value.random then
      local n = #value.random
      if n > 0 then
        for _, token in ipairs(value.random[math.random(1, n)]) do
          local obj = spawnChaosToken(token, pos)
          if obj ~= nil then
            chaosbag.putObject(obj)
          end
        end
      end
    end

    if value.message then
      broadcastToAll(value.message)
    end

    if value.warning then
      broadcastToAll(value.warning, { 1, 0.5, 0.5 })
    end
  end
end

function getDataValue(storage, key)
  local data = getObjectFromGUID(DATA_HELPER_GUID).getTable(storage)
  if data ~= nil then
    local value = data[key]
    if value ~= nil then
      local res = {}
      for m, v in pairs(value) do
        res[m] = v
        if res[m].parent ~= nil then
          local parentData = getDataValue(storage, res[m].parent)
          if parentData ~= nil and parentData[m] ~= nil and parentData[m].token ~= nil then
            res[m].token = parentData[m].token
          end
          res[m].parent = nil
        end
      end
      return res
    end
  end
end

function spawnChaosToken(id, pos)
  local url = getChaosTokenImageURL(id)
  if url ~= '' then
    local obj = spawnObject({
      type = 'Custom_Tile',
      position = {pos.x, pos.y + 3, pos.z},
      rotation = {0, 260, 0}
    })
    obj.setCustomObject({
      type = 2,
      image = url,
      thickness = 0.1
    })
    obj.scale {0.81, 1, 0.81}
    obj.setName(getTokenName({ url=url }))
    return obj
  end
end

-- chaos bag needs this for renaming chaos tokens
function getTokenName(params)
  local name = IMAGE_TOKEN_MAP[params.url]
    if name == nil then name = "" end
  return name
end

-- returns the image url for a chaos token (identified by the "id")
function getChaosTokenImageURL(id)
  if id == 'p1' then return 'https://i.imgur.com/uIx8jbY.png' end
  if id == '0' then return 'https://i.imgur.com/btEtVfd.png' end
  if id == 'm1' then return 'https://i.imgur.com/w3XbrCC.png' end
  if id == 'm2' then return 'https://i.imgur.com/bfTg2hb.png' end
  if id == 'm3' then return 'https://i.imgur.com/yfs8gHq.png' end
  if id == 'm4' then return 'https://i.imgur.com/qrgGQRD.png' end
  if id == 'm5' then return 'https://i.imgur.com/3Ym1IeG.png' end
  if id == 'm6' then return 'https://i.imgur.com/c9qdSzS.png' end
  if id == 'm7' then return 'https://i.imgur.com/4WRD42n.png' end
  if id == 'm8' then return 'https://i.imgur.com/9t3rPTQ.png' end
  if id == 'skull' then return 'https://i.imgur.com/stbBxtx.png' end
  if id == 'cultist' then return 'https://i.imgur.com/VzhJJaH.png' end
  if id == 'tablet' then return 'https://i.imgur.com/1plY463.png' end
  if id == 'elder' then return 'https://i.imgur.com/ttnspKt.png' end
  if id == 'red' then return 'https://i.imgur.com/lns4fhz.png' end
  if id == 'blue' then return 'https://i.imgur.com/nEmqjmj.png' end
	if id == 'frost' then return 'http://cloud-3.steamusercontent.com/ugc/1858293462583104677/195F93C063A8881B805CE2FD4767A9718B27B6AE/' end
  return ''
end

---------------------------------------------------------
-- Content Importing and XML functions
---------------------------------------------------------

local source_repo = 'https://raw.githubusercontent.com/chr1z93/loadable-objects/main'
local library = nil
local request_obj

function onClick_refreshList()
  local request = WebRequest.get(source_repo .. '/library.json', completed_list_update)
  request_obj = request
  startLuaCoroutine(Global, 'downloadCoroutine')
end

function onClick_select(player, params)
  params = JSON.decode(urldecode(params))
  local url = source_repo .. '/' .. params.url
  local request = WebRequest.get(url, function (request) complete_obj_download(request, params) end )
  request_obj = request
  startLuaCoroutine(Global, 'downloadCoroutine')
end

function onClick_load()
  UI.show('progress_display')
  UI.hide('load_button')
end

function onClick_toggleUi(_, title)
  UI.hide('optionPanel')
  UI.hide('load_ui')

  -- when same button is clicked or close window button is pressed, don't open UI
  if UI.getValue('title') ~= title and title ~= 'Hidden' then
    UI.setValue('title', title)

    if title == "Options" then
      UI.show('optionPanel')
    else
      update_window_content(title)
      UI.show('load_ui')
    end
  else
    UI.setValue('title', "Hidden")
  end
end

function downloadCoroutine()
  while request_obj do
    UI.setAttribute('download_progress', 'percentage', request_obj.download_progress * 100)
    coroutine.yield(0)
  end
  return 1
end

function update_list(objects)
  local ui = UI.getXmlTable()
  local update_height = find_tag_with_id(ui, 'ui_update_height')
  local update_children = find_tag_with_id(update_height.children, 'ui_update_point')

  update_children.children = {}

  for _, v in ipairs(objects) do
    local s = JSON.encode(v);
    table.insert(update_children.children,
      { tag = 'Text',
        value = v.name,
        attributes = { onClick = 'onClick_select(' .. urlencode(JSON.encode(v)) .. ')', alignment = 'MiddleLeft' }
      })
  end

  update_height.attributes.height = #(update_children.children) * 24
  UI.setXmlTable(ui)
end

function update_window_content(new_title)
  if not library then return end

  if new_title == 'Campaigns' then
    update_list(library.campaigns)
  elseif new_title == 'Standalone Scenarios' then
    update_list(library.scenarios)
  elseif new_title == 'Investigators' then
    update_list(library.investigators)
  elseif new_title == 'Community Content' then
    update_list(library.community)
  elseif new_title == 'Extras' then
    update_list(library.extras)
  else
    update_list({})
  end
end

function complete_obj_download(request, params)
  assert(request.is_done)
  if request.is_error or request.response_code ~= 200 then
    print('error: ' .. request.error)
  else
    if pcall(function()
      local replaced_object
      pcall(function()
        if params.replace then
          replaced_object = getObjectFromGUID(params.replace)
        end
      end)
      local json = request.text
      if replaced_object then
        local pos = replaced_object.getPosition()
        local rot = replaced_object.getRotation()
        destroyObject(replaced_object)
        Wait.frames(function()
          spawnObjectJSON({json = json, position = pos, rotation = rot})
        end, 1)
      else
        spawnObjectJSON({json = json})
      end
    end) then
      print('Object loaded.')
    else
      print('Error loading object.')
    end
  end

  request_obj = nil
  UI.setAttribute('download_progress', 'percentage', 100)
end

-- the download button on the placeholder objects calls this to directly initiate a download
-- params is a table with url and guid of replacement object, which happens to match what onClick_select wants
function placeholder_download(params)
  onClick_select(nil, JSON.encode(params))
end

function completed_list_update(request)
  assert(request.is_done)
  if request.is_error or request.response_code ~= 200 then
    print('error: ' .. request.error)
  else
    local json_response = nil
    if pcall(function () json_response = JSON.decode(request.text) end) then
      library = json_response
      update_window_content(UI.getValue('title'))
    else
      print('error parsing downloaded library')
    end
  end

  request_obj = nil
  UI.setAttribute('download_progress', 'percentage', 100)
end

function find_tag_with_id(ui, id)
  for _, obj in ipairs(ui) do
    if obj.attributes and obj.attributes.id and obj.attributes.id == id then return obj end
    if obj.children then
      local result = find_tag_with_id(obj.children, id)
      if result then return result end
    end
  end
  return nil
end

function urlencode(str)
  local str = string.gsub(str, "([^A-Za-z0-9-_.~])",
    function (c) return string.format("%%%02X", string.byte(c)) end)
  return str
end

function urldecode(str)
  local str = string.gsub(str, "%%(%x%x)",
    function (h) return string.char(tonumber(h, 16)) end)
  return str
end

---------------------------------------------------------
-- Option Panel related functionality
---------------------------------------------------------

-- called by toggling an option
function onClick_toggleOption(_, id)
  local state = self.UI.getAttribute(id, "isOn")

  -- flip state (and handle stupid "False" value)
  if state == "False" then
    state = true
  else
    state = false
  end

  self.UI.setAttribute(id, "isOn", state)
  applyOptionPanelChange(id, state)
end

-- sets the option panel to the correct state (corresponding to 'optionPanel')
function updateOptionPanelState()
  for id, enabled in pairs(optionPanel) do
    if (type(enabled) == "boolean" and enabled)
        or (type(enabled) == "string" and enabled)
        or (type(enabled) == "table" and #enabled ~= 0) then
      self.UI.setAttribute(id, "isOn", true)
    else
      self.UI.setAttribute(id, "isOn", "False")
    end
  end
end

-- handles the applying of option selections and calls the respective functions based
---@param id String ID of the option that was selected or deselected
---@param state Boolean State of the option (true = enabled)
function applyOptionPanelChange(id, state)
  -- option: Snap tags
  if id == "useSnapTags" then
    playmatAPI.setLimitSnapsByType(state, "All")
    optionPanel[id] = state

  -- option: Draw 1 button
  elseif id == "showDrawButton" then
    playmatAPI.showDrawButton(state, "All")
    optionPanel[id] = state

  -- option: Clickable clue counters
  elseif id == "useClueClickers" then
    playmatAPI.clickableClues(state, "All")
    optionPanel[id] = state

    -- update master clue counter
    getObjectFromGUID("4a3aa4").setVar("useClickableCounters", state)

  -- option: Clickable resource counters
  elseif id == "useResourceCounters" then
    optionPanel[id] = state

  -- option: Play area snap tags
  elseif id == "playAreaSnapTags" then
    playAreaAPI.setLimitSnapsByType(state)
    optionPanel[id] = state

  -- option: Show Title on placing scenarios
  elseif id == "showTitleSplash" then
    optionPanel[id] = state

  -- option: Show token arranger
  elseif id == "showTokenArranger" then
    -- delete previously pulled out tokens
    for _, token in ipairs(getObjectsWithTag("to_be_deleted")) do token.destruct() end

    optionPanel[id] = spawnOrRemoveHelper(state, "Token Arranger", {-42.3, 1.6, -46.5})

  -- option: Show clean up helper
  elseif id == "showCleanUpHelper" then
    optionPanel[id] = spawnOrRemoveHelper(state, "Clean Up Helper", {-66, 1.6, 46})

  -- option: Show hand helper for each player
  elseif id == "showHandHelper" then
    optionPanel[id][1] = spawnOrRemoveHelper(state, "Hand Helper", {-50.85, 1.6, 7.32}, {0, 270, 0}, "White")
    optionPanel[id][2] = spawnOrRemoveHelper(state, "Hand Helper", {-50.85, 1.6, -24.88}, {0, 270, 0}, "Orange")
    optionPanel[id][3] = spawnOrRemoveHelper(state, "Hand Helper", {-39.13, 1.6, 22.45}, {0, 000, 0}, "Green")
    optionPanel[id][4] = spawnOrRemoveHelper(state, "Hand Helper", {-21.57, 1.6, -22.45}, {0, 180, 0}, "Red")

  -- option: Show search assistant for each player
  elseif id == "showSearchAssistant" then
    optionPanel[id][1] = spawnOrRemoveHelper(state, "Search Assistant", {-50.85, 1.6, 10.25}, {0, 270, 0})
    optionPanel[id][2] = spawnOrRemoveHelper(state, "Search Assistant", {-50.85, 1.6, -21.95}, {0, 270, 0})
    optionPanel[id][3] = spawnOrRemoveHelper(state, "Search Assistant", {-36.20, 1.6, 22.45}, {0, 000, 0})
    optionPanel[id][4] = spawnOrRemoveHelper(state, "Search Assistant", {-24.50, 1.6, -22.45}, {0, 180, 0})

  -- option: Show chaos bag manager
  elseif id == "showChaosBagManager" then
    optionPanel[id] = spawnOrRemoveHelper(state, "Chaos Bag Manager", {-66, 1.6, -49.5})

  -- option: Show attachment helper
  elseif id == "showAttachmentHelper" then
    optionPanel[id] = spawnOrRemoveHelper(state, "Attachment Helper", {-64, 1.4, 0})

  -- option: Show navigation overlay
  elseif id == "showNavigationOverlay" then
    optionPanel[id] = spawnOrRemoveHelper(state, "jaqenZann's Navigation Overlay", {-11.7, 1.6, -15})

  -- option: Show CYOA campaign guides
  elseif id == "showCYOA" then
    optionPanel[id] = spawnOrRemoveHelper(state, "CYOA Campaign Guides", {65, 1.6, -11})

  -- option: Show custom playmat images
  elseif id == "showCustomPlaymatImages" then
    optionPanel[id] = spawnOrRemoveHelper(state, "Custom Playmat Images", {67.5, 1.6, 37})

  -- option: Show displacement tool
  elseif id == "showDisplacementTool" then
    optionPanel[id] = spawnOrRemoveHelper(state, "Displacement Tool", {-57, 1.6, 46})
  end
end

-- handler for spawn / remove functions of helper objects
---@param state Boolean Contains the state of the option: true = spawn it, false = remove it
---@param name String Name of the helper object
---@param position Vector Position of the object (where it will spawn)
---@param rotation Vector Rotation of the object for spawning (default: {0, 270, 0})
---@param color String This is only needed for correctly setting the color of the "Hand Helper"
---@return. GUID of the spawnedObj (or nil if object was removed)
function spawnOrRemoveHelper(state, name, position, rotation, color)
  if state then
    Player.getPlayers()[1].pingTable(position)
    return spawnHelperObject(name, position, rotation, color).getGUID()
  else
    return removeHelperObject(name)
  end
end

-- copies the specified tool (by name) from the barrel
---@param name String Name of the object that should be copied
---@param position Table Desired position of the object
function spawnHelperObject(name, position, rotation, color)
  local barrel = getObjectFromGUID(BARREL_GUID)

  -- error handling for missing barrel
  if not barrel then
    broadcastToAll("'Barrel' with fan-made accessories could not be found!", "Red")
    return
  end

  local spawnTable = {
    position = position,
    callback_function = function(object)
      if name == "Hand Helper" then
        Wait.time(function() object.call("externalColorChange", color) end, 0.1)
      elseif name == "Token Arranger" then
        Wait.time(function() object.call("layout") end, 0.1)
      end
    end
  }

  -- only overrride rotation if there is one provided (object's rotation used instead)
  if rotation then
    spawnTable.rotation = rotation
  end

  for _, obj in ipairs(barrel.getData().ContainedObjects) do
    if obj["Nickname"] == name then
      spawnTable.data = obj
      return spawnObjectData(spawnTable)
    end
  end
end

-- removes the specified tool (by name)
---@param name String Object that should be removed
function removeHelperObject(name)
  -- links objects name to the respective option name (to grab the GUID for removal)
  local referenceTable = {
    ["Token Arranger"]                 = "showTokenArranger",
    ["Clean Up Helper"]                = "showCleanUpHelper",
    ["Hand Helper"]                    = "showHandHelper",
    ["Search Assistant"]               = "showSearchAssistant",
    ["Chaos Bag Manager"]              = "showChaosBagManager",
    ["jaqenZann's Navigation Overlay"] = "showNavigationOverlay",
    ["Displacement Tool"]              = "showDisplacementTool",
    ["Custom Playmat Images"]          = "showCustomPlaymatImages",
    ["Attachment Helper"]              = "showAttachmentHelper",
    ["CYOA Campaign Guides"]           = "showCYOA"
  }

  local data = optionPanel[referenceTable[name]]

  -- if there is a GUID stored, remove that object
  if type(data) == "string" then
    local obj = getObjectFromGUID(data)
    if obj then obj.destruct() end

  -- if it is a table (e.g. for the "Hand Helper", remove all of them)
  elseif type(data) == "table" then
    for _, guid in pairs(data) do
      local obj = getObjectFromGUID(guid)
      if obj then obj.destruct() end
    end
  end
end

-- loads the default options
function onClick_defaultSettings()
  for id, _ in pairs(optionPanel) do
    local state = false
    -- override for settings that are enabled by default
    if id == "useSnapTags" or id == "showTitleSplash" then
      state = true
    end
    applyOptionPanelChange(id, state)
  end

  -- clean reset of variable
  optionPanel = {
    playAreaSnapTags = true,
    showAttachmentHelper = false,
    showCleanUpHelper = false,
    showChaosBagManager = false,
    showCustomPlaymatImages = false,
    showCYOA = false,
    showDisplacementTool = false,
    showDrawButton = false,
    showHandHelper = {},
    showNavigationOverlay = false,
    showSearchAssistant = {},
    showTitleSplash = true,
    showTokenArranger = false,
    useClueClickers = false,
    useSnapTags = true
  }

  -- update UI
  updateOptionPanelState()
end

-- splash scenario title on setup
function titleSplash(scenarioName)
  if optionPanel['showTitleSplash'] then

    -- if there's any ongoing title being displayed, hide it and cancel the waiting function
    if hideTitleSplashWaitFunctionId then
      Wait.stop(hideTitleSplashWaitFunctionId)
      hideTitleSplashWaitFunctionId = nil
      UI.setAttribute('title_splash', 'active', false)
    end

    -- display scenario name and set a 4 seconds (2 seconds animation and 2 seconds on screen)
    -- wait timer to hide the scenario name
    UI.setValue('title_splash_text', scenarioName)
    UI.show('title_splash')
    hideTitleSplashWaitFunctionId = Wait.time(function()
      UI.hide('title_splash')
      hideTitleSplashWaitFunctionId = nil
    end, 4)
  end
end
end)
__bundle_register("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules)
do
  local PlayAreaApi = { }

  local PLAY_AREA_GUID = "721ba2"

  -- Returns the current value of the investigator counter from the playmat
  ---@return Integer.  Number of investigators currently set on the counter
  PlayAreaApi.getInvestigatorCount = function()
    return getObjectFromGUID(PLAY_AREA_GUID).call("getInvestigatorCount")
  end

  -- Move all contents on the play area (cards, tokens, etc) one slot in the given direction.  Certain
  -- fixed objects will be ignored, as will anything the player has tagged with
  -- 'displacement_excluded'
  ---@param playerColor Color of the player requesting the shift.  Used solely to send an error
  ---    message in the unlikely case that the scripting zone has been deleted
  PlayAreaApi.shiftContentsUp = function(playerColor)
    return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsUp", playerColor)
  end

  PlayAreaApi.shiftContentsDown = function(playerColor)
    return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsDown", playerColor)
  end

  PlayAreaApi.shiftContentsLeft = function(playerColor)
    return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsLeft", playerColor)
  end

  PlayAreaApi.shiftContentsRight = function(playerColor)
    return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsRight", playerColor)
  end

  -- Reset the play area's tracking of which cards have had tokens spawned.
  PlayAreaApi.resetSpawnedCards = function()
    return getObjectFromGUID(PLAY_AREA_GUID).call("resetSpawnedCards")
  end

  -- Event to be called when the current scenario has changed.
  ---@param scenarioName Name of the new scenario
  PlayAreaApi.onScenarioChanged = function(scenarioName)
    getObjectFromGUID(PLAY_AREA_GUID).call("onScenarioChanged", scenarioName)
  end

  -- Sets this playmat's snap points to limit snapping to locations or not.
  -- If matchTypes is false, snap points will be reset to snap all cards.
  ---@param matchTypes Boolean  Whether snap points should only snap for the matching card types.
  PlayAreaApi.setLimitSnapsByType = function(matchCardTypes)
    getObjectFromGUID(PLAY_AREA_GUID).call("setLimitSnapsByType", matchCardTypes)
  end

  -- Receiver for the Global tryObjectEnterContainer event.  Used to clear vector lines from dragged
  -- cards before they're destroyed by entering the container
  PlayAreaApi.tryObjectEnterContainer = function(container, object)
    getObjectFromGUID(PLAY_AREA_GUID).call("tryObjectEnterContainer",
        { container = container, object = object })
  end

  return PlayAreaApi
end
end)
return __bundle_require("__root")
