ah_sce_unpacked/unpacked/Custom_Tile ArkhamDB Deck Importer a28140.ttslua
Adam Goldsmith 7e1ed4f41a
All checks were successful
Build and Release / Build and Release (push) Successful in 5m16s
Merge branch 'master' into patches
2023-09-02 23:00:05 -04:00

1910 lines
76 KiB
Plaintext

-- 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("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
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)
local loadAltInvestigator = "normal"
if loadInvestigators then
loadAltInvestigator = internal.addInvestigatorCards(deck, slots)
end
internal.maybeAddCustomizeUpgradeSheets(slots)
internal.maybeAddSummonedServitor(slots)
internal.maybeAddOnTheMend(slots, playerColor)
internal.maybeAddRealityAcidReference(slots)
local bondList = internal.extractBondedCards(slots)
internal.checkTaboos(deck.taboo_id, slots, playerColor)
-- get upgrades for customizable cards
local customizations = {}
if deck.meta then
customizations = JSON.decode(deck.meta)
end
callback(slots, deck.investigator_code, bondList, customizations, playerColor, loadAltInvestigator)
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 randomWeaknessAmount = slots[RANDOM_WEAKNESS_ID] or 0
slots[RANDOM_WEAKNESS_ID] = nil
if randomWeaknessAmount ~= 0 then
for i=1, randomWeaknessAmount do
local weaknessId = allCardsBag.call("getRandomWeaknessId")
slots[weaknessId] = (slots[weaknessId] or 0) + 1
end
internal.maybePrint("Added " .. randomWeaknessAmount .. " random basic weakness(es) 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
---@return string: Contains the name of the art that should be loaded ("normal", "promo" or "revised")
internal.addInvestigatorCards = function(deck, slots)
local investigatorId = deck.investigator_code
slots[investigatorId .. "-m"] = 1
local deckMeta = JSON.decode(deck.meta)
-- handling alternative investigator art and parallel investigators
local loadAltInvestigator = "normal"
if deckMeta ~= nil then
local altFrontId = tonumber(deckMeta.alternate_front) or 0
local altBackId = tonumber(deckMeta.alternate_back) or 0
local altArt = { front = "normal", back = "normal" }
-- translating front ID
if altFrontId > 90000 and altFrontId < 90047 then
altArt.front = "parallel"
elseif altFrontId > 01500 and altFrontId < 01506 then
altArt.front = "revised"
elseif altFrontId > 98000 then
altArt.front = "promo"
end
-- translating back ID
if altBackId > 90000 and altBackId < 90047 then
altArt.back = "parallel"
elseif altBackId > 01500 and altBackId < 01506 then
altArt.back = "revised"
elseif altBackId > 98000 then
altArt.back = "promo"
end
-- updating investigatorID based on alt investigator selection
-- precedence: parallel > promo > revised
if altArt.front == "parallel" then
if altArt.back == "parallel" then
investigatorId = investigatorId .. "-p"
else
investigatorId = investigatorId .. "-pf"
end
elseif altArt.back == "parallel" then
investigatorId = investigatorId .. "-pb"
elseif altArt.front == "promo" or altArt.back == "promo" then
loadAltInvestigator = "promo"
elseif altArt.front == "revised" or altArt.back == "revised" then
loadAltInvestigator = "revised"
end
end
slots[investigatorId] = 1
deck.investigator_code = investigatorId
return loadAltInvestigator
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 card list looking for Reality Acid and adds the reference sheet when 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.maybeAddRealityAcidReference = function(slots)
if slots["89004"] ~= nil then
slots["89005"] = 1
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("arkhamdb/DeckImporterUi", function(require, _LOADED, __bundle_register, __bundle_modules)
local INPUT_FIELD_HEIGHT = 340
local INPUT_FIELD_WIDTH = 1500
local FIELD_COLOR = { 0.9, 0.7, 0.5 }
local PRIVATE_TOGGLE_LABELS = {}
PRIVATE_TOGGLE_LABELS[true] = "Private"
PRIVATE_TOGGLE_LABELS[false] = "Published"
local UPGRADED_TOGGLE_LABELS = {}
UPGRADED_TOGGLE_LABELS[true] = "Upgraded"
UPGRADED_TOGGLE_LABELS[false] = "Specific"
local LOAD_INVESTIGATOR_TOGGLE_LABELS = {}
LOAD_INVESTIGATOR_TOGGLE_LABELS[true] = "Yes"
LOAD_INVESTIGATOR_TOGGLE_LABELS[false] = "No"
local redDeckId = ""
local orangeDeckId = ""
local whiteDeckId = ""
local greenDeckId = ""
local privateDeck = true
local loadNewestDeck = true
local loadInvestigators = false
-- Returns a table with the full state of the UI, including options and deck IDs.
-- This can be used to persist via onSave(), or provide values for a load operation
-- Table values:
-- redDeck: Deck ID to load for the red player
-- orangeDeck: Deck ID to load for the orange player
-- whiteDeck: Deck ID to load for the white player
-- greenDeck: Deck ID to load for the green player
-- private: True to load a private deck, false to load a public deck
-- loadNewest: True if the most upgraded version of the deck should be loaded
-- investigators: True if investigator cards should be spawned
function getUiState()
return {
redDeck = redDeckId,
orangeDeck = orangeDeckId,
whiteDeck = whiteDeckId,
greenDeck = greenDeckId,
private = privateDeck,
loadNewest = loadNewestDeck,
investigators = loadInvestigators
}
end
-- Updates the state of the UI based on the provided table. Any values not provided will be left the same.
-- @param uiStateTable Table of values to update on importer
-- Table values:
-- redDeck: Deck ID to load for the red player
-- orangeDeck: Deck ID to load for the orange player
-- whiteDeck: Deck ID to load for the white player
-- greenDeck: Deck ID to load for the green player
-- private: True to load a private deck, false to load a public deck
-- loadNewest: True if the most upgraded version of the deck should be loaded
-- investigators: True if investigator cards should be spawned
function setUiState(uiStateTable)
-- Callback functions aren't triggered when editing buttons/inputs so values must be set manually
if uiStateTable["greenDeck"] then
greenDeckId = uiStateTable["greenDeck"]
self.editInput({index=0, value=greenDeckId})
end
if uiStateTable["redDeck"] then
redDeckId = uiStateTable["redDeck"]
self.editInput({index=1, value=redDeckId})
end
if uiStateTable["whiteDeck"] then
whiteDeckId = uiStateTable["whiteDeck"]
self.editInput({index=2, value=whiteDeckId})
end
if uiStateTable["orangeDeck"]then
orangeDeckId = uiStateTable["orangeDeck"]
self.editInput({index=3, value=orangeDeckId})
end
if uiStateTable["private"] then
privateDeck = uiStateTable["private"]
self.editButton { index = 0, label = PRIVATE_TOGGLE_LABELS[privateDeck] }
end
if uiStateTable["loadNewest"] then
loadNewestDeck = uiStateTable["loadNewest"]
self.editButton { index = 1, label = UPGRADED_TOGGLE_LABELS[loadNewestDeck] }
end
if uiStateTable["investigators"] then
loadInvestigators = uiStateTable["investigators"]
self.editButton { index = 2, label = LOAD_INVESTIGATOR_TOGGLE_LABELS[loadInvestigators] }
end
end
-- Sets up the UI for the deck loader, populating fields from the given save state table decoded from onLoad()
function initializeUi(savedUiState)
if savedUiState ~= nil then
redDeckId = savedUiState.redDeck
orangeDeckId = savedUiState.orangeDeck
whiteDeckId = savedUiState.whiteDeck
greenDeckId = savedUiState.greenDeck
privateDeck = savedUiState.private
loadNewestDeck = savedUiState.loadNewest
loadInvestigators = savedUiState.investigators
end
makeOptionToggles()
makeDeckIdFields()
makeBuildButton()
end
function makeOptionToggles()
-- common parameters
local checkbox_parameters = {}
checkbox_parameters.function_owner = self
checkbox_parameters.width = INPUT_FIELD_WIDTH
checkbox_parameters.height = INPUT_FIELD_HEIGHT
checkbox_parameters.scale = { 0.1, 0.1, 0.1 }
checkbox_parameters.font_size = 240
checkbox_parameters.hover_color = { 0.4, 0.6, 0.8 }
checkbox_parameters.color = FIELD_COLOR
-- public / private deck
checkbox_parameters.click_function = "publicPrivateChanged"
checkbox_parameters.position = { 0.25, 0.1, -0.102 }
checkbox_parameters.tooltip = "Published or private deck?\n\nPLEASE USE A PRIVATE DECK IF JUST FOR TTS TO AVOID FLOODING ARKHAMDB PUBLISHED DECK LISTS!"
checkbox_parameters.label = PRIVATE_TOGGLE_LABELS[privateDeck]
self.createButton(checkbox_parameters)
-- load upgraded?
checkbox_parameters.click_function = "loadUpgradedChanged"
checkbox_parameters.position = { 0.25, 0.1, -0.01 }
checkbox_parameters.tooltip = "Load newest upgrade or exact deck?"
checkbox_parameters.label = UPGRADED_TOGGLE_LABELS[loadNewestDeck]
self.createButton(checkbox_parameters)
-- load investigators?
checkbox_parameters.click_function = "loadInvestigatorsChanged"
checkbox_parameters.position = { 0.25, 0.1, 0.081 }
checkbox_parameters.tooltip = "Spawn investigator cards?"
checkbox_parameters.label = LOAD_INVESTIGATOR_TOGGLE_LABELS[loadInvestigators]
self.createButton(checkbox_parameters)
end
-- Create the four deck ID entry fields
function makeDeckIdFields()
local input_parameters = {}
-- Parameters common to all entry fields
input_parameters.function_owner = self
input_parameters.scale = { 0.1, 0.1, 0.1 }
input_parameters.width = INPUT_FIELD_WIDTH
input_parameters.height = INPUT_FIELD_HEIGHT
input_parameters.font_size = 320
input_parameters.tooltip = "Deck ID from ArkhamDB URL of the deck\nPublic URL: 'https://arkhamdb.com/decklist/view/101/knowledge-overwhelming-solo-deck-1.0' = '101'\nPrivate URL: 'https://arkhamdb.com/deck/view/102' = '102'"
input_parameters.alignment = 3 -- Center
input_parameters.color = FIELD_COLOR
input_parameters.font_color = { 0, 0, 0 }
input_parameters.validation = 2 -- Integer
-- Green
input_parameters.input_function = "greenDeckChanged"
input_parameters.position = { -0.166, 0.1, 0.385 }
input_parameters.value = greenDeckId
self.createInput(input_parameters)
-- Red
input_parameters.input_function = "redDeckChanged"
input_parameters.position = { 0.171, 0.1, 0.385 }
input_parameters.value = redDeckId
self.createInput(input_parameters)
-- White
input_parameters.input_function = "whiteDeckChanged"
input_parameters.position = { -0.166, 0.1, 0.474 }
input_parameters.value = whiteDeckId
self.createInput(input_parameters)
-- Orange
input_parameters.input_function = "orangeDeckChanged"
input_parameters.position = { 0.171, 0.1, 0.474 }
input_parameters.value = orangeDeckId
self.createInput(input_parameters)
end
-- Create the Build All button. This is a transparent button which covers the Build All portion of the background graphic
function makeBuildButton()
local button_parameters = {}
button_parameters.click_function = "loadDecks"
button_parameters.function_owner = self
button_parameters.position = { 0, 0.1, 0.71 }
button_parameters.width = 320
button_parameters.height = 30
button_parameters.color = { 0, 0, 0, 0 }
button_parameters.tooltip = "Click to build all four decks!"
self.createButton(button_parameters)
end
-- Event handlers for deck ID change
function redDeckChanged(_, _, inputValue) redDeckId = inputValue end
function orangeDeckChanged(_, _, inputValue) orangeDeckId = inputValue end
function whiteDeckChanged(_, _, inputValue) whiteDeckId = inputValue end
function greenDeckChanged(_, _, inputValue) greenDeckId = inputValue end
-- Event handlers for toggle buttons
function publicPrivateChanged()
privateDeck = not privateDeck
self.editButton { index = 0, label = PRIVATE_TOGGLE_LABELS[privateDeck] }
end
function loadUpgradedChanged()
loadNewestDeck = not loadNewestDeck
self.editButton { index = 1, label = UPGRADED_TOGGLE_LABELS[loadNewestDeck] }
end
function loadInvestigatorsChanged()
loadInvestigators = not loadInvestigators
self.editButton { index = 2, label = LOAD_INVESTIGATOR_TOGGLE_LABELS[loadInvestigators] }
end
function loadDecks()
-- testLoadLotsOfDecks()
-- Method in DeckImporterMain, visible due to inclusion
-- TODO: Make this use the configuration ID for the all cards bag
local allCardsBag = getObjectFromGUID("15bb07")
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
if (redDeckId ~= nil and redDeckId ~= "") then
buildDeck("Red", redDeckId)
end
if (orangeDeckId ~= nil and orangeDeckId ~= "") then
buildDeck("Orange", orangeDeckId)
end
if (whiteDeckId ~= nil and whiteDeckId ~= "") then
buildDeck("White", whiteDeckId)
end
if (greenDeckId ~= nil and greenDeckId ~= "") then
buildDeck("Green", greenDeckId)
end
end
end)
__bundle_register("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules)
do
local PlayAreaApi = { }
local PLAY_AREA_GUID = "721ba2"
local IMAGE_SWAPPER = "b7b45b"
-- 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
-- Updates the current value of the investigator counter from the playmat
---@param count Number of investigators to set on the counter
PlayAreaApi.setInvestigatorCount = function(count)
return getObjectFromGUID(PLAY_AREA_GUID).call("setInvestigatorCount", count)
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
-- counts the VP on locations in the play area
PlayAreaApi.countVP = function()
return getObjectFromGUID(PLAY_AREA_GUID).call("countVP")
end
-- highlights all locations in the play area without metadata
---@param state Boolean True if highlighting should be enabled
PlayAreaApi.highlightMissingData = function(state)
return getObjectFromGUID(PLAY_AREA_GUID).call("highlightMissingData", state)
end
-- highlights all locations in the play area with VP
---@param state Boolean True if highlighting should be enabled
PlayAreaApi.highlightCountedVP = function(state)
return getObjectFromGUID(PLAY_AREA_GUID).call("highlightCountedVP", state)
end
-- Checks if an object is in the play area (returns true or false)
PlayAreaApi.isInPlayArea = function(object)
return getObjectFromGUID(PLAY_AREA_GUID).call("isInPlayArea", object)
end
PlayAreaApi.getSurface = function()
return getObjectFromGUID(PLAY_AREA_GUID).getCustomObject().image
end
PlayAreaApi.updateSurface = function(url)
return getObjectFromGUID(IMAGE_SWAPPER).call("updateSurface", url)
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 table Position where the cards should be spawned (global)
---@param rot table Rotation 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,
}
local sidewaysDeck = true
for _, spawnCard in ipairs(cardList) do
Spawner.addCardToDeck(deck, spawnCard.data)
-- set sidewaysDeck to false if any card is not a sideways card
sidewaysDeck = (sidewaysDeck and spawnCard.data.SidewaysCard)
end
-- set the alt view angle for sideway decks
if sidewaysDeck then
deck.AltLookAngle = { x = 0, y = 180, z = 90 }
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("playermat/PlaymatApi", function(require, _LOADED, __bundle_register, __bundle_modules)
do
local PlaymatApi = { }
local internal = { }
local MAT_IDS = {
White = "8b081b",
Orange = "bd0ff4",
Green = "383d8b",
Red = "0840d5"
}
local CLUE_COUNTER_GUIDS = {
White = "37be78",
Orange = "1769ed",
Green = "032300",
Red = "d86b7c"
}
local CLUE_CLICKER_GUIDS = {
White = "db85d6",
Orange = "3f22e5",
Green = "891403",
Red = "4111de"
}
-- Returns the color of the by position requested playermat as string
---@param startPos Table Position of the search, table get's roughly cut into 4 quarters to assign a playermat
PlaymatApi.getMatColorByPosition = function(startPos)
if startPos.x < -42 then
if startPos.z > 0 then
return "White"
else
return "Orange"
end
else
if startPos.z > 0 then
return "Green"
else
return "Red"
end
end
end
-- Returns the color of the player's hand that is seated next to the playermat
---@param matColor String Color of the playermat
PlaymatApi.getPlayerColor = function(matColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
return mat.getVar("playerColor")
end
-- Returns the color of the playermat that owns the playercolor's hand
---@param handColor String Color of the playermat
PlaymatApi.getMatColor = function(handColor)
local matColors = {"White", "Orange", "Green", "Red"}
for i, mat in ipairs(internal.getMatForColor("All")) do
local color = mat.getVar("playerColor")
if color == handColor then return matColors[i] end
end
return "NOT_FOUND"
end
-- Returns the result of a cast in the specificed playermat's area
---@param matColor String Color of the playermat
PlaymatApi.searchPlaymat = function(matColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
return mat.call("searchAroundSelf")
end
-- Returns if there is the card "Dream-Enhancing Serum" on the requested playermat
---@param matColor String Color of the playermat
PlaymatApi.isDES = function(matColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
return mat.getVar("isDES")
end
-- Returns the draw deck of the requested playmat
---@param matColor String Color of the playermat
PlaymatApi.getDrawDeck = function(matColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
mat.call("getDrawDiscardDecks")
return mat.getVar("drawDeck")
end
-- Returns the position of the discard pile of the requested playmat
---@param matColor String Color of the playermat
PlaymatApi.getDiscardPosition = function(matColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
return mat.call("returnGlobalDiscardPosition")
end
-- Transforms a local position into a global position
---@param localPos Table Local position to be transformed
---@param matColor String Color of the playermat
PlaymatApi.transformLocalPosition = function(localPos, matColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
return mat.positionToWorld(localPos)
end
-- Returns the rotation of the requested playmat
---@param matColor String Color of the playermat
PlaymatApi.returnRotation = function(matColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
return mat.getRotation()
end
-- Triggers the Upkeep for the requested playmat
---@param matColor String Color of the playermat
---@param playerColor String Color of the calling player (for messages)
PlaymatApi.doUpkeepFromHotkey = function(matColor, playerColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
return mat.call("doUpkeepFromHotkey", playerColor)
end
-- Returns the active investigator id
---@param matColor String Color of the playermat
PlaymatApi.returnInvestigatorId = function(matColor)
local mat = getObjectFromGUID(MAT_IDS[matColor])
return mat.getVar("activeInvestigatorId")
end
-- Sets the requested playermat's snap points to limit snapping to matching card types or not. If
-- matchTypes is true, the main card slot snap points will only snap assets, while the
-- investigator area point will only snap Investigators. If matchTypes is false, snap points will
-- be reset to snap all cards.
---@param matchCardTypes Boolean. Whether snap points should only snap for the matching card
-- types.
---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also
-- accepts "All" as a special value which will apply the setting to all four mats.
PlaymatApi.setLimitSnapsByType = function(matchCardTypes, matColor)
for _, mat in ipairs(internal.getMatForColor(matColor)) do
mat.call("setLimitSnapsByType", matchCardTypes)
end
end
-- Sets the requested playermat's draw 1 button to visible
---@param isDrawButtonVisible Boolean. Whether the draw 1 button should be visible or not
---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also
-- accepts "All" as a special value which will apply the setting to all four mats.
PlaymatApi.showDrawButton = function(isDrawButtonVisible, matColor)
for _, mat in ipairs(internal.getMatForColor(matColor)) do
mat.call("showDrawButton", isDrawButtonVisible)
end
end
-- Shows or hides the clickable clue counter for the requested playermat
---@param showCounter Boolean. Whether the clickable counter should be present or not
---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also
-- accepts "All" as a special value which will apply the setting to all four mats.
PlaymatApi.clickableClues = function(showCounter, matColor)
for _, mat in ipairs(internal.getMatForColor(matColor)) do
mat.call("clickableClues", showCounter)
end
end
-- Removes all clues (to the trash for tokens and counters set to 0) for the requested playermat
---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also
-- accepts "All" as a special value which will apply the setting to all four mats.
PlaymatApi.removeClues = function(matColor)
for _, mat in ipairs(internal.getMatForColor(matColor)) do
mat.call("removeClues")
end
end
-- Reports the clue count for the requested playermat
---@param useClickableCounters Boolean Controls which type of counter is getting checked
PlaymatApi.getClueCount = function(useClickableCounters, matColor)
local count = 0
for _, mat in ipairs(internal.getMatForColor(matColor)) do
count = count + tonumber(mat.call("getClueCount", useClickableCounters))
end
return count
end
-- Adds the specified amount of resources to the requested playermat's resource counter
PlaymatApi.gainResources = function(amount, matColor)
for _, mat in ipairs(internal.getMatForColor(matColor)) do
mat.call("gainResources", amount)
end
end
-- Discard a non-hidden card from the corresponding player's hand
PlaymatApi.doDiscardOne = function(matColor)
for _, mat in ipairs(internal.getMatForColor(matColor)) do
mat.call("doDiscardOne")
end
end
PlaymatApi.syncAllCustomizableCards = function()
for _, mat in ipairs(internal.getMatForColor("All")) do
mat.call("syncAllCustomizableCards")
end
end
-- Convenience function to look up a mat's object by color, or get all mats.
---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also
-- accepts "All" as a special value which will return all four mats.
---@return: Array of playermat objects. If a single mat is requested, will return a single-element
-- array to simplify processing by consumers.
internal.getMatForColor = function(matColor)
local targetMatGuid = MAT_IDS[matColor]
if targetMatGuid != nil then
return { getObjectFromGUID(targetMatGuid) }
end
if matColor == "All" then
return {
getObjectFromGUID(MAT_IDS.White),
getObjectFromGUID(MAT_IDS.Orange),
getObjectFromGUID(MAT_IDS.Green),
getObjectFromGUID(MAT_IDS.Red),
}
end
end
return PlaymatApi
end
end)
__bundle_register("playermat/Zones", function(require, _LOADED, __bundle_register, __bundle_modules)
-- Sets up and returns coordinates for all possible spawn zones. Because Lua assigns tables by reference
-- and there is no built-in function to copy a table this is relatively brute force.
--
-- Positions are all relative to the player mat, and most are consistent. The
-- exception are the SetAside# zones, which are placed to the left of the mat
-- for White/Green, and the right of the mat for Orange/Red.
--
-- Investigator: Investigator card area.
-- Minicard: Placement for the investigator's minicard, just above the player mat
-- Deck, Discard: Standard locations for the deck and discard piles.
-- BlankTop: used for assets that start in play (e.g. Duke)
-- Tarot, Hand1, Hand2, Ally, BlankBottom, Accessory, Arcane1, Arcane2, Body: Asset slot positions
-- Threat[1-4]: Threat area slots. Threat[1-3] correspond to the named threat area slots, and Threat4 is the blank threat area slot.
-- SetAside[1-3]: Column closest to the player mat, with 1 at the top and 3 at the bottom.
-- SetAside[4-6]: Column farther away from the mat, with 4 at the top and 6 at the bottom.
-- SetAside1: Permanent cards
-- SetAside2: Bonded cards
-- SetAside3: Ancestral Knowledge / Underworld Market
-- SetAside4: Upgrade sheets for customizable cards
-- SetAside5: Hunch Deck for Joe Diamond
-- SetAside6: currently unused
do
local Zones = { }
local playerMatGuids = {}
playerMatGuids["Red"] = "0840d5"
playerMatGuids["Orange"] = "bd0ff4"
playerMatGuids["White"] = "8b081b"
playerMatGuids["Green"] = "383d8b"
local commonZones = {}
commonZones["Investigator"] = { -1.17702, 0, 0.00209 }
commonZones["Deck"] = { -1.822724, 0, -0.02940192 }
commonZones["Discard"] = { -1.822451, 0, 0.6092291 }
commonZones["Ally"] = { -0.6157398, 0, 0.02435675 }
commonZones["Body"] = { -0.6306521, 0, 0.553170 }
commonZones["Hand1"] = { 0.2155387, 0, 0.04257287 }
commonZones["Hand2"] = { -0.1803701, 0, 0.03745948 }
commonZones["Arcane1"] = { 0.2124223, 0, 0.5596902 }
commonZones["Arcane2"] = { -0.1711275, 0, 0.5567944 }
commonZones["Tarot"] = { 0.6016169, 0, 0.03273106 }
commonZones["Accessory"] = { 0.6049907, 0, 0.5546234 }
commonZones["BlankTop"] = { 1.758446, 0, 0.03965336 }
commonZones["BlankBottom"] = { 1.754469, 0, 0.5634764 }
commonZones["Threat1"] = { -0.9116555, 0, -0.6446251 }
commonZones["Threat2"] = { -0.4544126, 0, -0.6428719 }
commonZones["Threat3"] = { 0.002246313, 0, -0.6430681 }
commonZones["Threat4"] = { 0.4590618, 0, -0.6432732 }
local zoneData = {}
zoneData["White"] = {}
zoneData["White"]["Investigator"] = commonZones["Investigator"]
zoneData["White"]["Deck"] = commonZones["Deck"]
zoneData["White"]["Discard"] = commonZones["Discard"]
zoneData["White"]["Ally"] = commonZones["Ally"]
zoneData["White"]["Body"] = commonZones["Body"]
zoneData["White"]["Hand1"] = commonZones["Hand1"]
zoneData["White"]["Hand2"] = commonZones["Hand2"]
zoneData["White"]["Arcane1"] = commonZones["Arcane1"]
zoneData["White"]["Arcane2"] = commonZones["Arcane2"]
zoneData["White"]["Tarot"] = commonZones["Tarot"]
zoneData["White"]["Accessory"] = commonZones["Accessory"]
zoneData["White"]["BlankTop"] = commonZones["BlankTop"]
zoneData["White"]["BlankBottom"] = commonZones["BlankBottom"]
zoneData["White"]["Threat1"] = commonZones["Threat1"]
zoneData["White"]["Threat2"] = commonZones["Threat2"]
zoneData["White"]["Threat3"] = commonZones["Threat3"]
zoneData["White"]["Threat4"] = commonZones["Threat4"]
zoneData["White"]["Minicard"] = { -1, 0, -1.45 }
zoneData["White"]["SetAside1"] = { 2.345893, 0, -0.520315 }
zoneData["White"]["SetAside2"] = { 2.345893, 0, 0.042552 }
zoneData["White"]["SetAside3"] = { 2.345893, 0, 0.605419 }
zoneData["White"]["UnderSetAside3"] = { 2.495893, 0, 0.805419 }
zoneData["White"]["SetAside4"] = { 2.775893, 0, -0.520315 }
zoneData["White"]["SetAside5"] = { 2.775893, 0, 0.042552 }
zoneData["White"]["SetAside6"] = { 2.775893, 0, 0.605419 }
zoneData["White"]["UnderSetAside6"] = { 2.925893, 0, 0.805419 }
zoneData["Orange"] = {}
zoneData["Orange"]["Investigator"] = commonZones["Investigator"]
zoneData["Orange"]["Deck"] = commonZones["Deck"]
zoneData["Orange"]["Discard"] = commonZones["Discard"]
zoneData["Orange"]["Ally"] = commonZones["Ally"]
zoneData["Orange"]["Body"] = commonZones["Body"]
zoneData["Orange"]["Hand1"] = commonZones["Hand1"]
zoneData["Orange"]["Hand2"] = commonZones["Hand2"]
zoneData["Orange"]["Arcane1"] = commonZones["Arcane1"]
zoneData["Orange"]["Arcane2"] = commonZones["Arcane2"]
zoneData["Orange"]["Tarot"] = commonZones["Tarot"]
zoneData["Orange"]["Accessory"] = commonZones["Accessory"]
zoneData["Orange"]["BlankTop"] = commonZones["BlankTop"]
zoneData["Orange"]["BlankBottom"] = commonZones["BlankBottom"]
zoneData["Orange"]["Threat1"] = commonZones["Threat1"]
zoneData["Orange"]["Threat2"] = commonZones["Threat2"]
zoneData["Orange"]["Threat3"] = commonZones["Threat3"]
zoneData["Orange"]["Threat4"] = commonZones["Threat4"]
zoneData["Orange"]["Minicard"] = { 1, 0, -1.45 }
zoneData["Orange"]["SetAside1"] = { -2.350362, 0, -0.520315 }
zoneData["Orange"]["SetAside2"] = { -2.350362, 0, 0.042552 }
zoneData["Orange"]["SetAside3"] = { -2.350362, 0, 0.605419 }
zoneData["Orange"]["UnderSetAside3"] = { -2.500362, 0, 0.80419 }
zoneData["Orange"]["SetAside4"] = { -2.7803627, 0, -0.520315 }
zoneData["Orange"]["SetAside5"] = { -2.7803627, 0, 0.042552 }
zoneData["Orange"]["SetAside6"] = { -2.7803627, 0, 0.605419 }
zoneData["Orange"]["UnderSetAside6"] = { -2.9303627, 0, 0.80419 }
-- Green positions are the same as White and Red the same as Orange
zoneData["Red"] = zoneData["Orange"]
zoneData["Green"] = zoneData["White"]
-- Gets the global position for the given zone on the specified player mat.
---@param playerColor: Color name of the player mat to get the zone position for (e.g. "Red")
---@param zoneName: Name of the zone to get the position for. See Zones object documentation for a list of valid zones.
---@return: Global position table, or nil if an invalid player color or zone is specified
Zones.getZonePosition = function(playerColor, zoneName)
if (playerColor ~= "Red"
and playerColor ~= "Orange"
and playerColor ~= "White"
and playerColor ~= "Green") then
return nil
end
return getObjectFromGUID(playerMatGuids[playerColor]).positionToWorld(zoneData[playerColor][zoneName])
end
-- Return the global rotation for a card on the given player mat, based on its metadata.
---@param playerColor: Color name of the player mat to get the rotation for (e.g. "Red")
---@param cardMetadata: Table of card metadata. Metadata fields type and permanent are required; all others are optional.
---@return: Global rotation vector for the given card. This will include the
-- Y rotation to orient the card on the given player mat as well as a
-- Z rotation to place the card face up or face down.
Zones.getDefaultCardRotation = function(playerColor, zone)
local deckRotation = getObjectFromGUID(playerMatGuids[playerColor]).getRotation()
if zone == "Deck" then
deckRotation = deckRotation + Vector(0, 0, 180)
end
return deckRotation
end
return Zones
end
end)
__bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules)
require("arkhamdb/DeckImporterMain")
end)
__bundle_register("arkhamdb/DeckImporterMain", function(require, _LOADED, __bundle_register, __bundle_modules)
require("arkhamdb/DeckImporterUi")
require("playercards/PlayerCardSpawner")
local playmatApi = require("playermat/PlaymatApi")
local playAreaApi = require("core/PlayAreaApi")
local arkhamDb = require("arkhamdb/ArkhamDb")
local zones = require("playermat/Zones")
local ALL_CARDS_GUID = "15bb07"
function onLoad(script_state)
initializeUi(JSON.decode(script_state))
math.randomseed(os.time())
arkhamDb.initialize()
end
function arkhamdb_reinit()
arkhamDb.initialize()
end
function onSave() return JSON.encode(getUiState()) end
-- Returns the zone name where the specified card should be placed, based on its metadata.
---@param cardMetadata Table of card metadata.
---@return Zone String Name of the zone such as "Deck", "SetAside1", etc.
-- See Zones object documentation for a list of valid zones.
function getDefaultCardZone(cardMetadata, bondedList)
if (cardMetadata.id == "09080-m") then -- Have to check the Servitor before other minicards
return "SetAside6"
elseif (cardMetadata.id == "09006") then -- On The Mend is set aside
return "SetAside2"
elseif cardMetadata.type == "Investigator" then
return "Investigator"
elseif cardMetadata.type == "Minicard" then
return "Minicard"
elseif cardMetadata.type == "UpgradeSheet" then
return "SetAside4"
elseif cardMetadata.startsInPlay then
return "BlankTop"
elseif cardMetadata.permanent then
return "SetAside1"
elseif bondedList[cardMetadata.id] then
return "SetAside2"
-- SetAside3 is used for Ancestral Knowledge / Underworld Market
else
return "Deck"
end
end
function buildDeck(playerColor, deckId)
local uiState = getUiState()
arkhamDb.getDecklist(
playerColor,
deckId,
uiState.private,
uiState.loadNewest,
uiState.investigators,
loadCards)
end
-- Process the slot list, which defines the card Ids and counts of cards to load. Spawn those cards
-- at the appropriate zones and report an error to the user if any could not be loaded.
-- This is a callback function which handles the results of ArkhamDb.getDecklist()
-- This method uses an encapsulated coroutine with yields to make the card spawning cleaner.
--
---@param slots Table Key-Value table of cardId:count. cardId is the ArkhamDB ID of the card to spawn,
-- and count is the number which should be spawned
---@param investigatorId String ArkhamDB ID (code) for this deck's investigator.
-- Investigator cards should already be added to the slots list if they
-- should be spawned, but this value is separate to check for special
-- handling for certain investigators
---@param bondedList Table A table of cardID keys to meaningless values. Card IDs in this list were added
-- from a parent bonded card.
---@param customizations String ArkhamDB data for customizations on customizable cards
---@param playerColor String Color name of the player mat to place this deck on (e.g. "Red")
---@param loadAltInvestigator String Contains the name of alternative art for the investigator ("normal", "revised" or "promo")
function loadCards(slots, investigatorId, bondedList, customizations, playerColor, loadAltInvestigator)
function coinside()
local allCardsBag = getObjectFromGUID(ALL_CARDS_GUID)
local yPos = {}
local cardsToSpawn = {}
for cardId, cardCount in pairs(slots) do
local card = allCardsBag.call("getCardById", { id = cardId })
if card ~= nil then
local cardZone = getDefaultCardZone(card.metadata, bondedList)
for i = 1, cardCount do
table.insert(cardsToSpawn, { data = card.data, metadata = card.metadata, zone = cardZone })
end
slots[cardId] = 0
end
end
handleAncestralKnowledge(cardsToSpawn)
handleUnderworldMarket(cardsToSpawn, playerColor)
handleHunchDeck(investigatorId, cardsToSpawn, playerColor)
handleCustomizableUpgrades(cardsToSpawn, customizations)
handlePeteSignatureAssets(investigatorId, cardsToSpawn)
-- Split the card list into separate lists for each zone
local zoneDecks = buildZoneLists(cardsToSpawn)
-- Spawn the list for each zone
for zone, zoneCards in pairs(zoneDecks) do
local deckPos = zones.getZonePosition(playerColor, zone)
deckPos.y = 3
local callback = nil
-- If cards are spread too close together TTS groups them weirdly, selecting multiples
-- when hovering over a single card. This distance is the minimum to avoid that
local spreadDistance = 1.15
if (zone == "SetAside4") then
-- SetAside4 is reserved for customization cards, and we want them spread on the table
-- so their checkboxes are visible
-- TO-DO: take into account that spreading will make multiple rows
-- (this is affected by the user's local settings!)
if (playerColor == "White") then
deckPos.z = deckPos.z + (#zoneCards - 1) * spreadDistance
elseif (playerColor == "Green") then
deckPos.x = deckPos.x + (#zoneCards - 1) * spreadDistance
end
callback = function(deck) deck.spread(spreadDistance) end
elseif zone == "Deck" then
callback = function(deck) deckSpawned(deck, playerColor) end
elseif zone == "Investigator" or zone == "Minicard" then
callback = function(card) loadAltArt(card, loadAltInvestigator) end
end
Spawner.spawnCards(
zoneCards,
deckPos,
zones.getDefaultCardRotation(playerColor, zone),
true, -- Sort deck
callback)
coroutine.yield(0)
end
-- Look for any cards which haven't been loaded
local hadError = false
for cardId, remainingCount in pairs(slots) do
if remainingCount > 0 then
hadError = true
arkhamDb.logCardNotFound(cardId, playerColor)
end
end
if (not hadError) then
printToAll("Deck loaded successfully!", playerColor)
end
return 1
end
startLuaCoroutine(self, "coinside")
end
-- Callback handler for the main deck spawning. Looks for cards which should start in hand, and
-- draws them for the appropriate player.
---@param deck Object Callback-provided spawned deck object
---@param playerColor String Color of the player to draw the cards to
function deckSpawned(deck, playerColor)
local player = Player[playmatApi.getPlayerColor(playerColor)]
local handPos = player.getHandTransform(1).position -- Only one hand zone per player
local deckCards = deck.getData().ContainedObjects
-- Process in reverse order so taking cards out doesn't upset the indexing
for i = #deckCards, 1, -1 do
local cardMetadata = JSON.decode(deckCards[i].GMNotes) or { }
if cardMetadata.startsInHand then
deck.takeObject({ index = i - 1, position = handPos, flip = true, smooth = true})
end
end
end
-- Converts the Raven Quill's selections from card IDs to card names. This could be more elegant
-- but the inputs are very static so we're using some brute force.
---@param selectionString String provided by ArkhamDB, indicates the customization selections
-- Should be either a single card ID or two separated by a ^ (e.g. XXXXX^YYYYY)
function convertRavenQuillSelections(selectionString)
if (string.len(selectionString) == 5) then
return getCardName(selectionString)
elseif (string.len(selectionString) == 11) then
return getCardName(string.sub(selectionString, 1, 5)) .. ", " .. getCardName(string.sub(selectionString, 7))
end
end
-- Converts Grizzled's selections from a single string with "^".
---@param selectionString String provided by ArkhamDB, indicates the customization selections
-- Should be two Traits separated by a ^ (e.g. XXXXX^YYYYY)
function convertGrizzledSelections(selectionString)
return selectionString:gsub("%^", ", ")
end
-- Returns the simple name of a card given its ID. This will find the card and strip any trailing
-- SCED-specific suffixes such as (Taboo) or (Level)
function getCardName(cardId)
local allCardsBag = getObjectFromGUID(ALL_CARDS_GUID)
local card = allCardsBag.call("getCardById", { id = cardId })
if (card ~= nil) then
local name = card.data.Nickname
if (string.find(name, " %(")) then
return string.sub(name, 1, string.find(name, " %(") - 1)
else
return name
end
end
end
-- Split a single list of cards into a separate table of lists, keyed by the zone
---@param cards: Table of {cardData, cardMetadata, zone}
---@return: Table of {zoneName=card list}
function buildZoneLists(cards)
local zoneList = {}
for _, card in ipairs(cards) do
if zoneList[card.zone] == nil then
zoneList[card.zone] = {}
end
table.insert(zoneList[card.zone], card)
end
return zoneList
end
-- Check to see if the deck list has Ancestral Knowledge. If it does, move 5 random skills to SetAside3
---@param cardList Table Deck list being created
function handleAncestralKnowledge(cardList)
local hasAncestralKnowledge = false
local skillList = {}
-- Have to process the entire list to check for Ancestral Knowledge and get all possible skills, so do both in one pass
for i, card in ipairs(cardList) do
if card.metadata.id == "07303" then
hasAncestralKnowledge = true
card.zone = "SetAside3"
elseif (card.metadata.type == "Skill"
and card.zone == "Deck"
and not card.metadata.weakness) then
table.insert(skillList, i)
end
end
if hasAncestralKnowledge then
for i = 1, 5 do
-- Move 5 random skills to SetAside3
local skillListIndex = math.random(#skillList)
cardList[skillList[skillListIndex]].zone = "UnderSetAside3"
table.remove(skillList, skillListIndex)
end
end
end
-- Check for and handle Underworld Market by moving all Illicit cards to UnderSetAside3
---@param cardList Table Deck list being created
---@param playerColor String Color this deck is being loaded for
function handleUnderworldMarket(cardList, playerColor)
local hasMarket = false
local illicitList = {}
-- Process the entire list to check for Underworld Market and get all possible skills, doing both in one pass
for i, card in ipairs(cardList) do
if card.metadata.id == "09077" then
-- Underworld Market found
hasMarket = true
card.zone = "SetAside3"
elseif card.metadata.traits ~= nil and string.find(card.metadata.traits, "Illicit", 1, true) and card.zone == "Deck" then
table.insert(illicitList, i)
end
end
if hasMarket then
if #illicitList < 10 then
printToAll("Only " .. #illicitList ..
" Illicit cards in your deck, you can't trigger Underworld Market's ability.",
playerColor)
else
-- Process cards to move them to the market deck. This is done in reverse
-- order because the sorting needs to be reversed (deck sorts for face down)
-- Performance here may be an issue, as table.remove() is an O(n) operation
-- which makes the full shift O(n^2). But keep it simple unless it becomes
-- a problem
for i = #illicitList, 1, -1 do
local moving = cardList[illicitList[i]]
moving.zone = "UnderSetAside3"
table.remove(cardList, illicitList[i])
table.insert(cardList, moving)
end
if #illicitList > 10 then
printToAll("Moved all " .. #illicitList ..
" Illicit cards to the Market deck, reduce it to 10",
playerColor)
else
printToAll("Built the Market deck", playerColor)
end
end
end
end
-- If the investigator is Joe Diamond, extract all Insight events to SetAside5 to build the Hunch
-- Deck.
---@param investigatorId String ID for the deck's investigator card. Passed separately because the
--- investigator may not be included in the cardList
---@param cardList Table Deck list being created
---@param playerColor String Color this deck is being loaded for
function handleHunchDeck(investigatorId, cardList, playerColor)
if investigatorId == "05002" then -- Joe Diamond
local insightList = {}
for i, card in ipairs(cardList) do
if (card.metadata.type == "Event"
and card.metadata.traits ~= nil
and string.match(card.metadata.traits, "Insight")
and card.metadata.bonded_to == nil) then
table.insert(insightList, i)
end
end
-- Process insights to move them to the hunch deck. This is done in reverse
-- order because the sorting needs to be reversed (deck sorts for face down)
-- Performance here may be an issue, as table.remove() is an O(n) operation
-- which makes the full shift O(n^2). But keep it simple unless it becomes
-- a problem
for i = #insightList, 1, -1 do
local moving = cardList[insightList[i]]
moving.zone = "SetAside5"
table.remove(cardList, insightList[i])
table.insert(cardList, moving)
end
if #insightList < 11 then
printToAll("Joe's hunch deck must have 11 cards but the deck only has " .. #insightList ..
" Insight events.", playerColor)
elseif #insightList > 11 then
printToAll("Moved all " .. #insightList ..
" Insight events to the hunch deck, reduce it to 11.", playerColor)
else
printToAll("Built Joe's hunch deck", playerColor)
end
end
end
-- For any customization upgrade cards in the card list, process the metadata from the deck to
-- set the save state to show the correct checkboxes/text field values
---@param cardList Table Deck list being created
---@param customizations Table Deck's meta table, extracted from ArkhamDB's deck structure
function handleCustomizableUpgrades(cardList, customizations)
for _, card in ipairs(cardList) do
if card.metadata.type == "UpgradeSheet" then
local baseId = string.sub(card.metadata.id, 1, 5)
local upgrades = customizations["cus_" .. baseId]
if upgrades ~= nil then
-- initialize tables
-- markedBoxes: contains the amount of markedBoxes (left to right) per row (starting at row 1)
-- inputValues: contains the amount of inputValues per row (starting at row 0)
local selectedUpgrades = { }
local index_xp = {}
-- get the index and xp values (looks like this: X|X,X|X, ..)
-- input string from ArkhamDB is split by ","
for str in string.gmatch(customizations["cus_" .. baseId], "([^,]+)") do
table.insert(index_xp, str)
end
-- split each pair and assign it to the proper position in markedBoxes
for _, entry in ipairs(index_xp) do
-- counter increments from 1 to 3 and indicates the part of the string we are on
-- usually: 1 = row, 2 = amount of check boxes, 3 = entry in inputfield
local counter = 0
local row = 0
-- parsing the string for each row
for str in entry:gmatch("([^|]+)") do
counter = counter + 1
if counter == 1 then
row = tonumber(str) + 1
elseif counter == 2 then
if selectedUpgrades[row] == nil then
selectedUpgrades[row] = { }
end
selectedUpgrades[row].xp = tonumber(str)
elseif counter == 3 and str ~= "" then
if baseId == "09042" then
selectedUpgrades[row].text = convertRavenQuillSelections(str)
elseif baseId == "09101" then
selectedUpgrades[row].text = convertGrizzledSelections(str)
elseif baseId == "09079" then -- Living Ink skill selection
-- All skills, regardless of row, are placed in upgrade slot 1 as a comma-delimited
-- list
if selectedUpgrades[1].text == nil then
selectedUpgrades[1].text = str
else
selectedUpgrades[1].text = selectedUpgrades[1].text .. "," .. str
end
else
selectedUpgrades[row].text = str
end
end
end
end
-- write the loaded values to the save_data of the sheets
card.data["LuaScriptState"] = JSON.encode({ selections = selectedUpgrades })
end
end
end
end
-- Handles cards that start in play under specific conditions for Ashcan Pete (Regular Pete - Duke, Parallel Pete - Guitar)
---@param investigatorId String ID for the deck's investigator card. Passed separately because the
--- investigator may not be included in the cardList
---@param cardList Table Deck list being created
function handlePeteSignatureAssets(investigatorId, cardList)
log(investigatorId)
if investigatorId == "02005" or investigatorId == "02005-pb" then -- regular Pete's front
for i, card in ipairs(cardList) do
if card.metadata.id == "02014" then -- Duke
card.zone = "BlankTop"
end
end
elseif investigatorId == "02005-p" or investigatorId == "02005-pf" then -- parallel Pete's front
for i, card in ipairs(cardList) do
if card.metadata.id == "90047" then -- Pete's Guitar
card.zone = "BlankTop"
end
end
end
end
-- Callback function for investigator cards and minicards to set the correct state for alt art
---@param card Object Card which needs to be set the state for
---@param loadAltInvestigator String Contains the name of alternative art for the investigator ("normal", "revised" or "promo")
function loadAltArt(card, loadAltInvestigator)
-- states are set up this way:
-- 1 - normal, 2 - revised/promo, 3 - promo (if 2 is revised)
-- This means we can always load the 2nd state for revised and just get the last state for promo
if loadAltInvestigator == "normal" then
return
elseif loadAltInvestigator == "revised" then
card.setState(2)
elseif loadAltInvestigator == "promo" then
local states = card.getStates()
card.setState(#states)
end
end
end)
return __bundle_require("__root")