-- 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("playercards/PlayerCardPanel", function(require, _LOADED, __bundle_register, __bundle_modules)
require("playercards/PlayerCardPanelData")
local spawnBag = require("playercards/spawnbag/SpawnBag")
local arkhamDb = require("arkhamdb/ArkhamDb")

-- Size and position information for the three rows of class buttons
local CIRCLE_BUTTON_SIZE = 250
local CLASS_BUTTONS_X_OFFSET = 0.1325
local INVESTIGATOR_ROW_START = Vector(0.125, 0.1, -0.447)
local LEVEL_ZERO_ROW_START = Vector(0.125, 0.1, -0.007)
local UPGRADED_ROW_START = Vector(0.125, 0.1, 0.333)

-- Size and position information for the two blocks of other buttons
local MISC_BUTTONS_X_OFFSET = 0.155
local WEAKNESS_ROW_START = Vector(0.157, 0.1, 0.666)
local OTHER_ROW_START = Vector(0.605, 0.1, 0.666)

-- Size and position information for the Cycle (box) buttons
local CYCLE_BUTTON_SIZE = 468
local CYCLE_BUTTON_START = Vector(-0.716, 0.1, -0.39)
local CYCLE_COLUMN_COUNT = 3
local CYCLE_BUTTONS_X_OFFSET = 0.267
local CYCLE_BUTTONS_Z_OFFSET = 0.2665

local ALL_CARDS_BAG_GUID = "15bb07"

local STARTER_DECK_MODE_SELECTED_COLOR = { 0.2, 0.2, 0.2, 0.8 }
local TRANSPARENT = { 0, 0, 0, 0 }
local STARTER_DECK_MODE_STARTERS = "starters"
local STARTER_DECK_MODE_CARDS_ONLY = "cards"

local FACE_UP_ROTATION = { x = 0, y = 270, z = 0}
local FACE_DOWN_ROTATION = { x = 0, y = 270, z = 180}

-- ----------  IMPORTANT  ----------
-- Coordinates defined below are in global dimensions relative to the panel - DO NOT USE THESE
-- DIRECTLY.  Call scalePositions() before use, and reference the variables below

-- Layout width for a single card, in global coordinate space
local CARD_WIDTH = 2.3

-- Coordinates to begin laying out cards.  These vary based on the cards that are being placed by
-- considering the width of the cards, number of cards, and desired spread intervals.
-- IMPORTANT!  Because of the mix of global card sizes and relative-to-scale positions, the X and Y
-- coordinates on these provide global disances while the Z is local.
local START_POSITIONS = {
  classCards = Vector(CARD_WIDTH * 9.5, 2, 1.4),
  investigator = Vector(6 * 2.5, 2, 1.3),
  cycle = Vector(CARD_WIDTH * 9.5, 2, 2.4),
  other = Vector(CARD_WIDTH * 9.5, 2, 1.4),
  randomWeakness = Vector(0, 2, 1.4),
  -- Because the card spread is handled by the SpawnBag, we don't know (programatically) where this
  -- should be placed.  If more customizable cards are added it will need to be moved.
  summonedServitor = Vector(CARD_WIDTH * -6.5, 2, 1.7),
}

-- Shifts to move rows of cards, and groups of rows, as different groupings are laid out
local CARD_ROW_OFFSET = 3.7
local CARD_GROUP_OFFSET = 2

-- Position offsets for investigator decks in investigator mode, defines the spacing for how the
-- rows and columns are laid out
local INVESTIGATOR_POSITION_SHIFT_ROW = Vector(0, 0, 11)
local INVESTIGATOR_POSITION_SHIFT_COL = Vector(-6, 0, 0)
local INVESTIGATOR_MAX_COLS = 6

-- Positions relative to the minicard to place other stacks.  Both signature card piles and starter
-- decks use SIGNATURE_OFFSET
local INVESTIGATOR_CARD_OFFSET = Vector(0, 0, 2.55)
local INVESTIGATOR_SIGNATURE_OFFSET = Vector(0, 0, 5.75)

-- USE THESE!  Positions and offset shifts accounting for the scale of the panel
local startPositions
local cardRowOffset
local cardGroupOffset
local investigatorPositionShiftRow
local investigatorPositionShiftCol
local investigatorCardOffset
local investigatorSignatureOffset

local CLASS_LIST = { "Guardian", "Seeker", "Rogue", "Mystic", "Survivor", "Neutral" }
local CYCLE_LIST = {
  "Core",
  "The Dunwich Legacy",
  "The Path to Carcosa",
  "The Forgotten Age",
  "The Circle Undone",
  "The Dream-Eaters",
  "The Innsmouth Conspiracy",
  "Edge of the Earth",
  "The Scarlet Keys",
  "Investigator Packs"
}

local excludedNonBasicWeaknesses

local starterDeckMode = STARTER_DECK_MODE_CARDS_ONLY
local helpVisibleToPlayers = { }

function onSave()
  local saveState = {
    spawnBagState = spawnBag.getStateForSave(),
  }
  return JSON.encode(saveState)
end

function onLoad(savedData)
  arkhamDb.initialize()
  if (savedData ~= nil) then
    local saveState = JSON.decode(savedData) or { }
    if (saveState.spawnBagState ~= nil) then
      spawnBag.loadFromSave(saveState.spawnBagState)
    end
  end
  buildExcludedWeaknessList()
  createButtons()
end

-- Build a list of non-basic weaknesses which should be excluded from the last weakness set,
-- including all signature cards and evolved weaknesses.
function buildExcludedWeaknessList()
  excludedNonBasicWeaknesses = { }
  for _, investigator in pairs(INVESTIGATORS) do
    for _, signatureId in ipairs(investigator.signatures) do
      excludedNonBasicWeaknesses[signatureId] = true
    end
  end
  for _, weaknessId in ipairs(EVOLVED_WEAKNESSES) do
    excludedNonBasicWeaknesses[weaknessId] = true
  end
end

function createButtons()
  createHelpButton()
  createInvestigatorButtons()
  createLevelZeroButtons()
  createUpgradedButtons()
  createWeaknessButtons()
  createOtherButtons()
  createCycleButtons()
  createClearButton()
  -- Create investigator mode buttons last so the indexes are set when we need to update them
  createInvestigatorModeButtons()
end

function createHelpButton()
  self.createButton({
    function_owner = self,
    click_function = "toggleHelp",
    position = Vector(0.845, 0.1, -0.855),
    rotation = Vector(0, 0, 0),
    height = 180,
    width = 180,
    scale = Vector(0.25, 1, 0.25),
    color = TRANSPARENT,
  })
end

function createInvestigatorButtons()
  local invButtonParams = {
    function_owner = self,
    rotation = Vector(0, 0, 0),
    height = CIRCLE_BUTTON_SIZE,
    width = CIRCLE_BUTTON_SIZE,
    scale = Vector(0.25, 1, 0.25),
    color = TRANSPARENT,
  }
  local buttonPos = INVESTIGATOR_ROW_START:copy()
  for _, class in ipairs(CLASS_LIST) do
    invButtonParams.click_function = "spawnInvestigators" .. class
    invButtonParams.position = buttonPos
    self.createButton(invButtonParams)
    buttonPos.x = buttonPos.x + CLASS_BUTTONS_X_OFFSET
    self.setVar(invButtonParams.click_function, function(_, _, _) spawnInvestigatorGroup(class) end)
  end
end

function createLevelZeroButtons()
  local l0ButtonParams = {
    function_owner = self,
    rotation = Vector(0, 0, 0),
    height = CIRCLE_BUTTON_SIZE,
    width = CIRCLE_BUTTON_SIZE,
    scale = Vector(0.25, 1, 0.25),
    color = TRANSPARENT,
  }
  local buttonPos = LEVEL_ZERO_ROW_START:copy()
  for _, class in ipairs(CLASS_LIST) do
    l0ButtonParams.click_function = "spawnBasic" .. class
    l0ButtonParams.position = buttonPos
    self.createButton(l0ButtonParams)
    buttonPos.x = buttonPos.x + CLASS_BUTTONS_X_OFFSET
    self.setVar(l0ButtonParams.click_function, function(_, _, _) spawnClassCards(class, false) end)
  end
end

function createUpgradedButtons()
  local upgradedButtonParams = {
    function_owner = self,
    rotation = Vector(0, 0, 0),
    height = CIRCLE_BUTTON_SIZE,
    width = CIRCLE_BUTTON_SIZE,
    scale = Vector(0.25, 1, 0.25),
    color = TRANSPARENT,
  }
  local buttonPos = UPGRADED_ROW_START:copy()
  for _, class in ipairs(CLASS_LIST) do
    upgradedButtonParams.click_function = "spawnUpgraded" .. class
    upgradedButtonParams.position = buttonPos
    self.createButton(upgradedButtonParams)
    buttonPos.x = buttonPos.x + CLASS_BUTTONS_X_OFFSET
    self.setVar(upgradedButtonParams.click_function, function(_, _, _) spawnClassCards(class, true) end)
  end
end

function createWeaknessButtons()
  local weaknessButtonParams = {
    function_owner = self,
    rotation = Vector(0, 0, 0),
    height = CIRCLE_BUTTON_SIZE,
    width = CIRCLE_BUTTON_SIZE,
    scale = Vector(0.25, 1, 0.25),
    color = TRANSPARENT,
  }
  local buttonPos = WEAKNESS_ROW_START:copy()
  weaknessButtonParams.click_function = "spawnWeaknesses"
  weaknessButtonParams.tooltip = "All Weaknesses"
  weaknessButtonParams.position = buttonPos
  self.createButton(weaknessButtonParams)
  buttonPos.x = buttonPos.x + MISC_BUTTONS_X_OFFSET
  weaknessButtonParams.click_function = "spawnRandomWeakness"
  weaknessButtonParams.tooltip = "Random Basic Weakness"
  weaknessButtonParams.position = buttonPos
  self.createButton(weaknessButtonParams)
end

function createOtherButtons()
  local otherButtonParams = {
    function_owner = self,
    rotation = Vector(0, 0, 0),
    height = CIRCLE_BUTTON_SIZE,
    width = CIRCLE_BUTTON_SIZE,
    scale = Vector(0.25, 1, 0.25),
    color = TRANSPARENT,
  }
  local buttonPos = OTHER_ROW_START:copy()
  otherButtonParams.click_function = "spawnBonded"
  otherButtonParams.tooltip = "Bonded Cards"
  otherButtonParams.position = buttonPos
  self.createButton(otherButtonParams)
  buttonPos.x = buttonPos.x + MISC_BUTTONS_X_OFFSET
  otherButtonParams.click_function = "spawnUpgradeSheets"
  otherButtonParams.tooltip = "Customization Upgrade Sheets"
  otherButtonParams.position = buttonPos
  self.createButton(otherButtonParams)
end

function createCycleButtons()
  local cycleButtonParams = {
    function_owner = self,
    rotation = Vector(0, 0, 0),
    height = CYCLE_BUTTON_SIZE,
    width = CYCLE_BUTTON_SIZE,
    scale = Vector(0.25, 1, 0.25),
    color = TRANSPARENT,
  }
  local buttonPos = CYCLE_BUTTON_START:copy()
  local rowCount = 0
  local colCount = 0
  for _, cycle in ipairs(CYCLE_LIST) do
    cycleButtonParams.click_function = "spawnCycle" .. cycle
    cycleButtonParams.position = buttonPos
    cycleButtonParams.tooltip = cycle
    self.createButton(cycleButtonParams)
    self.setVar(cycleButtonParams.click_function, function(_, _, _) spawnCycle(cycle) end)
    colCount = colCount + 1
    -- If we've reached the end of a row, shift down and back to the first column
    if colCount >= CYCLE_COLUMN_COUNT then
      buttonPos = CYCLE_BUTTON_START:copy()
      rowCount = rowCount + 1
      colCount = 0
      buttonPos.z = buttonPos.z + CYCLE_BUTTONS_Z_OFFSET * rowCount
      if rowCount == 3 then
        -- Account for centered button on the final row
        buttonPos.x = buttonPos.x + CYCLE_BUTTONS_X_OFFSET
      end
    else
      buttonPos.x = buttonPos.x + CYCLE_BUTTONS_X_OFFSET
    end
  end
end

function createClearButton()
  self.createButton({
    function_owner = self,
    click_function = "deleteAll",
    position = Vector(0, 0.1, 0.852),
    rotation = Vector(0, 0, 0),
    height = 170,
    width = 750,
    scale = Vector(0.25, 1, 0.25),
    color = TRANSPARENT,
  })
end

function createInvestigatorModeButtons()
  local starterMode = starterDeckMode == STARTER_DECK_MODE_STARTERS

  self.createButton({
    function_owner = self,
    click_function = "setCardsOnlyMode",
    position = Vector(0.251, 0.1, -0.322),
    rotation = Vector(0, 0, 0),
    height = 170,
    width = 760,
    scale = Vector(0.25, 1, 0.25),
    color = starterMode and TRANSPARENT or STARTER_DECK_MODE_SELECTED_COLOR
  })
  self.createButton({
    function_owner = self,
    click_function = "setStarterDeckMode",
    position = Vector(0.66, 0.1, -0.322),
    rotation = Vector(0, 0, 0),
    height = 170,
    width = 760,
    scale = Vector(0.25, 1, 0.25),
    color = starterMode and STARTER_DECK_MODE_SELECTED_COLOR or TRANSPARENT
  })
  local checkX = starterMode and 0.52 or 0.11
  self.createButton({
    function_owner = self,
    label = "✓",
    click_function = "doNothing",
    position = Vector(checkX, 0.11, -0.317),
    rotation = Vector(0, 0, 0),
    height = 0,
    width = 0,
    scale = Vector(0.3, 1, 0.3),
    font_color = { 0, 0, 0 },
    color = { 1, 1, 1 }
  })
end

function toggleHelp(_, playerColor, _)
  if helpVisibleToPlayers[playerColor] then
    helpVisibleToPlayers[playerColor] = nil
  else
    helpVisibleToPlayers[playerColor] = true
  end
  updateHelpVisibility()
end

function updateHelpVisibility()
  local visibility = ""
  for player, _ in pairs(helpVisibleToPlayers) do
    if string.len(visibility) > 0 then
      visibility = visibility .. "|" .. player
    else
      visibility = player
    end
  end
  self.UI.setAttribute("helpText", "visibility", visibility)
  self.UI.setAttribute("helpPanel", "visibility", visibility)
  self.UI.setAttribute("helpPanel", "active", string.len(visibility) > 0)
end

function setStarterDeckMode()
  starterDeckMode = STARTER_DECK_MODE_STARTERS
  updateStarterModeButtons()
end

function setCardsOnlyMode()
  starterDeckMode = STARTER_DECK_MODE_CARDS_ONLY
  updateStarterModeButtons()
end

function updateStarterModeButtons()
  local buttonCount = #self.getButtons()
  -- Buttons are 0-indexed, so the last three are -1, -2, and -3 from the size
  self.removeButton(buttonCount - 1)
  self.removeButton(buttonCount - 2)
  self.removeButton(buttonCount - 3)
  createInvestigatorModeButtons()
end

-- Clears the table and updates positions based on scale.  Should be called before ANY card
-- placement
function prepareToPlaceCards()
  deleteAll()
  scalePositions()
end

-- Updates the positions based on the current object scale to ensure the relative layout functions
-- properly at different scales.
function scalePositions()
  -- Assume scaling is consistent in X and Z dimensions
  local scale = 1 / self.getScale().x
  startPositions = { }
  for key, pos in pairs(START_POSITIONS) do
    -- Because a scaled object means a different global size, using global distance for Z results in
    -- the cards being closer or farther depending on the scale.  Leave the Z values and only scale
    -- X and Y
    startPositions[key] = Vector(pos)
    startPositions[key].x = startPositions[key].x * scale
    startPositions[key].y = startPositions[key].y * scale
  end
  cardRowOffset = CARD_ROW_OFFSET * scale
  cardGroupOffset = CARD_GROUP_OFFSET * scale
  investigatorPositionShiftRow = Vector(INVESTIGATOR_POSITION_SHIFT_ROW):scale(scale)
  investigatorPositionShiftCol = Vector(INVESTIGATOR_POSITION_SHIFT_COL):scale(scale)
  investigatorCardOffset = Vector(INVESTIGATOR_CARD_OFFSET):scale(scale)
  investigatorSignatureOffset = Vector(INVESTIGATOR_SIGNATURE_OFFSET):scale(scale)
end

-- Deletes all cards currently placed on the table
function deleteAll()
  spawnBag.recall(true)
end

-- Spawn an investigator group, based on the current UI setting for either investigators or starter
-- decks.
---@param groupName String.  Name of the group to spawn, matching a key in InvestigatorPanelData
function spawnInvestigatorGroup(groupName)
  local starterMode = starterDeckMode == STARTER_DECK_MODE_STARTERS
  prepareToPlaceCards()
  Wait.frames(function()
    if starterMode then
      spawnStarters(groupName)
    else
      spawnInvestigators(groupName)
    end
  end, 2)
end

-- Spawn cards for all investigators in the given group.  This creates piles for all defined
-- investigator cards and minicards as well as the signature cards.
---@param groupName String.  Name of the group to spawn, matching a key in InvestigatorPanelData
function spawnInvestigators(groupName)
  if INVESTIGATOR_GROUPS[groupName] == nil then
    printToAll("No " .. groupName .. " data yet")
    return
  end

  local col = 1
  local row = 1
  local investigatorCount = #INVESTIGATOR_GROUPS[groupName]
  local position = getInvestigatorRowStartPos(investigatorCount, row)

  for i, investigatorName in ipairs(INVESTIGATOR_GROUPS[groupName]) do
    for _, spawnSpec in ipairs(buildInvestigatorSpawnSpec(
        investigatorName, INVESTIGATORS[investigatorName], position, false)) do
      spawnBag.spawn(spawnSpec)
    end
    position:add(investigatorPositionShiftCol)
    col = col + 1
    if col > INVESTIGATOR_MAX_COLS then
      col = 1
      row = row + 1
      position = getInvestigatorRowStartPos(investigatorCount, row)
    end
  end
end

function getInvestigatorRowStartPos(investigatorCount, row)
  local rowStart = Vector(startPositions.investigator)
  rowStart:add(Vector(
      investigatorPositionShiftRow.x * (row - 1),
      investigatorPositionShiftRow.y * (row - 1),
      investigatorPositionShiftRow.z * (row - 1)))
  local investigatorsInRow =
      math.min(investigatorCount - INVESTIGATOR_MAX_COLS * (row - 1), INVESTIGATOR_MAX_COLS)
  rowStart:add(Vector(
      investigatorPositionShiftCol.x * (INVESTIGATOR_MAX_COLS - investigatorsInRow) / 2,
      investigatorPositionShiftCol.y * (INVESTIGATOR_MAX_COLS - investigatorsInRow) / 2,
      investigatorPositionShiftCol.z * (INVESTIGATOR_MAX_COLS - investigatorsInRow) / 2))

  return rowStart
end

-- Creates the spawn spec for the investigator's signature cards.
---@param investigatorName String.  Name of the investigator, matching a key in
---    InvestigatorPanelData
---@param investigatorData Table.  Spawn definition for the investigator, retrieved from
---    INVESTIGATORS
---@param position Vector.  Where to spawn the minicard; investigagor cards will be placed below
function buildInvestigatorSpawnSpec(investigatorName, investigatorData, position)
  local sigPos = Vector(position):add(investigatorSignatureOffset)
  local spawns = buildCommonSpawnSpec(investigatorName, investigatorData, position)
  table.insert(spawns, {
      name = investigatorName.."signatures",
      cards = investigatorData.signatures,
      globalPos = self.positionToWorld(sigPos),
      rotation = FACE_UP_ROTATION,
    })

  return spawns
end

-- Builds the spawn specs for minicards and investigator cards.  These are common enough to be
-- shared, and will only differ in whether they spawn the full stack of possible investigator and
-- minicards, or only the first of each.
---@param investigatorName String.  Name of the investigator, matching a key in
---    InvestigatorPanelData
---@param investigatorData Table.  Spawn definition for the investigator, retrieved from
---    INVESTIGATORS
---@param position Vector.  Where to spawn the minicard; investigagor cards will be placed below
---@param oneCardOnly Boolean.  If true, will spawn only the first card in the investigator card
---    and minicard lists.  Otherwise, spawn them all in a deck
function buildCommonSpawnSpec(investigatorName, investigatorData, position, oneCardOnly)
  local cardPos = Vector(position):add(investigatorCardOffset)
  return {
      {
        name = investigatorName.."minicards",
        cards = oneCardOnly and { investigatorData.minicards[1] } or investigatorData.minicards,
        globalPos = self.positionToWorld(position),
        rotation = FACE_UP_ROTATION,
      },
      {
        name = investigatorName.."cards",
        cards = oneCardOnly and { investigatorData.cards[1] } or investigatorData.cards,
        globalPos = self.positionToWorld(cardPos),
        rotation = FACE_UP_ROTATION,
      },
  }
end

-- Spawns all starter decks (single minicard and investigator card, plus the starter deck) for
-- investigators in the given group.
---@param groupName String.  Name of the group to spawn, matching a key in InvestigatorPanelData
function spawnStarters(groupName)
  local col = 1
  local row = 1
  local investigatorCount = #INVESTIGATOR_GROUPS[groupName]
  local position = getInvestigatorRowStartPos(investigatorCount, row)
  for _, investigatorName in ipairs(INVESTIGATOR_GROUPS[groupName]) do
    spawnStarterDeck(investigatorName, INVESTIGATORS[investigatorName], position)
    position:add(investigatorPositionShiftCol)
    col = col + 1
    if col > INVESTIGATOR_MAX_COLS then
      col = 1
      row = row + 1
      position = getInvestigatorRowStartPos(investigatorCount, row)
    end
  end
end

-- Spawns the defined starter deck for the given investigator's.
---@param investigatorName String.  Name of the investigator, matching a key in
---    InvestigatorPanelData
function spawnStarterDeck(investigatorName, investigatorData, position)
  for _, spawnSpec in ipairs(
      buildCommonSpawnSpec(investigatorName, INVESTIGATORS[investigatorName], position, true)) do
    spawnBag.spawn(spawnSpec)
  end
  local deckPos = Vector(position):add(investigatorSignatureOffset)
  arkhamDb.getDecklist("None", investigatorData.starterDeck, true, false, false, function(slots)
    local cardIdList = { }
    for id, count in pairs(slots) do
      for i = 1, count do
        table.insert(cardIdList, id)
      end
    end
    spawnBag.spawn({
      name = investigatorName.."starter",
      cards = cardIdList,
      globalPos = self.positionToWorld(deckPos),
      rotation = FACE_DOWN_ROTATION
    })
  end)
end
-- Clears the currently placed cards, then places cards for the given class and level spread
---@param cardClass String.  Class to place ("Guardian", "Seeker", etc)
---@param isUpgraded Boolean.  If true, spawn the Level 1-5 cards.  Otherwise, Level 0.
function spawnClassCards(cardClass, isUpgraded)
  prepareToPlaceCards()
  Wait.frames(function() placeClassCards(cardClass, isUpgraded) end, 2)
end

-- Spawn the class cards.
---@param cardClass String.  Class to place ("Guardian", "Seeker", etc)
---@param isUpgraded Boolean.  If true, spawn the Level 1-5 cards.  Otherwise, Level 0.
function placeClassCards(cardClass, isUpgraded)
  local allCardsBag = getObjectFromGUID(ALL_CARDS_BAG_GUID)
  local indexReady = allCardsBag.call("isIndexReady")
  if (not indexReady) then
    broadcastToAll("Still loading player cards, please try again in a few seconds", {0.9, 0.2, 0.2})
    return
  end
  local cardIdList = allCardsBag.call("getCardsByClassAndLevel", {class = cardClass, upgraded = isUpgraded})

  local skillList = { }
  local eventList = { }
  local assetList = { }
  for _, cardId in ipairs(cardIdList) do
    local cardMetadata = allCardsBag.call("getCardById", { id = cardId }).metadata
    if (cardMetadata.type == "Skill") then
      table.insert(skillList, cardId)
    elseif (cardMetadata.type == "Event") then
      table.insert(eventList, cardId)
    elseif (cardMetadata.type == "Asset") then
      table.insert(assetList, cardId)
    end
  end
  local groupPos = Vector(startPositions.classCards)
  if #skillList > 0 then
    spawnBag.spawn({
      name = cardClass .. (isUpgraded and "upgraded" or "basic"),
      cards = skillList,
      globalPos = self.positionToWorld(groupPos),
      rotation = FACE_UP_ROTATION,
      spread = true,
      spreadCols = 20
    })
    groupPos.z = groupPos.z + math.ceil(#skillList / 20) * cardRowOffset + cardGroupOffset
  end
  if #eventList > 0 then
    spawnBag.spawn({
      name = cardClass .. "event" .. (isUpgraded and "upgraded" or "basic"),
      cards = eventList,
      globalPos = self.positionToWorld(groupPos),
      rotation = FACE_UP_ROTATION,
      spread = true,
      spreadCols = 20
    })
    groupPos.z = groupPos.z + math.ceil(#eventList / 20) * cardRowOffset + cardGroupOffset
  end
  if #assetList > 0 then
    spawnBag.spawn({
      name = cardClass .. "asset" .. (isUpgraded and "upgraded" or "basic"),
      cards = assetList,
      globalPos = self.positionToWorld(groupPos),
      rotation = FACE_UP_ROTATION,
      spread = true,
      spreadCols = 20
    })
  end
end

-- Spawns the investigator sets and all cards for the given cycle
---@param cycle String Name of a cycle, should match the standard used in card metadata
function spawnCycle(cycle)
  prepareToPlaceCards()
  spawnInvestigators(cycle)
  local allCardsBag = getObjectFromGUID(ALL_CARDS_BAG_GUID)
  local indexReady = allCardsBag.call("isIndexReady")
  if (not indexReady) then
    broadcastToAll("Still loading player cards, please try again in a few seconds", {0.9, 0.2, 0.2})
    return
  end
  local cycleCardList = allCardsBag.call("getCardsByCycle", cycle)
  local copiedList = { }
  for i, id in ipairs(cycleCardList) do
    copiedList[i] = id
  end
  spawnBag.spawn({
    name = "cycle"..cycle,
    cards = copiedList,
    globalPos = self.positionToWorld(startPositions.cycle),
    rotation = FACE_UP_ROTATION,
    spread = true,
    spreadCols = 20
  })
end

function spawnBonded()
  prepareToPlaceCards()
  spawnBag.spawn({
    name = "bonded",
    cards = BONDED_CARD_LIST,
    globalPos = self.positionToWorld(startPositions.classCards),
    rotation = FACE_UP_ROTATION,
    spread = true,
    spreadCols = 20
  })
end

function spawnUpgradeSheets()
  prepareToPlaceCards()
  spawnBag.spawn({
    name = "upgradeSheets",
    cards = UPGRADE_SHEET_LIST,
    globalPos = self.positionToWorld(startPositions.classCards),
    rotation = FACE_UP_ROTATION,
    spread = true,
    spreadCols = 20
  })
  spawnBag.spawn({
    name = "servitor",
    cards = { "09080-m" },
    globalPos = self.positionToWorld(startPositions.summonedServitor),
    rotation = FACE_UP_ROTATION,
  })
end

-- Clears the current cards, and places all basic weaknesses on the table.
function spawnWeaknesses()
  prepareToPlaceCards()
  local allCardsBag = getObjectFromGUID(ALL_CARDS_BAG_GUID)
  local indexReady = allCardsBag.call("isIndexReady")
  if (not indexReady) then
    broadcastToAll("Still loading player cards, please try again in a few seconds", {0.9, 0.2, 0.2})
    return
  end
  local weaknessIdList = allCardsBag.call("getUniqueWeaknesses")
  local basicWeaknessList = { }
  local otherWeaknessList = { }
  for i, id in ipairs(weaknessIdList) do
    local cardMetadata = allCardsBag.call("getCardById", { id = id }).metadata
    if cardMetadata.basicWeaknessCount ~= nil and cardMetadata.basicWeaknessCount > 0 then
      table.insert(basicWeaknessList, id)
    elseif excludedNonBasicWeaknesses[id] == nil then
      table.insert(otherWeaknessList, id)
    end
  end
  local groupPos = Vector(startPositions.classCards)
  spawnBag.spawn({
    name = "basicWeaknesses",
    cards = basicWeaknessList,
    globalPos = self.positionToWorld(groupPos),
    rotation = FACE_UP_ROTATION,
    spread = true,
    spreadCols = 20
  })
  groupPos.z = groupPos.z + math.ceil(#basicWeaknessList / 20) * cardRowOffset + cardGroupOffset
  spawnBag.spawn({
    name = "evolvedWeaknesses",
    cards = EVOLVED_WEAKNESSES,
    globalPos = self.positionToWorld(groupPos),
    rotation = FACE_UP_ROTATION,
    spread = true,
    spreadCols = 20
  })
  groupPos.z = groupPos.z + math.ceil(#EVOLVED_WEAKNESSES / 20) * cardRowOffset + cardGroupOffset
  spawnBag.spawn({
    name = "otherWeaknesses",
    cards = otherWeaknessList,
    globalPos = self.positionToWorld(groupPos),
    rotation = FACE_UP_ROTATION,
    spread = true,
    spreadCols = 20
  })
end

function spawnRandomWeakness()
  prepareToPlaceCards()
  local allCardsBag = getObjectFromGUID(ALL_CARDS_BAG_GUID)
  local weaknessId = allCardsBag.call("getRandomWeaknessId")
  if (weaknessId == nil) then
    broadcastToAll("All basic weaknesses are in play!", {0.9, 0.2, 0.2})
    return
  end
  spawnBag.spawn({
    name = "randomWeakness",
    cards = { weaknessId },
    globalPos = self.positionToWorld(startPositions.randomWeakness),
    rotation = FACE_UP_ROTATION,
  })
end
end)
__bundle_register("arkhamdb/ArkhamDb", function(require, _LOADED, __bundle_register, __bundle_modules)
do
  local playAreaApi = require("core/PlayAreaApi")
  local ArkhamDb = { }
  local internal = { }

  local RANDOM_WEAKNESS_ID = "01000"

  local tabooList = { }
  --Forward declaration
  ---@type Request
  local Request = {}
  local configuration

  -- Sets up the ArkhamDb interface.  Should be called from the parent object on load.
  ArkhamDb.initialize = function()
    configuration = internal.getConfiguration()
    Request.start({ configuration.api_uri, configuration.taboo }, function(status)
      local json = JSON.decode(internal.fixUtf16String(status.text))
      for _, taboo in pairs(json) do
        ---@type <string, boolean>
        local cards = {}

        for _, card in pairs(JSON.decode(taboo.cards)) do
          cards[card.code] = true
        end

        tabooList[taboo.id] = {
          date = taboo.date_start,
          cards = cards
        }
      end
      return true, nil
    end)
  end

  -- Start the deck build process for the given player color and deck ID.  This
  -- will retrieve the deck from ArkhamDB, and pass to a callback for processing.
  ---@param playerColor String.  Color name of the player mat to place this deck on (e.g. "Red").
  ---@param deckId String.  ArkhamDB deck id to be loaded
  ---@param isPrivate Boolean.  Whether this deck is published or private on ArkhamDB
  ---@param loadNewest Boolean.  Whether the newest version of this deck should be loaded
  ---@param loadInvestigators Boolean.  Whether investigator cards should be loaded as part of this
  ---    deck
  ---@param callback Function.  Callback which will be sent the results of this load.  Parameters
  ---    to the callback will be:
  ---        slots Table.  A map of card ID to count in the deck
  ---        investigatorCode String.  ID of the investigator in this deck
  ---        customizations Table.  The decoded table of customization upgrades in this deck
  ---        playerColor String.  Color this deck is being loaded for
  ArkhamDb.getDecklist = function(
      playerColor,
      deckId,
      isPrivate,
      loadNewest,
      loadInvestigators,
      callback)
    -- Get a simple card to see if the bag indexes are complete. If not, abort
    -- the deck load. The called method will handle player notification.
    local allCardsBag = getObjectFromGUID(configuration.card_bag_guid)
    local checkCard = allCardsBag.call("getCardById", { id = "01001" })
    if (checkCard ~= nil and checkCard.data == nil) then
      return
    end

    local deckUri = { configuration.api_uri,
      isPrivate and configuration.private_deck or configuration.public_deck, deckId }

    local deck = Request.start(deckUri, function(status)
      if string.find(status.text, "<!DOCTYPE html>") then
        internal.maybePrint("Private deck ID " .. deckId .. " is not shared", playerColor)
        return false, table.concat({ "Private deck ", deckId, " is not shared" })
      end
      local json = JSON.decode(status.text)

      if not json then
        internal.maybePrint("Deck ID " .. deckId .. " not found", playerColor)
        return false, "Deck not found!"
      end

      return true, JSON.decode(status.text)
    end)

    deck:with(internal.onDeckResult, playerColor, loadNewest, loadInvestigators, callback)
  end

  -- Logs that a card could not be loaded in the mod by printing it to the console in the given
  -- color of the player owning the deck.  Attempts to look up the name on ArkhamDB for clarity,
  -- but prints the card ID if the name cannot be retrieved.
  ---@param cardId String.  ArkhamDB ID of the card that could not be found
  ---@param playerColor String.  Color of the player's deck that had the problem
  ArkhamDb.logCardNotFound = function(cardId, playerColor)
    local request = Request.start({
      configuration.api_uri,
      configuration.cards,
      cardId
    },
      function(result)
        local adbCardInfo = JSON.decode(internal.fixUtf16String(result.text))
        local cardName = adbCardInfo.real_name
        if (cardName ~= nil) then
          if (adbCardInfo.xp ~= nil and adbCardInfo.xp > 0) then
            cardName = cardName .. " (" .. adbCardInfo.xp .. ")"
          end
          internal.maybePrint("Card not found: " .. cardName .. ", ArkhamDB ID " .. cardId, playerColor)
        else
          internal.maybePrint("Card not found in ArkhamDB, ID " .. cardId, playerColor)
        end
      end)
  end

  -- Callback when the deck information is received from ArkhamDB.  Parses the
  -- response then applies standard transformations to the deck such as adding
  -- random weaknesses and checking for taboos.  Once the deck is processed,
  -- passes to loadCards to actually spawn the defined deck.
  ---@param deck ArkhamImportDeck
  ---@param playerColor String Color name of the player mat to place this deck on (e.g. "Red")
  ---@param loadNewest Boolean Whether the newest version of this deck should be loaded
  ---@param loadInvestigators Boolean Whether investigator cards should be loaded as part of this
  ---    deck
  ---@param callback Function Callback which will be sent the results of this load.  Parameters
  ---    to the callback will be:
  ---        slots Table.  A map of card ID to count in the deck
  ---        investigatorCode String.  ID of the investigator in this deck
  ---        bondedList A table of cardID keys to meaningless values.  Card IDs in this list were
  ---        added from a parent bonded card.
  ---        customizations Table.  The decoded table of customization upgrades in this deck
  ---        playerColor String.  Color this deck is being loaded for
  internal.onDeckResult = function(deck, playerColor, loadNewest, loadInvestigators, callback)
    -- Load the next deck in the upgrade path if the option is enabled
    if (loadNewest and deck.next_deck ~= nil and deck.next_deck ~= "") then
      buildDeck(playerColor, deck.next_deck)
      return
    end

    internal.maybePrint(table.concat({ "Found decklist: ", deck.name }), playerColor)

    -- Initialize deck slot table and perform common transformations.  The order of these should not
    -- be changed, as later steps may act on cards added in each.  For example, a random weakness or
    -- investigator may have bonded cards or taboo entries, and should be present
    local slots = deck.slots
    internal.maybeDrawRandomWeakness(slots, playerColor)
    if loadInvestigators then
      internal.addInvestigatorCards(deck, slots)
    end
    internal.maybeAddCustomizeUpgradeSheets(slots)
    internal.maybeAddSummonedServitor(slots)
    internal.maybeAddOnTheMend(slots, playerColor)
    local bondList = internal.extractBondedCards(slots)
    internal.checkTaboos(deck.taboo_id, slots, playerColor)

    -- get upgrades for customizable cards
    local meta = deck.meta
    local customizations = {}
    if meta then
      customizations = JSON.decode(meta)
    end

    callback(slots, deck.investigator_code, bondList, customizations, playerColor)
  end

  -- Checks to see if the slot list includes the random weakness ID. If it does,
  -- removes it from the deck and replaces it with the ID of a random basic weakness provided by the
  -- all cards bag
  ---@param slots Table The slot list for cards in this deck. Table key is the cardId, value is the number
  ---    of those cards which will be spawned
  ---@param playerColor String Color of the player this deck is being loaded for. Used for broadcast
  ---    if a weakness is added.
  internal.maybeDrawRandomWeakness = function(slots, playerColor)
    local allCardsBag = getObjectFromGUID(configuration.card_bag_guid)
    local hasRandomWeakness = false
    for cardId, cardCount in pairs(slots) do
      if cardId == RANDOM_WEAKNESS_ID then
        hasRandomWeakness = true
        break
      end
    end
    if hasRandomWeakness then
      local weaknessId = allCardsBag.call("getRandomWeaknessId")
      slots[weaknessId] = 1
      slots[RANDOM_WEAKNESS_ID] = nil
      internal.maybePrint("Random basic weakness added to deck", playerColor)
    end
  end

  -- Adds both the investigator (XXXXX) and minicard (XXXXX-m) slots with one copy each
  ---@param deck Table The processed ArkhamDB deck response
  ---@param slots Table The slot list for cards in this deck.  Table key is the cardId, value is the
  ---    number of those cards which will be spawned
  internal.addInvestigatorCards = function(deck, slots)
    local investigatorId = deck.investigator_code
    slots[investigatorId .. "-m"] = 1
    local deckMeta = JSON.decode(deck.meta)
    local parallelFront = deckMeta ~= nil and deckMeta.alternate_front ~= nil and deckMeta.alternate_front ~= ""
    local parallelBack = deckMeta ~= nil and deckMeta.alternate_back ~= nil and deckMeta.alternate_back ~= ""
    if parallelFront and parallelBack then
      investigatorId = investigatorId .. "-p"
    elseif parallelFront then
      local alternateNum = tonumber(deckMeta.alternate_front)
      if alternateNum >= 01501 and alternateNum <= 01506 then
        investigatorId = investigatorId .. "-r"
      else
        investigatorId = investigatorId .. "-pf"
      end
    elseif parallelBack then
      investigatorId = investigatorId .. "-pb"
    end
    slots[investigatorId] = 1
  end

  -- Process the card list looking for the customizable cards, and add their upgrade sheets if needed
  ---@param slots Table The slot list for cards in this deck. Table key is the cardId, value is the number
  --     of those cards which will be spawned
  internal.maybeAddCustomizeUpgradeSheets = function(slots)
    local allCardsBag = getObjectFromGUID(configuration.card_bag_guid)
    for cardId, _ in pairs(slots) do
      -- upgrade sheets for customizable cards
      local upgradesheet = allCardsBag.call("getCardById", { id = cardId .. "-c" })
      if upgradesheet ~= nil then
        slots[cardId .. "-c"] = 1
      end
    end
  end

  -- Process the card list looking for the Summoned Servitor, and add its minicard to the list if
  -- needed
  ---@param slots Table The slot list for cards in this deck. Table key is the cardId, value is the number
  --     of those cards which will be spawned
  internal.maybeAddSummonedServitor = function(slots)
    if slots["09080"] ~= nil then
      slots["09080-m"] = 1
    end
  end

  -- On the Mend should have 1-per-investigator copies set aside, but ArkhamDB always sends 1.  Update
  -- the count based on the investigator count
  ---@param slots Table The slot list for cards in this deck. Table key is the cardId, value is the number
  --     of those cards which will be spawned
  ---@param playerColor String Color of the player this deck is being loaded for. Used for broadcast if an error occurs
  internal.maybeAddOnTheMend = function(slots, playerColor)
    if slots["09006"] ~= nil then
      local investigatorCount = playAreaApi.getInvestigatorCount()
      if investigatorCount ~= nil then
        slots["09006"] = investigatorCount
      else
        internal.maybePrint("Something went wrong with the load, adding 4 copies of On the Mend", playerColor)
        slots["09006"] = 4
      end
    end
  end

  -- Process the slot list and looks for any cards which are bonded to those in the deck. Adds those cards to the slot list.
  ---@param slots Table The slot list for cards in this deck. Table key is the cardId, value is the number of those cards which will be spawned
  internal.extractBondedCards = function(slots)
    local allCardsBag = getObjectFromGUID(configuration.card_bag_guid)
    -- Create a list of bonded cards first so we don't modify slots while iterating
    local bondedCards = { }
    local bondedList = { }
    for cardId, cardCount in pairs(slots) do
      local card = allCardsBag.call("getCardById", { id = cardId })
      if (card ~= nil and card.metadata.bonded ~= nil) then
        for _, bond in ipairs(card.metadata.bonded) do
          bondedCards[bond.id] = bond.count
          -- We need to know which cards are bonded to determine their position, remember them
          bondedList[bond.id] = true
          -- Also adding taboo versions of bonded cards to the list
          bondedList[bond.id .. "-t"] = true
        end
      end
    end
    -- Add any bonded cards to the main slots list
    for bondedId, bondedCount in pairs(bondedCards) do
      slots[bondedId] = bondedCount
    end

    return bondedList
  end

  -- Check the deck for cards on its taboo list. If they're found, replace the entry in the slot with the Taboo id (i.e. "XXXX" becomes "XXXX-t")
  ---@param tabooId String The deck's taboo ID, taken from the deck response taboo_id field. May be nil, indicating that no taboo list should be used
  ---@param slots Table The slot list for cards in this deck. Table key is the cardId, value is the number of those cards which will be spawned
  internal.checkTaboos = function(tabooId, slots, playerColor)
    if tabooId then
      local allCardsBag = getObjectFromGUID(configuration.card_bag_guid)
      for cardId, _ in pairs(tabooList[tabooId].cards) do
        if slots[cardId] ~= nil then
          -- Make sure there's a taboo version of the card before we replace it
          -- SCED only maintains the most recent taboo cards. If a deck is using
          -- an older taboo list it's possible the card isn't a taboo any more
          local tabooCard = allCardsBag.call("getCardById", { id = cardId .. "-t" })
          if tabooCard == nil then
            local basicCard = allCardsBag.call("getCardById", { id = cardId })
            internal.maybePrint("Taboo version for " .. basicCard.data.Nickname .. " is not available. Using standard version", playerColor)
          else
            slots[cardId .. "-t"] = slots[cardId]
            slots[cardId] = nil
          end
        end
      end
    end
  end

  internal.maybePrint = function(message, playerColor)
    if playerColor ~= "None" then
      printToAll(message, playerColor)
    end
  end

  -- Gets the ArkhamDB config info from the configuration object.
  ---@return Table.  Configuration data
  internal.getConfiguration = function()
    local configuration = getObjectsWithTag("import_configuration_provider")[1]:getTable("configuration")
    printPriority = configuration.priority
    return configuration
  end

  internal.fixUtf16String = function(str)
    return str:gsub("\\u(%w%w%w%w)", function(match)
      return string.char(tonumber(match, 16))
    end)
  end

  ---@type Request
  Request = {
    is_done = false,
    is_successful = false
  }

  -- Creates a new instance of a Request. Should not be directly called. Instead use Request.start and Request.deferred.
  ---@param uri string
  ---@param configure fun(request: Request, status: WebRequestStatus)
  ---@return Request
  function Request:new(uri, configure)
    local this = {}

    setmetatable(this, self)
    self.__index = self

    if type(uri) == "table" then
      uri = table.concat(uri, "/")
    end

    this.uri = uri

    WebRequest.get(uri, function(status)
      configure(this, status)
    end)

    return this
  end

  -- Creates a new request. on_success should set the request's is_done, is_successful, and content variables.
  -- Deferred should be used when you don't want to set is_done immediately (such as if you want to wait for another request to finish)
  ---@param uri string
  ---@param on_success fun(request: Request, status: WebRequestStatus, vararg any)
  ---@param on_error fun(status: WebRequestStatus)|nil
  ---@vararg any[]
  ---@return Request
  function Request.deferred(uri, on_success, on_error, ...)
    local parameters = table.pack(...)
    return Request:new(uri, function(request, status)
      if (status.is_done) then
        if (status.is_error) then
          request.error_message = on_error and on_error(status, table.unpack(parameters)) or status.error
          request.is_successful = false
          request.is_done = true
        else
          on_success(request, status)
        end
      end
    end)
  end

  -- Creates a new request. on_success should return weather the resultant data is as expected, and the processed content of the request.
  ---@param uri string
  ---@param on_success fun(status: WebRequestStatus, vararg any): boolean, any
  ---@param on_error nil|fun(status: WebRequestStatus, vararg any): string
  ---@vararg any[]
  ---@return Request
  function Request.start(uri, on_success, on_error, ...)
    local parameters = table.pack(...)
    return Request.deferred(uri, function(request, status)
      local result, message = on_success(status, table.unpack(parameters))
      if not result then request.error_message = message else request.content = message end
      request.is_successful = result
      request.is_done = true
    end, on_error, table.unpack(parameters))
  end

  ---@param requests Request[]
  ---@param on_success fun(content: any[], vararg any[])
  ---@param on_error fun(requests: Request[], vararg any[])|nil
  ---@vararg any
  function Request.with_all(requests, on_success, on_error, ...)
    local parameters = table.pack(...)

    Wait.condition(function()
      ---@type any[]
      local results = {}

      ---@type Request[]
      local errors = {}

      for _, request in ipairs(requests) do
        if request.is_successful then
          table.insert(results, request.content)
        else
          table.insert(errors, request)
        end
      end

      if (#errors <= 0) then
        on_success(results, table.unpack(parameters))
      elseif on_error == nil then
        for _, request in ipairs(errors) do
          internal.maybePrint(table.concat({ "[ERROR]", request.uri, ":", request.error_message }))
        end
      else
        on_error(requests, table.unpack(parameters))
      end
    end, function()
      for _, request in ipairs(requests) do
        if not request.is_done then return false end
      end
      return true
    end)
  end

  ---@param callback fun(content: any, vararg any)
  function Request:with(callback, ...)
    local arguments = table.pack(...)
    Wait.condition(function()
      if self.is_successful then
        callback(self.content, table.unpack(arguments))
      end
    end, function() return self.is_done
    end)
  end

  return ArkhamDb
end
end)
__bundle_register("playercards/PlayerCardPanelData", function(require, _LOADED, __bundle_register, __bundle_modules)
BONDED_CARD_LIST = {
    "05314", -- Soothing Melody
    "06277", -- Wish Eater
    "06019", -- Bloodlust
    "06022", -- Pendant of the Queen
    "05317", -- Blood-rite
    "06113", -- Essence of the Dream
    "06028", -- Stars Are Right
    "06025", -- Guardian of the Crystallizer
    "06283", -- Unbound Beast
    "06032", -- Zeal
    "06031", -- Hope
    "06033", -- Augur
    "06331", -- Dream Parasite
    "06015a", -- Dream-Gate
}

UPGRADE_SHEET_LIST = {
  "09040-c", -- Alchemical Distillation
  "09023-c", -- Custom Modifications
  "09059-c", -- Damning Testimony
  "09041-c", -- Emperical Hypothesis
  "09060-c", -- Friends in Low Places
  "09101-c", -- Grizzled
  "09061-c", -- Honed Instinct
  "09021-c", -- Hunter's Armor
  "09119-c", -- Hyperphysical Shotcaster
  "09079-c", -- Living Ink
  "09100-c", -- Makeshift Trap
  "09099-c", -- Pocket Multi Tool
  "09081-c", -- Power Word
  "09022-c", -- Runic Axe
  "09080-c", -- Summoned Servitor
  "09042-c", -- Raven's Quill
}

EVOLVED_WEAKNESSES = {
  "04039",
  "04041",
  "04042",
  "53014",
  "53015",
}

------------------ START INVESTIGATOR DATA DEFINITION ------------------
INVESTIGATOR_GROUPS = {
  ["Guardian"] = {
    "Roland Banks",
  	"Zoey Samaras",
  	"Mark Harrigan",
  	"Leo Anderson",
  	"Carolyn Fern",
  	"Tommy Muldoon",
  	"Nathaniel Cho",
  	"Sister Mary",
  	"Daniela Reyes",
  	"Carson Sinclair"
  },
  ["Seeker"] = {
    "Daisy Walker",
  	"Rex Murphy",
  	"Minh Thi Phan",
  	"Ursula Downs",
  	"Joe Diamond",
  	"Mandy Thompson",
  	"Harvey Walters",
  	"Amanda Sharpe",
  	"Norman Withers",
  	"Vincent Lee"
  },
  ["Rogue"] = {
  	"\"Skids\" O'Toole",
  	"Jenny Barnes",
  	"Sefina Rousseau",
  	"Finn Edwards",
  	"Preston Fairmont",
  	"Tony Morgan",
  	"Winifred Habbamock",
  	"Trish Scarborough",
  	"Monterey Jack",
  	"Kymani Jones"
  },
  ["Mystic"] = {
  	"Agnes Baker",
  	"Jim Culver",
  	"Akachi Onyele",
  	"Father Mateo",
  	"Diana Stanley",
  	"Marie Lambeau",
  	"Luke Robinson",
  	"Jacqueline Fine",
  	"Dexter Drake",
  	"Lily Chen",
  	"Amina Zidane",
  	"Gloria Goldberg"
  },
  ["Survivor"] = {
  	"Wendy Adams",
  	"\"Ashcan\" Pete",
  	"William Yorick",
  	"Calvin Wright",
  	"Rita Young",
  	"Patrice Hathaway",
  	"Stella Clark",
  	"Silas Marsh",
  	"Bob Jenkins",
  	"Darrell Simmons"
  },
  ["Neutral"] = {
  	"Lola Hayes",
  	"Charlie Kane"
  },
  ["Core"] = {
    "Roland Banks",
    "Daisy Walker",
    "\"Skids\" O'Toole",
    "Agnes Baker",
    "Wendy Adams"
  },
  ["The Dunwich Legacy"] = {
  	"Zoey Samaras",
  	"Rex Murphy",
  	"Jenny Barnes",
  	"Jim Culver",
  	"\"Ashcan\" Pete"
  },
  ["The Path to Carcosa"] = {
  	"Mark Harrigan",
  	"Minh Thi Phan",
  	"Sefina Rousseau",
  	"Akachi Onyele",
  	"William Yorick",
  	"Lola Hayes"
  },
  ["The Forgotten Age"] = {
  	"Leo Anderson",
  	"Ursula Downs",
  	"Finn Edwards",
  	"Father Mateo",
  	"Calvin Wright"
  },
  ["The Circle Undone"] = {
  	"Carolyn Fern",
  	"Joe Diamond",
  	"Preston Fairmont",
  	"Diana Stanley",
  	"Rita Young",
  	"Marie Lambeau"
  },
  ["The Dream-Eaters"] = {
  	"Tommy Muldoon",
  	"Mandy Thompson",
  	"Tony Morgan",
  	"Luke Robinson",
  	"Patrice Hathaway"
  },
  ["Investigator Packs"] = {
  	"Nathaniel Cho",
  	"Harvey Walters",
  	"Winifred Habbamock",
  	"Jacqueline Fine",
  	"Stella Clark",
  	"Gloria Goldberg"
  },
  ["The Innsmouth Conspiracy"] = {
  	"Sister Mary",
  	"Amanda Sharpe",
  	"Trish Scarborough",
  	"Dexter Drake",
  	"Silas Marsh"
  },
  ["Edge of the Earth"] = {
  	"Daniela Reyes",
  	"Norman Withers",
  	"Monterey Jack",
  	"Lily Chen",
  	"Bob Jenkins"
  },
  ["The Scarlet Keys"] = {
  	"Carson Sinclair",
  	"Vincent Lee",
  	"Kymani Jones",
  	"Amina Zidane",
  	"Darrell Simmons",
  	"Charlie Kane"
  }
}

INVESTIGATORS = { }
--Core--
INVESTIGATORS["Roland Banks"] = {
  cards = { "01001", "01001-p", "01001-pf", "01001-pb" },
  minicards = { "01001-m" },
  signatures = { "01006", "01007", "90030", "90031", "98005", "98006" },
  starterDeck = "2624931"
}
INVESTIGATORS["Daisy Walker"] = {
  cards = { "01002", "01002-p", "01002-pf", "01002-pb" },
  minicards = { "01002-m" },
  signatures = { "01008", "01009", "90002", "90003" },
  starterDeck = "2624938"
}
INVESTIGATORS["\"Skids\" O'Toole"] = {
	cards = { "01003", "01003-p", "01003-pf", "01003-pb" },
	minicards = { "01003-m" },
	signatures = { "01010", "01011", "90009", "90010" },
	starterDeck = "2624940"
}
INVESTIGATORS["Agnes Baker"] = {
	cards = { "01004", "01004-p", "01004-pf", "01004-pb" },
	minicards = { "01004-m" },
	signatures = { "01012", "01013", "90018", "90019" },
	starterDeck = "2624944"
}
INVESTIGATORS["Wendy Adams"] = {
	cards = { "01005", "01005-p", "01005-pf", "01005-pb"},
	minicards = { "01005-m" },
	signatures = { "01014", "01015", "01515", "90039", "90040", "90038" },
	starterDeck = "2624949"
}
--Dunwich--
INVESTIGATORS["Zoey Samaras"] = {
	cards = { "02001" },
	minicards = { "02001-m" },
	signatures = { "02006", "02007" },
	starterDeck = "2624950"
}
INVESTIGATORS["Rex Murphy"] = {
	cards = { "02002", "02002-t" },
	minicards = { "02002-m" },
	signatures = { "02008", "02009" },
	starterDeck = "2624958"
}
INVESTIGATORS["Jenny Barnes"] = {
	cards = { "02003" },
	minicards = { "02003-m" },
	signatures = { "02010", "02011", "98002", "98003" },
	starterDeck = "2624961"
}
INVESTIGATORS["Jim Culver"] = {
	cards = { "02004" },
	minicards = { "02004-m" },
	signatures = { "02012", "02013" },
	starterDeck = "2624965"
}
INVESTIGATORS["\"Ashcan\" Pete"] = {
	cards = { "02005" },
	minicards = { "02005-m" },
	signatures = { "02014", "02015" },
	starterDeck = "2624969"
}
--Carcosa--
INVESTIGATORS["Mark Harrigan"] = {
	cards = { "03001" },
	minicards = { "03001-m" },
	signatures = { "03007", "03008", "03009" },
	starterDeck = "2624975"
}
INVESTIGATORS["Minh Thi Phan"] = {
	cards = { "03002" },
	minicards = { "03002-m" },
	signatures = { "03010", "03011" },
	starterDeck = "2624979"
}
INVESTIGATORS["Sefina Rousseau"] = {
	cards = { "03003" },
	minicards = { "03003-m" },
	signatures = { "03012", "03012", "03012", "03013" },
	starterDeck = "2624981"
}
INVESTIGATORS["Akachi Onyele"] = {
	cards = { "03004" },
	minicards = { "03004-m" },
	signatures = { "03014", "03015" },
	starterDeck = "2624984"
}
INVESTIGATORS["William Yorick"] = {
	cards = { "03005" },
	minicards = { "03005-m" },
	signatures = { "03016", "03017" },
	starterDeck = "2624988"
}
INVESTIGATORS["Lola Hayes"] = {
	cards = { "03006", "03006-t" },
	minicards = { "03006-m" },
	signatures = { "03018", "03018", "03019", "03019", "03019-t", "03019-t" },
	starterDeck = "2624990"
}
--Forgotten--
INVESTIGATORS["Leo Anderson"] = {
	cards = { "04001" },
	minicards = { "04001-m" },
	signatures = { "04006", "04007" },
	starterDeck = "2624994"
}
INVESTIGATORS["Ursula Downs"] = {
	cards = { "04002" },
	minicards = { "04002-m" },
	signatures = { "04008", "04009" },
	starterDeck = "2625000"
}
INVESTIGATORS["Finn Edwards"] = {
	cards = { "04003" },
	minicards = { "04003-m" },
	signatures = { "04010", "04011", "04012" },
	starterDeck = "2625003"
}
INVESTIGATORS["Father Mateo"] = {
	cards = { "04004" },
	minicards = { "04004-m" },
	signatures = { "04013", "04014" },
	starterDeck = "2625005"
}
INVESTIGATORS["Calvin Wright"] = {
	cards = { "04005" },
	minicards = { "04005-m" },
	signatures = { "04015", "04016" },
	starterDeck = "2625007"
}
--Circle--
INVESTIGATORS["Carolyn Fern"] = {
	cards = { "05001" },
	minicards = { "05001-m" },
	signatures = { "05007", "05008", "98011", "98012" },
	starterDeck = "2626342"
}
INVESTIGATORS["Joe Diamond"] = {
	cards = { "05002" },
	minicards = { "05002-m" },
	signatures = { "05009", "05010" },
	starterDeck = "2626347"
}
INVESTIGATORS["Preston Fairmont"] = {
	cards = { "05003" },
	minicards = { "05003-m" },
	signatures = { "05011", "05012" },
	starterDeck = "2626365"
}
INVESTIGATORS["Diana Stanley"] = {
	cards = { "05004" },
	minicards = { "05004-m" },
	signatures = { "05013", "05014", "05015" },
	starterDeck = "2626385"
}
INVESTIGATORS["Rita Young"] = {
	cards = { "05005" },
	minicards = { "05005-m" },
	signatures = { "05016", "05017" },
	starterDeck = "2626387"
}
INVESTIGATORS["Marie Lambeau"] = {
	cards = { "05006" },
	minicards = { "05006-m" },
	signatures = { "05018", "05019" },
	starterDeck = "2626395"
}
--Dream--
INVESTIGATORS["Tommy Muldoon"] = {
	cards = { "06001" },
	minicards = { "06001-m" },
	signatures = { "06006", "06007" },
	starterDeck = "2626402"
}
INVESTIGATORS["Mandy Thompson"] = {
	cards = { "06002", "06002-t" },
	minicards = { "06002-m" },
	signatures = { "06008", "06008", "06008", "06009" },
	starterDeck = "2626410"
}
INVESTIGATORS["Tony Morgan"] = {
	cards = { "06003" },
	minicards = { "06003-m" },
	signatures = { "06010", "06011", "06011", "06012" },
	starterDeck = "2626446"
}
INVESTIGATORS["Luke Robinson"] = {
	cards = { "06004" },
	minicards = { "06004-m" },
	signatures = { "06013", "06014", "06015" },
	starterDeck = "2626452"
}
INVESTIGATORS["Patrice Hathaway"] = {
	cards = { "06005" },
	minicards = { "06005-m" },
	signatures = { "06016", "06017" },
	starterDeck = "2626461"
}
--Starter--
INVESTIGATORS["Nathaniel Cho"] = {
	cards = { "60101" },
	minicards = { "60101-m" },
	signatures = { "60102", "60103" },
	starterDeck = "2643925"
}
INVESTIGATORS["Harvey Walters"] = {
	cards = { "60201" },
	minicards = { "60201-m" },
	signatures = { "60202", "60203" },
	starterDeck = "2643928"
}
INVESTIGATORS["Winifred Habbamock"] = {
	cards = { "60301" },
	minicards = { "60301-m" },
	signatures = { "60302", "60303" },
	starterDeck = "2643931"
}
INVESTIGATORS["Jacqueline Fine"] = {
	cards = { "60401" },
	minicards = { "60401-m" },
	signatures = { "60402", "60403" },
	starterDeck = "2643932"
}
INVESTIGATORS["Stella Clark"] = {
	cards = { "60501" },
	minicards = { "60501-m" },
	signatures = { "60502", "60502", "60502", "60503" },
	starterDeck = "2643934"
}
--Innsmouth--
INVESTIGATORS["Sister Mary"] = {
	cards = { "07001" },
	minicards = { "07001-m" },
	signatures = { "07006", "07007" },
	starterDeck = "2626464"
}
INVESTIGATORS["Amanda Sharpe"] = {
	cards = { "07002" },
	minicards = { "07002-m" },
	signatures = { "07008", "07009" },
	starterDeck = "2626469"
}
INVESTIGATORS["Trish Scarborough"] = {
	cards = { "07003" },
	minicards = { "07003-m" },
	signatures = { "07010", "07011" },
	starterDeck = "2626479"
}
INVESTIGATORS["Dexter Drake"] = {
	cards = { "07004" },
	minicards = { "07004-m" },
	signatures = { "07012", "07013", "98017", "98018" },
	starterDeck = "2626672"
}
INVESTIGATORS["Silas Marsh"] = {
	cards = { "07005" },
	minicards = { "07005-m" },
	signatures = { "07014", "07015", "07016", "98014", "98015" },
	starterDeck = "2626685"
}
--Edge--
INVESTIGATORS["Daniela Reyes"] = {
	cards = { "08001" },
	minicards = { "08001-m" },
	signatures = {"08002", "08003" },
	starterDeck = "2634588"
}
INVESTIGATORS["Norman Withers"] = {
	cards = { "08004" },
	minicards = { "08004-m" },
	signatures = { "08005", "08006", "98008", "98009" },
	starterDeck = "2634603"
}
INVESTIGATORS["Monterey Jack"] = {
	cards = { "08007" },
	minicards = { "08007-m" },
	signatures = { "08008", "08009" },
	starterDeck = "2634652"
}
INVESTIGATORS["Lily Chen"] = {
	cards = { "08010" },
	minicards = { "08010-m" },
	signatures = { "08011a", "08012a", "08013a", "08014a", "08015", "08015", "08015", "08015" },
	starterDeck = "2634658"
}
INVESTIGATORS["Bob Jenkins"] = {
	cards = { "08016" },
	minicards = { "08016-m" },
	signatures = { "08017", "08018" },
	starterDeck = "2634643"
}
--Scarlet--
INVESTIGATORS["Carson Sinclair"] = {
	cards = { "09001" },
	minicards = { "09001-m" },
	signatures = { "09002", "09003" },
	starterDeck = "2634667	"
}
INVESTIGATORS["Vincent Lee"] = {
	cards = { "09004" },
	minicards = { "09004-m" },
	signatures = { "09005", "09006", "09006", "09006", "09006", "09007" },
	starterDeck = "2634675"
}
INVESTIGATORS["Kymani Jones"] = {
	cards = { "09008" },
	minicards = { "09008-m" },
	signatures = { "09009", "09010" },
	starterDeck = "2634701"
}
INVESTIGATORS["Amina Zidane"] = {
	cards = { "09011" },
	minicards = { "09011-m" },
	signatures = { "09012", "09013", "09014" },
	starterDeck = "2634697"
}
INVESTIGATORS["Darrell Simmons"] = {
	cards = { "09015" },
	minicards = { "09015-m" },
	signatures = { "09016", "09017" },
	starterDeck = "2634757"
}
INVESTIGATORS["Charlie Kane"] = {
	cards = { "09018" },
	minicards = { "09018-m" },
	signatures = { "09019", "09020" },
	starterDeck = "2634706"
}
--Promo--
INVESTIGATORS["Gloria Goldberg"] = {
	cards = { "98019" },
	minicards = { "98019-m" },
	signatures = { "98020", "98021" },
	starterDeck = "2636199"
}
------------------ END INVESTIGATOR DATA DEFINITION ------------------
end)
__bundle_register("playercards/spawnbag/SpawnBag", function(require, _LOADED, __bundle_register, __bundle_modules)
require("playercards/PlayerCardSpawner")

-- Allows spawning of defined lists of cards which will be created from the template in the All
-- Player Cards bag.  SpawnBag.spawn will create objects based on a table definition, while
-- SpawnBag.recall will clean them all up.  Recall will be limited to a small area around the
-- spawned objects.  Objects moved out of this area will not be cleaned up.
--
-- SpawnSpec: Spawning requires a spawn specification with the following structure:
-- {
--   name: Name of this spawn content, used for internal tracking.  Multiple specs can be spawned,
--       but each requires a separate name
--   cards: A list of card IDs to be spawned
--   globalPos: Where the spawned objects should be placed, in global coordinates.  This should be
--       a valid Vector with x, y, and z defined, e.g. { x = 5, y = 1, z = 15 }
--   rotation: Rotation for the spawned objects.  X=180 should be used for face down items.  As with
--       globalPos, this should be a valid Vector with x, y, and z defined
--   spread: Optional Boolean.  If present and true, cards will be spawned next to each other in a
--       spread moving to the right.  globalPos will define the location of the first card, each
--       after that will be moved a predefined distance
--   spreadCols: Optional integer.  If spread is true, specifies the maximum columns cards will be
--       laid out in before starting a new row.  If spread is true but spreadCols is not set, all
--       cards will be in a single row (however long that may be)
-- }
-- See BondedBag.ttslua for an example
do
  local SpawnBag = { }
  local internal = { }

  -- To assist debugging, will draw a box around the recall zone when it's set up
  local SHOW_RECALL_ZONE = false

  local ALL_CARDS_GUID = "15bb07"

  -- Distance to expand the recall zone around any added object.
  local RECALL_BUFFER_X = 0.9
  local RECALL_BUFFER_Z = 0.5

  -- In order to mimic the behavior of the previous memory buttons we use a temporary bag when
  -- recalling objects.  This bag is tiny and transparent, and will be placed at the same location as
  -- this object.  Once all placed cards are recalled bag to this bag, it will be destroyed
  local RECALL_BAG = {
    Name = "Bag",
    Transform = {
      scaleX = 0.01,
      scaleY = 0.01,
      scaleZ = 0.01,
    },
    ColorDiffuse = {
      r = 0,
      g = 0,
      b = 0,
      a = 0,
    },
    Locked = true,
    Grid = true,
    Snap = false,
    Tooltip = false,
  }

  -- Tracks what has been placed by this "bag" so they can be recalled
  local placedSpecs = { }
  local placedObjectGuids = { }
  local recallZone = nil

  -- Loads a table of saved state, extracted during the parent object's onLoad
  SpawnBag.loadFromSave = function(saveTable)
    placedSpecs = saveTable.placed
    placedObjectGuids = saveTable.placedObjects
    recallZone = saveTable.recall
  end

  -- Generates a table of save state that can be included in the parent object's onSave
  SpawnBag.getStateForSave = function()
    return {
      placed = placedSpecs,
      placedObjects = placedObjectGuids,
      recall = recallZone,
    }
  end

  -- Places the given spawnSpec on the table.  See SpawnBag.ttslua header for spawnSpec table data and
  -- examples
  SpawnBag.spawn = function(spawnSpec)
    -- Limit to one placement at a time
    if (placedSpecs[spawnSpec.name]) then
      return
    end
    if (spawnSpec == nil) then
      -- TODO: error here
      return
    end
    local cardsToSpawn = { }
    local allCardsBag = getObjectFromGUID(ALL_CARDS_GUID)
    local cardList = spawnSpec.cards
    for _, cardId in ipairs(cardList) do
      local cardData = allCardsBag.call("getCardById", { id = cardId })
      if (cardData ~= nil) then
        table.insert(cardsToSpawn, cardData)
      else
        -- TODO: error here
      end
    end
    if (spawnSpec.spread) then
      Spawner.spawnCardSpread(cardsToSpawn, spawnSpec.globalPos, spawnSpec.spreadCols or 9999, spawnSpec.rotation, false, internal.recordPlacedObject)
    else
      -- TTS decks come out in reverse order of the cards, reverse the list so the input order stays
      -- This only applies for decks; spreads are spawned by us in the order given
      if spawnSpec.rotation.z != 180 then
        cardsToSpawn = internal.reverseList(cardsToSpawn)
      end
      Spawner.spawnCards(cardsToSpawn, spawnSpec.globalPos, spawnSpec.rotation, false, internal.recordPlacedObject)
    end
    placedSpecs[spawnSpec.name] = true
  end

  -- Recalls all spawned objects to the bag, and clears the placedObjectGuids list
  ---@param fast Boolean.  If true, cards will be deleted directly without faking the bag recall.
  SpawnBag.recall = function(fast)
    if fast then
      internal.deleteSpawned()
    else
      internal.recallSpawned()
    end

    -- We've recalled everything we can, some cards may have been moved out of the
    -- card area.  Just reset at this point.
    placedSpecs = { }
    placedObjectGuids = { }
    recallZone = nil
  end

  -- Deleted all spawned cards.
  internal.deleteSpawned = function()
    for guid, _ in pairs(placedObjectGuids) do
      local obj = getObjectFromGUID(guid)
      if (obj ~= nil) then
        if (internal.isInRecallZone(obj)) then
          obj.destruct()
        end
        placedObjectGuids[guid] = nil
      end
    end
  end

  -- Recalls spawned cards with a fake bag that replicates the memory bag recall style.
  internal.recallSpawned = function()
    local trash = spawnObjectData({data = RECALL_BAG, position = self.getPosition()})
    for guid, _ in pairs(placedObjectGuids) do
      local obj = getObjectFromGUID(guid)
      if (obj ~= nil) then
        if (internal.isInRecallZone(obj)) then
          trash.putObject(obj)
        end
        placedObjectGuids[guid] = nil
      end
    end

    trash.destruct()
  end


  -- Callback for when an object has been spawned.  Tracks the object for later recall and updates the
  -- recall zone.
  internal.recordPlacedObject = function(spawned)
    placedObjectGuids[spawned.getGUID()] = true
    internal.expandRecallZone(spawned)
  end

  -- Expands the current recall zone based on the position of the given object.  The recall zone will
  -- be maintained as the bounding box of the extreme object positions, plus a small amount of buffer
  internal.expandRecallZone = function(spawnedCard)
    local pos = spawnedCard.getPosition()
    if (recallZone == nil) then
      -- First card out of the bag, initialize surrounding that
      recallZone = { }
      recallZone.upperLeft = { x = pos.x + RECALL_BUFFER_X, z = pos.z + RECALL_BUFFER_Z }
      recallZone.lowerRight = { x = pos.x - RECALL_BUFFER_X, z = pos.z - RECALL_BUFFER_Z }
      return
    else
      if (pos.x > recallZone.upperLeft.x) then
        recallZone.upperLeft.x = pos.x + RECALL_BUFFER_X
      end
      if (pos.x < recallZone.lowerRight.x) then
        recallZone.lowerRight.x = pos.x - RECALL_BUFFER_X
      end
      if (pos.z > recallZone.upperLeft.z) then
        recallZone.upperLeft.z = pos.z + RECALL_BUFFER_Z
      end
      if (pos.z < recallZone.lowerRight.z) then
        recallZone.lowerRight.z = pos.z - RECALL_BUFFER_Z
      end
    end
    if (SHOW_RECALL_ZONE) then
      local y = 1.5
      local thick = 0.05
      Global.setVectorLines({
        {
            points    = { {recallZone.upperLeft.x,y,recallZone.upperLeft.z}, {recallZone.upperLeft.x,y,recallZone.lowerRight.z} },
            color     = {1,0,0},
            thickness = thick,
            rotation  = {0,0,0},
        },
        {
            points    = { {recallZone.upperLeft.x,y,recallZone.lowerRight.z}, {recallZone.lowerRight.x,y,recallZone.lowerRight.z} },
            color     = {1,0,0},
            thickness = thick,
            rotation  = {0,0,0},
        },
        {
            points    = { {recallZone.lowerRight.x,y,recallZone.lowerRight.z}, {recallZone.lowerRight.x,y,recallZone.upperLeft.z} },
            color     = {1,0,0},
            thickness = thick,
            rotation  = {0,0,0},
        },
        {
            points    = { {recallZone.lowerRight.x,y,recallZone.upperLeft.z}, {recallZone.upperLeft.x,y,recallZone.upperLeft.z} },
            color     = {1,0,0},
            thickness = thick,
            rotation  = {0,0,0},
        },
        })
    end
  end

  -- Checks to see if the given object is in the current recall zone.  If there isn't a recall zone,
  -- will return true so that everything can be easily cleaned up.
  internal.isInRecallZone = function(obj)
    if (recallZone == nil) then
      return true
    end
    local pos = obj.getPosition()
    return (pos.x < recallZone.upperLeft.x and pos.x > recallZone.lowerRight.x
        and pos.z < recallZone.upperLeft.z and pos.z > recallZone.lowerRight.z)
  end

  internal.reverseList = function(list)
    local reversed = { }
    for i = 1, #list do
      reversed[i] = list[#list - i + 1]
    end

    return reversed
  end

  return SpawnBag
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("playercards/PlayerCardSpawner", function(require, _LOADED, __bundle_register, __bundle_modules)

-- Amount to shift for the next card (zShift) or next row of cards (xShift)
-- Note that the table rotation is weird, and the X axis is vertical while the
-- Z axis is horizontal
local SPREAD_Z_SHIFT = -2.3
local SPREAD_X_SHIFT = -3.66

Spawner = { }

-- Spawns a list of cards at the given position/rotation.  This will separate cards by size -
-- investigator, standard, and mini, spawning them in that order with larger cards on bottom.  If
-- there are different types, the provided callback will be called once for each type as it spawns
-- either a card or deck.
-- @param cardList: A list of Player Card data structures (data/metadata)
-- @param pos Position table where the cards should be spawned (global)
-- @param rot Rotation table for the orientation of the spawned cards (global)
-- @param sort Boolean, true if this list of cards should be sorted before spawning
-- @param callback Function, callback to be called after the card/deck spawns.
Spawner.spawnCards = function(cardList, pos, rot, sort, callback)
  if (sort) then
    table.sort(cardList, Spawner.cardComparator)
  end

  local miniCards = { }
  local standardCards = { }
  local investigatorCards = { }

  for _, card in ipairs(cardList) do
    if (card.metadata.type == "Investigator") then
      table.insert(investigatorCards, card)
    elseif (card.metadata.type == "Minicard") then
      table.insert(miniCards, card)
    else
      table.insert(standardCards, card)
    end
  end
  -- Spawn each of the three types individually.  Each Y position shift accounts for the thickness
  -- of the spawned deck
  local position = { x = pos.x, y = pos.y, z = pos.z }
  Spawner.spawn(investigatorCards, position, { rot.x, rot.y - 90, rot.z}, callback)

  position.y = position.y + (#investigatorCards + #standardCards) * 0.07
  Spawner.spawn(standardCards, position, rot, callback)

  position.y = position.y + (#standardCards + #miniCards) * 0.07
  Spawner.spawn(miniCards, position, rot, callback)
end

Spawner.spawnCardSpread = function(cardList, startPos, maxCols, rot, sort, callback)
  if (sort) then
    table.sort(cardList, Spawner.cardComparator)
  end

  local position = { x = startPos.x, y = startPos.y, z = startPos.z }
  -- Special handle the first row if we have less than a full single row, but only if there's a
  -- reasonable max column count.  Single-row spreads will send a large value for maxCols
  if maxCols < 100 and #cardList < maxCols then
    position.z = startPos.z + ((maxCols - #cardList) / 2 * SPREAD_Z_SHIFT)
  end
  local cardsInRow = 0
  local rows = 0
  for _, card in ipairs(cardList) do
    Spawner.spawn({ card }, position, rot, callback)
    position.z = position.z + SPREAD_Z_SHIFT
    cardsInRow = cardsInRow + 1
    if cardsInRow >= maxCols then
      rows = rows + 1
      local cardsForRow = #cardList - rows * maxCols
      if cardsForRow > maxCols then
        cardsForRow = maxCols
      end
      position.z = startPos.z + ((maxCols - cardsForRow) / 2 * SPREAD_Z_SHIFT)
      position.x = position.x + SPREAD_X_SHIFT
      cardsInRow = 0
    end
  end
end

-- Spawn a specific list of cards.  This method is for internal use and should not be called
-- directly, use spawnCards instead.
-- @param cardList: A list of Player Card data structures (data/metadata)
-- @param pos Position table where the cards should be spawned (global)
-- @param rot Rotation table for the orientation of the spawned cards (global)
-- @param callback Function, callback to be called after the card/deck spawns.
Spawner.spawn = function(cardList, pos, rot, callback)
  if (#cardList == 0) then
    return
  end
  -- Spawn a single card directly
  if (#cardList == 1) then
    spawnObjectData({
      data = cardList[1].data,
      position = pos,
      rotation = rot,
      callback_function = callback,
    })
    return
  end
  -- For multiple cards, construct a deck and spawn that
  local deck = Spawner.buildDeckDataTemplate()
  -- Decks won't inherently scale to the cards in them.  The card list being spawned should be all
  -- the same type/size by this point, so use the first card to set the size
  deck.Transform = {
    scaleX = cardList[1].data.Transform.scaleX,
    scaleY = 1,
    scaleZ = cardList[1].data.Transform.scaleZ,
  }
  for _, spawnCard in ipairs(cardList) do
    Spawner.addCardToDeck(deck, spawnCard.data)
  end
  spawnObjectData({
    data = deck,
    position = pos,
    rotation = rot,
    callback_function = callback,
  })
end

-- Inserts a card into the given deck.  This does three things:
--   1. Add the card's data to ContainedObjects
--   2. Add the card's ID (the TTS CardID, not the Arkham ID) to the deck's
--      ID list.  Note that the deck's ID list is "DeckIDs" even though it
--      contains a list of card Ids
--   3. Extract the card's CustomDeck table and add it to the deck.  The deck's
--      "CustomDeck" field is a list of all CustomDecks used by cards within the
--      deck, keyed by the DeckID and referencing the custom deck table
---@param deck: TTS deck data structure to add to
---@param card: Data for the card to be inserted
Spawner.addCardToDeck = function(deck, cardData)
  for customDeckId, customDeckData in pairs(cardData.CustomDeck) do
    if (deck.CustomDeck[customDeckId] == nil) then
      -- CustomDeck not added to deck yet, add it
      deck.CustomDeck[customDeckId] = customDeckData
    elseif (deck.CustomDeck[customDeckId].FaceURL == customDeckData.FaceURL) then
      -- CustomDeck for this card matches the current one for the deck, do nothing
    else
      -- CustomDeck data conflict
      local newDeckId = nil
      for deckId, customDeck in pairs(deck.CustomDeck) do
        if (customDeckData.FaceURL == customDeck.FaceURL) then
          newDeckId = deckId
        end
      end
      if (newDeckId == nil) then
        -- No non-conflicting custom deck for this card, add a new one
        newDeckId = Spawner.findNextAvailableId(deck.CustomDeck, "1000")
        deck.CustomDeck[newDeckId] = customDeckData
      end
      -- Update the card with the new CustomDeck info
      cardData.CardID = newDeckId..string.sub(cardData.CardID, 5)
      cardData.CustomDeck[customDeckId] = nil
      cardData.CustomDeck[newDeckId] = customDeckData
      break
    end
  end
  table.insert(deck.ContainedObjects, cardData)
  table.insert(deck.DeckIDs, cardData.CardID)
end

-- Create an empty deck data table which can have cards added to it. This
-- creates a new table on each call without using metatables or previous
-- definitions because we can't be sure that TTS doesn't modify the structure
---@return: Table containing the minimal TTS deck data structure
Spawner.buildDeckDataTemplate = function()
  local deck = {}
  deck.Name = "Deck"

  -- Card data.  DeckIDs and CustomDeck entries will be built from the cards
  deck.ContainedObjects = {}
  deck.DeckIDs = {}
  deck.CustomDeck = {}

  -- Transform is required, Position and Rotation will be overridden by the spawn call so can be omitted here
  deck.Transform = {
    scaleX = 1,
    scaleY = 1,
    scaleZ = 1,
  }

  return deck
end

-- Returns the first ID which does not exist in the given table, starting at startId and increasing
-- @param objectTable Table keyed by strings which are numbers
-- @param startId First possible ID.
-- @return String ID >= startId
Spawner.findNextAvailableId = function(objectTable, startId)
  local id = startId
  while (objectTable[id] ~= nil) do
    id = tostring(tonumber(id) + 1)
  end

  return id
end

-- Get the PBCN (Permanent/Bonded/Customizable/Normal) value from the given metadata.
---@return: 1 for Permanent, 2 for Bonded or 4 for Normal. The actual values are
--      irrelevant as they provide only grouping and the order between them doesn't matter.
Spawner.getpbcn = function(metadata)
  if metadata.permanent then
    return 1
  elseif metadata.bonded_to ~= nil then
    return 2
  else -- Normal card
    return 3
  end
end

-- Comparison function used to sort the cards in a deck.  Groups bonded or
-- permanent cards first, then sorts within theose types by name/subname.
-- Normal cards will sort in standard alphabetical order, while
-- permanent/bonded/customizable will be in reverse alphabetical order.
--
-- Since cards spawn in the order provided by this comparator, with the first
-- cards ending up at the bottom of a pile, this ordering will spawn in reverse
-- alphabetical order.  This presents the cards in order for non-face-down
-- areas, and presents them in order when Searching the face-down deck.
Spawner.cardComparator = function(card1, card2)
  local pbcn1 = Spawner.getpbcn(card1.metadata)
  local pbcn2 = Spawner.getpbcn(card2.metadata)
  if pbcn1 ~= pbcn2 then
    return pbcn1 > pbcn2
  end
  if pbcn1 == 3 then
    if card1.data.Nickname ~= card2.data.Nickname then
      return card1.data.Nickname < card2.data.Nickname
    end
    return card1.data.Description < card2.data.Description
  else
    if card1.data.Nickname ~= card2.data.Nickname then
      return card1.data.Nickname > card2.data.Nickname
    end
    return card1.data.Description > card2.data.Description
  end
end
end)
__bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules)
require("playercards/PlayerCardPanel")
end)
return __bundle_require("__root")