-- 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("core/Global") end) __bundle_register("accessories/TokenArrangerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenArrangerApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") -- local function to call the token arranger, if it is on the table ---@param functionName string Name of the function to cal ---@param argument? table Parameter to pass local function callIfExistent(functionName, argument) local tokenArranger = guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenArranger") if tokenArranger ~= nil then tokenArranger.call(functionName, argument) end end -- updates the token modifiers with the provided data ---@param fullData table Contains the chaos token metadata TokenArrangerApi.onTokenDataChanged = function(fullData) callIfExistent("onTokenDataChanged", fullData) end -- deletes already laid out tokens TokenArrangerApi.deleteCopiedTokens = function() callIfExistent("deleteCopiedTokens") end -- updates the laid out tokens TokenArrangerApi.layout = function() Wait.time(function() callIfExistent("layout") end, 0.1) end return TokenArrangerApi end end) __bundle_register("chaosbag/BlessCurseManagerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local BlessCurseManagerApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getManager() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "BlessCurseManager") end -- removes all taken tokens and resets the counts BlessCurseManagerApi.removeTakenTokensAndReset = function() local BlessCurseManager = getManager() Wait.time(function() BlessCurseManager.call("removeTakenTokens", "Bless") end, 0.05) Wait.time(function() BlessCurseManager.call("removeTakenTokens", "Curse") end, 0.10) Wait.time(function() BlessCurseManager.call("doReset", "White") end, 0.15) end -- updates the internal count (called by cards that seal bless/curse tokens) ---@param type string Type of chaos token ("Bless" or "Curse") ---@param guid string GUID of the token BlessCurseManagerApi.sealedToken = function(type, guid) getManager().call("sealedToken", { type = type, guid = guid }) end -- updates the internal count (called by cards that seal bless/curse tokens) ---@param type string Type of chaos token ("Bless" or "Curse") ---@param guid string GUID of the token BlessCurseManagerApi.releasedToken = function(type, guid) getManager().call("releasedToken", { type = type, guid = guid }) end -- updates the internal count (called by cards that seal bless/curse tokens) ---@param type string Type of chaos token ("Bless" or "Curse") ---@param guid string GUID of the token BlessCurseManagerApi.returnedToken = function(type, guid) getManager().call("returnedToken", { type = type, guid = guid }) end -- broadcasts the current status for bless/curse tokens ---@param playerColor string Color of the player to show the broadcast to BlessCurseManagerApi.broadcastStatus = function(playerColor) getManager().call("broadcastStatus", playerColor) end -- removes all bless / curse tokens from the chaos bag and play ---@param playerColor string Color of the player to show the broadcast to BlessCurseManagerApi.removeAll = function(playerColor) getManager().call("doRemove", playerColor) end -- adds bless / curse sealing to the hovered card ---@param playerColor string Color of the player to show the broadcast to ---@param hoveredObject tts__Object Hovered object BlessCurseManagerApi.addBlurseSealingMenu = function(playerColor, hoveredObject) getManager().call("addMenuOptions", { playerColor = playerColor, hoveredObject = hoveredObject }) end return BlessCurseManagerApi 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/Global", function(require, _LOADED, __bundle_register, __bundle_modules) local blessCurseManagerApi = require("chaosbag/BlessCurseManagerApi") local guidReferenceApi = require("core/GUIDReferenceApi") local mythosAreaApi = require("core/MythosAreaApi") local navigationOverlayApi = require("core/NavigationOverlayApi") local playAreaApi = require("core/PlayAreaApi") local playmatApi = require("playermat/PlaymatApi") local searchLib = require("util/SearchLib") local soundCubeApi = require("core/SoundCubeApi") local tokenArrangerApi = require("accessories/TokenArrangerApi") local tokenChecker = require("core/token/TokenChecker") local tokenManager = require("core/token/TokenManager") --------------------------------------------------------- -- general setup --------------------------------------------------------- ENCOUNTER_DECK_POS = { -3.93, 1, 5.76 } ENCOUNTER_DECK_DISCARD_POSITION = { -3.85, 1, 10.38 } -- GUIDs that will not be interactable (e.g. parts of the table) local NOT_INTERACTABLE = { "6161b4", -- Decoration-Map "9f334f", -- MythosArea "463022", -- Panel behind tentacle stand "f182ee", -- InvestigatorCount "7bff34", -- Tentacle stand "8646eb", -- horizontal border left "75937e", -- horizontal border right "612072", -- vertical border left "975c39", -- vertical border right } local chaosTokens = {} local chaosTokensLastMatGUID = nil -- chaos token stat tracking local tokenDrawingStats = { ["Overall"] = {} } local bagSearchers = {} local hideTitleSplashWaitFunctionId = nil -- online functionality related variables local MOD_VERSION = "3.7.0" local SOURCE_REPO = 'https://raw.githubusercontent.com/chr1z93/loadable-objects/main' local library, requestObj, modMeta local acknowledgedUpgradeVersions = {} local contentToShow = "campaigns" local currentListItem = 1 local tabIdTable = { tab1 = "campaigns", tab2 = "scenarios", tab3 = "fanmadeCampaigns", tab4 = "fanmadeScenarios", tab5 = "fanmadePlayerCards" } -- optionPanel data optionPanel = {} local LANGUAGES = { { code = "zh_CN", name = "简体中文" }, { code = "zh_TW", name = "繁體中文" }, { code = "de", name = "Deutsch" }, { code = "en", name = "English" }, { code = "es", name = "Español" }, { code = "fr", name = "Français" }, { code = "it", name = "Italiano" } } local RESOURCE_OPTIONS = { "enabled", "custom", "disabled" } --------------------------------------------------------- -- data for tokens --------------------------------------------------------- TOKEN_DATA = { damage = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357115146/903D11AAE7BD5C254C8DC136E9202EE516289DEA/", scale = {0.17, 0.17, 0.17}}, horror = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357163535/6D9E0756503664D65BDB384656AC6D4BD713F5FC/", scale = {0.17, 0.17, 0.17}}, resource = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357192910/11DDDC7EF621320962FDCF3AE3211D5EDC3D1573/", scale = {0.17, 0.17, 0.17}}, doom = {image = "https://i.imgur.com/EoL7yaZ.png", scale = {0.17, 0.17, 0.17}}, clue = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357164917/1D06F1DC4D6888B6F57124BD2AFE20D0B0DA15A8/", scale = {0.15, 0.15, 0.15}} } ID_URL_MAP = { ['blue'] = {name = "Elder Sign", url = 'https://i.imgur.com/nEmqjmj.png'}, ['p1'] = {name = "+1", url = 'https://i.imgur.com/uIx8jbY.png'}, ['0'] = {name = "0", url = 'https://i.imgur.com/btEtVfd.png'}, ['m1'] = {name = "-1", url = 'https://i.imgur.com/w3XbrCC.png'}, ['m2'] = {name = "-2", url = 'https://i.imgur.com/bfTg2hb.png'}, ['m3'] = {name = "-3", url = 'https://i.imgur.com/yfs8gHq.png'}, ['m4'] = {name = "-4", url = 'https://i.imgur.com/qrgGQRD.png'}, ['m5'] = {name = "-5", url = 'https://i.imgur.com/3Ym1IeG.png'}, ['m6'] = {name = "-6", url = 'https://i.imgur.com/c9qdSzS.png'}, ['m7'] = {name = "-7", url = 'https://i.imgur.com/4WRD42n.png'}, ['m8'] = {name = "-8", url = 'https://i.imgur.com/9t3rPTQ.png'}, ['skull'] = {name = "Skull", url = 'https://i.imgur.com/stbBxtx.png'}, ['cultist'] = {name = "Cultist", url = 'https://i.imgur.com/VzhJJaH.png'}, ['tablet'] = {name = "Tablet", url = 'https://i.imgur.com/1plY463.png'}, ['elder'] = {name = "Elder Thing", url = 'https://i.imgur.com/ttnspKt.png'}, ['red'] = {name = "Auto-fail", url = 'https://i.imgur.com/lns4fhz.png'}, ['bless'] = {name = "Bless", url = 'http://cloud-3.steamusercontent.com/ugc/1655601092778627699/339FB716CB25CA6025C338F13AFDFD9AC6FA8356/'}, ['curse'] = {name = "Curse", url = 'http://cloud-3.steamusercontent.com/ugc/1655601092778636039/2A25BD38E8C44701D80DD96BF0121DA21843672E/'}, ['frost'] = {name = "Frost", url = 'http://cloud-3.steamusercontent.com/ugc/1858293462583104677/195F93C063A8881B805CE2FD4767A9718B27B6AE/'} } --------------------------------------------------------- -- general code --------------------------------------------------------- -- saving state of optionPanel to restore later function onSave() local chaosTokensGUID = {} for _, obj in ipairs(chaosTokens) do if obj ~= nil then table.insert(chaosTokensGUID, obj.getGUID()) end end return JSON.encode({ optionPanel = optionPanel, acknowledgedUpgradeVersions = acknowledgedUpgradeVersions, chaosTokensLastMatGUID = chaosTokensLastMatGUID, chaosTokensGUID = chaosTokensGUID }) end function onLoad(savedData) if savedData then loadedData = JSON.decode(savedData) optionPanel = loadedData.optionPanel acknowledgedUpgradeVersions = loadedData.acknowledgedUpgradeVersions -- hack to set options on load optionPanel["useResourceCounters"] = "enabled" updateOptionPanelState() -- restore saved state for drawn chaos tokens for _, guid in ipairs(loadedData.chaosTokensGUID or {}) do table.insert(chaosTokens, getObjectFromGUID(guid)) end chaosTokensLastMatGUID = loadedData.chaosTokensLastMatGUID else print("Saved state could not be found!") end for _, guid in ipairs(NOT_INTERACTABLE) do local obj = getObjectFromGUID(guid) if obj ~= nil then obj.interactable = false end end getModVersion() math.randomseed(os.time()) -- initialization of loadable objects library (delay to let Navigation Overlay build) Wait.time(function() WebRequest.get(SOURCE_REPO .. '/library.json', libraryDownloadCallback) end, 1) end -- Event hook for any object search. When chaos tokens are manipulated while the chaos bag -- container is being searched, a TTS bug can cause tokens to duplicate or vanish. We lock the -- chaos bag during search operations to avoid this. function onObjectSearchStart(object, playerColor) local chaosBag = findChaosBag() if object == chaosBag then bagSearchers[playerColor] = true end end -- Event hook for any object search. When chaos tokens are manipulated while the chaos bag -- container is being searched, a TTS bug can cause tokens to duplicate or vanish. We lock the -- chaos bag during search operations to avoid this. function onObjectSearchEnd(object, playerColor) local chaosBag = findChaosBag() if object == chaosBag then bagSearchers[playerColor] = nil end end -- Pass object enter container events to the PlayArea to clear vector lines from dragged cards. -- This requires the try method as cards won't exist any more after they enter a deck, so the lines -- can't be cleared. function tryObjectEnterContainer(container, object) playAreaApi.tryObjectEnterContainer(container, object) return true end -- TTS event for objects that enter zones -- used to detect the "token discard zones" beneath the hand zones function onObjectEnterZone(zone, enteringObj) if zone.getName() ~= "TokenDiscardZone" then return end if tokenChecker.isChaosToken(enteringObj) then return end if enteringObj.type == "Tile" and enteringObj.getMemo() and enteringObj.getLock() == false then local matcolor = playmatApi.getMatColorByPosition(enteringObj.getPosition()) local trash = guidReferenceApi.getObjectByOwnerAndType(matcolor, "Trash") trash.putObject(enteringObj) end end -- handle card drawing via number typing for multihanded gameplay -- (and additionally allow Norman Withers to draw multiple cards via number) function onObjectNumberTyped(hoveredObject, playerColor, number) -- only continue for decks or cards if hoveredObject.type ~= "Deck" and hoveredObject.type ~= "Card" then return end -- check whether the hovered object is part of a players draw objects for _, color in ipairs(playmatApi.getUsedMatColors()) do local deckAreaObjects = playmatApi.getDeckAreaObjects(color) if deckAreaObjects.topCard == hoveredObject or deckAreaObjects.draw == hoveredObject then playmatApi.drawCardsWithReshuffle(color, number) return true end end end --------------------------------------------------------- -- chaos token drawing --------------------------------------------------------- -- checks scripting zone for chaos bag (also called by a lot of objects!) function findChaosBag() local chaosBagZone = guidReferenceApi.getObjectByOwnerAndType("Mythos", "ChaosBagZone") -- error handling: scripting zone not found if chaosBagZone == nil then printToAll("Zone for chaos bag detection couldn't be found.", "Red") return end for _, item in ipairs(chaosBagZone.getObjects()) do if item.getDescription() == "Chaos Bag" then return item end end -- error handling: chaos bag not found printToAll("Chaos bag couldn't be found.", "Red") end function returnChaosTokens() local chaosBag = findChaosBag() for _, token in pairs(chaosTokens) do if token ~= nil then chaosBag.putObject(token) end end chaosTokens = {} end -- returns a single chaos token to the bag and calls respective functions function returnChaosTokenToBag(token) local name = token.getName() local guid = token.getGUID() local chaosBag = findChaosBag() chaosBag.putObject(token) tokenArrangerApi.layout() if name == "Bless" or name == "Curse" then blessCurseManagerApi.releasedToken(name, guid) end end -- Checks to see if the chaos bag can be manipulated. If a player is searching the bag when tokens -- are drawn or replaced a TTS bug can cause those tokens to vanish. Any functions which change the -- contents of the bag should check this method before doing so. -- This method will broadcast a message to all players if the bag is being searched. ---@return boolean: True if the bag is manipulated, false if it should be blocked. function canTouchChaosTokens() for color, searching in pairs(bagSearchers) do if searching then broadcastToAll("Someone is searching the chaos bag, can't touch the tokens.", "Red") return false end end return true end -- called by playermats (by the "Draw chaos token" button) function drawChaosToken(params) if not canTouchChaosTokens() then return end local tokenOffset = {-1.55, 0.25, -0.58} local matGUID = params.mat.getGUID() -- return token(s) on other playmat first if chaosTokensLastMatGUID ~= nil and chaosTokensLastMatGUID ~= matGUID and #chaosTokens ~= 0 then returnChaosTokens() chaosTokensLastMatGUID = nil return end chaosTokensLastMatGUID = matGUID -- if we have left clicked and have no tokens OR if we have right clicked if params.drawAdditional or #chaosTokens == 0 then local chaosBag = findChaosBag() if #chaosBag.getObjects() == 0 then return end chaosBag.shuffle() -- add the token to the list, compute new position based on list length tokenOffset[1] = tokenOffset[1] + (0.17 * #chaosTokens) local token if params.guidToBeResolved then -- resolve a sealed token from a card token = getObjectFromGUID(params.guidToBeResolved) token.setPositionSmooth(params.mat.positionToWorld(tokenOffset)) local guid = token.getGUID() local tokenType = token.getName() if tokenType == "Bless" or tokenType == "Curse" then blessCurseManagerApi.releasedToken(tokenType, guid) end tokenArrangerApi.layout() else -- take a token from the bag, either specified or random local takeParameters = { position = params.mat.positionToWorld(tokenOffset), rotation = params.mat.getRotation() } if params.tokenType then for i, lookedForToken in ipairs(chaosBag.getObjects()) do if lookedForToken.name == params.tokenType then takeParameters.index = i - 1 end end end token = chaosBag.takeObject(takeParameters) end -- get data for token description local name = token.getName() local tokenData = mythosAreaApi.returnTokenData().tokenData or {} local specificData = tokenData[name] or {} token.setDescription(specificData.description or "") -- track the chaos token (for stat tracker and future returning) trackChaosToken(name, matGUID) chaosTokens[#chaosTokens + 1] = token else returnChaosTokens() end end --------------------------------------------------------- -- token spawning --------------------------------------------------------- -- DEPRECATED. Use TokenManager instead. -- Spawns a single token. ---@param params table Array with arguments to the method. 1 = position, 2 = type, 3 = rotation function spawnToken(params) return tokenManager.spawnToken(params[1], params[2], params[3]) end --------------------------------------------------------- -- chaos token stat tracker --------------------------------------------------------- function trackChaosToken(tokenName, matGUID) -- initialize tables if not tokenDrawingStats[matGUID] then tokenDrawingStats[matGUID] = {} end -- increase stats by 1 tokenDrawingStats["Overall"][tokenName] = (tokenDrawingStats["Overall"][tokenName] or 0) + 1 tokenDrawingStats[matGUID][tokenName] = (tokenDrawingStats[matGUID][tokenName] or 0) + 1 end -- Left-click: print stats, Right-click: reset stats function handleStatTrackerClick(_, _, isRightClick) if isRightClick then resetChaosTokenStatTracker() else local squidKing = "Nobody" local maxSquid = 0 local foundAnyStats = false for key, personalStats in pairs(tokenDrawingStats) do local playerColor, playerName if key == "Overall" then playerColor = "White" playerName = "Overall" else -- get mat color local matColor = playmatApi.getMatColorByPosition(getObjectFromGUID(key).getPosition()) playerColor = playmatApi.getPlayerColor(matColor) playerName = Player[playerColor].steam_name or playerColor local playerSquidCount = personalStats["Auto-fail"] or 0 if playerSquidCount > maxSquid then squidKing = playerName maxSquid = playerSquidCount end end -- get the total count of drawn tokens for the player local totalCount = 0 for tokenName, value in pairs(personalStats) do totalCount = totalCount + value end -- only print the personal stats if any tokens were drawn if totalCount > 0 then foundAnyStats = true printToAll("------------------------------") printToAll(playerName .. " Stats", playerColor) for tokenName, value in pairs(personalStats) do if value ~= 0 then printToAll(tokenName .. ': ' .. tostring(value)) end end printToAll('Total: ' .. tostring(totalCount)) end end -- detect if any player drew tokens if foundAnyStats then printToAll("------------------------------") printToAll(squidKing .. " is an auto-fail magnet.", { 255, 0, 0 }) else printToAll("No tokens have been drawn yet.", "Yellow") end end end -- resets the count for each token to 0 function resetChaosTokenStatTracker() tokenDrawingStats = { ["Overall"] = {} } end --------------------------------------------------------- -- Difficulty selector script --------------------------------------------------------- -- called for button creation on the difficulty selectors ---@param args table Parameters for this function: -- object TTSObject Usually "self" -- key String Name of the scenario function createSetupButtons(args) local data = getDataValue('modeData', args.key) if data ~= nil then local buttonParameters = {} buttonParameters.function_owner = args.object buttonParameters.position = { 0, 0.1, -0.15 } buttonParameters.scale = { 0.47, 1, 0.47 } buttonParameters.height = 200 buttonParameters.width = 1150 buttonParameters.color = { 0.87, 0.8, 0.7 } if data.easy ~= nil then buttonParameters.label = "Easy" buttonParameters.click_function = "easyClick" args.object.createButton(buttonParameters) buttonParameters.position[3] = buttonParameters.position[3] + 0.20 end if data.normal ~= nil then buttonParameters.label = "Standard" buttonParameters.click_function = "normalClick" args.object.createButton(buttonParameters) buttonParameters.position[3] = buttonParameters.position[3] + 0.20 end if data.hard ~= nil then buttonParameters.label = "Hard" buttonParameters.click_function = "hardClick" args.object.createButton(buttonParameters) buttonParameters.position[3] = buttonParameters.position[3] + 0.20 end if data.expert ~= nil then buttonParameters.label = "Expert" buttonParameters.click_function = "expertClick" args.object.createButton(buttonParameters) buttonParameters.position[3] = buttonParameters.position[3] + 0.20 end if data.standalone ~= nil then buttonParameters.label = "Standalone" buttonParameters.click_function = "standaloneClick" args.object.createButton(buttonParameters) end end end -- called for adding chaos tokens ---@param args table Parameters for this function: -- object object Usually "self" -- key string Name of the scenario -- mode string difficulty (e.g. "hard" or "expert") function fillContainer(args) local data = getDataValue('modeData', args.key) if data == nil then return end local value = data[args.mode] if value == nil or value.token == nil then return end local tokenList = {} for _, tokenId in ipairs(value.token) do table.insert(tokenList, tokenId) end if value.append ~= nil then for _, tokenId in ipairs(value.append) do table.insert(tokenList, tokenId) end end -- randomly choose tokens for specific Carcosa scenarios in standalone if value.random then local n = #value.random if n > 0 then for _, tokenId in ipairs(value.random[math.random(1, n)]) do table.insert(tokenList, tokenId) end end end setChaosBagState(tokenList) if value.message then broadcastToAll(value.message) end if value.warning then broadcastToAll(value.warning, { 1, 0.5, 0.5 }) end end function getDataValue(storage, key) local DATA_HELPER = guidReferenceApi.getObjectByOwnerAndType("Mythos", "DataHelper") local data = DATA_HELPER.getTable(storage) if data ~= nil then local value = data[key] if value ~= nil then local res = {} for m, v in pairs(value) do res[m] = v if res[m].parent ~= nil then local parentData = getDataValue(storage, res[m].parent) if parentData ~= nil and parentData[m] ~= nil and parentData[m].token ~= nil then res[m].token = parentData[m].token end res[m].parent = nil end end return res end end end function createChaosTokenNameLookupTable() local namesToIds = {} for k, v in pairs(ID_URL_MAP) do namesToIds[v.name] = k end return namesToIds end -- returns the currently drawn chaos tokens ---@api ChaosBagApi function getChaosTokensinPlay() return chaosTokens end -- returns a Table List of chaos token ids in the current chaos bag ---@api ChaosBag / ChaosBagApi function getChaosBagState() local tokens = {} local invertedTable = createChaosTokenNameLookupTable() local chaosBag = findChaosBag() for _, v in ipairs(chaosBag.getObjects()) do local id = invertedTable[v.name] if id then table.insert(tokens, id) else printToAll(v.name .. " token not recognized. Will not be recorded.", "Yellow") end end return tokens end -- respawns the chaos bag with a new state of tokens ---@param tokenList table List of chaos token ids ---@api ChaosBag / ChaosBagApi function setChaosBagState(tokenList) if not canTouchChaosTokens() then return end local chaosBag = findChaosBag() local chaosBagData = chaosBag.getData() local reserveData = getObjectFromGUID("106418").getData() local tokenCache = {} local containedObjects = {} -- create a temporary copy of the data for each chaos token for _, objData in ipairs(reserveData.ContainedObjects) do tokenCache[objData.Nickname] = objData end -- iterate over tokenlist and insert specified tokens into new table for _, tokenId in ipairs(tokenList) do local tokenName = ID_URL_MAP[tokenId].name table.insert(containedObjects, tokenCache[tokenName]) end -- overwrite chaos bag content and respawn it chaosBagData.ContainedObjects = containedObjects chaosBag.destruct() spawnObjectData({ data = chaosBagData }) -- remove tokens that are still in play for _, token in pairs(chaosTokens) do if token ~= nil then token.destruct() end end chaosTokens = {} chaosTokensLastMatGUID = nil -- reset bless / curse manager blessCurseManagerApi.removeTakenTokensAndReset() printToAll("Chaos Bag set to chosen difficulty.", "Green") end -- spawns the specified chaos token and puts it into the chaos bag ---@param id string ID of the chaos token function spawnChaosToken(id) if not canTouchChaosTokens() then return end id = id:lower() local chaosBag = findChaosBag() local url = ID_URL_MAP[id].url or "" if url ~= "" then return spawnObject({ type = 'Custom_Tile', position = { 0.49, 3, 0 }, scale = { 0.81, 1.0, 0.81 }, rotation = { 0, 270, 0 }, callback_function = function(obj) obj.setName(ID_URL_MAP[id].name) chaosBag.putObject(obj) tokenArrangerApi.layout() end }).setCustomObject({ type = 2, image = url, thickness = 0.1 }) end end -- removes the specified chaos token from the chaos bag ---@param id string ID of the chaos token function removeChaosToken(id) if not canTouchChaosTokens() then return end local tokens = {} local chaosBag = findChaosBag() local name = ID_URL_MAP[id].name for _, v in ipairs(chaosBag.getObjects()) do if v.name == name then table.insert(tokens, v.guid) end end -- error handling: no matching token found if #tokens == 0 then printToAll("No " .. name .. " tokens in the chaos bag.", "Yellow") return end chaosBag.takeObject({ guid = tokens[1], smooth = false, callback_function = function(obj) obj.destruct() tokenArrangerApi.layout() end }) printToAll("Removing " .. name .. " token (in bag: " .. #tokens - 1 .. ")", "White") end -- empty the chaos bag function emptyChaosBag() if not canTouchChaosTokens() then return end local chaosBag = findChaosBag() for _, object in ipairs(chaosBag.getObjects()) do chaosBag.takeObject({ callback_function = function(item) item.destruct() end }) end end -- returns all sealed tokens on cards to the chaos bag function releaseAllSealedTokens(playerColor) local chaosBag = findChaosBag() for _, obj in ipairs(getObjectsWithTag("CardThatSeals")) do obj.call("releaseAllTokens", playerColor) end end --------------------------------------------------------- -- Content Importing and XML functions --------------------------------------------------------- -- forwards the requested content type to the update function and sets highlight to clicked tab ---@param tabId string Id of the clicked tab function onClick_tab(_, _, tabId) for listId, listContent in pairs(tabIdTable) do if listId == tabId then UI.setClass(listId, 'downloadTab activeTab') contentToShow = listContent else UI.setClass(listId, 'downloadTab') end end currentListItem = 1 updateDownloadItemList() end -- click function for the items in the download window -- updates backgroundcolor for row panel and fontcolor for list item function onClick_select(_, _, identificationKey) UI.setAttribute("panel" .. currentListItem, "color", "clear") UI.setAttribute(contentToShow .. "_" .. currentListItem, "color", "white") -- parses the identification key (contentToShow_currentListItem) if identificationKey then contentToShow = nil currentListItem = nil for str in string.gmatch(identificationKey, "([^_]+)") do if not contentToShow then -- grab the first part to know the content type contentToShow = str else -- get the index currentListItem = tonumber(str) break end end end UI.setAttribute("panel" .. currentListItem, "color", "grey") UI.setAttribute(contentToShow .. "_" .. currentListItem, "color", "black") updatePreviewWindow() end -- click function for the "Custom URL" button in the playarea image gallery function onClick_customUrl(player) changeWindowVisibilityForColor(player.color, "playareaGallery") Wait.time(function() player.showInputDialog("Enter a custom URL for the playarea image", "", function(newURL) playAreaApi.updateSurface(newURL) end) end, 0.15) end -- click function for the download button in the preview window function onClick_download(player) local params = library[contentToShow][currentListItem] params.player = player placeholder_download(params) end -- the download button on the placeholder objects calls this to directly initiate a download ---@param params table contains url and guid of replacement object function placeholder_download(params) function downloadCoroutine() -- show progress bar UI.setAttribute('download_progress', 'active', true) -- update progress bar while requestObj do UI.setAttribute('download_progress', 'percentage', requestObj.download_progress * 100) coroutine.yield(0) end UI.setAttribute('download_progress', 'percentage', 100) -- wait 30 frames for i = 1, 30 do coroutine.yield(0) end -- hide progress bar UI.setAttribute('download_progress', 'active', false) -- hide download window changeWindowVisibilityForColor(params.player.color, "downloadWindow", false) return 1 end local url = SOURCE_REPO .. '/' .. params.url requestObj = WebRequest.get(url, function (request) contentDownloadCallback(request, params) end) startLuaCoroutine(Global, 'downloadCoroutine') end -- spawns a bag that contains every object from the library function onClick_downloadAll(player) broadcastToAll("Download initiated - this will take a few minutes!") -- hide download window changeWindowVisibilityForColor(player.color, "downloadWindow", false) startLuaCoroutine(Global, "coroutineDownloadAll") end function coroutineDownloadAll() local JSON = [[ { "Name": "Bag", "Transform": { "posX": {{POSX}}, "posY": 2, "posZ": -95, "rotX": 0, "rotY": 270, "rotZ": 0, "scaleX": 1, "scaleY": 1, "scaleZ": 1 }, "Nickname": "{{NICKNAME}}", "Bag": { "Order": 0 }, "ContainedObjects": [ ]] local posx = -45.0 local downloadedItems = 0 local skippedItems = 0 -- loop through the library to add content for contentType, objectList in pairs(library) do broadcastToAll("Downloading " .. contentType .. "...") local contained = "" for _, params in ipairs(objectList) do local request = WebRequest.get(SOURCE_REPO .. '/' .. params.url, function() end) local start = os.time() while true do if request.is_done then contained = contained .. request.text .. "," downloadedItems = downloadedItems + 1 break -- time-out if item can't be loaded in 5s elseif request.is_error or (os.time() - start) > 5 then skippedItems = skippedItems + 1 break end coroutine.yield(0) end end local JSONCopy = JSON JSONCopy = JSONCopy .. contained .. "]}" JSONCopy = JSONCopy:gsub("{{POSX}}", posx) JSONCopy = JSONCopy:gsub("{{NICKNAME}}", contentType) spawnObjectJSON({json = JSONCopy}) posx = posx + 3 end broadcastToAll(downloadedItems .. " objects downloaded.", "Green") broadcastToAll(skippedItems .. " objects had a time-out / error.", "Orange") return 1 end -- spawns a placeholder box for the selected object function onClick_spawnPlaceholder(player) -- get object references local item = library[contentToShow][currentListItem] local dummy = guidReferenceApi.getObjectByOwnerAndType("Mythos", "PlaceholderBoxDummy") -- error handling if not item.boxsize or item.boxsize == "" or not item.boxart or item.boxart == "" then print("Error loading object.") return end -- get data for placeholder local spawnPos = {-39.5, 2, -87} local meshTable = { big = "https://raw.githubusercontent.com/RobMayer/TTSLibrary/master/advboxes/core_h_MSH.obj", small = "https://raw.githubusercontent.com/RobMayer/TTSLibrary/master/advboxes/tuckbox_h_MSH.obj", wide = "http://cloud-3.steamusercontent.com/ugc/2278324073260846176/33EFCAF30567F8756F665BE5A2A6502E9C61C7F7/" } local scaleTable = { big = {1.00, 0.14, 1.00}, small = {2.21, 0.46, 2.42}, wide = {2.00, 0.11, 1.69} } local placeholder = spawnObject({ type = "Custom_Model", position = spawnPos, rotation = {0, 270, 0}, scale = scaleTable[item.boxsize], }) placeholder.setCustomObject({ mesh = meshTable[item.boxsize], diffuse = item.boxart, material = 3 }) if item.boxsize == "big" then placeholder.addTag("LargeBox") end placeholder.setColorTint({1, 1, 1, 71/255}) placeholder.setName(item.name) placeholder.setDescription("by " .. (item.author or "Unknown")) placeholder.setGMNotes(item.url) placeholder.setLuaScript(dummy.getLuaScript()) Player.getPlayers()[1].pingTable(spawnPos) -- hide download window changeWindowVisibilityForColor(player.color, "downloadWindow", false) end -- toggles the visibility of the respective UI ---@param player tts__Player Player that triggered this ---@param windowId string Name of the UI to toggle function onClick_toggleUi(player, windowId) if windowId == "Navigation Overlay" then navigationOverlayApi.cycleVisibility(player.color) return end -- hide the playAreaGallery if visible if windowId == "downloadWindow" then changeWindowVisibilityForColor(player.color, "playAreaGallery", false) -- hide the downloadWindow if visible elseif windowId == "playAreaGallery" then changeWindowVisibilityForColor(player.color, "downloadWindow", false) end changeWindowVisibilityForColor(player.color, windowId) end -- toggles the visibility of the specific window for the specified color ---@param color string Player color to toggle the visibility for ---@param windowId string ID of the XML element ---@param overrideState? boolean Forcefully sets the new visibility ---@return boolean visible Returns the new state of the visibility function changeWindowVisibilityForColor(color, windowId, overrideState) -- current state local colorString = UI.getAttribute(windowId, "visibility") or "" -- parse the visibility string local visible = false local viewers = {} for str in string.gmatch(colorString, "%a+") do table.insert(viewers, str) if str == color then visible = true end end -- add / remove the color as viewer if visible == true then removeValueFromTable(viewers, color) elseif visible == false then table.insert(viewers, color) end visible = not visible -- resolve override if overrideState == true and visible == false then table.insert(viewers, color) visible = true elseif overrideState == false and visible == true then removeValueFromTable(viewers, color) visible = false end -- construct new string local newColorString = "" for _, viewer in ipairs(viewers) do newColorString = newColorString .. viewer .. "|" end -- remove last delimiter newColorString = newColorString:sub(1, -2) -- update the visibility of the XML UI.setAttribute(windowId, "visibility", newColorString) UI.setAttribute(windowId, "active", newColorString ~= "") return visible end -- forwards the call to the onClick function function togglePlayAreaGallery(playerColor) changeWindowVisibilityForColor(playerColor, "playareaGallery") end -- updates the preview window function updatePreviewWindow() local item = library[contentToShow][currentListItem] local tempImage = "http://cloud-3.steamusercontent.com/ugc/2115061845788345842/2CD6ABC551555CCF58F9D0DDB7620197BA398B06/" -- set default image if not defined if item.boxsize == nil or item.boxsize == "" or item.boxart == nil or item.boxart == "" then item.boxsize = "big" item.boxart = "http://cloud-3.steamusercontent.com/ugc/762723517667628371/18438B0A0045038A7099648AA3346DFCAA267C66/" end UI.setValue("previewTitle", item.name) UI.setValue("previewAuthor", "by " .. (item.author or "- Author not found -")) UI.setValue("previewDescription", item.description or "- Description not found -") -- update mask according to size (hardcoded values to align image in mask) local maskData = {} if item.boxsize == "big" then maskData = { image = "box-cover-mask-big", width = "870", height = "435", offsetXY = "154 60" } elseif item.boxsize == "small" then maskData = { image = "box-cover-mask-small", width = "792", height = "594", offsetXY = "135 13" } elseif item.boxsize == "wide" then maskData = { image = "box-cover-mask-wide", width = "756", height = "630", offsetXY = "-190 -70" } end -- loading empty image as placeholder until real image is loaded UI.setAttribute("previewArtImage", "image", tempImage) -- insert the image itself UI.setAttribute("previewArtImage", "image", item.boxart) UI.setAttributes("previewArtMask", maskData) end -- formats the json response from the webrequest into a key-value lua table -- strips the prefix from the community content items function formatLibrary(json_response) library = {} library["campaigns"] = json_response.campaigns library["scenarios"] = json_response.scenarios library["extras"] = json_response.extras library["fanmadeCampaigns"] = {} library["fanmadeScenarios"] = {} library["fanmadePlayerCards"] = {} for _, item in ipairs(json_response.community) do local identifier = nil for str in string.gmatch(item.name, "([^:]+)") do if not identifier then -- grab the first part to know the content type identifier = str else -- update the name without the content type item.name = str break end end if identifier == "Fan Investigators" then table.insert(library["fanmadePlayerCards"], item) elseif identifier == "Fan Campaign" then table.insert(library["fanmadeCampaigns"], item) elseif identifier == "Fan Scenario" then table.insert(library["fanmadeScenarios"], item) end end end -- updates the window content to the requested content function updateDownloadItemList() if not library then return end -- addition of list items according to library file local globalXml = UI.getXmlTable() local contentList = getXmlTableElementById(globalXml, 'contentList') contentList.children = {} for i, v in ipairs(library[contentToShow]) do table.insert(contentList.children, { tag = "Panel", attributes = { id = "panel" .. i }, children = { tag = 'Text', value = v.name, attributes = { id = contentToShow .. "_" .. i, onClick = 'onClick_select', alignment = 'MiddleLeft' } } }) end contentList.attributes.height = #contentList.children * 27 updateGlobalXml(globalXml) -- select the first item Wait.time(onClick_select, 0.2) end -- this helper function updates the global XML while preserving the visibility of windows function updateGlobalXml(newXml) -- preserve visibility settings for these elements local windowIdList = { "playAreaGallery", "downloadWindow", "optionPanel" } -- get current state and update newXml for _, windowId in ipairs(windowIdList) do local element = getXmlTableElementById(newXml, windowId) element.attributes.active = UI.getAttribute(windowId, "active") element.attributes.visibility = UI.getAttribute(windowId, "visibility") end UI.setXmlTable(newXml) end -- called after the webrequest of downloading an item -- deletes the placeholder and spawns the downloaded item function contentDownloadCallback(request, params) requestObj = nil -- error handling if request.is_error or request.response_code ~= 200 then print('Error: ' .. request.error) return end -- initiate content spawning local spawnTable = { json = request.text } if params.replace then local replacedObject = getObjectFromGUID(params.replace) if replacedObject then spawnTable.position = replacedObject.getPosition() spawnTable.rotation = replacedObject.getRotation() spawnTable.scale = replacedObject.getScale() destroyObject(replacedObject) end end -- if position is undefined, get empty position if not spawnTable.position then spawnTable.rotation = { 0, 270, 0} local pos = getValidSpawnPosition() if pos then spawnTable.position = pos else broadcastToAll("Please make space in the area below the tentacle stand in the upper middle of the table and try again.", "Red") return end end -- if spawned from menu, move the camera and/or ping the table if params.name then spawnTable["callback_function"] = function(obj) Wait.time(function() -- move camera if params.player then params.player.lookAt({ position = obj.getPosition(), pitch = 65, yaw = 90, distance = 65 }) end -- ping object local pingPlayer = params.player or Player.getPlayers()[1] pingPlayer.pingTable(obj.getPosition()) end, 0.1) end end if pcall(function() spawnObjectJSON(spawnTable) end) then print('Object loaded.') else print('Error loading object.') end end -- gets the first empty position to spawn a custom content object safely function getValidSpawnPosition() local potentialSpawnPositionX = { 65, 50, 35 } local potentialSpawnPositionY = 1.5 local potentialSpawnPositionZ = { 35, 21, 7, -7, -21, -35 } for i, posX in ipairs(potentialSpawnPositionX) do for j, posZ in ipairs(potentialSpawnPositionZ) do local pos = { x = posX, y = potentialSpawnPositionY, z = posZ, } if checkPositionForContentSpawn(pos) then return pos end end end return nil end -- checks whether something is in the specified position -- returns true if empty function checkPositionForContentSpawn(checkPos) local searchResult = searchLib.atPosition(checkPos) -- first hit is the table surface, additional hits means something is there return #searchResult == 1 end -- downloading of the library file function libraryDownloadCallback(request) if request.is_error or request.response_code ~= 200 then print('error: ' .. request.error) return end local json_response = nil if pcall(function () json_response = JSON.decode(request.text) end) then formatLibrary(json_response) updateDownloadItemList() else print('error parsing downloaded library') end end -- loops through an XML table and returns the specified object ---@param ui table XmlTable (get this via getXmlTable) ---@param id string Id of the object to return function getXmlTableElementById(ui, id) for _, obj in ipairs(ui) do if obj.attributes and obj.attributes.id and obj.attributes.id == id then return obj end if obj.children then local result = getXmlTableElementById(obj.children, id) if result then return result end end end return nil end --------------------------------------------------------- -- Option Panel related functionality --------------------------------------------------------- -- called by toggling an option function onClick_toggleOption(_, id) local state = self.UI.getAttribute(id, "isOn") -- flip state (and handle stupid "False" value) if state == "False" then state = true else state = false end self.UI.setAttribute(id, "isOn", state) applyOptionPanelChange(id, state) end -- color selection for playArea function onClick_playAreaConnectionColor(player, _, id) player.showColorDialog(optionPanel[id], function(color) applyOptionPanelChange(id, color) end) end -- called by the language selection dropdown function languageSelected(_, selectedIndex, id) optionPanel[id] = LANGUAGES[tonumber(selectedIndex) + 1].code end -- returns the ID (position in the table) for a provided language code function returnLanguageId(code) for index, tbl in ipairs(LANGUAGES) do if tbl.code == code then return index end end end -- called by the resource counter selection dropdown function resourceCounterSelected(_, selectedIndex, id) optionPanel[id] = RESOURCE_OPTIONS[tonumber(selectedIndex) + 1] end -- returns the ID for the provided option name function returnResourceCounterId(name) for index, optionName in ipairs(RESOURCE_OPTIONS) do if optionName == name then return index end end end -- called by the playermat removal selection dropdown function playermatRemovalSelected(player, selectedIndex, id) if selectedIndex == "0" then return end local matColorList = { "White", "Orange", "Green", "Red" } local matColor = matColorList[tonumber(selectedIndex)] local mat = guidReferenceApi.getObjectByOwnerAndType(matColor, "Playermat") if mat then -- confirmation dialog about deletion player.pingTable(mat.getPosition()) player.showConfirmDialog("Do you really want to remove " .. matColor .. "'s playermat and related objects? This can't be reversed.", function() removePlayermat(matColor) end) else -- info dialog that it is already deleted player.showInfoDialog(matColor .. "'s playermat has already been removed.") end -- set selected value back to first option UI.setAttribute(id, "value", 0) end -- removes a playermat and all related objects from play ---@param matColor string Color of the playermat to remove function removePlayermat(matColor) local matObjects = guidReferenceApi.getObjectsByOwner(matColor) if not matObjects.Playermat then return end -- remove action tokens local actionTokens = playmatApi.searchAroundPlaymat(matColor, "isActionToken") for _, obj in ipairs(actionTokens) do obj.destruct() end -- remove mat owned objects for _, obj in pairs(matObjects) do obj.destruct() end end -- sets the option panel to the correct state (corresponding to 'optionPanel') function updateOptionPanelState() for id, optionValue in pairs(optionPanel) do if id == "cardLanguage" and type(optionValue) == "string" then local dropdownId = returnLanguageId(optionValue) - 1 UI.setAttribute(id, "value", dropdownId) elseif id == "useResourceCounters" and type(optionValue) == "string" then local dropdownId = returnResourceCounterId(optionValue) - 1 UI.setAttribute(id, "value", dropdownId) elseif id == "playAreaConnectionColor" then UI.setAttribute(id, "color", "#" .. Color.new(optionValue):toHex()) elseif (type(optionValue) == "boolean" and optionValue) or (type(optionValue) == "string" and optionValue) or (type(optionValue) == "table" and #optionValue ~= 0) then UI.setAttribute(id, "isOn", true) else UI.setAttribute(id, "isOn", "False") end end end -- handles the applying of option selections and calls the respective functions based ---@param id string ID of the option that was selected or deselected ---@param state boolean|any State of the option (true = enabled) function applyOptionPanelChange(id, state) -- option: Snap tags if id == "useSnapTags" then playmatApi.setLimitSnapsByType(state, "All") optionPanel[id] = state -- option: Draw 1 button elseif id == "showDrawButton" then playmatApi.showDrawButton(state, "All") optionPanel[id] = state -- option: Clickable clue counters elseif id == "useClueClickers" then playmatApi.clickableClues(state, "All") optionPanel[id] = state -- update master clue counter local counter = guidReferenceApi.getObjectByOwnerAndType("Mythos", "MasterClueCounter") counter.setVar("useClickableCounters", state) -- option: Play area snap tags elseif id == "playAreaConnections" then playAreaApi.setConnectionDrawState(state) optionPanel[id] = state -- option: Play area connection color elseif id == "playAreaConnectionColor" then playAreaApi.setConnectionColor(state) UI.setAttribute(id, "color", "#" .. Color.new(state):toHex()) optionPanel[id] = state -- option: Play area snap tags elseif id == "playAreaSnapTags" then playAreaApi.setLimitSnapsByType(state) optionPanel[id] = state -- option: Show Title on placing scenarios elseif id == "showTitleSplash" then optionPanel[id] = state -- option: Change custom playarea image on setup elseif id == "changePlayAreaImage" then optionPanel[id] = state -- option: Show clean up helper elseif id == "showCleanUpHelper" then optionPanel[id] = spawnOrRemoveHelper(state, "Clean Up Helper", {-66, 1.6, 46}) -- option: Show hand helper for each player elseif id == "showHandHelper" then spawnOrRemoveHelperForPlayermats("Hand Helper", state) optionPanel[id] = state -- option: Show search assistant for each player elseif id == "showSearchAssistant" then spawnOrRemoveHelperForPlayermats("Search Assistant", state) optionPanel[id] = state -- option: Show attachment helper elseif id == "showAttachmentHelper" then optionPanel[id] = spawnOrRemoveHelper(state, "Attachment Helper", {-62, 1.4, 0}) -- option: Show CYOA campaign guides elseif id == "showCYOA" then optionPanel[id] = spawnOrRemoveHelper(state, "CYOA Campaign Guides", { 39, 1.3, -20}) -- option: Show displacement tool elseif id == "showDisplacementTool" then optionPanel[id] = spawnOrRemoveHelper(state, "Displacement Tool", {-57, 1.6, 46}) end end -- spawns or removes a helper object for all playermats ---@param helperName string Name of the helper object ---@param state boolean Contains the state of the option: true = spawn it, false = remove it function spawnOrRemoveHelperForPlayermats(helperName, state) for color, data in pairs(playmatApi.getHelperSpawnData("All", helperName)) do spawnOrRemoveHelper(state, helperName, data.position, data.rotation, color) end end -- handler for spawn / remove functions of helper objects ---@param state boolean Contains the state of the option: true = spawn it, false = remove it ---@param name string Name of the helper object ---@param position tts__Vector Position of the object (where it will spawn) ---@param rotation? tts__Vector Rotation of the object for spawning (default: {0, 270, 0}) ---@param owner? string Owner of the object (defaults to "Mythos") ---@return string|nil GUID GUID of the spawnedObj (or nil if object was removed) function spawnOrRemoveHelper(state, name, position, rotation, owner) if state then Player.getPlayers()[1].pingTable(position) local spawnedGUID = spawnHelperObject(name, position, rotation).getGUID() local cleanName = name:gsub("%s+", "") guidReferenceApi.editIndex(owner or "Mythos", cleanName, spawnedGUID) else return removeHelperObject(name) end end -- copies the specified tool (by name) from the option panel source bag ---@param name string Name of the object that should be copied ---@param position tts__Vector Desired position of the object ---@param rotation? tts__Vector Desired rotation of the object (defaults to object's rotation) function spawnHelperObject(name, position, rotation) local sourceBag = guidReferenceApi.getObjectByOwnerAndType("Mythos", "OptionPanelSource") -- error handling for missing sourceBag if not sourceBag then broadcastToAll("Option panel source bag could not be found!", "Red") return end local spawnTable = { position = position } -- only overrride rotation if there is one provided (object's rotation used instead) if rotation then spawnTable.rotation = rotation end for _, obj in ipairs(sourceBag.getData().ContainedObjects) do if obj["Nickname"] == name then spawnTable.data = obj spawnTable.callback_function = function(spawnedObj) Wait.time(function() spawnedObj.setLock(true) end, 2) end return spawnObjectData(spawnTable) end end end -- removes the specified tool (by name) ---@param name string Object that should be removed function removeHelperObject(name) local cleanName = name:gsub("%s+", "") for _, obj in pairs(guidReferenceApi.getObjectsByType(cleanName)) do obj.destruct() end end -- loads saved options ---@param newOptions table Contains the new state for the option panel function loadSettings(newOptions) for id, state in pairs(newOptions) do if optionPanel[id] ~= state then optionPanel[id] = state applyOptionPanelChange(id, state) end end -- update XML UI state updateOptionPanelState() end -- loads the default options function onClick_defaultSettings() for id, _ in pairs(optionPanel) do local state = false -- override for settings that are enabled by default if id == "useSnapTags" or id == "showTitleSplash" then state = true end applyOptionPanelChange(id, state) end -- clean reset of variables optionPanel = { cardLanguage = "en", changePlayAreaImage = false, playAreaConnectionColor = { a = 1, b = 0.4, g = 0.4, r = 0.4 }, playAreaConnections = true, playAreaSnapTags = true, showAttachmentHelper = false, showCleanUpHelper = false, showCYOA = false, showDisplacementTool = false, showDrawButton = false, showHandHelper = false, showSearchAssistant = false, showTitleSplash = true, useClueClickers = false, useResourceCounters = "disabled", useSnapTags = true } -- update UI updateOptionPanelState() end -- splash scenario title on setup function titleSplash(scenarioName) if optionPanel['showTitleSplash'] then -- if there's any ongoing title being displayed, hide it and cancel the waiting function if hideTitleSplashWaitFunctionId then Wait.stop(hideTitleSplashWaitFunctionId) hideTitleSplashWaitFunctionId = nil UI.setAttribute('title_splash', 'active', false) end -- display scenario name and set a 4 seconds (2 seconds animation and 2 seconds on screen) -- wait timer to hide the scenario name UI.setValue('title_splash_text', scenarioName) UI.show('title_splash') hideTitleSplashWaitFunctionId = Wait.time(function() UI.hide('title_splash') hideTitleSplashWaitFunctionId = nil end, 4) soundCubeApi.playSoundByName("Deep Bell") end end --------------------------------------------------------- -- Update notification related functionality --------------------------------------------------------- -- grabs the latest mod version and release notes from GitHub (called onLoad()) function getModVersion() WebRequest.get(SOURCE_REPO .. '/modversion.json', compareVersion) end -- compares the modversion with GitHub and possibly shows the update notification function compareVersion(request) if request.is_error then log(request.error) return end -- global variable to make it accessible for other functions modMeta = JSON.decode(request.text) -- stop here if on latest or newer version if convertVersionToNumber(MOD_VERSION) >= convertVersionToNumber(modMeta["latestVersion"]) then return end -- stop here if "don't show again" was clicked for this version before if acknowledgedUpgradeVersions[modMeta["latestVersion"]] then return end updateNotificationLoading() -- delay to avoid lagging during onLoad() Wait.time(function() UI.show("FinnIcon") end, 1) end -- converts a version number to a string ---@param version string Version number, separated by dots (e.g. 3.3.1) function convertVersionToNumber(version) local major, minor, patch = string.match(version, "(%d+)%.(%d+)%.(%d+)") return major * 100 + minor * 10 + patch end -- updates the XML update notification based on the mod metadata function updateNotificationLoading() -- grab data local highlights = modMeta["releaseHighlights"] -- concatenate the release highlights local highlightText = "• " .. highlights[1] for i, entry in pairs(highlights) do if i ~= 1 then highlightText = highlightText .. "\n• " .. entry end end -- update the XML UI UI.setValue("notificationHeader", "New version available: " .. modMeta["latestVersion"]) UI.setValue("releaseHighlightText", highlightText) UI.setAttribute("highlightRow", "preferredHeight", 20*#highlights) UI.setAttribute("updateNotification", "height", 20*#highlights + 125) end -- close / don't show again buttons on the update notification function onClick_notification(_, parameter) if parameter == "dontShowAgain" then -- this variable tracks if "don't show again" was pressed for a version acknowledgedUpgradeVersions[modMeta["latestVersion"]] = true end UI.hide("FinnIcon") UI.hide("updateNotification") end --------------------------------------------------------- -- Utility functions --------------------------------------------------------- function removeValueFromTable(t, val) for i, v in ipairs(t) do if v == val then table.remove(t, i) break end end end end) __bundle_register("core/MythosAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local MythosAreaApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getMythosArea() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "MythosArea") end ---@return any: Table of chaos token metadata (if provided through scenario reference card) MythosAreaApi.returnTokenData = function() return getMythosArea().call("returnTokenData") end ---@return any: Object reference to the encounter deck MythosAreaApi.getEncounterDeck = function() return getMythosArea().call("getEncounterDeck") end -- draw an encounter card for the requesting mat ---@param mat tts__Object Playermat that triggered this ---@param alwaysFaceUp boolean Whether the card should be drawn face-up MythosAreaApi.drawEncounterCard = function(mat, alwaysFaceUp) getMythosArea().call("drawEncounterCard", {mat = mat, alwaysFaceUp = alwaysFaceUp}) end -- reshuffle the encounter deck MythosAreaApi.reshuffleEncounterDeck = function() getMythosArea().call("reshuffleEncounterDeck") end return MythosAreaApi end end) __bundle_register("core/NavigationOverlayApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local NavigationOverlayApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getNOHandler() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "NavigationOverlayHandler") end -- copies the visibility for the Navigation overlay ---@param startColor string Color of the player to copy from ---@param targetColor string Color of the targeted player NavigationOverlayApi.copyVisibility = function(startColor, targetColor) getNOHandler().call("copyVisibility", { startColor = startColor, targetColor = targetColor }) end -- changes the Navigation Overlay view ("Full View" --> "Play Areas" --> "Closed" etc.) ---@param playerColor string Color of the player to update the visibility for NavigationOverlayApi.cycleVisibility = function(playerColor) getNOHandler().call("cycleVisibility", playerColor) end -- loads the specified camera for a player ---@param player tts__Player Player whose camera should be moved ---@param camera number|string If number: Index of the camera view to load | If string: Color of the playermat to swap to NavigationOverlayApi.loadCamera = function(player, camera) getNOHandler().call("loadCameraFromApi", { player = player, camera = camera }) end return NavigationOverlayApi end end) __bundle_register("core/OptionPanelApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local OptionPanelApi = {} -- loads saved options ---@param options table Set a new state for the option table OptionPanelApi.loadSettings = function(options) return Global.call("loadSettings", options) end ---@return any: Table of option panel state OptionPanelApi.getOptions = function() return Global.getTable("optionPanel") end return OptionPanelApi 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("core/SoundCubeApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local SoundCubeApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") -- this table links the name of a trigger effect to its index local soundIndices = { ["Vacuum"] = 0, ["Deep Bell"] = 1, ["Dark Souls"] = 2 } ---@param index number Index of the sound effect to play local function playTriggerEffect(index) local SoundCube = guidReferenceApi.getObjectByOwnerAndType("Mythos", "SoundCube") SoundCube.AssetBundle.playTriggerEffect(index) end -- plays the by name requested sound ---@param soundName string Name of the sound to play SoundCubeApi.playSoundByName = function(soundName) playTriggerEffect(soundIndices[soundName]) end return SoundCubeApi end end) __bundle_register("core/token/TokenChecker", function(require, _LOADED, __bundle_register, __bundle_modules) do local CHAOS_TOKEN_NAMES = { ["Elder Sign"] = true, ["+1"] = true, ["0"] = true, ["-1"] = true, ["-2"] = true, ["-3"] = true, ["-4"] = true, ["-5"] = true, ["-6"] = true, ["-7"] = true, ["-8"] = true, ["Skull"] = true, ["Cultist"] = true, ["Tablet"] = true, ["Elder Thing"] = true, ["Auto-fail"] = true, ["Bless"] = true, ["Curse"] = true, ["Frost"] = true } local TokenChecker = {} -- returns true if the passed object is a chaos token (by name) TokenChecker.isChaosToken = function(obj) if obj.type == "Tile" and CHAOS_TOKEN_NAMES[obj.getName()] then return true else return false end end return TokenChecker end end) __bundle_register("core/token/TokenManager", function(require, _LOADED, __bundle_register, __bundle_modules) do local guidReferenceApi = require("core/GUIDReferenceApi") local optionPanelApi = require("core/OptionPanelApi") local playAreaApi = require("core/PlayAreaApi") local searchLib = require("util/SearchLib") local tokenSpawnTrackerApi = require("core/token/TokenSpawnTrackerApi") local PLAYER_CARD_TOKEN_OFFSETS = { [1] = { Vector(0, 3, -0.2) }, [2] = { Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [3] = { Vector(0, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [4] = { Vector(0.4, 3, -0.9), Vector(-0.4, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [5] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [6] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2) }, [7] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0, 3, 0.5) }, [8] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(-0.35, 3, 0.5), Vector(0.35, 3, 0.5) }, [9] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5) }, [10] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(0, 3, 1.2) }, [11] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(-0.35, 3, 1.2), Vector(0.35, 3, 1.2) }, [12] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(0.7, 3, 1.2), Vector(0, 3, 1.2), Vector(-0.7, 3, 1.2) } } -- stateIDs for the multi-stated resource tokens local stateTable = { ["resource"] = 1, ["ammo"] = 2, ["bounty"] = 3, ["charge"] = 4, ["evidence"] = 5, ["secret"] = 6, ["supply"] = 7, ["offering"] = 8 } -- Table of data extracted from the token source bag, keyed by the Memo on each token which -- should match the token type keys ("resource", "clue", etc) local tokenTemplates local playerCardData local locationData local TokenManager = {} local internal = {} -- Spawns tokens for the card. This function is built to just throw a card at it and let it do -- the work once a card has hit an area where it might spawn tokens. It will check to see if -- the card has already spawned, find appropriate data from either the uses metadata or the Data -- Helper, and spawn the tokens. ---@param card tts__Object Card to maybe spawn tokens for ---@param extraUses table A table of = which will modify the number of tokens --- spawned for that type. e.g. Akachi's playmat should pass "Charge"=1 TokenManager.spawnForCard = function(card, extraUses) if tokenSpawnTrackerApi.hasSpawnedTokens(card.getGUID()) then return end local metadata = JSON.decode(card.getGMNotes()) if metadata ~= nil then internal.spawnTokensFromUses(card, extraUses) else internal.spawnTokensFromDataHelper(card) end end -- Spawns a set of tokens on the given card. ---@param card tts__Object Card to spawn tokens on ---@param tokenType string Type of token to spawn, for example "damage", "horror" or "resource" ---@param tokenCount number How many tokens to spawn. For damage or horror this value will be set to the -- spawned state object rather than spawning multiple tokens ---@param shiftDown? number An offset for the z-value of this group of tokens ---@param subType? string Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnTokenGroup = function(card, tokenType, tokenCount, shiftDown, subType) local optionPanel = optionPanelApi.getOptions() if tokenType == "damage" or tokenType == "horror" then TokenManager.spawnCounterToken(card, tokenType, tokenCount, shiftDown) elseif tokenType == "resource" and optionPanel["useResourceCounters"] == "enabled" then TokenManager.spawnResourceCounterToken(card, tokenCount) elseif tokenType == "resource" and optionPanel["useResourceCounters"] == "custom" and tokenCount == 0 then TokenManager.spawnResourceCounterToken(card, tokenCount) else TokenManager.spawnMultipleTokens(card, tokenType, tokenCount, shiftDown, subType) end end -- Spawns a single counter token and sets the value to tokenValue. Used for damage and horror -- tokens. ---@param card tts__Object Card to spawn tokens on ---@param tokenType string type of token to spawn, valid values are "damage" and "horror". Other -- types should use spawnMultipleTokens() ---@param tokenValue number Value to set the damage/horror to TokenManager.spawnCounterToken = function(card, tokenType, tokenValue, shiftDown) if tokenValue < 1 or tokenValue > 50 then return end local pos = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[1][1] + Vector(0, 0, shiftDown)) local rot = card.getRotation() TokenManager.spawnToken(pos, tokenType, rot, function(spawned) spawned.setState(tokenValue) end) end TokenManager.spawnResourceCounterToken = function(card, tokenCount) local pos = card.positionToWorld(card.positionToLocal(card.getPosition()) + Vector(0, 0.2, -0.5)) local rot = card.getRotation() TokenManager.spawnToken(pos, "resourceCounter", rot, function(spawned) spawned.call("updateVal", tokenCount) end) end -- Spawns a number of tokens. ---@param tokenType string type of token to spawn, valid values are resource", "doom", or "clue". -- Other types should use spawnCounterToken() ---@param tokenCount number How many tokens to spawn ---@param shiftDown? number An offset for the z-value of this group of tokens ---@param subType? string Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown, subType) -- not checking the max at this point since clue offsets are calculated dynamically if tokenCount < 1 then return end local offsets = {} if tokenType == "clue" then offsets = internal.buildClueOffsets(card, tokenCount) else -- only up to 12 offset tables defined if tokenCount > 12 then return end for i = 1, tokenCount do offsets[i] = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[tokenCount][i]) -- Fix the y-position for the spawn, since positionToWorld considers rotation which can -- have bad results for face up/down differences offsets[i].y = card.getPosition().y + 0.15 end end if shiftDown ~= nil then -- Copy the offsets to make sure we don't change the static values local baseOffsets = offsets offsets = {} -- get a vector for the shifting (downwards local to the card) local shiftDownVector = Vector(0, 0, shiftDown):rotateOver("y", card.getRotation().y) for i, baseOffset in ipairs(baseOffsets) do offsets[i] = baseOffset + shiftDownVector end end if offsets == nil then error("couldn't find offsets for " .. tokenCount .. ' tokens') return end -- handling for not provided subtype (for example when spawning from custom data helpers) if subType == nil then subType = "" end -- this is used to load the correct state for additional resource tokens (e.g. "Ammo") local callback = nil local stateID = stateTable[string.lower(subType)] if tokenType == "resource" and stateID ~= nil and stateID ~= 1 then callback = function(spawned) spawned.setState(stateID) end end for i = 1, tokenCount do TokenManager.spawnToken(offsets[i], tokenType, card.getRotation(), callback) end end -- Spawns a single token at the given global position by copying it from the template bag. ---@param position tts__Vector Global position to spawn the token ---@param tokenType string type of token to spawn, valid values are "damage", "horror", -- "resource", "doom", or "clue" ---@param rotation tts__Vector Rotation to be used for the new token. Only the y-value will be used, -- x and z will use the default rotation from the source bag ---@param callback? function A callback function triggered after the new token is spawned TokenManager.spawnToken = function(position, tokenType, rotation, callback) internal.initTokenTemplates() local loadTokenType = tokenType if tokenType == "clue" or tokenType == "doom" then loadTokenType = "clueDoom" end if tokenTemplates[loadTokenType] == nil then error("Unknown token type '" .. tokenType .. "'") return end local tokenTemplate = tokenTemplates[loadTokenType] -- Take ONLY the Y-value for rotation, so we don't flip the token coming out of the bag local rot = Vector(tokenTemplate.Transform.rotX, 270, tokenTemplate.Transform.rotZ) if rotation ~= nil then rot.y = rotation.y end if tokenType == "doom" then rot.z = 180 end tokenTemplate.Nickname = "" return spawnObjectData({ data = tokenTemplate, position = position, rotation = rot, callback_function = callback }) end -- Checks a card for metadata to maybe replenish it ---@param card tts__Object Card object to be replenished ---@param uses table The already decoded metadata.uses (to avoid decoding again) ---@param mat tts__Object The playmat the card is placed on (for rotation and casting) TokenManager.maybeReplenishCard = function(card, uses, mat) -- TODO: support for cards with multiple uses AND replenish (as of yet, no official card needs that) if uses[1].count and uses[1].replenish then internal.replenishTokens(card, uses, mat) end end -- Delegate function to the token spawn tracker. Exists to avoid circular dependencies in some -- callers. ---@param card tts__Object Card object to reset the tokens for TokenManager.resetTokensSpawned = function(card) tokenSpawnTrackerApi.resetTokensSpawned(card.getGUID()) end -- Pushes new player card data into the local copy of the Data Helper player data. ---@param dataTable table Key/Value pairs following the DataHelper style TokenManager.addPlayerCardData = function(dataTable) internal.initDataHelperData() for k, v in pairs(dataTable) do playerCardData[k] = v end end -- Pushes new location data into the local copy of the Data Helper location data. ---@param dataTable table Key/Value pairs following the DataHelper style TokenManager.addLocationData = function(dataTable) internal.initDataHelperData() for k, v in pairs(dataTable) do locationData[k] = v end end -- Checks to see if the given card has location data in the DataHelper ---@param card tts__Object Card to check for data ---@return boolean: True if this card has data in the helper, false otherwise TokenManager.hasLocationData = function(card) internal.initDataHelperData() return internal.getLocationData(card) ~= nil end internal.initTokenTemplates = function() if tokenTemplates ~= nil then return end tokenTemplates = {} local tokenSource = guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenSource") for _, tokenTemplate in ipairs(tokenSource.getData().ContainedObjects) do local tokenName = tokenTemplate.Memo tokenTemplates[tokenName] = tokenTemplate end end -- Copies the data from the DataHelper. Will only happen once. internal.initDataHelperData = function() if playerCardData ~= nil then return end local dataHelper = guidReferenceApi.getObjectByOwnerAndType("Mythos", "DataHelper") playerCardData = dataHelper.getTable('PLAYER_CARD_DATA') locationData = dataHelper.getTable('LOCATIONS_DATA') end -- Spawn tokens for a card based on the uses metadata. This will consider the face up/down state -- of the card for both locations and standard cards. ---@param card tts__Object Card to maybe spawn tokens for ---@param extraUses table A table of = which will modify the number of tokens --- spawned for that type. e.g. Akachi's playmat should pass "Charge"=1 internal.spawnTokensFromUses = function(card, extraUses) local uses = internal.getUses(card) if uses == nil then return end -- go through tokens to spawn local tokenCount for i, useInfo in ipairs(uses) do tokenCount = (useInfo.count or 0) + (useInfo.countPerInvestigator or 0) * playAreaApi.getInvestigatorCount() if extraUses ~= nil and extraUses[useInfo.type] ~= nil then tokenCount = tokenCount + extraUses[useInfo.type] end -- Shift each spawned group after the first down so they don't pile on each other TokenManager.spawnTokenGroup(card, useInfo.token, tokenCount, (i - 1) * 0.8, useInfo.type) end tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a card based on the data helper data. This will consider the face up/down state -- of the card for both locations and standard cards. ---@param card tts__Object Card to maybe spawn tokens for internal.spawnTokensFromDataHelper = function(card) internal.initDataHelperData() local playerData = internal.getPlayerCardData(card) if playerData ~= nil then internal.spawnPlayerCardTokensFromDataHelper(card, playerData) end local locationData = internal.getLocationData(card) if locationData ~= nil then internal.spawnLocationTokensFromDataHelper(card, locationData) end end -- Spawn tokens for a player card using data retrieved from the Data Helper. ---@param card tts__Object Card to maybe spawn tokens for ---@param playerData table Player card data structure retrieved from the DataHelper. Should be -- the right data for this card. internal.spawnPlayerCardTokensFromDataHelper = function(card, playerData) local token = playerData.tokenType local tokenCount = playerData.tokenCount TokenManager.spawnTokenGroup(card, token, tokenCount) tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a location using data retrieved from the Data Helper. ---@param card tts__Object Card to maybe spawn tokens for ---@param locationData table Location data structure retrieved from the DataHelper. Should be -- the right data for this card. internal.spawnLocationTokensFromDataHelper = function(card, locationData) local clueCount = internal.getClueCountFromData(card, locationData) if clueCount > 0 then TokenManager.spawnTokenGroup(card, "clue", clueCount) tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end end internal.getPlayerCardData = function(card) return playerCardData[card.getName() .. ':' .. card.getDescription()] or playerCardData[card.getName()] end internal.getLocationData = function(card) return locationData[card.getName() .. '_' .. card.getGUID()] or locationData[card.getName()] end internal.getClueCountFromData = function(card, locationData) -- Return the number of clues to spawn on this location if locationData == nil then error('attempted to get clue for unexpected object: ' .. card.getName()) return 0 end if ((card.is_face_down and locationData.clueSide == 'back') or (not card.is_face_down and locationData.clueSide == 'front')) then if locationData.type == 'fixed' then return locationData.value elseif locationData.type == 'perPlayer' then return locationData.value * playAreaApi.getInvestigatorCount() end error('unexpected location type: ' .. locationData.type) end return 0 end -- Gets the right uses structure for this card, based on metadata and face up/down state ---@param card tts__Object Card to pull the uses from internal.getUses = function(card) local metadata = JSON.decode(card.getGMNotes()) or {} if metadata.type == "Location" then if card.is_face_down and metadata.locationBack ~= nil then return metadata.locationBack.uses elseif not card.is_face_down and metadata.locationFront ~= nil then return metadata.locationFront.uses end elseif not card.is_face_down then return metadata.uses end return nil end -- Dynamically create positions for clues on a card. ---@param card tts__Object Card the clues will be placed on ---@param count number How many clues? ---@return table: Array of global positions to spawn the clues at internal.buildClueOffsets = function(card, count) local cluePositions = {} for i = 1, count do local row = math.floor(1 + (i - 1) / 4) local column = (i - 1) % 4 local cluePos = card.positionToWorld(Vector(-0.825 + 0.55 * column, 0, -1.5 + 0.55 * row)) cluePos.y = cluePos.y + 0.05 table.insert(cluePositions, cluePos) end return cluePositions end ---@param card tts__Object Card object to be replenished ---@param uses table The already decoded metadata.uses (to avoid decoding again) ---@param mat tts__Object The playmat the card is placed on (for rotation and casting) internal.replenishTokens = function(card, uses, mat) local cardPos = card.getPosition() -- don't continue for cards on the deck (Norman) or in the discard pile if mat.positionToLocal(cardPos).x < -1 then return end -- get current amount of resource tokens on the card local clickableResourceCounter = nil local foundTokens = 0 for _, obj in ipairs(searchLib.onObject(card, "isTileOrToken")) do local memo = obj.getMemo() if (stateTable[memo] or 0) > 0 then foundTokens = foundTokens + math.abs(obj.getQuantity()) obj.destruct() elseif memo == "resourceCounter" then foundTokens = obj.getVar("val") clickableResourceCounter = obj break end end -- this is the theoretical new amount of uses (to be checked below) local newCount = foundTokens + uses[1].replenish -- if there are already more uses than the replenish amount, keep them if foundTokens > uses[1].count then newCount = foundTokens -- only replenish up until the replenish amount elseif newCount > uses[1].count then newCount = uses[1].count end -- update the clickable counter or spawn a group of tokens if clickableResourceCounter then clickableResourceCounter.call("updateVal", newCount) else TokenManager.spawnTokenGroup(card, uses[1].token, newCount, _, uses[1].type) end end return TokenManager end end) __bundle_register("core/token/TokenSpawnTrackerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenSpawnTracker = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getSpawnTracker() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenSpawnTracker") end TokenSpawnTracker.hasSpawnedTokens = function(cardGuid) return getSpawnTracker().call("hasSpawnedTokens", cardGuid) end TokenSpawnTracker.markTokensSpawned = function(cardGuid) return getSpawnTracker().call("markTokensSpawned", cardGuid) end TokenSpawnTracker.resetTokensSpawned = function(cardGuid) return getSpawnTracker().call("resetTokensSpawned", cardGuid) end TokenSpawnTracker.resetAllAssetAndEvents = function() return getSpawnTracker().call("resetAllAssetAndEvents") end TokenSpawnTracker.resetAllLocations = function() return getSpawnTracker().call("resetAllLocations") end TokenSpawnTracker.resetAll = function() return getSpawnTracker().call("resetAll") end return TokenSpawnTracker 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 -- 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 -- 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("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")