-- 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("__root", function(require, _LOADED, __bundle_register, __bundle_modules)
require("core/PlayArea")
end)
__bundle_register("core/PlayArea", function(require, _LOADED, __bundle_register, __bundle_modules)
---------------------------------------------------------
-- general setup
---------------------------------------------------------

-- set true to enable debug logging
local DEBUG = false

-- Location connection directional options
local BIDIRECTIONAL = 0
local ONE_WAY = 1
local INCOMING_ONE_WAY = 2

-- Connector draw parameters
local CONNECTION_THICKNESS = 0.015
local DRAGGING_CONNECTION_THICKNESS = 0.15
local DRAGGING_CONNECTION_COLOR = { 0.8, 0.8, 0.8, 1 }
local CONNECTION_COLOR = { 0.4, 0.4, 0.4, 1 }
local DIRECTIONAL_ARROW_DISTANCE = 3.5
local ARROW_ARM_LENGTH = 0.9
local ARROW_ANGLE = 25

-- Height to draw the connector lines, places them just above the table and always below cards
local CONNECTION_LINE_Y = 1.529

-- we use this to turn off collision handling until onLoad() is complete
local collisionEnabled = false

-- used for recreating the link to a custom data helper after image change
customDataHelper = nil

local SHIFT_OFFSETS = {
  left  = { x = 0.00, y = 0, z = 7.67 },
  right = { x = 0.00, y = 0, z = -7.67 },
  up    = { x = 6.54, y = 0, z = 0.00 },
  down  = { x = -6.54, y = 0, z = 0.00 }
}
local SHIFT_EXCLUSION = {
  ["b7b45b"] = true,
  ["f182ee"] = true,
  ["721ba2"] = true
}
local LOC_LINK_EXCLUDE_SCENARIOS = {
  ["The Witching Hour"] = true,
}

local tokenManager = require("core/token/TokenManager")
local INVESTIGATOR_COUNTER_GUID = "f182ee"
local PLAY_AREA_ZONE_GUID = "a2f932"

local clueData = {}
local spawnedLocationGUIDs = {}
local locations = {}
local locationConnections = {}
local draggingGuids = {}
local locationData
local currentScenario

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

function onSave()
  return JSON.encode({
    trackedLocations = locations,
    currentScenario = currentScenario,
  })
end

function onLoad(saveState)
  -- records locations we have spawned clues for
  local save = JSON.decode(saveState) or { }
  locations = save.trackedLocations or { }
  currentScenario = save.currentScenario

  self.interactable = DEBUG
  Wait.time(function() collisionEnabled = true end, 1)
end

function log(message)
  if DEBUG then print(message) end
end

-- Called by Custom Data Helpers to push their location data into the Data Helper.  This adds the
-- data to the local token manager instance.
---@param args Table Single-value array holding the GUID of the Custom Data Helper making the call
function updateLocations(args)
  customDataHelper = getObjectFromGUID(args[1])
  if customDataHelper ~= nil then
    tokenManager.addLocationData(customDataHelper.getTable("LOCATIONS_DATA"))
  end
end

function onCollisionEnter(collisionInfo)
  local obj = collisionInfo.collision_object
  local objType = obj.name

  -- only continue for cards
  if not collisionEnabled or (objType ~= "Card" and objType ~= "CardCustom") then return end

  -- check if we should spawn clues here and do so according to playercount
  local card = collisionInfo.collision_object
  if shouldSpawnTokens(card) then
    tokenManager.spawnForCard(card)
  end
  -- If this card was being dragged, clear the dragging connections.  A multi-drag/drop may send
  -- the dropped card immediately into a deck, so this has to be done here
  if draggingGuids[card.getGUID()] ~= nil then
    card.setVectorLines(nil)
    draggingGuids[card.getGUID()] = nil
  end
  maybeTrackLocation(card)
end

function shouldSpawnTokens(card)
  local metadata = JSON.decode(card.getGMNotes())
  if metadata == nil then
    return tokenManager.hasLocationData(card)
  end
  return metadata.type == "Location"
      or metadata.type == "Enemy"
      or metadata.type == "Treachery"
      or metadata.weakness
end

function onCollisionExit(collisionInfo)
  maybeUntrackLocation(collisionInfo.collision_object)
end

-- Destroyed objects don't trigger onCollisionExit(), so check on destruction to untrack as well
function onObjectDestroy(object)
  maybeUntrackLocation(object)
end

function onObjectPickUp(player, object)
  -- only continue for cards
  local objType = object.name
  if objType ~= "Card" and objType ~= "CardCustom" then return end

  -- onCollisionExit USUALLY fires first, so we have to check the card to see if it's a location we
  -- should be tracking
  if showLocationLinks() and isInPlayArea(object) and object.getGMNotes() ~= nil and object.getGMNotes() ~= "" then
    local pickedUpGuid = object.getGUID()
    local metadata = JSON.decode(object.getGMNotes()) or { }
    if (metadata.type == "Location") then
      -- onCollisionExit sometimes comes 1 frame after onObjectPickUp (rather than before it or in
      -- the same frame).  This causes a mismatch in the data between dragging the on-table, and
      -- that one frame draws connectors on the card which then show up as shadows for snap points.
      -- Waiting ensures we always do thing in the expected Exit->PickUp order
      Wait.frames(function()
          draggingGuids[pickedUpGuid] = metadata
          rebuildConnectionList()
        end, 2)
    end
  end
end

function onUpdate()
  -- Due to the frequence of onUpdate calls, ensure that we only process any changes to the
  -- connection list once, and only redraw once
  local needsConnectionRebuild = false
  local needsConnectionDraw = false
  for guid, _ in pairs(draggingGuids) do
    local obj = getObjectFromGUID(guid)
    if obj == nil or not isInPlayArea(obj) then
      draggingGuids[guid] = nil
      needsConnectionRebuild = true
      -- If object still exists then it's been dragged outside the area and needs to clear the
      -- lines attached to it
      if obj ~= nil then
        obj.setVectorLines(nil)
      end
    end
    -- Even if the last location left the play area, need one last draw to clear the lines
    needsConnectionDraw = true
  end
  if (needsConnectionRebuild) then
    rebuildConnectionList()
  end
  if needsConnectionDraw then
    drawDraggingConnections()
  end
end

-- Checks the given card and adds it to the list of locations tracked for connection purposes.
-- A card will be added to the tracking if it is a location in the play area (based on centerpoint).
---@param card Object A card object, possibly a location.
function maybeTrackLocation(card)
  -- Collision checks for any part of the card overlap, but our other tracking is centerpoint
  -- Ignore any collision where the centerpoint isn't in the area
  if showLocationLinks() and isInPlayArea(card) then
    local metadata = JSON.decode(card.getGMNotes()) or { }
    if metadata.type == "Location" then
      locations[card.getGUID()] = metadata
      rebuildConnectionList()
      drawBaseConnections()
    end
  end
end

-- Stop tracking a location for connection drawing.  This should be called for both collision exit
-- and destruction, as a destroyed object does not trigger collision exit.  An object can also be
-- deleted mid-drag, but the ordering for drag events means we can't clear those here and those will
-- be cleared in the next onUpdate() cycle.
---@param card Object Card to (maybe) stop tracking
function maybeUntrackLocation(card)
  -- Locked objects no longer collide (hence triggering an exit event) but are still in the play
  -- area.  If the object is now locked, don't remove it.
  if locations[card.getGUID()] ~= nil and not card.locked then
    locations[card.getGUID()] = nil
    rebuildConnectionList()
    drawBaseConnections()
  end
end

-- Global event handler, delegated from Global.  Clears any connection lines from dragged cards
-- before they are destroyed by entering a deck.  Removal of the card from the dragging list will
-- be handled during the next onUpdate() call.
function tryObjectEnterContainer(params)
  for draggedGuid, _ in pairs(draggingGuids) do
    local draggedObj = getObjectFromGUID(draggedGuid)
    if draggedObj ~= nil then
      draggedObj.setVectorLines(nil)
    end
  end
end

-- Builds a list of GUID to GUID connection information based on the currently tracked locations.
-- This will update the connection information and store it in the locationConnections data member,
-- but does not draw those connections.  This should often be followed by a call to
-- drawBaseConnections()
function rebuildConnectionList()
  if not showLocationLinks() then
    locationConnections = { }
    return
  end

  local iconCardList = { }

  -- Build a list of cards with each icon as their location ID
  for cardId, metadata in pairs(draggingGuids) do
    buildLocListByIcon(cardId, iconCardList)
  end
  for cardId, metadata in pairs(locations) do
    buildLocListByIcon(cardId, iconCardList)
  end

  -- Pair up all the icons
  locationConnections = { }
  for cardId, metadata in pairs(draggingGuids) do
    buildConnection(cardId, iconCardList)
  end
  for cardId, metadata in pairs(locations) do
    if draggingGuids[cardId] == nil then
      buildConnection(cardId, iconCardList)
    end
  end
end

-- Extracts the card's icon string into a list of individual location icons
---@param cardID String GUID of the card to pull the icon data from
---@param iconCardList Table A table of icon->GUID list.  Mutable, will be updated by this method
function buildLocListByIcon(cardId, iconCardList)
  local card = getObjectFromGUID(cardId)
  local locData = getLocationData(card)
  if locData ~= nil and locData.icons ~= nil then
    for icon in string.gmatch(locData.icons, "%a+") do
      if iconCardList[icon] == nil then
        iconCardList[icon] = { }
      end
      table.insert(iconCardList[icon], card.getGUID())
    end
  end
end

-- Builds the connections for the given cardID by finding matching icons and adding them to the
-- Playarea's locationConnections table.
---@param cardId String GUID of the card to build the connections for
---@param iconCardList Table A table of icon->GUID List.  Used to find matching icons for connections.
function buildConnection(cardId, iconCardList)
  local card = getObjectFromGUID(cardId)
  local locData = getLocationData(card)
  if locData ~= nil and locData.connections ~= nil then
    locationConnections[card.getGUID()] = { }
    for icon in string.gmatch(locData.connections, "%a+") do
      if iconCardList[icon] ~= nil then
        for _, connectedGuid in ipairs(iconCardList[icon]) do
          -- If the reciprocal exists, convert it to BiDi, otherwise add as a one-way
          if locationConnections[connectedGuid] ~= nil
              and locationConnections[connectedGuid][card.getGUID()] == ONE_WAY then
            locationConnections[connectedGuid][card.getGUID()] = BIDIRECTIONAL
            locationConnections[card.getGUID()][connectedGuid] = nil
          else
            if locationConnections[connectedGuid] == nil then
              locationConnections[connectedGuid] = { }
            end
            locationConnections[card.getGUID()][connectedGuid] = ONE_WAY
            locationConnections[connectedGuid][card.getGUID()] = INCOMING_ONE_WAY
          end
        end
      end
    end
  end
end

-- Helper method to extract the location metadata from a card based on whether it's front or back
-- is showing.
---@param card String Card object to extract data from
---@return. Table with either the locationFront or locationBack metadata structure, or nil if the
--     metadata doesn't exist
function getLocationData(card)
  if card == nil then
    return nil
  end
  if card.is_face_down then
    return JSON.decode(card.getGMNotes()).locationBack
  else
    return JSON.decode(card.getGMNotes()).locationFront
  end
end

-- Draws the lines for connections currently in locationConnections but not in draggingGuids.
-- Constructed vectors will be set to the playmat
function drawBaseConnections()
  if not showLocationLinks() then
    locationConnections = { }
    return
  end
  local cardConnectionLines = { }

  for originGuid, targetGuids in pairs(locationConnections) do
    -- Objects should reliably exist at this point, but since this can be called during onUpdate the
    -- object checks are conservative just to make sure.
    local origin = getObjectFromGUID(originGuid)
    if draggingGuids[originGuid] == nil and origin != nil then
      for targetGuid, direction in pairs(targetGuids) do
        local target = getObjectFromGUID(targetGuid)
        if draggingGuids[targetGuid] == nil and target != nil then
          -- Since we process the full list, we're guaranteed to hit any ONE_WAY connections later
          -- so we can ignore INCOMING_ONE_WAY
          if direction == BIDIRECTIONAL then
            addBidirectionalVector(origin, target, self, cardConnectionLines)
          elseif direction == ONE_WAY then
            addOneWayVector(origin, target, self, cardConnectionLines)
          end
        end
      end
    end
  end
  self.setVectorLines(cardConnectionLines)
end

-- Draws the lines for cards which are currently being dragged.
function drawDraggingConnections()
  if not showLocationLinks() then
    return
  end
  local cardConnectionLines = { }
  local ownedVectors = { }

  for originGuid, _ in pairs(draggingGuids) do
    targetGuids = locationConnections[originGuid]
    -- Objects should reliably exist at this point, but since this can be called during onUpdate the
    -- object checks are conservative just to make sure.
    local origin = getObjectFromGUID(originGuid)
    if draggingGuids[originGuid] and origin ~= nil and targetGuids ~= nil then
      ownedVectors[originGuid] = { }
      for targetGuid, direction in pairs(targetGuids) do
        local target = getObjectFromGUID(targetGuid)
        if target != nil then
          if direction == BIDIRECTIONAL then
            addBidirectionalVector(origin, target, origin, ownedVectors[originGuid])
          elseif direction == ONE_WAY then
            addOneWayVector(origin, target, origin, ownedVectors[originGuid])
          elseif direction == INCOMING_ONE_WAY and not draggingGuids[targetGuid] then
            addOneWayVector(target, origin, origin, ownedVectors[originGuid])
          end
        end
      end
    end
  end
  for ownerGuid, vectors in pairs(ownedVectors) do
    local card = getObjectFromGUID(ownerGuid)
    card.setVectorLines(vectors)
  end
end

-- Draws a bidirectional location connection between the two cards, adding the lines to do so to the
-- given lines list.
---@param card1 Object One of the card objects to connect
---@param card2 Object The other card object to connect
---@param vectorOwner Object The object which these lines will be set to.  Used for relative
---    positioning and scaling, as well as highlighting connections during a drag operation
---@param lines Table List of vector line elements.  Mutable, will be updated to add this connector
function addBidirectionalVector(card1, card2, vectorOwner, lines)
  local cardPos1 = card1.getPosition()
  local cardPos2 = card2.getPosition()
  cardPos1.y = CONNECTION_LINE_Y
  cardPos2.y = CONNECTION_LINE_Y

  local pos1 = vectorOwner.positionToLocal(cardPos1)
  local pos2 = vectorOwner.positionToLocal(cardPos2)

  table.insert(lines, {
      points = { pos1, pos2 },
      color = vectorOwner == self and CONNECTION_COLOR or DRAGGING_CONNECTION_COLOR,
      thickness = vectorOwner == self and CONNECTION_THICKNESS or DRAGGING_CONNECTION_THICKNESS,
  })
end

-- Draws a one-way location connection between the two cards, adding the lines to do so to the
-- given lines list.  Arrows will point towards the target card.
---@param origin Object Origin card in the connection
---@param target Object Target card object to connect
---@param vectorOwner Object The object which these lines will be set to.  Used for relative
---    positioning and scaling, as well as highlighting connections during a drag operation
---@param lines Table List of vector line elements.  Mutable, will be updated to add this connector
function addOneWayVector(origin, target, vectorOwner, lines)
  -- Start with the BiDi then add the arrow lines to it
  addBidirectionalVector(origin, target, vectorOwner, lines)
  local originPos = origin.getPosition()
  local targetPos = target.getPosition()
  originPos.y = CONNECTION_LINE_Y
  targetPos.y = CONNECTION_LINE_Y

  -- Calculate card distance to be closer for horizontal positions than vertical, since cards are
  -- taller than they are wide
  local heading = Vector(originPos):sub(targetPos):heading("y")
  local distanceFromCard = DIRECTIONAL_ARROW_DISTANCE * 0.7 + DIRECTIONAL_ARROW_DISTANCE * 0.3 * math.abs(math.sin(math.rad(heading)))

  -- Calculate the three possible arrow positions.  These are offset by half the arrow length to
  -- make them visually balanced by keeping the arrows centered, not tracking the point
  local midpoint = Vector(originPos):add(targetPos):scale(Vector(0.5, 0.5, 0.5)):moveTowards(targetPos, ARROW_ARM_LENGTH / 2)
  local closeToOrigin = Vector(originPos):moveTowards(targetPos, distanceFromCard + ARROW_ARM_LENGTH / 2)
  local closeToTarget = Vector(targetPos):moveTowards(originPos, distanceFromCard - ARROW_ARM_LENGTH / 2)

  if (originPos:distance(closeToOrigin) > originPos:distance(closeToTarget)) then
    addArrowLines(midpoint, originPos, vectorOwner, lines)
  else
    addArrowLines(closeToOrigin, originPos, vectorOwner, lines)
    addArrowLines(closeToTarget, originPos, vectorOwner, lines)
  end
end

-- Draws an arrowhead at the given position.
---@param arrowheadPosition Table Centerpoint of the arrowhead to draw (NOT the tip of the arrow)
---@param originPos Table Origin point of the connection, used to position the arrow arms
---@param vectorOwner Object The object which these lines will be set to.  Used for relative
---    positioning and scaling, as well as highlighting connections during a drag operation
---@param lines Table List of vector line elements.  Mutable, will be updated to add this arrow
function addArrowLines(arrowheadPos, originPos, vectorOwner, lines)
  local arrowArm1 = Vector(arrowheadPos):moveTowards(originPos, ARROW_ARM_LENGTH):sub(arrowheadPos):rotateOver("y", -1 * ARROW_ANGLE):add(arrowheadPos)
  local arrowArm2 = Vector(arrowheadPos):moveTowards(originPos, ARROW_ARM_LENGTH):sub(arrowheadPos):rotateOver("y", ARROW_ANGLE):add(arrowheadPos)

  local head = vectorOwner.positionToLocal(arrowheadPos)
  local arm1 = vectorOwner.positionToLocal(arrowArm1)
  local arm2 = vectorOwner.positionToLocal(arrowArm2)
  table.insert(lines, {
    points = { arm1, head, arm2},
    color = vectorOwner == self and CONNECTION_COLOR or DRAGGING_CONNECTION_COLOR,
    thickness = vectorOwner == self and CONNECTION_THICKNESS or DRAGGING_CONNECTION_THICKNESS,
  })
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 String 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
function shiftContentsUp(playerColor)
  shiftContents(playerColor, "up")
end

function shiftContentsDown(playerColor)
  shiftContents(playerColor, "down")
end

function shiftContentsLeft(playerColor)
  shiftContents(playerColor, "left")
end

function shiftContentsRight(playerColor)
  shiftContents(playerColor, "right")
end

function shiftContents(playerColor, direction)
  local zone = getObjectFromGUID(PLAY_AREA_ZONE_GUID)
  if not zone then
    broadcastToColor("Scripting zone couldn't be found.", playerColor, "Red")
    return
  end

  for _, object in ipairs(zone.getObjects()) do
    if not (SHIFT_EXCLUSION[object.getGUID()] or object.hasTag("displacement_excluded")) then
      object.translate(SHIFT_OFFSETS[direction])
    end
  end
end

-- Returns the current value of the investigator counter from the playmat
---@return. Number of investigators currently set on the counter
function getInvestigatorCount()
  local investigatorCounter = getObjectFromGUID("f182ee")
  return investigatorCounter.getVar("val")
end

-- Check to see if the given object is within the bounds of the play area, based solely on the X and
-- Z coordinates, ignoring height
---@param object Object Object to check
---@return. True if the object is inside the play area
function isInPlayArea(object)
  local bounds = self.getBounds()
  local position = object.getPosition()
  -- Corners are arbitrary since it's all global - c1 goes down both axes, c2 goes up
  local c1 = { x = bounds.center.x - bounds.size.x / 2, z = bounds.center.z - bounds.size.z / 2}
  local c2 = { x = bounds.center.x + bounds.size.x / 2, z = bounds.center.z + bounds.size.z / 2}

  return position.x > c1.x and position.x < c2.x and position.z > c1.z and position.z < c2.z
end

-- Reset the play area's tracking of which cards have had tokens spawned.
function resetSpawnedCards()
  spawnedLocationGUIDs = {}
end

function onScenarioChanged(scenarioName)
  currentScenario = scenarioName
  if not showLocationLinks() then
    broadcastToAll("Automatic location connections not available for this scenario")
  end
end

function showLocationLinks()
  return not LOC_LINK_EXCLUDE_SCENARIOS[currentScenario]
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.
function setLimitSnapsByType(matchTypes)
  local snaps = self.getSnapPoints()
  for i, snap in ipairs(snaps) do
    local snapTags = snaps[i].tags
    if matchTypes then
      if snapTags == nil then
        snaps[i].tags = { "Location" }
      else
        table.insert(snaps[i].tags, "Location")
      end
    else
      snaps[i].tags = nil
    end
  end
  self.setSnapPoints(snaps)
end

-- rebuilds local snap points (could be useful in the future again)
function buildSnaps()
  local upperleft = { x = 1.53, z = -1.09}
  local lowerright = {x = -1.53, z = 1.55}
  local snaps = {}

  -- creates 81 snap points, for uneven rows + columns it makes a rotation snap point
  for i = 1, 9 do
    for j = 1, 9 do
      local snap = {}
      snap.position = {}
      snap.position.x = round(upperleft.x - (upperleft.x - lowerright.x) * (i - 1) / 8, 3)
      snap.position.y = 0.1
      snap.position.z = round(upperleft.z - (upperleft.z - lowerright.z) * (j - 1) / 8, 3)

      -- enable rotation snaps for uneven rows / columns
      if (i % 2 ~= 0) and (j % 2 ~= 0) then
        snap.rotation = {0, 0, 0}
        snap.rotation_snap = true
      end

      table.insert(snaps, snap)
    end
  end
  self.setSnapPoints(snaps)
end

-- utility function
function round(num, numDecimalPlaces)
  local mult = 10^(numDecimalPlaces or 0)
  return math.floor(num * mult + 0.5) / mult
end
end)
__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 row = math.floor(1 + (i - 1) / 4)
      local column = (i - 1) % 4
      table.insert(cluePositions, Vector(pos.x + 1.5 - 0.55 * row, pos.y + 0.15, pos.z - 0.825 + 0.55 * column))
    end

    return cluePositions
  end

  return TokenManager

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)
__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)
return __bundle_require("__root")
