-- Bundled by luabundle {"version":"1.6.0"} local __bundle_require, __bundle_loaded, __bundle_register, __bundle_modules = (function(superRequire) local loadingPlaceholder = {[{}] = true} local register local modules = {} local require local loaded = {} register = function(name, body) if not modules[name] then modules[name] = body end end require = function(name) local loadedModule = loaded[name] if loadedModule then if loadedModule == loadingPlaceholder then return nil end else if not modules[name] then if not superRequire then local identifier = type(name) == 'string' and '\"' .. name .. '\"' or tostring(name) error('Tried to require ' .. identifier .. ', but no such module has been registered') else return superRequire(name) end end loaded[name] = loadingPlaceholder loadedModule = modules[name](require, loaded, register, modules) loaded[name] = loadedModule end return loadedModule end return require, loaded, register, modules end)(nil) __bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules) require("arkhamdb/DeckImporterMain") end) __bundle_register("arkhamdb/ArkhamDb", function(require, _LOADED, __bundle_register, __bundle_modules) do local allCardsBagApi = require("playercards/AllCardsBagApi") local playAreaApi = require("core/PlayAreaApi") local ArkhamDb = {} local internal = {} local tabooList = {} local configuration local RANDOM_WEAKNESS_ID = "01000" ---@class Request local Request = {} -- 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 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 ---@return boolean ---@return string 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 checkCard = allCardsBagApi.getCardById("01001") if (checkCard ~= nil and checkCard.data == nil) then return false, "Indexing not complete" 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, "") then internal.maybePrint("Private deck ID " .. deckId .. " is not shared", playerColor) return false, "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 .. ", card ID " .. cardId, playerColor) else internal.maybePrint("Card not found in ArkhamDB/Index, 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 table 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) -- handles alternative investigators (parallel, promo or revised art) local loadAltInvestigator = "normal" if loadInvestigators then loadAltInvestigator = internal.addInvestigatorCards(deck, slots) end internal.maybeModifyDeckFromDescription(slots, deck.description_md, playerColor) internal.maybeAddSummonedServitor(slots) internal.maybeAddOnTheMend(slots, playerColor) internal.maybeAddRealityAcidReference(slots) local bondList = internal.extractBondedCards(slots) internal.checkTaboos(deck.taboo_id, slots, playerColor) internal.maybeAddUpgradeSheets(slots) -- 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 randomWeaknessAmount = slots[RANDOM_WEAKNESS_ID] or 0 slots[RANDOM_WEAKNESS_ID] = nil if randomWeaknessAmount ~= 0 then for i=1, randomWeaknessAmount do local weaknessId = allCardsBagApi.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 < 90100 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 < 90100 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.maybeAddUpgradeSheets = function(slots) for cardId, _ in pairs(slots) do -- upgrade sheets for customizable cards local upgradesheet = allCardsBagApi.getCardById(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 -- Processes the deck description from ArkhamDB and modifies the slot list accordingly ---@param slots table The slot list for cards in this deck. Table key is the cardId, value is the number ---@param description string The deck desription from ArkhamDB internal.maybeModifyDeckFromDescription = function(slots, description, playerColor) -- check for import instructions local pos = string.find(description, "++SCED import instructions++") if not pos then return end -- remove everything before instructions local tempStr = string.sub(description, pos) -- parse each line in instructions for line in tempStr:gmatch("([^\n]+)") do -- remove dashes at the start line = line:gsub("%- ", "") -- remove spaces line = line:gsub("%s", "") -- remove balanced brackets line = line:gsub("%b()", "") line = line:gsub("%b[]", "") -- get instructor local instructor = "" for word in line:gmatch("%a+:") do instructor = word break end -- go to the next line if no valid instructor found if instructor ~= "add:" and instructor ~= "remove:" then goto nextLine end -- remove instructor from line line = line:gsub(instructor, "") -- evaluate instructions for str in line:gmatch("([^,]+)") do if instructor == "add:" then slots[str] = (slots[str] or 0) + 1 elseif instructor == "remove:" then if slots[str] == nil then internal.maybePrint("Tried to remove card ID " .. str .. ", but didn't find card in deck.", playerColor) else slots[str] = math.max(slots[str] - 1, 0) -- fully remove cards that have a quantity of 0 if slots[str] == 0 then slots[str] = nil -- also remove related minicard slots[str .. "-m"] = nil end end end end -- jump mark at the end of the loop ::nextLine:: 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) -- 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 = allCardsBagApi.getCardById(cardId) if card ~= nil and card.metadata.bonded ~= nil then for _, bond in ipairs(card.metadata.bonded) do -- 'unlimited' upper limit for cards without this data local maxCount = bond.maxCount or 99 -- add a bonded card for each copy of the parent card (until the max value is reached) bondedCards[bond.id] = math.min(bond.count * cardCount, maxCount) -- 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 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 = allCardsBagApi.getCardById(cardId .. "-t") if tabooCard == nil then local basicCard = allCardsBagApi.getCardById(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 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 table ---@param configure fun(request, status) ---@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 table ---@param on_success fun(request, status, vararg) ---@param on_error fun(status)|nil ---@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 whether the resultant data is as expected, and the processed content of the request. ---@param uri table ---@param on_success fun(status, vararg): boolean, any ---@param on_error nil|fun(status, vararg): 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() local results = {} 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 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("playermat/PlaymatApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlaymatApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local searchLib = require("util/SearchLib") -- Convenience function to look up a mat's object by color, or get all mats. ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@return table: Single-element if only single playmat is requested local function getMatForColor(matColor) if matColor == "All" then return guidReferenceApi.getObjectsByType("Playermat") else return { matColor = guidReferenceApi.getObjectByOwnerAndType(matColor, "Playermat") } end end -- Returns the color of the closest playmat ---@param startPos table Starting position to get the closest mat from PlaymatApi.getMatColorByPosition = function(startPos) local result, smallestDistance for matColor, mat in pairs(getMatForColor("All")) do local distance = Vector.between(startPos, mat.getPosition()):magnitude() if smallestDistance == nil or distance < smallestDistance then smallestDistance = distance result = matColor end end return result end -- Returns the color of the player's hand that is seated next to the playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.getPlayerColor = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("playerColor") end end -- Returns the color of the playmat that owns the playercolor's hand ---@param handColor string Color of the playmat PlaymatApi.getMatColor = function(handColor) for matColor, mat in pairs(getMatForColor("All")) do local playerColor = mat.getVar("playerColor") if playerColor == handColor then return matColor end end end -- Returns if there is the card "Dream-Enhancing Serum" on the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.isDES = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("isDES") end end -- Performs a search of the deck area of the requested playmat and returns the result as table ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.getDeckAreaObjects = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("getDeckAreaObjects") end end -- Flips the top card of the deck (useful after deck manipulation for Norman Withers) ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.flipTopCardFromDeck = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("flipTopCardFromDeck") end end -- Returns the position of the discard pile of the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.getDiscardPosition = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("returnGlobalDiscardPosition") end end -- Returns the position of the draw pile of the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.getDrawPosition = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("returnGlobalDrawPosition") end end -- Transforms a local position into a global position ---@param localPos table Local position to be transformed ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.transformLocalPosition = function(localPos, matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.positionToWorld(localPos) end end -- Returns the rotation of the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.returnRotation = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getRotation() end end -- Returns a table with spawn data (position and rotation) for a helper object ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param helperName string Name of the helper object PlaymatApi.getHelperSpawnData = function(matColor, helperName) local resultTable = {} local localPositionTable = { ["Hand Helper"] = {0.05, 0, -1.182}, ["Search Assistant"] = {-0.3, 0, -1.182} } for color, mat in pairs(getMatForColor(matColor)) do resultTable[color] = { position = mat.positionToWorld(localPositionTable[helperName]), rotation = mat.getRotation() } end return resultTable end -- Triggers the Upkeep for the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param playerColor string Color of the calling player (for messages) PlaymatApi.doUpkeepFromHotkey = function(matColor, playerColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("doUpkeepFromHotkey", playerColor) end end -- Handles discarding for the requested playmat for the provided list of objects ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") ---@param objList table List of objects to discard PlaymatApi.discardListOfObjects = function(matColor, objList) for _, mat in pairs(getMatForColor(matColor)) do mat.call("discardListOfObjects", objList) end end -- Returns the active investigator id ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.returnInvestigatorId = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("activeInvestigatorId") end end -- Returns the position for encounter card drawing ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") ---@param stack boolean If true, returns the leftmost position instead of the first empty from the right PlaymatApi.getEncounterCardDrawPosition = function(matColor, stack) for _, mat in pairs(getMatForColor(matColor)) do return Vector(mat.call("getEncounterCardDrawPosition", stack)) end end -- Sets the requested playmat'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 Color of the playmat - White, Orange, Green, Red or All PlaymatApi.setLimitSnapsByType = function(matchCardTypes, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("setLimitSnapsByType", matchCardTypes) end end -- Sets the requested playmat's draw 1 button to visible ---@param isDrawButtonVisible boolean Whether the draw 1 button should be visible or not ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.showDrawButton = function(isDrawButtonVisible, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("showDrawButton", isDrawButtonVisible) end end -- Shows or hides the clickable clue counter for the requested playmat ---@param showCounter boolean Whether the clickable counter should be present or not ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.clickableClues = function(showCounter, matColor) for _, mat in pairs(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 playmat ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.removeClues = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("removeClues") end end -- Reports the clue count for the requested playmat ---@param useClickableCounters boolean Controls which type of counter is getting checked PlaymatApi.getClueCount = function(useClickableCounters, matColor) local count = 0 for _, mat in pairs(getMatForColor(matColor)) do count = count + mat.call("getClueCount", useClickableCounters) end return count end -- Updates the specified owned counter ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param type string Counter to target ---@param newValue number Value to set the counter to ---@param modifier number If newValue is not provided, the existing value will be adjusted by this modifier PlaymatApi.updateCounter = function(matColor, type, newValue, modifier) for _, mat in pairs(getMatForColor(matColor)) do mat.call("updateCounter", { type = type, newValue = newValue, modifier = modifier }) end end -- Triggers the draw function for the specified playmat ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param number number Amount of cards to draw PlaymatApi.drawCardsWithReshuffle = function(matColor, number) for _, mat in pairs(getMatForColor(matColor)) do mat.call("drawCardsWithReshuffle", number) end end -- Returns the resource counter amount ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") ---@param type string Counter to target PlaymatApi.getCounterValue = function(matColor, type) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("getCounterValue", type) end end -- Returns a list of mat colors that have an investigator placed PlaymatApi.getUsedMatColors = function() local localInvestigatorPosition = { x = -1.17, y = 1, z = -0.01 } local usedColors = {} for matColor, mat in pairs(getMatForColor("All")) do local searchPos = mat.positionToWorld(localInvestigatorPosition) local searchResult = searchLib.atPosition(searchPos, "isCardOrDeck") if #searchResult > 0 then table.insert(usedColors, matColor) end end return usedColors end -- Resets the specified skill tracker to "1, 1, 1, 1" ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.resetSkillTracker = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("resetSkillTracker") end end -- Finds all objects on the playmat and associated set aside zone and returns a table ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param filter string Name of the filte function (see util/SearchLib) PlaymatApi.searchAroundPlaymat = function(matColor, filter) local objList = {} for _, mat in pairs(getMatForColor(matColor)) do for _, obj in ipairs(mat.call("searchAroundSelf", filter)) do table.insert(objList, obj) end end return objList end -- Discard a non-hidden card from the corresponding player's hand ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.doDiscardOne = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("doDiscardOne") end end -- Triggers the metadata sync for all playmats PlaymatApi.syncAllCustomizableCards = function() for _, mat in pairs(getMatForColor("All")) do mat.call("syncAllCustomizableCards") 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. -- Blank1: used for assets that start in play (e.g. Duke) -- Tarot, Hand1, Hand2, Ally, Blank4, 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 playmatApi = require("playermat/PlaymatApi") local Zones = { } local commonZones = {} commonZones["Investigator"] = { -1.177, 0, 0.002 } commonZones["Deck"] = { -1.82, 0, 0 } commonZones["Discard"] = { -1.82, 0, 0.61 } commonZones["Ally"] = { -0.615, 0, 0.024 } commonZones["Body"] = { -0.630, 0, 0.553 } commonZones["Hand1"] = { 0.215, 0, 0.042 } commonZones["Hand2"] = { -0.180, 0, 0.037 } commonZones["Arcane1"] = { 0.212, 0, 0.559 } commonZones["Arcane2"] = { -0.171, 0, 0.557 } commonZones["Tarot"] = { 0.602, 0, 0.033 } commonZones["Accessory"] = { 0.602, 0, 0.555 } commonZones["Blank1"] = { 1.758, 0, 0.040 } commonZones["Blank2"] = { 1.754, 0, 0.563 } commonZones["Blank3"] = { 1.371, 0, 0.038 } commonZones["Blank4"] = { 1.371, 0, 0.558 } commonZones["Blank5"] = { 0.98, 0, 0.035 } commonZones["Blank6"] = { 0.977, 0, 0.556 } commonZones["Threat1"] = { -0.911, 0, -0.625 } commonZones["Threat2"] = { -0.454, 0, -0.625 } commonZones["Threat3"] = { 0.002, 0, -0.625 } commonZones["Threat4"] = { 0.459, 0, -0.625 } 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"]["Blank1"] = commonZones["Blank1"] zoneData["White"]["Blank2"] = commonZones["Blank2"] zoneData["White"]["Blank3"] = commonZones["Blank3"] zoneData["White"]["Blank4"] = commonZones["Blank4"] zoneData["White"]["Blank5"] = commonZones["Blank5"] zoneData["White"]["Blank6"] = commonZones["Blank6"] 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.35, 0, -0.520 } zoneData["White"]["SetAside2"] = { 2.35, 0, 0.042 } zoneData["White"]["SetAside3"] = { 2.35, 0, 0.605 } zoneData["White"]["UnderSetAside3"] = { 2.50, 0, 0.805 } zoneData["White"]["SetAside4"] = { 2.78, 0, -0.520 } zoneData["White"]["SetAside5"] = { 2.78, 0, 0.042 } zoneData["White"]["SetAside6"] = { 2.78, 0, 0.605 } zoneData["White"]["UnderSetAside6"] = { 2.93, 0, 0.805 } 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"]["Blank1"] = commonZones["Blank1"] zoneData["Orange"]["Blank2"] = commonZones["Blank2"] zoneData["Orange"]["Blank3"] = commonZones["Blank3"] zoneData["Orange"]["Blank4"] = commonZones["Blank4"] zoneData["Orange"]["Blank5"] = commonZones["Blank5"] zoneData["Orange"]["Blank6"] = commonZones["Blank6"] 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.35, 0, -0.520 } zoneData["Orange"]["SetAside2"] = { -2.35, 0, 0.042} zoneData["Orange"]["SetAside3"] = { -2.35, 0, 0.605 } zoneData["Orange"]["UnderSetAside3"] = { -2.50, 0, 0.805 } zoneData["Orange"]["SetAside4"] = { -2.78, 0, -0.520 } zoneData["Orange"]["SetAside5"] = { -2.78, 0, 0.042 } zoneData["Orange"]["SetAside6"] = { -2.78, 0, 0.605 } zoneData["Orange"]["UnderSetAside6"] = { -2.93, 0, 0.805 } -- 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 string Color name of the player mat to get the zone position for (e.g. "Red") ---@param zoneName string Name of the zone to get the position for. See Zones object documentation for a list of valid zones. ---@return tts__Vector|nil: 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 playmatApi.transformLocalPosition(zoneData[playerColor][zoneName], playerColor) end -- Return the global rotation for a card on the given player mat, based on its zone. ---@param playerColor string Color name of the player mat to get the rotation for (e.g. "Red") ---@param zoneName string Name of the zone. See Zones object documentation for a list of valid zones. ---@return tts__Vector: 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, zoneName) local cardRotation = playmatApi.returnRotation(playerColor) if zoneName == "Deck" then cardRotation = cardRotation + Vector(0, 0, 180) end return cardRotation end return Zones end end) __bundle_register("core/GUIDReferenceApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local GUIDReferenceApi = {} local function getGuidHandler() return getObjectFromGUID("123456") end -- Returns the matching object ---@param owner string Parent object for this search ---@param type string Type of object to search for ---@return any: Object reference to the matching object GUIDReferenceApi.getObjectByOwnerAndType = function(owner, type) return getGuidHandler().call("getObjectByOwnerAndType", { owner = owner, type = type }) end -- Returns all matching objects as a table with references ---@param type string Type of object to search for ---@return table: List of object references to matching objects GUIDReferenceApi.getObjectsByType = function(type) return getGuidHandler().call("getObjectsByType", type) end -- Returns all matching objects as a table with references ---@param owner string Parent object for this search ---@return table: List of object references to matching objects GUIDReferenceApi.getObjectsByOwner = function(owner) return getGuidHandler().call("getObjectsByOwner", owner) end -- Sends new information to the reference handler to edit the main index ---@param owner string Parent of the object ---@param type string Type of the object ---@param guid string GUID of the object GUIDReferenceApi.editIndex = function(owner, type, guid) return getGuidHandler().call("editIndex", { owner = owner, type = type, guid = guid }) end -- Returns the owner of an object or the object it's located on ---@param object tts__GameObject Object for this search ---@return string: Parent of the object or object it's located on GUIDReferenceApi.getOwnerOfObject = function(object) return getGuidHandler().call("getOwnerOfObject", object) end return GUIDReferenceApi end end) __bundle_register("arkhamdb/DeckImporterMain", function(require, _LOADED, __bundle_register, __bundle_modules) require("arkhamdb/DeckImporterUi") require("playercards/PlayerCardSpawner") local allCardsBagApi = require("playercards/AllCardsBagApi") local arkhamDb = require("arkhamdb/ArkhamDb") local playmatApi = require("playermat/PlaymatApi") local zones = require("playermat/Zones") local startsInPlayCount = 0 function onLoad(script_state) initializeUi(JSON.decode(script_state)) math.randomseed(os.time()) 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 Contains card metadata ---@return string Zone 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 bondedList[cardMetadata.id] then 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 startsInPlayTracker() elseif cardMetadata.permanent then return "SetAside1" -- SetAside3 is used for Ancestral Knowledge / Underworld Market else return "Deck" end end function startsInPlayTracker() startsInPlayCount = startsInPlayCount + 1 if startsInPlayCount > 6 then broadcastToAll("Card that should start in play was placed with permanents because no blank slots remained") return "SetAside1" else return "Blank" .. startsInPlayCount end end function buildDeck(playerColor, deckId) local uiState = getUiState() arkhamDb.getDecklist( playerColor, deckId, uiState.privateDeck, 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 table 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 cardsToSpawn = {} -- reset the startsInPlayCount startsInPlayCount = 0 for cardId, cardCount in pairs(slots) do local card = allCardsBagApi.getCardById(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, bondedList, playerColor) handleSpiritDeck(investigatorId, cardsToSpawn, playerColor, customizations) 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):setAt("y", 3) local deckRot = zones.getDefaultCardRotation(playerColor, zone) 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, deckRot, true, 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 tts__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 -- add the "PlayerCard" tag to the deck if deck and deck.type == "Deck" and deck.getQuantity() > 1 then deck.addTag("PlayerCard") 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 card = allCardsBagApi.getCardById(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 Table of {cardData, cardMetadata, zone} ---@return table ZoneNames Table with zoneName as index: {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 not hasAncestralKnowledge then return end 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 -- 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 Illicit cards, 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 not hasMarket then return end 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 -- 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, bondedList, playerColor) if investigatorId ~= "05002" then return end 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 bondedList[card.metadata.id] == 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 -- If the investigator is Parallel Jim Culver, extract all Ally assets to SetAside5 to build the Spirit 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 ---@param customizations table Additional deck information function handleSpiritDeck(investigatorId, cardList, playerColor, customizations) if investigatorId ~= "02004-p" and investigatorId ~= "02004-pb" then return end local spiritList = {} if customizations["extra_deck"] then -- split by "," for str in string.gmatch(customizations["extra_deck"], "([^,]+)") do local card = allCardsBagApi.getCardById(str) if card ~= nil then table.insert(cardList, { data = card.data, metadata = card.metadata, zone = "SetAside5" }) table.insert(spiritList, str) end end else for i, card in ipairs(cardList) do if card.metadata.id == "90053" or ( card.metadata.type == "Asset" and card.metadata.traits ~= nil and string.match(card.metadata.traits, "Ally") and card.metadata.level ~= nil and card.metadata.level < 3) then table.insert(spiritList, i) end end -- Process allies to move them to the spirit 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 = #spiritList, 1, -1 do local moving = cardList[spiritList[i]] moving.zone = "SetAside5" table.remove(cardList, spiritList[i]) table.insert(cardList, moving) end end if #spiritList < 10 then printToAll("Jim's spirit deck must have 9 Ally assets but the deck only has " .. (#spiritList - 1) .. " Ally assets.", playerColor) elseif #spiritList > 11 then printToAll("Moved all " .. (#spiritList - 1) .. " Ally assets to the spirit deck, reduce it to 10 (including Vengeful Shade).", playerColor) else printToAll("Built Jim's spirit deck", playerColor) 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 ArkhamDB data for customizations on customizable cards 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] == nil then selectedUpgrades[1] = { } end 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) 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 = startsInPlayTracker() 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 = startsInPlayTracker() end end end end -- Callback function for investigator cards and minicards to set the correct state for alt art ---@param card tts__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) __bundle_register("arkhamdb/DeckImporterUi", function(require, _LOADED, __bundle_register, __bundle_modules) local allCardsBagApi = require("playercards/AllCardsBagApi") 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 ---@return uiStateTable uiStateTable Contains data about the current UI state function getUiState() return { redDeck = redDeckId, orangeDeck = orangeDeckId, whiteDeck = whiteDeckId, greenDeck = greenDeckId, privateDeck = 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 Table of values to update on importer function setUiState(uiStateTable) self.clearButtons() self.clearInputs() initializeUi(uiStateTable) 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.privateDeck 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 local indexReady = allCardsBagApi.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("playercards/AllCardsBagApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local AllCardsBagApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getAllCardsBag() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "AllCardsBag") end -- Returns a specific card from the bag, based on ArkhamDB ID ---@param id table String ID of the card to retrieve ---@return table table -- If the indexes are still being constructed, an empty table is -- returned. Otherwise, a single table with the following fields -- cardData: TTS object data, suitable for spawning the card -- cardMetadata: Table of parsed metadata AllCardsBagApi.getCardById = function(id) return getAllCardsBag().call("getCardById", {id = id}) end -- Gets a random basic weakness from the bag. Once a given ID has been returned -- it will be removed from the list and cannot be selected again until a reload -- occurs or the indexes are rebuilt, which will refresh the list to include all -- weaknesses. ---@return string: ID of the selected weakness. AllCardsBagApi.getRandomWeaknessId = function() return getAllCardsBag().call("getRandomWeaknessId") end AllCardsBagApi.isIndexReady = function() return getAllCardsBag().call("isIndexReady") end -- Called by Hotfix bags when they load. If we are still loading indexes, then -- the all cards and hotfix bags are being loaded together, and we can ignore -- this call as the hotfix will be included in the initial indexing. If it is -- called once indexing is complete it means the hotfix bag has been added -- later, and we should rebuild the index to integrate the hotfix bag. AllCardsBagApi.rebuildIndexForHotfix = function() return getAllCardsBag().call("rebuildIndexForHotfix") end -- Searches the bag for cards which match the given name and returns a list. Note that this is -- an O(n) search without index support. It may be slow. ---@param name string or string fragment to search for names ---@param exact boolean Whether the name match should be exact AllCardsBagApi.getCardsByName = function(name, exact) return getAllCardsBag().call("getCardsByName", {name = name, exact = exact}) end AllCardsBagApi.isBagPresent = function() return getAllCardsBag() and true end -- Returns a list of cards from the bag matching a class and level (0 or upgraded) ---@param class string class to retrieve ("Guardian", "Seeker", etc) ---@param upgraded boolean true for upgraded cards (Level 1-5), false for Level 0 ---@return table: If the indexes are still being constructed, returns an empty table. -- Otherwise, a list of tables, each with the following fields -- cardData: TTS object data, suitable for spawning the card -- cardMetadata: Table of parsed metadata AllCardsBagApi.getCardsByClassAndLevel = function(class, upgraded) return getAllCardsBag().call("getCardsByClassAndLevel", {class = class, upgraded = upgraded}) end AllCardsBagApi.getCardsByCycle = function(cycle) return getAllCardsBag().call("getCardsByCycle", cycle) end AllCardsBagApi.getUniqueWeaknesses = function() return getAllCardsBag().call("getUniqueWeaknesses") end return AllCardsBagApi 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 table A list of Player Card data structures (data/metadata) ---@param pos tts__Vector table where the cards should be spawned (global) ---@param rot tts__Vector 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, 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 table 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 -- handle sideways card if cardList[1].data.SidewaysCard then rot = { rot.x, rot.y - 90, rot.z } end 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 sideways decks if sidewaysDeck then deck.AltLookAngle = { x = 0, y = 180, z = 90 } rot = { rot.x, rot.y - 90, rot.z } 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 table TTS deck data structure to add to ---@param cardData table 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 deck 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 string 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 number PBCN 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("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlayAreaApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getPlayArea() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "PlayArea") end local function getInvestigatorCounter() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "InvestigatorCounter") end -- Returns the current value of the investigator counter from the playmat ---@return number: Number of investigators currently set on the counter PlayAreaApi.getInvestigatorCount = function() return getInvestigatorCounter().getVar("val") end -- Updates the current value of the investigator counter from the playmat ---@param count number Number of investigators to set on the counter PlayAreaApi.setInvestigatorCount = function(count) getInvestigatorCounter().call("updateVal", 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 string Color of the player requesting the shift for messages PlayAreaApi.shiftContentsUp = function(playerColor) getPlayArea().call("shiftContentsUp", playerColor) end PlayAreaApi.shiftContentsDown = function(playerColor) getPlayArea().call("shiftContentsDown", playerColor) end PlayAreaApi.shiftContentsLeft = function(playerColor) getPlayArea().call("shiftContentsLeft", playerColor) end PlayAreaApi.shiftContentsRight = function(playerColor) getPlayArea().call("shiftContentsRight", playerColor) end ---@param state boolean This controls whether location connections should be drawn PlayAreaApi.setConnectionDrawState = function(state) getPlayArea().call("setConnectionDrawState", state) end ---@param color string Connection color to be used for location connections PlayAreaApi.setConnectionColor = function(color) getPlayArea().call("setConnectionColor", color) end -- Event to be called when the current scenario has changed ---@param scenarioName string Name of the new scenario PlayAreaApi.onScenarioChanged = function(scenarioName) getPlayArea().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 matchCardTypes boolean Whether snap points should only snap for the matching card types PlayAreaApi.setLimitSnapsByType = function(matchCardTypes) getPlayArea().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) getPlayArea().call("tryObjectEnterContainer", { container = container, object = object }) end -- Counts the VP on locations in the play area PlayAreaApi.countVP = function() return getPlayArea().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 getPlayArea().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 getPlayArea().call("countVP", state) end -- Checks if an object is in the play area (returns true or false) PlayAreaApi.isInPlayArea = function(object) return getPlayArea().call("isInPlayArea", object) end -- Returns the current surface of the play area PlayAreaApi.getSurface = function() return getPlayArea().getCustomObject().image end -- Updates the surface of the play area PlayAreaApi.updateSurface = function(url) return getPlayArea().call("updateSurface", url) end -- Returns a deep copy of the currently tracked locations PlayAreaApi.getTrackedLocations = function() local t = {} for k, v in pairs(getPlayArea().call("getTrackedLocations")) do t[k] = v end return t end -- Called by Custom Data Helpers to push their location data into the Data Helper. This adds the -- data to the local token manager instance. ---@param args table Single-value array holding the GUID of the Custom Data Helper making the call PlayAreaApi.updateLocations = function(args) getPlayArea().call("updateLocations", args) end PlayAreaApi.getCustomDataHelper = function() return getPlayArea().getVar("customDataHelper") end return PlayAreaApi end end) __bundle_register("util/SearchLib", function(require, _LOADED, __bundle_register, __bundle_modules) do local SearchLib = {} local filterFunctions = { isActionToken = function(x) return x.getDescription() == "Action Token" end, isCard = function(x) return x.type == "Card" end, isDeck = function(x) return x.type == "Deck" end, isCardOrDeck = function(x) return x.type == "Card" or x.type == "Deck" end, isClue = function(x) return x.memo == "clueDoom" and x.is_face_down == false end, isTileOrToken = function(x) return x.type == "Tile" end } -- performs the actual search and returns a filtered list of object references ---@param pos tts__Vector Global position ---@param rot? tts__Vector Global rotation ---@param size table Size ---@param filter? string Name of the filter function ---@param direction? table Direction (positive is up) ---@param maxDistance? number Distance for the cast local function returnSearchResult(pos, rot, size, filter, direction, maxDistance) local filterFunc if filter then filterFunc = filterFunctions[filter] end local searchResult = Physics.cast({ origin = pos, direction = direction or { 0, 1, 0 }, orientation = rot or { 0, 0, 0 }, type = 3, size = size, max_distance = maxDistance or 0 }) -- filtering the result local objList = {} for _, v in ipairs(searchResult) do if not filter or filterFunc(v.hit_object) then table.insert(objList, v.hit_object) end end return objList end -- searches the specified area SearchLib.inArea = function(pos, rot, size, filter) return returnSearchResult(pos, rot, size, filter) end -- searches the area on an object SearchLib.onObject = function(obj, filter) pos = obj.getPosition() size = obj.getBounds().size:setAt("y", 1) return returnSearchResult(pos, _, size, filter) end -- searches the specified position (a single point) SearchLib.atPosition = function(pos, filter) size = { 0.1, 2, 0.1 } return returnSearchResult(pos, _, size, filter) end -- searches below the specified position (downwards until y = 0) SearchLib.belowPosition = function(pos, filter) direction = { 0, -1, 0 } maxDistance = pos.y return returnSearchResult(pos, _, size, filter, direction, maxDistance) end return SearchLib end end) return __bundle_require("__root")