-- 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("core/NavigationOverlayApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local NavigationOverlayApi = {} local HANDLER_GUID = "797ede" -- 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) getObjectFromGUID(HANDLER_GUID).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) getObjectFromGUID(HANDLER_GUID).call("cycleVisibility", playerColor) end return NavigationOverlayApi end end) __bundle_register("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlayAreaApi = { } local PLAY_AREA_GUID = "721ba2" local IMAGE_SWAPPER = "b7b45b" -- Returns the current value of the investigator counter from the playmat ---@return Integer. Number of investigators currently set on the counter PlayAreaApi.getInvestigatorCount = function() return getObjectFromGUID(PLAY_AREA_GUID).call("getInvestigatorCount") end -- Updates the current value of the investigator counter from the playmat ---@param count Number of investigators to set on the counter PlayAreaApi.setInvestigatorCount = function(count) return getObjectFromGUID(PLAY_AREA_GUID).call("setInvestigatorCount", count) end -- Move all contents on the play area (cards, tokens, etc) one slot in the given direction. Certain -- fixed objects will be ignored, as will anything the player has tagged with -- 'displacement_excluded' ---@param playerColor Color of the player requesting the shift. Used solely to send an error --- message in the unlikely case that the scripting zone has been deleted PlayAreaApi.shiftContentsUp = function(playerColor) return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsUp", playerColor) end PlayAreaApi.shiftContentsDown = function(playerColor) return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsDown", playerColor) end PlayAreaApi.shiftContentsLeft = function(playerColor) return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsLeft", playerColor) end PlayAreaApi.shiftContentsRight = function(playerColor) return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsRight", playerColor) end -- Reset the play area's tracking of which cards have had tokens spawned. PlayAreaApi.resetSpawnedCards = function() return getObjectFromGUID(PLAY_AREA_GUID).call("resetSpawnedCards") end -- Event to be called when the current scenario has changed. ---@param scenarioName Name of the new scenario PlayAreaApi.onScenarioChanged = function(scenarioName) getObjectFromGUID(PLAY_AREA_GUID).call("onScenarioChanged", scenarioName) end -- Sets this playmat's snap points to limit snapping to locations or not. -- If matchTypes is false, snap points will be reset to snap all cards. ---@param matchTypes Boolean Whether snap points should only snap for the matching card types. PlayAreaApi.setLimitSnapsByType = function(matchCardTypes) getObjectFromGUID(PLAY_AREA_GUID).call("setLimitSnapsByType", matchCardTypes) end -- Receiver for the Global tryObjectEnterContainer event. Used to clear vector lines from dragged -- cards before they're destroyed by entering the container PlayAreaApi.tryObjectEnterContainer = function(container, object) getObjectFromGUID(PLAY_AREA_GUID).call("tryObjectEnterContainer", { container = container, object = object }) end -- counts the VP on locations in the play area PlayAreaApi.countVP = function() return getObjectFromGUID(PLAY_AREA_GUID).call("countVP") end -- highlights all locations in the play area without metadata ---@param state Boolean True if highlighting should be enabled PlayAreaApi.highlightMissingData = function(state) return getObjectFromGUID(PLAY_AREA_GUID).call("highlightMissingData", state) end -- highlights all locations in the play area with VP ---@param state Boolean True if highlighting should be enabled PlayAreaApi.highlightCountedVP = function(state) return getObjectFromGUID(PLAY_AREA_GUID).call("highlightCountedVP", state) end -- Checks if an object is in the play area (returns true or false) PlayAreaApi.isInPlayArea = function(object) return getObjectFromGUID(PLAY_AREA_GUID).call("isInPlayArea", object) end PlayAreaApi.getSurface = function() return getObjectFromGUID(PLAY_AREA_GUID).getCustomObject().image end PlayAreaApi.updateSurface = function(url) return getObjectFromGUID(IMAGE_SWAPPER).call("updateSurface", url) end return PlayAreaApi end end) __bundle_register("core/token/TokenManager", function(require, _LOADED, __bundle_register, __bundle_modules) do local tokenSpawnTracker = require("core/token/TokenSpawnTrackerApi") local playArea = require("core/PlayAreaApi") 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 } -- Source for tokens local TOKEN_SOURCE_GUID = "124381" -- 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 DATA_HELPER_GUID = "708279" 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 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 tokenSpawnTracker.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 Object Card to spawn tokens on ---@param tokenType String Type of token to spawn, valid values are "damage", "horror", -- "resource", "doom", or "clue" ---@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 Number 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 = Global.getTable("optionPanel") if tokenType == "damage" or tokenType == "horror" then TokenManager.spawnCounterToken(card, tokenType, tokenCount, shiftDown) elseif tokenType == "resource" and optionPanel["useResourceCounters"] 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 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 Number Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown, subType) if tokenCount < 1 or tokenCount > 12 then return end local offsets = {} if tokenType == "clue" then offsets = internal.buildClueOffsets(card, tokenCount) else 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 = { } for i, baseOffset in ipairs(baseOffsets) do offsets[i] = baseOffset offsets[i][3] = offsets[i][3] + shiftDown 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 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 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 Object Card object to be replenished ---@param uses Table The already decoded metadata.uses (to avoid decoding again) ---@param mat 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 Object Card object to reset the tokens for TokenManager.resetTokensSpawned = function(card) tokenSpawnTracker.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 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 = getObjectFromGUID(TOKEN_SOURCE_GUID) 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 = getObjectFromGUID(DATA_HELPER_GUID) 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 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 type, token, tokenCount for i, useInfo in ipairs(uses) do type = useInfo.type token = useInfo.token tokenCount = (useInfo.count or 0) + (useInfo.countPerInvestigator or 0) * playArea.getInvestigatorCount() if extraUses ~= nil and extraUses[type] ~= nil then tokenCount = tokenCount + extraUses[type] end -- Shift each spawned group after the first down so they don't pile on each other TokenManager.spawnTokenGroup(card, token, tokenCount, (i - 1) * 0.8, type) end tokenSpawnTracker.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 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 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) token = playerData.tokenType tokenCount = playerData.tokenCount --log("Spawning data helper tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token) TokenManager.spawnTokenGroup(card, token, tokenCount) tokenSpawnTracker.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a location using data retrieved from the Data Helper. ---@param card Object Card to maybe spawn tokens for ---@param playerData 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) tokenSpawnTracker.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 --log(card.getName() .. ' : ' .. locationData.type .. ' : ' .. locationData.value .. ' : ' .. locationData.clueSide) 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 * playArea.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 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 Object Card the clues will be placed on ---@param count Integer How many clues? ---@return Table Array of global positions to spawn the clues at internal.buildClueOffsets = function(card, count) local pos = card.getPosition() local cluePositions = { } for i = 1, count do local column = math.floor((i - 1) / 4) local row = (i - 1) % 4 table.insert(cluePositions, Vector(pos.x - 1 + 0.55 * row, pos.y, pos.z - 1.4 - 0.55 * column)) end return cluePositions end ---@param card Object Card object to be replenished ---@param uses Table The already decoded metadata.uses (to avoid decoding again) ---@param mat 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 search = internal.searchOnCard(cardPos, card.getRotation()) local clickableResourceCounter = nil local foundTokens = 0 for _, obj in ipairs(search) do local obj = obj.hit_object 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 -- searches on a card (standard size) and returns the result ---@param position Table Position of the card ---@param rotation Table Rotation of the card internal.searchOnCard = function(position, rotation) return Physics.cast({ origin = position, direction = {0, 1, 0}, orientation = rotation, type = 3, size = { 2.5, 0.5, 3.5 }, max_distance = 1, debug = false }) end return TokenManager end end) __bundle_register("core/token/TokenSpawnTrackerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenSpawnTracker = { } local SPAWN_TRACKER_GUID = "e3ffc9" TokenSpawnTracker.hasSpawnedTokens = function(cardGuid) return getObjectFromGUID(SPAWN_TRACKER_GUID).call("hasSpawnedTokens", cardGuid) end TokenSpawnTracker.markTokensSpawned = function(cardGuid) return getObjectFromGUID(SPAWN_TRACKER_GUID).call("markTokensSpawned", cardGuid) end TokenSpawnTracker.resetTokensSpawned = function(cardGuid) return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetTokensSpawned", cardGuid) end TokenSpawnTracker.resetAllAssetAndEvents = function() return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAllAssetAndEvents") end TokenSpawnTracker.resetAllLocations = function() return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAllLocations") end TokenSpawnTracker.resetAll = function() return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAll") end return TokenSpawnTracker end end) __bundle_register("accessories/TokenArrangerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenArrangerApi = {} -- local function to call the token arranger, if it is on the table ---@param functionName String Name of the function to cal ---@param argument Variant Parameter to pass local function callIfExistent(functionName, argument) local tokenArranger = getObjectsWithTag("TokenArranger")[1] if tokenArranger ~= nil then tokenArranger.call(functionName, argument) end end -- updates the token modifiers with the provided data ---@param tokenData 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 MANAGER_GUID = "5933fb" -- removes all taken tokens and resets the counts BlessCurseManagerApi.removeTakenTokensAndReset = function() local BlessCurseManager = getObjectFromGUID(MANAGER_GUID) 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) BlessCurseManagerApi.sealedToken = function(type, guid) getObjectFromGUID(MANAGER_GUID).call("sealedToken", { type = type, guid = guid }) end -- updates the internal count (called by cards that seal bless/curse tokens) BlessCurseManagerApi.releasedToken = function(type, guid) getObjectFromGUID(MANAGER_GUID).call("releasedToken", { 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) getObjectFromGUID(MANAGER_GUID).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) getObjectFromGUID(MANAGER_GUID).call("doRemove", playerColor) end -- adds Wendy's menu to the hovered card (allows sealing of tokens) ---@param color String Color of the player to show the broadcast to BlessCurseManagerApi.addWendysMenu = function(playerColor, hoveredObject) getObjectFromGUID(MANAGER_GUID).call("addMenuOptions", { playerColor = playerColor, hoveredObject = hoveredObject }) end return BlessCurseManagerApi end end) __bundle_register("core/MythosAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local MythosAreaApi = {} local MYTHOS_AREA_GUID = "9f334f" -- returns the chaos token metadata (if provided through scenario reference card) MythosAreaApi.returnTokenData = function() return getObjectFromGUID(MYTHOS_AREA_GUID).call("returnTokenData") end return MythosAreaApi 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 CHAOS_TOKEN_NAMES[obj.getName()] then return true else return false end end return TokenChecker end end) __bundle_register("playermat/PlaymatApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlaymatApi = { } local internal = { } local MAT_IDS = { White = "8b081b", Orange = "bd0ff4", Green = "383d8b", Red = "0840d5" } local CLUE_COUNTER_GUIDS = { White = "37be78", Orange = "1769ed", Green = "032300", Red = "d86b7c" } local CLUE_CLICKER_GUIDS = { White = "db85d6", Orange = "3f22e5", Green = "891403", Red = "4111de" } -- Returns the color of the by position requested playermat as string ---@param startPos Table Position of the search, table get's roughly cut into 4 quarters to assign a playermat PlaymatApi.getMatColorByPosition = function(startPos) if startPos.x < -42 then if startPos.z > 0 then return "White" else return "Orange" end else if startPos.z > 0 then return "Green" else return "Red" end end end -- Returns the color of the player's hand that is seated next to the playermat ---@param matColor String Color of the playermat PlaymatApi.getPlayerColor = function(matColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) return mat.getVar("playerColor") end -- Returns the color of the playermat that owns the playercolor's hand ---@param handColor String Color of the playermat PlaymatApi.getMatColor = function(handColor) local matColors = {"White", "Orange", "Green", "Red"} for i, mat in ipairs(internal.getMatForColor("All")) do local color = mat.getVar("playerColor") if color == handColor then return matColors[i] end end return "NOT_FOUND" end -- Returns the result of a cast in the specificed playermat's area ---@param matColor String Color of the playermat PlaymatApi.searchPlaymat = function(matColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) return mat.call("searchAroundSelf") end -- Returns if there is the card "Dream-Enhancing Serum" on the requested playermat ---@param matColor String Color of the playermat PlaymatApi.isDES = function(matColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) return mat.getVar("isDES") end -- Returns the draw deck of the requested playmat ---@param matColor String Color of the playermat PlaymatApi.getDrawDeck = function(matColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) mat.call("getDrawDiscardDecks") return mat.getVar("drawDeck") end -- Returns the position of the discard pile of the requested playmat ---@param matColor String Color of the playermat PlaymatApi.getDiscardPosition = function(matColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) return mat.call("returnGlobalDiscardPosition") end -- Transforms a local position into a global position ---@param localPos Table Local position to be transformed ---@param matColor String Color of the playermat PlaymatApi.transformLocalPosition = function(localPos, matColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) return mat.positionToWorld(localPos) end -- Returns the rotation of the requested playmat ---@param matColor String Color of the playermat PlaymatApi.returnRotation = function(matColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) return mat.getRotation() end -- Triggers the Upkeep for the requested playmat ---@param matColor String Color of the playermat ---@param playerColor String Color of the calling player (for messages) PlaymatApi.doUpkeepFromHotkey = function(matColor, playerColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) return mat.call("doUpkeepFromHotkey", playerColor) end -- Returns the active investigator id ---@param matColor String Color of the playermat PlaymatApi.returnInvestigatorId = function(matColor) local mat = getObjectFromGUID(MAT_IDS[matColor]) return mat.getVar("activeInvestigatorId") end -- Sets the requested playermat's snap points to limit snapping to matching card types or not. If -- matchTypes is true, the main card slot snap points will only snap assets, while the -- investigator area point will only snap Investigators. If matchTypes is false, snap points will -- be reset to snap all cards. ---@param matchCardTypes Boolean. Whether snap points should only snap for the matching card -- types. ---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also -- accepts "All" as a special value which will apply the setting to all four mats. PlaymatApi.setLimitSnapsByType = function(matchCardTypes, matColor) for _, mat in ipairs(internal.getMatForColor(matColor)) do mat.call("setLimitSnapsByType", matchCardTypes) end end -- Sets the requested playermat's draw 1 button to visible ---@param isDrawButtonVisible Boolean. Whether the draw 1 button should be visible or not ---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also -- accepts "All" as a special value which will apply the setting to all four mats. PlaymatApi.showDrawButton = function(isDrawButtonVisible, matColor) for _, mat in ipairs(internal.getMatForColor(matColor)) do mat.call("showDrawButton", isDrawButtonVisible) end end -- Shows or hides the clickable clue counter for the requested playermat ---@param showCounter Boolean. Whether the clickable counter should be present or not ---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also -- accepts "All" as a special value which will apply the setting to all four mats. PlaymatApi.clickableClues = function(showCounter, matColor) for _, mat in ipairs(internal.getMatForColor(matColor)) do mat.call("clickableClues", showCounter) end end -- Removes all clues (to the trash for tokens and counters set to 0) for the requested playermat ---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also -- accepts "All" as a special value which will apply the setting to all four mats. PlaymatApi.removeClues = function(matColor) for _, mat in ipairs(internal.getMatForColor(matColor)) do mat.call("removeClues") end end -- Reports the clue count for the requested playermat ---@param useClickableCounters Boolean Controls which type of counter is getting checked PlaymatApi.getClueCount = function(useClickableCounters, matColor) local count = 0 for _, mat in ipairs(internal.getMatForColor(matColor)) do count = count + tonumber(mat.call("getClueCount", useClickableCounters)) end return count end -- Adds the specified amount of resources to the requested playermat's resource counter PlaymatApi.gainResources = function(amount, matColor) for _, mat in ipairs(internal.getMatForColor(matColor)) do mat.call("gainResources", amount) end end -- Discard a non-hidden card from the corresponding player's hand PlaymatApi.doDiscardOne = function(matColor) for _, mat in ipairs(internal.getMatForColor(matColor)) do mat.call("doDiscardOne") end end PlaymatApi.syncAllCustomizableCards = function() for _, mat in ipairs(internal.getMatForColor("All")) do mat.call("syncAllCustomizableCards") end end -- Convenience function to look up a mat's object by color, or get all mats. ---@param matColor String for one of the active player colors - White, Orange, Green, Red. Also -- accepts "All" as a special value which will return all four mats. ---@return: Array of playermat objects. If a single mat is requested, will return a single-element -- array to simplify processing by consumers. internal.getMatForColor = function(matColor) local targetMatGuid = MAT_IDS[matColor] if targetMatGuid != nil then return { getObjectFromGUID(targetMatGuid) } end if matColor == "All" then return { getObjectFromGUID(MAT_IDS.White), getObjectFromGUID(MAT_IDS.Orange), getObjectFromGUID(MAT_IDS.Green), getObjectFromGUID(MAT_IDS.Red), } end end return PlaymatApi end end) __bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules) require("core/Global") end) __bundle_register("core/Global", function(require, _LOADED, __bundle_register, __bundle_modules) --------------------------------------------------------- -- general setup --------------------------------------------------------- ENCOUNTER_DECK_POS = {-3.93, 1, 5.76} ENCOUNTER_DECK_DISCARD_POSITION = {-3.85, 1, 10.38} -- optionPanel data optionPanel = {} -- GUID of data helper DATA_HELPER_GUID = "708279" -- GUIDs that will not be interactable (e.g. parts of the table) local NOT_INTERACTABLE = { "6161b4", -- Decoration-Map "721ba2", -- PlayArea "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 chaosTokensLastMat = nil local IS_RESHUFFLING = false local bagSearchers = {} local MAT_COLORS = {"White", "Orange", "Green", "Red"} local hideTitleSplashWaitFunctionId = nil local playmatApi = require("playermat/PlaymatApi") local tokenManager = require("core/token/TokenManager") local playAreaAPI = require("core/PlayAreaApi") local soundCubeApi = require("core/SoundCubeApi") local mythosAreaApi = require("core/MythosAreaApi") local tokenArrangerApi = require("accessories/TokenArrangerApi") local blessCurseManagerApi = require("chaosbag/BlessCurseManagerApi") local navigationOverlayApi = require("core/NavigationOverlayApi") local tokenChecker = require("core/token/TokenChecker") -- online functionality related variables local MOD_VERSION = "3.2.0" local SOURCE_REPO = 'https://raw.githubusercontent.com/chr1z93/loadable-objects/main' local library, requestObj, modMeta, notificationVisible local acknowledgedUpgradeVersions = {} 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" } } --------------------------------------------------------- -- 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/'} } --------------------------------------------------------- -- data for chaos token stat tracker --------------------------------------------------------- local MAT_GUID_TO_COLOR = { ["Overall"] = "Overall", ["8b081b"] = "White", ["bd0ff4"] = "Orange", ["383d8b"] = "Green", ["0840d5"] = "Red" } local tokenDrawingStats = { ["Overall"] = {}, ["8b081b"] = {}, ["bd0ff4"] = {}, ["383d8b"] = {}, ["0840d5"] = {} } --------------------------------------------------------- -- general code --------------------------------------------------------- -- saving state of optionPanel to restore later function onSave() return JSON.encode({ optionPanel = optionPanel, acknowledgedUpgradeVersions = acknowledgedUpgradeVersions }) end function onLoad(savedData) if savedData then loadedData = JSON.decode(savedData) optionPanel = loadedData.optionPanel acknowledgedUpgradeVersions = loadedData.acknowledgedUpgradeVersions updateOptionPanelState() -- hack to set options on load applyOptionPanelChange("useClueClickers", false) applyOptionPanelChange("useResourceCounters", true) applyOptionPanelChange("showTokenArranger", true) applyOptionPanelChange("showChaosBagManager", true) 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 resetChaosTokenStatTracker() getModVersion() math.randomseed(os.time()) end --------------------------------------------------------- -- encounter card drawing --------------------------------------------------------- function isDeck(x) return x.tag == 'Deck' end function isCardOrDeck(x) return x.tag == 'Card' or x.tag == 'Deck' 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) 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) 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 function drawEncountercard(color) --[[ Parameter Player color ]] local items = findInRadiusBy(ENCOUNTER_DECK_POS, 4, isCardOrDeck) if #items > 0 then for _, v in ipairs(items) do if v.tag == 'Deck' then v.deal(1, color) return end end -- we didn't find the deck so just pull the first thing we did find items[1].deal(1, color) else -- nothing here, time to reshuffle reshuffleEncounterDeck(color) end end function actualEncounterCardDraw(card, params) local position = params[1] local rotation = params[2] local alwaysFaceUp = params[3] local faceUpRotation = 0 if not alwaysFaceUp then local metadata = JSON.decode(card.getGMNotes()) or {} if metadata.hidden or getObjectFromGUID(DATA_HELPER_GUID).call('checkHiddenCard', card.getName()) then faceUpRotation = 180 end end card.setPositionSmooth(position, false, false) card.setRotationSmooth({0, rotation.y, faceUpRotation}, false, false) end function reshuffleEncounterDeck(color) -- finishes moving the deck back and draws a card local function move(deck) deck.setPositionSmooth({ENCOUNTER_DECK_POS[1], ENCOUNTER_DECK_POS[2] + 2, ENCOUNTER_DECK_POS[3]}, false, true) deck.deal(1, color) Wait.time(function() IS_RESHUFFLING = false end, 1) end -- bail out if we're mid reshuffle if IS_RESHUFFLING then return end local discarded = findInRadiusBy(ENCOUNTER_DECK_DISCARD_POSITION, 4, isDeck) if #discarded > 0 then IS_RESHUFFLING = true local deck = discarded[1] if not deck.is_face_down then deck.flip() end deck.shuffle() Wait.time(|| move(deck), 0.3) else printToAll("Couldn't find encounter discard pile to reshuffle.", {1, 0, 0}) end end function findInRadiusBy(pos, radius, filter) local objList = Physics.cast({ origin = pos, direction = {0, 1, 0}, type = 2, size = {radius, radius, radius}, max_distance = 0 }) local filteredList = {} for _, obj in ipairs(objList) do if filter and filter(obj.hit_object) then table.insert(filteredList, obj.hit_object) end end return filteredList end --------------------------------------------------------- -- chaos token drawing --------------------------------------------------------- -- checks scripting zone for chaos bag (also called by a lot of objects!) function findChaosBag() local chaosbag_zone = getObjectFromGUID("83ef06") -- error handling: scripting zone not found if chaosbag_zone == nil then printToAll("Zone for chaos bag detection couldn't be found.", "Red") return end for _, item in ipairs(chaosbag_zone.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() for _, token in pairs(chaosTokens) do if token ~= nil then chaosbag.putObject(token) end end chaosTokens = {} 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 mat = params[1] local tokenOffset = params[2] local isRightClick = params[3] chaosbag = findChaosBag() -- return token(s) on other playmat first if chaosTokensLastMat ~= nil and chaosTokensLastMat ~= mat and #chaosTokens ~= 0 then returnChaosTokens() chaosTokensLastMat = nil return end chaosTokensLastMat = mat -- if we have left clicked and have no tokens OR if we have right clicked if isRightClick or #chaosTokens == 0 then 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 = chaosbag.takeObject({ index = 0, position = mat.positionToWorld(tokenOffset), rotation = mat.getRotation() }) -- 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, mat.getGUID()) chaosTokens[#chaosTokens + 1] = token return 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) 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 playerColor = playmatApi.getPlayerColor(MAT_GUID_TO_COLOR[key]) playerName = Player[playerColor].steam_name or playerColor local playerSquidCount = personalStats["Auto-fail"] 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() for key, _ in pairs(tokenDrawingStats) do tokenDrawingStats[key] = {} for _, token in pairs(ID_URL_MAP) do tokenDrawingStats[key][token.name] = 0 end end end --------------------------------------------------------- -- Difficulty selector script --------------------------------------------------------- -- called for button creation on the difficulty selectors ---@param object object Usually "self" ---@param 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 object object Usually "self" ---@param key string Name of the scenario ---@param 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 = getObjectFromGUID(DATA_HELPER_GUID).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 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 = {} chaosTokensLastMat = 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 --------------------------------------------------------- function onClick_refreshList() local request = WebRequest.get(SOURCE_REPO .. '/library.json', completed_list_update) requestObj = request startLuaCoroutine(Global, 'downloadCoroutine') end function onClick_select(player, params) params = JSON.decode(urldecode(params)) local url = SOURCE_REPO .. '/' .. params.url local request = WebRequest.get(url, function (request) complete_obj_download(request, params) end ) requestObj = request startLuaCoroutine(Global, 'downloadCoroutine') end function onClick_load() UI.show('progress_display') UI.hide('load_button') end function onClick_toggleUi(player, title) if title == "Navigation Overlay" then navigationOverlayApi.cycleVisibility(player.color) return end UI.hide('optionPanel') UI.hide('load_ui') -- when same button is clicked or close window button is pressed, don't open UI if UI.getValue('title') ~= title and title ~= 'Hidden' then UI.setValue('title', title) if title == "Options" then UI.show('optionPanel') else update_window_content(title) UI.show('load_ui') end else UI.setValue('title', "Hidden") end end function downloadCoroutine() while requestObj do UI.setAttribute('download_progress', 'percentage', requestObj.download_progress * 100) coroutine.yield(0) end return 1 end function update_list(objects) local ui = UI.getXmlTable() local update_height = find_tag_with_id(ui, 'ui_update_height') local update_children = find_tag_with_id(update_height.children, 'ui_update_point') update_children.children = {} for _, v in ipairs(objects) do local s = JSON.encode(v); table.insert(update_children.children, { tag = 'Text', value = v.name, attributes = { onClick = 'onClick_select(' .. urlencode(JSON.encode(v)) .. ')', alignment = 'MiddleLeft' } }) end update_height.attributes.height = #(update_children.children) * 24 UI.setXmlTable(ui) end function update_window_content(new_title) if not library then return end if new_title == 'Campaigns' then update_list(library.campaigns) elseif new_title == 'Standalone Scenarios' then update_list(library.scenarios) elseif new_title == 'Investigators' then update_list(library.investigators) elseif new_title == 'Community Content' then update_list(library.community) elseif new_title == 'Extras' then update_list(library.extras) else update_list({}) end end function complete_obj_download(request, params) assert(request.is_done) if request.is_error or request.response_code ~= 200 then print('error: ' .. request.error) else if pcall(function() local replaced_object pcall(function() if params.replace then replaced_object = getObjectFromGUID(params.replace) end end) local json = request.text if replaced_object then local pos = replaced_object.getPosition() local rot = replaced_object.getRotation() destroyObject(replaced_object) Wait.frames(function() spawnObjectJSON({json = json, position = pos, rotation = rot}) end, 1) else spawnObjectJSON({json = json}) end end) then print('Object loaded.') else print('Error loading object.') end end requestObj = nil UI.setAttribute('download_progress', 'percentage', 100) end -- the download button on the placeholder objects calls this to directly initiate a download -- params is a table with url and guid of replacement object, which happens to match what onClick_select wants function placeholder_download(params) onClick_select(nil, JSON.encode(params)) end function completed_list_update(request) assert(request.is_done) if request.is_error or request.response_code ~= 200 then print('error: ' .. request.error) else local json_response = nil if pcall(function () json_response = JSON.decode(request.text) end) then library = json_response update_window_content(UI.getValue('title')) else print('error parsing downloaded library') end end requestObj = nil UI.setAttribute('download_progress', 'percentage', 100) end function find_tag_with_id(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 = find_tag_with_id(obj.children, id) if result then return result end end end return nil end function urlencode(str) local str = string.gsub(str, "([^A-Za-z0-9-_.~])", function (c) return string.format("%%%02X", string.byte(c)) end) return str end function urldecode(str) local str = string.gsub(str, "%%(%x%x)", function (h) return string.char(tonumber(h, 16)) end) return str 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 -- 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 -- 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 (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 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 getObjectFromGUID("4a3aa4").setVar("useClickableCounters", state) -- option: Clickable resource counters elseif id == "useResourceCounters" then 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: 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 for i, color in ipairs(MAT_COLORS) do local pos = playmatApi.transformLocalPosition({0.05, 0, -1.182}, color) local rot = playmatApi.returnRotation(color) optionPanel[id][i] = spawnOrRemoveHelper(state, "Hand Helper", pos, rot) end -- option: Show search assistant for each player elseif id == "showSearchAssistant" then for i, color in ipairs(MAT_COLORS) do local pos = playmatApi.transformLocalPosition({-0.3, 0, -1.182}, color) local rot = playmatApi.returnRotation(color) optionPanel[id][i] = spawnOrRemoveHelper(state, "Search Assistant", pos, rot) end -- 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", {65, 1.6, -11}) -- option: Show custom playmat images elseif id == "showCustomPlaymatImages" then optionPanel[id] = spawnOrRemoveHelper(state, "Custom Playmat Images", {67.5, 1.6, 37}) -- option: Show displacement tool elseif id == "showDisplacementTool" then optionPanel[id] = spawnOrRemoveHelper(state, "Displacement Tool", {-57, 1.6, 46}) 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 Vector Position of the object (where it will spawn) ---@param rotation Vector Rotation of the object for spawning (default: {0, 270, 0}) ---@return. GUID of the spawnedObj (or nil if object was removed) function spawnOrRemoveHelper(state, name, position, rotation) if (type(state) == "table" and #state == 0) then return removeHelperObject(name) elseif state then Player.getPlayers()[1].pingTable(position) return spawnHelperObject(name, position, rotation).getGUID() 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 Table Desired position of the object function spawnHelperObject(name, position, rotation) local sourceBag = getObjectFromGUID("830bd0") -- 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) -- links objects name to the respective option name (to grab the GUID for removal) local referenceTable = { ["Clean Up Helper"] = "showCleanUpHelper", ["Hand Helper"] = "showHandHelper", ["Search Assistant"] = "showSearchAssistant", ["Displacement Tool"] = "showDisplacementTool", ["Custom Playmat Images"] = "showCustomPlaymatImages", ["Attachment Helper"] = "showAttachmentHelper", ["CYOA Campaign Guides"] = "showCYOA" } local data = optionPanel[referenceTable[name]] -- if there is a GUID stored, remove that object if type(data) == "string" then local obj = getObjectFromGUID(data) if obj then obj.destruct() end -- if it is a table (e.g. for the "Hand Helper", remove all of them) elseif type(data) == "table" then for _, guid in pairs(data) do local obj = getObjectFromGUID(guid) if obj then obj.destruct() end end end end -- loads saved options function loadSettings(newOptions) optionPanel = newOptions updateOptionPanelState() for id, state in pairs(optionPanel) do applyOptionPanelChange(id, state) end 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 variable optionPanel = { playAreaSnapTags = true, showAttachmentHelper = false, showCleanUpHelper = false, showCustomPlaymatImages = false, showCYOA = false, showDisplacementTool = false, showDrawButton = false, showHandHelper = {}, showSearchAssistant = {}, showTitleSplash = true, useClueClickers = false, 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 version if MOD_VERSION == 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 -- 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 -- triggered by clicking on the Finn Icon function onClick_FinnIcon() if notificationVisible then UI.hide("updateNotification") notificationVisible = false else UI.show("updateNotification") notificationVisible = true end 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 end) __bundle_register("core/SoundCubeApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local SoundCubeApi = {} local internal = {} -- this table links the name of a trigger effect to its index local soundIndices = { ["Vacuum"] = 0, ["Deep Bell"] = 1, ["Dark Souls"] = 2 } internal.playTriggerEffect = function(index) getObjectsWithTag("SoundCube")[1].AssetBundle.playTriggerEffect(index) end -- plays the by name requested sound ---@param soundName String Name of the sound to play SoundCubeApi.playSoundByName = function(soundName) internal.playTriggerEffect(soundIndices[soundName]) end return SoundCubeApi end end) return __bundle_require("__root")