-- 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("playercards/PlayerCardPanel") 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 -- add a bonded card for each copy of the parent card (except for Pendant of the Queen) if bond.id == "06022" then bondedCards[bond.id] = bond.count else bondedCards[bond.id] = bond.count * cardCount end -- 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("core/GUIDReferenceApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local GUIDReferenceApi = {} local function getGuidHandler() return getObjectFromGUID("123456") end ---@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 return GUIDReferenceApi 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 PlayAreaApi.getSurface = function() return getPlayArea().getCustomObject().image end PlayAreaApi.updateSurface = function(url) return getPlayArea().call("updateSurface", url) 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("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/PlayerCardPanel", function(require, _LOADED, __bundle_register, __bundle_modules) ---@diagnostic disable: param-type-mismatch require("playercards/PlayerCardPanelData") local allCardsBagApi = require("playercards/AllCardsBagApi") local arkhamDb = require("arkhamdb/ArkhamDb") local spawnBag = require("playercards/SpawnBag") -- Size and position information for the three rows of class buttons local CIRCLE_BUTTON_SIZE = 250 local CLASS_BUTTONS_X_OFFSET = 0.1325 local INVESTIGATOR_ROW_START = Vector(0.125, 0.1, -0.447) local LEVEL_ZERO_ROW_START = Vector(0.125, 0.1, -0.007) local UPGRADED_ROW_START = Vector(0.125, 0.1, 0.333) -- Size and position information for the two blocks of other buttons local MISC_BUTTONS_X_OFFSET = 0.155 local WEAKNESS_ROW_START = Vector(0.157, 0.1, 0.666) local OTHER_ROW_START = Vector(0.605, 0.1, 0.666) -- Size and position information for the Cycle (box) buttons local CYCLE_BUTTON_SIZE = 468 local CYCLE_BUTTON_START = Vector(-0.716, 0.1, -0.39) local CYCLE_COLUMN_COUNT = 3 local CYCLE_BUTTONS_X_OFFSET = 0.267 local CYCLE_BUTTONS_Z_OFFSET = 0.2665 local STARTER_DECK_MODE_SELECTED_COLOR = { 0.2, 0.2, 0.2, 0.8 } local TRANSPARENT = { 0, 0, 0, 0 } local STARTER_DECK_MODE_STARTERS = "starters" local STARTER_DECK_MODE_CARDS_ONLY = "cards" local FACE_UP_ROTATION = { x = 0, y = 270, z = 0} local FACE_DOWN_ROTATION = { x = 0, y = 270, z = 180} -- ---------- IMPORTANT ---------- -- Coordinates defined below are in global dimensions relative to the panel - DO NOT USE THESE -- DIRECTLY. Call scalePositions() before use, and reference the variables below -- Layout width for a single card, in global coordinate space local CARD_WIDTH = 2.3 -- Coordinates to begin laying out cards. These vary based on the cards that are being placed by -- considering the width of the cards, number of cards, and desired spread intervals. -- IMPORTANT! Because of the mix of global card sizes and relative-to-scale positions, the X and Y -- coordinates on these provide global disances while the Z is local. local START_POSITIONS = { classCards = Vector(CARD_WIDTH * 9.5, 2, 1.4), investigator = Vector(6 * 2.5, 2, 1.3), cycle = Vector(CARD_WIDTH * 9.5, 2, 2.4), other = Vector(CARD_WIDTH * 9.5, 2, 1.4), randomWeakness = Vector(0, 2, 1.4), -- Because the card spread is handled by the SpawnBag, we don't know (programatically) where this -- should be placed. If more customizable cards are added it will need to be moved. summonedServitor = Vector(CARD_WIDTH * -7.5, 2, 1.7) } -- Shifts to move rows of cards, and groups of rows, as different groupings are laid out local CARD_ROW_OFFSET = 3.7 local CARD_GROUP_OFFSET = 2 -- Position offsets for investigator decks in investigator mode, defines the spacing for how the -- rows and columns are laid out local INVESTIGATOR_POSITION_SHIFT_ROW = Vector(0, 0, 11) local INVESTIGATOR_POSITION_SHIFT_COL = Vector(-6, 0, 0) local INVESTIGATOR_MAX_COLS = 6 -- Positions relative to the minicard to place other stacks. Both signature card piles and starter -- decks use SIGNATURE_OFFSET local INVESTIGATOR_CARD_OFFSET = Vector(0, 0, 2.55) local INVESTIGATOR_SIGNATURE_OFFSET = Vector(0, 0, 5.75) -- USE THESE! Positions and offset shifts accounting for the scale of the panel local startPositions local cardRowOffset local cardGroupOffset local investigatorPositionShiftRow local investigatorPositionShiftCol local investigatorCardOffset local investigatorSignatureOffset local CLASS_LIST = { "Guardian", "Seeker", "Rogue", "Mystic", "Survivor", "Neutral" } local CYCLE_LIST = { "Core", "The Dunwich Legacy", "The Path to Carcosa", "The Forgotten Age", "The Circle Undone", "The Dream-Eaters", "The Innsmouth Conspiracy", "Edge of the Earth", "The Scarlet Keys", "The Feast of Hemlock Vale", "Investigator Packs" } local excludedNonBasicWeaknesses local starterDeckMode = STARTER_DECK_MODE_CARDS_ONLY local helpVisibleToPlayers = { } function onSave() return JSON.encode({ spawnBagState = spawnBag.getStateForSave() }) end function onLoad(savedData) arkhamDb.initialize() if (savedData ~= nil) then local saveState = JSON.decode(savedData) or { } if (saveState.spawnBagState ~= nil) then spawnBag.loadFromSave(saveState.spawnBagState) end end buildExcludedWeaknessList() createButtons() end -- Build a list of non-basic weaknesses which should be excluded from the last weakness set, -- including all signature cards and evolved weaknesses. function buildExcludedWeaknessList() excludedNonBasicWeaknesses = { } for _, investigator in pairs(INVESTIGATORS) do for _, signatureId in ipairs(investigator.signatures) do excludedNonBasicWeaknesses[signatureId] = true end end for _, weaknessId in ipairs(EVOLVED_WEAKNESSES) do excludedNonBasicWeaknesses[weaknessId] = true end end function createButtons() createHelpButton() createInvestigatorButtons() createLevelZeroButtons() createUpgradedButtons() createWeaknessButtons() createOtherButtons() createCycleButtons() createClearButton() -- Create investigator mode buttons last so the indexes are set when we need to update them createInvestigatorModeButtons() end function createHelpButton() self.createButton({ function_owner = self, click_function = "toggleHelp", position = Vector(0.845, 0.1, -0.855), height = 180, width = 180, scale = Vector(0.25, 1, 0.25), color = TRANSPARENT }) end function createInvestigatorButtons() local invButtonParams = { function_owner = self, height = CIRCLE_BUTTON_SIZE, width = CIRCLE_BUTTON_SIZE, scale = Vector(0.25, 1, 0.25), color = TRANSPARENT } local buttonPos = INVESTIGATOR_ROW_START:copy() for _, class in ipairs(CLASS_LIST) do invButtonParams.click_function = "spawnInvestigators" .. class invButtonParams.position = buttonPos self.createButton(invButtonParams) buttonPos.x = buttonPos.x + CLASS_BUTTONS_X_OFFSET self.setVar(invButtonParams.click_function, function(_, _, _) spawnInvestigatorGroup(class) end) end end function createLevelZeroButtons() local l0ButtonParams = { function_owner = self, height = CIRCLE_BUTTON_SIZE, width = CIRCLE_BUTTON_SIZE, scale = Vector(0.25, 1, 0.25), color = TRANSPARENT } local buttonPos = LEVEL_ZERO_ROW_START:copy() for _, class in ipairs(CLASS_LIST) do l0ButtonParams.click_function = "spawnBasic" .. class l0ButtonParams.position = buttonPos self.createButton(l0ButtonParams) buttonPos.x = buttonPos.x + CLASS_BUTTONS_X_OFFSET self.setVar(l0ButtonParams.click_function, function(_, _, _) spawnClassCards(class, false) end) end end function createUpgradedButtons() local upgradedButtonParams = { function_owner = self, height = CIRCLE_BUTTON_SIZE, width = CIRCLE_BUTTON_SIZE, scale = Vector(0.25, 1, 0.25), color = TRANSPARENT } local buttonPos = UPGRADED_ROW_START:copy() for _, class in ipairs(CLASS_LIST) do upgradedButtonParams.click_function = "spawnUpgraded" .. class upgradedButtonParams.position = buttonPos self.createButton(upgradedButtonParams) buttonPos.x = buttonPos.x + CLASS_BUTTONS_X_OFFSET self.setVar(upgradedButtonParams.click_function, function(_, _, _) spawnClassCards(class, true) end) end end function createWeaknessButtons() local weaknessButtonParams = { function_owner = self, height = CIRCLE_BUTTON_SIZE, width = CIRCLE_BUTTON_SIZE, scale = Vector(0.25, 1, 0.25), color = TRANSPARENT } local buttonPos = WEAKNESS_ROW_START:copy() weaknessButtonParams.click_function = "spawnWeaknesses" weaknessButtonParams.tooltip = "All Weaknesses" weaknessButtonParams.position = buttonPos self.createButton(weaknessButtonParams) buttonPos.x = buttonPos.x + MISC_BUTTONS_X_OFFSET weaknessButtonParams.click_function = "spawnRandomWeakness" weaknessButtonParams.tooltip = "Random Basic Weakness" weaknessButtonParams.position = buttonPos self.createButton(weaknessButtonParams) end function createOtherButtons() local otherButtonParams = { function_owner = self, height = CIRCLE_BUTTON_SIZE, width = CIRCLE_BUTTON_SIZE, scale = Vector(0.25, 1, 0.25), color = TRANSPARENT } local buttonPos = OTHER_ROW_START:copy() otherButtonParams.click_function = "spawnBonded" otherButtonParams.tooltip = "Bonded Cards" otherButtonParams.position = buttonPos self.createButton(otherButtonParams) buttonPos.x = buttonPos.x + MISC_BUTTONS_X_OFFSET otherButtonParams.click_function = "spawnUpgradeSheets" otherButtonParams.tooltip = "Customization Upgrade Sheets" otherButtonParams.position = buttonPos self.createButton(otherButtonParams) end function createCycleButtons() local cycleButtonParams = { function_owner = self, height = CYCLE_BUTTON_SIZE, width = CYCLE_BUTTON_SIZE, scale = Vector(0.25, 1, 0.25), color = TRANSPARENT } local buttonPos = CYCLE_BUTTON_START:copy() local rowCount = 0 local colCount = 0 for _, cycle in ipairs(CYCLE_LIST) do cycleButtonParams.click_function = "spawnCycle" .. cycle cycleButtonParams.position = buttonPos cycleButtonParams.tooltip = cycle self.createButton(cycleButtonParams) self.setVar(cycleButtonParams.click_function, function(_, _, _) spawnCycle(cycle) end) colCount = colCount + 1 -- If we've reached the end of a row, shift down and back to the first column if colCount >= CYCLE_COLUMN_COUNT then buttonPos = CYCLE_BUTTON_START:copy() rowCount = rowCount + 1 colCount = 0 buttonPos.z = buttonPos.z + CYCLE_BUTTONS_Z_OFFSET * rowCount if rowCount == 3 then -- Account for two centered buttons on the final row buttonPos.x = buttonPos.x + CYCLE_BUTTONS_X_OFFSET / 2 --[[ Account for centered button on the final row buttonPos.x = buttonPos.x + CYCLE_BUTTONS_X_OFFSET ]] end else buttonPos.x = buttonPos.x + CYCLE_BUTTONS_X_OFFSET end end end function createClearButton() self.createButton({ function_owner = self, click_function = "deleteAll", position = Vector(0, 0.1, 0.852), height = 170, width = 750, scale = Vector(0.25, 1, 0.25), color = TRANSPARENT }) end function createInvestigatorModeButtons() local starterMode = starterDeckMode == STARTER_DECK_MODE_STARTERS self.createButton({ function_owner = self, click_function = "setCardsOnlyMode", position = Vector(0.251, 0.1, -0.322), height = 170, width = 760, scale = Vector(0.25, 1, 0.25), color = starterMode and TRANSPARENT or STARTER_DECK_MODE_SELECTED_COLOR }) self.createButton({ function_owner = self, click_function = "setStarterDeckMode", position = Vector(0.66, 0.1, -0.322), height = 170, width = 760, scale = Vector(0.25, 1, 0.25), color = starterMode and STARTER_DECK_MODE_SELECTED_COLOR or TRANSPARENT }) local checkX = starterMode and 0.52 or 0.11 self.createButton({ function_owner = self, label = "✓", click_function = "doNothing", position = Vector(checkX, 0.11, -0.317), height = 0, width = 0, scale = Vector(0.3, 1, 0.3), font_color = { 0, 0, 0 }, color = { 1, 1, 1 } }) end function toggleHelp(_, playerColor, _) if helpVisibleToPlayers[playerColor] then helpVisibleToPlayers[playerColor] = nil else helpVisibleToPlayers[playerColor] = true end updateHelpVisibility() end function updateHelpVisibility() local visibility = "" for player, _ in pairs(helpVisibleToPlayers) do if string.len(visibility) > 0 then visibility = visibility .. "|" .. player else visibility = player end end self.UI.setAttribute("helpText", "visibility", visibility) self.UI.setAttribute("helpPanel", "visibility", visibility) self.UI.setAttribute("helpPanel", "active", string.len(visibility) > 0) end function setStarterDeckMode() starterDeckMode = STARTER_DECK_MODE_STARTERS updateStarterModeButtons() end function setCardsOnlyMode() starterDeckMode = STARTER_DECK_MODE_CARDS_ONLY updateStarterModeButtons() end function updateStarterModeButtons() local buttonCount = #self.getButtons() -- Buttons are 0-indexed, so the last three are -1, -2, and -3 from the size self.removeButton(buttonCount - 1) self.removeButton(buttonCount - 2) self.removeButton(buttonCount - 3) createInvestigatorModeButtons() end -- Clears the table and updates positions based on scale (should be called before ANY card placement) function prepareToPlaceCards() deleteAll() scalePositions() end -- Updates the positions based on the current object scale to ensure the relative layout functions -- properly at different scales. function scalePositions() -- Assume scaling is consistent in X and Z dimensions local scale = 1 / self.getScale().x startPositions = { } for key, pos in pairs(START_POSITIONS) do -- Because a scaled object means a different global size, using global distance for Z results in -- the cards being closer or farther depending on the scale. Leave the Z values and only scale X and Y startPositions[key] = Vector(pos) startPositions[key].x = startPositions[key].x * scale startPositions[key].y = startPositions[key].y * scale end cardRowOffset = CARD_ROW_OFFSET * scale cardGroupOffset = CARD_GROUP_OFFSET * scale investigatorPositionShiftRow = Vector(INVESTIGATOR_POSITION_SHIFT_ROW):scale(scale) investigatorPositionShiftCol = Vector(INVESTIGATOR_POSITION_SHIFT_COL):scale(scale) investigatorCardOffset = Vector(INVESTIGATOR_CARD_OFFSET):scale(scale) investigatorSignatureOffset = Vector(INVESTIGATOR_SIGNATURE_OFFSET):scale(scale) end -- Deletes all cards currently placed on the table function deleteAll() spawnBag.recall(true) end -- Spawn an investigator group, based on the current UI setting for either investigators or starter -- decks. ---@param groupName string Name of the group to spawn, matching a key in InvestigatorPanelData function spawnInvestigatorGroup(groupName) local starterMode = starterDeckMode == STARTER_DECK_MODE_STARTERS prepareToPlaceCards() Wait.frames(function() if starterMode then spawnStarters(groupName) else spawnInvestigators(groupName) end end, 2) end -- Spawn cards for all investigators in the given group. This creates piles for all defined -- investigator cards and minicards as well as the signature cards. ---@param groupName string Name of the group to spawn, matching a key in InvestigatorPanelData function spawnInvestigators(groupName) if INVESTIGATOR_GROUPS[groupName] == nil then printToAll("No " .. groupName .. " data yet") return end local col = 1 local row = 1 local investigatorCount = #INVESTIGATOR_GROUPS[groupName] local position = getInvestigatorRowStartPos(investigatorCount, row) for i, investigatorName in ipairs(INVESTIGATOR_GROUPS[groupName]) do for _, spawnSpec in ipairs(buildInvestigatorSpawnSpec(investigatorName, INVESTIGATORS[investigatorName], position)) do spawnBag.spawn(spawnSpec) end position:add(investigatorPositionShiftCol) col = col + 1 if col > INVESTIGATOR_MAX_COLS then col = 1 row = row + 1 position = getInvestigatorRowStartPos(investigatorCount, row) end end end function getInvestigatorRowStartPos(investigatorCount, row) local rowStart = Vector(startPositions.investigator) rowStart:add(Vector( investigatorPositionShiftRow.x * (row - 1), investigatorPositionShiftRow.y * (row - 1), investigatorPositionShiftRow.z * (row - 1))) local investigatorsInRow = math.min(investigatorCount - INVESTIGATOR_MAX_COLS * (row - 1), INVESTIGATOR_MAX_COLS) rowStart:add(Vector( investigatorPositionShiftCol.x * (INVESTIGATOR_MAX_COLS - investigatorsInRow) / 2, investigatorPositionShiftCol.y * (INVESTIGATOR_MAX_COLS - investigatorsInRow) / 2, investigatorPositionShiftCol.z * (INVESTIGATOR_MAX_COLS - investigatorsInRow) / 2)) return rowStart end -- Creates the spawn spec for the investigator's signature cards. ---@param investigatorName string Name of the investigator, matching a key in InvestigatorPanelData ---@param investigatorData table Spawn definition for the investigator, retrieved from INVESTIGATORS ---@param position tts__Vector Where to spawn the minicard; investigagor cards will be placed below function buildInvestigatorSpawnSpec(investigatorName, investigatorData, position) local sigPos = Vector(position):add(investigatorSignatureOffset) local spawns = buildCommonSpawnSpec(investigatorName, investigatorData, position) table.insert(spawns, { name = investigatorName .. "signatures", cards = investigatorData.signatures, globalPos = self.positionToWorld(sigPos), rotation = FACE_UP_ROTATION }) return spawns end -- Builds the spawn specs for minicards and investigator cards. These are common enough to be -- shared, and will only differ in whether they spawn the full stack of possible investigator and -- minicards, or only the first of each. ---@param investigatorName string Name of the investigator, matching a key in InvestigatorPanelData ---@param investigatorData table Spawn definition for the investigator, retrieved from INVESTIGATORS ---@param position tts__Vector Where to spawn the minicard; investigagor cards will be placed below ---@param oneCardOnly? boolean If true, will spawn only the first card in the investigator card --- and minicard lists. Otherwise, spawn them all in a deck function buildCommonSpawnSpec(investigatorName, investigatorData, position, oneCardOnly) local cardPos = Vector(position):add(investigatorCardOffset) return { { name = investigatorName .. "minicards", cards = oneCardOnly and { investigatorData.minicards[1] } or investigatorData.minicards, globalPos = self.positionToWorld(position), rotation = FACE_UP_ROTATION }, { name = investigatorName .. "cards", cards = oneCardOnly and { investigatorData.cards[1] } or investigatorData.cards, globalPos = self.positionToWorld(cardPos), rotation = FACE_UP_ROTATION } } end -- Spawns all starter decks (single minicard and investigator card, plus the starter deck) for -- investigators in the given group. ---@param groupName string Name of the group to spawn, matching a key in InvestigatorPanelData function spawnStarters(groupName) local col = 1 local row = 1 local investigatorCount = #INVESTIGATOR_GROUPS[groupName] local position = getInvestigatorRowStartPos(investigatorCount, row) for _, investigatorName in ipairs(INVESTIGATOR_GROUPS[groupName]) do spawnStarterDeck(investigatorName, INVESTIGATORS[investigatorName], position) position:add(investigatorPositionShiftCol) col = col + 1 if col > INVESTIGATOR_MAX_COLS then col = 1 row = row + 1 position = getInvestigatorRowStartPos(investigatorCount, row) end end end -- Spawns the defined starter deck for the given investigator's. ---@param investigatorName string Name of the investigator, matching a key in InvestigatorPanelData function spawnStarterDeck(investigatorName, investigatorData, position) for _, spawnSpec in ipairs(buildCommonSpawnSpec(investigatorName, investigatorData, position, true)) do spawnBag.spawn(spawnSpec) end local deckPos = Vector(position):add(investigatorSignatureOffset) arkhamDb.getDecklist("None", investigatorData.starterDeck, true, false, false, function(slots) local cardIdList = { } for id, count in pairs(slots) do for i = 1, count do table.insert(cardIdList, id) end end spawnBag.spawn({ name = investigatorName.."starter", cards = cardIdList, globalPos = self.positionToWorld(deckPos), rotation = FACE_DOWN_ROTATION }) end) end -- Clears the currently placed cards, then places cards for the given class and level spread ---@param cardClass string Class to place ("Guardian", "Seeker", etc) ---@param isUpgraded boolean If true, spawn the Level 1-5 cards. Otherwise, Level 0. function spawnClassCards(cardClass, isUpgraded) prepareToPlaceCards() Wait.frames(function() placeClassCards(cardClass, isUpgraded) end, 2) end -- Spawn the class cards. ---@param cardClass string Class to place ("Guardian", "Seeker", etc) ---@param isUpgraded boolean If true, spawn the Level 1-5 cards. Otherwise, Level 0. function placeClassCards(cardClass, isUpgraded) local 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 local cardIdList = allCardsBagApi.getCardsByClassAndLevel(cardClass, isUpgraded) local skillList = { } local eventList = { } local assetList = { } for _, cardId in ipairs(cardIdList) do local cardMetadata = allCardsBagApi.getCardById(cardId).metadata if (cardMetadata.type == "Skill") then table.insert(skillList, cardId) elseif (cardMetadata.type == "Event") then table.insert(eventList, cardId) elseif (cardMetadata.type == "Asset") then table.insert(assetList, cardId) end end local groupPos = Vector(startPositions.classCards) if #skillList > 0 then spawnBag.spawn({ name = cardClass .. (isUpgraded and "upgraded" or "basic"), cards = skillList, globalPos = self.positionToWorld(groupPos), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) groupPos.z = groupPos.z + math.ceil(#skillList / 20) * cardRowOffset + cardGroupOffset end if #eventList > 0 then spawnBag.spawn({ name = cardClass .. "event" .. (isUpgraded and "upgraded" or "basic"), cards = eventList, globalPos = self.positionToWorld(groupPos), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) groupPos.z = groupPos.z + math.ceil(#eventList / 20) * cardRowOffset + cardGroupOffset end if #assetList > 0 then spawnBag.spawn({ name = cardClass .. "asset" .. (isUpgraded and "upgraded" or "basic"), cards = assetList, globalPos = self.positionToWorld(groupPos), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) end end -- Spawns the investigator sets and all cards for the given cycle ---@param cycle string Name of a cycle, should match the standard used in card metadata function spawnCycle(cycle) prepareToPlaceCards() spawnInvestigators(cycle) local 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 local cycleCardList = allCardsBagApi.getCardsByCycle(cycle) local copiedList = { } for i, id in ipairs(cycleCardList) do copiedList[i] = id end spawnBag.spawn({ name = "cycle"..cycle, cards = copiedList, globalPos = self.positionToWorld(startPositions.cycle), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) end function spawnBonded() prepareToPlaceCards() spawnBag.spawn({ name = "bonded", cards = BONDED_CARD_LIST, globalPos = self.positionToWorld(startPositions.classCards), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) end function spawnUpgradeSheets() prepareToPlaceCards() spawnBag.spawn({ name = "upgradeSheets", cards = UPGRADE_SHEET_LIST, globalPos = self.positionToWorld(startPositions.classCards), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) spawnBag.spawn({ name = "servitor", cards = { "09080-m" }, globalPos = self.positionToWorld(startPositions.summonedServitor), rotation = FACE_UP_ROTATION, }) end -- Clears the current cards, and places all basic weaknesses on the table. function spawnWeaknesses() prepareToPlaceCards() local 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 local weaknessIdList = allCardsBagApi.getUniqueWeaknesses() local basicWeaknessList = { } local otherWeaknessList = { } for i, id in ipairs(weaknessIdList) do local cardMetadata = allCardsBagApi.getCardById(id).metadata if cardMetadata.basicWeaknessCount ~= nil and cardMetadata.basicWeaknessCount > 0 then table.insert(basicWeaknessList, id) elseif excludedNonBasicWeaknesses[id] == nil then table.insert(otherWeaknessList, id) end end local groupPos = Vector(startPositions.classCards) spawnBag.spawn({ name = "basicWeaknesses", cards = basicWeaknessList, globalPos = self.positionToWorld(groupPos), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) groupPos.z = groupPos.z + math.ceil(#basicWeaknessList / 20) * cardRowOffset + cardGroupOffset spawnBag.spawn({ name = "evolvedWeaknesses", cards = EVOLVED_WEAKNESSES, globalPos = self.positionToWorld(groupPos), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) groupPos.z = groupPos.z + math.ceil(#EVOLVED_WEAKNESSES / 20) * cardRowOffset + cardGroupOffset spawnBag.spawn({ name = "otherWeaknesses", cards = otherWeaknessList, globalPos = self.positionToWorld(groupPos), rotation = FACE_UP_ROTATION, spread = true, spreadCols = 20 }) end function spawnRandomWeakness() prepareToPlaceCards() local weaknessId = allCardsBagApi.getRandomWeaknessId() if (weaknessId == nil) then broadcastToAll("All basic weaknesses are in play!", {0.9, 0.2, 0.2}) return end spawnBag.spawn({ name = "randomWeakness", cards = { weaknessId }, globalPos = self.positionToWorld(startPositions.randomWeakness), rotation = FACE_UP_ROTATION, }) end end) __bundle_register("playercards/PlayerCardPanelData", function(require, _LOADED, __bundle_register, __bundle_modules) BONDED_CARD_LIST = { "05314", -- Soothing Melody "06277", -- Wish Eater "06019", -- Bloodlust "06022", -- Pendant of the Queen "05317", -- Blood-rite "06113", -- Essence of the Dream "06028", -- Stars Are Right "06025", -- Guardian of the Crystallizer "06283", -- Unbound Beast "06032", -- Zeal "06031", -- Hope "06033", -- Augur "06331", -- Dream Parasite "06015a", -- Dream-Gate "10006", -- Aetheric Current (Yuggoth) "10007", -- Aetheric Current (Yoth) "10036", -- Blade of Yoth "10039", -- Evanescent Ascension "10045", -- Uncanny Growth "10063", -- Bianca "10086", -- Rot "10087", -- Rot "10088", -- Rot "10089", -- Rot "10090", -- Rot "10106", -- Keeper of the Key "10107", -- Servant of Brass "10134", -- Twilight Diadem } UPGRADE_SHEET_LIST = { "09040-c", -- Alchemical Distillation "09023-c", -- Custom Modifications "09059-c", -- Damning Testimony "09041-c", -- Emperical Hypothesis "09060-c", -- Friends in Low Places "09101-c", -- Grizzled "09061-c", -- Honed Instinct "09021-c", -- Hunter's Armor "09119-c", -- Hyperphysical Shotcaster "09079-c", -- Living Ink "09100-c", -- Makeshift Trap "09099-c", -- Pocket Multi Tool "09081-c", -- Power Word "09081-t-c", -- Power Word (Taboo) "09022-c", -- Runic Axe "09022-t-c", -- Runic Axe (Taboo) "09080-c", -- Summoned Servitor "09042-c", -- Raven's Quill } EVOLVED_WEAKNESSES = { "04039", "04041", "04042", "53014", "53015", } ------------------ START INVESTIGATOR DATA DEFINITION ------------------ INVESTIGATOR_GROUPS = { ["Guardian"] = { "Roland Banks", "Zoey Samaras", "Mark Harrigan", "Leo Anderson", "Carolyn Fern", "Tommy Muldoon", "Nathaniel Cho", "Sister Mary", "Daniela Reyes", "Carson Sinclair", "Wilson Richards" }, ["Seeker"] = { "Daisy Walker", "Rex Murphy", "Minh Thi Phan", "Ursula Downs", "Joe Diamond", "Mandy Thompson", "Harvey Walters", "Amanda Sharpe", "Norman Withers", "Vincent Lee", "Kate Winthrop" }, ["Rogue"] = { "\"Skids\" O'Toole", "Jenny Barnes", "Sefina Rousseau", "Finn Edwards", "Preston Fairmont", "Tony Morgan", "Winifred Habbamock", "Trish Scarborough", "Monterey Jack", "Kymani Jones", "Alessandra Zorzi" }, ["Mystic"] = { "Agnes Baker", "Jim Culver", "Akachi Onyele", "Father Mateo", "Diana Stanley", "Marie Lambeau", "Luke Robinson", "Jacqueline Fine", "Dexter Drake", "Lily Chen", "Amina Zidane", "Gloria Goldberg", "Kōhaku Narukami" }, ["Survivor"] = { "Wendy Adams", "\"Ashcan\" Pete", "William Yorick", "Calvin Wright", "Rita Young", "Patrice Hathaway", "Stella Clark", "Silas Marsh", "Bob Jenkins", "Darrell Simmons", "Hank Samson" }, ["Neutral"] = { "Lola Hayes", "Charlie Kane", "Subject 5U-21" }, ["Core"] = { "Roland Banks", "Daisy Walker", "\"Skids\" O'Toole", "Agnes Baker", "Wendy Adams" }, ["The Dunwich Legacy"] = { "Zoey Samaras", "Rex Murphy", "Jenny Barnes", "Jim Culver", "\"Ashcan\" Pete" }, ["The Path to Carcosa"] = { "Mark Harrigan", "Minh Thi Phan", "Sefina Rousseau", "Akachi Onyele", "William Yorick", "Lola Hayes" }, ["The Forgotten Age"] = { "Leo Anderson", "Ursula Downs", "Finn Edwards", "Father Mateo", "Calvin Wright" }, ["The Circle Undone"] = { "Carolyn Fern", "Joe Diamond", "Preston Fairmont", "Diana Stanley", "Rita Young", "Marie Lambeau" }, ["The Dream-Eaters"] = { "Tommy Muldoon", "Mandy Thompson", "Tony Morgan", "Luke Robinson", "Patrice Hathaway" }, ["Investigator Packs"] = { "Nathaniel Cho", "Harvey Walters", "Winifred Habbamock", "Jacqueline Fine", "Stella Clark", "Gloria Goldberg" }, ["The Innsmouth Conspiracy"] = { "Sister Mary", "Amanda Sharpe", "Trish Scarborough", "Dexter Drake", "Silas Marsh" }, ["Edge of the Earth"] = { "Daniela Reyes", "Norman Withers", "Monterey Jack", "Lily Chen", "Bob Jenkins" }, ["The Scarlet Keys"] = { "Carson Sinclair", "Vincent Lee", "Kymani Jones", "Amina Zidane", "Darrell Simmons", "Charlie Kane" }, ["The Feast of Hemlock Vale"] = { "Wilson Richards", "Kate Winthrop", "Alessandra Zorzi", "Kōhaku Narukami", "Hank Samson" } } INVESTIGATORS = {} -- Core Box INVESTIGATORS["Roland Banks"] = { cards = { "01001", "01001-p", "01001-pf", "01001-pb" }, minicards = { "01001-m" }, signatures = { "01006", "01007", "90030", "90031", "90025", "90026", "90027", "90028", "90029", "98005", "98006" }, starterDeck = "2624931" } INVESTIGATORS["Daisy Walker"] = { cards = { "01002", "01002-p", "01002-pf", "01002-pb" }, minicards = { "01002-m" }, signatures = { "01008", "01009", "90002", "90003" }, starterDeck = "2624938" } INVESTIGATORS["\"Skids\" O'Toole"] = { cards = { "01003", "01003-p", "01003-pf", "01003-pb" }, minicards = { "01003-m" }, signatures = { "01010", "01011", "90009", "90010" }, starterDeck = "2624940" } INVESTIGATORS["Agnes Baker"] = { cards = { "01004", "01004-p", "01004-pf", "01004-pb" }, minicards = { "01004-m" }, signatures = { "01012", "01013", "90018", "90019" }, starterDeck = "2624944" } INVESTIGATORS["Wendy Adams"] = { cards = { "01005", "01005-p", "01005-pf", "01005-pb" }, minicards = { "01005-m" }, signatures = { "01014", "01015", "01515", "90039", "90040", "90038" }, starterDeck = "2624949" } -- The Dunwich Legacy INVESTIGATORS["Zoey Samaras"] = { cards = { "02001", "02001-p", "02001-pf", "02001-pb" }, minicards = { "02001-m" }, signatures = { "02006", "02007", "90060", "90061" }, starterDeck = "2624950" } INVESTIGATORS["Rex Murphy"] = { cards = { "02002", "02002-t" }, minicards = { "02002-m" }, signatures = { "02008", "02009" }, starterDeck = "2624958" } INVESTIGATORS["Jenny Barnes"] = { cards = { "02003" }, minicards = { "02003-m" }, signatures = { "02010", "02011", "98002", "98003" }, starterDeck = "2624961" } INVESTIGATORS["Jim Culver"] = { cards = { "02004", "02004-p", "02004-pf", "02004-pb" }, minicards = { "02004-m" }, signatures = { "02012", "02013", "90050", "90051", "90052", "90053" }, starterDeck = "2624965" } INVESTIGATORS["\"Ashcan\" Pete"] = { cards = { "02005", "02005-p", "02005-pf", "02005-pb" }, minicards = { "02005-m" }, signatures = { "02014", "02015", "90047", "90048" }, starterDeck = "2624969" } -- The Path to Carcosa INVESTIGATORS["Mark Harrigan"] = { cards = { "03001" }, minicards = { "03001-m" }, signatures = { "03007", "03008", "03009" }, starterDeck = "2624975" } INVESTIGATORS["Minh Thi Phan"] = { cards = { "03002" }, minicards = { "03002-m" }, signatures = { "03010", "03011" }, starterDeck = "2624979" } INVESTIGATORS["Sefina Rousseau"] = { cards = { "03003" }, minicards = { "03003-m" }, signatures = { "03012", "03012", "03012", "03013" }, starterDeck = "2624981" } INVESTIGATORS["Akachi Onyele"] = { cards = { "03004" }, minicards = { "03004-m" }, signatures = { "03014", "03015" }, starterDeck = "2624984" } INVESTIGATORS["William Yorick"] = { cards = { "03005" }, minicards = { "03005-m" }, signatures = { "03016", "03017" }, starterDeck = "2624988" } INVESTIGATORS["Lola Hayes"] = { cards = { "03006", "03006-t" }, minicards = { "03006-m" }, signatures = { "03018", "03018", "03019", "03019", "03019-t", "03019-t" }, starterDeck = "2624990" } -- The Forgotten Age INVESTIGATORS["Leo Anderson"] = { cards = { "04001" }, minicards = { "04001-m" }, signatures = { "04006", "04007" }, starterDeck = "2624994" } INVESTIGATORS["Ursula Downs"] = { cards = { "04002" }, minicards = { "04002-m" }, signatures = { "04008", "04009" }, starterDeck = "2625000" } INVESTIGATORS["Finn Edwards"] = { cards = { "04003" }, minicards = { "04003-m" }, signatures = { "04010", "04011", "04012" }, starterDeck = "2625003" } INVESTIGATORS["Father Mateo"] = { cards = { "04004" }, minicards = { "04004-m" }, signatures = { "04013", "04014" }, starterDeck = "2625005" } INVESTIGATORS["Calvin Wright"] = { cards = { "04005" }, minicards = { "04005-m" }, signatures = { "04015", "04016" }, starterDeck = "2625007" } -- The Circle Undone INVESTIGATORS["Carolyn Fern"] = { cards = { "05001" }, minicards = { "05001-m" }, signatures = { "05007", "05008", "98011", "98012" }, starterDeck = "2626342" } INVESTIGATORS["Joe Diamond"] = { cards = { "05002" }, minicards = { "05002-m" }, signatures = { "05009", "05010" }, starterDeck = "2626347" } INVESTIGATORS["Preston Fairmont"] = { cards = { "05003" }, minicards = { "05003-m" }, signatures = { "05011", "05012" }, starterDeck = "2626365" } INVESTIGATORS["Diana Stanley"] = { cards = { "05004" }, minicards = { "05004-m" }, signatures = { "05013", "05014", "05015" }, starterDeck = "2626385" } INVESTIGATORS["Rita Young"] = { cards = { "05005" }, minicards = { "05005-m" }, signatures = { "05016", "05017" }, starterDeck = "2626387" } INVESTIGATORS["Marie Lambeau"] = { cards = { "05006" }, minicards = { "05006-m" }, signatures = { "05018", "05019" }, starterDeck = "2626395" } -- The Dream-Eaters INVESTIGATORS["Tommy Muldoon"] = { cards = { "06001" }, minicards = { "06001-m" }, signatures = { "06006", "06007" }, starterDeck = "2626402" } INVESTIGATORS["Mandy Thompson"] = { cards = { "06002", "06002-t" }, minicards = { "06002-m" }, signatures = { "06008", "06008", "06008", "06009" }, starterDeck = "2626410" } INVESTIGATORS["Tony Morgan"] = { cards = { "06003" }, minicards = { "06003-m" }, signatures = { "06010", "06011", "06011", "06012" }, starterDeck = "2626446" } INVESTIGATORS["Luke Robinson"] = { cards = { "06004" }, minicards = { "06004-m" }, signatures = { "06013", "06014", "06015" }, starterDeck = "2626452" } INVESTIGATORS["Patrice Hathaway"] = { cards = { "06005" }, minicards = { "06005-m" }, signatures = { "06016", "06017" }, starterDeck = "2626461" } -- Starter Decks INVESTIGATORS["Nathaniel Cho"] = { cards = { "60101" }, minicards = { "60101-m" }, signatures = { "60102", "60103" }, starterDeck = "2643925" } INVESTIGATORS["Harvey Walters"] = { cards = { "60201" }, minicards = { "60201-m" }, signatures = { "60202", "60203" }, starterDeck = "2643928" } INVESTIGATORS["Winifred Habbamock"] = { cards = { "60301" }, minicards = { "60301-m" }, signatures = { "60302", "60303" }, starterDeck = "2643931" } INVESTIGATORS["Jacqueline Fine"] = { cards = { "60401" }, minicards = { "60401-m" }, signatures = { "60402", "60403" }, starterDeck = "2643932" } INVESTIGATORS["Stella Clark"] = { cards = { "60501" }, minicards = { "60501-m" }, signatures = { "60502", "60502", "60502", "60503" }, starterDeck = "2643934" } -- The Innsmouth Conspiracy INVESTIGATORS["Sister Mary"] = { cards = { "07001" }, minicards = { "07001-m" }, signatures = { "07006", "07007" }, starterDeck = "2626464" } INVESTIGATORS["Amanda Sharpe"] = { cards = { "07002" }, minicards = { "07002-m" }, signatures = { "07008", "07009" }, starterDeck = "2626469" } INVESTIGATORS["Trish Scarborough"] = { cards = { "07003", "07003-t" }, minicards = { "07003-m" }, signatures = { "07010", "07011" }, starterDeck = "2626479" } INVESTIGATORS["Dexter Drake"] = { cards = { "07004" }, minicards = { "07004-m" }, signatures = { "07012", "07013", "98017", "98018" }, starterDeck = "2626672" } INVESTIGATORS["Silas Marsh"] = { cards = { "07005" }, minicards = { "07005-m" }, signatures = { "07014", "07015", "07016", "98014", "98015" }, starterDeck = "2626685" } -- Edge of the Earth INVESTIGATORS["Daniela Reyes"] = { cards = { "08001" }, minicards = { "08001-m" }, signatures = { "08002", "08003" }, starterDeck = "2634588" } INVESTIGATORS["Norman Withers"] = { cards = { "08004" }, minicards = { "08004-m" }, signatures = { "08005", "08006", "98008", "98009" }, starterDeck = "2634603" } INVESTIGATORS["Monterey Jack"] = { cards = { "08007", "08007-p", "08007-pf", "08007-pb" }, minicards = { "08007-m" }, signatures = { "08008", "08009", "90063", "90064" }, starterDeck = "2634652" } INVESTIGATORS["Lily Chen"] = { cards = { "08010" }, minicards = { "08010-m" }, signatures = { "08011a", "08012a", "08013a", "08014a", "08015", "08015", "08015", "08015" }, starterDeck = "2634658" } INVESTIGATORS["Bob Jenkins"] = { cards = { "08016" }, minicards = { "08016-m" }, signatures = { "08017", "08018" }, starterDeck = "2634643" } -- The Scarlet Keys INVESTIGATORS["Carson Sinclair"] = { cards = { "09001" }, minicards = { "09001-m" }, signatures = { "09002", "09002", "09003" }, starterDeck = "2634667" } INVESTIGATORS["Vincent Lee"] = { cards = { "09004" }, minicards = { "09004-m" }, signatures = { "09005", "09006", "09006", "09006", "09006", "09007" }, starterDeck = "2634675" } INVESTIGATORS["Kymani Jones"] = { cards = { "09008" }, minicards = { "09008-m" }, signatures = { "09009", "09010" }, starterDeck = "2634701" } INVESTIGATORS["Amina Zidane"] = { cards = { "09011" }, minicards = { "09011-m" }, signatures = { "09012", "09013", "09014" }, starterDeck = "2634697" } INVESTIGATORS["Darrell Simmons"] = { cards = { "09015" }, minicards = { "09015-m" }, signatures = { "09016", "09017" }, starterDeck = "2634757" } INVESTIGATORS["Charlie Kane"] = { cards = { "09018" }, minicards = { "09018-m" }, signatures = { "09019", "09020" }, starterDeck = "2634706" } -- The Feast of Hemlock Vale INVESTIGATORS["Wilson Richards"] = { cards = { "10001" }, minicards = { "10001-m" }, signatures = { "10002", "10003" }, starterDeck = "2634667" --carson deck as placeholder } INVESTIGATORS["Kate Winthrop"] = { cards = { "10004" }, minicards = { "10004-m" }, signatures = { "10005", "10006", "10007", "10008" }, starterDeck = "2643928" --harvey deck as placeholder } INVESTIGATORS["Alessandra Zorzi"] = { cards = { "10009" }, minicards = { "10009-m" }, signatures = { "10010", "10010", "10010", "10011" }, starterDeck = "2643931" --winifred deck as placeholder } INVESTIGATORS["Kōhaku Narukami"] = { cards = { "10012" }, minicards = { "10012-m" }, signatures = { "10013", "10014" }, starterDeck = "2636199" --gloria deck as placeholder } INVESTIGATORS["Hank Samson"] = { cards = { "10015", "10015-b1", "10015-b2" }, minicards = { "10015-m" }, signatures = { "10017", "10018"}, starterDeck = "2643934" --stella deck as placeholder } -- PnP content INVESTIGATORS["Subject 5U-21"] = { cards = { "89001" }, minicards = { "89001-m" }, signatures = { "89002", "89003", "89003", "89003", "89004", "89004", "89004", "89005" }, starterDeck = "2624990" -- Lola's deck id until Suzi is on ArkhamDB } -- Promo content INVESTIGATORS["Gloria Goldberg"] = { cards = { "98019" }, minicards = { "98019-m" }, signatures = { "98020", "98021" }, starterDeck = "2636199" } ------------------ END INVESTIGATOR DATA DEFINITION ------------------ 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("playercards/SpawnBag", function(require, _LOADED, __bundle_register, __bundle_modules) require("playercards/PlayerCardSpawner") -- Allows spawning of defined lists of cards which will be created from the template in the All -- Player Cards bag. SpawnBag.spawn will create objects based on a table definition, while -- SpawnBag.recall will clean them all up. Recall will be limited to a small area around the -- spawned objects. Objects moved out of this area will not be cleaned up. -- -- SpawnSpec: Spawning requires a spawn specification with the following structure: -- { -- name: Name of this spawn content, used for internal tracking. Multiple specs can be spawned, -- but each requires a separate name -- cards: A list of card IDs to be spawned -- globalPos: Where the spawned objects should be placed, in global coordinates. This should be -- a valid Vector with x, y, and z defined, e.g. { x = 5, y = 1, z = 15 } -- rotation: Rotation for the spawned objects. X=180 should be used for face down items. As with -- globalPos, this should be a valid Vector with x, y, and z defined -- spread: Optional Boolean. If present and true, cards will be spawned next to each other in a -- spread moving to the right. globalPos will define the location of the first card, each -- after that will be moved a predefined distance -- spreadCols: Optional integer. If spread is true, specifies the maximum columns cards will be -- laid out in before starting a new row. If spread is true but spreadCols is not set, all -- cards will be in a single row (however long that may be) -- } -- See BondedBag.ttslua for an example do local allCardsBagApi = require("playercards/AllCardsBagApi") local SpawnBag = { } local internal = { } -- To assist debugging, will draw a box around the recall zone when it's set up local SHOW_RECALL_ZONE = false -- Distance to expand the recall zone around any added object. local RECALL_BUFFER_X = 0.9 local RECALL_BUFFER_Z = 0.5 -- In order to mimic the behavior of the previous memory buttons we use a temporary bag when -- recalling objects. This bag is tiny and transparent, and will be placed at the same location as -- this object. Once all placed cards are recalled bag to this bag, it will be destroyed local RECALL_BAG = { Name = "Bag", Transform = { scaleX = 0.01, scaleY = 0.01, scaleZ = 0.01, }, ColorDiffuse = { r = 0, g = 0, b = 0, a = 0, }, Locked = true, Grid = true, Snap = false, Tooltip = false } -- Tracks what has been placed by this "bag" so they can be recalled local placedSpecs = { } local placedObjectGuids = { } local recallZone = nil -- Loads a table of saved state, extracted during the parent object's onLoad SpawnBag.loadFromSave = function(saveTable) placedSpecs = saveTable.placed placedObjectGuids = saveTable.placedObjects recallZone = saveTable.recall end -- Generates a table of save state that can be included in the parent object's onSave SpawnBag.getStateForSave = function() return { placed = placedSpecs, placedObjects = placedObjectGuids, recall = recallZone, } end -- Places the given spawnSpec on the table. See comment at the start of the file for spawnSpec table data and examples SpawnBag.spawn = function(spawnSpec) -- Limit to one placement at a time if (placedSpecs[spawnSpec.name]) then return end if (spawnSpec == nil) then -- TODO: error here return end local cardsToSpawn = { } local cardList = spawnSpec.cards for _, cardId in ipairs(cardList) do local cardData = allCardsBagApi.getCardById(cardId) if (cardData ~= nil) then table.insert(cardsToSpawn, cardData) else -- TODO: error here end end if (spawnSpec.spread) then Spawner.spawnCardSpread(cardsToSpawn, spawnSpec.globalPos, spawnSpec.spreadCols or 9999, spawnSpec.rotation, false, internal.recordPlacedObject) else -- TTS decks come out in reverse order of the cards, reverse the list so the input order stays -- This only applies for decks; spreads are spawned by us in the order given if spawnSpec.rotation.z ~= 180 then cardsToSpawn = internal.reverseList(cardsToSpawn) end Spawner.spawnCards(cardsToSpawn, spawnSpec.globalPos, spawnSpec.rotation, false, internal.recordPlacedObject) end placedSpecs[spawnSpec.name] = true end -- Recalls all spawned objects to the bag, and clears the placedObjectGuids list ---@param fast boolean If true, cards will be deleted directly without faking the bag recall. SpawnBag.recall = function(fast) if fast then internal.deleteSpawned() else internal.recallSpawned() end -- We've recalled everything we can, some cards may have been moved out of the -- card area. Just reset at this point. placedSpecs = { } placedObjectGuids = { } recallZone = nil end -- Deleted all spawned cards. internal.deleteSpawned = function() for guid, _ in pairs(placedObjectGuids) do local obj = getObjectFromGUID(guid) if (obj ~= nil) then if (internal.isInRecallZone(obj)) then obj.destruct() end placedObjectGuids[guid] = nil end end end -- Recalls spawned cards with a fake bag that replicates the memory bag recall style. internal.recallSpawned = function() local trash = spawnObjectData({data = RECALL_BAG, position = self.getPosition()}) for guid, _ in pairs(placedObjectGuids) do local obj = getObjectFromGUID(guid) if (obj ~= nil) then if (internal.isInRecallZone(obj)) then trash.putObject(obj) end placedObjectGuids[guid] = nil end end trash.destruct() end -- Callback for when an object has been spawned. Tracks the object for later recall and updates the -- recall zone. internal.recordPlacedObject = function(spawned) placedObjectGuids[spawned.getGUID()] = true internal.expandRecallZone(spawned) end -- Expands the current recall zone based on the position of the given object. The recall zone will -- be maintained as the bounding box of the extreme object positions, plus a small amount of buffer internal.expandRecallZone = function(spawnedCard) local pos = spawnedCard.getPosition() if (recallZone == nil) then -- First card out of the bag, initialize surrounding that recallZone = { } recallZone.upperLeft = { x = pos.x + RECALL_BUFFER_X, z = pos.z + RECALL_BUFFER_Z } recallZone.lowerRight = { x = pos.x - RECALL_BUFFER_X, z = pos.z - RECALL_BUFFER_Z } return else if (pos.x > recallZone.upperLeft.x) then recallZone.upperLeft.x = pos.x + RECALL_BUFFER_X end if (pos.x < recallZone.lowerRight.x) then recallZone.lowerRight.x = pos.x - RECALL_BUFFER_X end if (pos.z > recallZone.upperLeft.z) then recallZone.upperLeft.z = pos.z + RECALL_BUFFER_Z end if (pos.z < recallZone.lowerRight.z) then recallZone.lowerRight.z = pos.z - RECALL_BUFFER_Z end end if (SHOW_RECALL_ZONE) then local y = 1.5 local thick = 0.05 Global.setVectorLines({ { points = { {recallZone.upperLeft.x,y,recallZone.upperLeft.z}, {recallZone.upperLeft.x,y,recallZone.lowerRight.z} }, color = {1,0,0}, thickness = thick, rotation = {0,0,0} }, { points = { {recallZone.upperLeft.x,y,recallZone.lowerRight.z}, {recallZone.lowerRight.x,y,recallZone.lowerRight.z} }, color = {1,0,0}, thickness = thick, rotation = {0,0,0} }, { points = { {recallZone.lowerRight.x,y,recallZone.lowerRight.z}, {recallZone.lowerRight.x,y,recallZone.upperLeft.z} }, color = {1,0,0}, thickness = thick, rotation = {0,0,0} }, { points = { {recallZone.lowerRight.x,y,recallZone.upperLeft.z}, {recallZone.upperLeft.x,y,recallZone.upperLeft.z} }, color = {1,0,0}, thickness = thick, rotation = {0,0,0} } }) end end -- Checks to see if the given object is in the current recall zone. If there isn't a recall zone, -- will return true so that everything can be easily cleaned up. internal.isInRecallZone = function(obj) if (recallZone == nil) then return true end local pos = obj.getPosition() return (pos.x < recallZone.upperLeft.x and pos.x > recallZone.lowerRight.x and pos.z < recallZone.upperLeft.z and pos.z > recallZone.lowerRight.z) end internal.reverseList = function(list) local reversed = { } for i = 1, #list do reversed[i] = list[#list - i + 1] end return reversed end return SpawnBag end end) return __bundle_require("__root")