-- Generated by EmmyLua (https://github.com/EmmyLua) -- Created by Whimsical. -- DateTime: 2021-08-19 6:38 a.m. -- updated by Chr1Z (2022-10-11) ---@type ArkhamImportConfiguration 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 local bondedList = { } local customizationRowsWithFields = { } -- inputMap maps from (our 1-indexes) customization row index to inputValue table index -- The Raven Quill customizationRowsWithFields["09042"] = { } customizationRowsWithFields["09042"].inputCount = 2 customizationRowsWithFields["09042"].inputMap = { } customizationRowsWithFields["09042"].inputMap[1] = 1 customizationRowsWithFields["09042"].inputMap[5] = 2 -- Friends in Low Places customizationRowsWithFields["09060"] = { } customizationRowsWithFields["09060"].inputCount = 2 customizationRowsWithFields["09060"].inputMap = { } customizationRowsWithFields["09060"].inputMap[1] = 1 customizationRowsWithFields["09060"].inputMap[3] = 2 -- Living Ink customizationRowsWithFields["09079"] = { } customizationRowsWithFields["09079"].inputCount = 3 customizationRowsWithFields["09079"].inputMap = { } customizationRowsWithFields["09079"].inputMap[1] = 1 customizationRowsWithFields["09079"].inputMap[5] = 2 customizationRowsWithFields["09079"].inputMap[6] = 3 -- Grizzled customizationRowsWithFields["09101"] = { } customizationRowsWithFields["09101"].inputCount = 3 customizationRowsWithFields["09101"].inputMap = { } customizationRowsWithFields["09101"].inputMap[1] = 1 customizationRowsWithFields["09101"].inputMap[2] = 2 customizationRowsWithFields["09101"].inputMap[3] = 3 -- 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 -- 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() -- 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 -- 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 local playerMatGuids = {} playerMatGuids["Red"] = "0840d5" playerMatGuids["Orange"] = "bd0ff4" playerMatGuids["White"] = "8b081b" playerMatGuids["Green"] = "383d8b" commonZones = {} commonZones["Investigator"] = { -1.17702, 0, 0.00209 } commonZones["Minicard"] = { -0.4668214, 0, -1.222326 } 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 } Zones = {} Zones["White"] = {} Zones["White"]["Investigator"] = commonZones["Investigator"] Zones["White"]["Minicard"] = commonZones["Minicard"] Zones["White"]["Deck"] = commonZones["Deck"] Zones["White"]["Discard"] = commonZones["Discard"] Zones["White"]["Ally"] = commonZones["Ally"] Zones["White"]["Body"] = commonZones["Body"] Zones["White"]["Hand1"] = commonZones["Hand1"] Zones["White"]["Hand2"] = commonZones["Hand2"] Zones["White"]["Arcane1"] = commonZones["Arcane1"] Zones["White"]["Arcane2"] = commonZones["Arcane2"] Zones["White"]["Tarot"] = commonZones["Tarot"] Zones["White"]["Accessory"] = commonZones["Accessory"] Zones["White"]["BlankTop"] = commonZones["BlankTop"] Zones["White"]["BlankBottom"] = commonZones["BlankBottom"] Zones["White"]["Threat1"] = commonZones["Threat1"] Zones["White"]["Threat2"] = commonZones["Threat2"] Zones["White"]["Threat3"] = commonZones["Threat3"] Zones["White"]["Threat4"] = commonZones["Threat4"] Zones["White"]["SetAside1"] = { 2.345893, 0, -0.520315 } Zones["White"]["SetAside2"] = { 2.345893, 0, 0.042552 } Zones["White"]["SetAside3"] = { 2.345893, 0, 0.605419 } Zones["White"]["UnderSetAside3"] = { 2.495893, 0, 0.805419 } Zones["White"]["SetAside4"] = { 2.775893, 0, -0.520315 } Zones["White"]["SetAside5"] = { 2.775893, 0, 0.042552 } Zones["White"]["SetAside6"] = { 2.775893, 0, 0.605419 } Zones["White"]["UnderSetAside6"] = { 2.925893, 0, 0.805419 } Zones["Orange"] = {} Zones["Orange"]["Investigator"] = commonZones["Investigator"] Zones["Orange"]["Minicard"] = commonZones["Minicard"] Zones["Orange"]["Deck"] = commonZones["Deck"] Zones["Orange"]["Discard"] = commonZones["Discard"] Zones["Orange"]["Ally"] = commonZones["Ally"] Zones["Orange"]["Body"] = commonZones["Body"] Zones["Orange"]["Hand1"] = commonZones["Hand1"] Zones["Orange"]["Hand2"] = commonZones["Hand2"] Zones["Orange"]["Arcane1"] = commonZones["Arcane1"] Zones["Orange"]["Arcane2"] = commonZones["Arcane2"] Zones["Orange"]["Tarot"] = commonZones["Tarot"] Zones["Orange"]["Accessory"] = commonZones["Accessory"] Zones["Orange"]["BlankTop"] = commonZones["BlankTop"] Zones["Orange"]["BlankBottom"] = commonZones["BlankBottom"] Zones["Orange"]["Threat1"] = commonZones["Threat1"] Zones["Orange"]["Threat2"] = commonZones["Threat2"] Zones["Orange"]["Threat3"] = commonZones["Threat3"] Zones["Orange"]["Threat4"] = commonZones["Threat4"] Zones["Orange"]["SetAside1"] = { -2.350362, 0, -0.520315 } Zones["Orange"]["SetAside2"] = { -2.350362, 0, 0.042552 } Zones["Orange"]["SetAside3"] = { -2.350362, 0, 0.605419 } Zones["Orange"]["UnderSetAside3"] = { -2.500362, 0, 0.80419 } Zones["Orange"]["SetAside4"] = { -2.7803627, 0, -0.520315 } Zones["Orange"]["SetAside5"] = { -2.7803627, 0, 0.042552 } Zones["Orange"]["SetAside6"] = { -2.7803627, 0, 0.605419 } Zones["Orange"]["UnderSetAside6"] = { -2.9303627, 0, 0.80419 } -- Green positions are the same as White and Red the same as Orange Zones["Red"] = Zones["Orange"] Zones["Green"] = Zones["White"] -- Returns the zone name where the specified card should be placed, based on its metadata. ---@param cardMetadata: Table of card metadata. Metadata fields type and permanent are required; all others are optional. ---@return: Zone name such as "Deck", "SetAside1", etc. See Zones object documentation for a list of valid zones. function Zones.getDefaultCardZone(cardMetadata) if cardMetadata.type == "Investigator" then return "Investigator" elseif cardMetadata.type == "Minicard" then return "Minicard" elseif cardMetadata.permanent then return "SetAside1" elseif bondedList[cardMetadata.id] then return "SetAside2" -- SetAside3 is used for Ancestral Knowledge / Underworld Market -- SetAside4 is used for upgrade sheets else return "Deck" end end -- 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 function Zones.getZonePosition(playerColor, zoneName) if (playerColor ~= "Red" and playerColor ~= "Orange" and playerColor ~= "White" and playerColor ~= "Green") then return nil end return getObjectFromGUID(playerMatGuids[playerColor]).positionToWorld(Zones[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. function Zones.getDefaultCardRotation(playerColor, zone) local deckRotation = getObjectFromGUID(playerMatGuids[playerColor]).getRotation() if zone == "Investigator" then deckRotation = deckRotation + Vector(0, 270, 0) elseif zone == "Deck" then deckRotation = deckRotation + Vector(0, 0, 180) end return deckRotation end local RANDOM_WEAKNESS_ID = "01000" local tags = { configuration = "import_configuration_provider" } local Priority = { ERROR = 0, WARNING = 1, INFO = 2, DEBUG = 3 } ---@type fun(text: string) local printFunction = printToAll local printPriority = Priority.INFO ---@param priority number ---@return string function Priority.getLabel(priority) if priority == 0 then return "ERROR" elseif priority == 1 then return "WARNING" elseif priority == 2 then return "INFO" elseif priority == 3 then return "DEBUG" else error(table.concat({ "Priority", priority, "not found" }, " ")) return "" end end ---@param message string ---@param priority number local function debugPrint(message, priority, color) if (color == nil) then color = { 0.5, 0.5, 0.5 } end if (printPriority >= priority) then printFunction("[" .. Priority.getLabel(priority) .. "] " .. message, color) end end local function fixUtf16String(str) return str:gsub("\\u(%w%w%w%w)", function(match) return string.char(tonumber(match, 16)) end) end --Forward declaration ---@type Request local Request = {} ---@type table local tabooList = {} ---@return ArkhamImportConfiguration local function getConfiguration() local configuration = getObjectsWithTag(tags.configuration)[1]:getTable("configuration") printPriority = configuration.priority return configuration end function onLoad(script_state) local state = JSON.decode(script_state) initializeUi(state) math.randomseed(os.time()) local configuration = getConfiguration() Request.start({ configuration.api_uri, configuration.taboo }, function(status) local json = JSON.decode(fixUtf16String(status.text)) for _, taboo in pairs(json) do ---@type 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 function onSave() return JSON.encode(getUiState()) 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 configuration ArkhamImportConfiguration local function onDeckResult(deck, playerColor, configuration) -- Load the next deck in the upgrade path if the option is enabled if (getUiState().loadNewest and deck.next_deck ~= nil and deck.next_deck ~= "") then buildDeck(playerColor, deck.next_deck) return end debugPrint(table.concat({ "Found decklist: ", deck.name }), Priority.INFO, playerColor) debugPrint(table.concat({ "-", deck.name, "-" }), Priority.DEBUG) for k, v in pairs(deck) do if type(v) == "table" then debugPrint(table.concat { k, ": " }, Priority.DEBUG) else debugPrint(table.concat { k, ": ", tostring(v) }, Priority.DEBUG) end end debugPrint("", Priority.DEBUG) -- 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 maybeDrawRandomWeakness(slots, playerColor, configuration) maybeAddInvestigatorCards(deck, slots) extractBondedCards(slots, configuration) checkTaboos(deck.taboo_id, slots, playerColor, configuration) local commandManager = getObjectFromGUID(configuration.command_manager_guid) ---@type ArkhamImport_CommandManager_InitializationArguments local parameters = { configuration = configuration, description = deck.description_md, } ---@type ArkhamImport_CommandManager_InitializationResults local results = commandManager:call("initialize", parameters) if not results.is_successful then debugPrint(results.error_message, Priority.ERROR) return end -- get upgrades for customizable cards local meta = deck.meta local customizations = {} if meta then customizations = JSON.decode(deck.meta) end loadCards(slots, deck.investigator_code, playerColor, commandManager, configuration, results.configuration, customizations) 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: 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: Color name of the player this deck is being loaded for. Used for broadcast if a weakness is added. ---@param configuration: The API configuration object function maybeDrawRandomWeakness(slots, playerColor, configuration) 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 debugPrint("Random basic weakness added to deck", Priority.INFO, playerColor) end end -- If investigator cards should be loaded, add both the investigator (XXXXX) and minicard (XXXXX-m) slots with one copy each ---@param deck: The processed ArkhamDB deck response ---@param slots: The slot list for cards in this deck. Table key is the cardId, value is the number of those cards which will be spawned function maybeAddInvestigatorCards(deck, slots) if getUiState().investigators then 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 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: 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 configuration: The API configuration object function extractBondedCards(slots, configuration) local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) -- Create a list of bonded cards first so we don't modify slots while iterating local bondedCards = {} 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 end end end -- Add any bonded cards to the main slots list for bondedId, bondedCount in pairs(bondedCards) do slots[bondedId] = bondedCount end 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: 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: The slot list for cards in this deck. Table key is the cardId, value is the number of those cards which will be spawned function checkTaboos(tabooId, slots, playerColor, configuration) 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 }) debugPrint("Taboo version for " .. basicCard.data.Nickname .. " is not available. Using standard version", Priority.WARNING, playerColor) else slots[cardId .. "-t"] = slots[cardId] slots[cardId] = nil end end end end 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 method uses an encapsulated coroutine with yields to make the card spawning cleaner. -- ---@param slots: 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 playerColor String Color name of the player mat to place this deck on (e.g. "Red") ---@param configuration: Loader configuration object ---@param customizations: ArkhamDB data for customizations on customizable cards function loadCards(slots, investigatorId, playerColor, commandManager, configuration, command_config, customizations) function coinside() local allCardsBag = getObjectFromGUID(configuration.card_bag_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 = Zones.getDefaultCardZone(card.metadata) for i = 1, cardCount do table.insert(cardsToSpawn, { data = card.data, metadata = card.metadata, zone = cardZone }) end -- upgrade sheets for customizable cards local upgradesheet = allCardsBag.call("getCardById", { id = cardId .. "-c" }) if upgradesheet ~= nil then -- update metadata for spawned upgrade sheets local upgrades = customizations["cus_" .. cardId] 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 markedBoxes = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } local inputValues = {} local index_xp = {} -- get the index and xp values (looks like this: X|X,X|X, ..) for str in string.gmatch(customizations["cus_" .. cardId], "([^,]+)") do table.insert(index_xp, str) end -- split each pair and assign it to the proper position in markedBoxes if (customizationRowsWithFields[cardId] ~= nil) then for i = 1, customizationRowsWithFields[cardId].inputCount do table.insert(inputValues, "") end end local inputCount = 0 for _, entry in ipairs(index_xp) do local counter = 0 local index = 0 -- if found number is 0, then only get inputvalue for str in string.gmatch(entry, "([^|]+)") do counter = counter + 1 if counter == 1 then index = tonumber(str) + 1 elseif counter == 2 then markedBoxes[index] = tonumber(str) elseif counter == 3 and str ~= "" then if (cardId == "09042") then inputValues[customizationRowsWithFields[cardId].inputMap[index]] = convertRavenQuillSelections(str) else inputValues[customizationRowsWithFields[cardId].inputMap[index]] = str end end end end -- remove first entry in markedBoxes if row 0 has textbox if customizationRowsWithFields[cardId] ~= nil and customizationRowsWithFields[cardId].inputCount > 0 then table.remove(markedBoxes, 1) end -- write the loaded values to the save_data of the sheets upgradesheet.data["LuaScriptState"] = JSON.encode({ markedBoxes, inputValues }) table.insert(cardsToSpawn, { data = upgradesheet.data, metadata = upgradesheet.metadata, zone = "SetAside4" }) end end -- spawn additional minicard for 'Summoned Servitor' if cardId == "09080" then local servitor = allCardsBag.call("getCardById", { id = "09080-m" }) table.insert(cardsToSpawn, { data = servitor.data, metadata = servitor.metadata, zone = "SetAside6" }) end slots[cardId] = 0 end end -- TODO: Re-enable this later, as a command -- handleAltInvestigatorCard(cardsToSpawn, "promo", configuration) table.sort(cardsToSpawn, cardComparator) -- TODO: Process commands for the cardsToSpawn list -- These should probably be commands, once the command handler is updated handleStartsInPlay(cardsToSpawn) handleAncestralKnowledge(cardsToSpawn) handleUnderworldMarket(cardsToSpawn, playerColor) handleHunchDeck(investigatorId, cardsToSpawn, playerColor) -- Count the number of cards in each zone so we know if it's a deck or card. -- TTS's Card vs. Deck distinction requires this since we can't spawn a deck with only one card local zoneCounts = getZoneCounts(cardsToSpawn) local zoneDecks = {} for zone, count in pairs(zoneCounts) do if count > 1 then zoneDecks[zone] = buildDeckDataTemplate() end end -- For each card in a deck zone, add it to that deck. Otherwise, spawn it directly for _, spawnCard in ipairs(cardsToSpawn) do if zoneDecks[spawnCard.zone] ~= nil then addCardToDeck(zoneDecks[spawnCard.zone], spawnCard.data) else local cardPos = Zones.getZonePosition(playerColor, spawnCard.zone) cardPos.y = 2 spawnObjectData({ data = spawnCard.data, position = cardPos, rotation = Zones.getDefaultCardRotation(playerColor, spawnCard.zone), }) end end -- Spawn each of the decks for zone, deck in pairs(zoneDecks) do local deckPos = Zones.getZonePosition(playerColor, zone) deckPos.y = 3 local spreadCallback = nil; if (zone == "SetAside4") then -- SetAside4 is reserved for customization cards, and we want them spread on the table -- so their checkboxes are visible if (playerColor == "White") then deckPos.z = deckPos.z + (#deck.ContainedObjects - 1) elseif (playerColor == "Green") then deckPos.x = deckPos.x + (#deck.ContainedObjects - 1) end spreadCallback = function(deck) deck.spread(1.0) end end spawnObjectData({ data = deck, position = deckPos, rotation = Zones.getDefaultCardRotation(playerColor, zone), callback_function = spreadCallback }) 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 local request = Request.start({ configuration.api_uri, configuration.cards, cardId }, function(result) local adbCardInfo = JSON.decode(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 debugPrint("Card not found: " .. cardName .. ", ArkhamDB ID " .. cardId, Priority.ERROR, playerColor) else debugPrint("Card not found in ArkhamDB, ID " .. cardId, Priority.ERROR, playerColor) end end) end end if (not hadError) then debugPrint("Deck loaded successfully!", Priority.INFO, playerColor) end return 1 end startLuaCoroutine(self, "coinside") end -- Conver 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 An ArkhamDB string indicating the customization selections for The Raven's Quill. 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 -- 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 configuration = getConfiguration() local allCardsBag = getObjectFromGUID(configuration.card_bag_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 -- 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 function addCardToDeck(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 = 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 function findNextAvailableId(objectTable, startId) local id = startId while (objectTable[id] ~= nil) do id = tostring(tonumber(id) + 1) end return id end -- Count the number of cards in each zone ---@param cards: Table of {cardData, cardMetadata, zone} ---@return: Table of {zoneName=zoneCount} function getZoneCounts(cards) local counts = {} for _, card in ipairs(cards) do if counts[card.zone] == nil then counts[card.zone] = 1 else counts[card.zone] = counts[card.zone] + 1 end end return counts 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 function buildDeckDataTemplate() 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 -- 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. function getpbcn(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. function cardComparator(card1, card2) local pbcn1 = getpbcn(card1.metadata) local pbcn2 = 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 -- Replace the investigator card and minicard with an alternate version. This -- will find the relevant cards and look for IDs with -, and -- --m, and update the entries in cardList with the new card -- data. -- ---@param cardList: Deck list being created ---@param altVersionTag: The tag for the different version, currently the only alt versions are "promo", but will soon inclide "revised" ---@param configuration: ArkhamDB configuration defniition, used for the card bag function handleAltInvestigatorCard(cardList, altVersionTag, configuration) local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) for _, card in ipairs(cardList) do if card.metadata.type == "Investigator" then local altInvestigator = allCardsBag.call("getCardById", { id = card.metadata.id .. "-" .. altVersionTag }) if (altInvestigator ~= nil) then card.data = altInvestigator.data card.metadata = altInvestigator.metadata end end if card.metadata.type == "Minicard" then -- -promo comes before -m in the ID, so needs a little massaging local investigatorId = string.sub(card.metadata.id, 1, 5) local altMinicard = allCardsBag.call("getCardById", { id = investigatorId .. "-" .. altVersionTag .. "-m" }) if altMinicard ~= nil then card.data = altMinicard.data card.metadata = altMinicard.metadata end end end end -- Place cards which start in play (Duke, Sophie) in the play area function handleStartsInPlay(cardList) for _, card in ipairs(cardList) do if card.metadata.startsInPlay then card.zone = "BlankTop" end end end -- Check to see if the deck list has Ancestral Knowledge. If it does, move 5 random skills to SetAside3 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.metadata.bonded_to == nil 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: Deck list being created ---@param playerColor: 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 (string.find(card.metadata.traits or "", "Illicit", 1, true) and card.metadata.bonded_to == nil and not card.metadata.weakness) then table.insert(illicitList, i) end end if hasMarket then if #illicitList < 10 then debugPrint("Only " .. #illicitList .. " Illicit cards in your deck, you can't trigger Underworld Market's ability." , Priority.WARNING, 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 debugPrint("Moved all " .. #illicitList .. " Illicit cards to the Market deck, reduce it to 10", Priority.INFO, playerColor) else debugPrint("Built the Market deck", Priority.INFO, playerColor) end end end end -- If the investigator is Joe Diamond, extract all Insight events to SetAside5 to build the Hunch Deck. ---@param investigatorId: ID for the deck's investigator card. Passed separately because the investigator may not be included in the cardList ---@param cardList: Deck list being created ---@param playerColor: 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 string.match(card.metadata.traits or "", "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 debugPrint("Joe's hunch deck must have 11 cards but the deck only has " .. #insightList .. " Insight events.", Priority.INFO, playerColor) elseif #insightList > 11 then debugPrint("Moved all " .. #insightList .. " Insight events to the hunch deck, reduce it to 11.", Priority.INFO, playerColor) else debugPrint("Built Joe's hunch deck", Priority.INFO, playerColor) end end end -- Test method. Loads all decks which were submitted to ArkhamDB on a given date window. function testLoadLotsOfDecks() local configuration = getConfiguration() local numDays = 7 local day = os.time { year = 2021, month = 7, day = 15 } -- Start date here for i = 1, numDays do local dateString = os.date("%Y-%m-%d", day) local deckList = Request.start({ configuration.api_uri, "decklists/by_date", dateString, }, function(result) local json = JSON.decode(result.text) for i, deckData in ipairs(json) do buildDeck(getColorForTest(i), deckData.id) end end) day = day + (60 * 60 * 24) -- Move forward by one day end end -- Rotates the player mat based on index, to spread the card stacks during a mass load function getColorForTest(index) if (index % 4 == 0) then return "Red" elseif (index % 4 == 1) then return "Orange" elseif (index % 4 == 2) then return "White" elseif (index % 4 == 3) then return "Green" 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: ArkhamDB deck id to be loaded function buildDeck(playerColor, deckId) local configuration = getConfiguration() -- 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, getUiState().private and configuration.private_deck or configuration.public_deck, deckId } local deck = Request.start(deckUri, function(status) if string.find(status.text, "") then debugPrint("Private deck ID " .. deckId .. " is not shared", Priority.ERROR, playerColor) return false, table.concat({ "Private deck ", deckId, " is not shared" }) end local json = JSON.decode(status.text) if not json then debugPrint("Deck ID " .. deckId .. " not found", Priority.ERROR, playerColor) return false, "Deck not found!" end return true, JSON.decode(status.text) end) deck:with(onDeckResult, playerColor, configuration) 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 debugPrint(table.concat({ "[ERROR]", request.uri, ":", request.error_message }), Priority.ERROR) 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