diff --git a/src/arkhamdb/CommandManager.ttslua b/src/arkhamdb/CommandManager.ttslua new file mode 100644 index 00000000..3d4c4583 --- /dev/null +++ b/src/arkhamdb/CommandManager.ttslua @@ -0,0 +1,287 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-08-22 6:36 a.m. +--- + +---@class CommandTableEntry +---@field public object TTSObject +---@field public runOn ArkhamImport_Command_RunDirectives +local CommandTableEntry = {} + +---@type table +local commands = {} + +---@type table +local found_commands = {} + +---@type table +local command_state + +local function load_commands() + local command_objects = getObjectsWithTag("import_command") + + for _, object in ipairs(command_objects) do + commands[object:getVar("command_name")] = { + object = object, + runOn = object:getTable("runOn") + } + end +end + +---@param configuration ArkhamImportConfiguration +---@param message string +---@return ArkhamImport_CommandManager_InitializationResults +local function build_error(configuration, message) + return { + configuration = configuration, + is_successful = false, + error_message = message + } +end + +---@param source table +---@param updates table +local function merge_tables(source, updates) + for key, _ in pairs(source) do + local update = updates[key] + if update~=nil then + source[key] = update + end + end +end + +---@param instruction TTSObject +---@param initialization_state any +---@param arguments string[] +---@return ArkhamImport_CommandManager_InitializationResults|nil +local function run_instruction(instruction, initialization_state, arguments) + ---@type ArkhamImport_Command_DescriptionInstructionResults + local result = instruction:call("do_instruction", { + configuration = initialization_state.configuration, + command_state = initialization_state.command_state, + arguments = arguments + }) + + if (not result) or type(result)~="table" then + return build_error(initialization_state.configuration, table.concat({"Command \"", instruction:getName(), "\" did not return a table from do_instruction call. Type \"", type(result), "\" was returned."})) + end + + if not result.is_successful then + return build_error(result.configuration, result.error_message) + end + + merge_tables(initialization_state, result) +end + +---@param description string +---@param initialization_state table +---@return ArkhamImport_CommandManager_InitializationResults|nil +local function initialize_instructions(description, initialization_state) + for _, instruction in ipairs(parse(description)) do + local command = commands[instruction.command] + + if command==nil then + return build_error(initialization_state.configuration, table.concat({ "Could not find command \"", command, "\"."})) + end + + found_commands[instruction.command] = true + + if command.runOn.instructions then + local error = run_instruction(command.object, initialization_state, instruction.arguments) + if error then return error end + end + end +end + +---@param parameters ArkhamImport_CommandManager_InitializationArguments +---@return table +local function create_initialize_state(parameters) + return { + configuration = parameters.configuration, + command_state = {} + } +end + +---@param parameters ArkhamImport_CommandManager_InitializationArguments +---@return ArkhamImport_CommandManager_InitializationResults +function initialize(parameters) + found_commands = {} + load_commands() + + local initialization_state = create_initialize_state(parameters) + + local error = initialize_instructions(parameters.description, initialization_state) + if error then return error end + + command_state = initialization_state.command_state + + return { + configuration = initialization_state.configuration, + is_successful = true + } +end + +---@param parameters ArkhamImport_CommandManager_HandlerArguments +---@return table +local function create_handler_state(parameters) + return { + card = parameters.card, + handled = false, + zone = parameters.zone, + command_state = command_state + }, + { + configuration = parameters.configuration, + source_guid = parameters.source_guid + } +end + +---@param card ArkhamImportCard +---@param zone = string[] +---@param handled boolean +---@param error_message string +---@return ArkhamImport_CommandManager_HandlerResults +local function create_handler_error(card, zone, handled, error_message) + return { + handled = handled, + card = card, + zone = zone, + is_successful = false, + error_message = error_message + } +end + +---@param handler TTSObject +---@param handler_state table +---@param handler_constants table +---@return ArkhamImport_CommandManager_HandlerResults|nil +local function call_handler(handler, handler_state, handler_constants) + ---@type ArkhamImport_CommandManager_HandlerResults + local results = handler:call("handle_card", { + configuration = handler_constants.configuration, + source_guid = handler_constants.source_guid, + card = handler_state.card, + zone = handler_state.zone, + command_state = handler_state.command_state, + }) + + if not results.is_successful then return create_handler_error(results.card, results.zone, results.handled, results.error_message) end + + merge_tables(handler_state, results) + command_state = handler_state.command_state +end + +---@param handler_state table +---@param handler_constants table +---@return ArkhamImport_CommandManager_HandlerResults|nil +local function run_handlers(handler_state, handler_constants) + for command_name, _ in pairs(found_commands) do + local command = commands[command_name] + if command.runOn.handlers then + local error = call_handler(command.object, handler_state, handler_constants) + if error then return error end + + if (handler_state.handled) then return end + end + end +end + +---@param parameters ArkhamImport_CommandManager_HandlerArguments +---@return ArkhamImport_CommandManager_HandlerResults +function handle(parameters) + local handler_state, handler_constants = create_handler_state(parameters) + + local error = run_handlers(handler_state, handler_constants) + if error then return error end + + return { + handled = handler_state.handled, + card = handler_state.card, + zone = handler_state.zone, + is_successful = true + } +end + +---@param description string +---@return ArkhamImportCommandParserResult[] +function parse(description) + local input = description + + if #input<=4 then return {} end + + ---@type string + local current, l1, l2, l3 = "", "", "", "" + + local concat = table.concat + + local function advance() + current, l1, l2, l3 = l1, l2, l3, input:sub(1,1) + input = input:sub(2) + end + + local function advance_all() + current, l1, l2, l3 = input:sub(1,1), input:sub(2,2), input:sub(3,3), input:sub(4,4) + input = input:sub(5) + end + + advance_all() + + ---@type ArkhamImportCommandParserResult[] + local results = {} + + ---@type string + local command + + ---@type string[] + local arguments = {} + + ---@type string + local separator + + ---@type string[] + local result = {} + + while #current>0 do + if current=="<" and l1=="?" and l2 == "?" then + command = nil + arguments = {} + separator = l3 + result = {} + + advance_all() + elseif current == "?" and l1 == "?" and l2 == ">" then + if not command then + table.insert(results, { + command = concat(result), + arguments = {} + }) + else + table.insert(arguments, concat(result)) + table.insert(results, { + command = command, + arguments = arguments + }) + end + + separator = nil + current, l1, l2, l3 = l3, input:sub(1,1), input:sub(2,2), input:sub(3,3) + input = input:sub(4) + elseif current == separator then + if not command then + command = concat(result) + else + table.insert(arguments, concat(result)) + end + result = {} + advance() + else + if separator~=nil then + table.insert(result, current) + end + advance() + end + end + + return results +end diff --git a/src/arkhamdb/Configuration.ttslua b/src/arkhamdb/Configuration.ttslua new file mode 100644 index 00000000..fe996698 --- /dev/null +++ b/src/arkhamdb/Configuration.ttslua @@ -0,0 +1,165 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-08-19 6:39 a.m. +--- + +local Priority = { + ERROR = 0, + WARNING = 1, + INFO = 2, + DEBUG = 3 +} + +---@type ArkhamImportConfiguration +configuration = { + api_uri = "https://arkhamdb.com/api/public", + priority = Priority.INFO, + public_deck = "decklist", + private_deck = "deck", + cards = "card", + taboo = "taboos", + card_bag_guid = "15bb07", + weaknesses_bag_guid = "770c4e", + investigator_bag_guid = "84a000", + minicard_bag_guid = "80b12d", + ui_builder_guid = "ddd2eb", + command_manager_guid = "a0b1de", + bonded_cards = { + ["05313"] = { -- Hallowed Mirror / Soothing Melody + code = "05314", + count = 3 + }, + ["54002"] = { -- Hallowed Mirror (3) / Soothing Melody + code = "05314", + count = 3 + }, + ["05316"] = { -- Occult Lexicon / Blood-Rite + code = "05317", + count = 3 + }, + ["54004"] = { -- Occult Lexicon (3) / Blood-Rite + code = "05317", + count = 3 + }, + ["06018"] = { -- The Hungering Blade / Bloodlust + code = "06019", + count = 3 + }, + ["06330"] = { -- Nightmare Bauble / Dream Parasite + code = "06331", + count = 3 + }, + ["06030"] = { -- Miss Doyle / Hope/ Zeal / Augur + { + code = "06031", + count = 1 + }, + { + code = "06032", + count = 1 + }, + { + code = "06033", + count = 1 + } + }, + ["06013"] = { -- Gate Box / Dream-Gate + code = "06015a", + count = 1 + }, + ["06024"] = { -- Crystallizer of Dreams / Guardian of the Crystallizer + code = "06025", + count = 2 + }, + ["06112"] = { -- Dream Diary / Essence of the Dream + code = "06113", + count = 1 + }, + ["06236"] = { -- Dream Diary / Essence of the Dream + code = "06113", + count = 1 + }, + ["06237"] = { -- Dream Diary / Essence of the Dream + code = "06113", + count = 1 + }, + ["06238"] = { -- Dream Diary / Essence of the Dream + code = "06113", + count = 1 + }, + ["06276"] = { -- Empty Vessel / Wish Eater + code = "06277", + count = 1 + }, + ["06021"] = { -- Segment of Onyx / Pendant of the Queen + code = "06022", + count = 1 + }, + ["06027"] = { -- Stargazing / The Stars Are Right + code = "06028", + count = 2 + }, + ["06282"] = { -- Summoned Hound / Unbound Beast + code = "06283", + count = 2 + } + }, + default_zone_overrides = { + ["02014"] = "bonded", -- Duke + ["03009"] = "bonded", -- Sophie + ["06013"] = "bonded", -- Gate Box + ["05014"] = "bonded" -- Dark Insight + }, + discriminators = { + ["53010"] = "Permanent", -- On Your Own (Permanent) + ["01008"] = "Signature", -- Daisy's Tote Bag + ["90002"] = "Advanced", -- Daisy's Tote Bag Advanced + ["90003"] = "John Dee Translation (Advanced)", -- The Necronomicon + ["01010"] = "Signature", -- On the Lam + ["90009"] = "Advanced", -- On the Lam + ["01011"] = "Signature", -- Hospital Debts + ["90010"] = "Advanced", -- Hospital Debts + ["01013"] = "Signature", -- Dark Memory + ["90019"] = "Advanced", -- Dark Memory + ["90018"] = "Artifact from Another Life (Advanced)", -- Heirloom of Hyperborea + ["90030"] = "Advanced", -- Roland's .38 Special + ["01006"] = "Signature", -- Roland's .38 Special + ["90031"] = "Advanced", -- Cover Up + ["01007"] = "Signature", -- Cover Up + ["05186"] = "Guardian", -- .45 Thompson + ["05187"] = "Rogue", -- .45 Thompson + ["05188"] = "Seeker", -- Scroll of Secrets + ["05189"] = "Mystic", -- Scroll of Secrets + ["05190"] = "Rogue", -- Tennessee Sour Mash + ["05191"] = "Survivor", -- Tennessee Sour Mash + ["05192"] = "Guardian", -- Enchanted Blade + ["05193"] = "Mystic", -- Enchanted Blade + ["05194"] = "Seeker", -- Grisly Totem + ["05195"] = "Survivor", -- Grisly Totem + ["06015a"] = "", -- Dream-Gate + }, + zones = { + default = { + position = Vector(0.285, 1.5, 0.40), + is_facedown = true + }, + permanent = { + position = Vector(-0.285, 1.5, 0.40), + is_facedown = false + }, + weakness = { + position = Vector(-0.855, 1.5, 0.40), + is_facedown = true + }, + bonded = { + position = Vector(-1.425, 1.5, 0.40), + is_facedown = false + }, + investigator = { + position = Vector(1.150711, 1.5, 0.454833), + is_facedown = false + } + }, + debug_deck_id = nil, +} diff --git a/src/arkhamdb/DeckImporterMain.ttslua b/src/arkhamdb/DeckImporterMain.ttslua new file mode 100644 index 00000000..df8fde31 --- /dev/null +++ b/src/arkhamdb/DeckImporterMain.ttslua @@ -0,0 +1,718 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-08-19 6:38 a.m. +--- + +---@type ArkhamImportConfiguration + +require("src/arkhamdb/LoaderUi") +local Zones = require("src/arkhamdb/Zones") + +local RANDOM_WEAKNESS_ID = "01000" + +local tags = { configuration = "import_configuration_provider" } + +local Priority = { + ERROR = 0, + WARNING = 1, + INFO = 2, + DEBUG = 3 +} + +---@type fun(text: string) +local printFunction = printToAll +local printPriority = Priority.INFO + +---@param priority number +---@return string +function Priority.getLabel(priority) + if priority==0 then return "ERROR" + elseif priority==1 then return "WARNING" + elseif priority==2 then return "INFO" + elseif priority==3 then return "DEBUG" + else error(table.concat({"Priority", priority, "not found"}, " ")) return "" + end +end + +---@param message string +---@param priority number +local function debugPrint(message, priority, color) + if (color == nil) then + color = { 0.5, 0.5, 0.5 } + end + if (printPriority >= priority) then + printFunction("[" .. Priority.getLabel(priority) .. "] " .. message, color) + end +end + +---@param str string +---@return string +local function fixUtf16String(str) + return str:gsub("\\u(%w%w%w%w)", function (match) + return string.char(tonumber(match,16)) + end) +end + +--Forward declaration +---@type Request +local Request = {} + +---@type table +local tabooList = {} + +---@return ArkhamImportConfiguration +local function getConfiguration() + local configuration = getObjectsWithTag(tags.configuration)[1]:getTable("configuration") + printPriority = configuration.priority + return configuration +end + +function onLoad(script_state) + local state = JSON.decode(script_state) + initializeUi(state) + math.randomseed(os.time()) + + local configuration = getConfiguration() + Request.start({configuration.api_uri, configuration.taboo}, function (status) + local json = JSON.decode(fixUtf16String(status.text)) + for _, taboo in pairs(json) do + ---@type + local cards = {} + + for _, card in pairs(JSON.decode(taboo.cards)) do + cards[card.code] = true + end + + tabooList[taboo.id] = { + date = taboo.date_start, + cards = cards + } + end + return true, nil + end) +end + +function onSave() + return JSON.encode(getUiState()) +end + +-- Callback when the deck information is received from ArkhamDB. Parses the +-- response then applies standard transformations to the deck such as adding +-- random weaknesses and checking for taboos. Once the deck is processed, +-- passes to loadCards to actually spawn the defined deck. +---@param deck ArkhamImportDeck +---@param playerColor String Color name of the player mat to place this deck +-- on (e.g. "Red") +---@param configuration ArkhamImportConfiguration +local function onDeckResult(deck, playerColor, configuration) + -- Load the next deck in the upgrade path if the option is enabled + if (getUiState().loadNewest and deck.next_deck ~= nil and deck.next_deck ~= "") then + buildDeck(playerColor, deck.next_deck) + return + end + + debugPrint(table.concat({ "Found decklist: ", deck.name}), Priority.INFO, playerColor) + + debugPrint(table.concat({"-", deck.name, "-"}), Priority.DEBUG) + for k,v in pairs(deck) do + if type(v)=="table" then + debugPrint(table.concat {k, ": "}, Priority.DEBUG) + else + debugPrint(table.concat {k, ": ", tostring(v)}, Priority.DEBUG) + end + end + debugPrint("", Priority.DEBUG) + + -- Initialize deck slot table and perform common transformations. The order + -- of these should not be changed, as later steps may act on cards added in + -- each. For example, a random weakness or investigator may have bonded + -- cards or taboo entries, and should be present + local slots = deck.slots + maybeDrawRandomWeakness(slots, playerColor, configuration) + maybeAddInvestigatorCards(deck, slots) + extractBondedCards(slots, configuration) + checkTaboos(deck.taboo_id, slots, playerColor, configuration) + + local commandManager = getObjectFromGUID(configuration.command_manager_guid) + + ---@type ArkhamImport_CommandManager_InitializationArguments + local parameters = { + configuration = configuration, + description = deck.description_md, + } + + ---@type ArkhamImport_CommandManager_InitializationResults + local results = commandManager:call("initialize", parameters) + + if not results.is_successful then + debugPrint(results.error_message, Priority.ERROR) + return + end + + loadCards(slots, playerColor, commandManager, configuration, results.configuration) +end + +-- Checks to see if the slot list includes the random weakness ID. If it does, +-- removes it from the deck and replaces it with the ID of a random basic +-- weakness provided by the all cards bag +-- Param slots: The slot list for cards in this deck. Table key is the cardId, +-- value is the number of those cards which will be spawned +-- Param playerColor: Color name of the player this deck is being loaded for. +-- Used for broadcast if a weakness is added. +-- Param configuration: The API configuration object +function maybeDrawRandomWeakness(slots, playerColor, configuration) + local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) + local hasRandomWeakness = false + for cardId, cardCount in pairs(slots) do + if (cardId == RANDOM_WEAKNESS_ID) then + hasRandomWeakness = true + break + end + end + if (hasRandomWeakness) then + local weaknessId = allCardsBag.call("getRandomWeaknessId") + slots[weaknessId] = 1 + slots[RANDOM_WEAKNESS_ID] = nil + debugPrint("Random basic weakness added to deck", Priority.INFO, playerColor) + end + +end + +-- If the UI indicates that investigator cards should be loaded, add both the +-- investigator (XXXXX) and minicard (XXXXX-m) slots with one copy each +-- Param deck: The processed ArkhamDB deck response +-- Param slots: The slot list for cards in this deck. Table key is the cardId, +-- value is the number of those cards which will be spawned +function maybeAddInvestigatorCards(deck, slots) + if (getUiState().investigators) then + local investigatorId = deck.investigator_code + slots[investigatorId.."-m"] = 1 + local parallelFront = deck.meta ~= nil and deck.meta.alternate_front ~= nil and deck.meta.alternate_front ~= "" + local parallelBack = deck.meta ~= nil and deck.meta.alternate_back ~= nil and deck.meta.alternate_back ~= "" + if (parallelFront and parallelBack) then + investigatorId = investigatorId.."-p" + elseif (parallelFront) then + investigatorId = investigatorId.."-pf" + elseif (parallelBack) then + investigatorId = investigatorId.."-pb" + end + slots[investigatorId] = 1 + end +end + +-- Process the slot list and looks for any cards which are bonded to those in +-- the deck. Adds those cards to the slot list. +-- Param slots: The slot list for cards in this deck. Table key is the cardId, +-- value is the number of those cards which will be spawned +-- Param configuration: The API configuration object +function extractBondedCards(slots, configuration) + local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) + -- Create a list of bonded cards first so we don't modify slots while iterating + local bondedCards = { } + for cardId, cardCount in pairs(slots) do + local card = allCardsBag.call("getCardById", { id = cardId }) + if (card ~= nil and card.metadata.bonded ~= nil) then + for _, bond in ipairs(card.metadata.bonded) do + bondedCards[bond.id] = bond.count + end + end + end + -- Add any bonded cards to the main slots list + for bondedId, bondedCount in pairs(bondedCards) do + slots[bondedId] = bondedCount + end +end + +-- Check the deck for any cards on its taboo list. If they're found, replace +-- the entry in the slot with the Taboo id (i.e. "XXXX" becomes "XXXX-t") +-- Param tabooId: The deck's taboo ID, taken from the deck response taboo_id +-- field. May be nil, indicating that no taboo list should be used +-- Param slots: The slot list for cards in this deck. Table key is the cardId, +-- value is the number of those cards which will be spawned +function checkTaboos(tabooId, slots, playerColor, configuration) + if (tabooId) then + local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) + for cardId, _ in pairs(tabooList[tabooId].cards) do + if (slots[cardId] ~= nil) then + -- Make sure there's a taboo version of the card before we replace it + -- SCED only maintains the most recent taboo cards. If a deck is using + -- an older taboo list it's possible the card isn't a taboo any more + local tabooCard = allCardsBag.call("getCardById", { id = cardId.."-t" }) + if (tabooCard == nil) then + local basicCard = allCardsBag.call("getCardById", { id = cardId }) + debugPrint("Taboo version for "..basicCard.data.Nickname.. + " is not available. Using standard version", Priority.WARNING, playerColor) + else + slots[cardId.."-t"] = slots[cardId] + slots[cardId] = nil + end + end + end + end +end + +-- Process the slot list, which defines the card Ids and counts of cards to +-- load. Spawn those cards at the appropriate zones, and report an error to the +-- user if any could not be loaded. +-- +-- This method uses an encapsulated coroutine with yields to make the card +-- spawning cleaner. +-- +-- Param slots: Key-Value table of cardId:count. cardId is the ArkhamDB ID of +-- the card to spawn, and count is the number which should be spawned +-- Param playerColor String Color name of the player mat to place this deck +-- on (e.g. "Red") +-- Param commandManager +-- Param configuration: Loader configuration object +-- Param command_config: +function loadCards(slots, playerColor, commandManager, configuration, command_config) + function coinside() + local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) + local yPos = { } + local cardsToSpawn = { } + for cardId, cardCount in pairs(slots) do + local card = allCardsBag.call("getCardById", { id = cardId }) + if (card ~= nil) then + local cardZone = Zones.getDefaultCardZone(card.metadata) + for i = 1, cardCount do + table.insert(cardsToSpawn, { data = card.data, metadata = card.metadata, zone = cardZone }) + end + slots[cardId] = 0 + end + end + + -- TODO: Re-enable this later, as a command + --handleAltInvestigatorCard(cardsToSpawn, "promo", configuration) + + table.sort(cardsToSpawn, cardComparator) + + -- TODO: Process commands for the cardsToSpawn list + + -- These should probably be commands, once the command handler is updated + handleStartsInPlay(cardsToSpawn) + handleAncestralKnowledge(cardsToSpawn) + + -- Count the number of cards in each zone so we know if it's a deck or card. + -- TTS's Card vs. Deck distinction requires this since we can't spawn a deck + -- with only one card + local zoneCounts = getZoneCounts(cardsToSpawn) + local zoneDecks = { } + for zone, count in pairs(zoneCounts) do + if (count > 1) then + zoneDecks[zone] = buildDeckDataTemplate() + end + end + -- For each card in a deck zone, add it to that deck. Otherwise, spawn it + -- directly + for _, spawnCard in ipairs(cardsToSpawn) do + if (zoneDecks[spawnCard.zone] ~= nil) then + addCardToDeck(zoneDecks[spawnCard.zone], spawnCard.data) + else + local cardPos = Zones.getZonePosition(playerColor, spawnCard.zone) + cardPos.y = 2 + spawnObjectData({ + data = spawnCard.data, + position = cardPos, + rotation = Zones.getDefaultCardRotation(playerColor, spawnCard.zone)}) + end + end + -- Spawn each of the decks + for zone, deck in pairs(zoneDecks) do + local deckPos = Zones.getZonePosition(playerColor, zone) + deckPos.y = 3 + spawnObjectData({ + data = deck, + position = deckPos, + rotation = Zones.getDefaultCardRotation(playerColor, zone)}) + coroutine.yield(0) + end + + -- Look for any cards which haven't been loaded + local hadError = false + for cardId, remainingCount in pairs(slots) do + if (remainingCount > 0) then + hadError = true + local request = Request.start({ + configuration.api_uri, + configuration.cards, + cardId}, + function(result) + local adbCardInfo = JSON.decode(fixUtf16String(result.text)) + local cardName = adbCardInfo.real_name + if (cardName ~= nil) then + if (adbCardInfo.xp ~= nil and adbCardInfo.xp > 0) then + cardName = cardName.." ("..adbCardInfo.xp..")" + end + debugPrint("Card not found: "..cardName..", ArkhamDB ID "..cardId, Priority.ERROR, playerColor) + else + debugPrint("Card not found in ArkhamDB, ID "..cardId, Priority.ERROR, playerColor) + end + end) + end + end + if (not hadError) then + debugPrint("Deck loaded successfully!", Priority.INFO, playerColor) + end + return 1 + end + startLuaCoroutine(self, "coinside") +end + +-- Inserts a card into the given deck. This does three things: +-- 1. Add the card's data to ContainedObjects +-- 2. Add the card's ID (the TTS CardID, not the Arkham ID) to the deck's +-- ID list. Note that the deck's ID list is "DeckIDs" even though it +-- contains a list of card Ids +-- 3. Extract the card's CustomDeck table and add it to the deck. The deck's +-- "CustomDeck" field is a list of all CustomDecks used by cards within the +-- deck, keyed by the DeckID and referencing the custom deck table +-- Param deck: TTS deck data structure to add to +-- Param card: Data for the card to be inserted +function addCardToDeck(deck, cardData) + table.insert(deck.ContainedObjects, cardData) + table.insert(deck.DeckIDs, cardData.CardID) + for customDeckId, customDeckData in pairs(cardData.CustomDeck) do + deck.CustomDeck[customDeckId] = customDeckData + end +end + +-- Count the number of cards in each zone +-- Param cards: Table of {cardData, cardMetadata, zone} +-- Return: Table of {zoneName=zoneCount} +function getZoneCounts(cards) + local counts = { } + for _, card in ipairs(cards) do + if (counts[card.zone] == nil) then + counts[card.zone] = 1 + else + counts[card.zone] = counts[card.zone] + 1 + end + end + + return counts +end + +-- Create an empty deck data table which can have cards added to it. This +-- creates a new table on each call without using metatables or previous +-- definitions because we can't be sure that TTS doesn't modify the structure +-- Return: Table containing the minimal TTS deck data structure +function buildDeckDataTemplate() + local deck = { } + deck.Name = "Deck" + + -- Card data. DeckIDs and CustomDeck entries will be built from the cards + deck.ContainedObjects = { } + deck.DeckIDs = { } + deck.CustomDeck = { } + + -- Transform is required, Position and Rotation will be overridden by the + -- spawn call so can be omitted here + deck.Transform = { + scaleX = 1, + scaleY = 1, + scaleZ = 1, } + + return deck +end + +-- Get the PBN (Permanent/Bonded/Normal) value from the given metadata. +-- Return: 1 for Permanent, 2 for Bonded, or 3 for Normal. The actual values +-- are irrelevant as they provide only grouping and the order between them +-- doesn't matter. +function getPbn(metadata) + if (metadata.permanent) then + return 1 + elseif (metadata.bonded_to ~= nil) then + return 2 + else -- Normal card + return 3 + end +end + +-- Comparison function used to sort the cards in a deck. Groups bonded or +-- permanent cards first, then sorts within theose types by name/subname. +-- Normal cards will sort in standard alphabetical order, while permanent/bonded +-- will be in reverse alphabetical order. +-- +-- Since cards spawn in the order provided by this comparator, with the first +-- cards ending up at the bottom of a pile, this ordering will spawn in reverse +-- alphabetical order. This presents the cards in order for non-face-down +-- areas, and presents them in order when Searching the face-down deck. +function cardComparator(card1, card2) + local pbn1 = getPbn(card1.metadata) + local pbn2 = getPbn(card2.metadata) + if (pbn1 ~= pbn2) then + return pbn1 > pbn2 + end + if (pbn1 == 3) then + if (card1.data.Nickname ~= card2.data.Nickname) then + return card1.data.Nickname < card2.data.Nickname + end + return card1.data.Description < card2.data.Description + else + if (card1.data.Nickname ~= card2.data.Nickname) then + return card1.data.Nickname > card2.data.Nickname + end + return card1.data.Description > card2.data.Description + end +end + +-- Replace the investigator card and minicard with an alternate version. This +-- will find the relevant cards and look for IDs with -, and +-- --m, and update the entries in cardList with the new card +-- data. +-- +-- Param cardList: Deck list being created +-- Param altVersionTag: The tag for the different version, currently the only +-- alt versions are "promo", but will soon inclide "revised" +-- Param configuration: ArkhamDB configuration defniition, used for the card bag +function handleAltInvestigatorCard(cardList, altVersionTag, configuration) + local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) + for _, card in ipairs(cardList) do + if (card.metadata.type == "Investigator") then + local altInvestigator = allCardsBag.call("getCardById", { id = card.metadata.id.."-"..altVersionTag}) + if (altInvestigator ~= nil) then + card.data = altInvestigator.data + card.metadata = altInvestigator.metadata + end + end + if (card.metadata.type == "Minicard") then + -- -promo comes before -m in the ID, so needs a little massaging + local investigatorId = string.sub(card.metadata.id, 1, 5) + local altMinicard = allCardsBag.call("getCardById", { id = investigatorId.."-"..altVersionTag.."-m"}) + if (altMinicard ~= nil) then + card.data = altMinicard.data + card.metadata = altMinicard.metadata + end + end + end +end + +-- Place cards which start in play (Duke, Sophie) in the play area +function handleStartsInPlay(cardList) + for _, card in ipairs(cardList) do + -- 02014 = Duke (Ashcan Pete) + -- 03009 = Sophie (Mark Harrigan) + if (card.metadata.id == "02014" or card.metadata.id == "03009") then + card.zone = "BlankTop" + end + end +end + +-- Check to see if the deck list has Ancestral Knowledge. If it does, move 5 +-- random skills to SetAside3 +function handleAncestralKnowledge(cardList) + local hasAncestralKnowledge = false + local skillList = { } + -- Have to process the entire list to check for Ancestral Knowledge and get + -- all possible skills, so do both in one pass + for i, card in ipairs(cardList) do + if (card.metadata.id == "07303") then + -- Ancestral Knowledge found + hasAncestralKnowledge = true + card.zone = "SetAside3" + elseif (card.metadata.type == "Skill" + and card.metadata.bonded_to == nil + and not card.metadata.weakness) then + table.insert(skillList, i) + end + end + if (hasAncestralKnowledge) then + for i = 1,5 do + -- Move 5 random skills to SetAside3 + local skillListIndex = math.random(#skillList) + cardList[skillList[skillListIndex]].zone = "UnderSetAside3" + table.remove(skillList, skillListIndex) + end + end +end + +-- Test method. Loads all decks which were submitted to ArkhamDB on a given +-- date window. +function testLoadLotsOfDecks() + local configuration = getConfiguration() + local numDays = 7 + local day = os.time{year=2021, month=7, day=15} -- Start date here + for i=1,numDays do + local dateString = os.date("%Y-%m-%d", day) + local deckList = Request.start({ + configuration.api_uri, + "decklists/by_date", + dateString, + }, + function(result) + local json = JSON.decode(result.text) + for i, deckData in ipairs(json) do + buildDeck(getColorForTest(i), deckData.id) + end + end) + day = day + (60 * 60 * 24) -- Move forward by one day + end +end + +-- Rotates the player mat based on index, to spread the card stacks during +-- a mass load +function getColorForTest(index) + if (index % 4 == 0) then + return "Red" + elseif (index % 4 == 1) then + return "Orange" + elseif (index % 4 == 2) then + return "White" + elseif (index % 4 == 3) then + return "Green" + end +end + +-- Start the deck build process for the given player color and deck ID. This +-- will retrieve the deck from ArkhamDB, and pass to a callback for processing. +-- Param playerColor String Color name of the player mat to place this deck +-- on (e.g. "Red") +-- Param deckId: ArkhamDB deck id to be loaded +function buildDeck(playerColor, deckId) + local configuration = getConfiguration() + -- Get a simple card to see if the bag indexes are complete. If not, abort + -- the deck load. The called method will handle player notification. + local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) + local checkCard = allCardsBag.call("getCardById", { id = "01001"}) + if (checkCard ~= nil and checkCard.data == nil) then + return + end + + local deckUri = { configuration.api_uri, getUiState().private and configuration.private_deck or configuration.public_deck, deckId } + + local deck = Request.start(deckUri, function (status) + if string.find(status.text, "") then + debugPrint("Private deck ID "..deckId.." is not shared", Priority.ERROR, playerColor) + return false, table.concat({ "Private deck ", deckId, " is not shared"}) + end + local json = JSON.decode(status.text) + + if not json then + debugPrint("Deck ID "..deckId.." not found", Priority.ERROR, playerColor) + return false, "Deck not found!" + end + + return true, JSON.decode(status.text) + end) + + deck:with(onDeckResult, playerColor, configuration) +end + +---@type Request +Request = { + is_done = false, + is_successful = false +} + +--- Creates a new instance of a Request. Should not be directly called. Instead use Request.start and Request.deferred. +---@param uri string +---@param configure fun(request: Request, status: WebRequestStatus) +---@return Request +function Request:new(uri, configure) + local this = {} + + setmetatable(this, self) + self.__index = self + + if type(uri)=="table" then + uri = table.concat(uri, "/") + end + + this.uri = uri + + WebRequest.get(uri, function(status) + configure(this, status) + end) + + return this +end + +--- Creates a new request. on_success should set the request's is_done, is_successful, and content variables. +--- Deferred should be used when you don't want to set is_done immediately (such as if you want to wait for another request to finish) +---@param uri string +---@param on_success fun(request: Request, status: WebRequestStatus, vararg any) +---@param on_error fun(status: WebRequestStatus)|nil +---@vararg any[] +---@return Request +function Request.deferred(uri, on_success, on_error, ...) + local parameters = table.pack(...) + return Request:new(uri, function (request, status) + if (status.is_done) then + if (status.is_error) then + request.error_message = on_error and on_error(status, table.unpack(parameters)) or status.error + request.is_successful = false + request.is_done = true + else + on_success(request, status) + end + end + end) +end + +--- Creates a new request. on_success should return weather the resultant data is as expected, and the processed content of the request. +---@param uri string +---@param on_success fun(status: WebRequestStatus, vararg any): boolean, any +---@param on_error nil|fun(status: WebRequestStatus, vararg any): string +---@vararg any[] +---@return Request +function Request.start(uri, on_success, on_error, ...) + local parameters = table.pack(...) + return Request.deferred(uri, function(request, status) + local result, message = on_success(status, table.unpack(parameters)) + if not result then request.error_message = message else request.content = message end + request.is_successful = result + request.is_done = true + end, on_error, table.unpack(parameters)) +end + +---@param requests Request[] +---@param on_success fun(content: any[], vararg any[]) +---@param on_error fun(requests: Request[], vararg any[])|nil +---@vararg any +function Request.with_all(requests, on_success, on_error, ...) + local parameters = table.pack(...) + + Wait.condition(function () + ---@type any[] + local results = {} + + ---@type Request[] + local errors = {} + + for _, request in ipairs(requests) do + if request.is_successful then + table.insert(results, request.content) + else + table.insert(errors, request) + end + end + + if (#errors<=0) then + on_success(results, table.unpack(parameters)) + elseif on_error ==nil then + for _, request in ipairs(errors) do + debugPrint(table.concat({ "[ERROR]", request.uri, ":", request.error_message }), Priority.ERROR) + end + else + on_error(requests, table.unpack(parameters)) + end + end, function () + for _, request in ipairs(requests) do + if not request.is_done then return false end + end + return true + end) +end + +---@param callback fun(content: any, vararg any) +function Request:with(callback, ...) + local arguments = table.pack(...) + Wait.condition(function () + if self.is_successful then + callback(self.content, table.unpack(arguments)) + end + end, function () return self.is_done + end) +end diff --git a/src/arkhamdb/HotfixBag.ttslua b/src/arkhamdb/HotfixBag.ttslua new file mode 100644 index 00000000..6d6c53bd --- /dev/null +++ b/src/arkhamdb/HotfixBag.ttslua @@ -0,0 +1,12 @@ +-- A Hotfix bag contains replacement cards for the All Cards Bag, and should +-- have the 'AllCardsHotfix' tag on the object. Code for the All Cards Bag will +-- find these bags during indexing, and use them to replace cards from the +-- actual bag. + +-- Tells the All Cards Bag to recreate its indexes. The All Cards Bag may +-- ignore this request; see the rebuildIndexForHotfix() method in the All Cards +-- Bag for details. +function onLoad() + local allCardsBag = getObjectFromGUID("15bb07") + allCardsBag.call("rebuildIndexForHotfix") +end diff --git a/src/arkhamdb/LoaderUi.ttslua b/src/arkhamdb/LoaderUi.ttslua new file mode 100644 index 00000000..e8b16937 --- /dev/null +++ b/src/arkhamdb/LoaderUi.ttslua @@ -0,0 +1,258 @@ +local INPUT_FIELD_HEIGHT = 340 +local INPUT_FIELD_WIDTH = 1500 + +local FIELD_COLOR = {0.9,0.7,0.5} + +local PRIVATE_TOGGLE_LABELS = { } +PRIVATE_TOGGLE_LABELS[true] = "Private" +PRIVATE_TOGGLE_LABELS[false] = "Published" +local UPGRADED_TOGGLE_LABELS = { } +UPGRADED_TOGGLE_LABELS[true] = "Upgraded" +UPGRADED_TOGGLE_LABELS[false] = "Specific" +local LOAD_INVESTIGATOR_TOGGLE_LABELS = { } +LOAD_INVESTIGATOR_TOGGLE_LABELS[true] = "Yes" +LOAD_INVESTIGATOR_TOGGLE_LABELS[false] = "No" + +local redDeckId = "" +local orangeDeckId = "" +local whiteDeckId = "" +local greenDeckId = "" +local privateDeck = true +local loadNewestDeck = true +local loadInvestigators = false +local loadingColor = "" + +-- Returns a table with the full state of the UI, including options and deck +-- IDs. This can be used to persist via onSave(), or provide values for a load +-- operation +-- Table values: +-- redDeck: Deck ID to load for the red player +-- orangeDeck: Deck ID to load for the orange player +-- whiteDeck: Deck ID to load for the white player +-- greenDeck: Deck ID to load for the green player +-- private: True to load a private deck, false to load a public deck +-- loadNewest: True if the most upgraded version of the deck should be loaded +-- investigators: True if investigator cards should be spawned +function getUiState() + return { + redDeck = redDeckId, + orangeDeck = orangeDeckId, + whiteDeck = whiteDeckId, + greenDeck = greenDeckId, + private = privateDeck, + loadNewest = loadNewestDeck, + investigators = loadInvestigators, + } +end + +-- Sets up the UI for the deck loader, populating fields from the given save +-- state table decoded from onLoad() +function initializeUi(savedUiState) + if (savedUiState ~= nil) then + redDeckId = savedUiState.redDeck + orangeDeckId = savedUiState.orangeDeck + whiteDeckId = savedUiState.whiteDeck + greenDeckId = savedUiState.greenDeck + privateDeck = savedUiState.private + loadNewestDeck = savedUiState.loadNewest + loadInvestigators = savedUiState.investigators + else + redDeckId = "" + orangeDeckId = "" + whiteDeckId = "" + greenDeckId = "" + privateDeck = true + loadNewestDeck = true + loadInvestigators = true + end + + makeOptionToggles() + makeDeckIdFields() + makeBuildButton() +end + +function makeOptionToggles() + -- Creates the three option toggle buttons. Each toggle assumes its index as + -- part of the toggle logic. IF YOU CHANGE THE ORDER OF THESE FIELDS YOU MUST + -- CHANGE THE EVENT HANDLERS + makePublicPrivateToggle() + makeLoadUpgradedToggle() + makeLoadInvestigatorsToggle() +end + +function makePublicPrivateToggle() + local checkbox_parameters = {} + checkbox_parameters.click_function = "publicPrivateChanged" + checkbox_parameters.function_owner = self + checkbox_parameters.position = {0.25,0.1,-0.102} + checkbox_parameters.width = INPUT_FIELD_WIDTH + checkbox_parameters.height = INPUT_FIELD_HEIGHT + checkbox_parameters.tooltip = "Published or private deck.\n\n*****PLEASE USE A PRIVATE DECK IF JUST FOR TTS TO AVOID FLOODING ARKHAMDB PUBLISHED DECK LISTS!" + checkbox_parameters.label = PRIVATE_TOGGLE_LABELS[privateDeck] + checkbox_parameters.font_size = 240 + checkbox_parameters.scale = {0.1,0.1,0.1} + checkbox_parameters.color = FIELD_COLOR + checkbox_parameters.hover_color = {0.4,0.6,0.8} + self.createButton(checkbox_parameters) +end + +function makeLoadUpgradedToggle() + local checkbox_parameters = {} + checkbox_parameters.click_function = "loadUpgradedChanged" + checkbox_parameters.function_owner = self + checkbox_parameters.position = {0.25,0.1,-0.01} + checkbox_parameters.width = INPUT_FIELD_WIDTH + checkbox_parameters.height = INPUT_FIELD_HEIGHT + checkbox_parameters.tooltip = "Load newest upgrade, or exact deck" + checkbox_parameters.label = UPGRADED_TOGGLE_LABELS[loadNewestDeck] + checkbox_parameters.font_size = 240 + checkbox_parameters.scale = {0.1,0.1,0.1} + checkbox_parameters.color = FIELD_COLOR + checkbox_parameters.hover_color = {0.4,0.6,0.8} + self.createButton(checkbox_parameters) +end + +function makeLoadInvestigatorsToggle() + local checkbox_parameters = {} + checkbox_parameters.click_function = "loadInvestigatorsChanged" + checkbox_parameters.function_owner = self + checkbox_parameters.position = {0.25,0.1,0.081} + checkbox_parameters.width = INPUT_FIELD_WIDTH + checkbox_parameters.height = INPUT_FIELD_HEIGHT + checkbox_parameters.tooltip = "Spawn investigator cards?" + checkbox_parameters.label = LOAD_INVESTIGATOR_TOGGLE_LABELS[loadInvestigators] + checkbox_parameters.font_size = 240 + checkbox_parameters.scale = {0.1,0.1,0.1} + checkbox_parameters.color = FIELD_COLOR + checkbox_parameters.hover_color = {0.4,0.6,0.8} + self.createButton(checkbox_parameters) +end + +-- Create the four deck ID entry fields +function makeDeckIdFields() + local input_parameters = {} + -- Parameters common to all entry fields + input_parameters.function_owner = self + input_parameters.scale = {0.1,0.1,0.1} + input_parameters.width = INPUT_FIELD_WIDTH + input_parameters.height = INPUT_FIELD_HEIGHT + input_parameters.font_size = 320 + input_parameters.tooltip = "Deck ID from ArkhamDB URL of the deck\nPublic URL: 'https://arkhamdb.com/decklist/view/101/knowledge-overwhelming-solo-deck-1.0' = '101'\nPrivate URL: 'https://arkhamdb.com/deck/view/102' = '102'" + input_parameters.alignment = 3 -- Center + input_parameters.color = FIELD_COLOR + input_parameters.font_color = {0, 0, 0} + input_parameters.validation = 2 -- Integer + + -- Green + input_parameters.input_function = "greenDeckChanged" + input_parameters.position = {-0.166,0.1,0.385} + input_parameters.value=greenDeckId + self.createInput(input_parameters) + -- Red + input_parameters.input_function = "redDeckChanged" + input_parameters.position = {0.171,0.1,0.385} + input_parameters.value=redDeckId + self.createInput(input_parameters) + -- White + input_parameters.input_function = "whiteDeckChanged" + input_parameters.position = {-0.166,0.1,0.474} + input_parameters.value=whiteDeckId + self.createInput(input_parameters) + -- Orange + input_parameters.input_function = "orangeDeckChanged" + input_parameters.position = {0.171,0.1,0.474} + input_parameters.value=orangeDeckId + self.createInput(input_parameters) +end + +-- Create the Build All button. This is a transparent button which covers the +-- Build All portion of the background graphic +function makeBuildButton() + local button_parameters = {} + button_parameters.click_function = "loadDecks" + button_parameters.function_owner = self + button_parameters.position = {0,0.1,0.71} + button_parameters.width = 320 + button_parameters.height = 30 + button_parameters.color = {0, 0, 0, 0} + button_parameters.tooltip = "Click to build all four decks!" + self.createButton(button_parameters) +end + +-- Event handler for the Public/Private toggle. Changes the local value and the +-- labels to toggle the button +function publicPrivateChanged() + -- editButton uses parameters.index which is 0-indexed + privateDeck = not privateDeck + self.editButton { + index = 0, + label = PRIVATE_TOGGLE_LABELS[privateDeck], + } +end + +-- Event handler for the Upgraded toggle. Changes the local value and the +-- labels to toggle the button +function loadUpgradedChanged() + -- editButton uses parameters.index which is 0-indexed + loadNewestDeck = not loadNewestDeck + self.editButton { + index = 1, + label = UPGRADED_TOGGLE_LABELS[loadNewestDeck], + } +end + +-- Event handler for the load investigator cards toggle. Changes the local +-- value and the labels to toggle the button +function loadInvestigatorsChanged() + -- editButton uses parameters.index which is 0-indexed + loadInvestigators = not loadInvestigators + self.editButton { + index = 2, + label = LOAD_INVESTIGATOR_TOGGLE_LABELS[loadInvestigators], + } +end + +-- Event handler for deck ID change +function redDeckChanged(objectInputTyped, playerColorTyped, inputValue, selected) + redDeckId = inputValue +end + +-- Event handler for deck ID change +function orangeDeckChanged(objectInputTyped, playerColorTyped, inputValue, selected) + orangeDeckId = inputValue +end + +-- Event handler for deck ID change +function whiteDeckChanged(objectInputTyped, playerColorTyped, inputValue, selected) + whiteDeckId = inputValue +end + +-- Event handler for deck ID change +function greenDeckChanged(objectInputTyped, playerColorTyped, inputValue, selected) + greenDeckId = inputValue +end + +function loadDecks() + -- testLoadLotsOfDecks() + -- Method in DeckImporterMain, visible due to inclusion + + -- TODO: Make this use the configuration ID for the all cards bag + local allCardsBag = getObjectFromGUID("15bb07") + local indexReady = allCardsBag.call("isIndexReady") + if (not indexReady) then + broadcastToAll("Still loading player cards, please try again in a few seconds", {0.9, 0.2, 0.2}) + return + end + if (redDeckId ~= nil and redDeckId ~= "") then + buildDeck("Red", redDeckId) + end + if (orangeDeckId ~= nil and orangeDeckId ~= "") then + buildDeck("Orange", orangeDeckId) + end + if (whiteDeckId ~= nil and whiteDeckId ~= "") then + buildDeck("White", whiteDeckId) + end + if (greenDeckId ~= nil and greenDeckId ~= "") then + buildDeck("Green", greenDeckId) + end +end diff --git a/src/arkhamdb/MainLogic.ttslua b/src/arkhamdb/MainLogic.ttslua new file mode 100644 index 00000000..22f4750f --- /dev/null +++ b/src/arkhamdb/MainLogic.ttslua @@ -0,0 +1,542 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-08-19 6:38 a.m. +--- + +---@type ArkhamImportConfiguration + +local tags = { configuration = "import_configuration_provider" } + +local Priority = { + ERROR = 0, + WARNING = 1, + INFO = 2, + DEBUG = 3 +} + +---@type fun(text: string) +local print_fun = print +local print_priority = Priority.DEBUG + +---@param priority number +---@return string +function Priority.get_label(priority) + if priority==0 then return "ERROR" + elseif priority==1 then return "WARNING" + elseif priority==2 then return "INFO" + elseif priority==3 then return "DEBUG" + else error(table.concat({"Priority", priority, "not found"}, " ")) return "" + end +end + +---@param message string +---@param priority number +local function debug_print(message, priority) + if (print_priority >= priority) then + print_fun("[" .. Priority.get_label(priority) .. "] " .. message) + end +end + +---@param str string +---@return string +local function fix_utf16_string(str) + return str:gsub("\\u(%w%w%w%w)", function (match) + return string.char(tonumber(match,16)) + end) +end + +--Forward declaration +---@type Request +local Request = {} + +---@type table +local taboo_list = {} + +---@type number +local deck_type_button_index + +local is_private_deck = true + +function on_decktype_checkbox_clicked() + self:editButton { + label = is_private_deck and "Published" or "Private", + index = deck_type_button_index + } + is_private_deck = not is_private_deck +end + +---@return ArkhamImportConfiguration +local function get_configuration() + local configuration = getObjectsWithTag(tags.configuration)[1]:getTable("configuration") + print_priority = configuration.priority + return configuration +end + +---@param configuration ArkhamImportConfiguration +local function initialize(_, configuration) + local builder = getObjectFromGUID(configuration.ui_builder_guid) + + deck_type_button_index = builder:call("create_ui", { + target_guid = self:getGUID(), + debug_deck_id = configuration.debug_deck_id, + checkbox_toggle_callback_name = "on_decktype_checkbox_clicked", + build_deck_callback_name = "build_deck" + }) +end + +function onLoad() + Wait.frames(function () + local configuration = get_configuration() + local taboo = Request.start({configuration.api_uri, configuration.taboo}, function (status) + local json = JSON.decode(fix_utf16_string(status.text)) + for _, taboo in pairs(json) do + ---@type + local cards = {} + + for _, card in pairs(JSON.decode(taboo.cards)) do + cards[card.code] = true + end + + taboo_list[taboo.id] = { + date = taboo.date_start, + cards = cards + } + end + return true, nil + end) + + taboo:with(initialize, configuration) + end, 1) +end + +---@param status WebRequestStatus +---@param number number +---@param is_bonded boolean +---@return boolean, ArkhamImportCard +local function on_card_request(status, number, is_bonded) + local text = fix_utf16_string(status.text) + + ---@type ArkhamImportCard + local card = JSON.decode(text) + card.count = number + card.is_bonded = is_bonded + + return true, card +end + +---@param configuration ArkhamImportConfiguration +---@param card_code string +---@param count number +---@param is_bonded boolean +---@return Request +local function add_card(configuration, card_code, count, is_bonded) + local api, card_path = configuration.api_uri, configuration.cards + local request = Request.start({api, card_path, card_code}, on_card_request, nil, count, is_bonded) + return request +end + +---@param source TTSObject +---@param count number +---@param zones ArkhamImportZone[] +---@param keep_card boolean +---@return fun(card: TTSObject) +local function position_card(source, count, zones, keep_card) + ---@param card TTSObject + return function (card) + + for n = 1, count do + local zone = zones[n] + + local destination = zone.is_absolute and zone.position or self:positionToWorld(zone.position) + local rotation = self:getRotation() + Vector(0, 0, zone.is_facedown and 180 or 0) + card:clone { + position = destination, + rotation = rotation + } + end + + if keep_card then source:putObject(card) else card:destruct() end + end +end + +---@param source TTSObject +---@param target_name string +---@param target_subname string +---@param count number +---@param zone ArkhamImportZone[] +local function process_card(source, target_name, target_subname, count, zone) + for _, card in ipairs(source:getObjects()) do + if (card.name == target_name and (not target_subname or card.description==target_subname)) then + source:takeObject { + position = {0, 1.5, 0}, + index = card.index, + smooth = false, + callback_function = position_card(source, count, zone, true) + } + debug_print(table.concat({ "Added", count, "of", target_name}, " "), Priority.DEBUG) + return + end + end + debug_print(table.concat({ "Card not found:", target_name}, " "), Priority.WARNING) +end + +---@param source TTSObject +---@param zones ArkhamImportZone[] +local function random_weakness(source, zones) + source:shuffle() + + local card = source:takeObject { + position = {0, 1.5, 0}, + index = 0, + smooth = false, + callback_function = position_card(source, 1, zones, false), + } + + broadcastToAll("Drew random basic weakness: " .. card:getName()) +end + +---@param configuration ArkhamImportConfiguration +---@param card_id string +---@param used_bindings table +---@param requests Request[] +local function process_bindings(configuration, card_id, used_bindings, requests) + local bondedCards = configuration.bonded_cards[card_id] + + if not bondedCards then return end + + if bondedCards.code then bondedCards = {bondedCards} end + + for _, bond in ipairs(bondedCards) do + if not used_bindings[bond.code] then + used_bindings[bond.code] = true + local result = add_card(configuration, bond.code, bond.count, true) + + table.insert(requests, result) + end + end +end + +---@param configuration ArkhamImportConfiguration +---@param slots table +---@return Request[] +local function load_cards(configuration, slots) + ---@type Request[] + local requests = {} + + ---@type + local used_bindings = {} -- Bonded cards that we've already processed + for card_id, number in pairs(slots) do + table.insert(requests, add_card(configuration, card_id, number, false)) + + process_bindings(configuration, card_id, used_bindings, requests) + end + + return requests +end + +---@type string[] +local parallel_component = {"", " (Parallel Back)", " (Parallel Front)", " (Parallel)"} + +---@param discriminators table +---@param card ArkhamImportCard +---@param taboo ArkhamImportTaboo +---@param meta table +---@return string, string|nil +local function get_card_selector(discriminators, card, taboo, meta) + local discriminator = discriminators[card.code] + + if card.type_code == "investigator" then + local parallel = (meta.alternate_front and 2 or 0) + (meta.alternate_back and 1 or 0) + + return table.concat {card.real_name, parallel_component[parallel]}, nil + end + + local xp_component = "" + if ((tonumber(card.xp) or 0) > 0) then + xp_component = table.concat {" (", card.xp, ")"} + end + + local taboo_component = "" + local cards = taboo.cards or {} + if (cards[card.code]) then + taboo_component = " (Taboo)" + end + + + local target_name = table.concat({ card.real_name, xp_component, taboo_component }) + local target_subname = discriminator or card.subname + + return target_name, target_subname +end + +---@param zone string +---@param count number +---@return string[] +local function fill_zone(zone, count) + local result = {} + for n=1,count do + result[n] = zone + end + return result +end + +---@param card ArkhamImportCard +---@param zone string[] +---@param override string[] +---@return string[] +local function get_zone_id(card, zone, override) + local result = {} + for n=1,card.count do + result[n] = zone[n] + or override[n] + or (card.is_bonded and "bonded") + or (card.permanent and "permanent") + or (card.subtype_name and card.subtype_name:find("Weakness") and "weakness") + or (card.type_code == "investigator" and "investigator") + or "default" + end + + return result +end + +---@param cards ArkhamImportCard[] +---@param deck ArkhamImportDeck +---@param command_manager TTSObject +---@param configuration ArkhamImportConfiguration +local function on_cards_ready(cards, deck, command_manager, configuration) + local card_bag = getObjectFromGUID(configuration.card_bag_guid) + local weakness_bag = getObjectFromGUID(configuration.weaknesses_bag_guid) + local investigator_bag = getObjectFromGUID(configuration.investigator_bag_guid) + local minicard_bag = getObjectFromGUID(configuration.minicard_bag_guid) + + local taboo = taboo_list[deck.taboo_id] or {} + + local meta = deck.meta and JSON.decode(deck.meta) or {} + + for _, card in ipairs(cards) do + ---@type ArkhamImport_Command_HandlerArguments + local parameters = { + configuration = configuration, + source_guid = self:getGUID(), + zone = {}, + card = card, + } + + ---@type ArkhamImport_CommandManager_HandlerResults + local command_result = command_manager:call("handle", parameters) + + if not command_result.is_successful then + debug_print(command_result.error_message, Priority.ERROR) + return + end + + local card = command_result.card + + if not command_result.handled then + local target_name, target_subname = get_card_selector(configuration.discriminators, card, taboo, meta) + local override = configuration.default_zone_overrides[card.code] + + if type(override)=="string" then override = fill_zone(override, card.count) end + + local zone = get_zone_id(card, command_result.zone, configuration.default_zone_overrides[card.code] or {}) + + local spawn_zones = {} + + local zones = configuration.zones + for index, zone in ipairs(zone) do + spawn_zones[index] = zones[zone] + end + + if card.real_name == "Random Basic Weakness" then + random_weakness(weakness_bag, spawn_zones) + elseif card.type_code == "investigator" then + process_card(investigator_bag, target_name, nil, card.count, spawn_zones) + process_card(minicard_bag, card.real_name, nil, card.count, spawn_zones) + else + process_card(card_bag, target_name, target_subname, card.count, spawn_zones) + end + end + end +end + +---@param deck ArkhamImportDeck +---@param configuration ArkhamImportConfiguration +local function on_deck_result(deck, configuration) + debug_print(table.concat({ "Found decklist: ", deck.name}), Priority.INFO) + + debug_print(table.concat({"-", deck.name, "-"}), Priority.DEBUG) + for k,v in pairs(deck) do + if type(v)=="table" then + debug_print(table.concat {k, ":
"}, Priority.DEBUG) + else + debug_print(table.concat {k, ": ", tostring(v)}, Priority.DEBUG) + end + end + debug_print("", Priority.DEBUG) + + local investigator_id = deck.investigator_code + + local slots = deck.slots + slots[investigator_id] = 1 + + ---@type ArkhamImportCard[] + local requests = load_cards(configuration, deck.slots) + + local command_manager = getObjectFromGUID(configuration.command_manager_guid) + + ---@type ArkhamImport_CommandManager_InitializationArguments + local parameters = { + configuration = configuration, + description = deck.description_md, + } + + ---@type ArkhamImport_CommandManager_InitializationResults + local results = command_manager:call("initialize", parameters) + + if not results.is_successful then + debug_print(results.error_message, Priority.ERROR) + return + end + + Request.with_all(requests, on_cards_ready, nil, deck, command_manager, results.configuration) +end + +function build_deck() + local configuration = get_configuration() + local deck_id = self:getInputs()[1].value + local deck_uri = { configuration.api_uri, is_private_deck and configuration.private_deck or configuration.public_deck, deck_id } + + local deck = Request.start(deck_uri, function (status) + if string.find(status.text, "") then + return false, table.concat({ "Private deck ", deck_id, " is not shared"}) + end + + local json = JSON.decode(status.text) + + if not json then + return false, "Deck not found!" + end + + return true, JSON.decode(status.text) + end) + + deck:with(on_deck_result, configuration) +end + +---@type Request +Request = { + is_done = false, + is_successful = false +} + +--- Creates a new instance of a Request. Should not be directly called. Instead use Request.start and Request.deferred. +---@param uri string +---@param configure fun(request: Request, status: WebRequestStatus) +---@return Request +function Request:new(uri, configure) + local this = {} + + setmetatable(this, self) + self.__index = self + + if type(uri)=="table" then + uri = table.concat(uri, "/") + end + + this.uri = uri + + WebRequest.get(uri, function(status) + configure(this, status) + end) + + return this +end + +--- Creates a new request. on_success should set the request's is_done, is_successful, and content variables. +--- Deferred should be used when you don't want to set is_done immediately (such as if you want to wait for another request to finish) +---@param uri string +---@param on_success fun(request: Request, status: WebRequestStatus, vararg any) +---@param on_error fun(status: WebRequestStatus)|nil +---@vararg any[] +---@return Request +function Request.deferred(uri, on_success, on_error, ...) + local parameters = table.pack(...) + return Request:new(uri, function (request, status) + if (status.is_done) then + if (status.is_error) then + request.error_message = on_error and on_error(status, table.unpack(parameters)) or status.error + request.is_successful = false + request.is_done = true + else + on_success(request, status) + end + end + end) +end + +--- Creates a new request. on_success should return weather the resultant data is as expected, and the processed content of the request. +---@param uri string +---@param on_success fun(status: WebRequestStatus, vararg any): boolean, any +---@param on_error nil|fun(status: WebRequestStatus, vararg any): string +---@vararg any[] +---@return Request +function Request.start(uri, on_success, on_error, ...) + local parameters = table.pack(...) + return Request.deferred(uri, function(request, status) + local result, message = on_success(status, table.unpack(parameters)) + if not result then request.error_message = message else request.content = message end + request.is_successful = result + request.is_done = true + end, on_error, table.unpack(parameters)) +end + +---@param requests Request[] +---@param on_success fun(content: any[], vararg any[]) +---@param on_error fun(requests: Request[], vararg any[])|nil +---@vararg any +function Request.with_all(requests, on_success, on_error, ...) + local parameters = table.pack(...) + + Wait.condition(function () + ---@type any[] + local results = {} + + ---@type Request[] + local errors = {} + + for _, request in ipairs(requests) do + if request.is_successful then + table.insert(results, request.content) + else + table.insert(errors, request) + end + end + + if (#errors<=0) then + on_success(results, table.unpack(parameters)) + elseif on_error ==nil then + for _, request in ipairs(errors) do + debug_print(table.concat({ "[ERROR]", request.uri, ":", request.error_message }), Priority.ERROR) + end + else + on_error(requests, table.unpack(parameters)) + end + end, function () + for _, request in ipairs(requests) do + if not request.is_done then return false end + end + return true + end) +end + +---@param callback fun(content: any, vararg any) +function Request:with(callback, ...) + local arguments = table.pack(...) + Wait.condition(function () + if self.is_successful then + callback(self.content, table.unpack(arguments)) + end + end, function () return self.is_done + end) +end \ No newline at end of file diff --git a/src/arkhamdb/MoveCommand.ttslua b/src/arkhamdb/MoveCommand.ttslua new file mode 100644 index 00000000..f5feeaaa --- /dev/null +++ b/src/arkhamdb/MoveCommand.ttslua @@ -0,0 +1,70 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-08-24 6:02 p.m. +--- + +command_name = "move" + +---@type ArkhamImport_Command_RunDirectives +runOn = { + instructions = true, + handlers = true +} + +---@param parameters ArkhamImport_Command_DescriptionInstructionArguments +---@return ArkhamImport_Command_DescriptionInstructionResults +function do_instruction(parameters) + local args = parameters.arguments + + if (#args~=2 and #args~=3) then + return { is_successful = false, error_message = "Move Command requires 2 or 3 arguments. " .. #args .. " were provided." } + end + + local card_id = args[1] + local new_zone = args[2] + local count = tonumber(args[3]) or 3 + + if not parameters.configuration.zones[new_zone] then + return { is_successful = false, error_message = "Move Command: Zone \"" .. new_zone .. "\" was not found." } + end + + local state = parameters.command_state["move"] + + if not state then + state = {} + parameters.command_state["move"] = state + end + + local card_data = state[card_id] + + if not card_data then + card_data = { + zone = {}, + offset = 0 + } + + state[card_id] = card_data + end + + local zone = card_data.zone + local offset = card_data.offset + + for index=offset,offset+count do + zone[index] = new_zone + end + + return { command_state = parameters.command_state, is_successful = true } +end + +---@param parameters ArkhamImport_Command_HandlerArguments +---@return ArkhamImport_Command_HandlerResults +function handle_card(parameters) + local state = parameters.command_state["move"] or {} + + local card_data = state[parameters.card.code] + + if not card_data then return { is_successful = true} end + + return { zone = card_data.zone, is_successful = true } +end diff --git a/src/arkhamdb/ProxyCardCommand.ttslua b/src/arkhamdb/ProxyCardCommand.ttslua new file mode 100644 index 00000000..bbd395ac --- /dev/null +++ b/src/arkhamdb/ProxyCardCommand.ttslua @@ -0,0 +1,91 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-08-24 6:11 p.m. +--- + +command_name = "proxy-card" + +---@type ArkhamImport_Command_RunDirectives +runOn = { + instructions = true, + handlers = true +} + +local back_image_default = "https://images-ext-2.discordapp.net/external/QY_dmo_UnAHEi1pgWwaRr1-HSB8AtrAv0W74Mh_Z6vg/https/i.imgur.com/EcbhVuh.jpg" + +---@param parameters ArkhamImport_Command_DescriptionInstructionArguments +---@return ArkhamImport_Command_DescriptionInstructionResults +function do_instruction(parameters) + local args = parameters.arguments + if (#args<4 or #args>6) then + return { + is_successful = false, + error_message = "Move Command requires between 4 or 6 arguments. " .. #args .. " were provided." + } + end + + if not parameters.command_state["proxy-card"] then + parameters.command_state["proxy-card"] = {} + parameters.command_state["proxy-card-offset"] = 0.1 + end + + parameters.command_state["proxy-card"][args[1]] = { + name = args[2], + subtitle = args[3], + image_uri = args[4], + zone = args[5] or "default", + back_image_uri = args[6] or back_image_default + } + + return { + command_state = parameters.command_state, + is_successful = true + } +end + +---@param parameters ArkhamImport_Command_HandlerArguments +---@return ArkhamImport_Command_HandlerResults +function handle_card(parameters) + local state = parameters.command_state["proxy-card"] or {} + + local card_data = state[parameters.card.code] + + if not card_data then return { is_successful = true } end + + local offset = parameters.command_state["proxy-card-offset"] + parameters.command_state["proxy-card-offset"] = offset + 0.1 + + local zone = parameters.configuration.zones[card_data.zone] + + if not zone then + return { + is_successful = false, + error_message = "Proxy Card [" .. tostring(parameters.card.code) .. "]: Zone \"" .. tostring(card_data.zone) .. "\" was not found." + } + end + + local source = getObjectFromGUID(parameters.source_guid) + local position = zone.is_absolute and zone.position or source:positionToWorld(zone.position) + + for _=1, parameters.card.count do + local new = spawnObject { + type = "CardCustom", + position = position + Vector(0, offset, 0), + rotation = source:getRotation() + Vector(0, 0, zone.is_facedown and 180 or 0), + ---@param card TTSObject + callback_function = function (card) + card:setName(card_data.name) + card:setDescription(card_data.subtitle) + end + } + + new:setCustomObject { + type = 0, + face = card_data.image_uri, + back = card_data.back_image_uri + } + end + + return { handled = true, is_successful = true } +end diff --git a/src/arkhamdb/ProxyInvestigatorCommand.ttslua b/src/arkhamdb/ProxyInvestigatorCommand.ttslua new file mode 100644 index 00000000..a96a3981 --- /dev/null +++ b/src/arkhamdb/ProxyInvestigatorCommand.ttslua @@ -0,0 +1,96 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-08-25 2:32 p.m. +--- + +command_name = "proxy-investigator" + +---@type ArkhamImport_Command_RunDirectives +runOn = { + instructions = true, + handlers = true +} + +---@param parameters ArkhamImport_Command_DescriptionInstructionArguments +---@return ArkhamImport_Command_DescriptionInstructionResults +function do_instruction(parameters) + local args = parameters.arguments + + if (#args~=6 and #args~=7) then + return { + is_successful = false, + error_message = "Proxy Investigator command requires either 7 or 8 arguments. " .. #args .. " were provided." + } + end + + parameters.command_state["proxy-investigator"] = { + name = args[1], + subtitle = args[2], + front_uri = args[3], + back_uri = args[4], + mini_front_uri = args[5], + mini_back_uri = args[6], + zone = args[7] or "investigator" + } + + return { + command_state = parameters.command_state, + is_successful = true + } +end + +---@param source TTSObject +---@param name string +---@param subtitle string +---@param offset number +---@param zone ArkhamImportZone +---@param front string +---@param back string +---@param use_minicard_scaling boolean +local function create_card(source, name, subtitle, offset, zone, front, back, use_minicard_scaling) + local position = zone.is_absolute and zone.position or source:positionToWorld(zone.position) + + local card = spawnObject { + type = "CardCustom", + position = position + Vector(0, offset, 0), + rotation = source:getRotation() + Vector(0, 0, zone.is_facedown and 180 or 0), + scale = use_minicard_scaling and Vector(0.6, 1, 0.6) or Vector(1,1,1), + callback_function = function (card) card:setName(name) card:setDescription(subtitle) end + } + + card:setCustomObject { + type = 0, + face = front, + back = back + } +end + +---@param parameters ArkhamImport_Command_HandlerArguments +---@return ArkhamImport_Command_HandlerResults +function handle_card(parameters) + if parameters.card.type_code ~= "investigator" then return {is_successful = true } end + + local card_data = parameters.command_state["proxy-investigator"] or {} + + if not card_data then return { is_successful = true } end + + local zone = parameters.configuration.zones[card_data.zone] + + if not zone then + return { + is_successful = false, + command_state = parameters.command_state, + error_message = "Proxy Investigator [" .. tostring(parameters.card.code) .. "]: Zone \"" .. tostring(card_data.zone) .. "\" was not found." + } + end + + local source = getObjectFromGUID(parameters.source_guid) + + for _=1, parameters.card.count do + create_card(source, card_data.name, card_data.subtitle, 10, zone, card_data.front_uri, card_data.back_uri, false) + create_card(source, card_data.name, card_data.subtitle, 20, zone, card_data.mini_front_uri, card_data.mini_back_uri, true) + end + + return { handled = true, is_successful = true} +end diff --git a/src/arkhamdb/RandomWeaknessGenerator.ttslua b/src/arkhamdb/RandomWeaknessGenerator.ttslua new file mode 100644 index 00000000..f083210d --- /dev/null +++ b/src/arkhamdb/RandomWeaknessGenerator.ttslua @@ -0,0 +1,24 @@ +local allCardsBagGuid = "15bb07" + +function onLoad(saved_data) + createDrawButton() +end + +function createDrawButton() + self.createButton({ + label="Draw Random\nWeakness", click_function="buttonClick_draw", function_owner=self, + position={0,0.1,2.1}, rotation={0,0,0}, height=600, width=1800, + font_size=250, color={0,0,0}, font_color={1,1,1} + }) +end + +-- Draw a random weakness and spawn it below the object +function buttonClick_draw() + local allCardsBag = getObjectFromGUID(allCardsBagGuid) + local weaknessId = allCardsBag.call("getRandomWeaknessId") + local card = allCardsBag.call("getCardById", { id = weaknessId }) + spawnObjectData({ + data = card.data, + position = self.positionToWorld({0, 1, 5.5}), + rotation = self.getRotation()}) +end diff --git a/src/arkhamdb/Zones.ttslua b/src/arkhamdb/Zones.ttslua new file mode 100644 index 00000000..b05ea915 --- /dev/null +++ b/src/arkhamdb/Zones.ttslua @@ -0,0 +1,171 @@ +-- Sets up and returns coordinates for all possible spawn zones. Because Lua +-- assigns tables by reference and there is no built-in function to copy a +-- table this is relatively brute force. +-- +-- Positions are all relative to the player mat, and most are consistent. The +-- exception are the SetAside# zones, which are placed to the left of the mat +-- for White/Green, and the right of the mat for Orange/Red. +-- +-- Valid Zones: +-- Investigator: Investigator card area. +-- Minicard: Placement for the investigator's minicard. This is just above the +-- player mat, vertically in line with the investigator card area. +-- Deck, Discard: Standard locations for the deck and discard piles. +-- BlankTop, Tarot, Hand1, Hand2, Ally, BlankBottom, Accessory, Arcane1, +-- Arcane2, Body: Asset slot positions on the player mat. +-- Threat[1-4]: Threat area slots. Threat[1-3] correspond to the named threat +-- area slots, and Threat4 is the blank threat area slot. +-- SetAside[1-6]: Areas outside the player mat, to the right for Red/Orange and +-- the left for White/Green. SetAside[1-3] are a column closest to the +-- player mat, with 1 at the top of the mat and 3 at the bottom. +-- SetAside[4-6] are a column farther away from the mat, with 4 at the top +-- and 6 at the bottom. +do + local playerMatGuids = { } + playerMatGuids["Red"] = "0840d5" + playerMatGuids["Orange"] = "bd0ff4" + playerMatGuids["White"] = "8b081b" + playerMatGuids["Green"] = "383d8b" + + local Zones = { } + + local commonZones = { } + commonZones["Investigator"] = {-0.7833852, 0, 0.0001343352} + commonZones["Minicard"] = {-0.7833852, 0, -1.187242} + commonZones["Deck"] = {-1.414127, 0, -0.006668129} + commonZones["Discard"] = {-1.422189,0,0.643440} + commonZones["Ally"] = {-0.236577,0,0.023543} + commonZones["Body"] = {-0.257249,0,0.553170} + commonZones["Hand1"] = {0.600493,0,0.037291} + commonZones["Hand2"] = {0.206867,0,0.025540} + commonZones["Arcane1"] = {0.585817,0,0.567969} + commonZones["Arcane2"] = {0.197267,0,0.562296} + commonZones["Tarot"] = {0.980616,0,0.047756} + commonZones["Accessory"] = {0.976689,0,0.569344} + commonZones["BlankTop"] = {1.364696,0,0.062552} + commonZones["BlankBottom"] = {1.349999,0,0.585419} + commonZones["Threat1"] = {-0.835423,0,-0.633271} + commonZones["Threat2"] = {-0.384615,0,-0.633493} + commonZones["Threat3"] = {0.071090,0,-0.633717} + commonZones["Threat4"] = {0.520816,0,-0.633936} + + Zones["White"] = { } + Zones["White"]["Investigator"] = commonZones["Investigator"] + Zones["White"]["Minicard"] = commonZones["Minicard"] + Zones["White"]["Deck"] = commonZones["Deck"] + Zones["White"]["Discard"] = commonZones["Discard"] + Zones["White"]["Ally"] = commonZones["Ally"] + Zones["White"]["Body"] = commonZones["Body"] + Zones["White"]["Hand1"] = commonZones["Hand1"] + Zones["White"]["Hand2"] = commonZones["Hand2"] + Zones["White"]["Arcane1"] = commonZones["Arcane1"] + Zones["White"]["Arcane2"] = commonZones["Arcane2"] + Zones["White"]["Tarot"] = commonZones["Tarot"] + Zones["White"]["Accessory"] = commonZones["Accessory"] + Zones["White"]["BlankTop"] = commonZones["BlankTop"] + Zones["White"]["BlankBottom"] = commonZones["BlankBottom"] + Zones["White"]["Threat1"] = commonZones["Threat1"] + Zones["White"]["Threat2"] = commonZones["Threat2"] + Zones["White"]["Threat3"] = commonZones["Threat3"] + Zones["White"]["Threat4"] = commonZones["Threat4"] + Zones["White"]["SetAside1"] = {2.004500,0,-0.520315} + Zones["White"]["SetAside2"] = {2.004500,0,0.042552} + Zones["White"]["SetAside3"] = {2.004500,0,0.605419} + Zones["White"]["UnderSetAside3"] = {2.154500,0,0.805419} + Zones["White"]["SetAside4"] = {2.434500,0,-0.520315} + Zones["White"]["SetAside5"] = {2.434500,0,0.042552} + Zones["White"]["SetAside6"] = {2.434500,0,0.605419} + Zones["White"]["UnderSetAside6"] = {2.584500,0,0.805419} + Zones["Orange"] = { } + Zones["Orange"]["Investigator"] = commonZones["Investigator"] + Zones["Orange"]["Minicard"] = commonZones["Minicard"] + Zones["Orange"]["Deck"] = commonZones["Deck"] + Zones["Orange"]["Discard"] = commonZones["Discard"] + Zones["Orange"]["Ally"] = commonZones["Ally"] + Zones["Orange"]["Body"] = commonZones["Body"] + Zones["Orange"]["Hand1"] = commonZones["Hand1"] + Zones["Orange"]["Hand2"] = commonZones["Hand2"] + Zones["Orange"]["Arcane1"] = commonZones["Arcane1"] + Zones["Orange"]["Arcane2"] = commonZones["Arcane2"] + Zones["Orange"]["Tarot"] = commonZones["Tarot"] + Zones["Orange"]["Accessory"] = commonZones["Accessory"] + Zones["Orange"]["BlankTop"] = commonZones["BlankTop"] + Zones["Orange"]["BlankBottom"] = commonZones["BlankBottom"] + Zones["Orange"]["Threat1"] = commonZones["Threat1"] + Zones["Orange"]["Threat2"] = commonZones["Threat2"] + Zones["Orange"]["Threat3"] = commonZones["Threat3"] + Zones["Orange"]["Threat4"] = commonZones["Threat4"] + Zones["Orange"]["SetAside1"] = {-2.004500,0,-0.520315} + Zones["Orange"]["SetAside2"] = {-2.004500,0,0.042552} + Zones["Orange"]["SetAside3"] = {-2.004500,0,0.605419} + Zones["Orange"]["UnderSetAside3"] = {-2.154500,0,0.80419} + Zones["Orange"]["SetAside4"] = {-2.434500,0,-0.520315} + Zones["Orange"]["SetAside5"] = {-2.434500,0,0.042552} + Zones["Orange"]["SetAside6"] = {-2.434500,0,0.605419} + Zones["Orange"]["UnderSetAside6"] = {-2.584500,0,0.80419} + + -- Green positions are the same as White, and Red the same as orange, so we + -- can just point these at the White/Orange definitions + Zones["Red"] = Zones["Orange"] + Zones["Green"] = Zones["White"] + + -- Returns the zone name where the specified card should be placed, based on + -- its metadata. + -- Param cardMetadata: Table of card metadata. Metadata fields type and + -- permanent are required; all others are optional. + -- Return: Zone name such as "Deck", "SetAside1", etc. See Zones object + -- documentation for a list of valid zones. + function Zones.getDefaultCardZone(cardMetadata) + if (cardMetadata.type == "Investigator") then + return "Investigator" + elseif (cardMetadata.type == "Minicard") then + return "Minicard" + elseif (cardMetadata.permanent) then + return "SetAside1" + elseif (cardMetadata.bonded_to ~= nil) then + return "SetAside2" + else + return "Deck" + end + end + + -- Gets the global position for the given zone on the specified player mat. + -- Param playerColor: Color name of the player mat to get the zone position + -- for (e.g. "Red") + -- Param zoneName: Name of the zone to get the position for. See Zones object + -- documentation for a list of valid zones. + -- Return: Global position table, or nil if an invalid player color or zone + -- is specified + function Zones.getZonePosition(playerColor, zoneName) + if (playerColor ~= "Red" + and playerColor ~= "Orange" + and playerColor ~= "White" + and playerColor ~= "Green") then + return nil + end + return getObjectFromGUID(playerMatGuids[playerColor]).positionToWorld(Zones[playerColor][zoneName]) + end + + -- Return the global rotation for a card on the given player mat, based on its + -- metadata. + -- Param playerColor: Color name of the player mat to get the rotation + -- for (e.g. "Red") + -- Param cardMetadata: Table of card metadata. Metadata fields type and + -- permanent are required; all others are optional. + -- Return: Global rotation vector for the given card. This will include the + -- Y rotation to orient the card on the given player mat as well as a + -- Z rotation to place the card face up or face down. + function Zones.getDefaultCardRotation(playerColor, zone) + local deckRotation = getObjectFromGUID(playerMatGuids[playerColor]).getRotation() + + if (zone == "Investigator") then + deckRotation = deckRotation + Vector(0, 270, 0) + elseif (zone == "Deck") then + deckRotation = deckRotation + Vector(0, 0, 180) + end + + return deckRotation + end + + return Zones +end diff --git a/src/chaosbag/ChaosBag.ttslua b/src/chaosbag/ChaosBag.ttslua new file mode 100644 index 00000000..0dac438b --- /dev/null +++ b/src/chaosbag/ChaosBag.ttslua @@ -0,0 +1,13 @@ +function filterObjectEnter(obj) + local props = obj.getCustomObject() + if props ~= nil and props.image ~= nil then + obj.setName(Global.call("getTokenName", { url=props.image })) + end + return true +end + +function onCollisionEnter(collision_info) + self.shuffle() + self.shuffle() + self.shuffle() +end diff --git a/src/chaosbag/StatTracker.ttslua b/src/chaosbag/StatTracker.ttslua new file mode 100644 index 00000000..1856aae2 --- /dev/null +++ b/src/chaosbag/StatTracker.ttslua @@ -0,0 +1,103 @@ +function onload(saved_data) + light_mode = false + + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + light_mode = loaded_data[1] + end + createAll() +end + +-- functions delegated to Global +function printStats(object, player, isRightClick) + -- local toPosition = self.positionToWorld(DRAWN_CHAOS_TOKEN_OFFSET) + if isRightClick then + Global.call("resetStats") + else + Global.call("printStats") + end +end + +function updateSave() + local data_to_save = {light_mode } + saved_data = JSON.encode(data_to_save) + self.script_state = saved_data +end + +function createAll() + s_color = {0.5, 0.5, 0.5, 95} + + if light_mode then + f_color = {1,1,1,95} + else + f_color = {0,0,0,100} + end + + self.createButton({ + click_function="printStats", + function_owner=self, + position={0,0.05,0}, + height=600, + width=1000, + alignment = 3, + tooltip = "Left Click to print stats. Right Click to reset them.", + scale={x=1.5, y=1.5, z=1.5}, + font_size=600, + font_color=f_color, + color={0,0,0,0} + }) + + if light_mode then + lightButtonText = "[ Set dark ]" + else + lightButtonText = "[ Set light ]" + end + +end + +function removeAll() + self.removeInput(0) + self.removeInput(1) + self.removeButton(0) + self.removeButton(1) + self.removeButton(2) +end + +function reloadAll() + removeAll() + createAll() + updateSave() +end + +function swap_fcolor(_obj, _color, alt_click) + light_mode = not light_mode + reloadAll() +end + +function swap_align(_obj, _color, alt_click) + center_mode = not center_mode + reloadAll() +end + +function editName(_obj, _string, value) + self.setName(value) + setTooltips() +end + +function setTooltips() + self.editInput({ + index = 0, + value = self.getName(), + tooltip = "Left click to show stats. Right click to reset them." + }) +end + +function keepSample(_obj, _string, value) + reloadAll() +end + +function onDestroy() + if timerID and type(timerID) == 'object' then + Timer.destroy(timerID) + end +end diff --git a/src/core/ActiveInvestigatorCounter.ttslua b/src/core/ActiveInvestigatorCounter.ttslua new file mode 100644 index 00000000..c6ef982b --- /dev/null +++ b/src/core/ActiveInvestigatorCounter.ttslua @@ -0,0 +1,134 @@ +DEBUG = false +MIN_VALUE = 1 +MAX_VALUE = 4 + +function onload(saved_data) + self.interactable = DEBUG + light_mode = false + val = 0 + + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + light_mode = loaded_data[1] + val = loaded_data[2] + end + + createAll() +end + +function updateSave() + local data_to_save = {light_mode, val} + saved_data = JSON.encode(data_to_save) + self.script_state = saved_data +end + +function createAll() + s_color = {0.5, 0.5, 0.5, 95} + + if light_mode then + f_color = {1,1,1,95} + else + f_color = {0,0,0,100} + end + + + + self.createButton({ + label=tostring(val), + click_function="add_subtract", + function_owner=self, + position={0,0.05,0}, + height=600, + width=1000, + alignment = 3, + scale={x=1.5, y=1.5, z=1.5}, + font_size=600, + font_color=f_color, + color={0,0,0,0} + }) + + + + + if light_mode then + lightButtonText = "[ Set dark ]" + else + lightButtonText = "[ Set light ]" + end + +end + +function removeAll() + self.removeInput(0) + self.removeInput(1) + self.removeButton(0) + self.removeButton(1) + self.removeButton(2) +end + +function reloadAll() + removeAll() + createAll() + + updateSave() +end + +function swap_fcolor(_obj, _color, alt_click) + light_mode = not light_mode + reloadAll() +end + +function swap_align(_obj, _color, alt_click) + center_mode = not center_mode + reloadAll() +end + +function editName(_obj, _string, value) + self.setName(value) + setTooltips() +end + +function add_subtract(_obj, _color, alt_click) + mod = alt_click and -1 or 1 + new_value = math.min(math.max(val + mod, MIN_VALUE), MAX_VALUE) + if val ~= new_value then + val = new_value + updateVal() + updateSave() + end +end + +function updateVal() + + self.editButton({ + index = 0, + label = tostring(val), + + }) +end + +function reset_val() + val = 0 + updateVal() + updateSave() +end + +function setTooltips() + self.editInput({ + index = 0, + value = self.getName(), + tooltip = ttText + }) + self.editButton({ + index = 0, + value = tostring(val), + tooltip = ttText + }) +end + +function null() +end + +function keepSample(_obj, _string, value) + reloadAll() +end diff --git a/src/core/AgendaDeck.ttslua b/src/core/AgendaDeck.ttslua new file mode 100644 index 00000000..f5448079 --- /dev/null +++ b/src/core/AgendaDeck.ttslua @@ -0,0 +1,132 @@ +MIN_VALUE = -99 +MAX_VALUE = 999 + +function onload(saved_data) + light_mode = false + val = 0 + + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + light_mode = loaded_data[1] + val = loaded_data[2] + end + + createAll() +end + +function updateSave() + local data_to_save = {light_mode, val} + saved_data = JSON.encode(data_to_save) + self.script_state = saved_data +end + +function createAll() + s_color = {0.5, 0.5, 0.5, 95} + + if light_mode then + f_color = {1,1,1,95} + else + f_color = {0,0,0,100} + end + + + + self.createButton({ + label=tostring(val), + click_function="add_subtract", + function_owner=self, + position={0,0.05,0}, + height=600, + width=1000, + alignment = 3, + scale={x=1.5, y=1.5, z=1.5}, + font_size=600, + font_color=f_color, + color={0,0,0,0} + }) + + + + + if light_mode then + lightButtonText = "[ Set dark ]" + else + lightButtonText = "[ Set light ]" + end + +end + +function removeAll() + self.removeInput(0) + self.removeInput(1) + self.removeButton(0) + self.removeButton(1) + self.removeButton(2) +end + +function reloadAll() + removeAll() + createAll() + + updateSave() +end + +function swap_fcolor(_obj, _color, alt_click) + light_mode = not light_mode + reloadAll() +end + +function swap_align(_obj, _color, alt_click) + center_mode = not center_mode + reloadAll() +end + +function editName(_obj, _string, value) + self.setName(value) + setTooltips() +end + +function add_subtract(_obj, _color, alt_click) + mod = alt_click and -1 or 1 + new_value = math.min(math.max(val + mod, MIN_VALUE), MAX_VALUE) + if val ~= new_value then + val = new_value + updateVal() + updateSave() + end +end + +function updateVal() + + self.editButton({ + index = 0, + label = tostring(val), + + }) +end + +function reset_val() + val = 0 + updateVal() + updateSave() +end + +function setTooltips() + self.editInput({ + index = 0, + value = self.getName(), + tooltip = ttText + }) + self.editButton({ + index = 0, + value = tostring(val), + tooltip = ttText + }) +end + +function null() +end + +function keepSample(_obj, _string, value) + reloadAll() +end diff --git a/src/core/CustomDataHelper.ttslua b/src/core/CustomDataHelper.ttslua new file mode 100644 index 00000000..e69de29b diff --git a/src/core/DataHelper.ttslua b/src/core/DataHelper.ttslua new file mode 100644 index 00000000..6b711ba2 --- /dev/null +++ b/src/core/DataHelper.ttslua @@ -0,0 +1,1979 @@ +-- set true to enable debug logging +DEBUG = false + +function log(message) + if DEBUG then + print(message) + end +end + +--[[ +Known locations and clues. We check this to determine if we should +atttempt to spawn clues, first we look for _ and if +we find nothing we look for +format is [location_guid -> clueCount] +]] +LOCATIONS_DATA_JSON = [[ +{ + "Study": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Study_670914": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Attic_377b20": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Attic": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Cellar_5d3bcc": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Cellar": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Bathroom": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Bedroom": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Far Above Your House": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Deep Below Your House": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Northside_86faac": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Northside": {"type" : "perPlayer", "value": 2, "clueSide": "back"}, + "Graveyard": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Miskatonic University_cedb0a": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Miskatonic University": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Downtown_1aa7cb": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Downtown": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "St. Mary's Hospital": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Easttown_88245c": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Easttown": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Southside": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Rivertown": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Your House_377b20": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Your House_b28633": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Ritual Site": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Arkham Woods_e8e04b": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Arkham Woods": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "New Orleans_5ab18a": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "New Orleans": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Riverside_ab9d69": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Riverside": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Wilderness_3c5ea8": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Wilderness": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unhallowed Land_552a1d": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Unhallowed Land_15983c": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Flooded Square": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Streets of Venice": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Rialto Bridge": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Venetian Garden": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Guardian": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Canal-side": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Accademia Bridge": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Bridge of Sighs": {"type": "fixed", "value": 2, "clueSide": "back"}, + + "Warren Observatory": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Science Building": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Orne Library": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Administration Building": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Student Union": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Humanities Building": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Dormitories": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + "Faculty Offices": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Faculty Offices_1c567d": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + + "La Bella Luna": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Back Hall Doorway": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Museum Entrance": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Security Office": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Security Office_fcb3e4": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Administration Office": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Administration Office_d2eb25": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Exhibit Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Exhibit Hall_563240": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Exhibit Hall_f3ffb6": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Exhibit Hall_0b0c58": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Exhibit Hall_2d87e6": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Exhibit Hall_da02ea": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + + "Train Car": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Train Car_f3f902": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Train Car_905f69": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Train Car_a3a321": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Train Car_464528": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Train Car_3cfca4": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Train Car_64ffb0": {"type": "fixed", "value": 3, "clueSide": "back"}, + "Train Car_0fb5f0": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + "Engine Car": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "House in the Reeds": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Osborn's General Store": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Congregational Church": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Bishop's Brook": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Burned Ruins": {"type": "fixed", "value": 3, "clueSide": "back"}, + "Schoolhouse": {"type": "fixed", "value": 1, "clueSide": "back"}, + + "Dunwich Village": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Dunwich Village_ac4427": {"type": "fixed", "value": 3, "clueSide": "back"}, + "Cold Spring Glen": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Cold Spring Glen_e58475": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Ten-Acre Meadow": {"type": "fixed", "value": 3, "clueSide": "back"}, + "Ten-Acre Meadow_05b0dd": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Blasted Heath": {"type": "fixed", "value": 3, "clueSide": "back"}, + "Blasted Heath_995fe7": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Whateley Ruins": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Devil's Hop Yard": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Devil's Hop Yard_f7dd31": {"type": "fixed", "value": 2, "clueSide": "back"}, + + "Base of the Hill": {"type": "fixed", "value": 3, "clueSide": "back"}, + "Base of the Hill_80236e": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Ascending Path": {"type": "fixed", "value": 3, "clueSide": "back"}, + "Ascending Path_d3ae26": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Sentinel Peak": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Diverging Path": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Diverging Path_7239aa": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Altered Path": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "The Edge of the Universe": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Tear Through Time": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Prismatic Cascade": {"type": "fixed", "value": 3, "clueSide": "front"}, + "Towering Luminosity": {"type": "fixed", "value": 4, "clueSide": "front"}, + "Tear Through Space": {"type": "fixed", "value": 1, "clueSide": "front"}, + "Endless Bridge": {"type": "fixed", "value": 2, "clueSide": "front"}, + "Dimensional Doorway": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Steps of Y'hagharl": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Unstable Vortex": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Indecipherable Stairs": {"type": "fixed", "value": 1, "clueSide": "front"}, + + "Backstage Doorway": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Backstage Doorway_0797a9": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Lobby Doorway": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Lobby Doorway_7605cf": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Lobby": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Backstage": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Balcony": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Foyer": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Historical Society": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Historical Society_40f79d": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Historical Society_b352f8": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Historical Society_0cf5d5": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Historical Society_abc0cb": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Historical Society_ab6a72": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Hidden Library": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + + "Patient Confinement": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Asylum Halls": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Asylum Halls_f99530": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Asylum Halls_576595": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Infirmary": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Basement Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Yard": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Garden": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Kitchen": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Mess Hall": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Grand Guignol": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Montmartre": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Montmartre_cbaacc": {"type": "perPlayer", "value": 0, "clueSide": "front"}, + "Montparnasse": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Notre-Dame": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Gare d'Orsay": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Opéra Garnier": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Canal Saint-Martin": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Le Marais": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Gardens of Luxembourg": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Père Lachaise Cemetery": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Catacombs": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Catacombs_29170f": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Catacombs_f1237c": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Catacombs_c3151e": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Catacombs_14b1cb": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Catacombs_81920c": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Catacombs_c14c8b": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Catacombs_ea2a55": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Catacombs_8bcab3": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Catacombs_7c7f4a": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Catacombs_80cf41": {"type": "fixed", "value": 0, "clueSide": "back"}, + + "Abbey Church": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Porte de l'Avancée": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Grand Rue": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Cloister": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Knight's Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Chœur Gothique": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Outer Wall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Outer Wall_014bd6": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "North Tower": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "North Tower_69eae5": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Chapel of St. Aubert": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + "Chapel of St. Aubert_e75ba8": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Abbey Tower": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + "Abbey Tower_2f3d21": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Shores of Hali": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Dark Spires": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Palace of the King": {"type": "perPlayer", "value": 3, "clueSide": "front"}, + "Palace of the King_60d758": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Ruins of Carcosa": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Dim Streets": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Depths of Demhe": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Bleak Plains": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Recesses of Your Own Mind": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "The Throne Room": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Stage of the Ward Theatre": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + + "Serpent’s Haven": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Ruins of Eztli": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Rope Bridge": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Overgrown Ruins": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "River Canyon": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Path of Thorns": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Temple of the Fang": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Circuitous Trail": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Riverside Temple": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Waterfall": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Trail of the Dead": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Cloud Forest": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + + "Chamber of Time": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Ancient Hall": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Ancient Hall_b9acb8": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Grand Chamber": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Entryway": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Underground Ruins": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Burial Pit": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Secret Passage": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Snake Pit": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Throne Room": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Mosaic Chamber": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Tomb of the Ancients": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + + "Town Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Curiositie Shoppe": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "At the Station": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "At the Station_e0833c": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Missing Persons": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "The Relic is Missing!": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Trial of the Huntress": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Search for the Meaning": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Seeking Trouble": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Seeking Trouble_42f93b": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + + "Sacred Woods": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Chapultepec Hill": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Chapultepec Hill_baec21": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Canals of Tenochtitlán": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Lake Xochimilco": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Lake Xochimilco_59bf7d": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Templo Mayor": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Templo Mayor_fb0083": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Temples of Tenochtitlán": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Temples of Tenochtitlán_80cef8": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + + "Mouth of K'n-yan_38a3e5": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Stone Altar": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Time-Wracked Woods": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Vast Passages": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Perilous Gulch": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Dark Hollow": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Hall of Idolatry": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Crystal Pillars": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Ruins of K’n-yan": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Chthonian Depths": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Subterranean Swamp": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Treacherous Descent": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + + "Interview Room": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Interview Room_b1861c": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Halls of Pnakotus": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Deconstruction Room": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Towers of Pnakotus": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Laboratory of the Great Race": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Yithian Orrery": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Great Library": {"type": "fixed", "value": 4, "clueSide": "back"}, + "Cyclopean Vaults": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Alien Conservatory": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "City of the Serpents": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Bridge over N'kai": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Abandoned Site": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Caverns of Yoth": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Hall of Heresy": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Bright Canyon": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Forked Path": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + + "Nexus of N'kai": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "A Pocket in Time": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "City of the Unseen": {"type": "fixed", "value": 1, "clueSide": "front"}, + "Valusia": {"type": "fixed", "value": 2, "clueSide": "front"}, + "Great Hall of Celeano": {"type": "fixed", "value": 3, "clueSide": "front"}, + "Buenos Aires": {"type": "fixed", "value": 3, "clueSide": "front"}, + "Ultima Thule": {"type": "fixed", "value": 2, "clueSide": "front"}, + + "Shores of R’lyeh": {"type": "fixed", "value": 2, "clueSide": "front"}, + "Atlantis": {"type": "fixed", "value": 2, "clueSide": "front"}, + "Pnakotus": {"type": "fixed", "value": 3, "clueSide": "front"}, + "Ruins of New York": {"type": "fixed", "value": 3, "clueSide": "front"}, + "Yuggoth": {"type": "fixed", "value": 3, "clueSide": "front"}, + "Mu": {"type": "fixed", "value": 4, "clueSide": "front"}, + "Plateau of Leng_0ab6ff": {"type": "fixed", "value": 1, "clueSide": "front"}, + + "Billiards Room": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Billiards Room_33990b": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Trophy Room": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Trophy Room_e9160a": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Master Bedroom": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Balcony_1b5483": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Office": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Office_a1bd9a": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Witch-Haunted Woods_1539ea": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Witch-Haunted Woods_db1663": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Witch-Haunted Woods": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Witch-Haunted Woods_d3f8c3": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Witch-Haunted Woods_eca18e": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Paths into Twilight": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + + "The Imperial Entrance": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Dark Stairwell": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Stairway": {"type": "fixed", "value": 1, "clueSide": "back"}, + "The Balcony": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Back Booths": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Lobby": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Backroom Door": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Backroom Door_ed439d": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Dining Area": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "The Dance Floor": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Gateway to the East": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Back Alley": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Mingzhu Laundry": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Dragon's Den": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "The Phoenix's Nest": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Golden Temple of the Heavens": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Flea Market": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Zihao's House of Fighting Arts": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Daiyu's Tea Garden": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Moldy Halls": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Decrepit Door": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Walter Gilman's Room": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unknown Places_b538f8": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Unknown Places_7bea34": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Unknown Places": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Unknown Places_9a471d": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unknown Places_0ac3ea": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unknown Places_ea7a2b": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unknown Places_713ec2": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unknown Places_609112": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Strange Geometry": {"type": "fixed", "value": 1, "clueSide": "front"}, + "Site of the Sacrifice": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + + "Hangman's Brook": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Abandoned Chapel": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Haunted Fields": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Lobby_1c2dfe": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Lobby_bcd556": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Lodge Gates_fa6a29": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Lodge Gates": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Lodge Cellar": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Lodge Cellar_8ea4fd": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Lounge": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Vault": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Inner Sanctum": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Library": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Library_47ccbc": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Sanctum Doorway": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Sanctum Doorway_4da6c3": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Sanctum Doorway_587a15": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + + "The Geist-Trap": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Forbidding Shore": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unvisited Isle": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Rivertown_92ee68": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Rivertown_db4b20": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Rivertown_ca2443": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Southside_c898a0": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Southside_e7f5fa": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Southside_9fed9d": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Silver Twilight Lodge": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Silver Twilight Lodge_17e686": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Hangman's Hill": {"type": "fixed", "value": 0, "clueSide": "back"}, + "Hangman's Hill_5f4d8a": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Cosmic Ingress": {"type": "fixed", "value": 3, "clueSide": "back"}, + "Cosmos": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Cosmos_a89dbf": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Cosmos_1a0ad2": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Cosmos_30fc53": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Cosmos_8f3e16": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Cosmos_4e8ae3": {"type": "fixed", "value": 2, "clueSide": "back"}, + "Cosmos_a8d84d": {"type": "fixed", "value": 4, "clueSide": "back"}, + "Cosmos_7a3ece": {"type": "fixed", "value": 6, "clueSide": "back"}, + "Cosmos_311eb1": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Cosmos_6bd5ca": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Cosmos_294c00": {"type": "fixed", "value": 2, "clueSide": "back"}, + + "Seventy Steps": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Seven Hundred Steps": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Base of the Steps": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Enchanted Woods": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Stairwell": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Basement Door_42fa87": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Basement Door": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Waiting Room": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Emergency Room": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Experimental Therapies Ward": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Records Office": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Foyer_9a9f9a": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Room 245": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Hotel Roof": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Office_b3ed47": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Room 212": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Basement": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Second Floor Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Room 225": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Restaurant": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Suite Balcony": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Ulthar": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Dylath-Leen": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Mt. Ngranek": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Baharna": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Zulan-Thek": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Sarnath": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "City-Which-Appears-On-No-Map": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Celephaïs": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Nameless Ruins": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Kadatheron": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Ilek-Vad": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Ruins of Ib": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Temple of Unattainable Desires": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Hazuth-Kleg": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Serannian": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + + "Mysterious Stairs": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Mysterious Stairs_df1a40": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Attic_10faf9": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unmarked Tomb": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Upstairs Doorway": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Front Porch": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Downstairs Doorway": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Downstairs Doorway_c93906": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Burial Ground": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Temple of the Moon Lizard": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "City of the Moon-Beasts": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Moon-Forest": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Dark Crater": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Caverns Beneath the Moon": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Black Core": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Light Side of the Moon": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "City of Gugs": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Vaults of Zin": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Plain of the Ghouls": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Sea of Bones": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Vale of Pnath": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Crag of the Ghouls": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Sea of Pitch": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + + "Plateau of Leng": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Cold Wastes": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Monastery of Leng": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Onyx Gates": {"type": "fixed", "value": 12, "clueSide": "back"}, + "Forsaken Tower": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "The Crater": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Quarantine Zone": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Quarantine Zone_5f2a9b": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Quarantine Zone_4a8e9c": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Quarantine Zone_5193e9": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Quarantine Zone_b3a920": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "The Great Web": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Great Web_39ace3": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "The Great Web_727790": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "The Great Web_5c5ec4": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "The Great Web_361fd7": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "The Great Web_dfdc8c": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Expedition Camp": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Desert Oasis": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Untouched Vault": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Sands of Dashur": {"type": "perPlayer", "value": 0, "clueSide": "front"}, + "Sandswept Ruins": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Nile River": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Faceless Sphinx": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Dunes of the Sahara": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + + "Streets of Cairo": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Cairo Bazaar": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Temple Courtyard": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Museum of Egyptian Antiquities": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Outskirts of Cairo": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Eldritch Gate": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Mist-Filled Caverns": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Stairway to Sarkomand": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Tunnels under Ngranek": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "The Great Abyss": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "A Dream Betwixt": {"type": "perPlayer", "value": 0, "clueSide": "front"}, + + "Velma's Doghouse": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Barkham City Pound": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Barkham Asylum": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Beasttown": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Tailside": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Slobbertown": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Snoutside": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Muttskatonic University": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Boneyard": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "St. Mary's Animal Hospital": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Arkham": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Streets of New York City": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Penthouse": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Burning Pit": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Streets of Providence": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Athenaeum of the Empty Sky": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Arcade": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Streets of Montréal": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Chateau Ramezay": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Shrine of Magh’an Ark’at": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "Unfamiliar Chamber": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Tidal Tunnel": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Tidal Tunnel_0f20fc": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Tidal Tunnel_d5566b": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Tidal Tunnel_dc9eb7": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Tidal Tunnel_513d82": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + + "First National Grocery": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Marsh Refinery": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Innsmouth Square": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Innsmouth Harbour": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Fish Street Bridge_b6b9b7": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Gilman House": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Little Bookshop": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Innsmouth Jail_f63738": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "New Church Green_d1ef9c": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Sawbone Alley_899c2c": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "The House on Water Street_e4f53a": {"type": "perPlayer", "value": 2, "clueSide": "front"}, + "Shoreward Slums_24e42d": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Esoteric Order of Dagon_28c301": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + + "Esoteric Order of Dagon_ef8cef": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "New Church Green_921a9b": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Marsh Refinery_44c342": {"type": "fixed", "value": 1, "clueSide": "back"}, + "The House on Water Street_104e07": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "The Little Bookshop_a17a82": {"type": "fixed", "value": 1, "clueSide": "back"}, + "First National Grocery_9ae75c": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Fish Street Bridge_a358fc": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Innsmouth Harbour_30b2c0": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Sawbone Alley_e58cff": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Gilman House_e589b8": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Innsmouth Jail_755fc0": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Shoreward Slums_c0d0df": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Desolate Coastline": {"type": "fixed", "value": 1, "clueSide": "back"}, + + "Unfathomable Depths_cb5e3e": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Unfathomable Depths_7d180e": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unfathomable Depths_fdf43f": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Unfathomable Depths_431ca2": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Unfathomable Depths_dfc9b4": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Unfathomable Depths_086743": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Tidal Tunnel_0e611a": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Tidal Tunnel_b1a7f2": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Old Innsmouth Road": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Old Innsmouth Road_07ba2e": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + "Old Innsmouth Road_48b819": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Old Innsmouth Road_02e79c": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Old Innsmouth Road_27826a": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Old Innsmouth Road_dd62cc": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Old Innsmouth Road_687b03": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Old Innsmouth Road_eb3303": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Old Innsmouth Road_bebfba": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Old Innsmouth Road_c36e38": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Old Innsmouth Road_175a8a": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Old Innsmouth Road_d2c47a": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Old Innsmouth Road_095dac": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Old Innsmouth Road_fe2e46": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Old Innsmouth Road_f35c3d": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Falcon Point Cliffside": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Lighthouse Stairwell": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Lantern Room": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Lighthouse Keeper's Cottage": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + + "Tidal Tunnel_7eba72": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Tidal Tunnel_b4bcd8": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Tidal Tunnel_4ba689": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Tidal Tunnel_ffdbef": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + + "First Floor Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "First Floor Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Second Floor Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Second Floor Hall_b06d36": {"type": "fixed", "value": 1, "clueSide": "back"}, + "Third Floor Hall": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Lair of Dagon": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + + "Tidal Tunnel_01c28f": {"type": "fixed", "value": 1, "clueSide": "back"}, + + "Y'ha-nthlei": {"type": "perPlayer", "value": 0, "clueSide": "back"}, + "Y'ha-nthlei_014f88": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Y'ha-nthlei_eca6a9": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Y'ha-nthlei_3e58ef": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Y'ha-nthlei_ce1a94": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Y'ha-nthlei Sanctum": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Lair of Dagon_819894": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + "Lair of Hydra": {"type": "perPlayer", "value": 3, "clueSide": "back"}, + + "Arkham Police Station": {"type": "fixed", "value": 4, "clueSide": "back"}, + + "Senator Nathaniel Rhodes": {"type": "perPlayer", "value": 1, "clueSide": "front"}, + "Wine Cellar": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + "Wine Cellar_9d0410": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Wine Cellar_b882f3": {"type": "perPlayer", "value": 2, "clueSide": "back"}, + "Hidden Passageway": {"type": "perPlayer", "value": 1, "clueSide": "back"}, + + "XXXX": {"type": "fixed", "value": 2, "clueSide": "back"}, + "xxx": {"type": "perPlayer", "value": 2, "clueSide": "back"} +} +]] +--[[ +Player cards with token counts and types +]] +PLAYER_CARD_DATA_JSON = [[ +{ + "Flashlight": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Shrivelling": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Shrivelling (3)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Grotesque Statue (4)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Forbidden Knowledge": { + "tokenType": "resource", + "tokenCount": 4 + }, + ".45 Automatic": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Shotgun (4)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Liquid Courage": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Song of the Dead (2)": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Cover Up": { + "tokenType": "clue", + "tokenCount": 3 + }, + "Roland's .38 Special": { + "tokenType": "resource", + "tokenCount": 4 + }, + "First Aid": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Scrying": { + "tokenType": "resource", + "tokenCount": 3 + }, + ".41 Derringer": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Painkillers": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Smoking Pipe": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Clarity of Mind": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Rite of Seeking": { + "tokenType": "resource", + "tokenCount": 3 + }, + "M1918 BAR (4)": { + "tokenType": "resource", + "tokenCount": 8 + }, + "Ornate Bow (3)": { + "tokenType": "resource", + "tokenCount": 1 + }, + ".41 Derringer (2)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Suggestion (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Chicago Typewriter (4)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Lupara (3)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "First Aid (3)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Springfield M1903 (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Springfield M1903 (4) (Taboo)": { + "tokenType": "resource", + "tokenCount": 3 + }, + ".32 Colt": { + "tokenType": "resource", + "tokenCount": 6 + }, + "Venturer": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Lockpicks (1)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Finn's Trusty .38": { + "tokenType": "resource", + "tokenCount": 3 + }, + ".45 Automatic (2)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Lightning Gun (5)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Strange Solution (4)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Strange Solution (4):Acidic Ichor": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Strange Solution (4):Empowering Elixir": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Arcane Insight (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Archaic Glyphs (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "In the Know (1)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Rite of Seeking (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Alchemical Transmutation": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Scrying (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Shrivelling (5)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Mists of R'lyeh": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Mists of R'lyeh (4)": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Colt Vest Pocket": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Old Hunting Rifle (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Thermos": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Feed the Mind (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Seal of the Seventh Sign (5)": { + "tokenType": "resource", + "tokenCount": 7 + }, + "Flamethrower (5)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Flamethrower (5) (Taboo)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Pnakotic Manuscripts (5)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Kerosene (1)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Shards of the Void (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Try and Try Again (1)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Arcane Initiate": { + "tokenType": "doom", + "tokenCount": 1 + }, + "Detective's Colt 1911s": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Extra Ammunition (1)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Rite of Seeking (2)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Arcane Initiate (3)": { + "tokenType": "doom", + "tokenCount": 1 + }, + "Clarity of Mind (3)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Fingerprint Kit": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Truth from Fiction": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Enchanted Blade": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Tennessee Sour Mash": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Scroll of Secrets": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Scroll of Secrets (Taboo)": { + "tokenType": "resource", + "tokenCount": 3 + }, + ".45 Thompson": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Mr. \"Rook\"": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Mr. \"Rook\" (Taboo)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Scroll of Secrets (3):Seeker": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Scroll of Secrets (3) (Taboo):Seeker": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Scroll of Secrets (3):Mystic": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Scroll of Secrets (3) (Taboo):Mystic": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Enchanted Blade (3):Guardian": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Enchanted Blade (3):Mystic": { + "tokenType": "resource", + "tokenCount": 4 + }, + ".45 Thompson (3)": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Esoteric Atlas (1)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Tennessee Sour Mash (3):Rogue": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Tennessee Sour Mash (3):Survivor": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Mk 1 Grenades (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Dayana Esperence (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Pendant of the Queen": { + "tokenType": "resource", + "tokenCount": 3 + }, + ".32 Colt (2)": { + "tokenType": "resource", + "tokenCount": 6 + }, + "Alchemical Transmutation (2)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Suggestion (1)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Gate Box": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Tony's .38 Long Colt": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Gregory Gry": { + "tokenType": "resource", + "tokenCount": 9 + }, + "Scroll of Prophecies": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Healing Words": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Otherworld Codex (2)": { + "tokenType": "resource", + "tokenCount": 3 + }, + ".35 Winchester": { + "tokenType": "resource", + "tokenCount": 5 + }, + ".35 Winchester (Taboo)": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Old Book of Lore (3)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Sawed-Off Shotgun (5)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Mind's Eye (2)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Colt Vest Pocket (2)": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Mists of R'lyeh (2)": { + "tokenType": "resource", + "tokenCount": 5 + }, + "The Chthonian Stone (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Flesh Ward": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Physical Training (4)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Encyclopedia": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Feed the Mind": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Forbidden Tome": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Esoteric Atlas (2)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "The Necronomicon (5)": { + "tokenType": "resource", + "tokenCount": 6 + }, + "The Necronomicon (5) (Taboo)": { + "tokenType": "resource", + "tokenCount": 6 + }, + "Mauser C96": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Liquid Courage (1)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Mauser C96 (2)": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Beretta M1918 (4)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Scrying Mirror": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Azure Flame": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Clairvoyance": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Ineffable Truth": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Grotesque Statue (2)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Azure Flame (3)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Clairvoyance (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Ineffable Truth (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Arcane Studies (4)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Azure Flame (5)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Clairvoyance (5)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Ineffable Truth (5)": { + "tokenType": "resource", + "tokenCount": 3 + }, + ".18 Derringer": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Grimm's Fairy Tales": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Old Keyring": { + "tokenType": "resource", + "tokenCount": 2 + }, + ".18 Derringer (2)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Chainsaw (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Becky": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Book of Psalms": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Cryptographic Cipher": { + "tokenType": "resource", + "tokenCount": 3 + }, + ".25 Automatic": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Obfuscation": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Eldritch Sophist": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Armageddon": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Eye of Chaos": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Shroud of Shadows": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Guided by the Unseen (3)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Eye of Chaos (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Shroud of Shadows (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Armageddon (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Hyperawareness (4)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Hard Knocks (4)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Dig Deep (4)": { + "tokenType": "resource", + "tokenCount": 2 + }, + ".25 Automatic (2)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Shrine of the Moirai (3)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Archive of Conduits": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Archive of Conduits (4)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Eon Chart (1)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Eon Chart (4)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Brand of Cthugha (1)": { + "tokenType": "resource", + "tokenCount": 6 + }, + "Brand of Cthugha (4)": { + "tokenType": "resource", + "tokenCount": 9 + }, + "True Magick (5)": { + "tokenType": "resource", + "tokenCount": 1 + }, + "Healing Words (3)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Close the Circle (1)": { + "tokenType": "resource", + "tokenCount": 1 + }, + "Bangle of Jinxes (1)": { + "tokenType": "resource", + "tokenCount": 1 + }, + "Jury-Rig": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Bandages": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Schoffner's Catalogue": { + "tokenType": "resource", + "tokenCount": 5 + }, + "Antiquary (3)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Crafty (3)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Bruiser (3)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Sleuth (3)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Prophetic (3)": { + "tokenType": "resource", + "tokenCount": 2 + }, + "Earthly Serenity (4)": { + "tokenType": "resource", + "tokenCount": 6 + }, + "Earthly Serenity (1)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Enchanted Bow (2)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Blur (4)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Blur (1)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Professor William Webb (2)": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Professor William Webb": { + "tokenType": "resource", + "tokenCount": 3 + }, + "Divination (4)": { + "tokenType": "resource", + "tokenCount": 6 + }, + "Divination (1)": { + "tokenType": "resource", + "tokenCount": 4 + }, + "Cover Up:Advanced": { + "tokenType": "clue", + "tokenCount": 4 + }, + + "xxx": { + "tokenType": "resource", + "tokenCount": 3 + } +} +]] + +-- Encounter Cards with Hidden. +HIDDEN_CARD_DATA = { + "Visions in Your Mind (Death)", + "Visions in Your Mind (Failure)", + "Visions in Your Mind (Hatred)", + "Visions in Your Mind (Horrors)", + "Gift of Madness (Misery)", + "Gift of Madness (Pity)", + "Possession (Murderous)", + "Possession (Torturous)", + "Possession (Traitorous)", + + "Whispers in Your Head (Anxiety)", + "Whispers in Your Head (Dismay)", + "Whispers in Your Head (Doubt)", + "Whispers in Your Head (Dread)", + "Delusory Evils", + "Hastur's Gaze", + "Hastur's Grasp", + + "Law of 'Ygiroth (Chaos)", + "Law of 'Ygiroth (Discord)", + "Law of 'Ygiroth (Pandemonium)", + "Nyarlathotep", + "Restless Journey (Fallacy)", + "Restless Journey (Hardship)", + "Restless Journey (Lies)", + "Whispering Chaos (East)", + "Whispering Chaos (North)", + "Whispering Chaos (South)", + "Whispering Chaos (West)" +} + +LOCATIONS_DATA = JSON.decode(LOCATIONS_DATA_JSON) +PLAYER_CARD_DATA = JSON.decode(PLAYER_CARD_DATA_JSON) + +PLAYER_CARD_TOKEN_OFFSETS = { + [1] = { + { 0, 3, -0.2 }, + }, + [2] = { + { 0.4, 3, -0.2 }, + { -0.4, 3, -0.2 }, + }, + [3] = { + { 0, 3, -0.9 }, + { 0.4, 3, -0.2 }, + { -0.4, 3, -0.2 }, + }, + [4] = { + { 0.4, 3, -0.9 }, + { -0.4, 3, -0.9 }, + { 0.4, 3, -0.2 }, + { -0.4, 3, -0.2 } + }, + [5] = { + { 0.7, 3, -0.9 }, + { 0, 3, -0.9 }, + { -0.7, 3, -0.9 }, + { 0.4, 3, -0.2 }, + { -0.4, 3, -0.2 } + }, + [6] = { + { 0.7, 3, -0.9 }, + { 0, 3, -0.9 }, + { -0.7, 3, -0.9 }, + { 0.7, 3, -0.2 }, + { 0, 3, -0.2 }, + { -0.7, 3, -0.2 }, + }, + [7] = { + { 0.7, 3, -0.9 }, + { 0, 3, -0.9 }, + { -0.7, 3, -0.9 }, + { 0.7, 3, -0.2 }, + { 0, 3, -0.2 }, + { -0.7, 3, -0.2 }, + { 0, 3, 0.5 }, + }, + [8] = { + { 0.7, 3, -0.9 }, + { 0, 3, -0.9 }, + { -0.7, 3, -0.9 }, + { 0.7, 3, -0.2 }, + { 0, 3, -0.2 }, + { -0.7, 3, -0.2 }, + { -0.35, 3, 0.5 }, + { 0.35, 3, 0.5 }, + }, + [9] = { + { 0.7, 3, -0.9 }, + { 0, 3, -0.9 }, + { -0.7, 3, -0.9 }, + { 0.7, 3, -0.2 }, + { 0, 3, -0.2 }, + { -0.7, 3, -0.2 }, + { 0.7, 3, 0.5 }, + { 0, 3, 0.5 }, + { -0.7, 3, 0.5 }, + }, + [12] = { + { 0.7, 3, -0.9 }, + { 0, 3, -0.9 }, + { -0.7, 3, -0.9 }, + { 0.7, 3, -0.2 }, + { 0, 3, -0.2 }, + { -0.7, 3, -0.2 }, + { 0.7, 3, 0.5 }, + { 0, 3, 0.5 }, + { -0.7, 3, 0.5 }, + { 0.7, 3, 1.2 }, + { 0, 3, 1.2 }, + { -0.7, 3, 1.2 }, + } + +} + +modeData = { + ['Core Set'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['The Devourer Below'] = { + easy = { parent = 'Core Set', append = { 'elder' }, message = 'An additional token for the preparation of this scenario has been added to the bag.' }, + normal = { parent = 'Core Set', append = { 'elder' }, message = 'An additional token for the preparation of this scenario has been added to the bag.' }, + hard = { parent = 'Core Set', append = { 'elder' }, message = 'An additional token for the preparation of this scenario has been added to the bag.' }, + expert = { parent = 'Core Set', append = { 'elder' }, message = 'An additional token for the preparation of this scenario has been added to the bag.' } + }, + -----------------The Dunwich Legacy + + ['The Dunwich Legacy'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'red', 'blue' } } + }, + ['The Miskatonic Museum'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Essex County Express'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Blood on the Altar'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Undimensioned and Unseen'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Where Doom Awaits'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Lost in Time and Space'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + -----------------The Path to Carcosa + + ['The Path to Carcosa'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'skull', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'skull', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'skull', 'red', 'blue' } } + }, + ['The Last King'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'red', 'blue' }, random = { {'cultist', 'cultist'}, {'tablet', 'tablet'}, {'elder', 'elder'} } } + }, + ['Echoes of the Past'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'red', 'blue' }, random = { {'cultist', 'cultist'}, {'tablet', 'tablet'}, {'elder', 'elder'} } } + }, + ['The Unspeakable Oath'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'skull', 'red', 'blue' }, random = { {'cultist', 'cultist'}, {'tablet', 'tablet'}, {'elder', 'elder'} } } + }, + ['A Phantom of Truth'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'skull', 'red', 'blue' }, random = { {'cultist', 'cultist'}, {'tablet', 'tablet'}, {'elder', 'elder'} } } + }, + ['The Pallid Mask'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'skull', 'red', 'blue' }, random = { {'cultist', 'cultist'}, {'tablet', 'tablet'}, {'elder', 'elder'} } } + }, + ['Black Stars Rise'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'skull', 'red', 'blue' }, random = { {'cultist', 'cultist'}, {'tablet', 'tablet'}, {'elder', 'elder'} } } + }, + ['Dim Carcosa'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'skull', 'red', 'blue' } } + }, + -----------------The Forgotten Age + + ['The Forgotten Age'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'skull', 'skull', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'skull', 'skull', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm6', 'm8', 'skull', 'skull', 'elder', 'red', 'blue' } } + }, + ['The Doom of Eztli'] = { + standalone = { token = { 'p1', '0', '0', '0','m1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Threads of Fate'] = { + standalone = { token = { 'p1', '0', '0', '0','m1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Boundary Beyond'] = { + standalone = { token = { 'p1', '0', '0', '0','m1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The City of Archives'] = { + standalone = { token = { 'p1', '0', '0', '0','m1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Depths of Yoth'] = { + standalone = { token = { 'p1', '0', '0', '0','m1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Heart of the Elders'] = { + standalone = { token = { 'p1', '0', '0', '0','m1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Shattered Aeons'] = { + standalone = { token = { 'p1', '0', '0', '0','m1', 'm2', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'elder', 'red', 'blue' } } + }, + + -----------------The Circle Undone + + ['The Circle Undone'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'skull', 'skull', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm6', 'm8', 'skull', 'skull', 'red', 'blue' } } + }, + ["At Death's Doorstep"] = { + standalone = { token = { 'p1', '0', '0', 'm1','m1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Secret Name'] = { + standalone = { token = { 'p1', '0', '0', 'm1','m1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Wages of Sin'] = { + standalone = { token = { 'p1', '0', '0', 'm1','m1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['For the Greater Good'] = { + standalone = { token = { 'p1', '0', '0', 'm1','m1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Union and Disillusion'] = { + standalone = { token = { 'p1', '0', '0', 'm1','m1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['In the Clutches of Chaos'] = { + standalone = { token = { 'p1', '0', '0', 'm1','m1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Before the Black Throne'] = { + standalone = { token = { 'p1', '0', '0', 'm1','m1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + + + -----------------The Dream-Eaters + + ['TDE_A'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'cultist', 'tablet', 'tablet', 'red', 'blue' } } + }, + ['TDE_B'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'elder', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'elder', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'elder', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'elder', 'elder', 'red', 'blue' } } + }, + ['The Search For Kadath'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'red', 'blue' } } + }, + ['A Thousand Shapes of Horror'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'elder', 'elder', 'red', 'blue' } } + }, + ['Dark Side of the Moon'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'red', 'blue' } } + }, + ['Point of No Return'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'cultist', 'elder', 'elder', 'red', 'blue' } } + }, + ['Where the Gods Dwell'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'red', 'blue' } } + }, + ['Weaver of the Cosmos'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'cultist', 'elder', 'elder', 'red', 'blue' } } + }, + + + -----------------The Innsmouth Conspiracy + ['The Innsmouth Conspiracy'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'elder', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'elder', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'elder', 'elder', 'red', 'blue' } } , + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'elder', 'elder', 'red', 'blue' } } + }, + ['TIC_Standalone'] = { + standalone = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'elder', 'elder', 'red', 'blue' } } + }, + + -----------------The Side Missions + --official + ['Curse of the Rougarou'] = { + normal = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm5', 'm6', 'm8', 'skull', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Carnevale of Horrors'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm6', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm3', 'm4', 'm5', 'm6', 'm7', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Labyrinths of Lunacy'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'red', 'blue' } }, + hard = { token = { 'p1', '0','m1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'red', 'blue' } } + }, + ['Guardians of the Abyss'] = { + normal = { token = { 'p1', 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm6', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm7', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + }, + ['Excelsior'] = { + normal = { token = { 'p1', '0', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', 'm1', 'm2', 'm3', 'm4', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + }, + ['Read or Die'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['All or Nothing'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + + ['Meowlathotep'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + + ['WotOG'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'skull', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'skull', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'skull', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'skull', 'skull', 'skull', 'red', 'blue' } } + }, + + ['Bad Blood'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + + --fan-made + ['Carnevale of Spiders'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm6', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm3', 'm4', 'm5', 'm6', 'm7', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + + ['The Nephew Calls'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Outsider'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Stranger Things'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Winter Winds'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm3', 'skull', 'cultist', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'cultist', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'cultist', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'skull', 'cultist', 'red', 'blue' } } + }, + ['The Festival'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm6', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm3', 'm4', 'm5', 'm6', 'm7', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Forbidding Desert'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'red', 'blue' } } + }, + ['Happys Funhouse'] = { + normal = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm2', 'm3', 'm3', 'm5', 'm7', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Knightfall'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm5', 'm6', 'm8', 'cultist', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Last Call at Roxies'] = { + easy = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'skull', 'elder', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'elder', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'elder', 'elder', 'red', 'blue' } } + }, + ['The Limens of Belief'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'red', 'blue' } } + }, + ['Blood Spilled in Salem'] = { + normal = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'skull', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Bread and Circuses'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['Bridge of Sighs'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['The Collector'] = { + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['The Colour out of Space'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm5', 'm6', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Curse of Amultep'] = { + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['The Dying Star'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'blue', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'blue', 'red', 'blue' } } + }, + ['Against the Wendigo'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm7', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Pensher Wyrm'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm5', 'm6', 'm8', 'skull', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'elder', 'red', 'blue' } } + }, + ['Approaching Storm'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Into the Shadowlands'] = { + easy = { token = { 'p1', 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm5', 'm6', 'm7', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['London Set 1'] = { + easy = { token = { 'p2', 'p1', '0', '0', '0', 'm1', 'm2', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm2', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm2', 'm4', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + }, + ['London Set 2'] = { + normal = { token = { 'p1', '0', '0', 'm1', 'm2', 'm3', 'skull', 'skull', 'elder', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm2', 'm3', 'skull', 'skull', 'elder', 'elder', 'tablet', 'red', 'blue' } }, + }, + ['London Set 3'] = { + normal = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + }, + ['Delta Green'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + }, + ['Jennys Choice'] = { + easy = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4','skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', 'm1', 'm2', 'm2', 'm3', 'm3', 'm5', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'elder', 'red', 'blue' } } + }, + ['The Blob'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + }, + ['The Initiation'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'skull', 'skull', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'skull', 'skull', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm6', 'm8', 'skull', 'skull', 'elder', 'red', 'blue' } } + }, + ['Consternation'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm4', 'm5', 'm6', 'skull', 'skull', 'skull', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm4', 'm5', 'm6', 'm7', 'skull', 'skull', 'skull', 'red', 'blue' } }, + }, + ['Of Sphinx'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'skull', 'elder', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'elder', 'cultist', 'cultist', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'skull', 'elder', 'cultist', 'cultist', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'elder', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['Ordis'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'elder', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'elder', 'cultist', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'elder', 'cultist', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'elder', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['Darkness Falls'] = { + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + ['War of the Worlds'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'elder', 'red', 'blue' } } + }, + ['Alice in Wonderland'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'm5', 'm6', 'skull', 'skull', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm7', 'm8', 'skull', 'skull', 'elder', 'red', 'blue' } } + }, + ['Pokemon'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm3', 'skull', 'skull', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'skull', 'skull', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm6', 'm8', 'skull', 'skull', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Safari'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + }, + ['Cerulean'] = { + normal = { token = { 'p1', '0', '0', '0', 'm1', 'm2', 'm2', 'm3', 'm5', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', 'm1', 'm2', 'm3', 'm3', 'm4', 'm6', 'skull', 'skull', 'cultist', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + }, + ['Erich Zann'] = { + easy = { token = { 'p1', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + normal = { token = { 'p1', '0', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', 'm1', 'm2', 'm3', 'm4', 'm4', 'm5', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } } + }, + ['Kaimonogatari'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'skull', 'skull', 'cultist', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm2', 'm2', 'm3', 'm4', 'm4', 'm5', 'skull', 'skull', 'cultist', 'red', 'blue' } }, + expert = { token = { '0', '0', 'm1', 'm1', 'm2', 'm3', 'm4', 'm5', 'm6', 'm6', 'm8', 'skull', 'skull', 'cultist', 'red', 'blue' } } + }, + ['Sleepy Hollow'] = { + normal = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + }, + ['Flesh'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm3', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', 'm1', 'm1', 'm2', 'm3', 'm3', 'm4', 'm4', 'm6', 'skull', 'skull', 'cultist', 'tablet', 'tablet', 'red', 'blue' } }, + }, + ['Dark Matter'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'cultist', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'cultist', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'cultist', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'cultist', 'red', 'blue' } } + }, + ['Dont Starve'] = { + normal = { token = { 'p1', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + hard = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm5', 'm7', 'skull', 'skull', 'cultist', 'tablet', 'elder', 'red', 'blue' } }, + }, + ['XXXX'] = { + easy = { token = { 'p1', 'p1', '0', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + normal = { token = { 'p1', '0', '0', 'm1', 'm1', 'm1', 'm2', 'm2', 'm3', 'm4', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + hard = { token = { '0', '0', '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm5', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } }, + expert = { token = { '0', 'm1', 'm1', 'm2', 'm2', 'm3', 'm3', 'm4', 'm4', 'm5', 'm6', 'm8', 'skull', 'skull', 'cultist', 'tablet', 'red', 'blue' } } + }, + +} + +function onSave() + local globalState = JSON.encode(SPAWNED_PLAYER_CARD_GUIDS) + log('saving global state: ' .. globalState) + self.script_state = globalState +end + +function onload(save_state) + if save_state ~= '' then + log('loading global state: ' .. save_state) + SPAWNED_PLAYER_CARD_GUIDS = JSON.decode(save_state) + else + SPAWNED_PLAYER_CARD_GUIDS = {} + end +end + +function getSpawnedPlayerCardGuid(params) + local guid = params[1] + if SPAWNED_PLAYER_CARD_GUIDS == nil then + return nil + end + return SPAWNED_PLAYER_CARD_GUIDS[guid] +end + +function setSpawnedPlayerCardGuid(params) + local guid = params[1] + local value = params[2] + if SPAWNED_PLAYER_CARD_GUIDS ~= nil then + SPAWNED_PLAYER_CARD_GUIDS[guid] = value + return true + end + return false +end + +function checkHiddenCard(name) + for _, n in ipairs(HIDDEN_CARD_DATA) do + if name == n then + return true + end + end + return false +end + +function updateHiddenCards(args) + local custom_data_helper = getObjectFromGUID(args[1]) + local data_hiddenCards = custom_data_helper.getTable("HIDDEN_CARD_DATA") + for k, v in ipairs(data_hiddenCards) do + table.insert(HIDDEN_CARD_DATA, v) + end +end diff --git a/src/core/Global.ttslua b/src/core/Global.ttslua new file mode 100644 index 00000000..f980e4c9 --- /dev/null +++ b/src/core/Global.ttslua @@ -0,0 +1,768 @@ +--[[ Lua code. See documentation: http://berserk-games.com/knowledgebase/scripting/ --]] +-- Card size used for autodealing -- + +-- global position constants +ENCOUNTER_DECK_POS = {-3.8, 1, 5.7} +ENCOUNTER_DECK_SPAWN_POS = {-3.8, 3, 5.7} +ENCOUNTER_DECK_DISCARD_POSITION = {-3.8, 0.5, 10.5} +g_cardWith=2.30; +g_cardHeigth=3.40; + +containerId = 'fea079' +tokenDataId = '708279' + + +maxSquid = 0 + +CACHE = { + object = {}, + data = {} +} + +--[[ The OnLoad function. This is called after everything in the game save finishes loading. +Most of your script code goes here. --]] +function onload() + --Player.White.changeColor('Yellow') + tokenplayerone = { + damageone = "http://cloud-3.steamusercontent.com/ugc/1758068501357115146/903D11AAE7BD5C254C8DC136E9202EE516289DEA/", + damagethree = "http://cloud-3.steamusercontent.com/ugc/1758068501357113055/8A45F27B2838FED09DEFE492C9C40DD82781613A/", + horrorone = "http://cloud-3.steamusercontent.com/ugc/1758068501357163535/6D9E0756503664D65BDB384656AC6D4BD713F5FC/", + horrorthree = "http://cloud-3.steamusercontent.com/ugc/1758068501357162977/E5D453CC14394519E004B4F8703FC425A7AE3D6C/", + resource = "http://cloud-3.steamusercontent.com/ugc/1758068501357192910/11DDDC7EF621320962FDCF3AE3211D5EDC3D1573/", + resourcethree = "https://i.imgur.com/1GZsDTt.png", + doom = "https://i.imgur.com/EoL7yaZ.png", + clue = "http://cloud-3.steamusercontent.com/ugc/1758068501357164917/1D06F1DC4D6888B6F57124BD2AFE20D0B0DA15A8/" + } + + TOKEN_DATA = { + clue = {image = tokenplayerone.clue, scale = {0.15, 0.15, 0.15}}, + resource = {image = tokenplayerone.resource, scale = {0.17, 0.17, 0.17}}, + doom = {image = tokenplayerone.doom, scale = {0.17, 0.17, 0.17}} + } + + getObjectFromGUID("6161b4").interactable=false + getObjectFromGUID("721ba2").interactable=false + getObjectFromGUID("9f334f").interactable=false + getObjectFromGUID("23a43c").interactable=false + getObjectFromGUID("5450cc").interactable=false + getObjectFromGUID("463022").interactable=false + getObjectFromGUID("9487a4").interactable=false + getObjectFromGUID("91dd9b").interactable=false + getObjectFromGUID("f182ee").interactable=false + +end + +function onObjectDrop(player, obj) +-- local mat = getObjectFromGUID("dsbd0ff4") +-- log(mat.positionToLocal(obj.getPosition())) +end + +function take_callback(object_spawned, mat) + customObject = object_spawned.getCustomObject() + local player = mat.getGUID(); + + local image = customObject.image + + -- Update global stats + if PULLS[image] == nil then + PULLS[image] = 0 + end + PULLS[image] = PULLS[image] + 1 + -- Update player stats + if PLAYER_PULLS[player][image] == nil then + PLAYER_PULLS[player][image] = 0 + end + PLAYER_PULLS[player][image] = PLAYER_PULLS[player][image] + 1 + +end +MAT_GUID_TO_COLOUR = { + ["8b081b"] = "White", + -- player 2 conrad + ["bd0ff4"] = "Orange", + -- player + ["383d8b"] = "Green", + -- playur 4 olivia + ["0840d5"] = "Red" +} + + +PLAYER_PULLS = { + -- player 1 max + ["8b081b"] = {}, + -- player 2 conrad + ["bd0ff4"] = {}, + -- player + ["383d8b"] = {}, + -- playur 4 olivia + ["0840d5"] = {} +} + +PULLS = { + -- cultist + ["https://i.imgur.com/VzhJJaH.png"] = 0, + -- skull + ["https://i.imgur.com/stbBxtx.png"] = 0, + -- tablet + ["https://i.imgur.com/1plY463.png"] = 0, + -- curse + ["http://cloud-3.steamusercontent.com/ugc/1655601092778636039/2A25BD38E8C44701D80DD96BF0121DA21843672E/"] = 0, + -- tentacle + ["https://i.imgur.com/lns4fhz.png"] = 0, + -- minus eight + ["https://i.imgur.com/9t3rPTQ.png"] = 0, + -- minus seven + ["https://i.imgur.com/4WRD42n.png"] = 0, + -- minus six + ["https://i.imgur.com/c9qdSzS.png"] = 0, + -- minus five + ["https://i.imgur.com/3Ym1IeG.png"] = 0, + -- minus four + ["https://i.imgur.com/qrgGQRD.png"] = 0, + -- minus three + ["https://i.imgur.com/yfs8gHq.png"] = 0, + -- minus two + ["https://i.imgur.com/bfTg2hb.png"] = 0, + -- minus one + ["https://i.imgur.com/w3XbrCC.png"] = 0, + -- zero + ["https://i.imgur.com/btEtVfd.png"] = 0, + -- plus one + ["https://i.imgur.com/uIx8jbY.png"] = 0, + -- elder thing + ["https://i.imgur.com/ttnspKt.png"] = 0, + -- bless + ["http://cloud-3.steamusercontent.com/ugc/1655601092778627699/339FB716CB25CA6025C338F13AFDFD9AC6FA8356/"] = 0, + -- elder sign + ["https://i.imgur.com/nEmqjmj.png"] = 0, +} + +IMAGE_TOKEN_MAP = { + -- elder sign + ["https://i.imgur.com/nEmqjmj.png"] = "Elder Sign", + -- plus one + ["https://i.imgur.com/uIx8jbY.png"] = "+1", + -- zero + ["https://i.imgur.com/btEtVfd.png"] = "0", + -- minus one + ["https://i.imgur.com/w3XbrCC.png"] = "-1", + -- minus two + ["https://i.imgur.com/bfTg2hb.png"] = "-2", + -- minus three + ["https://i.imgur.com/yfs8gHq.png"] = "-3", + -- minus four + ["https://i.imgur.com/qrgGQRD.png"] = "-4", + -- minus five + ["https://i.imgur.com/3Ym1IeG.png"] = "-5", + -- minus six + ["https://i.imgur.com/c9qdSzS.png"] = "-6", + -- minus seven + ["https://i.imgur.com/4WRD42n.png"] = "-7", + -- minus eight + ["https://i.imgur.com/9t3rPTQ.png"] = "-8", + -- skull + ["https://i.imgur.com/stbBxtx.png"] = "Skull", + -- cultist + ["https://i.imgur.com/VzhJJaH.png"] = "Cultist", + -- tablet + ["https://i.imgur.com/1plY463.png"] = "Tablet", + -- elder thing + ["https://i.imgur.com/ttnspKt.png"] = "Elder Thing", + -- tentacle + ["https://i.imgur.com/lns4fhz.png"] = "Auto-fail", + -- bless + ["http://cloud-3.steamusercontent.com/ugc/1655601092778627699/339FB716CB25CA6025C338F13AFDFD9AC6FA8356/"] = "Bless", + -- curse + ["http://cloud-3.steamusercontent.com/ugc/1655601092778636039/2A25BD38E8C44701D80DD96BF0121DA21843672E/"] = "Curse" +} + +function resetStats() + for key,value in pairs(PULLS) do + PULLS[key] = 0 + end + for playerKey, playerValue in pairs(PLAYER_PULLS) do + for key,value in pairs(PULLS) do + PLAYER_PULLS[playerKey][key] = value + end + end + + +end + +function getPlayerName(playerMatGuid) + local playerColour = MAT_GUID_TO_COLOUR[playerMatGuid] + if Player[playerColour].seated then + return Player[playerColour].steam_name + else + return playerColour + end +end + +function printStats() + local squidKing = "Nobody" + + + printToAll("\nOverall Game stats\n") + printNonZeroTokenPairs(PULLS) + printToAll("\nIndividual Stats\n") + for playerMatGuid, countTable in pairs(PLAYER_PULLS) do + local playerName = getPlayerName(playerMatGuid) + printToAll(playerName .. " Stats", {r=255,g=0,b=0}) + printNonZeroTokenPairs(PLAYER_PULLS[playerMatGuid]) + playerSquidCount = PLAYER_PULLS[playerMatGuid]["https://i.imgur.com/lns4fhz.png"] + if playerSquidCount ~= nil and playerSquidCount > maxSquid then + squidKing = playerName + maxSquid = playerSquidCount + end + end + printToAll(squidKing .. " is an auto-fail magnet.", {r=255,g=0,b=0}) +end + +function printNonZeroTokenPairs(theTable) + for key,value in pairs(theTable) do + if value ~= 0 then + printToAll(IMAGE_TOKEN_MAP[key] .. '=' .. tostring(value)) + end + end +end + +-- Remove comments to enable autorotate cards on hands. +-- function onObjectEnterScriptingZone(zone, object) +-- Autorotate cards with right side up when entering hand. +-- if zone.getGUID() == "c506bf" or -- white +-- zone.getGUID() == "cbc751" then -- orange +-- object.setRotationSmooth({0,270,0}) +-- elseif zone.getGUID() == "67ce9a" then -- green +-- object.setRotationSmooth({0,0,0}) +-- elseif zone.getGUID() == "57c22c" then -- red +-- object.setRotationSmooth({0,180,0}) +--end +--end + +function findInRadiusBy(pos, radius, filter, debug) + local radius = (radius or 1) + local objList = Physics.cast({ + origin = pos, + direction = {0,1,0}, + type = 2, + size = {radius, radius, radius}, + max_distance = 0, + debug = (debug or false) + }) + + local filteredList = {} + for _, obj in ipairs(objList) do + if filter == nil then + table.insert(filteredList, obj.hit_object) + elseif filter and filter(obj.hit_object) then + table.insert(filteredList, obj.hit_object) + end + end + return filteredList +end + +function dealCardsInRows(paramlist) + local currPosition={}; + local numRow=1; + local numCard=0; + local invMultiplier=1; + local allCardsDealed=0; + if paramlist.inverse then + invMultiplier=-1; + end + if paramlist.maxCardsDealed==nil then + + allCardsDealed=0; + paramlist.maxCardsDealed=paramlist.cardDeck.getQuantity() + + elseif paramlist.maxCardsDealed>=paramlist.cardDeck.getQuantity() or paramlist.maxCardsDealed<=0 then + + allCardsDealed=0; + paramlist.maxCardsDealed=paramlist.cardDeck.getQuantity() + + else + + allCardsDealed=1; + + end + + if paramlist.mode=="x" then + currPosition={paramlist.iniPosition[1]+(2*g_cardWith*invMultiplier*allCardsDealed),paramlist.iniPosition[2],paramlist.iniPosition[3]}; + + else + currPosition={paramlist.iniPosition[1],paramlist.iniPosition[2],paramlist.iniPosition[3]+(2*g_cardWith*invMultiplier*allCardsDealed)}; + + end + + for i = 1,paramlist.maxCardsDealed,1 do + + paramlist.cardDeck.takeObject + ({ + position= currPosition, + smooth= true + }); + + numCard=numCard+1; + if numCard>=paramlist.maxCardRow then + + if paramlist.mode=="x" then + currPosition={paramlist.iniPosition[1]+(2*g_cardWith*invMultiplier*allCardsDealed),paramlist.iniPosition[2],paramlist.iniPosition[3]}; + currPosition[3]=currPosition[3]-(numRow*g_cardHeigth*invMultiplier); + else + currPosition={paramlist.iniPosition[1],paramlist.iniPosition[2],paramlist.iniPosition[3]+(2*g_cardWith*invMultiplier*allCardsDealed)}; + currPosition[1]=currPosition[1]+(numRow*g_cardHeigth*invMultiplier); + end + numCard=0; + numRow=numRow+1; + + else + if paramlist.mode=="x" then + currPosition[1]=currPosition[1]+(g_cardWith*invMultiplier); + else + currPosition[3]=currPosition[3]+(g_cardWith*invMultiplier); + end + end + end +end + +function isDeck(x) + return x.tag == 'Deck' +end + +function isCardOrDeck(x) + return x.tag == 'Card' or isDeck(x) +end + +function drawEncountercard(params) --[[ Parameter Table Position, Table Rotation]] + local position = params[1] + local rotation = params[2] + local alwaysFaceUp = params[3] + local faceUpRotation + local card + local items = findInRadiusBy(ENCOUNTER_DECK_POS, 4, isCardOrDeck) + if #items > 0 then + for i, v in ipairs(items) do + if v.tag == 'Deck' then + card = v.takeObject({index = 0}) + break + end + end + -- we didn't find the deck so just pull the first thing we did find + if card == nil then card = items[1] end + actualEncounterCardDraw(card, params) + return + end +-- nothing here, time to reshuffle + reshuffleEncounterDeck(params) +end + +function actualEncounterCardDraw(card, params) + local position = params[1] + local rotation = params[2] + local alwaysFaceUp = params[3] + local faceUpRotation = 0 + if not alwaysFaceUp then + if getObjectFromGUID(tokenDataId).call('checkHiddenCard', card.getName()) then + faceUpRotation = 180 + end + end + card.setPositionSmooth(position, false, false) + card.setRotationSmooth({0,rotation.y,faceUpRotation}, false, false) +end + +IS_RESHUFFLING = false +function reshuffleEncounterDeck(params) + -- finishes moving the deck back and draws a card + local function move(deck) + deck.setPositionSmooth(ENCOUNTER_DECK_SPAWN_POS, true, false) + actualEncounterCardDraw(deck.takeObject({index=0}), params) + 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 + +CHAOS_TOKENS = {} +CHAOS_TOKENS_LAST_MAT = nil +function putBackChaosTokens() + local chaosbagposition = chaosbag.getPosition() + for k, token in pairs(CHAOS_TOKENS) do + if token ~= nil then + chaosbag.putObject(token) + token.setPosition({chaosbagposition[1],chaosbagposition[2]+0.5,chaosbagposition[3]}) + end + end + CHAOS_TOKENS = {} + end + +function drawChaostoken(params) + local mat = params[1] + local tokenOffset = params[2] + local isRightClick = params[3] + local isSameMat = (CHAOS_TOKENS_LAST_MAT == nil or CHAOS_TOKENS_LAST_MAT == mat) + if not isSameMat then + putBackChaosTokens() + end + CHAOS_TOKENS_LAST_MAT = mat + -- if we have left clicked and have no tokens OR if we have right clicked + if isRightClick or #CHAOS_TOKENS == 0 then + local items = getObjectFromGUID("83ef06").getObjects() + for i,v in ipairs(items) do + if items[i].getDescription() == "Chaos Bag" then + chaosbag = getObjectFromGUID(items[i].getGUID()) + break + end + end + -- bail out if we have no tokens + 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 * #CHAOS_TOKENS) + local toPosition = mat.positionToWorld(tokenOffset) + local token = chaosbag.takeObject({ + index = 0, + position = toPosition, + rotation = mat.getRotation(), + callback_function = function(obj) take_callback(obj, mat) end + }) + CHAOS_TOKENS[#CHAOS_TOKENS + 1] = token + return + else + putBackChaosTokens() + end +end + +function spawnToken(params) + -- Position to spawn, + -- rotation vector to apply + -- translation vector to apply + -- token type + local position = params[1] + local tokenType = params[2] + local tokenData = TOKEN_DATA[tokenType] + if tokenData == nil then + error("no token data found for '" .. tokenType .. "'") + end + + local token = spawnObject({ + type = 'Custom_Token', + position = position, + rotation = {x=0, y=270, z=0} + }) + token.setCustomObject({ + image = tokenData['image'], + thickness = 0.3, + merge_distance = 5.0, + stackable = true, + }) + token.use_snap_points=false + token.scale(tokenData['scale']) + return token +end + +function round(params) -- Parameter (int number, int numberDecimalPlaces) + return tonumber(string.format("%." .. (params[2] or 0) .. "f", params[1])) +end + +function roundposition(params) -- Parameter (Table position) + return {round({params[1], 2}),round({params[2], 2}),round({params[3], 2})} +end + +function isEqual(params) --Parameter (Table table1, Table table2) returns true if the tables are equal + if params[1][1] == params[2][1] and params[1][2] == params[2][2] and params[1][3] == params[2][3] then + return true + else + return false + end +end + +function isFaceup(params) --Object object + if params.getRotation()[3] > -5 and params.getRotation()[3] < 5 then + return true + else + return false + end +end + +--Difficulty selector script + +function createSetupButtons(args) + local data = getDataValue('modeData', args.key) + if data ~= nil then + local z = -0.15 + if data.easy ~= nil then + args.object.createButton({ + label = 'Easy', + click_function = 'easyClick', + function_owner = args.object, + position = {0, 0.1, z}, + rotation = {0, 0, 0}, + scale = {0.47, 1, 0.47}, + height = 200, + width = 1150, + font_size = 100, + color = {0.87, 0.8, 0.70}, + font_color = {0, 0, 0} + }) + z = z + 0.20 + end + if data.normal ~= nil then + args.object.createButton({ + label = 'Standard', + click_function = 'normalClick', + function_owner = args.object, + position = {0, 0.1, z}, + rotation = {0, 0, 0}, + scale = {0.47, 1, 0.47}, + height = 200, + width = 1150, + font_size = 100, + color = {0.87, 0.8, 0.70}, + font_color = {0, 0, 0} + }) + z = z + 0.20 + end + if data.hard ~= nil then + args.object.createButton({ + label = 'Hard', + click_function = 'hardClick', + function_owner = args.object, + position = {0, 0.1, z}, + rotation = {0, 0, 0}, + scale = {0.47, 1, 0.47}, + height = 200, + width = 1150, + font_size = 100, + color = {0.87, 0.8, 0.70}, + font_color = {0, 0, 0} + }) + z = z + 0.20 + end + if data.expert ~= nil then + args.object.createButton({ + label = 'Expert', + click_function = 'expertClick', + function_owner = args.object, + position = {0, 0.1, z}, + rotation = {0, 0, 0}, + scale = {0.47, 1, 0.47}, + height = 200, + width = 1150, + font_size = 100, + color = {0.87, 0.8, 0.70}, + font_color = {0, 0, 0} + }) + z = z + 0.20 + end + z = z + 0.10 + if data.standalone ~= nil then + args.object.createButton({ + label = 'Standalone', + click_function = 'standaloneClick', + function_owner = args.object, + position = {0, 0.1, z}, + rotation = {0, 0, 0}, + scale = {0.47, 1, 0.47}, + height = 200, + width = 1150, + font_size = 100, + color = {0.87, 0.8, 0.70}, + font_color = {0, 0, 0} + }) + end + end +end + +function fillContainer(args) + local container = getObjectCache(containerId) + + if container ~= nil then + 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 pos = container.getPosition() + if args.object ~= nil then + pos = args.object.getPosition() + end + + cleanContainer(container) + + for _, token in ipairs(value.token) do + local obj = spawnToken_2(token, pos) + if obj ~= nil then + container.putObject(obj) + end + end + + if value.append ~= nil then + for _, token in ipairs(value.append) do + local obj = spawnToken_2(token, pos) + if obj ~= nil then + container.putObject(obj) + end + end + end + + if value.random then + local n = #value.random + if n > 0 then + for _, token in ipairs(value.random[getRandomCount(n)]) do + local obj = spawnToken_2(token, pos) + if obj ~= nil then + container.putObject(obj) + end + end + end + end + + if value.message then + broadcastToAll(value.message) + end + if value.warning then + broadcastToAll(value.warning, { 1, 0.5, 0.5 }) + end + end +end + +function spawnToken_2(id, pos) + local url = getImageUrl(id) + if url ~= '' then + local obj = spawnObject({ + type = 'Custom_Tile', + position = {pos.x, pos.y + 3, pos.z}, + rotation = {x = 0, y = 260, z = 0} + }) + obj.setCustomObject({ + type = 2, + image = url, + thickness = 0.10, + }) + obj.scale {0.81, 1, 0.81} + obj.setName(getTokenName({ url=url })) + return obj + end +end + +function getTokenName(params) + local name = IMAGE_TOKEN_MAP[params.url] + if name == nil then name = "" end + return name +end + +function getImageUrl(id) + if id == 'p1' then return 'https://i.imgur.com/uIx8jbY.png' end + if id == '0' then return 'https://i.imgur.com/btEtVfd.png' end + if id == 'm1' then return 'https://i.imgur.com/w3XbrCC.png' end + if id == 'm2' then return 'https://i.imgur.com/bfTg2hb.png' end + if id == 'm3' then return 'https://i.imgur.com/yfs8gHq.png' end + if id == 'm4' then return 'https://i.imgur.com/qrgGQRD.png' end + if id == 'm5' then return 'https://i.imgur.com/3Ym1IeG.png' end + if id == 'm6' then return 'https://i.imgur.com/c9qdSzS.png' end + if id == 'm7' then return 'https://i.imgur.com/4WRD42n.png' end + if id == 'm8' then return 'https://i.imgur.com/9t3rPTQ.png' end + if id == 'skull' then return 'https://i.imgur.com/stbBxtx.png' end + if id == 'cultist' then return 'https://i.imgur.com/VzhJJaH.png' end + if id == 'tablet' then return 'https://i.imgur.com/1plY463.png' end + if id == 'elder' then return 'https://i.imgur.com/ttnspKt.png' end + if id == 'red' then return 'https://i.imgur.com/lns4fhz.png' end + if id == 'blue' then return 'https://i.imgur.com/nEmqjmj.png' end + return '' +end + +function cleanContainer(container) + for _, item in ipairs(container.getObjects()) do + destroyObject(container.takeObject({})) + end +end + +function getObjectsInZone(zoneId) + local zoneObject = getObjectCache(zoneId) + + if zoneObject == nil then + return + end + + local objectsInZone = zoneObject.getObjects() + local objectsFound = {} + + for i = 1, #objectsInZone do + local object = objectsInZone[i] + if object.tag == 'Bag' then + table.insert(objectsFound, object.guid) + end + end + + if #objectsFound > 0 then + return objectsFound + end +end + +function getObjectCache(id) + if CACHE.object[id] == nil then + CACHE.object[id] = getObjectFromGUID(id) + end + return CACHE.object[id] +end + +function getDataTable(storage) + if CACHE.data[storage] == nil then + local obj = getObjectCache(tokenDataId) + if obj ~= nil then + CACHE.data[storage] = obj.getTable(storage) + end + end + return CACHE.data[storage] +end + +function getDataValue(storage, key) + local data = getDataTable(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 getRandomCount(to) + updateRandomSeed() + return math.random(1, to) +end + +function updateRandomSeed() + local chance = math.random(1,10) + if chance == 1 then + math.randomseed(os.time()) + end +end diff --git a/src/core/MasterClueCounter.ttslua b/src/core/MasterClueCounter.ttslua new file mode 100644 index 00000000..efbb38cc --- /dev/null +++ b/src/core/MasterClueCounter.ttslua @@ -0,0 +1,161 @@ +MIN_VALUE = -99 +MAX_VALUE = 999 + +function onload(saved_data) + light_mode = false + val = 0 + + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + light_mode = loaded_data[1] + val = loaded_data[2] + end + p1ClueCounter = getObjectFromGUID("37be78") + p2ClueCounter = getObjectFromGUID("1769ed") + p3ClueCounter = getObjectFromGUID("032300") + p4ClueCounter = getObjectFromGUID("d86b7c") + + timerID = self.getGUID()..math.random(9999999999999) + Timer.create({ + identifier=timerID, + function_name="totalCounters", function_owner=self, + repetitions=0, delay=1 + }) + createAll() +end + +function loadPlayerCounters() + p1ClueCounter = getObjectFromGUID("37be78") + p2ClueCounter = getObjectFromGUID("1769ed") + p3ClueCounter = getObjectFromGUID("032300") + p4ClueCounter = getObjectFromGUID("d86b7c") +end + + +function totalCounters() + if p1ClueCounter == nil or p2ClueCounter == nil or p3ClueCounter == nil or p4ClueCounter == nil then + loadPlayerCounters() + end + local p1ClueCount = p1ClueCounter.getVar("exposedValue") + local p2ClueCount = p2ClueCounter.getVar("exposedValue") + local p3ClueCount = p3ClueCounter.getVar("exposedValue") + local p4ClueCount = p4ClueCounter.getVar("exposedValue") + val = tonumber(p1ClueCount) + tonumber(p2ClueCount) + tonumber(p3ClueCount) + tonumber(p4ClueCount) + updateVal() + updateSave() +end + +function updateSave() + local data_to_save = {light_mode, val} + saved_data = JSON.encode(data_to_save) + self.script_state = saved_data +end + +function createAll() + s_color = {0.5, 0.5, 0.5, 95} + + if light_mode then + f_color = {1,1,1,95} + else + f_color = {0,0,0,100} + end + + self.createButton({ + label=tostring(val), + click_function="removeAllPlayerClues", + function_owner=self, + position={0,0.05,0}, + height=600, + width=1000, + alignment = 3, + tooltip = "Click button to remove all clues from all investigators", + scale={x=1.5, y=1.5, z=1.5}, + font_size=600, + font_color=f_color, + color={0,0,0,0} + }) + + if light_mode then + lightButtonText = "[ Set dark ]" + else + lightButtonText = "[ Set light ]" + end + +end + +function removeAll() + self.removeInput(0) + self.removeInput(1) + self.removeButton(0) + self.removeButton(1) + self.removeButton(2) +end + +function removeAllPlayerClues() + p1ClueCounter.call("removeAllClues") + p2ClueCounter.call("removeAllClues") + p3ClueCounter.call("removeAllClues") + p4ClueCounter.call("removeAllClues") +end + + +function reloadAll() + removeAll() + createAll() + updateSave() +end + +function swap_fcolor(_obj, _color, alt_click) + light_mode = not light_mode + reloadAll() +end + +function swap_align(_obj, _color, alt_click) + center_mode = not center_mode + reloadAll() +end + +function editName(_obj, _string, value) + self.setName(value) + setTooltips() +end + +function updateVal() + self.editButton({ + index = 0, + label = tostring(val), + + }) +end + +function reset_val() + val = 0 + updateVal() + updateSave() +end + +function setTooltips() + self.editInput({ + index = 0, + value = self.getName(), + tooltip = "Click button to remove all clues from all investigators" + }) + self.editButton({ + index = 0, + value = tostring(val), + + }) +end + +function null() +end + +function keepSample(_obj, _string, value) + reloadAll() +end + +function onDestroy() + if timerID and type(timerID) == 'object' then + Timer.destroy(timerID) + end +end diff --git a/src/core/PlayArea.ttslua b/src/core/PlayArea.ttslua new file mode 100644 index 00000000..096e2f14 --- /dev/null +++ b/src/core/PlayArea.ttslua @@ -0,0 +1,214 @@ +-- set true to enable debug logging +DEBUG = false + +-- we use this to turn off collision handling (for clue spawning) +-- until after load is complete (probably a better way to do this) +COLLISION_ENABLED = false + +-- TODO get the log function from global instead +-- log = Global.call('getLogFunction', this) +function getLogFunction(object) + return function (message) + if DEBUG then + print(message) + end + end +end + +log = getLogFunction(self) + +function onload(save_state) + self.interactable = DEBUG + local dataHelper = getObjectFromGUID('708279') + LOCATIONS = dataHelper.getTable('LOCATIONS_DATA') + + TOKEN_PLAYER_ONE = Global.getTable('tokenplayerone') + COUNTER = getObjectFromGUID('f182ee') + log('attempting to load state: ' .. save_state) + if save_state ~= '' then + SPAWNED_LOCATION_GUIDS = JSON.decode(save_state) + end + + COLLISION_ENABLED = true +end + +function onSave() + local spawned_locations = JSON.encode(SPAWNED_LOCATION_GUIDS) + self.script_state = spawned_locations +end + +--[[ +records locations we have spawned clues for, we write this to the save +file onsave() so we don't spawn clues again after a load +]] +SPAWNED_LOCATION_GUIDS = {} + +function isAlreadySpawned(object) + return SPAWNED_LOCATION_GUIDS[object.getGUID()] ~= nil +end + +function markSpawned(object) + SPAWNED_LOCATION_GUIDS[object.getGUID()] = 1 +end + +function buildKey(object) + return object.getName() .. '_' .. object.getGUID() +end + +-- try the compound key then the name alone as default +function getLocation(object) + return LOCATIONS[buildKey(object)] or LOCATIONS[object.getName()] +end + +function isLocationWithClues(object) + return getLocation(object) ~= nil +end + +--[[ +Return the number of clues to spawn on this location +]] +function getClueCount(object, isFaceDown, playerCount) + if not isLocationWithClues(object) then + error('attempted to get clue for unexpected object: ' .. object.getName()) + end + local details = getLocation(object) + log(object.getName() .. ' : ' .. details['type'] .. ' : ' .. details['value'] .. ' : ' .. details['clueSide']) + if ((isFaceDown and details['clueSide'] == 'back') + or (not isFaceDown and details['clueSide'] == 'front')) then + if details['type'] == 'fixed' then + return details['value'] + elseif details['type'] == 'perPlayer' then + return details['value'] * playerCount + end + error('unexpected location type: ' .. details['type']) + end + return 0 +end + +function spawnToken(position, number) + local obj_parameters = { + position = position, + rotation = {3.87674022, -90, 0.239081308} + } + local custom = { + thickness = 0.1, + stackable = true + } + + if number == '1' or number == '2' then + obj_parameters.type = 'Custom_Token' + custom.merge_distance = 5.0 + local token = spawnObject(obj_parameters) + if number == '1' then + custom.image = TOKEN_PLAYER_ONE.damageone + token.setCustomObject(custom) + token.scale {0.17, 1, 0.17} + return token + end + + if number == '2' then + custom.image = TOKEN_PLAYER_ONE.damagethree + token.setCustomObject(custom) + token.scale {0.18, 1, 0.18} + return token + end + end + + if number == '3' or number == '4' then + obj_parameters.type = 'Custom_Tile' + custom.type = 2 + local token = spawnObject(obj_parameters) + if number == '3' then + custom.image = TOKEN_PLAYER_ONE.clue + custom.image_bottom = TOKEN_PLAYER_ONE.doom + token.setCustomObject(custom) + token.scale {0.25, 1, 0.25} + token.use_snap_points=false + return token + end + + if number == '4' then + custom.image = TOKEN_PLAYER_ONE.doom + custom.image_bottom = TOKEN_PLAYER_ONE.clue + token.setCustomObject(custom) + token.scale {0.25, 1, 0.25} + token.use_snap_points=false + return token + end + end + +end + + +function spawnCluesAtLocation(clueCount, collision_info) + local object = collision_info.collision_object + if isAlreadySpawned(object) then + error('tried to spawn clue for already spawned location:' .. object.getName()) + end + + local obj_parameters = {} + obj_parameters.type = 'Custom_Token' + obj_parameters.position = { + object.getPosition()[1], + object.getPosition()[2] + 1, + object.getPosition()[3] + } + + log('spawning clues for ' .. object.getName() .. '_' .. object.getGUID()) + local playerCount = COUNTER.getVar('val') + log('player count is ' .. playerCount .. ', clue count is ' .. clueCount) + -- mark this location as spawned, can't happen again + markSpawned(object) + i = 0 + while i < clueCount do + if i < 4 then + obj_parameters.position = { + collision_info.collision_object.getPosition()[1] + 0.3, + collision_info.collision_object.getPosition()[2] + 0.2, + collision_info.collision_object.getPosition()[3] - 0.8 + (0.55 * i) + } + elseif i < 8 then + obj_parameters.position = { + collision_info.collision_object.getPosition()[1] + 0.85, + collision_info.collision_object.getPosition()[2] + 0.2, + collision_info.collision_object.getPosition()[3] - 3 + (0.55 * i) + } + else + obj_parameters.position = { + collision_info.collision_object.getPosition()[1] + 0.575, + collision_info.collision_object.getPosition()[2] + 0.4, + collision_info.collision_object.getPosition()[3] - 5.2 + (0.55 * i)} + end + spawnToken(obj_parameters.position, '3') + i = i + 1 + end +end + + +function updateLocations(args) + local custom_data_helper = getObjectFromGUID(args[1]) + data_locations = custom_data_helper.getTable("LOCATIONS_DATA") + for k, v in pairs(data_locations) do + LOCATIONS[k] = v + end +end + +function onCollisionEnter(collision_info) + -- short circuit all collision stuff until we've loaded state + if not COLLISION_ENABLED then + return + end + + -- check if we should spawn clues here + local object = collision_info.collision_object + if isLocationWithClues(object) and not isAlreadySpawned(object) then + -- this isn't an either/or as down/up here means at a relatively low angle + -- local isFaceUp = not object.is_face_down + -- local isFaceDown = (object.getRotation()[3] > 160 and object.getRotation()[3] < 200) + local playerCount = COUNTER.getVar('val') + local clueCount = getClueCount(object, object.is_face_down, playerCount) + if clueCount > 0 then + spawnCluesAtLocation(clueCount, collision_info) + end + end +end diff --git a/src/core/PlayAreaSelector.ttslua b/src/core/PlayAreaSelector.ttslua new file mode 100644 index 00000000..164a305f --- /dev/null +++ b/src/core/PlayAreaSelector.ttslua @@ -0,0 +1,199 @@ + + +function onSave() + saved_data = JSON.encode({tid=tableImageData, cd=checkData}) + --saved_data = "" + return saved_data +end + +function onload(saved_data) + --Loads the tracking for if the game has started yet + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + tableImageData = loaded_data.tid + checkData = loaded_data.cd + else + tableImageData = {} + checkData = {move=false, scale=false} + end + + --Disables interactable status of objects with GUID in list + for _, guid in ipairs(ref_noninteractable) do + local obj = getObjectFromGUID(guid) + if obj then obj.interactable = false end + end + + + + obj_surface = getObjectFromGUID("721ba2") + + + controlActive = false + createOpenCloseButton() +end + + + +--Activation/deactivation of control panel + + + +--Activated by clicking on +function click_toggleControl(_, color) + if permissionCheck(color) then + if not controlActive then + --Activate control panel + controlActive = true + self.clearButtons() + createOpenCloseButton() + createSurfaceInput() + createSurfaceButtons() + + else + --Deactivate control panel + controlActive = false + self.clearButtons() + self.clearInputs() + createOpenCloseButton() + + end + end +end + + + + +--Table surface control + + + +--Changes table surface +function click_applySurface(_, color) + if permissionCheck(color) then + updateSurface() + broadcastToAll("New Playmat Image Applied", {0.2,0.9,0.2}) + end +end + +--Updates surface from the values in the input field +function updateSurface() + local customInfo = obj_surface.getCustomObject() + customInfo.image = self.getInputs()[1].value + obj_surface.setCustomObject(customInfo) + obj_surface = obj_surface.reload() +end + + + +--Information gathering + + + +--Checks if a color is promoted or host +function permissionCheck(color) + if Player[color].host==true or Player[color].promoted==true then + return true + else + return false + end +end + +--Locates a string saved within memory file +function findInImageDataIndex(...) + for _, str in ipairs({...}) do + for i, v in ipairs(tableImageData) do + if v.url == str or v.name == str then + return i + end + end + end + return nil +end + +--Round number (num) to the Nth decimal (dec) +function round(num, dec) + local mult = 10^(dec or 0) + return math.floor(num * mult + 0.5) / mult +end + +--Locates a button with a helper function +function findButton(obj, func) + if func==nil then error("No func supplied to findButton") end + for _, v in ipairs(obj.getButtons()) do + if func(v) then + return v + end + end + return nil +end + + + +--Creation of buttons/inputs + + + +function createOpenCloseButton() + local tooltip = "Open Playmat Panel" + if controlActive then + tooltip = "Close Playmat Panel" + end + self.createButton({ + click_function="click_toggleControl", function_owner=self, + position={0,0,0}, rotation={-45,0,0}, height=1500, width=1500, + color={1,1,1,0}, tooltip=tooltip + }) +end + +function createSurfaceInput() + local currentURL = obj_surface.getCustomObject().diffuse + local nickname = "" + if findInImageDataIndex(currentURL) ~= nil then + nickname = tableImageData[findInImageDataIndex(currentURL)].name + end + + self.createInput({ + label="URL", input_function="none", function_owner=self, + alignment=3, position={0,0.15,3}, height=224, width=4000, + font_size=200, tooltip="Enter URL for playmat image", + value=currentURL + }) +end + +function createSurfaceButtons() + --Label + self.createButton({ + label="Playmat Image Swapper", click_function="none", + position={0,0.15,2.2}, height=0, width=0, font_size=300, font_color={1,1,1} + }) + --Functional + self.createButton({ + label="Apply Image\nTo Playmat", click_function="click_applySurface", + function_owner=self, tooltip="Apply URL as playmat image", + position={0,0.15,4}, height=440, width=1400, font_size=200, + }) + +end + + + + + + +--Data tables + + + + +ref_noninteractable = { + "afc863","c8edca","393bf7","12c65e","f938a2","9f95fd","35b95f", + "5af8f2","4ee1f2","bd69bd" +} + +ref_playerColor = { + "White", "Brown", "Red", "Orange", "Yellow", + "Green", "Teal", "Blue", "Purple", "Pink", "Black" +} + +--Dummy function, absorbs unwanted triggers +function none() end diff --git a/src/playercards/AllCardsBag.ttslua b/src/playercards/AllCardsBag.ttslua new file mode 100644 index 00000000..eb578943 --- /dev/null +++ b/src/playercards/AllCardsBag.ttslua @@ -0,0 +1,240 @@ + +local cardIdIndex = { } +local classAndLevelIndex = { } +local basicWeaknessList = { } + +local indexingDone = false +local allowRemoval = false + +function onLoad() + self.addContextMenuItem("Rebuild Index", startIndexBuild) + math.randomseed(os.time()) + Wait.frames(startIndexBuild, 30) +end + +-- Called by Hotfix bags when they load. If we are still loading indexes, then +-- the all cards and hotfix bags are being loaded together, and we can ignore +-- this call as the hotfix will be included in the initial indexing. If it is +-- called once indexing is complete it means the hotfix bag has been added +-- later, and we should rebuild the index to integrate the hotfix bag. +function rebuildIndexForHotfix() + if (indexingDone) then + startIndexBuild() + end +end + +-- Resets all current bag indexes +function clearIndexes() + indexingDone = false + cardIdIndex = { } + classAndLevelIndex = { } + classAndLevelIndex["Guardian-upgrade"] = { } + classAndLevelIndex["Seeker-upgrade"] = { } + classAndLevelIndex["Mystic-upgrade"] = { } + classAndLevelIndex["Survivor-upgrade"] = { } + classAndLevelIndex["Rogue-upgrade"] = { } + classAndLevelIndex["Neutral-upgrade"] = { } + classAndLevelIndex["Guardian-level0"] = { } + classAndLevelIndex["Seeker-level0"] = { } + classAndLevelIndex["Mystic-level0"] = { } + classAndLevelIndex["Survivor-level0"] = { } + classAndLevelIndex["Rogue-level0"] = { } + classAndLevelIndex["Neutral-level0"] = { } + basicWeaknessList = { } +end + +-- Clears the bag indexes and starts the coroutine to rebuild the indexes +function startIndexBuild(playerColor) + clearIndexes() + startLuaCoroutine(self, "buildIndex") +end + +function onObjectLeaveContainer(container, object) + if (container == self and not allowRemoval) then + broadcastToAll( + "Removing cards from the All Player Cards bag may break some functions. Please replace the card.", + {0.9, 0.2, 0.2} + ) + end +end + +-- Debug option to suppress the warning when cards are removed from the bag +function setAllowCardRemoval() + allowRemoval = true +end + +-- Create the card indexes by iterating all cards in the bag, parsing their +-- metadata, and creating the keyed lookup tables for the cards. This is a +-- coroutine which will spread the workload by processing 20 cards before +-- yielding. Based on the current count of cards this will require +-- approximately 60 frames to complete. +function buildIndex() + indexingDone = false + if (self.getData().ContainedObjects == nil) then + return 1 + end + for i, cardData in ipairs(self.getData().ContainedObjects) do + local cardMetadata = JSON.decode(cardData.GMNotes) + if (cardMetadata ~= nil) then + addCardToIndex(cardData, cardMetadata) + end + if (i % 20 == 0) then + coroutine.yield(0) + end + end + local hotfixBags = getObjectsWithTag("AllCardsHotfix") + for _, hotfixBag in ipairs(hotfixBags) do + if (#hotfixBag.getObjects() > 0) then + for i, cardData in ipairs(hotfixBag.getData().ContainedObjects) do + local cardMetadata = JSON.decode(cardData.GMNotes) + if (cardMetadata ~= nil) then + addCardToIndex(cardData, cardMetadata) + end + end + end + end + buildSupplementalIndexes() + indexingDone = true + return 1 +end + +-- Adds a card to any indexes it should be a part of, based on its metadata. +-- Param cardData: TTS object data for the card +-- Param cardMetadata: SCED metadata for the card +function addCardToIndex(cardData, cardMetadata) + cardIdIndex[cardMetadata.id] = { data = cardData, metadata = cardMetadata } + if (cardMetadata.alternate_ids ~= nil) then + for _, alternateId in ipairs(cardMetadata.alternate_ids) do + cardIdIndex[alternateId] = { data = cardData, metadata = cardMetadata } + end + end +end + +function buildSupplementalIndexes() + for cardId, card in pairs(cardIdIndex) do + local cardData = card.data + local cardMetadata = card.metadata + -- Add card to the basic weakness list, if appropriate. Some weaknesses have + -- multiple copies, and are added multiple times + if (cardMetadata.weakness and cardMetadata.basicWeaknessCount ~= nil) then + for i = 1, cardMetadata.basicWeaknessCount do + table.insert(basicWeaknessList, cardMetadata.id) + end + end + + -- Add the card to the appropriate class and level indexes + local isGuardian = false + local isSeeker = false + local isMystic = false + local isRogue = false + local isSurvivor = false + local isNeutral = false + local upgradeKey + if (cardMetadata.class ~= nil and cardMetadata.level ~= nil) then + isGuardian = string.match(cardMetadata.class, "Guardian") + isSeeker = string.match(cardMetadata.class, "Seeker") + isMystic = string.match(cardMetadata.class, "Mystic") + isRogue = string.match(cardMetadata.class, "Rogue") + isSurvivor = string.match(cardMetadata.class, "Survivor") + isNeutral = string.match(cardMetadata.class, "Neutral") + if (cardMetadata.level > 0) then + upgradeKey = "-upgrade" + else + upgradeKey = "-level0" + end + if (isGuardian) then + table.insert(classAndLevelIndex["Guardian"..upgradeKey], cardMetadata.id) + end + if (isSeeker) then + table.insert(classAndLevelIndex["Seeker"..upgradeKey], cardMetadata.id) + end + if (isMystic) then + table.insert(classAndLevelIndex["Mystic"..upgradeKey], cardMetadata.id) + end + if (isRogue) then + table.insert(classAndLevelIndex["Rogue"..upgradeKey], cardMetadata.id) + end + if (isSurvivor) then + table.insert(classAndLevelIndex["Survivor"..upgradeKey], cardMetadata.id) + end + if (isNeutral) then + table.insert(classAndLevelIndex["Neutral"..upgradeKey], cardMetadata.id) + end + end + end + for _, indexTable in pairs(classAndLevelIndex) do + table.sort(indexTable, cardComparator) + end +end + +-- Comparison function used to sort the class card bag indexes. Sorts by card +-- level, then name, then subname. +function cardComparator(id1, id2) + local card1 = cardIdIndex[id1] + local card2 = cardIdIndex[id2] + if (card1.metadata.level ~= card2.metadata.level) then + return card1.metadata.level < card2.metadata.level + end + if (card1.data.Nickname ~= card2.data.Nickname) then + return card1.data.Nickname < card2.data.Nickname + end + return card1.data.Description < card2.data.Description +end + +function isIndexReady() + return indexingDone +end + +-- Returns a specific card from the bag, based on ArkhamDB ID +-- Params table: +-- id: String ID of the card to retrieve +-- Return: If the indexes are still being constructed, an empty table is +-- returned. Otherwise, a single table with the following fields +-- cardData: TTS object data, suitable for spawning the card +-- cardMetadata: Table of parsed metadata +function getCardById(params) + if (not indexingDone) then + broadcastToAll("Still loading player cards, please try again in a few seconds", {0.9, 0.2, 0.2}) + return { } + end + return cardIdIndex[params.id] +end + +-- Returns a list of cards from the bag matching a class and level (0 or upgraded) +-- Params table: +-- class: String class to retrieve ("Guardian", "Seeker", etc) +-- isUpgraded: true for upgraded cards (Level 1-5), false for Level 0 +-- Return: If the indexes are still being constructed, returns an empty table. +-- Otherwise, a list of tables, each with the following fields +-- cardData: TTS object data, suitable for spawning the card +-- cardMetadata: Table of parsed metadata +function getCardsByClassAndLevel(params) + if (not indexingDone) then + broadcastToAll("Still loading player cards, please try again in a few seconds", {0.9, 0.2, 0.2}) + return { } + end + local upgradeKey + if (params.upgraded) then + upgradeKey = "-upgrade" + else + upgradeKey = "-level0" + end + return classAndLevelIndex[params.class..upgradeKey]; +end + +-- Gets a random basic weakness from the bag. Once a given ID has been returned +-- it will be removed from the list and cannot be selected again until a reload +-- occurs or the indexes are rebuilt, which will refresh the list to include all +-- weaknesses. +-- Return: String ID of the selected weakness. +function getRandomWeaknessId() + local pickedIndex = math.random(#basicWeaknessList) + local weaknessId = basicWeaknessList[pickedIndex] + if (#basicWeaknessList > 1) then + table.remove(basicWeaknessList, pickedIndex) + else + broadcastToAll("All weaknesses have been drawn!", {0.9, 0.2, 0.2}) + end + + return weaknessId +end diff --git a/src/playercards/ClassCardContainer.ttslua b/src/playercards/ClassCardContainer.ttslua new file mode 100644 index 00000000..acd2449d --- /dev/null +++ b/src/playercards/ClassCardContainer.ttslua @@ -0,0 +1,271 @@ +-- Class card bag implementation. Mimics the behavior of the previous SCED +-- card memory bags, but spawns cards from the All Cards Bag instead. +-- +-- The All Cards Bag handles indexing of the player cards by class and level, as +-- well as sorting those lists. See that object for more information. + +local allCardsBagGuid = "15bb07" + +-- Lines which define the card layout area. The X threshold is shared, basic +-- cards have Z > 47 while upgrades have Z < -46 +-- Note that the SCED table is rotated, making X the vertical (up the table) +-- axis and Z the side-to-side axis +local CARD_AREA_X_THRESHOLD = 22 +local CARD_AREA_BASIC_Z_THRESHOLD = 47 +local CARD_AREA_UPGRADED_Z_THRESHOLD = -46 + +local skillCount = 0 +local eventCount = 0 +local assetCount = 0 + +-- Coordinates to begin laying out cards to match the reserved areas of the +-- table. Cards will lay out horizontally, then create additional rows +local startPositions = { + upgrades = { + skill = Vector(58.09966, 1.36, -47.42), + event = Vector(52.94421, 1.36, -47.42), + asset = Vector(40.29005, 1.36, -47.42), + }, + level0 = { + skill = Vector(58.38383, 1.36, 92.39036), + event = Vector(53.22857, 1.36, 92.44123), + asset = Vector(40.9602, 1.36, 92.44869), + }, +} + +-- Amount to shift for the next card (zShift) or next row of cards (xShift) +-- Note that the table rotation is weird, and the X axis is vertical while the +-- Z axis is horizontal +local zShift = -2.29998 +local xShift = -3.66572 +local yRotation = 270 +local cardsPerRow = 20 + +-- Tracks cards which are placed by this "bag" so they can be recalled +local placedCardGuids = { } +local placedCardsCount = 0 + +-- In order to mimic the behavior of the previous memory buttons we use a +-- a temporary bag when recalling objects. This bag is tiny and transparent, +-- and will be placed at the same location as this object. Once all placed +-- cards are recalled bag to this bag, it will be destroyed +local recallBag = { + Name = "Bag", + Transform = { + scaleX = 0.01, + scaleY = 0.01, + scaleZ = 0.01, + }, + ColorDiffuse = { + r = 0, + g = 0, + b = 0, + a = 0, + }, + Locked = true, + Grid = true, + Snap = false, + Tooltip = false, +} + +function onLoad(savedData) + createPlaceRecallButtons() + placedCardGuids = { } + placedCardCount = 0 + if (savedData ~= nil) then + local saveState = JSON.decode(savedData) + if (saveState.placedCards ~= nil) then + placedCardGuids = saveState.placedCards + end + end +end + +function onSave() + local saveState = { + placedCards = placedCardGuids, + } + + return JSON.encode(saveState) +end + +--Creates recall and place buttons +function createPlaceRecallButtons() + self.createButton({ + label="Place", click_function="buttonClick_place", function_owner=self, + position={1,0.1,2.1}, rotation={0,0,0}, height=350, width=800, + font_size=250, color={0,0,0}, font_color={1,1,1} + }) + self.createButton({ + label="Recall", click_function="buttonClick_recall", function_owner=self, + position={-1,0.1,2.1}, rotation={0,0,0}, height=350, width=800, + font_size=250, color={0,0,0}, font_color={1,1,1} + }) +end + +-- Spawns the set of cards identified by this objects Name (which should hold +-- the class) and description (whether to spawn basic cards or upgraded) +function buttonClick_place() + -- Cards already on the table, don't spawn more + if (placedCardCount > 0) then + return + end + local cardClass = self.getName() + local isUpgraded = false + if (self.getDescription() == "Upgrades") then + isUpgraded = true + end + skillCount = 0 + eventCount = 0 + assetCount = 0 + local allCardsBag = getObjectFromGUID(allCardsBagGuid) + local cardList = allCardsBag.call("getCardsByClassAndLevel", {class = cardClass, upgraded = isUpgraded}) + placeCards(cardList) +end + +-- Spawn all cards from the returned index +function placeCards(cardIdList) + local allCardsBag = getObjectFromGUID(allCardsBagGuid) + local indexReady = allCardsBag.call("isIndexReady") + if (not indexReady) then + broadcastToAll("Still loading player cards, please try again in a few seconds", {0.9, 0.2, 0.2}) + return + end + + for _, cardId in ipairs(cardIdList) do + local card = allCardsBag.call("getCardById", { id = cardId }) + placeCard(card.data, card.metadata) + end +end + +function placeCard(cardData, cardMetadata) + local destinationPos + if (cardMetadata.type == "Skill") then + destinationPos = getSkillPosition(cardMetadata.level > 0) + elseif (cardMetadata.type == "Event") then + destinationPos = getEventPosition(cardMetadata.level > 0) + elseif (cardMetadata.type == "Asset") then + destinationPos = getAssetPosition(cardMetadata.level > 0) + end + -- Clear the GUID from the card's data so it will get a new GUID on spawn. + -- This solves the issue of duplicate GUIDs being spawned and causing problems + -- with recall + cardData.GUID = nil + local spawnedCard = spawnObjectData({ + data = cardData, + position = destinationPos, + rotation = {0, yRotation, 0}, + callback_function = recordPlacedCard}) +end + +-- Returns the table position where the next skill should be placed +-- Param isUpgraded: True if it's an upgraded card (right side of the table), +-- false for a Level 0 card (left side of table) +function getSkillPosition(isUpgraded) + local skillPos + if (isUpgraded) then + skillPos = startPositions.upgrades.skill:copy() + else + skillPos = startPositions.level0.skill:copy() + end + local shift = Vector(div(skillCount, cardsPerRow) * xShift, 0, (skillCount % cardsPerRow) * zShift) + skillPos:add(shift) + skillCount = skillCount + 1 + + return skillPos +end + +-- Returns the table position where the next event should be placed +-- Param isUpgraded: True if it's an upgraded card (right side of the table), +-- false for a Level 0 card (left side of table) +function getEventPosition(isUpgraded) + local eventPos + if (isUpgraded) then + eventPos = startPositions.upgrades.event:copy() + else + eventPos = startPositions.level0.event:copy() + end + local shift = Vector(div(eventCount, cardsPerRow) * xShift, 0, (eventCount % cardsPerRow) * zShift) + eventPos:add(shift) + eventCount = eventCount + 1 + + return eventPos +end + +-- Returns the table position where the next asset should be placed +-- Param isUpgraded: True if it's an upgraded card (right side of the table), +-- false for a Level 0 card (left side of table) +function getAssetPosition(isUpgraded) + local assetPos + if (isUpgraded) then + assetPos = startPositions.upgrades.asset:copy() + else + assetPos = startPositions.level0.asset:copy() + end + local shift = Vector(div(assetCount, cardsPerRow) * xShift, 0, (assetCount % cardsPerRow) * zShift) + assetPos:add(shift) + assetCount = assetCount + 1 + + return assetPos +end + +-- Callback function which adds a spawned card to the tracking list +function recordPlacedCard(spawnedCard) + if (spawnedCard.getName() == "Protecting the Anirniq (2)") then + log("Spawned PtA "..spawnedCard.getGUID()) + end + placedCardGuids[spawnedCard.getGUID()] = true + placedCardCount = placedCardCount + 1 +end + +-- Recalls all spawned cards to the bag, and clears the placedCardGuids list +function buttonClick_recall() + local trash = spawnObjectData({data = recallBag, position = self.getPosition()}) + for cardGuid, _ in pairs(placedCardGuids) do + local card = getObjectFromGUID(cardGuid) + if (card ~= nil) then + trash.putObject(card) + placedCardGuids[cardGuid] = nil + placedCardCount = placedCardCount - 1 + end + end + + if (placedCardCount > 0) then + -- Couldn't recall all the cards, check and pull them from decks + local decksInArea = { } + local allObjects = getAllObjects() + for _, object in ipairs(allObjects) do + if (object.name == "Deck" and isInArea(object)) then + table.insert(decksInArea, object) + end + end + for _, deck in ipairs(decksInArea) do + local cardsInDeck = deck.getObjects() + for i, card in ipairs(cardsInDeck) do + if (placedCardGuids[card.guid]) then + trash.putObject(deck.takeObject({ guid = card.guid })) + break + end + end + end + end + + trash.destruct() + -- We've recalled everything we can, some cards may have been moved out of the + -- card area. Just reset at this point. + placedCardGuids = { } + placedCardCount = 0 +end + +function isInArea(object) + if (object == nil) then + return false + end + local position = object.getPosition() + return position.x > CARD_AREA_X_THRESHOLD + and (position.z > CARD_AREA_BASIC_Z_THRESHOLD + or position.z < CARD_AREA_UPGRADED_Z_THRESHOLD) +end + +function div(a,b) + return (a - a % b) / b +end diff --git a/src/playercards/RandomWeaknessGenerator.ttslua b/src/playercards/RandomWeaknessGenerator.ttslua new file mode 100644 index 00000000..f083210d --- /dev/null +++ b/src/playercards/RandomWeaknessGenerator.ttslua @@ -0,0 +1,24 @@ +local allCardsBagGuid = "15bb07" + +function onLoad(saved_data) + createDrawButton() +end + +function createDrawButton() + self.createButton({ + label="Draw Random\nWeakness", click_function="buttonClick_draw", function_owner=self, + position={0,0.1,2.1}, rotation={0,0,0}, height=600, width=1800, + font_size=250, color={0,0,0}, font_color={1,1,1} + }) +end + +-- Draw a random weakness and spawn it below the object +function buttonClick_draw() + local allCardsBag = getObjectFromGUID(allCardsBagGuid) + local weaknessId = allCardsBag.call("getRandomWeaknessId") + local card = allCardsBag.call("getCardById", { id = weaknessId }) + spawnObjectData({ + data = card.data, + position = self.positionToWorld({0, 1, 5.5}), + rotation = self.getRotation()}) +end diff --git a/src/playercards/ScriptedTarot.ttslua b/src/playercards/ScriptedTarot.ttslua new file mode 100644 index 00000000..abc5666f --- /dev/null +++ b/src/playercards/ScriptedTarot.ttslua @@ -0,0 +1,90 @@ +CARD_OFFSET = Vector({0, 0.1, -2}) +ORIENTATIONS = { {0, 270, 0}, { 0, 90, 0} } +READING = { + "Temperance", + "Justice", + "Hermit", + "Hanged Man", + "Hierophant", + "Lovers", + "Chariot", + "Wheel of Fortune" +} + +function onLoad() + self.addContextMenuItem("Chaos", chaos, false) + self.addContextMenuItem("Balance", balance, false) + self.addContextMenuItem("Choice", choice, false) + self.addContextMenuItem("Destiny (Campaign)", destiny, false) + self.addContextMenuItem("Accept Your Fate", fate, false) + + math.randomseed(os.time()) +end + +function chaos(color) + self.shuffle() + self.takeObject({ + position = self.getPosition() + CARD_OFFSET, + rotation = ORIENTATIONS[math.random(2)], + smooth = true + }) +end + +function balance(color) + self.shuffle() + self.takeObject({ + position = self.getPosition() + CARD_OFFSET, + rotation = ORIENTATIONS[1], + smooth = true + }) + self.takeObject({ + position = self.getPosition() + 2*CARD_OFFSET, + rotation = ORIENTATIONS[2], + smooth = true + }) +end + +function choice(color) + self.shuffle() + for i=1,3 do + self.takeObject({ + position = self.getPosition() + i*CARD_OFFSET, + rotation = ORIENTATIONS[1], + smooth = true + }) + end + broadcastToColor("Choose and reverse two of the cards.", color) +end + +function destiny(color) + self.shuffle() + for i=1,8 do + self.takeObject({ + position = self.getPosition() + i*CARD_OFFSET, + rotation = ORIENTATIONS[1], + smooth = true + }) + end + broadcastToColor("Each card corresponds to one scenario, leftmost is first. Choose and reverse half of the cards (rounded up).", color) +end + +function fate(color) + local guids = {} + local cards = self.getObjects() + for i,card in ipairs(cards) do + for j,reading in ipairs(READING) do + if string.match(card.name, reading) ~= nil then + guids[j] = card.guid + end + end + end + for k,guid in ipairs(guids) do + self.takeObject({ + guid = guid, + position = self.getPosition() + k*CARD_OFFSET, + rotation = ORIENTATIONS[1], + smooth = true + }) + end + broadcastToColor("Each card corresponds to one scenario, leftmost is first. Choose and reverse half of the cards (rounded up).", color) +end diff --git a/src/playermat/ClueCounter.ttslua b/src/playermat/ClueCounter.ttslua new file mode 100644 index 00000000..08a1942f --- /dev/null +++ b/src/playermat/ClueCounter.ttslua @@ -0,0 +1,114 @@ +--Counting Bowl by MrStump + +--Table of items which can be counted in this Bowl +--Each entry has 2 things to enter + --a name (what is in the name field of that object) + --a value (how much it is worth) +--A number in the items description will override the number entry in this table +validCountItemList = { + ["Clue"] = 1, + [""] = 1, + --["Name3"] = 2, + --["Name4"] = 31, + --Add more entries as needed + --Remove the -- from before a line for the script to use it +} + +--END OF CODE TO EDIT + +function onLoad() + timerID = self.getGUID()..math.random(9999999999999) + --Sets position/color for the button, spawns it + self.createButton({ + label="", click_function="removeAllClues", function_owner=self, + position={0,0,0}, rotation={0,8,0}, height=0, width=0, + font_color={0,0,0}, font_size=2000 + }) + --Start timer which repeats forever, running countItems() every second + Timer.create({ + identifier=timerID, + function_name="countItems", function_owner=self, + repetitions=0, delay=1 + }) + exposedValue = 0 + trashCan = getObjectFromGUID("147e80") +end + +function findValidItemsInSphere() + return filterByValidity(findItemsInSphere()) +end + +--Activated once per second, counts items in bowls +function countItems() + local totalValue = -1 + local countableItems = findValidItemsInSphere() + for ind, entry in ipairs(countableItems) do + local descValue = tonumber(entry.hit_object.getDescription()) + local stackMult = math.abs(entry.hit_object.getQuantity()) + --Use value in description if available + if descValue ~= nil then + totalValue = totalValue + descValue * stackMult + else + --Otherwise use the value in validCountItemList + totalValue = totalValue + validCountItemList[entry.hit_object.getName()] * stackMult + end + end + exposedValue = totalValue + --Updates the number display + self.editButton({index=0, label=totalValue}) +end + +function filterByValidity(items) + retval = {} + for _, entry in ipairs(items) do + --Ignore the bowl + if entry.hit_object ~= self then + --Ignore if not in validCountItemList + local tableEntry = validCountItemList[entry.hit_object.getName()] + if tableEntry ~= nil then + table.insert(retval, entry) + end + end + end + return retval +end + + +--Gets the items in the bowl for countItems to count +function findItemsInSphere() + --Find scaling factor + local scale = self.getScale() + --Set position for the sphere + local pos = self.getPosition() + pos.y=pos.y+(1.25*scale.y) + --Ray trace to get all objects + return Physics.cast({ + origin=pos, direction={0,1,0}, type=2, max_distance=0, + size={6*scale.x,6*scale.y,6*scale.z}, --debug=true + }) +end + +function removeAllClues() + startLuaCoroutine(self, "clueRemovalCoroutine") +end + +function clueRemovalCoroutine() + for _, entry in ipairs(findValidItemsInSphere()) do + -- Do not put the table in the garbage + if entry.hit_object.getGUID() ~= "4ee1f2" then + --delay for animation purposes + for k=1,10 do + coroutine.yield(0) + end + trashCan.putObject(entry.hit_object) + end + end + --coroutines must return a value + return 1 +end + +function onDestroy() + if timerID and type(timerID) == 'object' then + Timer.destroy(timerID) + end +end diff --git a/src/playermat/DamageTracker.ttslua b/src/playermat/DamageTracker.ttslua new file mode 100644 index 00000000..a198b345 --- /dev/null +++ b/src/playermat/DamageTracker.ttslua @@ -0,0 +1,132 @@ +MIN_VALUE = -99 +MAX_VALUE = 999 + +function onload(saved_data) + light_mode = true + val = 0 + + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + light_mode = loaded_data[1] + val = loaded_data[2] + end + + createAll() +end + +function updateSave() + local data_to_save = {light_mode, val} + saved_data = JSON.encode(data_to_save) + self.script_state = saved_data +end + +function createAll() + s_color = {0,0,0,100} + + if light_mode then + f_color = {1,1,1,100} + else + f_color = {0,0,0,100} + end + + + + self.createButton({ + label=tostring(val), + click_function="add_subtract", + function_owner=self, + position={0.1,0.05,0.1}, + height=600, + width=1000, + alignment = 3, + scale={x=1.5, y=1.5, z=1.5}, + font_size=600, + font_color=f_color, + color={1,1,1,0} + }) + + + + + if light_mode then + lightButtonText = "[ Set dark ]" + else + lightButtonText = "[ Set light ]" + end + +end + +function removeAll() + self.removeInput(0) + self.removeInput(1) + self.removeButton(0) + self.removeButton(1) + self.removeButton(2) +end + +function reloadAll() + removeAll() + createAll() + + updateSave() +end + +function swap_fcolor(_obj, _color, alt_click) + light_mode = not light_mode + reloadAll() +end + +function swap_align(_obj, _color, alt_click) + center_mode = not center_mode + reloadAll() +end + +function editName(_obj, _string, value) + self.setName(value) + setTooltips() +end + +function add_subtract(_obj, _color, alt_click) + mod = alt_click and -1 or 1 + new_value = math.min(math.max(val + mod, MIN_VALUE), MAX_VALUE) + if val ~= new_value then + val = new_value + updateVal() + updateSave() + end +end + +function updateVal() + + self.editButton({ + index = 0, + label = tostring(val), + + }) +end + +function reset_val() + val = 0 + updateVal() + updateSave() +end + +function setTooltips() + self.editInput({ + index = 0, + value = self.getName(), + tooltip = ttText + }) + self.editButton({ + index = 0, + value = tostring(val), + tooltip = ttText + }) +end + +function null() +end + +function keepSample(_obj, _string, value) + reloadAll() +end diff --git a/src/playermat/HorrorTracker.ttslua b/src/playermat/HorrorTracker.ttslua new file mode 100644 index 00000000..a0c5cd84 --- /dev/null +++ b/src/playermat/HorrorTracker.ttslua @@ -0,0 +1,132 @@ +MIN_VALUE = -99 +MAX_VALUE = 999 + +function onload(saved_data) + light_mode = true + val = 0 + + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + light_mode = loaded_data[1] + val = loaded_data[2] + end + + createAll() +end + +function updateSave() + local data_to_save = {light_mode, val} + saved_data = JSON.encode(data_to_save) + self.script_state = saved_data +end + +function createAll() + s_color = {0,0,0,100} + + if light_mode then + f_color = {1,1,1,100} + else + f_color = {0,0,0,100} + end + + + + self.createButton({ + label=tostring(val), + click_function="add_subtract", + function_owner=self, + position={-0.025,0.05,-0.025}, + height=600, + width=1000, + alignment = 3, + scale={x=1.5, y=1.5, z=1.5}, + font_size=600, + font_color=f_color, + color={1,1,1,0} + }) + + + + + if light_mode then + lightButtonText = "[ Set dark ]" + else + lightButtonText = "[ Set light ]" + end + +end + +function removeAll() + self.removeInput(0) + self.removeInput(1) + self.removeButton(0) + self.removeButton(1) + self.removeButton(2) +end + +function reloadAll() + removeAll() + createAll() + + updateSave() +end + +function swap_fcolor(_obj, _color, alt_click) + light_mode = not light_mode + reloadAll() +end + +function swap_align(_obj, _color, alt_click) + center_mode = not center_mode + reloadAll() +end + +function editName(_obj, _string, value) + self.setName(value) + setTooltips() +end + +function add_subtract(_obj, _color, alt_click) + mod = alt_click and -1 or 1 + new_value = math.min(math.max(val + mod, MIN_VALUE), MAX_VALUE) + if val ~= new_value then + val = new_value + updateVal() + updateSave() + end +end + +function updateVal() + + self.editButton({ + index = 0, + label = tostring(val), + + }) +end + +function reset_val() + val = 0 + updateVal() + updateSave() +end + +function setTooltips() + self.editInput({ + index = 0, + value = self.getName(), + tooltip = ttText + }) + self.editButton({ + index = 0, + value = tostring(val), + tooltip = ttText + }) +end + +function null() +end + +function keepSample(_obj, _string, value) + reloadAll() +end diff --git a/src/playermat/PlaymatGreen.ttslua b/src/playermat/PlaymatGreen.ttslua new file mode 100644 index 00000000..bee11ca3 --- /dev/null +++ b/src/playermat/PlaymatGreen.ttslua @@ -0,0 +1,480 @@ +-- set true to enable debug logging +DEBUG = false +-- we use this to turn off collision handling (for clue spawning) +-- until after load is complete (probably a better way to do this) +COLLISION_ENABLED = false +-- position offsets, adjust these to reposition things relative to mat [x,y,z] +DRAWN_ENCOUNTER_CARD_OFFSET = {0.98, 0.5, -0.635} +DRAWN_CHAOS_TOKEN_OFFSET = {-1.2, 0.5, -0.45} +DISCARD_BUTTON_OFFSETS = { + {-0.98, 0.2, -0.945}, + {-0.525, 0.2, -0.945}, + {-0.07, 0.2, -0.945}, + {0.39, 0.2, -0.945}, + {0.84, 0.2, -0.945}, +} +-- draw deck and discard zone +DECK_POSITION = { x=-1.4, y=0, z=0.3 } +DECK_ZONE_SCALE = { x=3, y=5, z=8 } +DRAW_DECK_POSITION = { x=-37, y=2.5, z=26.5 } + +-- play zone +PLAYER_COLOR = "Green" +PLAY_ZONE_POSITION = { x=-25, y=4, z=27 } +PLAY_ZONE_ROTATION = { x=0, y=0, z=0 } +PLAY_ZONE_SCALE = { x=30, y=5, z=15 } + +RESOURCE_COUNTER_GUID = "cd15ac" + +-- the position of the global discard pile +-- TODO: delegate to global for any auto discard actions +DISCARD_POSITION = {-3.85, 3, 10.38} + +function log(message) + if DEBUG then + print(message) + end +end + +-- builds a function that discards things in searchPostion to discardPostition +function makeDiscardHandlerFor(searchPosition, discardPosition) + return function (_) + local discardItemList = findObjectsAtPosition(searchPosition) + for _, obj in ipairs(discardItemList) do + obj.setPositionSmooth(discardPosition, false, true) + obj.setRotation({0, -90, 0}) + end + end +end + +-- build a discard button at position to discard from searchPosition to discardPosition +-- number must be unique +function makeDiscardButton(position, searchPosition, discardPosition, number) + local handler = makeDiscardHandlerFor(searchPosition, discardPosition) + local handlerName = 'handler' .. number + self.setVar(handlerName, handler) + self.createButton({ + label = "Discard", + click_function= handlerName, + function_owner= self, + position = position, + scale = {0.12, 0.12, 0.12}, + width = 800, + height = 280, + font_size = 180, + }) +end + +function onload(save_state) + self.interactable = DEBUG + DATA_HELPER = getObjectFromGUID('708279') + PLAYER_CARDS = DATA_HELPER.getTable('PLAYER_CARD_DATA') + PLAYER_CARD_TOKEN_OFFSETS = DATA_HELPER.getTable('PLAYER_CARD_TOKEN_OFFSETS') + + -- positions of encounter card slots + local encounterSlots = { + {1, 0, -0.7}, + {0.55, 0, -0.7}, + {0.1, 0, -0.7}, + {-0.35, 0, -0.7}, + {-0.8, 0, -0.7} + } + + local i = 1 + while i <= 5 do + makeDiscardButton(DISCARD_BUTTON_OFFSETS[i], encounterSlots[i], DISCARD_POSITION, i) + i = i + 1 + end + + self.createButton({ + label = " ", + click_function = "drawEncountercard", + function_owner = self, + position = {-1.45,0,-0.7}, + rotation = {0,-15,0}, + width = 170, + height = 255, + font_size = 50 + }) + + self.createButton({ + label=" ", + click_function = "drawChaostokenButton", + function_owner = self, + position = {1.48,0.0,-0.74}, + rotation = {0,-45,0}, + width = 125, + height = 125, + font_size = 50 + }) + + self.createButton({ + label="Upkeep", + click_function = "doUpkeep", + function_owner = self, + position = {1.48,0.1,-0.44}, + scale = {0.12, 0.12, 0.12}, + width = 800, + height = 280, + font_size = 180 + }) + + -- self.createButton({ + -- label="Draw 1", + -- click_function = "doDrawOne", + -- function_owner = self, + -- position = {1.48,0.1,-0.36}, + -- scale = {0.12, 0.12, 0.12}, + -- width = 800, + -- height = 280, + -- font_size = 180 + -- }) + + local state = JSON.decode(save_state) + if state ~= nil then + if state.playerColor ~= nil then + PLAYER_COLOR = state.playerColor + end + if state.zoneID ~= nil then + zoneID = state.zoneID + Wait.time(checkDeckZoneExists, 30) + else + spawnDeckZone() + end + else + spawnDeckZone() + end + + COLLISION_ENABLED = true +end + +function onSave() + return JSON.encode({ zoneID=zoneID, playerColor=PLAYER_COLOR }) +end + +function setMessageColor(color) + -- send messages to player who clicked button if no seated player found + messageColor = Player[PLAYER_COLOR].seated and PLAYER_COLOR or color +end + +function getDrawDiscardDecks(zone) + -- get the draw deck and discard pile objects + drawDeck = nil + discardPile = nil + for i,object in ipairs(zone.getObjects()) do + if object.tag == "Deck" or object.tag == "Card" then + if object.is_face_down then + drawDeck = object + else + discardPile = object + end + end + end +end + +function checkDeckThenDrawOne() + -- draw 1 card, shuffling the discard pile if necessary + if drawDeck == nil then + if discardPile ~= nil then + shuffleDiscardIntoDeck() + Wait.time(|| drawCards(1), 1) + end + printToColor("Take 1 horror (drawing card from empty deck)", messageColor) + else + drawCards(1) + end +end + +function doUpkeep(obj, color, alt_click) + -- right-click binds to new player color + if alt_click then + PLAYER_COLOR = color + printToColor("Upkeep button bound to " .. color, color) + return + end + + setMessageColor(color) + + -- unexhaust cards in play zone + local objs = Physics.cast({ + origin = PLAY_ZONE_POSITION, + direction = { x=0, y=1, z=0 }, + type = 3, + size = PLAY_ZONE_SCALE, + orientation = PLAY_ZONE_ROTATION + }) + + local y = PLAY_ZONE_ROTATION.y + + local investigator = nil + for i,v in ipairs(objs) do + local obj = v.hit_object + local props = obj.getCustomObject() + if obj.tag == "Card" and not obj.is_face_down and not doNotReady(obj) then + if props ~= nil and props.unique_back then + local name = obj.getName() + if string.match(name, "Jenny Barnes") ~= nil then + investigator = "Jenny Barnes" + elseif name == "Patrice Hathaway" then + investigator = name + end + else + local r = obj.getRotation() + if (r.y - y > 10) or (y - r.y > 10) then + obj.setRotation(PLAY_ZONE_ROTATION) + end + end + elseif obj.tag == "Board" and obj.getDescription() == "Action token" then + if obj.is_face_down then obj.flip() end + end + end + + -- gain resource + getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") + if investigator == "Jenny Barnes" then + getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") + printToColor("Taking 2 resources (Jenny)", messageColor) + end + + -- get the draw deck and discard pile objects + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + getDrawDiscardDecks(zone) + + -- special draw for Patrice Hathaway (shuffle discards if necessary) + if investigator == "Patrice Hathaway" then + patriceDraw() + return + end + + -- draw 1 card (shuffle discards if necessary) + checkDeckThenDrawOne() +end + +function doDrawOne(obj, color, alt_click) + -- right-click binds to new player color + if alt_click then + PLAYER_COLOR = color + printToColor("Draw 1 button bound to " .. color, color) + return + end + + setMessageColor(color) + + -- get the draw deck and discard pile objects + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + getDrawDiscardDecks(zone) + + -- draw 1 card (shuffle discards if necessary) + checkDeckThenDrawOne() +end + +function doNotReady(card) + if card.getVar("do_not_ready") == true then + return true + else + return false + end +end + +function drawCards(numCards) + if drawDeck == nil then return end + drawDeck.deal(numCards, PLAYER_COLOR) +end + +function shuffleDiscardIntoDeck() + discardPile.flip() + discardPile.shuffle() + discardPile.setPositionSmooth(DRAW_DECK_POSITION, false, false) + drawDeck = discardPile + discardPile = nil +end + +function patriceDraw() + local handSize = #Player[PLAYER_COLOR].getHandObjects() + if handSize >= 5 then return end + local cardsToDraw = 5 - handSize + local deckSize + printToColor("Drawing " .. cardsToDraw .. " cards (Patrice)", messageColor) + if drawDeck == nil then + deckSize = 0 + elseif drawDeck.tag == "Deck" then + deckSize = #drawDeck.getObjects() + else + deckSize = 1 + end + + if deckSize >= cardsToDraw then + drawCards(cardsToDraw) + return + end + + drawCards(deckSize) + if discardPile ~= nil then + shuffleDiscardIntoDeck() + Wait.time(|| drawCards(cardsToDraw - deckSize), 1) + end + printToColor("Take 1 horror (drawing card from empty deck)", messageColor) +end + +function checkDeckZoneExists() + if getObjectFromGUID(zoneID) ~= nil then return end + spawnDeckZone() +end + +function spawnDeckZone() + local pos = self.positionToWorld(DECK_POSITION) + local zoneProps = { + position = pos, + scale = DECK_ZONE_SCALE, + type = 'ScriptingTrigger', + callback = 'zoneCallback', + callback_owner = self, + rotation = self.getRotation() + } + spawnObject(zoneProps) +end + +function zoneCallback(zone) + zoneID = zone.getGUID() +end + +function findObjectsAtPosition(localPos) + local globalPos = self.positionToWorld(localPos) + local objList = Physics.cast({ + origin=globalPos, --Where the cast takes place + direction={0,1,0}, --Which direction it moves (up is shown) + type=2, --Type. 2 is "sphere" + size={2,2,2}, --How large that sphere is + max_distance=1, --How far it moves. Just a little bit + debug=false --If it displays the sphere when casting. + }) + local decksAndCards = {} + for _, obj in ipairs(objList) do + if obj.hit_object.tag == "Deck" or obj.hit_object.tag == "Card" then + table.insert(decksAndCards, obj.hit_object) + end + end + return decksAndCards +end + +function spawnTokenOn(object, offsets, tokenType) + local tokenPosition = object.positionToWorld(offsets) + spawnToken(tokenPosition, tokenType) +end + +-- spawn a group of tokens of the given type on the object +function spawnTokenGroup(object, tokenType, tokenCount) + local offsets = PLAYER_CARD_TOKEN_OFFSETS[tokenCount] + if offsets == nil then + error("couldn't find offsets for " .. tokenCount .. ' tokens') + end + local i = 0 + while i < tokenCount do + local offset = offsets[i + 1] + spawnTokenOn(object, offset, tokenType) + i = i + 1 + end +end + +function buildPlayerCardKey(object) + return object.getName() .. ':' .. object.getDescription() +end + +function getPlayerCardData(object) + return PLAYER_CARDS[buildPlayerCardKey(object)] or PLAYER_CARDS[object.getName()] +end + +function shouldSpawnTokens(object) + -- we assume we shouldn't spawn tokens if in doubt, this should + -- only ever happen on load and in that case prevents respawns + local spawned = DATA_HELPER.call('getSpawnedPlayerCardGuid', {object.getGUID()}) + local canSpawn = getPlayerCardData(object) + return not spawned and canSpawn +end + +function markSpawned(object) + local saved = DATA_HELPER.call('setSpawnedPlayerCardGuid', {object.getGUID(), true}) + if not saved then + error('attempt to mark player card spawned before data loaded') + end +end + +function spawnTokensFor(object) + local data = getPlayerCardData(object) + if data == nil then + error('attempt to spawn tokens for ' .. object.getName() .. ': no token data') + end + log(object.getName() .. '[' .. object.getDescription() .. ']' .. ' : ' .. data['tokenType'] .. ' : ' .. data['tokenCount']) + spawnTokenGroup(object, data['tokenType'], data['tokenCount']) + markSpawned(object) +end + +function resetSpawnState() + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + for i,object in ipairs(zone.getObjects()) do + if object.tag == "Card" then + local guid = object.getGUID() + if guid ~= nil then unmarkSpawned(guid, true) end + elseif object.tag == "Deck" then + local cards = object.getObjects() + if (cards ~= nil) then + for i,v in ipairs(cards) do + if v.guid ~= nil then unmarkSpawned(v.guid) end + end + end + end + end +end + +function unmarkSpawned(guid, force) + if not force and getObjectFromGUID(guid) ~= nil then return end + DATA_HELPER.call('setSpawnedPlayerCardGuid', {guid, false}) +end + +function onCollisionEnter(collision_info) + if not COLLISION_ENABLED then + return + end + + local object = collision_info.collision_object + Wait.time(resetSpawnState, 1) + -- anything to the left of this is legal to spawn + local discardSpawnBoundary = self.positionToWorld({-1.2, 0, 0}) + local boundaryLocalToCard = object.positionToLocal(discardSpawnBoundary) + if boundaryLocalToCard.x > 0 then + log('not checking for token spawn, boundary relative is ' .. boundaryLocalToCard.x) + return + end + if not object.is_face_down and shouldSpawnTokens(object) then + spawnTokensFor(object) + end +end + +-- functions delegated to Global +function drawChaostokenButton(object, player, isRightClick) + -- local toPosition = self.positionToWorld(DRAWN_CHAOS_TOKEN_OFFSET) + Global.call("drawChaostoken", {self, DRAWN_CHAOS_TOKEN_OFFSET, isRightClick}) +end + +function drawEncountercard(object, player, isRightClick) +local toPosition = self.positionToWorld(DRAWN_ENCOUNTER_CARD_OFFSET) +Global.call("drawEncountercard", {toPosition, self.getRotation(), isRightClick}) +end + +function spawnToken(position, tokenType) + Global.call('spawnToken', {position, tokenType}) +end + +function updatePlayerCards(args) + local custom_data_helper = getObjectFromGUID(args[1]) + data_player_cards = custom_data_helper.getTable("PLAYER_CARD_DATA") + for k, v in pairs(data_player_cards) do + PLAYER_CARDS[k] = v + end +end diff --git a/src/playermat/PlaymatOrange.ttslua b/src/playermat/PlaymatOrange.ttslua new file mode 100644 index 00000000..d3e58430 --- /dev/null +++ b/src/playermat/PlaymatOrange.ttslua @@ -0,0 +1,480 @@ +-- set true to enable debug logging +DEBUG = false +-- we use this to turn off collision handling (for clue spawning) +-- until after load is complete (probably a better way to do this) +COLLISION_ENABLED = false +-- position offsets, adjust these to reposition things relative to mat [x,y,z] +DRAWN_ENCOUNTER_CARD_OFFSET = {0.98, 0.5, -0.635} +DRAWN_CHAOS_TOKEN_OFFSET = {-1.2, 0.5, -0.45} +DISCARD_BUTTON_OFFSETS = { + {-0.98, 0.2, -0.945}, + {-0.525, 0.2, -0.945}, + {-0.07, 0.2, -0.945}, + {0.39, 0.2, -0.945}, + {0.84, 0.2, -0.945}, +} +-- draw deck and discard zone +DECK_POSITION = { x=-1.4, y=0, z=0.3 } +DECK_ZONE_SCALE = { x=3, y=5, z=8 } +DRAW_DECK_POSITION = { x=-55, y=2.5, z=-22.7 } + +-- play zone +PLAYER_COLOR = "Orange" +PLAY_ZONE_POSITION = { x=-54.53, y=4.10, z=-20.94} +PLAY_ZONE_ROTATION = { x=0, y=270, z=0 } +PLAY_ZONE_SCALE = { x=36.96, y=5.10, z=14.70} + +RESOURCE_COUNTER_GUID = "816d84" + +-- the position of the global discard pile +-- TODO: delegate to global for any auto discard actions +DISCARD_POSITION = {-3.85, 3, 10.38} + +function log(message) + if DEBUG then + print(message) + end +end + +-- builds a function that discards things in searchPostion to discardPostition +function makeDiscardHandlerFor(searchPosition, discardPosition) + return function (_) + local discardItemList = findObjectsAtPosition(searchPosition) + for _, obj in ipairs(discardItemList) do + obj.setPositionSmooth(discardPosition, false, true) + obj.setRotation({0, -90, 0}) + end + end +end + +-- build a discard button at position to discard from searchPosition to discardPosition +-- number must be unique +function makeDiscardButton(position, searchPosition, discardPosition, number) + local handler = makeDiscardHandlerFor(searchPosition, discardPosition) + local handlerName = 'handler' .. number + self.setVar(handlerName, handler) + self.createButton({ + label = "Discard", + click_function= handlerName, + function_owner= self, + position = position, + scale = {0.12, 0.12, 0.12}, + width = 800, + height = 280, + font_size = 180, + }) +end + +function onload(save_state) + self.interactable = DEBUG + DATA_HELPER = getObjectFromGUID('708279') + PLAYER_CARDS = DATA_HELPER.getTable('PLAYER_CARD_DATA') + PLAYER_CARD_TOKEN_OFFSETS = DATA_HELPER.getTable('PLAYER_CARD_TOKEN_OFFSETS') + + -- positions of encounter card slots + local encounterSlots = { + {1, 0, -0.7}, + {0.55, 0, -0.7}, + {0.1, 0, -0.7}, + {-0.35, 0, -0.7}, + {-0.8, 0, -0.7} + } + + local i = 1 + while i <= 5 do + makeDiscardButton(DISCARD_BUTTON_OFFSETS[i], encounterSlots[i], DISCARD_POSITION, i) + i = i + 1 + end + + self.createButton({ + label = " ", + click_function = "drawEncountercard", + function_owner = self, + position = {-1.45,0,-0.7}, + rotation = {0,-15,0}, + width = 170, + height = 255, + font_size = 50 + }) + + self.createButton({ + label=" ", + click_function = "drawChaostokenButton", + function_owner = self, + position = {1.48,0.0,-0.74}, + rotation = {0,-45,0}, + width = 125, + height = 125, + font_size = 50 + }) + + self.createButton({ + label="Upkeep", + click_function = "doUpkeep", + function_owner = self, + position = {1.48,0.1,-0.44}, + scale = {0.12, 0.12, 0.12}, + width = 800, + height = 280, + font_size = 180 + }) + + -- self.createButton({ + -- label="Draw 1", + -- click_function = "doDrawOne", + -- function_owner = self, + -- position = {1.48,0.1,-0.36}, + -- scale = {0.12, 0.12, 0.12}, + -- width = 800, + -- height = 280, + -- font_size = 180 + -- }) + + local state = JSON.decode(save_state) + if state ~= nil then + if state.playerColor ~= nil then + PLAYER_COLOR = state.playerColor + end + if state.zoneID ~= nil then + zoneID = state.zoneID + Wait.time(checkDeckZoneExists, 30) + else + spawnDeckZone() + end + else + spawnDeckZone() + end + + COLLISION_ENABLED = true +end + +function onSave() + return JSON.encode({ zoneID=zoneID, playerColor=PLAYER_COLOR }) +end + +function setMessageColor(color) + -- send messages to player who clicked button if no seated player found + messageColor = Player[PLAYER_COLOR].seated and PLAYER_COLOR or color +end + +function getDrawDiscardDecks(zone) + -- get the draw deck and discard pile objects + drawDeck = nil + discardPile = nil + for i,object in ipairs(zone.getObjects()) do + if object.tag == "Deck" or object.tag == "Card" then + if object.is_face_down then + drawDeck = object + else + discardPile = object + end + end + end +end + +function checkDeckThenDrawOne() + -- draw 1 card, shuffling the discard pile if necessary + if drawDeck == nil then + if discardPile ~= nil then + shuffleDiscardIntoDeck() + Wait.time(|| drawCards(1), 1) + end + printToColor("Take 1 horror (drawing card from empty deck)", messageColor) + else + drawCards(1) + end +end + +function doUpkeep(obj, color, alt_click) + -- right-click binds to new player color + if alt_click then + PLAYER_COLOR = color + printToColor("Upkeep button bound to " .. color, color) + return + end + + setMessageColor(color) + + -- unexhaust cards in play zone + local objs = Physics.cast({ + origin = PLAY_ZONE_POSITION, + direction = { x=0, y=1, z=0 }, + type = 3, + size = PLAY_ZONE_SCALE, + orientation = PLAY_ZONE_ROTATION + }) + + local y = PLAY_ZONE_ROTATION.y + + local investigator = nil + for i,v in ipairs(objs) do + local obj = v.hit_object + local props = obj.getCustomObject() + if obj.tag == "Card" and not obj.is_face_down and not doNotReady(obj) then + if props ~= nil and props.unique_back then + local name = obj.getName() + if string.match(name, "Jenny Barnes") ~= nil then + investigator = "Jenny Barnes" + elseif name == "Patrice Hathaway" then + investigator = name + end + else + local r = obj.getRotation() + if (r.y - y > 10) or (y - r.y > 10) then + obj.setRotation(PLAY_ZONE_ROTATION) + end + end + elseif obj.tag == "Board" and obj.getDescription() == "Action token" then + if obj.is_face_down then obj.flip() end + end + end + + -- gain resource + getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") + if investigator == "Jenny Barnes" then + getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") + printToColor("Taking 2 resources (Jenny)", messageColor) + end + + -- get the draw deck and discard pile objects + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + getDrawDiscardDecks(zone) + + -- special draw for Patrice Hathaway (shuffle discards if necessary) + if investigator == "Patrice Hathaway" then + patriceDraw() + return + end + + -- draw 1 card (shuffle discards if necessary) + checkDeckThenDrawOne() +end + +function doDrawOne(obj, color, alt_click) + -- right-click binds to new player color + if alt_click then + PLAYER_COLOR = color + printToColor("Draw 1 button bound to " .. color, color) + return + end + + setMessageColor(color) + + -- get the draw deck and discard pile objects + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + getDrawDiscardDecks(zone) + + -- draw 1 card (shuffle discards if necessary) + checkDeckThenDrawOne() +end + +function doNotReady(card) + if card.getVar("do_not_ready") == true then + return true + else + return false + end +end + +function drawCards(numCards) + if drawDeck == nil then return end + drawDeck.deal(numCards, PLAYER_COLOR) +end + +function shuffleDiscardIntoDeck() + discardPile.flip() + discardPile.shuffle() + discardPile.setPositionSmooth(DRAW_DECK_POSITION, false, false) + drawDeck = discardPile + discardPile = nil +end + +function patriceDraw() + local handSize = #Player[PLAYER_COLOR].getHandObjects() + if handSize >= 5 then return end + local cardsToDraw = 5 - handSize + local deckSize + printToColor("Drawing " .. cardsToDraw .. " cards (Patrice)", messageColor) + if drawDeck == nil then + deckSize = 0 + elseif drawDeck.tag == "Deck" then + deckSize = #drawDeck.getObjects() + else + deckSize = 1 + end + + if deckSize >= cardsToDraw then + drawCards(cardsToDraw) + return + end + + drawCards(deckSize) + if discardPile ~= nil then + shuffleDiscardIntoDeck() + Wait.time(|| drawCards(cardsToDraw - deckSize), 1) + end + printToColor("Take 1 horror (drawing card from empty deck)", messageColor) +end + +function checkDeckZoneExists() + if getObjectFromGUID(zoneID) ~= nil then return end + spawnDeckZone() +end + +function spawnDeckZone() + local pos = self.positionToWorld(DECK_POSITION) + local zoneProps = { + position = pos, + scale = DECK_ZONE_SCALE, + type = 'ScriptingTrigger', + callback = 'zoneCallback', + callback_owner = self, + rotation = self.getRotation() + } + spawnObject(zoneProps) +end + +function zoneCallback(zone) + zoneID = zone.getGUID() +end + +function findObjectsAtPosition(localPos) + local globalPos = self.positionToWorld(localPos) + local objList = Physics.cast({ + origin=globalPos, --Where the cast takes place + direction={0,1,0}, --Which direction it moves (up is shown) + type=2, --Type. 2 is "sphere" + size={2,2,2}, --How large that sphere is + max_distance=1, --How far it moves. Just a little bit + debug=false --If it displays the sphere when casting. + }) + local decksAndCards = {} + for _, obj in ipairs(objList) do + if obj.hit_object.tag == "Deck" or obj.hit_object.tag == "Card" then + table.insert(decksAndCards, obj.hit_object) + end + end + return decksAndCards +end + +function spawnTokenOn(object, offsets, tokenType) + local tokenPosition = object.positionToWorld(offsets) + spawnToken(tokenPosition, tokenType) +end + +-- spawn a group of tokens of the given type on the object +function spawnTokenGroup(object, tokenType, tokenCount) + local offsets = PLAYER_CARD_TOKEN_OFFSETS[tokenCount] + if offsets == nil then + error("couldn't find offsets for " .. tokenCount .. ' tokens') + end + local i = 0 + while i < tokenCount do + local offset = offsets[i + 1] + spawnTokenOn(object, offset, tokenType) + i = i + 1 + end +end + +function buildPlayerCardKey(object) + return object.getName() .. ':' .. object.getDescription() +end + +function getPlayerCardData(object) + return PLAYER_CARDS[buildPlayerCardKey(object)] or PLAYER_CARDS[object.getName()] +end + +function shouldSpawnTokens(object) + -- we assume we shouldn't spawn tokens if in doubt, this should + -- only ever happen on load and in that case prevents respawns + local spawned = DATA_HELPER.call('getSpawnedPlayerCardGuid', {object.getGUID()}) + local canSpawn = getPlayerCardData(object) + return not spawned and canSpawn +end + +function markSpawned(object) + local saved = DATA_HELPER.call('setSpawnedPlayerCardGuid', {object.getGUID(), true}) + if not saved then + error('attempt to mark player card spawned before data loaded') + end +end + +function spawnTokensFor(object) + local data = getPlayerCardData(object) + if data == nil then + error('attempt to spawn tokens for ' .. object.getName() .. ': no token data') + end + log(object.getName() .. '[' .. object.getDescription() .. ']' .. ' : ' .. data['tokenType'] .. ' : ' .. data['tokenCount']) + spawnTokenGroup(object, data['tokenType'], data['tokenCount']) + markSpawned(object) +end + +function resetSpawnState() + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + for i,object in ipairs(zone.getObjects()) do + if object.tag == "Card" then + local guid = object.getGUID() + if guid ~= nil then unmarkSpawned(guid, true) end + elseif object.tag == "Deck" then + local cards = object.getObjects() + if (cards ~= nil) then + for i,v in ipairs(cards) do + if v.guid ~= nil then unmarkSpawned(v.guid) end + end + end + end + end +end + +function unmarkSpawned(guid, force) + if not force and getObjectFromGUID(guid) ~= nil then return end + DATA_HELPER.call('setSpawnedPlayerCardGuid', {guid, false}) +end + +function onCollisionEnter(collision_info) + if not COLLISION_ENABLED then + return + end + + local object = collision_info.collision_object + Wait.time(resetSpawnState, 1) + -- anything to the left of this is legal to spawn + local discardSpawnBoundary = self.positionToWorld({-1.2, 0, 0}) + local boundaryLocalToCard = object.positionToLocal(discardSpawnBoundary) + if boundaryLocalToCard.x > 0 then + log('not checking for token spawn, boundary relative is ' .. boundaryLocalToCard.x) + return + end + if not object.is_face_down and shouldSpawnTokens(object) then + spawnTokensFor(object) + end +end + +-- functions delegated to Global +function drawChaostokenButton(object, player, isRightClick) + -- local toPosition = self.positionToWorld(DRAWN_CHAOS_TOKEN_OFFSET) + Global.call("drawChaostoken", {self, DRAWN_CHAOS_TOKEN_OFFSET, isRightClick}) +end + +function drawEncountercard(object, player, isRightClick) +local toPosition = self.positionToWorld(DRAWN_ENCOUNTER_CARD_OFFSET) +Global.call("drawEncountercard", {toPosition, self.getRotation(), isRightClick}) +end + +function spawnToken(position, tokenType) + Global.call('spawnToken', {position, tokenType}) +end + +function updatePlayerCards(args) + local custom_data_helper = getObjectFromGUID(args[1]) + data_player_cards = custom_data_helper.getTable("PLAYER_CARD_DATA") + for k, v in pairs(data_player_cards) do + PLAYER_CARDS[k] = v + end +end diff --git a/src/playermat/PlaymatRed.ttslua b/src/playermat/PlaymatRed.ttslua new file mode 100644 index 00000000..bef792bf --- /dev/null +++ b/src/playermat/PlaymatRed.ttslua @@ -0,0 +1,480 @@ +-- set true to enable debug logging +DEBUG = false +-- we use this to turn off collision handling (for clue spawning) +-- until after load is complete (probably a better way to do this) +COLLISION_ENABLED = false +-- position offsets, adjust these to reposition things relative to mat [x,y,z] +DRAWN_ENCOUNTER_CARD_OFFSET = {0.98, 0.5, -0.635} +DRAWN_CHAOS_TOKEN_OFFSET = {-1.2, 0.5, -0.45} +DISCARD_BUTTON_OFFSETS = { + {-0.98, 0.2, -0.945}, + {-0.525, 0.2, -0.945}, + {-0.07, 0.2, -0.945}, + {0.39, 0.2, -0.945}, + {0.84, 0.2, -0.945}, +} +-- draw deck and discard zone +DECK_POSITION = { x=-1.4, y=0, z=0.3 } +DECK_ZONE_SCALE = { x=3, y=5, z=8 } +DRAW_DECK_POSITION = { x=-18.9, y=2.5, z=-26.7 } + +-- play zone +PLAYER_COLOR = "Red" +PLAY_ZONE_POSITION = { x=-25, y=4, z=-27 } +PLAY_ZONE_ROTATION = { x=0, y=180, z=0 } +PLAY_ZONE_SCALE = { x=30, y=5, z=15 } + +RESOURCE_COUNTER_GUID = "a4b60d" + +-- the position of the global discard pile +-- TODO: delegate to global for any auto discard actions +DISCARD_POSITION = {-3.85, 3, 10.38} + +function log(message) + if DEBUG then + print(message) + end +end + +-- builds a function that discards things in searchPostion to discardPostition +function makeDiscardHandlerFor(searchPosition, discardPosition) + return function (_) + local discardItemList = findObjectsAtPosition(searchPosition) + for _, obj in ipairs(discardItemList) do + obj.setPositionSmooth(discardPosition, false, true) + obj.setRotation({0, -90, 0}) + end + end +end + +-- build a discard button at position to discard from searchPosition to discardPosition +-- number must be unique +function makeDiscardButton(position, searchPosition, discardPosition, number) + local handler = makeDiscardHandlerFor(searchPosition, discardPosition) + local handlerName = 'handler' .. number + self.setVar(handlerName, handler) + self.createButton({ + label = "Discard", + click_function= handlerName, + function_owner= self, + position = position, + scale = {0.12, 0.12, 0.12}, + width = 800, + height = 280, + font_size = 180, + }) +end + +function onload(save_state) + self.interactable = DEBUG + DATA_HELPER = getObjectFromGUID('708279') + PLAYER_CARDS = DATA_HELPER.getTable('PLAYER_CARD_DATA') + PLAYER_CARD_TOKEN_OFFSETS = DATA_HELPER.getTable('PLAYER_CARD_TOKEN_OFFSETS') + + -- positions of encounter card slots + local encounterSlots = { + {1, 0, -0.7}, + {0.55, 0, -0.7}, + {0.1, 0, -0.7}, + {-0.35, 0, -0.7}, + {-0.8, 0, -0.7} + } + + local i = 1 + while i <= 5 do + makeDiscardButton(DISCARD_BUTTON_OFFSETS[i], encounterSlots[i], DISCARD_POSITION, i) + i = i + 1 + end + + self.createButton({ + label = " ", + click_function = "drawEncountercard", + function_owner = self, + position = {-1.45,0,-0.7}, + rotation = {0,-15,0}, + width = 170, + height = 255, + font_size = 50 + }) + + self.createButton({ + label=" ", + click_function = "drawChaostokenButton", + function_owner = self, + position = {1.48,0.0,-0.74}, + rotation = {0,-45,0}, + width = 125, + height = 125, + font_size = 50 + }) + + self.createButton({ + label="Upkeep", + click_function = "doUpkeep", + function_owner = self, + position = {1.48,0.1,-0.44}, + scale = {0.12, 0.12, 0.12}, + width = 800, + height = 280, + font_size = 180 + }) + + -- self.createButton({ + -- label="Draw 1", + -- click_function = "doDrawOne", + -- function_owner = self, + -- position = {1.48,0.1,-0.36}, + -- scale = {0.12, 0.12, 0.12}, + -- width = 800, + -- height = 280, + -- font_size = 180 + -- }) + + local state = JSON.decode(save_state) + if state ~= nil then + if state.playerColor ~= nil then + PLAYER_COLOR = state.playerColor + end + if state.zoneID ~= nil then + zoneID = state.zoneID + Wait.time(checkDeckZoneExists, 30) + else + spawnDeckZone() + end + else + spawnDeckZone() + end + + COLLISION_ENABLED = true +end + +function onSave() + return JSON.encode({ zoneID=zoneID, playerColor=PLAYER_COLOR }) +end + +function setMessageColor(color) + -- send messages to player who clicked button if no seated player found + messageColor = Player[PLAYER_COLOR].seated and PLAYER_COLOR or color +end + +function getDrawDiscardDecks(zone) + -- get the draw deck and discard pile objects + drawDeck = nil + discardPile = nil + for i,object in ipairs(zone.getObjects()) do + if object.tag == "Deck" or object.tag == "Card" then + if object.is_face_down then + drawDeck = object + else + discardPile = object + end + end + end +end + +function checkDeckThenDrawOne() + -- draw 1 card, shuffling the discard pile if necessary + if drawDeck == nil then + if discardPile ~= nil then + shuffleDiscardIntoDeck() + Wait.time(|| drawCards(1), 1) + end + printToColor("Take 1 horror (drawing card from empty deck)", messageColor) + else + drawCards(1) + end +end + +function doUpkeep(obj, color, alt_click) + -- right-click binds to new player color + if alt_click then + PLAYER_COLOR = color + printToColor("Upkeep button bound to " .. color, color) + return + end + + setMessageColor(color) + + -- unexhaust cards in play zone + local objs = Physics.cast({ + origin = PLAY_ZONE_POSITION, + direction = { x=0, y=1, z=0 }, + type = 3, + size = PLAY_ZONE_SCALE, + orientation = PLAY_ZONE_ROTATION + }) + + local y = PLAY_ZONE_ROTATION.y + + local investigator = nil + for i,v in ipairs(objs) do + local obj = v.hit_object + local props = obj.getCustomObject() + if obj.tag == "Card" and not obj.is_face_down and not doNotReady(obj) then + if props ~= nil and props.unique_back then + local name = obj.getName() + if string.match(name, "Jenny Barnes") ~= nil then + investigator = "Jenny Barnes" + elseif name == "Patrice Hathaway" then + investigator = name + end + else + local r = obj.getRotation() + if (r.y - y > 10) or (y - r.y > 10) then + obj.setRotation(PLAY_ZONE_ROTATION) + end + end + elseif obj.tag == "Board" and obj.getDescription() == "Action token" then + if obj.is_face_down then obj.flip() end + end + end + + -- gain resource + getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") + if investigator == "Jenny Barnes" then + getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") + printToColor("Taking 2 resources (Jenny)", messageColor) + end + + -- get the draw deck and discard pile objects + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + getDrawDiscardDecks(zone) + + -- special draw for Patrice Hathaway (shuffle discards if necessary) + if investigator == "Patrice Hathaway" then + patriceDraw() + return + end + + -- draw 1 card (shuffle discards if necessary) + checkDeckThenDrawOne() +end + +function doDrawOne(obj, color, alt_click) + -- right-click binds to new player color + if alt_click then + PLAYER_COLOR = color + printToColor("Draw 1 button bound to " .. color, color) + return + end + + setMessageColor(color) + + -- get the draw deck and discard pile objects + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + getDrawDiscardDecks(zone) + + -- draw 1 card (shuffle discards if necessary) + checkDeckThenDrawOne() +end + +function doNotReady(card) + if card.getVar("do_not_ready") == true then + return true + else + return false + end +end + +function drawCards(numCards) + if drawDeck == nil then return end + drawDeck.deal(numCards, PLAYER_COLOR) +end + +function shuffleDiscardIntoDeck() + discardPile.flip() + discardPile.shuffle() + discardPile.setPositionSmooth(DRAW_DECK_POSITION, false, false) + drawDeck = discardPile + discardPile = nil +end + +function patriceDraw() + local handSize = #Player[PLAYER_COLOR].getHandObjects() + if handSize >= 5 then return end + local cardsToDraw = 5 - handSize + local deckSize + printToColor("Drawing " .. cardsToDraw .. " cards (Patrice)", messageColor) + if drawDeck == nil then + deckSize = 0 + elseif drawDeck.tag == "Deck" then + deckSize = #drawDeck.getObjects() + else + deckSize = 1 + end + + if deckSize >= cardsToDraw then + drawCards(cardsToDraw) + return + end + + drawCards(deckSize) + if discardPile ~= nil then + shuffleDiscardIntoDeck() + Wait.time(|| drawCards(cardsToDraw - deckSize), 1) + end + printToColor("Take 1 horror (drawing card from empty deck)", messageColor) +end + +function checkDeckZoneExists() + if getObjectFromGUID(zoneID) ~= nil then return end + spawnDeckZone() +end + +function spawnDeckZone() + local pos = self.positionToWorld(DECK_POSITION) + local zoneProps = { + position = pos, + scale = DECK_ZONE_SCALE, + type = 'ScriptingTrigger', + callback = 'zoneCallback', + callback_owner = self, + rotation = self.getRotation() + } + spawnObject(zoneProps) +end + +function zoneCallback(zone) + zoneID = zone.getGUID() +end + +function findObjectsAtPosition(localPos) + local globalPos = self.positionToWorld(localPos) + local objList = Physics.cast({ + origin=globalPos, --Where the cast takes place + direction={0,1,0}, --Which direction it moves (up is shown) + type=2, --Type. 2 is "sphere" + size={2,2,2}, --How large that sphere is + max_distance=1, --How far it moves. Just a little bit + debug=false --If it displays the sphere when casting. + }) + local decksAndCards = {} + for _, obj in ipairs(objList) do + if obj.hit_object.tag == "Deck" or obj.hit_object.tag == "Card" then + table.insert(decksAndCards, obj.hit_object) + end + end + return decksAndCards +end + +function spawnTokenOn(object, offsets, tokenType) + local tokenPosition = object.positionToWorld(offsets) + spawnToken(tokenPosition, tokenType) +end + +-- spawn a group of tokens of the given type on the object +function spawnTokenGroup(object, tokenType, tokenCount) + local offsets = PLAYER_CARD_TOKEN_OFFSETS[tokenCount] + if offsets == nil then + error("couldn't find offsets for " .. tokenCount .. ' tokens') + end + local i = 0 + while i < tokenCount do + local offset = offsets[i + 1] + spawnTokenOn(object, offset, tokenType) + i = i + 1 + end +end + +function buildPlayerCardKey(object) + return object.getName() .. ':' .. object.getDescription() +end + +function getPlayerCardData(object) + return PLAYER_CARDS[buildPlayerCardKey(object)] or PLAYER_CARDS[object.getName()] +end + +function shouldSpawnTokens(object) + -- we assume we shouldn't spawn tokens if in doubt, this should + -- only ever happen on load and in that case prevents respawns + local spawned = DATA_HELPER.call('getSpawnedPlayerCardGuid', {object.getGUID()}) + local canSpawn = getPlayerCardData(object) + return not spawned and canSpawn +end + +function markSpawned(object) + local saved = DATA_HELPER.call('setSpawnedPlayerCardGuid', {object.getGUID(), true}) + if not saved then + error('attempt to mark player card spawned before data loaded') + end +end + +function spawnTokensFor(object) + local data = getPlayerCardData(object) + if data == nil then + error('attempt to spawn tokens for ' .. object.getName() .. ': no token data') + end + log(object.getName() .. '[' .. object.getDescription() .. ']' .. ' : ' .. data['tokenType'] .. ' : ' .. data['tokenCount']) + spawnTokenGroup(object, data['tokenType'], data['tokenCount']) + markSpawned(object) +end + +function resetSpawnState() + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + for i,object in ipairs(zone.getObjects()) do + if object.tag == "Card" then + local guid = object.getGUID() + if guid ~= nil then unmarkSpawned(guid, true) end + elseif object.tag == "Deck" then + local cards = object.getObjects() + if (cards ~= nil) then + for i,v in ipairs(cards) do + if v.guid ~= nil then unmarkSpawned(v.guid) end + end + end + end + end +end + +function unmarkSpawned(guid, force) + if not force and getObjectFromGUID(guid) ~= nil then return end + DATA_HELPER.call('setSpawnedPlayerCardGuid', {guid, false}) +end + +function onCollisionEnter(collision_info) + if not COLLISION_ENABLED then + return + end + + local object = collision_info.collision_object + Wait.time(resetSpawnState, 1) + -- anything to the left of this is legal to spawn + local discardSpawnBoundary = self.positionToWorld({-1.2, 0, 0}) + local boundaryLocalToCard = object.positionToLocal(discardSpawnBoundary) + if boundaryLocalToCard.x > 0 then + log('not checking for token spawn, boundary relative is ' .. boundaryLocalToCard.x) + return + end + if not object.is_face_down and shouldSpawnTokens(object) then + spawnTokensFor(object) + end +end + +-- functions delegated to Global +function drawChaostokenButton(object, player, isRightClick) + -- local toPosition = self.positionToWorld(DRAWN_CHAOS_TOKEN_OFFSET) + Global.call("drawChaostoken", {self, DRAWN_CHAOS_TOKEN_OFFSET, isRightClick}) +end + +function drawEncountercard(object, player, isRightClick) +local toPosition = self.positionToWorld(DRAWN_ENCOUNTER_CARD_OFFSET) +Global.call("drawEncountercard", {toPosition, self.getRotation(), isRightClick}) +end + +function spawnToken(position, tokenType) + Global.call('spawnToken', {position, tokenType}) +end + +function updatePlayerCards(args) + local custom_data_helper = getObjectFromGUID(args[1]) + data_player_cards = custom_data_helper.getTable("PLAYER_CARD_DATA") + for k, v in pairs(data_player_cards) do + PLAYER_CARDS[k] = v + end +end diff --git a/src/playermat/PlaymatWhite.ttslua b/src/playermat/PlaymatWhite.ttslua new file mode 100644 index 00000000..4f93ed6f --- /dev/null +++ b/src/playermat/PlaymatWhite.ttslua @@ -0,0 +1,480 @@ +-- set true to enable debug logging +DEBUG = false +-- we use this to turn off collision handling (for clue spawning) +-- until after load is complete (probably a better way to do this) +COLLISION_ENABLED = false +-- position offsets, adjust these to reposition things relative to mat [x,y,z] +DRAWN_ENCOUNTER_CARD_OFFSET = {0.98, 0.5, -0.635} +DRAWN_CHAOS_TOKEN_OFFSET = {-1.2, 0.5, -0.45} +DISCARD_BUTTON_OFFSETS = { + {-0.98, 0.2, -0.945}, + {-0.525, 0.2, -0.945}, + {-0.07, 0.2, -0.945}, + {0.39, 0.2, -0.945}, + {0.84, 0.2, -0.945}, +} +-- draw deck and discard zone +DECK_POSITION = { x=-1.4, y=0, z=0.3 } +DECK_ZONE_SCALE = { x=3, y=5, z=8 } +DRAW_DECK_POSITION = { x=-55, y=2.5, z=4.5 } + +-- play zone +PLAYER_COLOR = "White" +PLAY_ZONE_POSITION = { x=-54.42, y=4.10, z=20.96} +PLAY_ZONE_ROTATION = { x=0, y=270, z=0 } +PLAY_ZONE_SCALE = { x=36.63, y=5.10, z=14.59} + +RESOURCE_COUNTER_GUID = "4406f0" + +-- the position of the global discard pile +-- TODO: delegate to global for any auto discard actions +DISCARD_POSITION = {-3.85, 3, 10.38} + +function log(message) + if DEBUG then + print(message) + end +end + +-- builds a function that discards things in searchPostion to discardPostition +function makeDiscardHandlerFor(searchPosition, discardPosition) + return function (_) + local discardItemList = findObjectsAtPosition(searchPosition) + for _, obj in ipairs(discardItemList) do + obj.setPositionSmooth(discardPosition, false, true) + obj.setRotation({0, -90, 0}) + end + end +end + +-- build a discard button at position to discard from searchPosition to discardPosition +-- number must be unique +function makeDiscardButton(position, searchPosition, discardPosition, number) + local handler = makeDiscardHandlerFor(searchPosition, discardPosition) + local handlerName = 'handler' .. number + self.setVar(handlerName, handler) + self.createButton({ + label = "Discard", + click_function= handlerName, + function_owner= self, + position = position, + scale = {0.12, 0.12, 0.12}, + width = 800, + height = 280, + font_size = 180, + }) +end + +function onload(save_state) + self.interactable = DEBUG + DATA_HELPER = getObjectFromGUID('708279') + PLAYER_CARDS = DATA_HELPER.getTable('PLAYER_CARD_DATA') + PLAYER_CARD_TOKEN_OFFSETS = DATA_HELPER.getTable('PLAYER_CARD_TOKEN_OFFSETS') + + -- positions of encounter card slots + local encounterSlots = { + {1, 0, -0.7}, + {0.55, 0, -0.7}, + {0.1, 0, -0.7}, + {-0.35, 0, -0.7}, + {-0.8, 0, -0.7} + } + + local i = 1 + while i <= 5 do + makeDiscardButton(DISCARD_BUTTON_OFFSETS[i], encounterSlots[i], DISCARD_POSITION, i) + i = i + 1 + end + + self.createButton({ + label = " ", + click_function = "drawEncountercard", + function_owner = self, + position = {-1.45,0,-0.7}, + rotation = {0,-15,0}, + width = 170, + height = 255, + font_size = 50 + }) + + self.createButton({ + label=" ", + click_function = "drawChaostokenButton", + function_owner = self, + position = {1.48,0.0,-0.74}, + rotation = {0,-45,0}, + width = 125, + height = 125, + font_size = 50 + }) + + self.createButton({ + label="Upkeep", + click_function = "doUpkeep", + function_owner = self, + position = {1.48,0.1,-0.44}, + scale = {0.12, 0.12, 0.12}, + width = 800, + height = 280, + font_size = 180 + }) + + -- self.createButton({ + -- label="Draw 1", + -- click_function = "doDrawOne", + -- function_owner = self, + -- position = {1.48,0.1,-0.36}, + -- scale = {0.12, 0.12, 0.12}, + -- width = 800, + -- height = 280, + -- font_size = 180 + -- }) + + local state = JSON.decode(save_state) + if state ~= nil then + if state.playerColor ~= nil then + PLAYER_COLOR = state.playerColor + end + if state.zoneID ~= nil then + zoneID = state.zoneID + Wait.time(checkDeckZoneExists, 30) + else + spawnDeckZone() + end + else + spawnDeckZone() + end + + COLLISION_ENABLED = true +end + +function onSave() + return JSON.encode({ zoneID=zoneID, playerColor=PLAYER_COLOR }) +end + +function setMessageColor(color) + -- send messages to player who clicked button if no seated player found + messageColor = Player[PLAYER_COLOR].seated and PLAYER_COLOR or color +end + +function getDrawDiscardDecks(zone) + -- get the draw deck and discard pile objects + drawDeck = nil + discardPile = nil + for i,object in ipairs(zone.getObjects()) do + if object.tag == "Deck" or object.tag == "Card" then + if object.is_face_down then + drawDeck = object + else + discardPile = object + end + end + end +end + +function checkDeckThenDrawOne() + -- draw 1 card, shuffling the discard pile if necessary + if drawDeck == nil then + if discardPile ~= nil then + shuffleDiscardIntoDeck() + Wait.time(|| drawCards(1), 1) + end + printToColor("Take 1 horror (drawing card from empty deck)", messageColor) + else + drawCards(1) + end +end + +function doUpkeep(obj, color, alt_click) + -- right-click binds to new player color + if alt_click then + PLAYER_COLOR = color + printToColor("Upkeep button bound to " .. color, color) + return + end + + setMessageColor(color) + + -- unexhaust cards in play zone + local objs = Physics.cast({ + origin = PLAY_ZONE_POSITION, + direction = { x=0, y=1, z=0 }, + type = 3, + size = PLAY_ZONE_SCALE, + orientation = PLAY_ZONE_ROTATION + }) + + local y = PLAY_ZONE_ROTATION.y + + local investigator = nil + for i,v in ipairs(objs) do + local obj = v.hit_object + local props = obj.getCustomObject() + if obj.tag == "Card" and not obj.is_face_down and not doNotReady(obj) then + if props ~= nil and props.unique_back then + local name = obj.getName() + if string.match(name, "Jenny Barnes") ~= nil then + investigator = "Jenny Barnes" + elseif name == "Patrice Hathaway" then + investigator = name + end + else + local r = obj.getRotation() + if (r.y - y > 10) or (y - r.y > 10) then + obj.setRotation(PLAY_ZONE_ROTATION) + end + end + elseif obj.tag == "Board" and obj.getDescription() == "Action token" then + if obj.is_face_down then obj.flip() end + end + end + + -- gain resource + getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") + if investigator == "Jenny Barnes" then + getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") + printToColor("Taking 2 resources (Jenny)", messageColor) + end + + -- get the draw deck and discard pile objects + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + getDrawDiscardDecks(zone) + + -- special draw for Patrice Hathaway (shuffle discards if necessary) + if investigator == "Patrice Hathaway" then + patriceDraw() + return + end + + -- draw 1 card (shuffle discards if necessary) + checkDeckThenDrawOne() +end + +function doDrawOne(obj, color, alt_click) + -- right-click binds to new player color + if alt_click then + PLAYER_COLOR = color + printToColor("Draw 1 button bound to " .. color, color) + return + end + + setMessageColor(color) + + -- get the draw deck and discard pile objects + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + getDrawDiscardDecks(zone) + + -- draw 1 card (shuffle discards if necessary) + checkDeckThenDrawOne() +end + +function doNotReady(card) + if card.getVar("do_not_ready") == true then + return true + else + return false + end +end + +function drawCards(numCards) + if drawDeck == nil then return end + drawDeck.deal(numCards, PLAYER_COLOR) +end + +function shuffleDiscardIntoDeck() + discardPile.flip() + discardPile.shuffle() + discardPile.setPositionSmooth(DRAW_DECK_POSITION, false, false) + drawDeck = discardPile + discardPile = nil +end + +function patriceDraw() + local handSize = #Player[PLAYER_COLOR].getHandObjects() + if handSize >= 5 then return end + local cardsToDraw = 5 - handSize + local deckSize + printToColor("Drawing " .. cardsToDraw .. " cards (Patrice)", messageColor) + if drawDeck == nil then + deckSize = 0 + elseif drawDeck.tag == "Deck" then + deckSize = #drawDeck.getObjects() + else + deckSize = 1 + end + + if deckSize >= cardsToDraw then + drawCards(cardsToDraw) + return + end + + drawCards(deckSize) + if discardPile ~= nil then + shuffleDiscardIntoDeck() + Wait.time(|| drawCards(cardsToDraw - deckSize), 1) + end + printToColor("Take 1 horror (drawing card from empty deck)", messageColor) +end + +function checkDeckZoneExists() + if getObjectFromGUID(zoneID) ~= nil then return end + spawnDeckZone() +end + +function spawnDeckZone() + local pos = self.positionToWorld(DECK_POSITION) + local zoneProps = { + position = pos, + scale = DECK_ZONE_SCALE, + type = 'ScriptingTrigger', + callback = 'zoneCallback', + callback_owner = self, + rotation = self.getRotation() + } + spawnObject(zoneProps) +end + +function zoneCallback(zone) + zoneID = zone.getGUID() +end + +function findObjectsAtPosition(localPos) + local globalPos = self.positionToWorld(localPos) + local objList = Physics.cast({ + origin=globalPos, --Where the cast takes place + direction={0,1,0}, --Which direction it moves (up is shown) + type=2, --Type. 2 is "sphere" + size={2,2,2}, --How large that sphere is + max_distance=1, --How far it moves. Just a little bit + debug=false --If it displays the sphere when casting. + }) + local decksAndCards = {} + for _, obj in ipairs(objList) do + if obj.hit_object.tag == "Deck" or obj.hit_object.tag == "Card" then + table.insert(decksAndCards, obj.hit_object) + end + end + return decksAndCards +end + +function spawnTokenOn(object, offsets, tokenType) + local tokenPosition = object.positionToWorld(offsets) + spawnToken(tokenPosition, tokenType) +end + +-- spawn a group of tokens of the given type on the object +function spawnTokenGroup(object, tokenType, tokenCount) + local offsets = PLAYER_CARD_TOKEN_OFFSETS[tokenCount] + if offsets == nil then + error("couldn't find offsets for " .. tokenCount .. ' tokens') + end + local i = 0 + while i < tokenCount do + local offset = offsets[i + 1] + spawnTokenOn(object, offset, tokenType) + i = i + 1 + end +end + +function buildPlayerCardKey(object) + return object.getName() .. ':' .. object.getDescription() +end + +function getPlayerCardData(object) + return PLAYER_CARDS[buildPlayerCardKey(object)] or PLAYER_CARDS[object.getName()] +end + +function shouldSpawnTokens(object) + -- we assume we shouldn't spawn tokens if in doubt, this should + -- only ever happen on load and in that case prevents respawns + local spawned = DATA_HELPER.call('getSpawnedPlayerCardGuid', {object.getGUID()}) + local canSpawn = getPlayerCardData(object) + return not spawned and canSpawn +end + +function markSpawned(object) + local saved = DATA_HELPER.call('setSpawnedPlayerCardGuid', {object.getGUID(), true}) + if not saved then + error('attempt to mark player card spawned before data loaded') + end +end + +function spawnTokensFor(object) + local data = getPlayerCardData(object) + if data == nil then + error('attempt to spawn tokens for ' .. object.getName() .. ': no token data') + end + log(object.getName() .. '[' .. object.getDescription() .. ']' .. ' : ' .. data['tokenType'] .. ' : ' .. data['tokenCount']) + spawnTokenGroup(object, data['tokenType'], data['tokenCount']) + markSpawned(object) +end + +function resetSpawnState() + local zone = getObjectFromGUID(zoneID) + if zone == nil then return end + + for i,object in ipairs(zone.getObjects()) do + if object.tag == "Card" then + local guid = object.getGUID() + if guid ~= nil then unmarkSpawned(guid, true) end + elseif object.tag == "Deck" then + local cards = object.getObjects() + if (cards ~= nil) then + for i,v in ipairs(cards) do + if v.guid ~= nil then unmarkSpawned(v.guid) end + end + end + end + end +end + +function unmarkSpawned(guid, force) + if not force and getObjectFromGUID(guid) ~= nil then return end + DATA_HELPER.call('setSpawnedPlayerCardGuid', {guid, false}) +end + +function onCollisionEnter(collision_info) + if not COLLISION_ENABLED then + return + end + + local object = collision_info.collision_object + Wait.time(resetSpawnState, 1) + -- anything to the left of this is legal to spawn + local discardSpawnBoundary = self.positionToWorld({-1.2, 0, 0}) + local boundaryLocalToCard = object.positionToLocal(discardSpawnBoundary) + if boundaryLocalToCard.x > 0 then + log('not checking for token spawn, boundary relative is ' .. boundaryLocalToCard.x) + return + end + if not object.is_face_down and shouldSpawnTokens(object) then + spawnTokensFor(object) + end +end + +-- functions delegated to Global +function drawChaostokenButton(object, player, isRightClick) + -- local toPosition = self.positionToWorld(DRAWN_CHAOS_TOKEN_OFFSET) + Global.call("drawChaostoken", {self, DRAWN_CHAOS_TOKEN_OFFSET, isRightClick}) +end + +function drawEncountercard(object, player, isRightClick) +local toPosition = self.positionToWorld(DRAWN_ENCOUNTER_CARD_OFFSET) +Global.call("drawEncountercard", {toPosition, self.getRotation(), isRightClick}) +end + +function spawnToken(position, tokenType) + Global.call('spawnToken', {position, tokenType}) +end + +function updatePlayerCards(args) + local custom_data_helper = getObjectFromGUID(args[1]) + data_player_cards = custom_data_helper.getTable("PLAYER_CARD_DATA") + for k, v in pairs(data_player_cards) do + PLAYER_CARDS[k] = v + end +end diff --git a/src/playermat/ResourceTracker.ttslua b/src/playermat/ResourceTracker.ttslua new file mode 100644 index 00000000..12817f1c --- /dev/null +++ b/src/playermat/ResourceTracker.ttslua @@ -0,0 +1,132 @@ +MIN_VALUE = -99 +MAX_VALUE = 999 + +function onload(saved_data) + light_mode = true + val = 0 + + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + light_mode = loaded_data[1] + val = loaded_data[2] + end + + createAll() +end + +function updateSave() + local data_to_save = {light_mode, val} + saved_data = JSON.encode(data_to_save) + self.script_state = saved_data +end + +function createAll() + s_color = {0,0,0,100} + + if light_mode then + f_color = {1,1,1,100} + else + f_color = {0,0,0,100} + end + + + + self.createButton({ + label=tostring(val), + click_function="add_subtract", + function_owner=self, + position={0,0.05,0.1}, + height=600, + width=1000, + alignment = 3, + scale={x=1.5, y=1.5, z=1.5}, + font_size=600, + font_color=f_color, + color={1,1,1,0} + }) + + + + + if light_mode then + lightButtonText = "[ Set dark ]" + else + lightButtonText = "[ Set light ]" + end + +end + +function removeAll() + self.removeInput(0) + self.removeInput(1) + self.removeButton(0) + self.removeButton(1) + self.removeButton(2) +end + +function reloadAll() + removeAll() + createAll() + + updateSave() +end + +function swap_fcolor(_obj, _color, alt_click) + light_mode = not light_mode + reloadAll() +end + +function swap_align(_obj, _color, alt_click) + center_mode = not center_mode + reloadAll() +end + +function editName(_obj, _string, value) + self.setName(value) + setTooltips() +end + +function add_subtract(_obj, _color, alt_click) + mod = alt_click and -1 or 1 + new_value = math.min(math.max(val + mod, MIN_VALUE), MAX_VALUE) + if val ~= new_value then + val = new_value + updateVal() + updateSave() + end +end + +function updateVal() + + self.editButton({ + index = 0, + label = tostring(val), + + }) +end + +function reset_val() + val = 0 + updateVal() + updateSave() +end + +function setTooltips() + self.editInput({ + index = 0, + value = self.getName(), + tooltip = ttText + }) + self.editButton({ + index = 0, + value = tostring(val), + tooltip = ttText + }) +end + +function null() +end + +function keepSample(_obj, _string, value) + reloadAll() +end diff --git a/src/tokens/BlessCurseManager.ttslua b/src/tokens/BlessCurseManager.ttslua new file mode 100644 index 00000000..b8ad9578 --- /dev/null +++ b/src/tokens/BlessCurseManager.ttslua @@ -0,0 +1,336 @@ +BLESS_COLOR = { r=0.3, g=0.25, b=0.09 } +CURSE_COLOR = { r=0.2, g=0.08, b=0.24 } +MIN_VALUE = 1 +MAX_VALUE = 10 +IMAGE_URL = { + Bless = "http://cloud-3.steamusercontent.com/ugc/1655601092778627699/339FB716CB25CA6025C338F13AFDFD9AC6FA8356/", + Curse = "http://cloud-3.steamusercontent.com/ugc/1655601092778636039/2A25BD38E8C44701D80DD96BF0121DA21843672E/" +} + +function onload() + self.createButton({ + label="Add", + click_function="addBlessToken", + function_owner=self, + position={-2.3,0.1,-0.5}, + height=150, + width=300, + scale={x=1.75, y=1.75, z=1.75}, + font_size=100, + font_color={ r=1, g=1, b=1 }, + color=BLESS_COLOR + }) + + self.createButton({ + label="Remove", + click_function="removeBlessToken", + function_owner=self, + position={-0.9,0.1,-0.5}, + height=150, + width=450, + scale={x=1.75, y=1.75, z=1.75}, + font_size=100, + font_color={ r=1, g=1, b=1 }, + color=BLESS_COLOR + }) + + self.createButton({ + label="Take", + click_function="takeBlessToken", + function_owner=self, + position={0.7,0.1,-0.5}, + height=150, + width=350, + scale={x=1.75, y=1.75, z=1.75}, + font_size=100, + font_color={ r=1, g=1, b=1 }, + color=BLESS_COLOR + }) + + self.createButton({ + label="Return", + click_function="returnBlessToken", + function_owner=self, + position={2.1,0.1,-0.5}, + height=150, + width=400, + scale={x=1.75, y=1.75, z=1.75}, + font_size=100, + font_color={ r=1, g=1, b=1 }, + color=BLESS_COLOR + }) + + self.createButton({ + label="Add", + click_function="addCurseToken", + function_owner=self, + position={-2.3,0.1,0.5}, + height=150, + width=300, + scale={x=1.75, y=1.75, z=1.75}, + font_size=100, + font_color={ r=1, g=1, b=1 }, + color=CURSE_COLOR + }) + + self.createButton({ + label="Remove", + click_function="removeCurseToken", + function_owner=self, + position={-0.9,0.1,0.5}, + height=150, + width=450, + scale={x=1.75, y=1.75, z=1.75}, + font_size=100, + font_color={ r=1, g=1, b=1 }, + color=CURSE_COLOR + }) + + self.createButton({ + label="Take", + click_function="takeCurseToken", + function_owner=self, + position={0.7,0.1,0.5}, + height=150, + width=350, + scale={x=1.75, y=1.75, z=1.75}, + font_size=100, + font_color={ r=1, g=1, b=1 }, + color=CURSE_COLOR + }) + + self.createButton({ + label="Return", + click_function="returnCurseToken", + function_owner=self, + position={2.1,0.1,0.5}, + height=150, + width=400, + scale={x=1.75, y=1.75, z=1.75}, + font_size=100, + font_color={ r=1, g=1, b=1 }, + color=CURSE_COLOR + }) + + self.createButton({ + label="Reset", click_function="doReset", function_owner=self, + position={0,0.3,1.8}, rotation={0,0,0}, height=350, width=800, + font_size=250, color={0,0,0}, font_color={1,1,1} + }) + + numInPlay = { Bless=0, Curse=0 } + tokensTaken = { Bless={}, Curse={} } + Wait.time(initializeState, 1) + + addHotkey("Bless Curse Status", printStatus, false) +end + +function initializeState() + playerColor = "White" + -- count tokens in the bag + local chaosbag = getChaosBag() + if chaosbag == nil then return end + local tokens = {} + for i,v in ipairs(chaosbag.getObjects()) do + if v.name == "Bless" then + numInPlay.Bless = numInPlay.Bless + 1 + elseif v.name == "Curse" then + numInPlay.Curse = numInPlay.Curse + 1 + end + end + + -- find tokens in the play area + local objs = Physics.cast({ + origin = { x=-33, y=0, z=0.5 }, + direction = { x=0, y=1, z=0 }, + type = 3, + size = { x=77, y=5, z=77 }, + orientation = { x=0, y=90, z=0 } + }) + + for i,v in ipairs(objs) do + local obj = v.hit_object + if obj.getName() == "Bless" then + table.insert(tokensTaken.Bless, obj.getGUID()) + numInPlay.Bless = numInPlay.Bless + 1 + elseif obj.getName() == "Curse" then + table.insert(tokensTaken.Curse, obj.getGUID()) + numInPlay.Curse = numInPlay.Curse + 1 + end + end + + mode = "Bless" + print("Bless Tokens " .. getTokenCount()) + mode = "Curse" + print("Curse Tokens " .. getTokenCount()) +end + +function printStatus(player_color, hovered_object, world_position, key_down_up) + mode = "Curse" + broadcastToColor("Curse Tokens " .. getTokenCount(), player_color) + mode = "Bless" + broadcastToColor("Bless Tokens " .. getTokenCount(), player_color) +end + +function doReset(_obj, _color, alt_click) + playerColor = _color + numInPlay = { Bless=0, Curse=0 } + tokensTaken = { Bless={}, Curse={} } + initializeState() +end + +function addBlessToken(_obj, _color, alt_click) + addToken("Bless", _color) +end + +function addCurseToken(_obj, _color, alt_click) + addToken("Curse", _color) +end + +function addToken(type, _color) + if numInPlay[type] == MAX_VALUE then + printToColor(MAX_VALUE .. " tokens already in play, not adding any", _color) + else + mode = type + spawnToken() + end +end + +function spawnToken() + local pos = getChaosBagPosition() + if pos == nil then return end + local url = IMAGE_URL[mode] + local obj = spawnObject({ + type = 'Custom_Tile', + position = {pos.x, pos.y + 3, pos.z}, + rotation = {x = 0, y = 260, z = 0}, + callback_function = spawn_callback + }) + obj.setCustomObject({ + type = 2, + image = url, + thickness = 0.10, + }) + obj.scale {0.81, 1, 0.81} + return obj +end + +function spawn_callback(obj) + obj.setName(mode) + local guid = obj.getGUID() + numInPlay[mode] = numInPlay[mode] + 1 + printToAll("Adding " .. mode .. " token " .. getTokenCount()) +end + +function removeBlessToken(_obj, _color, alt_click) + takeToken("Bless", _color, true) +end + +function removeCurseToken(_obj, _color, alt_click) + takeToken("Curse", _color, true) +end + +function takeBlessToken(_obj, _color, alt_click) + takeToken("Bless", _color, false) +end + +function takeCurseToken(_obj, _color, alt_click) + takeToken("Curse", _color, false) +end + +function takeToken(type, _color, remove) + playerColor = _color + local chaosbag = getChaosBag() + if chaosbag == nil then return end + local tokens = {} + for i,v in ipairs(chaosbag.getObjects()) do + if v.name == type then + table.insert(tokens, v.guid) + end + end + if #tokens == 0 then + printToColor("No " .. type .. " tokens in the chaos bag", _color) + return + end + local pos = self.getPosition() + local callback = take_callback + if remove then + callback = remove_callback + num = removeNum + end + local guid = table.remove(tokens) + mode = type + chaosbag.takeObject({ + guid = guid, + position = {pos.x-2, pos.y, pos.z}, + smooth = false, + callback_function = callback + }) +end + +function remove_callback(obj) + take_callback(obj, true) +end + +function take_callback(obj, remove) + local guid = obj.getGUID() + if remove then + numInPlay[mode] = numInPlay[mode] - 1 + printToAll("Removing " .. mode .. " token " .. getTokenCount()) + obj.destruct() + else + table.insert(tokensTaken[mode], guid) + printToAll("Taking " .. mode .. " token " .. getTokenCount()) + end +end + +function returnBlessToken(_obj, _color, alt_click) + returnToken("Bless", _color) +end + +function returnCurseToken(_obj, _color, alt_click) + returnToken("Curse", _color) +end + +function returnToken(type, _color) + mode = type + local guid = table.remove(tokensTaken[type]) + if guid == nil then + printToColor("No " .. mode .. " tokens to return", _color) + return + end + local token = getObjectFromGUID(guid) + if token == nil then + printToColor("Couldn't find token " .. guid .. ", not returning to bag", _color) + return + end + playerColor = _color + local chaosbag = getChaosBag() + if chaosbag == nil then return end + chaosbag.putObject(token) + printToAll("Returning " .. type .. " token " .. getTokenCount()) +end + +function getChaosBag() + local items = getObjectFromGUID("83ef06").getObjects() + local chaosbag = nil + for i,v in ipairs(items) do + if v.getDescription() == "Chaos Bag" then + chaosbag = getObjectFromGUID(v.getGUID()) + break + end + end + if chaosbag == nil then printToColor("No chaos bag found", playerColor) end + return chaosbag +end + +function getChaosBagPosition() + local chaosbag = getChaosBag() + if chaosbag == nil then return nil end + return chaosbag.getPosition() +end + +function getTokenCount() + return "(" .. (numInPlay[mode] - #tokensTaken[mode]) .. "/" .. + #tokensTaken[mode] .. ")" +end diff --git a/src/util/ClueCounterSwapper.ttslua b/src/util/ClueCounterSwapper.ttslua new file mode 100644 index 00000000..f932fb96 --- /dev/null +++ b/src/util/ClueCounterSwapper.ttslua @@ -0,0 +1,273 @@ +function updateSave() + local data_to_save = {["ml"]=memoryList} + saved_data = JSON.encode(data_to_save) + self.script_state = saved_data +end + +function onload(saved_data) + if saved_data ~= "" then + local loaded_data = JSON.decode(saved_data) + --Set up information off of loaded_data + memoryList = loaded_data.ml + else + --Set up information for if there is no saved saved data + memoryList = {} + end + + if next(memoryList) == nil then + createSetupButton() + else + createMemoryActionButtons() + end +end + + +--Beginning Setup + + +--Make setup button +function createSetupButton() + self.createButton({ + label="Setup", click_function="buttonClick_setup", function_owner=self, + position={0,5,-2}, rotation={0,0,0}, height=250, width=600, + font_size=150, color={0,0,0}, font_color={1,1,1} + }) +end + +--Triggered by setup button, +function buttonClick_setup() + memoryListBackup = duplicateTable(memoryList) + memoryList = {} + self.clearButtons() + createButtonsOnAllObjects() + createSetupActionButtons() +end + +--Creates selection buttons on objects +function createButtonsOnAllObjects() + local howManyButtons = 0 + for _, obj in ipairs(getAllObjects()) do + if obj ~= self then + local dummyIndex = howManyButtons + --On a normal bag, the button positions aren't the same size as the bag. + globalScaleFactor = 1.25 * 1/self.getScale().x + --Super sweet math to set button positions + local selfPos = self.getPosition() + local objPos = obj.getPosition() + local deltaPos = findOffsetDistance(selfPos, objPos, obj) + local objPos = rotateLocalCoordinates(deltaPos, self) + objPos.x = -objPos.x * globalScaleFactor + objPos.y = objPos.y * globalScaleFactor + objPos.z = objPos.z * 4 + --Offset rotation of bag + local rot = self.getRotation() + rot.y = -rot.y + 180 + --Create function + local funcName = "selectButton_" .. howManyButtons + local func = function() buttonClick_selection(dummyIndex, obj) end + self.setVar(funcName, func) + self.createButton({ + click_function=funcName, function_owner=self, + position=objPos, rotation=rot, height=1000, width=1000, + color={0.75,0.25,0.25,0.6}, + }) + howManyButtons = howManyButtons + 1 + end + end +end + +--Creates submit and cancel buttons +function createSetupActionButtons() + self.createButton({ + label="Cancel", click_function="buttonClick_cancel", function_owner=self, + position={1.5,5,2}, rotation={0,0,0}, height=350, width=1100, + font_size=250, color={0,0,0}, font_color={1,1,1} + }) + self.createButton({ + label="Submit", click_function="buttonClick_submit", function_owner=self, + position={-1.2,5,2}, rotation={0,0,0}, height=350, width=1100, + font_size=250, color={0,0,0}, font_color={1,1,1} + }) + self.createButton({ + label="Reset", click_function="buttonClick_reset", function_owner=self, + position={-3.5,5,2}, rotation={0,0,0}, height=350, width=800, + font_size=250, color={0,0,0}, font_color={1,1,1} + }) +end + + +--During Setup + + +--Checks or unchecks buttons +function buttonClick_selection(index, obj) + local color = {0,1,0,0.6} + if memoryList[obj.getGUID()] == nil then + self.editButton({index=index, color=color}) + --Adding pos/rot to memory table + local pos, rot = obj.getPosition(), obj.getRotation() + --I need to add it like this or it won't save due to indexing issue + memoryList[obj.getGUID()] = { + pos={x=round(pos.x,4), y=round(pos.y,4), z=round(pos.z,4)}, + rot={x=round(rot.x,4), y=round(rot.y,4), z=round(rot.z,4)}, + lock=obj.getLock() + } + obj.highlightOn({0,1,0}) + else + color = {0.75,0.25,0.25,0.6} + self.editButton({index=index, color=color}) + memoryList[obj.getGUID()] = nil + obj.highlightOff() + end +end + +--Cancels selection process +function buttonClick_cancel() + memoryList = memoryListBackup + self.clearButtons() + if next(memoryList) == nil then + createSetupButton() + else + createMemoryActionButtons() + end + removeAllHighlights() + broadcastToAll("Selection Canceled", {1,1,1}) +end + +--Saves selections +function buttonClick_submit() + if next(memoryList) == nil then + broadcastToAll("You cannot submit without any selections.", {0.75, 0.25, 0.25}) + else + self.clearButtons() + createMemoryActionButtons() + local count = 0 + for guid in pairs(memoryList) do + count = count + 1 + local obj = getObjectFromGUID(guid) + if obj ~= nil then obj.highlightOff() end + end + broadcastToAll(count.." Objects Saved", {1,1,1}) + updateSave() + end +end + +--Resets bag to starting status +function buttonClick_reset() + memoryList = {} + self.clearButtons() + createSetupButton() + removeAllHighlights() + broadcastToAll("Tool Reset", {1,1,1}) + updateSave() +end + + +--After Setup + + +--Creates recall and place buttons +function createMemoryActionButtons() + self.createButton({ + label="Clicker", click_function="buttonClick_place", function_owner=self, + position={4.2,1,0}, rotation={0,0,0}, height=500, width=1100, + font_size=350, color={0,0,0}, font_color={1,1,1} + }) + self.createButton({ + label="Counter", click_function="buttonClick_recall", function_owner=self, + position={-4.2,1,-0.1}, rotation={0,0,0}, height=500, width=1300, + font_size=350, color={0,0,0}, font_color={1,1,1} + }) +-- self.createButton({ +-- label="Setup", click_function="buttonClick_setup", function_owner=self, +-- position={-6,1,0}, rotation={0,90,0}, height=500, width=1200, +-- font_size=350, color={0,0,0}, font_color={1,1,1} +-- }) +end + +--Sends objects from bag/table to their saved position/rotation +function buttonClick_place() + local bagObjList = self.getObjects() + for guid, entry in pairs(memoryList) do + local obj = getObjectFromGUID(guid) + --If obj is out on the table, move it to the saved pos/rot + if obj ~= nil then + obj.setPositionSmooth(entry.pos) + obj.setRotationSmooth(entry.rot) + obj.setLock(entry.lock) + else + --If obj is inside of the bag + for _, bagObj in ipairs(bagObjList) do + if bagObj.guid == guid then + local item = self.takeObject({ + guid=guid, position=entry.pos, rotation=entry.rot, + }) + item.setLock(entry.lock) + break + end + end + end + end + broadcastToAll("Objects Placed", {1,1,1}) +end + +--Recalls objects to bag from table +function buttonClick_recall() + for guid, entry in pairs(memoryList) do + local obj = getObjectFromGUID(guid) + if obj ~= nil then self.putObject(obj) end + end + broadcastToAll("Objects Recalled", {1,1,1}) +end + + +--Utility functions + + +--Find delta (difference) between 2 x/y/z coordinates +function findOffsetDistance(p1, p2, obj) + local deltaPos = {} + local bounds = obj.getBounds() + deltaPos.x = (p2.x-p1.x) + deltaPos.y = (p2.y-p1.y) + (bounds.size.y - bounds.offset.y) + deltaPos.z = (p2.z-p1.z) + return deltaPos +end + +--Used to rotate a set of coordinates by an angle +function rotateLocalCoordinates(desiredPos, obj) + local objPos, objRot = obj.getPosition(), obj.getRotation() + local angle = math.rad(objRot.y) + local x = desiredPos.x * math.cos(angle) - desiredPos.z * math.sin(angle) + local z = desiredPos.x * math.sin(angle) + desiredPos.z * math.cos(angle) + --return {x=objPos.x+x, y=objPos.y+desiredPos.y, z=objPos.z+z} + return {x=x, y=desiredPos.y, z=z} +end + +--Coroutine delay, in seconds +function wait(time) + local start = os.time() + repeat coroutine.yield(0) until os.time() > start + time +end + +--Duplicates a table (needed to prevent it making reference to the same objects) +function duplicateTable(oldTable) + local newTable = {} + for k, v in pairs(oldTable) do + newTable[k] = v + end + return newTable +end + +--Moves scripted highlight from all objects +function removeAllHighlights() + for _, obj in ipairs(getAllObjects()) do + obj.highlightOff() + end +end + +--Round number (num) to the Nth decimal (dec) +function round(num, dec) + local mult = 10^(dec or 0) + return math.floor(num * mult + 0.5) / mult +end diff --git a/src/util/HandSizeCounter.ttslua b/src/util/HandSizeCounter.ttslua new file mode 100644 index 00000000..7f8a5f40 --- /dev/null +++ b/src/util/HandSizeCounter.ttslua @@ -0,0 +1,71 @@ +function onload(save_state) + val = 0 + playerColor = "Orange" + if save_state ~= nil then + local obj = JSON.decode(save_state) + if obj ~= nil and obj.playerColor ~= nil then + playerColor = obj.playerColor + end + end + des = false + loopId = Wait.time(|| updateValue(), 1, -1) + self.addContextMenuItem("Bind to my color", bindColor) +end + +function bindColor(player_color) + playerColor = player_color + self.setName(player_color .. " Hand Size Counter") +end + +function onSave() + return JSON.encode({ playerColor = playerColor }) +end + +function onHover(player_color) + if not (player_color == playerColor) then return end + Wait.stop(loopId) + des = not des + updateValue() + des = not des + loopId = Wait.time(|| updateValue(), 1, -1) +end + +function updateDES(player, value, id) + if (value == "True") then des = true + else des = false + end + updateValue() +end + +function updateValue() + local hand = Player[playerColor].getHandObjects() + local size = 0 + + if (des) then + self.UI.setAttribute("handSize", "color", "#00FF00") + -- count by name for Dream Enhancing Serum + local cardHash = {} + for key, obj in pairs(hand) do + if obj != nil and obj.tag == "Card" then + local name = obj.getName() + local title, xp = string.match(name, '(.+)(%s%(%d+%))') + if title ~= nil then name = title end + cardHash[name] = obj + end + end + for key, obj in pairs(cardHash) do + size = size + 1 + end + else + self.UI.setAttribute("handSize", "color", "#FFFFFF") + -- otherwise count individually + for key, obj in pairs(hand) do + if obj != nil and obj.tag == "Card" then + size = size + 1 + end + end + end + + val = size + self.UI.setValue("handSize", val) +end diff --git a/src/util/HandSizeCounter.xml b/src/util/HandSizeCounter.xml new file mode 100644 index 00000000..4c812548 --- /dev/null +++ b/src/util/HandSizeCounter.xml @@ -0,0 +1,13 @@ + + + + + + + + ? + + + DES + + diff --git a/src/util/StatTracker.ttslua b/src/util/StatTracker.ttslua new file mode 100644 index 00000000..e69de29b diff --git a/src/util/TokenRemover.ttslua b/src/util/TokenRemover.ttslua new file mode 100644 index 00000000..9ed755f9 --- /dev/null +++ b/src/util/TokenRemover.ttslua @@ -0,0 +1,72 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-02-02 9:41 a.m. +--- + +local zone = nil + +-- Forward Declaration +---@param is_enabled boolean +local setMenu = function(is_enabled) end + +local function enable() + if self.held_by_color~=nil then return end + local position = self:getPosition() + local rotation = self:getRotation() + local scale = self:getScale() + + zone = spawnObject { + type = "ScriptingTrigger", + position = Vector(position.x, position.y+25+(bit32.rshift(scale.y, 1))+0.41, position.z), + rotation = rotation, + scale = Vector(scale.x*2, 50, scale.z*2), + sound = true, + snap_to_grid = true + } + + setMenu(false) +end + +local function disable() + if zone~=nil then zone:destruct() end + setMenu(true) +end + +---@param is_enabled boolean +setMenu = function(is_enabled) + self:clearContextMenu() + if is_enabled then + self:addContextMenuItem("Enable", enable, false) + else + self:addContextMenuItem("Disable", disable, false) + end +end + +function onLoad(save_state) + if save_state=="" then return end + local data = JSON.decode(save_state) + zone = getObjectFromGUID(data.zone) + setMenu(zone==nil) +end + +function onSave() + return JSON.encode { + zone = zone and zone:getGUID() or nil + } +end + +---@param entering TTSObject +---@param object TTSObject +function onObjectEnterScriptingZone(entering , object) + if zone~=entering then return end + if object==self then return end + if object.type=="Deck" or object.type=="Card" then return end + + object:destruct() +end + +---@param color string +function onPickUp(color) + disable() +end diff --git a/src/util/TokenSpawner.ttslua b/src/util/TokenSpawner.ttslua new file mode 100644 index 00000000..76843812 --- /dev/null +++ b/src/util/TokenSpawner.ttslua @@ -0,0 +1,92 @@ +--- +--- Generated by EmmyLua(https://github.com/EmmyLua) +--- Created by Whimsical. +--- DateTime: 2021-01-14 1:10 a.m. +--- +local TILE_TYPE_CIRCLE = 2 + +local HEIGHT_ADJUSTMENT = 1.0 + +local VALID_INDEX = {false, false, false, true, true, true, true, true, true} + +local INDEX_DAMAGE = 4 +local INDEX_PATH = 5 +local INDEX_HORROR = 6 +local INDEX_CLUE = 8 +local INDEX_DOOM = 7 +local INDEX_RESOURCE = 9 + +local TOKEN_CLUE = "http://cloud-3.steamusercontent.com/ugc/1758068501357164917/1D06F1DC4D6888B6F57124BD2AFE20D0B0DA15A8/" +local TOKEN_DOOM = "https://i.imgur.com/EoL7yaZ.png" +local TOKEN_RESOURCE = "http://cloud-3.steamusercontent.com/ugc/1758068501357192910/11DDDC7EF621320962FDCF3AE3211D5EDC3D1573/" + +local CLOOM_SCALE = Vector(0.25, 1, 0.25) +local RESOURCE_SCALE = Vector(0.17, 0.17, 0.17) +local PATH_SCALE = Vector(1,1,1) + +local DAMAGE_JSON = "{\"GUID\":\"142b55\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-38.6177826,\"posY\":1.688475,\"posZ\":10.7887154,\"rotX\":359.9204,\"rotY\":270.009583,\"rotZ\":0.0172974449,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357115146/903D11AAE7BD5C254C8DC136E9202EE516289DEA/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\",\"States\":{\"2\":{\"GUID\":\"c6ddbe\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-28.3444748,\"posY\":1.112169,\"posZ\":19.0119534,\"rotX\":0.000318417122,\"rotY\":270.007721,\"rotZ\":359.99176,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357113699/2929CC7461A8A6C464203FF768A7A5A22650E337/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"3\":{\"GUID\":\"a0f2a0\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-28.3444748,\"posY\":1.11216891,\"posZ\":19.0119534,\"rotX\":0.0003119017,\"rotY\":270.007874,\"rotZ\":359.99173,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357113055/8A45F27B2838FED09DEFE492C9C40DD82781613A/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"4\":{\"GUID\":\"24c940\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-28.3444748,\"posY\":1.11216891,\"posZ\":19.0119534,\"rotX\":0.0003254045,\"rotY\":270.008026,\"rotZ\":359.99173,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357112812/BCCAAB919EBE76E2B770417B0B06A699E9F4C8D0/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"5\":{\"GUID\":\"ec79a1\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-28.3444748,\"posY\":1.11216879,\"posZ\":19.0119534,\"rotX\":0.000309352879,\"rotY\":270.008,\"rotZ\":359.99173,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357112513/3D68F6F7D7A1B81C2E89AFBC948FD9C4395908F1/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"6\":{\"GUID\":\"afe500\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-28.3444729,\"posY\":1.11216879,\"posZ\":19.0119514,\"rotX\":0.00031043886,\"rotY\":270.008423,\"rotZ\":359.99176,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357112217/E941CBCED5D8D42431FD29A53CE74ECF0FBB4BFB/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"7\":{\"GUID\":\"c7cbd1\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-28.3444729,\"posY\":1.11216891,\"posZ\":19.0119514,\"rotX\":0.000295429461,\"rotY\":270.00824,\"rotZ\":359.99176,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357110928/44F5CF8F9BC4D54D47D450A807560D8A1F2A1769/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"8\":{\"GUID\":\"67b357\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-28.3444729,\"posY\":1.11216891,\"posZ\":19.01195,\"rotX\":0.000306701084,\"rotY\":270.0082,\"rotZ\":359.99173,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357110576/7222C0B6E628D08F828F1FA686EB65E0B83B3B54/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"9\":{\"GUID\":\"582a00\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-28.2681675,\"posY\":1.21000624,\"posZ\":14.044548,\"rotX\":-0.0000358944635,\"rotY\":270.006958,\"rotZ\":-0.00000148946117,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Damage\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357114084/33093C666B9F4530D64B0117605494D5D17B38CC/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"}}}" +local HORROR_JSON = "{\"GUID\":\"36be72\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-44.08369,\"posY\":1.69583237,\"posZ\":9.886347,\"rotX\":359.9201,\"rotY\":270.008972,\"rotZ\":0.0168560985,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357163535/6D9E0756503664D65BDB384656AC6D4BD713F5FC/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\",\"States\":{\"2\":{\"GUID\":\"5c2361\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-27.7533741,\"posY\":1.210006,\"posZ\":15.9442654,\"rotX\":-0.0000202706469,\"rotY\":270.015259,\"rotZ\":0.0000220759175,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357163230/ED46F8BBAEDB4D3C96C654D48C56110D35F3F54F/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"3\":{\"GUID\":\"3a3415\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-27.7533741,\"posY\":1.210006,\"posZ\":15.9442368,\"rotX\":-0.0000104253941,\"rotY\":270.015137,\"rotZ\":0.0000102804506,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357162977/E5D453CC14394519E004B4F8703FC425A7AE3D6C/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"4\":{\"GUID\":\"4a91a8\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-27.7533741,\"posY\":1.21000612,\"posZ\":15.9442348,\"rotX\":0.00000189065361,\"rotY\":270.01532,\"rotZ\":0.0000159575811,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357164483/5E22FEAE253AE65BDE3FA09E4EE7133569F7E194/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"5\":{\"GUID\":\"887aae\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-27.7533741,\"posY\":1.21000612,\"posZ\":15.9442024,\"rotX\":-0.00003432232,\"rotY\":270.016,\"rotZ\":-0.00000373151761,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357164251/34DC7172A2B433047DA853796DB52AECE019F99F/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"6\":{\"GUID\":\"baa831\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-27.7533741,\"posY\":1.21000624,\"posZ\":15.94415,\"rotX\":-0.0000249414188,\"rotY\":270.0157,\"rotZ\":0.00000292043842,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357164030/0A12FD352F28A560EA7E7952D8CA618A5245F1E0/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"7\":{\"GUID\":\"da94da\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-27.7533741,\"posY\":1.21000612,\"posZ\":15.944108,\"rotX\":-0.0000233948358,\"rotY\":270.015656,\"rotZ\":0.00000218774017,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357114485/8B2B8A9F61CC2D8C1F10977ABDB4BA2423AD143F/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"8\":{\"GUID\":\"2e1687\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-27.753376,\"posY\":1.210006,\"posZ\":15.9440966,\"rotX\":-1.57902083e-8,\"rotY\":270.016541,\"rotZ\":-0.0000219759459,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357163806/F397C22A8DDB8F22E08E42E6449C3B5D8CFDB313/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"},\"9\":{\"GUID\":\"a6f1e0\",\"Name\":\"Custom_Token\",\"Transform\":{\"posX\":-27.753376,\"posY\":1.21000612,\"posZ\":15.9440622,\"rotX\":-0.0000209277514,\"rotY\":270.016724,\"rotZ\":0.00004970206,\"scaleX\":0.25,\"scaleY\":1,\"scaleZ\":0.25},\"Nickname\":\"Horror\",\"Description\":\"\",\"GMNotes\":\"\",\"ColorDiffuse\":{\"r\":1,\"g\":1,\"b\":1},\"LayoutGroupSortIndex\":0,\"Locked\":false,\"Grid\":false,\"Snap\":false,\"IgnoreFoW\":false,\"MeasureMovement\":false,\"DragSelectable\":true,\"Autoraise\":true,\"Sticky\":true,\"Tooltip\":true,\"GridProjection\":false,\"HideWhenFaceDown\":false,\"Hands\":false,\"CustomImage\":{\"ImageURL\":\"http://cloud-3.steamusercontent.com/ugc/1758068501357110165/AD791E6817304851C0ABD7AE97AA60326AC14538/\",\"ImageSecondaryURL\":\"\",\"ImageScalar\":1,\"WidthScale\":0,\"CustomToken\":{\"Thickness\":0.1,\"MergeDistancePixels\":5,\"StandUp\":false,\"Stackable\":false}},\"LuaScript\":\"\",\"LuaScriptState\":\"\",\"XmlUI\":\"\"}}}" +local PATH_JSON = "{\"GUID\": \"7234af\",\"Name\": \"Custom_Tile\",\"Transform\": {\"posX\": -50.92423,\"posY\": 1.63760316,\"posZ\": 11.0779743,\"rotX\": 359.9201,\"rotY\": 270.00946,\"rotZ\": 0.0168931335,\"scaleX\": 1.0,\"scaleY\": 1.0,\"scaleZ\": 1.0},\"Nickname\": \"\",\"Description\": \"\",\"GMNotes\": \"\",\"ColorDiffuse\": {\"r\": 0.6045295,\"g\": 0.6045295,\"b\": 0.6045295},\"LayoutGroupSortIndex\": 0,\"Locked\": false,\"Grid\": true,\"Snap\": true,\"IgnoreFoW\": false,\"MeasureMovement\": false,\"DragSelectable\": true,\"Autoraise\": true,\"Sticky\": true,\"Tooltip\": true,\"GridProjection\": false,\"HideWhenFaceDown\": false,\"Hands\": false,\"CustomImage\": {\"ImageURL\": \"https://i.imgur.com/vppt2my.png\",\"ImageSecondaryURL\": \"https://i.imgur.com/vppt2my.png\",\"ImageScalar\": 1.0,\"WidthScale\": 0.0,\"CustomTile\": {\"Type\": 3,\"Thickness\": 0.1,\"Stackable\": false,\"Stretch\": true}},\"LuaScript\": \"\",\"LuaScriptState\": \"\",\"XmlUI\": \"\",\"States\": {\"2\": {\"GUID\": \"44b0c5\",\"Name\": \"Custom_Tile\",\"Transform\": {\"posX\": -39.7933121,\"posY\": 1.63758957,\"posZ\": 2.038383,\"rotX\": 359.9201,\"rotY\": 269.9961,\"rotZ\": 0.0168742146,\"scaleX\": 1.0,\"scaleY\": 1.0,\"scaleZ\": 1.0},\"Nickname\": \"\",\"Description\": \"\",\"GMNotes\": \"\",\"ColorDiffuse\": {\"r\": 0.6045295,\"g\": 0.6045295,\"b\": 0.6045295},\"LayoutGroupSortIndex\": 0,\"Locked\": false,\"Grid\": true,\"Snap\": true,\"IgnoreFoW\": false,\"MeasureMovement\": false,\"DragSelectable\": true,\"Autoraise\": true,\"Sticky\": true,\"Tooltip\": true,\"GridProjection\": false,\"HideWhenFaceDown\": false,\"Hands\": false,\"CustomImage\": {\"ImageURL\": \"https://i.imgur.com/HyfE8m8.png\",\"ImageSecondaryURL\": \"https://i.imgur.com/HyfE8m8.png\",\"ImageScalar\": 1.0,\"WidthScale\": 0.0,\"CustomTile\": {\"Type\": 3,\"Thickness\": 0.1,\"Stackable\": false,\"Stretch\": true}},\"LuaScript\": \"\",\"LuaScriptState\": \"\",\"XmlUI\": \"\"},\"3\": {\"GUID\": \"5b38c6\",\"Name\": \"Custom_Tile\",\"Transform\": {\"posX\": -38.8217163,\"posY\": 1.99356019,\"posZ\": 0.4159239,\"rotX\": 359.9201,\"rotY\": 272.9828,\"rotZ\": 0.01687373,\"scaleX\": 0.8,\"scaleY\": 1.0,\"scaleZ\": 0.8},\"Nickname\": \"\",\"Description\": \"\",\"GMNotes\": \"\",\"ColorDiffuse\": {\"r\": 0.6045295,\"g\": 0.6045295,\"b\": 0.6045295},\"LayoutGroupSortIndex\": 0,\"Locked\": false,\"Grid\": true,\"Snap\": true,\"IgnoreFoW\": false,\"MeasureMovement\": false,\"DragSelectable\": true,\"Autoraise\": true,\"Sticky\": true,\"Tooltip\": true,\"GridProjection\": false,\"HideWhenFaceDown\": false,\"Hands\": false,\"CustomImage\": {\"ImageURL\": \"https://i.imgur.com/dHKBLoD.png\",\"ImageSecondaryURL\": \"https://i.imgur.com/HyfE8m8.png\",\"ImageScalar\": 1.0,\"WidthScale\": 0.0,\"CustomTile\": {\"Type\": 3,\"Thickness\": 0.1,\"Stackable\": false,\"Stretch\": true}},\"LuaScript\": \"\",\"LuaScriptState\": \"\",\"XmlUI\": \"\"}}}" + +local OBJECT_JSON = {} +OBJECT_JSON[INDEX_DAMAGE] = DAMAGE_JSON +OBJECT_JSON[INDEX_HORROR] = HORROR_JSON +OBJECT_JSON[INDEX_PATH] = PATH_JSON + + +---@param index number +---@param player_color string +function onScriptingButtonDown(index, player_color) + if not VALID_INDEX[index] then return end + local isResource = index==INDEX_RESOURCE + ---@type Player + local player = Player[player_color] + local rotation = player:getPointerRotation() + local position = player:getPointerPosition() + position.y = position.y + HEIGHT_ADJUSTMENT + + ---@type SpawnObjectParams + local parameters = { + snap_to_grid = false, + position = position, + rotation = Vector(0, rotation, index==INDEX_DOOM and 180 or 0), + scale = isResource and RESOURCE_SCALE or (index==INDEX_PATH and PATH_SCALE or CLOOM_SCALE), + type = isResource and "Custom_Token" or "Custom_Tile", + sound = false, + ---@param thing TTSObject + callback_function = function (thing) + thing.use_snap_points = false + end + } + + if index<=INDEX_HORROR then + ---@type SpawnObjectParamsJSON + parameters = parameters + parameters.json = OBJECT_JSON[index] + spawnObjectJSON(parameters) + else + local object = spawnObject(parameters) + if index==INDEX_RESOURCE then makeResource(object) else makeCloom(object) end + end +end + +---@param object TTSObject +function makeCloom(object) + object:setCustomObject { + type = TILE_TYPE_CIRCLE, + image = TOKEN_CLUE, + image_bottom = TOKEN_DOOM, + stackable = true, + thickness = 0.1 + } +end + +---@param object TTSObject +function makeResource(object) + object:setCustomObject { + image = TOKEN_RESOURCE, + thickness = 0.3, + merge_distance = 5, + stackable = true + } +end