From fb4651a80dbc33f923088352870d0bd93d26ba09 Mon Sep 17 00:00:00 2001 From: UTDZac Date: Mon, 23 Sep 2024 17:09:02 -0700 Subject: [PATCH 01/26] Initial backend network support TODO: 1) UI for user to see connection status and configure settings, 2) implementation for EventData commands --- .gitignore | 3 + ironmon_tracker/Main.lua | 1 + ironmon_tracker/Program.lua | 9 +- ironmon_tracker/constants/Paths.lua | 1 + ironmon_tracker/network/EventData.lua | 1240 +++++++++++++++++ ironmon_tracker/network/EventHandler.lua | 814 +++++++++++ ironmon_tracker/network/Json.lua | 389 ++++++ ironmon_tracker/network/Network.lua | 440 ++++++ ironmon_tracker/network/RequestHandler.lua | 370 +++++ .../network/StreamerbotCodeImport.txt | 1 + .../network/Tracker-StreamerBot.cs | 785 +++++++++++ ironmon_tracker/utils/NetworkUtils.lua | 131 ++ 12 files changed, 4183 insertions(+), 1 deletion(-) create mode 100644 ironmon_tracker/network/EventData.lua create mode 100644 ironmon_tracker/network/EventHandler.lua create mode 100644 ironmon_tracker/network/Json.lua create mode 100644 ironmon_tracker/network/Network.lua create mode 100644 ironmon_tracker/network/RequestHandler.lua create mode 100644 ironmon_tracker/network/StreamerbotCodeImport.txt create mode 100644 ironmon_tracker/network/Tracker-StreamerBot.cs create mode 100644 ironmon_tracker/utils/NetworkUtils.lua diff --git a/.gitignore b/.gitignore index 2c3a621d..dae896f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ Settings.ini +NetworkSettings.ini +Requests.json *.trackerdata *.nds *.log @@ -8,6 +10,7 @@ Settings.ini *.colortheme *.qlp !death_quotes.txt +!StreamerbotCodeImport.txt *.pt *.faves *.tdata \ No newline at end of file diff --git a/ironmon_tracker/Main.lua b/ironmon_tracker/Main.lua index 84b27337..8113159a 100644 --- a/ironmon_tracker/Main.lua +++ b/ironmon_tracker/Main.lua @@ -44,6 +44,7 @@ local function Main() dofile(Paths.FOLDERS.UTILS_FOLDER .. "/ThemeFactory.lua") dofile(Paths.FOLDERS.UTILS_FOLDER .. "/HoverFrameFactory.lua") dofile(Paths.FOLDERS.UTILS_FOLDER .. "/FrameFactory.lua") + dofile(Paths.FOLDERS.UTILS_FOLDER .. "/NetworkUtils.lua") dofile(Paths.FOLDERS.DATA_FOLDER .. "/GameConfigurator.lua") dofile(Paths.FOLDERS.UTILS_FOLDER .. "/UIUtils.lua") dofile(Paths.FOLDERS.DATA_FOLDER .. "/RepelDrawer.lua") diff --git a/ironmon_tracker/Program.lua b/ironmon_tracker/Program.lua index 3e3ef38f..db653e7a 100644 --- a/ironmon_tracker/Program.lua +++ b/ironmon_tracker/Program.lua @@ -38,6 +38,7 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, dofile(Paths.FOLDERS.DATA_FOLDER .. "/BattleHandlerGen5.lua") local PokemonThemeManager = dofile(Paths.FOLDERS.DATA_FOLDER .. "/PokemonThemeManager.lua") local TourneyTracker = dofile(Paths.FOLDERS.DATA_FOLDER .. "/TourneyTracker.lua") + dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/Network.lua") self.SELECTED_PLAYERS = { PLAYER = 0, @@ -688,6 +689,7 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, function self.tryToInstallUpdate(callbackFunc) tracker.save(gameInfo.NAME) + Network.closeConnections() local success = trackerUpdater.downloadUpdate() if type(callbackFunc) == "function" then callbackFunc(success) @@ -959,6 +961,8 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, AnimatedSpriteManager.advanceFrame() end + Network.initialize(self) + frameCounters = { restorePointUpdate = FrameCounter(30, updateRestorePoints), memoryReading = FrameCounter(30, readMemory, nil, true), @@ -971,7 +975,9 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, nil, true ), - animatedSprites = FrameCounter(8, advanceAnimationFrame, nil, true) + animatedSprites = FrameCounter(8, advanceAnimationFrame, nil, true), + networkStartup = FrameCounter(30, Network.startup, nil, true), + networkUpdate = FrameCounter(10, Network.update, nil, true), } function self.pauseEventListeners() @@ -1049,6 +1055,7 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, tracker.updatePlaytime(gameInfo.NAME) client.saveram() forms.destroyall() + Network.closeConnections() end function self.getSeedLogger() diff --git a/ironmon_tracker/constants/Paths.lua b/ironmon_tracker/constants/Paths.lua index 7c8a5730..faa2b75a 100644 --- a/ironmon_tracker/constants/Paths.lua +++ b/ironmon_tracker/constants/Paths.lua @@ -13,4 +13,5 @@ Paths.FOLDERS = { POKEMON_ICONS_FOLDER = "ironmon_tracker/images/pokemonIconSets", BROWS_IMAGES_FOLDER = "ironmon_tracker/images/brows", EXTRAS_FOLDER = "ironmon_tracker/extras", + NETWORK_FOLDER = "ironmon_tracker/network", } \ No newline at end of file diff --git a/ironmon_tracker/network/EventData.lua b/ironmon_tracker/network/EventData.lua new file mode 100644 index 00000000..33645c1e --- /dev/null +++ b/ironmon_tracker/network/EventData.lua @@ -0,0 +1,1240 @@ +EventData = {} + +-- Helper Functions and Variables + +---Searches for a Pokémon by name, finds the best match; returns 0 if no good match +---@param name string? +---@param threshold number? Default threshold distance of 3 +---@return number pokemonId +local function findPokemonId(name, threshold) + if name == nil or name == "" then + return 0 + end + threshold = threshold or 3 + -- Format list of Pokemon as id, name pairs + local pokemonNames = {} + for id, pokemon in ipairs(PokemonData.POKEMON) do + if (pokemon.name ~= "---") then + pokemonNames[id - 1] = pokemon.name:lower() + end + end + -- Try and find a name match + local id, _ = NetworkUtils.getClosestWord(name:lower(), pokemonNames, threshold) + return id or 0 +end + +---Searches for a Move by name, finds the best match; returns 0 if no good match +---@param name string? +---@param threshold number? Default threshold distance of 3 +---@return number moveId +local function findMoveId(name, threshold) + if name == nil or name == "" then + return 0 + end + threshold = threshold or 3 + -- Format list of Moves as id, name pairs + local moveNames = {} + for id, move in ipairs(MoveData.MOVES) do + if (move.name ~= "---") then + moveNames[id - 1] = move.name:lower() + end + end + -- Try and find a name match + local id, _ = NetworkUtils.getClosestWord(name:lower(), moveNames, threshold) + return id or 0 +end + +---Searches for an Ability by name, finds the best match; returns 0 if no good match +---@param name string? +---@param threshold number? Default threshold distance of 3 +---@return number abilityId +local function findAbilityId(name, threshold) + if name == nil or name == "" then + return 0 + end + threshold = threshold or 3 + -- Format list of Abilities as id, name pairs + local abilityNames = {} + for id, ability in ipairs(AbilityData.ABILITIES) do + if (ability.name ~= "---") then + abilityNames[id - 1] = ability.name:lower() + end + end + -- Try and find a name match + local id, _ = NetworkUtils.getClosestWord(name:lower(), abilityNames, threshold) + return id or 0 +end + +---Searches for a Route by name, finds the best match; returns 0 if no good match +---@param name string? +---@param threshold number? Default threshold distance of 5! +---@return number mapId +local function findRouteId(name, threshold) + if name == nil or name == "" then + return 0 + end + threshold = threshold or 5 + -- If the lookup is just a route number, allow it to be searchable + if tonumber(name) ~= nil then + name = string.format("route %s", name) + end + local routes = gameInfo and gameInfo.LOCATION_DATA.locations or {} + -- Format list of Routes as id, name pairs + local routeNames = {} + for id, route in pairs(routes) do + routeNames[id] = (route.name or "Unnamed Route"):lower() + end + -- Try and find a name match + local id, _ = NetworkUtils.getClosestWord(name:lower(), routeNames, threshold) + return id or 0 +end + +-- The max # of items to show for any commands that output a list of items (try keep chat message output short) +local MAX_ITEMS = 12 +local OUTPUT_CHAR = ">" +local DEFAULT_OUTPUT_MSG = "No info found." + +---Returns a response message by combining information into a single string +---@param prefix string? [Optional] Prefixes the response with this header as "HEADER RESPONSE" +---@param infoList table|string? [Optional] A string or list of strings to combine +---@param infoDelimeter string? [Optional] Defaults to " | " +---@return string response Example: "Prefix Info Item 1 | Info Item 2 | Info Item 3" +local function buildResponse(prefix, infoList, infoDelimeter) + prefix = (prefix or "") ~= "" and (prefix .. " ") or "" + if not infoList or #infoList == 0 then + return prefix .. DEFAULT_OUTPUT_MSG + elseif type(infoList) ~= "table" then + return prefix .. tostring(infoList) + else + return prefix .. table.concat(infoList, infoDelimeter or " | ") + end +end +local function buildDefaultResponse(input) + if (input or "") ~= "" then + return buildResponse() + else + return buildResponse(string.format("%s %s", input, OUTPUT_CHAR)) + end +end + +local function getPokemonOrDefault(input) + local id + if (input or "") ~= "" then + id = findPokemonId(input) + else + local pokemon = Tracker.getPokemon(1, true) or {} + id = pokemon.pokemonID + end + return PokemonData.POKEMON[id or false] +end +local function getMoveOrDefault(input) + if (input or "") ~= "" then + return MoveData.Moves[findMoveId(input) or false] + else + return nil + end +end +local function getAbilityOrDefault(input) + local id + if (input or "") ~= "" then + id = findAbilityId(input) + else + local pokemon = Tracker.getPokemon(1, true) or {} + if PokemonData.isValid(pokemon.pokemonID) then + id = PokemonData.getAbilityId(pokemon.pokemonID, pokemon.abilityNum) + end + end + return AbilityData.ABILITIES[id or false] +end +local function getRouteIdOrDefault(input) + if (input or "") ~= "" then + local id = findRouteId(input) + -- Special check for Route 21 North/South in FRLG + if not RouteData.Info[id or false] and Utils.containsText(input, "21") then + -- Okay to default to something in route 21 + return (Utils.containsText(input, "north") and 109) or 219 + else + return id + end + else + return TrackerAPI.getMapId() + end +end + +-- Data Calculation Functions + +---@param params string? +---@return string response +function EventData.getPokemon(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + + local pokemon = getPokemonOrDefault(params) + if not pokemon then + return buildDefaultResponse(params) + end + + local info = {} + local types + if pokemon.types[2] ~= PokemonData.Types.EMPTY and pokemon.types[2] ~= pokemon.types[1] then + types = Utils.formatUTF8("%s/%s", PokemonData.getTypeResource(pokemon.types[1]), PokemonData.getTypeResource(pokemon.types[2])) + else + types = PokemonData.getTypeResource(pokemon.types[1]) + end + local coreInfo = string.format("%s #%03d (%s) %s: %s", + pokemon.name, + pokemon.pokemonID, + types, + Resources.TrackerScreen.StatBST, + pokemon.bst + ) + table.insert(info, coreInfo) + local evos = table.concat(Utils.getDetailedEvolutionsInfo(pokemon.evolution), ", ") + table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelEvolution, evos)) + local moves + if #pokemon.movelvls[GameSettings.versiongroup] > 0 then + moves = table.concat(pokemon.movelvls[GameSettings.versiongroup], ", ") + else + moves = "None." + end + table.insert(info, string.format("%s. %s: %s", Resources.TrackerScreen.LevelAbbreviation, Resources.TrackerScreen.HeaderMoves, moves)) + local trackedPokemon = Tracker.Data.allPokemon[pokemon.pokemonID] or {} + if (trackedPokemon.eT or 0) > 0 then + table.insert(info, string.format("%s: %s", Resources.TrackerScreen.BattleSeenOnTrainers, trackedPokemon.eT)) + end + if (trackedPokemon.eW or 0) > 0 then + table.insert(info, string.format("%s: %s", Resources.TrackerScreen.BattleSeenInTheWild, trackedPokemon.eW)) + end + return buildResponse(OUTPUT_CHAR, info) +end + +---@param params string? +---@return string response +function EventData.getBST(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local pokemon = getPokemonOrDefault(params) + if not pokemon then + return buildDefaultResponse(params) + end + + local info = {} + table.insert(info, string.format("%s: %s", Resources.TrackerScreen.StatBST, pokemon.bst)) + local prefix = string.format("%s %s", pokemon.name, OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getWeak(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local pokemon = getPokemonOrDefault(params) + if not pokemon then + return buildDefaultResponse(params) + end + + local info = {} + local pokemonDefenses = PokemonData.getEffectiveness(pokemon.pokemonID) + local weak4x = Utils.firstToUpperEachWord(table.concat(pokemonDefenses[4] or {}, ", ")) + if not Utils.isNilOrEmpty(weak4x) then + table.insert(info, string.format("[4x] %s", weak4x)) + end + local weak2x = Utils.firstToUpperEachWord(table.concat(pokemonDefenses[2] or {}, ", ")) + if not Utils.isNilOrEmpty(weak2x) then + table.insert(info, string.format("[2x] %s", weak2x)) + end + local types + if pokemon.types[2] ~= PokemonData.Types.EMPTY and pokemon.types[2] ~= pokemon.types[1] then + types = Utils.formatUTF8("%s/%s", PokemonData.getTypeResource(pokemon.types[1]), PokemonData.getTypeResource(pokemon.types[2])) + else + types = PokemonData.getTypeResource(pokemon.types[1]) + end + + if #info == 0 then + table.insert(info, Resources.InfoScreen.LabelNoWeaknesses) + end + + local prefix = string.format("%s (%s) %s %s", pokemon.name, types, Resources.TypeDefensesScreen.Weaknesses, OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getMove(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local move = getMoveOrDefault(params) + if not move then + return buildDefaultResponse(params) + end + + local info = {} + table.insert(info, string.format("%s: %s", + Resources.InfoScreen.LabelContact, + move.iscontact and Resources.AllScreens.Yes or Resources.AllScreens.No)) + table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelPP, move.pp or Constants.BLANKLINE)) + table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelPower, move.power or Constants.BLANKLINE)) + table.insert(info, string.format("%s: %s", Resources.TrackerScreen.HeaderAcc, move.accuracy or Constants.BLANKLINE)) + table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelMoveSummary, move.summary)) + local prefix = string.format("%s (%s, %s) %s", + move.name, + Utils.firstToUpperEachWord(move.type), + Utils.firstToUpperEachWord(move.category), + OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getAbility(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local ability = getAbilityOrDefault(params) + if not ability then + return buildDefaultResponse(params) + end + + local info = {} + table.insert(info, string.format("%s: %s", ability.name, ability.description)) + -- Emerald only + if GameSettings.game == 2 and ability.descriptionEmerald then + table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelEmeraldAbility, ability.descriptionEmerald)) + end + return buildResponse(OUTPUT_CHAR, info) +end + +---@param params string? +---@return string response +function EventData.getRoute(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + -- Check for optional parameters + local paramsLower = Utils.toLowerUTF8(params or "") + local option + for key, val in pairs(RouteData.EncounterArea or {}) do + if Utils.containsText(paramsLower, val, true) then + paramsLower = Utils.replaceText(paramsLower, Utils.toLowerUTF8(val), "", true) + option = key + break + end + end + -- If option keywords were removed, trim any whitespace + if option then + -- Removes duplicate, consecutive whitespaces, and leading/trailer whitespaces + paramsLower = ((paramsLower:gsub("(%s)%s+", "%1")):gsub("^%s*(.-)%s*$", "%1")) + end + + local routeId = getRouteIdOrDefault(paramsLower) + local route = RouteData.Info[routeId or false] + if not route then + return buildDefaultResponse(params) + end + + local info = {} + -- Check for trainers in the route, but only if a specific encounter area wasnt requested + if not option and route.trainers and #route.trainers > 0 then + local defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByLocation(routeId) + table.insert(info, string.format("%s: %s/%s", "Trainers defeated", #defeatedTrainers, totalTrainers)) + end + -- Check for wilds in the route + local encounterArea + if option then + encounterArea = RouteData.EncounterArea[option] or RouteData.EncounterArea.LAND + else + -- Default to the first area type (usually Walking) + encounterArea = RouteData.getNextAvailableEncounterArea(routeId, RouteData.EncounterArea.TRAINER) + end + local wildIds = RouteData.getEncounterAreaPokemon(routeId, encounterArea) + if #wildIds > 0 then + local seenIds = Tracker.getRouteEncounters(routeId, encounterArea or RouteData.EncounterArea.LAND) + local pokemonNames = {} + for _, pokemonId in ipairs(seenIds) do + if PokemonData.isValid(pokemonId) then + table.insert(pokemonNames, PokemonData.Pokemon[pokemonId].name) + end + end + local wildsText = string.format("%s: %s/%s", "Wild Pokémon seen", #seenIds, #wildIds) + if #seenIds > 0 then + wildsText = wildsText .. string.format(" (%s)", table.concat(pokemonNames, ", ")) + end + table.insert(info, wildsText) + end + + local prefix + if option then + prefix = string.format("%s: %s %s", route.name, Utils.firstToUpperEachWord(encounterArea), OUTPUT_CHAR) + else + prefix = string.format("%s %s", route.name, OUTPUT_CHAR) + end + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getDungeon(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local routeId = getRouteIdOrDefault(params) + local route = RouteData.Info[routeId or false] + if not route then + return buildDefaultResponse(params) + end + + local info = {} + -- Check for trainers in the area/route + local defeatedTrainers, totalTrainers + if route.area ~= nil then + defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByCombinedArea(route.area) + elseif route.trainers and #route.trainers > 0 then + defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByLocation(routeId) + end + if defeatedTrainers and totalTrainers then + local trainersText = string.format("%s: %s/%s", "Trainers defeated", #defeatedTrainers, totalTrainers) + table.insert(info, trainersText) + end + local routeName = route.area and route.area.name or route.name + local prefix = string.format("%s %s", routeName, OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getUnfoughtTrainers(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local allowPartialDungeons = Utils.containsText(params, "dungeon", true) + local includeSevii + if GameSettings.game == 3 then + includeSevii = Utils.containsText(params, "sevii", true) + else + includeSevii = true -- to allow routes above the sevii route id for RSE + end + + local MAX_AREAS_TO_CHECK = 7 + local saveBlock1Addr = Utils.getSaveBlock1Addr() + local trainersToExclude = TrainerData.getExcludedTrainers() + local currentRouteId = TrackerAPI.getMapId() + + -- For a given unfought trainer, this function returns unfought trainer counts for its route/area + local checkedIds = {} + local function getUnfinishedRouteInfo(trainerId) + local trainer = TrainerData.Trainers[trainerId] or {} + local routeId = trainer.routeId or -1 + local route = RouteData.Info[routeId] or {} + + -- If sevii is excluded (default option), skip those routes and non-existent routes + if routeId == -1 or (routeId >= 230 and not includeSevii) then + return nil + end + -- Skip certain trainers, only checking unfought trainers + if checkedIds[trainerId] or trainersToExclude[trainerId] or not TrainerData.shouldUseTrainer(trainerId) then + return nil + end + if Program.hasDefeatedTrainer(trainerId, saveBlock1Addr) then + return nil + end + + -- Check area for defeated trainers and mark each trainer as checked + local defeatedTrainers = {} + local totalTrainers = 0 + local ifDungeonAndIncluded = true -- true for non-dungeons, otherwise gets excluded if partially completed + if route.area and #route.area > 0 then + defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByCombinedArea(route.area, saveBlock1Addr) + -- Don't include dungeons that are partially completed unless the player is currently there + if route.area.dungeon and #defeatedTrainers > 0 then + local isThere = false + for _, id in ipairs(route.area or {}) do + if id == currentRouteId then + isThere = true + break + end + end + ifDungeonAndIncluded = isThere or allowPartialDungeons + end + for _, areaRouteId in ipairs(route.area) do + local areaRoute = RouteData.Info[areaRouteId] or {} + for _, id in ipairs(areaRoute.trainers or {}) do + checkedIds[id] = true + end + end + elseif route.trainers and #route.trainers > 0 then + defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByLocation(routeId, saveBlock1Addr) + -- Don't include dungeons that are partially completed unless the player is currently there + if route.dungeon and #defeatedTrainers > 0 and currentRouteId ~= routeId then + ifDungeonAndIncluded = allowPartialDungeons + end + for _, id in ipairs(route.trainers) do + checkedIds[id] = true + end + else + return nil + end + + -- Add to info if route/area has unfought trainers (not all defeated) + if #defeatedTrainers < totalTrainers and ifDungeonAndIncluded then + local routeName = route.area and route.area.name or route.name + return string.format("%s (%s/%s)", routeName, #defeatedTrainers, totalTrainers) + end + end + + local info = {} + for _, trainerId in ipairs(TrainerData.OrderedIds or {}) do + local routeText = getUnfinishedRouteInfo(trainerId) + if routeText ~= nil then + table.insert(info, routeText) + end + if #info >= MAX_AREAS_TO_CHECK then + table.insert(info, "...") + break + end + end + if #info == 0 then + local reminderText = "" + if not allowPartialDungeons or not includeSevii then + reminderText = ' (Use param "dungeon" and/or "sevii" to check partially completed dungeons or Sevii Islands.)' + end + table.insert(info, string.format("%s %s", "All available trainers have been defeated!", reminderText)) + end + + local prefix = string.format("%s %s", "Unfought Trainers", OUTPUT_CHAR) + return buildResponse(prefix, info, ", ") +end + +---@param params string? +---@return string response +function EventData.getPivots(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local info = {} + local mapIds + if GameSettings.game == 3 then -- FRLG + mapIds = { 89, 90, 110, 117 } -- Route 1, 2, 22, Viridian Forest + else -- RSE + local offset = GameSettings.versioncolor == "Emerald" and 0 or 1 -- offset all "mapId > 107" by +1 + mapIds = { 17, 18, 19, 20, 32, 135 + offset } -- Route 101, 102, 103, 104, 116, Petalburg Forest + end + for _, mapId in ipairs(mapIds) do + -- Check for tracked wild encounters in the route + local seenIds = Tracker.getRouteEncounters(mapId, RouteData.EncounterArea.LAND) + local pokemonNames = {} + for _, pokemonId in ipairs(seenIds) do + if PokemonData.isValid(pokemonId) then + table.insert(pokemonNames, PokemonData.Pokemon[pokemonId].name) + end + end + if #seenIds > 0 then + local route = RouteData.Info[mapId or false] or {} + table.insert(info, string.format("%s: %s", route.name or "Unknown Route", table.concat(pokemonNames, ", "))) + end + end + local prefix = string.format("%s %s", "Pivots", OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getRevo(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local pokemonID, targetEvoId + if not Utils.isNilOrEmpty(params) then + pokemonID = DataHelper.findPokemonId(params) + -- If more than one Pokémon name is provided, set the other as the target evo (i.e. "Eevee Vaporeon") + if pokemonID == 0 then + local s = Utils.split(params, " ", true) + pokemonID = DataHelper.findPokemonId(s[1]) + targetEvoId = DataHelper.findPokemonId(s[2]) + end + else + local pokemon = Tracker.getPokemon(1, true) or {} + pokemonID = pokemon.pokemonID + end + local revo = PokemonRevoData.getEvoTable(pokemonID, targetEvoId) + if not revo then + local pokemon = PokemonData.Pokemon[pokemonID or false] or {} + if pokemon.evolution == PokemonData.Evolutions.NONE then + local prefix = string.format("%s %s %s", pokemon.name, "Evos", OUTPUT_CHAR) + return buildResponse(prefix, "Does not evolve.") + else + return buildDefaultResponse(pokemon.name or params) + end + end + + local info = {} + local shortenPerc = function(p) + if p < 0.01 then return "<0.01%" + elseif p < 0.1 then return string.format("%.2f%%", p) + else return string.format("%.1f%%", p) end + end + local extraMons = 0 + for _, revoInfo in ipairs(revo or {}) do + if #info < MAX_ITEMS then + table.insert(info, string.format("%s %s", PokemonData.Pokemon[revoInfo.id].name, shortenPerc(revoInfo.perc))) + else + extraMons = extraMons + 1 + end + end + if extraMons > 0 then + table.insert(info, string.format("(+%s more Pokémon)", extraMons)) + end + local prefix = string.format("%s %s %s", PokemonData.Pokemon[pokemonID].name, "Evos", OUTPUT_CHAR) + return buildResponse(prefix, info, ", ") +end + +---@param params string? +---@return string response +function EventData.getCoverage(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local calcFromLead = true + local onlyFullyEvolved = false + local moveTypes = {} + if not Utils.isNilOrEmpty(params) then + params = Utils.replaceText(params or "", ",%s*", " ") -- Remove any list commas + for _, word in ipairs(Utils.split(params, " ", true) or {}) do + if Utils.containsText(word, "evolve", true) or Utils.containsText(word, "fully", true) then + onlyFullyEvolved = true + else + local moveType = DataHelper.findPokemonType(word) + if moveType and moveType ~= "EMPTY" then + calcFromLead = false + table.insert(moveTypes, PokemonData.Types[moveType] or moveType) + end + end + end + end + if calcFromLead then + moveTypes = CoverageCalcScreen.getPartyPokemonEffectiveMoveTypes(1) or {} + end + if #moveTypes == 0 then + return buildDefaultResponse(params) + end + + local info = {} + local coverageData = CoverageCalcScreen.calculateCoverageTable(moveTypes, onlyFullyEvolved) + local multipliers = {} + for _, tab in pairs(CoverageCalcScreen.Tabs) do + table.insert(multipliers, tab) + end + table.sort(multipliers, function(a,b) return a < b end) + for _, tab in ipairs(multipliers) do + local mons = coverageData[tab] or {} + if #mons > 0 then + local format = "[%0dx] %s" + if tab == CoverageCalcScreen.Tabs.Half then + format = "[%0.1fx] %s" + elseif tab == CoverageCalcScreen.Tabs.Quarter then + format = "[%0.2fx] %s" + end + table.insert(info, string.format(format, tab, #mons)) + end + end + + local pokemon = Tracker.getPokemon(1, true) or {} + local typesText = Utils.firstToUpperEachWord(table.concat(moveTypes, ", ")) + local fullyEvoText = onlyFullyEvolved and " Fully Evolved" or "" + local prefix = string.format("%s (%s)%s %s", "Coverage", typesText, fullyEvoText, OUTPUT_CHAR) + if calcFromLead and PokemonData.isValid(pokemon.pokemonID) then + prefix = string.format("%s's %s", PokemonData.Pokemon[pokemon.pokemonID].name, prefix) + end + return buildResponse(prefix, info, ", ") +end + +---@param params string? +---@return string response +function EventData.getHeals(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local info = {} + + local displayHP, displayStatus, displayPP, displayBerries + if not Utils.isNilOrEmpty(params) then + local paramToLower = Utils.toLowerUTF8(params) + displayHP = Utils.containsText(paramToLower, "hp", true) + displayPP = Utils.containsText(paramToLower, "pp", true) + displayStatus = Utils.containsText(paramToLower, "status", true) + displayBerries = Utils.containsText(paramToLower, "berries", true) + end + -- Default to showing all (except redundant berries) + if not (displayHP or displayPP or displayStatus or displayBerries) then + displayHP = true + displayPP = true + displayStatus = true + end + local function sortFunc(a,b) return a.value > b.value or (a.value == b.value and a.id < b.id) end + local function getSortableItem(id, quantity) + if not MiscData.Items[id or 0] or (quantity or 0) <= 0 then return nil end + local item = MiscData.HealingItems[id] or MiscData.PPItems[id] or MiscData.StatusItems[id] or {} + local text = MiscData.Items[item.id] + if quantity > 1 then + text = string.format("%s (%s)", text, quantity) + end + local value = item.amount or 0 + if item.type == MiscData.HealingType.Percentage then + value = value + 1000 + elseif item.type == MiscData.StatusType.All then -- The really good status items + value = value + 2 + elseif MiscData.StatusItems[id] then -- All other status items + value = value + 1 + end + return { id = id, text = text, value = value } + end + local function sortAndCombine(label, items) + table.sort(items, sortFunc) + local t = {} + for _, item in ipairs(items) do table.insert(t, item.text) end + table.insert(info, string.format("[%s] %s", label, table.concat(t, ", "))) + end + local healingItems, ppItems, statusItems, berryItems = {}, {}, {}, {} + for id, quantity in pairs(Program.GameData.Items.HPHeals) do + local itemInfo = getSortableItem(id, quantity) + if itemInfo then + table.insert(healingItems, itemInfo) + if displayBerries and MiscData.HealingItems[id].pocket == MiscData.BagPocket.Berries then + table.insert(berryItems, itemInfo) + end + end + end + for id, quantity in pairs(Program.GameData.Items.PPHeals) do + local itemInfo = getSortableItem(id, quantity) + if itemInfo then + table.insert(ppItems, itemInfo) + if displayBerries and MiscData.PPItems[id].pocket == MiscData.BagPocket.Berries then + table.insert(berryItems, itemInfo) + end + end + end + for id, quantity in pairs(Program.GameData.Items.StatusHeals) do + local itemInfo = getSortableItem(id, quantity) + if itemInfo then + table.insert(statusItems, itemInfo) + if displayBerries and MiscData.StatusItems[id].pocket == MiscData.BagPocket.Berries then + table.insert(berryItems, itemInfo) + end + end + end + if displayHP and #healingItems > 0 then + sortAndCombine("HP", healingItems) + end + if displayPP and #ppItems > 0 then + sortAndCombine("PP", ppItems) + end + if displayStatus and #statusItems > 0 then + sortAndCombine("Status", statusItems) + end + if displayBerries and #berryItems > 0 then + sortAndCombine("Berries", berryItems) + end + local prefix = string.format("%s %s", Resources.TrackerScreen.HealsInBag, OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getTMsHMs(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local info = {} + local prefix = string.format("%s %s", "TMs", OUTPUT_CHAR) + local canSeeTM = Options["Open Book Play Mode"] + + local singleTmLookup + local displayGym, displayNonGym, displayHM + if params and not Utils.isNilOrEmpty(params) then + displayGym = Utils.containsText(params, "gym", true) + displayHM = Utils.containsText(params, "hm", true) + singleTmLookup = tonumber(params:match("(%d+)") or "") + end + -- Default to showing just tms (gym & other) + if not displayGym and not displayHM and not singleTmLookup then + displayGym = true + displayNonGym = true + end + local tms, hms = Program.getTMsHMsBagItems() + if singleTmLookup then + if not canSeeTM then + for _, item in ipairs(tms or {}) do + local tmInBag = item.id - 289 + 1 -- 289 is the item ID of the first TM + if singleTmLookup == tmInBag then + canSeeTM = true + break + end + end + end + local moveId = Program.getMoveIdFromTMHMNumber(singleTmLookup) + local textToAdd + if canSeeTM and MoveData.isValid(moveId) then + textToAdd = MoveData.Moves[moveId].name + else + textToAdd = string.format("%s %s", Constants.BLANKLINE, "(not acquired yet)") + end + return buildResponse(prefix, string.format("%s %02d: %s", "TM", singleTmLookup, textToAdd)) + end + if displayGym or displayNonGym then + local isGymTm = {} + for _, gymInfo in ipairs(TrainerData.GymTMs) do + if gymInfo.number then + isGymTm[gymInfo.number] = true + end + end + local tmsObtained = {} + local otherTMs, gymTMs = {}, {} + for _, item in ipairs(tms or {}) do + local tmNumber = item.id - 289 + 1 -- 289 is the item ID of the first TM + local moveId = Program.getMoveIdFromTMHMNumber(tmNumber) + if MoveData.isValid(moveId) then + tmsObtained[tmNumber] = string.format("#%02d %s", tmNumber, MoveData.Moves[moveId].name) + if not isGymTm[tmNumber] then + table.insert(otherTMs, tmsObtained[tmNumber]) + end + end + end + if displayGym then + -- Get them sorted in Gym ordered + for _, gymInfo in ipairs(TrainerData.GymTMs) do + if tmsObtained[gymInfo.number] then + table.insert(gymTMs, tmsObtained[gymInfo.number]) + elseif canSeeTM then + local moveId = Program.getMoveIdFromTMHMNumber(gymInfo.number) + table.insert(gymTMs, string.format("#%02d %s", gymInfo.number, MoveData.Moves[moveId].name)) + end + end + local textToAdd = #gymTMs > 0 and table.concat(gymTMs, ", ") or "None" + table.insert(info, string.format("[%s] %s", "Gym", textToAdd)) + end + if displayNonGym then + local textToAdd + if #otherTMs > 0 then + local otherMax = math.min(#otherTMs, MAX_ITEMS - #gymTMs) + textToAdd = table.concat(otherTMs, ", ", 1, otherMax) + if #otherTMs > otherMax then + textToAdd = string.format("%s, (+%s more TMs)", textToAdd, #otherTMs - otherMax) + end + else + textToAdd = "None" + end + table.insert(info, string.format("[%s] %s", "Other", textToAdd)) + end + end + if displayHM then + local hmTexts = {} + for _, item in ipairs(hms or {}) do + local hmNumber = item.id - 339 + 1 -- 339 is the item ID of the first HM + local moveId = Program.getMoveIdFromTMHMNumber(hmNumber, true) + if MoveData.isValid(moveId) then + local hmText = string.format("%s (HM%02d)", MoveData.Moves[moveId].name, hmNumber) + table.insert(hmTexts, hmText) + end + end + local textToAdd = #hmTexts > 0 and table.concat(hmTexts, ", ") or "None" + table.insert(info, string.format("%s: %s", "HMs", textToAdd)) + end + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getSearch(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local helpResponse = "Search tracked info for a Pokémon, move, or ability." + if Utils.isNilOrEmpty(params, true) then + return buildResponse(params, helpResponse) + end + local function getModeAndId(input, threshold) + local id = DataHelper.findPokemonId(input, threshold) + if id ~= 0 then return "pokemon", id end + id = DataHelper.findMoveId(input, threshold) + if id ~= 0 then return "move", id end + id = DataHelper.findAbilityId(input, threshold) + if id ~= 0 then return "ability", id end + return nil, 0 + end + local searchMode, searchId + for i=1, 4, 1 do + searchMode, searchId = getModeAndId(params, i) + if searchMode then + break + end + end + if not searchMode then + local prefix = string.format("%s %s", params, OUTPUT_CHAR) + return buildResponse(prefix, "Can't find a Pokémon, move, or ability with that name.") + end + + local info = {} + if searchMode == "pokemon" then + local pokemon = PokemonData.Pokemon[searchId] + if not pokemon then + return buildDefaultResponse(params) + end + -- Tracked Abilities + local trackedAbilities = {} + for _, ability in ipairs(Tracker.getAbilities(pokemon.pokemonID) or {}) do + if AbilityData.isValid(ability.id) then + table.insert(trackedAbilities, AbilityData.Abilities[ability.id].name) + end + end + if #trackedAbilities > 0 then + table.insert(info, string.format("%s: %s", "Abilities", table.concat(trackedAbilities, ", "))) + end + -- Tracked Stat Markings + local statMarksToAdd = {} + local trackedStatMarkings = Tracker.getStatMarkings(pokemon.pokemonID) or {} + for _, statKey in ipairs(Constants.OrderedLists.STATSTAGES) do + local markVal = trackedStatMarkings[statKey] + if markVal ~= 0 then + local marking = Constants.STAT_STATES[markVal] or {} + local symbol = string.sub(marking.text or " ", 1, 1) or "" + table.insert(statMarksToAdd, string.format("%s(%s)", Utils.toUpperUTF8(statKey), symbol)) + end + end + if #statMarksToAdd > 0 then + table.insert(info, string.format("%s: %s", "Stats", table.concat(statMarksToAdd, ", "))) + end + -- Tracked Moves + local extra = 0 + local trackedMoves = {} + for _, move in ipairs(Tracker.getMoves(pokemon.pokemonID) or {}) do + if MoveData.isValid(move.id) then + if #trackedMoves < MAX_ITEMS then + -- { id = moveId, level = level, minLv = level, maxLv = level, }, + local lvText + if move.minLv and move.maxLv and move.minLv ~= move.maxLv then + lvText = string.format(" (%s.%s-%s)", Resources.TrackerScreen.LevelAbbreviation, move.minLv, move.maxLv) + elseif move.level > 0 then + lvText = string.format(" (%s.%s)", Resources.TrackerScreen.LevelAbbreviation, move.level) + end + table.insert(trackedMoves, string.format("%s%s", MoveData.Moves[move.id].name, lvText or "")) + else + extra = extra + 1 + end + end + end + if #trackedMoves > 0 then + table.insert(info, string.format("%s: %s", "Moves", table.concat(trackedMoves, ", "))) + if extra > 0 then + table.insert(info, string.format("(+%s more)", extra)) + end + end + -- Tracked Encounters + local seenInWild = Tracker.getEncounters(pokemon.pokemonID, true) + local seenOnTrainers = Tracker.getEncounters(pokemon.pokemonID, false) + local trackedSeen = {} + if seenInWild > 0 then + table.insert(trackedSeen, string.format("%s in wild", seenInWild)) + end + if seenOnTrainers > 0 then + table.insert(trackedSeen, string.format("%s on trainers", seenOnTrainers)) + end + if #trackedSeen > 0 then + table.insert(info, string.format("%s: %s", "Seen", table.concat(trackedSeen, ", "))) + end + -- Tracked Notes + local trackedNote = Tracker.getNote(pokemon.pokemonID) + if #trackedNote > 0 then + table.insert(info, string.format("%s: %s", "Note", trackedNote)) + end + local prefix = string.format("%s %s %s", "Tracked", pokemon.name, OUTPUT_CHAR) + return buildResponse(prefix, info) + elseif searchMode == "move" or searchMode == "moves" then + local move = MoveData.Moves[searchId] + if not move then + return buildDefaultResponse(params) + end + local moveId = tonumber(move.id) or 0 + local foundMons = {} + for pokemonID, trackedPokemon in pairs(Tracker.Data.allPokemon or {}) do + for _, trackedMove in ipairs(trackedPokemon.moves or {}) do + if trackedMove.id == moveId and trackedMove.level > 0 then + local lvText = tostring(trackedMove.level) + if trackedMove.minLv and trackedMove.maxLv and trackedMove.minLv ~= trackedMove.maxLv then + lvText = string.format("%s-%s", trackedMove.minLv, trackedMove.maxLv) + end + local pokemon = PokemonData.Pokemon[pokemonID] + local notes = string.format("%s (%s.%s)", pokemon.name, Resources.TrackerScreen.LevelAbbreviation, lvText) + table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = notes}) + break + end + end + end + table.sort(foundMons, function(a,b) return a.bst > b.bst or (a.bst == b.bst and a.id < b.id) end) + local extra = 0 + for _, mon in ipairs(foundMons) do + if #info < MAX_ITEMS then + table.insert(info, mon.notes) + else + extra = extra + 1 + end + end + if extra > 0 then + table.insert(info, string.format("(+%s more Pokémon)", extra)) + end + local prefix = string.format("%s %s %s Pokémon:", move.name, OUTPUT_CHAR, #foundMons) + return buildResponse(prefix, info, ", ") + elseif searchMode == "ability" or searchMode == "abilities" then + local ability = AbilityData.Abilities[searchId] + if not ability then + return buildDefaultResponse(params) + end + local foundMons = {} + for pokemonID, trackedPokemon in pairs(Tracker.Data.allPokemon or {}) do + for _, trackedAbility in ipairs(trackedPokemon.abilities or {}) do + if trackedAbility.id == ability.id then + local pokemon = PokemonData.Pokemon[pokemonID] + table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = pokemon.name }) + break + end + end + end + table.sort(foundMons, function(a,b) return a.bst > b.bst or (a.bst == b.bst and a.id < b.id) end) + local extra = 0 + for _, mon in ipairs(foundMons) do + if #info < MAX_ITEMS then + table.insert(info, mon.notes) + else + extra = extra + 1 + end + end + if extra > 0 then + table.insert(info, string.format("(+%s more Pokémon)", extra)) + end + local prefix = string.format("%s %s %s Pokémon:", ability.name, OUTPUT_CHAR, #foundMons) + return buildResponse(prefix, info, ", ") + end + -- Unused + local prefix = string.format("%s %s", params, OUTPUT_CHAR) + return buildResponse(prefix, helpResponse) +end + +---@param params string? +---@return string response +function EventData.getSearchNotes(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + if Utils.isNilOrEmpty(params, true) then + return buildDefaultResponse(params) + end + + local info = {} + local foundMons = {} + for pokemonID, trackedPokemon in pairs(Tracker.Data.allPokemon or {}) do + if trackedPokemon.note and Utils.containsText(trackedPokemon.note, params, true) then + local pokemon = PokemonData.Pokemon[pokemonID] + table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = pokemon.name }) + end + end + table.sort(foundMons, function(a,b) return a.bst > b.bst or (a.bst == b.bst and a.id < b.id) end) + local extra = 0 + for _, mon in ipairs(foundMons) do + if #info < MAX_ITEMS then + table.insert(info, mon.notes) + else + extra = extra + 1 + end + end + if extra > 0 then + table.insert(info, string.format("(+%s more Pokémon)", extra)) + end + local prefix = string.format("%s: \"%s\" %s %s Pokémon:", "Note", params, OUTPUT_CHAR, #foundMons) + return buildResponse(prefix, info, ", ") +end + +---@param params string? +---@return string response +function EventData.getFavorites(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local info = {} + local faveButtons = { + StreamerScreen.Buttons.PokemonFavorite1, + StreamerScreen.Buttons.PokemonFavorite2, + StreamerScreen.Buttons.PokemonFavorite3, + } + local favesList = {} + for i, button in ipairs(faveButtons or {}) do + local name + if PokemonData.isValid(button.pokemonID) then + name = PokemonData.Pokemon[button.pokemonID].name + else + name = Constants.BLANKLINE + end + table.insert(favesList, string.format("#%s %s", i, name)) + end + if #favesList > 0 then + table.insert(info, table.concat(favesList, ", ")) + end + local prefix = string.format("%s %s", "Favorites", OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getTheme(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local info = {} + local themeCode = Theme.exportThemeToText() + local themeName = Theme.getThemeNameFromCode(themeCode) + table.insert(info, string.format("%s: %s", themeName, themeCode)) + local prefix = string.format("%s %s", "Theme", OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getGameStats(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local info = {} + for _, statPair in ipairs(StatsScreen.StatTables or {}) do + if type(statPair.getText) == "function" and type(statPair.getValue) == "function" then + local statValue = statPair.getValue() or 0 + if type(statValue) == "number" then + statValue = Utils.formatNumberWithCommas(statValue) + end + table.insert(info, string.format("%s: %s", statPair:getText(), statValue)) + end + end + local prefix = string.format("%s %s", Resources.GameOptionsScreen.ButtonGameStats, OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getProgress(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + local includeSevii = Utils.containsText(params, "sevii", true) + local info = {} + local badgesObtained, maxBadges = 0, 8 + for i = 1, maxBadges, 1 do + local badgeButton = TrackerScreen.Buttons["badge" .. i] or {} + if (badgeButton.badgeState or 0) ~= 0 then + badgesObtained = badgesObtained + 1 + end + end + table.insert(info, string.format("%s: %s/%s", "Gym badges", badgesObtained, maxBadges)) + local saveBlock1Addr = Utils.getSaveBlock1Addr() + local totalDefeated, totalTrainers = 0, 0 + for mapId, route in pairs(RouteData.Info) do + -- Don't check sevii islands (id = 230+) by default + if mapId < 230 or includeSevii then + if route.trainers and #route.trainers > 0 then + local defeatedTrainers, totalInRoute = Program.getDefeatedTrainersByLocation(mapId, saveBlock1Addr) + totalDefeated = totalDefeated + #defeatedTrainers + totalTrainers = totalTrainers + totalInRoute + end + end + end + table.insert(info, string.format("%s%s: %s/%s (%0.1f%%)", + "Trainers defeated", + includeSevii and ", including Sevii" or "", + totalDefeated, + totalTrainers, + totalDefeated / totalTrainers * 100)) + local fullyEvolvedSeen, fullyEvolvedTotal = 0, 0 + -- local legendarySeen, legendaryTotal = 0, 0 + for pokemonID, pokemon in ipairs(PokemonData.Pokemon) do + if pokemon.evolution == PokemonData.Evolutions.NONE then + fullyEvolvedTotal = fullyEvolvedTotal + 1 + local trackedPokemon = Tracker.Data.allPokemon[pokemonID] or {} + if (trackedPokemon.eT or 0) > 0 then + fullyEvolvedSeen = fullyEvolvedSeen + 1 + end + end + end + table.insert(info, string.format("%s: %s/%s (%0.1f%%)", --, Legendary: %s/%s (%0.1f%%)", + "Pokémon seen fully evolved", + fullyEvolvedSeen, + fullyEvolvedTotal, + fullyEvolvedSeen / fullyEvolvedTotal * 100)) + local prefix = string.format("%s %s", "Progress", OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getLog(params) + -- TODO: Implement this function + if true then return buildDefaultResponse(params) end + -- TODO: add "previous" as a parameter; requires storing this information somewhere + local prefix = string.format("%s %s", "Log", OUTPUT_CHAR) + local hasParsedThisLog = RandomizerLog.Data.Settings and string.find(RandomizerLog.loadedLogPath or "", FileManager.PostFixes.AUTORANDOMIZED, 1, true) + if not hasParsedThisLog then + return buildResponse(prefix, "This game's log file hasn't been opened yet.") + end + + local info = {} + for _, button in ipairs(Utils.getSortedList(LogTabMisc.Buttons or {})) do + table.insert(info, string.format("%s %s", button:getText(), button:getValue())) + end + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getBallQueue(params) + local prefix = string.format("%s %s", "BallQueue", OUTPUT_CHAR) + + local info = {} + + local queueSize = 0 + for _, _ in pairs(EventHandler.Queues.BallRedeems.Requests or {}) do + queueSize = queueSize + 1 + end + if queueSize == 0 then + return buildResponse(prefix, "The pick ball queue is empty.") + end + table.insert(info, string.format("%s: %s", "Size", queueSize)) + + local request = EventHandler.Queues.BallRedeems.ActiveRequest + if request and request.Username then + table.insert(info, string.format("%s: %s - %s", "Current pick", request.Username, request.SanitizedInput or "N/A")) + end + + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getAbout(params) + local info = {} + table.insert(info, string.format("Version: %s", MiscConstants.TRACKER_VERSION)) + table.insert(info, string.format("Game: %s", "HGSS" or GameSettings.gamename)) -- TODO: Fix + table.insert(info, string.format("Attempts: %s", 1234 or Main.currentSeed or 1)) -- TODO: Fix + table.insert(info, string.format("Streamerbot Code: v%s", Network.currentStreamerbotVersion or "N/A")) + local prefix = string.format("NDS Ironmon Tracker %s", OUTPUT_CHAR) + return buildResponse(prefix, info) +end + +---@param params string? +---@return string response +function EventData.getHelp(params) + local availableCommands = {} + for _, event in pairs(EventHandler.Events or {}) do + if event.Type == EventHandler.EventTypes.Command and event.Command and event.IsEnabled then + availableCommands[event.Command] = event + end + end + local info = {} + if params ~= nil and params ~= "" then + local paramsAsLower = params:lower() + if paramsAsLower:sub(1, 1) ~= EventHandler.COMMAND_PREFIX then + paramsAsLower = EventHandler.COMMAND_PREFIX .. paramsAsLower + end + local command = availableCommands[paramsAsLower] + if not command or (command.Help or "") == "" then + return buildDefaultResponse(params) + end + table.insert(info, string.format("%s %s", paramsAsLower, command.Help)) + else + for commandWord, _ in pairs(availableCommands) do + table.insert(info, commandWord) + end + table.sort(info, function(a,b) return a < b end) + end + local prefix = string.format("Tracker Commands %s", OUTPUT_CHAR) + return buildResponse(prefix, info, ", ") +end \ No newline at end of file diff --git a/ironmon_tracker/network/EventHandler.lua b/ironmon_tracker/network/EventHandler.lua new file mode 100644 index 00000000..72b703c0 --- /dev/null +++ b/ironmon_tracker/network/EventHandler.lua @@ -0,0 +1,814 @@ +EventHandler = { + RewardsExternal = {}, -- A list of external rewards + Queues = {}, -- A table of lists for each set of processed requests that still need to be fulfilled + EVENT_SETTINGS_FORMAT = "Event__%s__%s", + DUPLICATE_COMMAND_COOLDOWN = 6, -- # of seconds + + -- Shared values between server and client + COMMAND_PREFIX = "!", +} + +EventHandler.EventTypes = { + None = "None", + Command = "Command", -- For chat commands + Reward = "Reward", -- For channel rewards (channel point redeem) + Tracker = "Tracker", -- Trigger off of a change to the Tracker itself + Game = "Game", -- Trigger off of something in the actual game +} + +EventHandler.CoreEventKeys = { + Start = "TS_Start", + Stop = "TS_Stop", + GetRewards = "TS_GetRewardsList", + UpdateEvents = "TS_UpdateEvents", +} + +EventHandler.Events = { + None = { Key = "None", Type = EventHandler.EventTypes.None, Exclude = true }, +} + +EventHandler.CommandRoles = { + Broadcaster = "Broadcaster", -- Allow the one user who is the Broadcaster (always allowed) + Everyone = "Everyone", -- Allow all users, regardless of other roles selected + Moderator = "Moderator", -- Allow users that are Moderators + Vip = "Vip", -- Allow users with VIP + Subscriber = "Subscriber", -- Allow users that are Subscribers + Custom = "Custom", -- Allow users that belong to a custom-defined role + Viewer = "Viewer", -- Unused +} + +-- Event object prototypes + +EventHandler.IEvent = { + -- Required unique key + Key = EventHandler.Events.None.Key, + -- Required type + Type = EventHandler.EventTypes.None, + -- Required display name of the event + Name = "", + -- Enable/Disable from triggering + IsEnabled = true, + -- Determine what to do with the IRequest, return true if ready to fulfill + Process = function(self, request) return true end, + -- Only after fully processed and ready, finish completing the request and return a response message or partial response table + Fulfill = function(self, request) return "" end, +} +---Creates and returns a new IEvent object +---@param o? table Optional initial object table +---@return table event An IEvent object +function EventHandler.IEvent:new(o) + o = o or {} + o.Key = o.Key or EventHandler.Events.None.Key + o.Type = o.Type or EventHandler.EventTypes.None + if o.IsEnabled == nil then + o.IsEnabled = true + end + setmetatable(o, self) + self.__index = self + return o +end + +---Runs additional functions after Network attempts to connect +function EventHandler.onStartup() + local settingsUpdated = false + + local ballqEvent = EventHandler.Events.CMD_BallQueue or {} + local ballqRequest = EventHandler.Queues.BallRedeems.ActiveRequest + if ballqEvent.O_ShowBallQueueOnStartup and ballqRequest ~= nil then + -- Only show message if it wasn't shown the last startup + local lasGUID = Network.MetaSettings.network.LastBallQueueGUID or "" + if lasGUID ~= ballqRequest.GUID then + EventHandler.triggerEvent("CMD_BallQueue") + Network.MetaSettings.network.LastBallQueueGUID = ballqRequest.GUID + settingsUpdated = true + end + end + + if settingsUpdated then + Network.saveSettings() + end +end + +---Clears out existing event info; similar to initialize(), but managed by Network +function EventHandler.reset() + EventHandler.RewardsExternal = {} + EventHandler.Queues = { + BallRedeems = { Requests = {}, }, + } +end + +---Checks if the event is of a known event type +---@param event table IEvent +---@return boolean +function EventHandler.isValidEvent(event) + if not event then return false end + return EventHandler.Events[event.Key or false] and event.Key ~= EventHandler.Events.None.Key +end + +--- Adds an IEvent to the events list; returns true if successful +---@param event table IEvent object (requires: Key, Process, Fulfill) +---@return boolean success +function EventHandler.addNewEvent(event) + -- Only add new, properly structured events + if (event.Key or "") == "" or EventHandler.Events[event.Key] then + return false + end + -- Attempt to auto-detect the event type, based on other properties + if (event.Type or "") == "" or event.Type == EventHandler.EventTypes.None then + if event.Command and not event.RewardId then + event.Type = EventHandler.EventTypes.Command + elseif event.RewardId and not event.Command then + event.Type = EventHandler.EventTypes.Reward + else + event.Type = EventHandler.EventTypes.None + end + end + EventHandler.Events[event.Key] = event + EventHandler.loadEventSettings(event) + return true +end + +--- Adds an IEvent to the events list; returns true if successful +---@param eventKey string IEvent.Key +---@param fulfillFunc function Must return a string or a partial Response table { Message="", GlobalVars={} } +---@param name? string (Optional) A descriptive name for the event +---@return boolean success +function EventHandler.addNewGameEvent(eventKey, fulfillFunc, name) + if (eventKey or "") == "" or type(fulfillFunc) ~= "function" then + return false + end + return EventHandler.addNewEvent(EventHandler.IEvent:new({ + Key = eventKey, + Type = EventHandler.EventTypes.Game, + Name = name or eventKey, + Fulfill = fulfillFunc, + })) +end + +---Internally triggers an event by creating a new Request for it +---@param eventKey string IEvent.Key +---@param input? string +function EventHandler.triggerEvent(eventKey, input) + local event = EventHandler.Events[eventKey or false] + if not EventHandler.isValidEvent(event) then + return + end + RequestHandler.addUpdateRequest(RequestHandler.IRequest:new({ + EventKey = eventKey, + Args = { Input = input }, + })) +end + +--- Removes an IEvent from the events list; returns true if successful +---@param eventKey string IEvent.Key +---@return boolean success +function EventHandler.removeEvent(eventKey) + if not EventHandler.Events[eventKey] then + return false + end + EventHandler.Events[eventKey] = nil + return true +end + +---Returns the IEvent for a given command; or nil if not found +---@param command string Example: !testcommand +---@return table events List of events with matching commands +function EventHandler.getEventsForCommand(command) + local events = {} + if (command or "") == "" then + return events + end + if command:sub(1,1) ~= EventHandler.COMMAND_PREFIX then + command = EventHandler.COMMAND_PREFIX .. command + end + command = command:lower() + for _, event in pairs(EventHandler.Events) do + if event.Command == command then + table.insert(events, event) + end + end + return events +end + +---Returns the IEvent for a given rewardId; or nil if not found +---@param rewardId string +---@return table events List of events with matching rewards +function EventHandler.getEventsForReward(rewardId) + local events = {} + if (rewardId or "") == "" then + return events + end + for _, event in pairs(EventHandler.Events) do + if event.RewardId == rewardId then + table.insert(events, event) + end + end + return events +end + +---Updates internal Reward events with associated RewardIds and RewardTitles +---@param rewards table +function EventHandler.updateRewardList(rewards) + -- Unsure if this clear out is necessary yet + if #rewards > 0 then + EventHandler.RewardsExternal = {} + end + + for _, reward in pairs(rewards or {}) do + if (reward.Id or "") ~= "" and reward.Title then + EventHandler.RewardsExternal[reward.Id] = reward.Title + end + end + -- Temp disable any Reward events without matching reward ids + for _, event in pairs(EventHandler.Events) do + if event.Type == EventHandler.EventTypes.Reward and event.IsEnabled and event.RewardId and not EventHandler.RewardsExternal[event.RewardId] then + event.IsEnabled = false + end + end +end + +---Saves a configurable settings attribute for an event to the Settings.ini file +---@param event table IEvent +---@param attribute string The IEvent attribute being saved +function EventHandler.saveEventSetting(event, attribute) + if not EventHandler.isValidEvent(event) or not attribute then + return + end + local defaultEvent = EventHandler.DefaultEvents[event.Key] or {} + local key = string.format(EventHandler.EVENT_SETTINGS_FORMAT, event.Key, attribute) + local value = event[attribute] + -- Only save if the value isn't empty and it's not the known default value (keep Settings file a bit cleaner) + if value ~= nil and value ~= defaultEvent[attribute] then + -- Add MetaSetting (adds it as a new setting in the ini settings file) + if Network.MetaSettings.network == nil then + Network.MetaSettings.network = {} + end + Network.MetaSettings.network[key] = value + else + -- Remove MetaSetting (deletes it from the ini settings file) + if Network.MetaSettings.network ~= nil then + Network.MetaSettings.network[key] = nil + end + end + Network.saveSettings() + event.ConfigurationUpdated = true +end + +---Loads all configurable settings for an event from the Settings.ini file +---@param event table IEvent +function EventHandler.loadEventSettings(event) + if not EventHandler.isValidEvent(event) then + return false + end + local settings = Network.MetaSettings.network or {} + local anyLoaded = false + for attribute, existingValue in pairs(event) do + local key = string.format(EventHandler.EVENT_SETTINGS_FORMAT, event.Key, attribute) + local value = settings[key] + if value ~= nil and value ~= existingValue then + event[attribute] = value + anyLoaded = true + end + end + -- Disable any rewards without associations defined + if event.Type == EventHandler.EventTypes.Reward and event.IsEnabled and event.RewardId and event.RewardId == "" then + event.IsEnabled = false + end + event.ConfigurationUpdated = anyLoaded or nil +end + +---Checks if any event settings have been modified, and if so notify the external application of the changes +function EventHandler.checkForConfigChanges() + local modifiedEvents = {} + for _, event in pairs(EventHandler.Events) do + if event.ConfigurationUpdated then + table.insert(modifiedEvents, event) + event.ConfigurationUpdated = nil + end + end + if #modifiedEvents > 0 then + RequestHandler.addUpdateRequest(RequestHandler.IRequest:new({ + EventKey = EventHandler.CoreEventKeys.UpdateEvents, + Args = modifiedEvents + })) + end +end + +---Queues up a request to be processed at a later time (not immediate), determined by the event +---@param queueKey string The event queue this request will belong to +---@param request table IRequest +---@return boolean success +function EventHandler.queueRequestForLater(queueKey, request) + local Q = EventHandler.Queues[queueKey] + if not Q or Q.Requests[request.GUID] then + return false + end + Q.Requests[request.GUID] = request + -- NOTE: If any screen is displaying the current queue, refresh its list here + return true +end + +---Cancels and removes all active Requests from the requests queue; returns number that were cancelled +---@return number numCancelled +function EventHandler.cancelAllQueues() + local count = 0 + for _, queue in pairs(EventHandler.Queues or {}) do + for _, request in pairs(queue.Requests or {}) do + request.IsCancelled = true + count = count + 1 + end + queue.ActiveRequest = nil + queue.Requests = {} + end + return count +end + +function EventHandler.addDefaultEvents() + -- Add these events directly, as they aren't modifiable by the user + for key, event in pairs(EventHandler.CoreEvents) do + event.IsEnabled = true + event.Key = key + EventHandler.addNewEvent(event) + end + + -- Make a copy of each default event, such that they can still be referenced without being changed. + for key, event in pairs(EventHandler.DefaultEvents) do + event.IsEnabled = true + event.Key = key + local eventCopy = MiscUtils.deepCopy(event) + if eventCopy then + EventHandler.addNewEvent(eventCopy) + end + end +end + +function EventHandler.isDuplicateCommandRequest(event, request) + if event.Type ~= EventHandler.EventTypes.Command then + return false + end + if not request.SanitizedInput then + RequestHandler.sanitizeInput(request) + end + if not event.RecentRequests then + event.RecentRequests = {} + elseif event.RecentRequests[request.SanitizedInput] then + return true + end + event.RecentRequests[request.SanitizedInput] = os.time() + EventHandler.DUPLICATE_COMMAND_COOLDOWN + return false +end + +function EventHandler.cleanupDuplicateCommandRequests() + local currentTime = os.time() + for _, event in pairs(EventHandler.Events) do + if event.Type == EventHandler.EventTypes.Command and event.RecentRequests then + for requestInput, timestamp in pairs(event.RecentRequests) do + if currentTime > timestamp then + event.RecentRequests[requestInput] = nil + end + end + end + end +end + +-- Helper functions +local function parseBallChoice(input) + -- Not implemented + local keyword = "Random" + local ballNumber = math.random(3) + return keyword, ballNumber +end + +local function changeStarterFavorite(pokemonName, slotNumber) + -- Not implemented + slotNumber = slotNumber or 1 + local originalFaveName = "Bulbasaur" + local newFaveName = "Ivysaur" + return string.format("Favorite #%s changed from %s to %s.", + slotNumber, + originalFaveName, + newFaveName) +end + +EventHandler.CoreEvents = { + -- TS_: Tracker Server (Core events that shouldn't be modified) + [EventHandler.CoreEventKeys.Start] = { + Type = EventHandler.EventTypes.Tracker, + Exclude = true, + Process = function(self, request) + -- Wait to hear from Streamerbot before fulfilling this request + return request.Args.Source == RequestHandler.SOURCE_STREAMERBOT + end, + Fulfill = function(self, request) + Network.updateConnectionState(Network.ConnectionState.Established) + Network.checkVersion(request.Args and request.Args.Version or "") + RequestHandler.removedExcludedRequests() + -- NOTE: If any screen is displaying connection status info, add code here to refresh it + print("[Stream Connect] Connected to Streamer.bot") + return RequestHandler.REQUEST_COMPLETE + end, + }, + [EventHandler.CoreEventKeys.Stop] = { + Type = EventHandler.EventTypes.Tracker, + Exclude = true, + Process = function(self, request) + local ableToStop = Network.CurrentConnection.State >= Network.ConnectionState.Established + -- Wait to hear from Streamerbot before fulfilling this request + return ableToStop and request.Args.Source == RequestHandler.SOURCE_STREAMERBOT + end, + Fulfill = function(self, request) + Network.updateConnectionState(Network.ConnectionState.Listen) + RequestHandler.removedExcludedRequests() + -- NOTE: If any screen is displaying connection status info, add code here to refresh it + return RequestHandler.REQUEST_COMPLETE + end, + }, + [EventHandler.CoreEventKeys.GetRewards] = { + Type = EventHandler.EventTypes.Tracker, + Exclude = true, + Process = function(self, request) + -- Wait to hear from Streamerbot before fulfilling this request + return request.Args.Source == RequestHandler.SOURCE_STREAMERBOT + end, + Fulfill = function(self, request) + EventHandler.updateRewardList(request.Args.Rewards) + return RequestHandler.REQUEST_COMPLETE + end, + }, + [EventHandler.CoreEventKeys.UpdateEvents] = { + Type = EventHandler.EventTypes.Tracker, + Exclude = true, + Fulfill = function(self, request) + local allowedEvents = {} + for _, event in pairs(EventHandler.Events) do + if event.IsEnabled and not event.Exclude then + if event.Type == EventHandler.EventTypes.Command then + table.insert(allowedEvents, event.Command:sub(2)) + elseif event.Type == EventHandler.EventTypes.Reward then + table.insert(allowedEvents, event.RewardId) + end + end + end + return { + AdditionalInfo = { + AllowedEvents = table.concat(allowedEvents, ","), + CommandRoles = Network.Options["CommandRoles"] or EventHandler.CommandRoles.Everyone, + }, + } + end, + } +} + +EventHandler.DefaultEvents = { + -- CMD_: Chat Commands + CMD_Pokemon = { + Type = EventHandler.EventTypes.Command, + Command = "!pokemon", + Name = "Pokémon Info", + Help = "name > Displays useful game info for a Pokémon.", + Fulfill = function(self, request) return EventData.getPokemon(request.SanitizedInput) end, + }, + CMD_BST = { + Type = EventHandler.EventTypes.Command, + Command = "!bst", + Name = "Pokémon BST", + Help = "name > Displays the base stat total (BST) for a Pokémon.", + Fulfill = function(self, request) return EventData.getBST(request.SanitizedInput) end, + }, + CMD_Weak = { + Type = EventHandler.EventTypes.Command, + Command = "!weak", + Name = "Pokémon Weaknesses", + Help = "name > Displays the weaknesses for a Pokémon.", + Fulfill = function(self, request) return EventData.getWeak(request.SanitizedInput) end, + }, + CMD_Move = { + Type = EventHandler.EventTypes.Command, + Command = "!move", + Name = "Move Info", + Help = "name > Displays game info for a move.", + Fulfill = function(self, request) return EventData.getMove(request.SanitizedInput) end, + }, + CMD_Ability = { + Type = EventHandler.EventTypes.Command, + Command = "!ability", + Name = "Ability Info", + Help = "name > Displays game info for a Pokémon's ability.", + Fulfill = function(self, request) return EventData.getAbility(request.SanitizedInput) end, + }, + CMD_Route = { + Type = EventHandler.EventTypes.Command, + Command = "!route", + Name = "Route Info", + Help = "name > Displays trainer and wild encounter info for a route or area.", + Fulfill = function(self, request) return EventData.getRoute(request.SanitizedInput) end, + }, + -- CMD_Dungeon = { + -- Type = EventHandler.EventTypes.Command, + -- Command = "!dungeon", + -- Name = "Dungeon Info", + -- Help = "name > Displays info about which trainers have been defeated for an area.", + -- Fulfill = function(self, request) return EventData.getDungeon(request.SanitizedInput) end, + -- }, + -- CMD_Unfought = { + -- Type = EventHandler.EventTypes.Command, + -- Command = "!unfought", + -- Name = "Unfought Trainers", + -- Help = "[dungeon] [sevii]> Displays a list of areas ordered by lowest-level, undefeated trainers. (Add param 'dungeon' to include partially completed dungeons.)", + -- Fulfill = function(self, request) return EventData.getUnfoughtTrainers(request.SanitizedInput) end, + -- }, + CMD_Pivots = { + Type = EventHandler.EventTypes.Command, + Command = "!pivots", + Name = "Pivots Seen", + Help = "> Displays known early game wild encounters for an area.", + Fulfill = function(self, request) return EventData.getPivots(request.SanitizedInput) end, + }, + CMD_Revo = { + Type = EventHandler.EventTypes.Command, + Command = "!revo", + Name = "Pokémon Random Evolutions", + Help = "name [target-evo] > Displays randomized evolution possibilities for a Pokémon, and it's [target-evo] if more than one available.", + Fulfill = function(self, request) return EventData.getRevo(request.SanitizedInput) end, + }, + CMD_Coverage = { + Type = EventHandler.EventTypes.Command, + Command = "!coverage", + Name = "Move Coverage Effectiveness", + Help = "types [fully evolved] > For a list of move types, checks all Pokémon matchups (or only [fully evolved]) for effectiveness.", + Fulfill = function(self, request) return EventData.getCoverage(request.SanitizedInput) end, + }, + CMD_Heals = { + Type = EventHandler.EventTypes.Command, + Command = "!heals", + Name = "Heals in Bag", + Help = "[hp pp status berries] > Displays all healing items in the bag, or only those for a specified [category].", + Fulfill = function(self, request) return EventData.getHeals(request.SanitizedInput) end, + }, + CMD_TMs = { + Type = EventHandler.EventTypes.Command, + Command = "!tms", + Name = "TM Lookup", + Help = "[gym hm #] > Displays all TMs in the bag, or only those for a specified [category] or TM #.", + Fulfill = function(self, request) return EventData.getTMsHMs(request.SanitizedInput) end, + }, + CMD_Search = { + Type = EventHandler.EventTypes.Command, + Command = "!search", + Name = "Search Tracked Info", + Help = "searchterms > Search tracked info for a Pokémon, move, or ability.", + Fulfill = function(self, request) return EventData.getSearch(request.SanitizedInput) end, + }, + CMD_SearchNotes = { + Type = EventHandler.EventTypes.Command, + Command = "!searchnotes", + Name = "Search Notes on Pokémon", + Help = "notes > Displays a list of Pokémon with any matching notes.", + Fulfill = function(self, request) return EventData.getSearchNotes(request.SanitizedInput) end, + }, + CMD_Favorites = { + Type = EventHandler.EventTypes.Command, + Command = "!favorites", + Name = "Favorite Starters", + Help = "> Displays the list of favorites used for picking a starter.", + Fulfill = function(self, request) return EventData.getFavorites(request.SanitizedInput) end, + }, + CMD_Theme = { + Type = EventHandler.EventTypes.Command, + Command = "!theme", + Name = "Theme Export", + Help = "name > Displays the name and code string for a Tracker theme.", + Fulfill = function(self, request) return EventData.getTheme(request.SanitizedInput) end, + }, + -- CMD_GameStats = { + -- Type = EventHandler.EventTypes.Command, + -- Command = "!gamestats", + -- Name = "Game Stats", + -- Help = "> Displays fun stats for the current game.", + -- Fulfill = function(self, request) return EventData.getGameStats(request.SanitizedInput) end, + -- }, + CMD_Progress = { + Type = EventHandler.EventTypes.Command, + Command = "!progress", + Name = "Game Progress", + Help = "> Displays fun progress percentages for the current game.", + Fulfill = function(self, request) return EventData.getProgress(request.SanitizedInput) end, + }, + CMD_Log = { + Type = EventHandler.EventTypes.Command, + Command = "!log", + Name = "Log Randomizer Settings", + Help = "> If the log has been opened, displays shareable randomizer settings from the log for current game.", + Fulfill = function(self, request) return EventData.getLog(request.SanitizedInput) end, + }, + -- CMD_BallQueue = { + -- Type = EventHandler.EventTypes.Command, + -- Command = "!ballqueue", + -- Name = "Ball Queue", + -- Help = "> Displays the size of the ball queue and the current pick, if any.", + -- Options = { "O_ShowBallQueueOnStartup", }, + -- O_ShowBallQueueOnStartup = false, + -- Fulfill = function(self, request) return EventData.getBallQueue(request.SanitizedInput) end, + -- }, + CMD_About = { + Type = EventHandler.EventTypes.Command, + Command = "!about", + Name = "About the Tracker", + Help = "> Displays info about the Ironmon Tracker and game being played.", + Fulfill = function(self, request) return EventData.getAbout(request.SanitizedInput) end, + }, + CMD_Help = { + Type = EventHandler.EventTypes.Command, + Command = "!help", + Name = "Command Help", + Help = "[command] > Displays a list of all commands, or help info for a specified [command].", + Fulfill = function(self, request) return EventData.getHelp(request.SanitizedInput) end, + }, + + -- CR_: Channel Rewards (Point Redeems) + CR_PickBallOnce = { + Type = EventHandler.EventTypes.Reward, + Name = "Pick Starter Ball (One Try)", + RewardId = "", + Options = { + "O_SendMessage", "O_AutoComplete", "O_RequireChosenMon", + "O_WordForLeft", "O_WordForMiddle", "O_WordForRight", "O_WordForRandom", + }, + O_SendMessage = true, + O_AutoComplete = true, + O_RequireChosenMon = true, + O_WordForLeft = "Left", + O_WordForMiddle = "Mid", + O_WordForRight = "Right", + O_WordForRandom = "Random", + Process = function(self, request) + -- Not implemented + return true + end, + Fulfill = function(self, request) + -- Not implemented + return "" + end, + }, + CR_PickBallUntilOut = { + Type = EventHandler.EventTypes.Reward, + Name = "Pick Starter Ball (Until Out)", + RewardId = "", + Options = { + "O_SendMessage", "O_AutoComplete", "O_RequireChosenMon", + "O_WordForLeft", "O_WordForMiddle", "O_WordForRight", "O_WordForRandom", + }, + O_SendMessage = true, + O_AutoComplete = true, + O_RequireChosenMon = true, + O_WordForLeft = "Left", + O_WordForMiddle = "Mid", + O_WordForRight = "Right", + O_WordForRandom = "Random", + Process = function(self, request) + -- Not implemented + return true + end, + Fulfill = function(self, request) + -- Not implemented + return "" + end, + }, + CR_ChangeFavorite = { + Type = EventHandler.EventTypes.Reward, + Name = "Change Starter Favorite: # NAME", + RewardId = "", + Options = { "O_SendMessage", "O_AutoComplete", }, + O_SendMessage = true, + O_AutoComplete = true, + Fulfill = function(self, request) + local response = { AdditionalInfo = { AutoComplete = false } } + if (request.SanitizedInput or "") == "" then + response.Message = string.format("> Unable to change a favorite, please enter a number (1, 2, or 3) followed by a Pokémon name.") + return response + end + local slotNumber, pokemonName = request.SanitizedInput:match("^#?(%d*)%s*(%D.+)") + local successMsg = changeStarterFavorite(pokemonName, slotNumber) + if not successMsg then + response.Message = string.format("%s > Unable to change a favorite, please enter a number (1, 2, or 3) followed by a Pokémon name.", request.SanitizedInput) + return response + end + if self.O_SendMessage then + response.Message = successMsg + end + response.AdditionalInfo.AutoComplete = self.O_AutoComplete + return response + end, + }, + CR_ChangeFavoriteOne = { + Type = EventHandler.EventTypes.Reward, + Name = "Change Starter Favorite: #1", + RewardId = "", + Options = { "O_SendMessage", "O_AutoComplete", }, + O_SendMessage = true, + O_AutoComplete = true, + Fulfill = function(self, request) + local response = { AdditionalInfo = { AutoComplete = false } } + if (request.SanitizedInput or "") == "" then + response.Message = string.format("> Unable to change favorite #1, please enter a valid Pokémon name.") + return response + end + local successMsg = changeStarterFavorite(request.SanitizedInput, 1) + if not successMsg then + response.Message = string.format("%s > Unable to change favorite #1, please enter a valid Pokémon name.", request.SanitizedInput) + return response + end + if self.O_SendMessage then + response.Message = successMsg + end + response.AdditionalInfo.AutoComplete = self.O_AutoComplete + return response + end, + }, + CR_ChangeFavoriteTwo = { + Type = EventHandler.EventTypes.Reward, + Name = "Change Starter Favorite: #2", + RewardId = "", + Options = { "O_SendMessage", "O_AutoComplete", }, + O_SendMessage = true, + O_AutoComplete = true, + Fulfill = function(self, request) + local response = { AdditionalInfo = { AutoComplete = false } } + if (request.SanitizedInput or "") == "" then + response.Message = string.format("> Unable to change favorite #2, please enter a valid Pokémon name.") + return response + end + local successMsg = changeStarterFavorite(request.SanitizedInput, 2) + if not successMsg then + response.Message = string.format("%s > Unable to change favorite #2, please enter a valid Pokémon name.", request.SanitizedInput) + return response + end + if self.O_SendMessage then + response.Message = successMsg + end + response.AdditionalInfo.AutoComplete = self.O_AutoComplete + return response + end, + }, + CR_ChangeFavoriteThree = { + Type = EventHandler.EventTypes.Reward, + Name = "Change Starter Favorite: #3", + RewardId = "", + Options = { "O_SendMessage", "O_AutoComplete", }, + O_SendMessage = true, + O_AutoComplete = true, + Fulfill = function(self, request) + local response = { AdditionalInfo = { AutoComplete = false } } + if (request.SanitizedInput or "") == "" then + response.Message = string.format("> Unable to change favorite #3, please enter a valid Pokémon name.") + return response + end + local successMsg = changeStarterFavorite(request.SanitizedInput, 3) + if not successMsg then + response.Message = string.format("%s > Unable to change favorite #3, please enter a valid Pokémon name.", request.SanitizedInput) + return response + end + if self.O_SendMessage then + response.Message = successMsg + end + response.AdditionalInfo.AutoComplete = self.O_AutoComplete + return response + end, + }, + CR_ChangeTheme = { + Type = EventHandler.EventTypes.Reward, + Name = "Change Tracker Theme", + RewardId = "", + Options = { "O_SendMessage", "O_AutoComplete", "O_Duration", }, + O_SendMessage = true, + O_AutoComplete = true, + -- O_Duration = tostring(10 * 60), -- # of seconds + Fulfill = function(self, request) + local response = { AdditionalInfo = { AutoComplete = false } } + if (request.SanitizedInput or "") == "" then + response.Message = string.format("> Unable to change Tracker Theme, please enter a valid theme code or name.") + return response + end + -- Not implemented + return "" + end, + }, + CR_ChangeLanguage = { + Type = EventHandler.EventTypes.Reward, + Name = "Change Tracker Language", + RewardId = "", + Options = { "O_SendMessage", "O_AutoComplete", }, + O_SendMessage = true, + O_AutoComplete = true, + -- O_Duration = tostring(10 * 60), -- # of seconds + Fulfill = function(self, request) + local response = { AdditionalInfo = { AutoComplete = false } } + if (request.SanitizedInput or "") == "" then + response.Message = string.format("> Unable to change Tracker language, please enter a valid language name.") + return response + end + -- Not implemented + return "" + end, + }, +} diff --git a/ironmon_tracker/network/Json.lua b/ironmon_tracker/network/Json.lua new file mode 100644 index 00000000..65c02f18 --- /dev/null +++ b/ironmon_tracker/network/Json.lua @@ -0,0 +1,389 @@ +-- +-- json.lua +-- +-- Copyright (c) 2020 rxi +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy of +-- this software and associated documentation files (the "Software"), to deal in +-- the Software without restriction, including without limitation the rights to +-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +-- of the Software, and to permit persons to whom the Software is furnished to do +-- so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in all +-- copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. +-- + +local json = { _version = "0.1.2" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\", + [ "\"" ] = "\"", + [ "\b" ] = "b", + [ "\f" ] = "f", + [ "\n" ] = "n", + [ "\r" ] = "r", + [ "\t" ] = "t", +} + +local escape_char_map_inv = { [ "/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte())) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if rawget(val, 1) ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, + [ "function" ] = encode_nil, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(1, 4), 16 ) + local n2 = tonumber( s:sub(7, 10), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + + +return json diff --git a/ironmon_tracker/network/Network.lua b/ironmon_tracker/network/Network.lua new file mode 100644 index 00000000..102ff78f --- /dev/null +++ b/ironmon_tracker/network/Network.lua @@ -0,0 +1,440 @@ +Network = { + CurrentConnection = {}, + lastUpdateTime = 0, + STREAMERBOT_VERSION = "1.0.1", -- Known streamerbot version. Update this value to inform user to update streamerbot code + TEXT_UPDATE_FREQUENCY = 2, -- # of seconds + SOCKET_UPDATE_FREQUENCY = 2, -- # of seconds + HTTP_UPDATE_FREQUENCY = 2, -- # of seconds + TEXT_INBOUND_FILE = "NDS-Tracker-Requests.json", -- The CLIENT's outbound data file; Tracker is the "Server" and will read requests from this file + TEXT_OUTBOUND_FILE = "NDS-Tracker-Responses.json", -- The CLIENT's inbound data file; Tracker is the "Server" and will write responses to this file + SOCKET_SERVER_NOT_FOUND = "Socket server was not initialized", + FILE_SETTINGS = "NetworkSettings.ini", + FILE_IMPORT_CODE = "StreamerbotCodeImport.txt", +} + +Network.ConnectionTypes = { + None = "None", + Text = "Text", + + -- WebSockets WARNING: Bizhawk must be started with command line arguments to enable connections + -- It must also be a custom/new build of Bizhawk that actually supports asynchronous web sockets (not released yet) + WebSockets = "WebSockets", + + -- Http WARNING: If Bizhawk is not started with command line arguments to enable connections + -- Then an internal Bizhawk error will crash the tracker. This cannot be bypassed with pcall() or other exception handling + -- Consider turning off "AutoConnectStartup" if exploring Http + Http = "Http", +} + +Network.ConnectionState = { + Closed = 0, -- The server (Tracker) is not currently connected nor trying to connect + Listen = 1, -- The server (Tracker) is online and trying to connect, waiting for response from a client + Established = 9, -- Both the server (Tracker) and client are connected; communication is open +} + +Network.Options = { + ["AutoConnectStartup"] = true, + ["ConnectionType"] = Network.ConnectionTypes.Text, + ["DataFolder"] = "", + ["WebSocketIP"] = "0.0.0.0", -- Localhost: 127.0.0.1 + ["WebSocketPort"] = "8080", + ["HttpGet"] = "", + ["HttpPost"] = "", + ["CommandRoles"] = "Everyone", -- A comma-separated list of allowed roles for command events + ["CustomCommandRole"] = "", -- Currently unused, not supported +} + +-- In some cases, allow a mismatch between Tracker code and Streamerbot code +-- This simply offers convenience for the end user, such that they aren't forced to update to continue using it +Network.DeprecatedVersions = { + -- FAKE EXAMPLE: On version 1.0.0 of Streamerbot code, the message cap limit was assumed to be checked by the Tracker, not Streamerbot itself + -- This override forces the Tracker to check the message cap + -- ["1.0.0"] = function() + -- RequestHandler.REQUIRES_MESSAGE_CAP = true + -- end, +} + +-- Connection object prototype +Network.IConnection = { + Type = Network.ConnectionTypes.None, + State = Network.ConnectionState.Closed, + UpdateFrequency = -1, -- Number of seconds; 0 or less will prevent scheduled updates + SendReceive = function(self) end, + -- Don't override the follow functions + SendReceiveOnSchedule = function(self, updateFunc) + if (self.UpdateFrequency or 0) > 0 and (os.time() - Network.lastUpdateTime) >= self.UpdateFrequency then + updateFunc = updateFunc or self.SendReceive + if type(updateFunc) == "function" then + updateFunc(self) + end + Network.lastUpdateTime = os.time() + end + end, +} +function Network.IConnection:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + return o +end + +function Network.initialize(initialProgram) + Network.program = initialProgram + Network.iniParser = dofile(Paths.FOLDERS.DATA_FOLDER .. "/Inifile.lua") + + dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/Json.lua") + dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/EventData.lua") + dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/EventHandler.lua") + dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/RequestHandler.lua") + NetworkUtils.setupJsonLibrary() + Network.loadSettings() + + -- Clear and reload Event and Request information + EventHandler.reset() + RequestHandler.reset() + EventHandler.addDefaultEvents() + RequestHandler.loadRequestsData() + RequestHandler.removedExcludedRequests() + + Network.requiresUpdating = false + Network.lastUpdateTime = 0 + Network.loadConnectionSettings() + if Network.Options["AutoConnectStartup"] then + Network.tryConnect() + end +end + +function Network.startup() + EventHandler.onStartup() + -- Remove the delayed start frame counter; should not repeat + if Network.program and Network.program.frameCounters then + Network.program.frameCounters.networkStartup = nil + end +end + +---Checks current version of the Tracker's Network code against the Streamerbot code version +---@param externalVersion string +function Network.checkVersion(externalVersion) + externalVersion = externalVersion or "0.0.0" + Network.currentStreamerbotVersion = externalVersion + local changeFunc = Network.DeprecatedVersions[externalVersion] + if type(changeFunc) == "function" then + changeFunc() + end + + -- Convert verion strings to numbers such that "05" is less than "8" + local major1, minor1, patch1 = string.match(Network.STREAMERBOT_VERSION, "(%d+)%.(%d+)%.?(%d*)") + local major2, minor2, patch2 = string.match(externalVersion, "(%d+)%.(%d+)%.?(%d*)") + major1 = tonumber(major1 or "") or 0 + major2 = tonumber(major2 or "") or 0 + minor1 = tonumber(minor1 or "") or 0 + minor2 = tonumber(minor2 or "") or 0 + patch1 = tonumber(patch1 or "") or 0 + patch2 = tonumber(patch2 or "") or 0 + + -- If tracker code version is newer (greater) than external Streamerbot version, require an update + Network.requiresUpdating = (major1 * 10000 + minor1 * 100 + patch1) > (major2 * 10000 + minor2 * 100 + patch2) + if Network.requiresUpdating then + Network.openUpdateRequiredPrompt() + end +end + +---@return boolean +function Network.isConnected() + return Network.CurrentConnection.State > Network.ConnectionState.Closed +end + +---@return table supportedTypes +function Network.getSupportedConnectionTypes() + local supportedTypes = { + Network.ConnectionTypes.Text, + -- Network.ConnectionTypes.WebSockets, -- Not fully supported + -- Network.ConnectionTypes.Http, -- Not fully supported + } + return supportedTypes +end + +function Network.loadConnectionSettings() + Network.CurrentConnection = Network.IConnection:new() + if (Network.Options["ConnectionType"] or "") ~= "" then + Network.changeConnection(Network.Options["ConnectionType"]) + end +end + +---Changes the current connection type +---@param connectionType string A Network.ConnectionTypes enum +function Network.changeConnection(connectionType) + connectionType = connectionType or Network.ConnectionTypes.None + -- Create or swap to a new connection + if Network.CurrentConnection.Type ~= connectionType then + if Network.isConnected() then + Network.closeConnections() + end + Network.CurrentConnection = Network.IConnection:new({ Type = connectionType }) + Network.Options["ConnectionType"] = connectionType + Network.saveSettings() + end +end + +---Attempts to connect to the network using the current connection +---@return number connectionState The resulting Network.ConnectionState +function Network.tryConnect() + local C = Network.CurrentConnection or {} + -- Create or swap to a new connection + if not C.Type then + Network.changeConnection(Network.ConnectionTypes.None) + C = Network.CurrentConnection + end + -- Don't try to connect if connection is fully established + if C.State >= Network.ConnectionState.Established then + return C.State + end + if C.Type == Network.ConnectionTypes.WebSockets then + if true then return Network.ConnectionState.Closed end -- Not fully supported + C.UpdateFrequency = Network.SOCKET_UPDATE_FREQUENCY + C.SendReceive = Network.updateBySocket + C.SocketIP = Network.Options["WebSocketIP"] or "0.0.0.0" + C.SocketPort = tonumber(Network.Options["WebSocketPort"] or "") or 0 + local serverInfo + if C.SocketIP ~= "0.0.0.0" and C.SocketPort ~= 0 then + comm.socketServerSetIp(C.SocketIP) + comm.socketServerSetPort(C.SocketPort) + serverInfo = comm.socketServerGetInfo() or Network.SOCKET_SERVER_NOT_FOUND + -- Might also test/try 'bool comm.socketServerIsConnected()' + end + local ableToConnect = serverInfo and serverInfo:lower():find(Network.SOCKET_SERVER_NOT_FOUND, 1, true) ~= nil + if ableToConnect then + C.State = Network.ConnectionState.Listen + comm.socketServerSetTimeout(500) -- # of milliseconds + end + elseif C.Type == Network.ConnectionTypes.Http then + if true then return Network.ConnectionState.Closed end -- Not fully supported + C.UpdateFrequency = Network.HTTP_UPDATE_FREQUENCY + C.SendReceive = Network.updateByHttp + C.HttpGetUrl = Network.Options["HttpGet"] or "" + C.HttpPostUrl = Network.Options["HttpPost"] or "" + if not (C.HttpGetUrl or "") ~= "" then + -- Necessary for comm.httpTest() + comm.httpSetGetUrl(C.HttpGetUrl) + end + if not (C.HttpPostUrl or "") ~= "" then + -- Necessary for comm.httpTest() + comm.httpSetPostUrl(C.HttpPostUrl) + end + local result + if (C.HttpGetUrl or "") ~= "" and C.HttpPostUrl then + -- See HTTP WARNING at the top of this file + pcall(function() result = comm.httpTest() or "N/A" end) + end + local ableToConnect = result and result:lower():find("done testing", 1, true) ~= nil + if ableToConnect then + C.State = Network.ConnectionState.Listen + comm.httpSetTimeout(500) -- # of milliseconds + end + elseif C.Type == Network.ConnectionTypes.Text then + C.UpdateFrequency = Network.TEXT_UPDATE_FREQUENCY + C.SendReceive = Network.updateByText + local folder = Network.Options["DataFolder"] or "" + C.InboundFile = folder .. "/" .. Network.TEXT_INBOUND_FILE + C.OutboundFile = folder .. "/" .. Network.TEXT_OUTBOUND_FILE + local ableToConnect = (folder or "") ~= "" and NetworkUtils.folderExists(folder) + if ableToConnect then + C.State = Network.ConnectionState.Listen + end + end + if C.State == Network.ConnectionState.Listen then + RequestHandler.addUpdateRequest(RequestHandler.IRequest:new({ + EventKey = EventHandler.CoreEventKeys.Start, + })) + RequestHandler.addUpdateRequest(RequestHandler.IRequest:new({ + EventKey = EventHandler.CoreEventKeys.GetRewards, + })) + RequestHandler.addUpdateRequest(RequestHandler.IRequest:new({ + EventKey = EventHandler.CoreEventKeys.UpdateEvents, + })) + end + return C.State +end + +---Updates the current connection state to the one provided +---@param connectionState number a Network.ConnectionState +function Network.updateConnectionState(connectionState) + Network.CurrentConnection.State = connectionState +end + +--- Closes any active connections and saves outstanding Requests +function Network.closeConnections() + if Network.isConnected() then + RequestHandler.addUpdateRequest(RequestHandler.IRequest:new({ + EventKey = EventHandler.CoreEventKeys.Stop, + })) + Network.CurrentConnection:SendReceive() + Network.updateConnectionState(Network.ConnectionState.Closed) + end + RequestHandler.saveRequestsData() +end + +--- Attempts to perform the scheduled network data update +function Network.update() + if not Network.isConnected() then + return + end + Network.CurrentConnection:SendReceiveOnSchedule() + RequestHandler.saveRequestsDataOnSchedule() +end + +--- The update function used by the "Text" Network connection type +function Network.updateByText() + local C = Network.CurrentConnection + if not C.InboundFile or not C.OutboundFile or not NetworkUtils.JsonLibrary then + return + end + + EventHandler.checkForConfigChanges() + local requestsAsJson = NetworkUtils.decodeJsonFile(C.InboundFile) + RequestHandler.receiveJsonRequests(requestsAsJson) + RequestHandler.processAllRequests() + local responses = RequestHandler.getResponses() + -- Prevent consecutive "empty" file writes + if #responses > 0 or not C.InboundWasEmpty then + local success = NetworkUtils.encodeToJsonFile(C.OutboundFile, responses) + C.InboundWasEmpty = (success == false) -- false if no resulting json data + RequestHandler.clearAllResponses() + end +end + +--- The update function used by the "Socket" Network connection type +function Network.updateBySocket() + -- Not implemented. Requires asynchronous compatibility + if true then return end + + local C = Network.CurrentConnection + if C.SocketIP == "0.0.0.0" or C.SocketPort == 0 or not NetworkUtils.JsonLibrary then + return + end + + EventHandler.checkForConfigChanges() + local input = "" + local requestsAsJson = NetworkUtils.JsonLibrary.decode(input) or {} + RequestHandler.receiveJsonRequests(requestsAsJson) + RequestHandler.processAllRequests() + local responses = RequestHandler.getResponses() + if #responses > 0 then + local output = NetworkUtils.JsonLibrary.encode(responses) or "[]" + RequestHandler.clearAllResponses() + end +end + +--- The update function used by the "Http" Network connection type +function Network.updateByHttp() + -- Not implemented. Requires asynchronous compatibility + if true then return end + + local C = Network.CurrentConnection + if (C.HttpGetUrl or "") == "" or (C.HttpPostUrl or "") == "" or not NetworkUtils.JsonLibrary then + return + end + + EventHandler.checkForConfigChanges() + local resultGet = comm.httpGet(C.HttpGetUrl) or "" + local requestsAsJson = NetworkUtils.JsonLibrary.decode(resultGet) or {} + RequestHandler.receiveJsonRequests(requestsAsJson) + RequestHandler.processAllRequests() + local responses = RequestHandler.getResponses() + if #responses > 0 then + local payload = NetworkUtils.JsonLibrary.encode(responses) or "[]" + local resultPost = comm.httpPost(C.HttpPostUrl, payload) + RequestHandler.clearAllResponses() + end +end + +function Network.getStreamerbotCode() + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. "/" .. Network.FILE_IMPORT_CODE + local lines = MiscUtils.readLinesFromFile(filepath) or {} + return lines[1] or "" +end + +function Network.openUpdateRequiredPrompt() + local form = FormsUtils.popupDialog("Streamerbot Update Required", 350, 150, FormsUtils.POPUP_DIALOG_TYPES.WARNING, true) + -- TODO: Fix + -- local x, y, lineHeight = 20, 20, 20 + -- form:createLabel(Resources.StreamConnect.PromptUpdateDesc1, x, y) + -- y = y + lineHeight + -- form:createLabel(Resources.StreamConnect.PromptUpdateDesc2, x, y) + -- y = y + lineHeight + -- -- Bottom row buttons + -- y = y + 10 + -- form:createButton(Resources.StreamConnect.PromptNetworkShowMe, 40, y, function() + -- form:destroy() + -- StreamConnectOverlay.openGetCodeWindow() + -- end) + -- form:createButton(Resources.StreamConnect.PromptNetworkTurnOff, 150, y, function() + -- Network.Options["AutoConnectStartup"] = false + -- Main.SaveSettings(true) + -- Network.closeConnections() + -- form:destroy() + -- end) +end + +function Network.loadSettings() + Network.MetaSettings = {} + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. "/" .. Network.FILE_SETTINGS + if not FormsUtils.fileExists(filepath) then + return + end + + local settings = Network.iniParser.parse(filepath) or {} + -- Keep the meta data for saving settings later in a specified order + Network.MetaSettings = settings + + -- [NETWORK] + if settings.network ~= nil then + for key, _ in pairs(Network.Options or {}) do + local optionValue = settings.network[string.gsub(key, " ", "_")] + if optionValue ~= nil then + Network.Options[key] = optionValue + end + end + end +end + +function Network.saveSettings() + local settings = Network.MetaSettings or {} + settings.network = settings.network or {} + + -- [NETWORK] + for key, val in pairs(Network.Options or {}) do + local encodedKey = string.gsub(key, " ", "_") + settings.network[encodedKey] = val + end + + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. "/" .. Network.FILE_SETTINGS + Network.iniParser.save(filepath, settings) +end + +-- Not supported + +-- [Web Sockets] Streamer.bot Docs +-- https://wiki.streamer.bot/en/Servers-Clients +-- https://wiki.streamer.bot/en/Servers-Clients/WebSocket-Server +-- https://wiki.streamer.bot/en/Servers-Clients/WebSocket-Server/Requests +-- https://wiki.streamer.bot/en/Servers-Clients/WebSocket-Server/Events +-- [Web Sockets] Bizhawk Docs +-- string comm.socketServerGetInfo -- returns the IP and port of the Lua socket server +-- bool comm.socketServerIsConnected -- socketServerIsConnected +-- string comm.socketServerResponse -- Receives a message from the Socket server. Formatted with msg length at start, e.g. "3 ABC" +-- int comm.socketServerSend -- sends a string to the Socket server +-- void comm.socketServerSetTimeout -- sets the timeout in milliseconds for receiving messages +-- bool comm.socketServerSuccessful -- returns the status of the last Socket server action + +-- --- Registering an event is required to enable you to listen to events emitted by Streamer.bot: +-- --- https://wiki.streamer.bot/en/Servers-Clients/WebSocket-Server/Events +-- ---@param requestId string Example: "123" +-- ---@param eventSource string Example: "Command" +-- ---@param eventTypes table Example: { "Message", "Whisper" } +-- function Network.registerWebSocketEvent(requestId, eventSource, eventTypes) +-- local registerFormat = [[{"request":"Subscribe","id":"%s","events":{"%s":[%s]}}]] +-- local requestStr = string.format(registerFormat, requestId, eventSource, table.concat(eventTypes, ",")) +-- local response = comm.socketServerSend(requestStr) +-- -- -1 = failed ? +-- end \ No newline at end of file diff --git a/ironmon_tracker/network/RequestHandler.lua b/ironmon_tracker/network/RequestHandler.lua new file mode 100644 index 00000000..e434b578 --- /dev/null +++ b/ironmon_tracker/network/RequestHandler.lua @@ -0,0 +1,370 @@ +RequestHandler = { + Requests = {}, -- A list of all known requests that still need to be processed + Responses = {}, -- A list of all responses ready to be sent + lastSaveTime = 0, + SAVE_FREQUENCY = 60, -- Number of seconds to wait before saving Requests data to file + REQUIRES_MESSAGE_CAP = false, -- Will shorten outgoing responses to message cap (not needed anymore, done on Streamerbot side) + TWITCH_MESSAGE_CAP = 499, -- Maximum # of characters to allow for a given response message + YOUTUBE_MESSAGE_CAP = 200, -- Maximum # of characters to allow for a given response message + SAVED_REQUESTS = "Requests.json", -- Filename where requests are saved between game sessions + + -- Shared values between server and client + SOURCE_STREAMERBOT = "Streamerbot", + REQUEST_COMPLETE = "Complete", + PLATFORM_TWITCH = "twitch", + PLATFORM_YOUTUBE = "youtube", +} + +-- https://developer.mozilla.org/en-US/docs/Web/HTTP/Status +RequestHandler.StatusCodes = { + PROCESSING = 102, -- The server (Tracker) has received and is processing the request, but no response is available yet + SUCCESS = 200, -- The request succeeded and a response message is available + ALREADY_REPORTED = 208, -- The request is a duplicate of another recent request, no additional response message will be sent + FAIL = 400, -- The server (Tracker) won't process, likely due to a client error with formatting the request + NOT_FOUND = 404, -- The server (Tracker) cannot find the requested resource or event + UNAVAILABLE = 503, -- The server (Tracker) is not able to handle the request, usually because its event hook disabled +} + +-- Request/Response object prototypes + +RequestHandler.IRequest = { + -- Required unique GUID, for syncing request/responses with the client + GUID = "", + -- A comma-separate list of event keys; must match one or more existing Events + EventKey = EventHandler.Events.None.Key, + -- Number of seconds, representing time the originating request was created + CreatedAt = -1, + -- A Request should always send a response (at least once) when received + SentResponse = false, + -- Username of the user creating the request + Username = "", + -- The streaming platform for the source that's creating the request ('twitch' or 'youtube') + Platform = "", + -- Optional arguments included with the request + Args = {}, +} +---Creates and returns a new IRequest object +---@param o? table Optional initial object table +---@return table request An IRequest object +function RequestHandler.IRequest:new(o) + o = o or {} + o.GUID = o.GUID or NetworkUtils.newGUID() + o.EventKey = o.EventKey or EventHandler.Events.None.Key + o.CreatedAt = o.CreatedAt or os.time() + setmetatable(o, self) + self.__index = self + return o +end + +RequestHandler.IResponse = { + -- Required unique GUID, for syncing request/responses with the client + GUID = "", + -- Must match an existing Event + EventKey = EventHandler.Events.None.Key, + -- Number of seconds, representing time the request was processed into a response + CreatedAt = -1, + StatusCode = RequestHandler.StatusCodes.NOT_FOUND, + -- The streaming platform associated with the original request ('twitch' or 'youtube') + Platform = "", + -- The informative response message to send back to the client + Message = "", +} +---Creates and returns a new IResponse object +---@param o? table Optional initial object table +---@return table response An IResponse object +function RequestHandler.IResponse:new(o) + o = o or {} + o.GUID = o.GUID or NetworkUtils.newGUID() + o.CreatedAt = o.CreatedAt or os.time() + o.StatusCode = o.StatusCode or RequestHandler.StatusCodes.NOT_FOUND + o.Platform = o.Platform or "" + o.Message = o.Message or "" + setmetatable(o, self) + self.__index = self + return o +end + +---Clears out existing request info; similar to initialize(), but managed by Network +function RequestHandler.reset() + RequestHandler.Requests = {} + RequestHandler.Responses = {} + RequestHandler.lastSaveTime = os.time() +end + +--- Adds a IRequest to the requests queue, or updates an existing matching request; returns true if successful +---@param request table IRequest object +---@return boolean success +function RequestHandler.addUpdateRequest(request) + -- Only add requests if they match an existing event type + if not request or request.EventKey == EventHandler.Events.None.Key then + return false + end + if RequestHandler.Requests[request.GUID] then + RequestHandler.Requests[request.GUID] = MiscUtils.deepCopy(request) + else + RequestHandler.Requests[request.GUID] = request + end + return true +end + +--- Removes a IRequest from the requests queue; returns true if successful +---@param requestGUID string IRequest.GUID +---@return boolean success +function RequestHandler.removeRequest(requestGUID) + if not RequestHandler.Requests[requestGUID] then + return false + end + RequestHandler.Requests[requestGUID] = nil + return true +end + +--- Adds a IResponse to the responses list, or updates an existing matching response; returns true if successful +---@param response table IResponse object +---@return boolean success +function RequestHandler.addUpdateResponse(response) + if not response then + return false + end + if RequestHandler.Responses[response.GUID] then + RequestHandler.Requests[response.GUID] = MiscUtils.deepCopy(response) + else + RequestHandler.Responses[response.GUID] = response + end + return true +end + +---Returns a list of IResponses +---@return table responses +function RequestHandler.getResponses() + local responses = {} + for _, response in pairs(RequestHandler.Responses) do + table.insert(responses, response) + end + return responses +end + +---Removes all responses +function RequestHandler.clearAllResponses() + RequestHandler.Responses = {} +end + +---Removes any requests that should not be saved/loaded (e.g. core start and stop requests) +function RequestHandler.removedExcludedRequests() + local toRemove = {} + for _, request in pairs(RequestHandler.Requests or {}) do + local event = EventHandler.Events[request.EventKey] or EventHandler.Events.None + if event.Exclude then + table.insert(toRemove, request) + end + end + for _, request in pairs(toRemove) do + RequestHandler.removeRequest(request.GUID) + end +end + +---Receives [external] requests as Json and converts them into IRequests +---@param jsonTable table? +function RequestHandler.receiveJsonRequests(jsonTable) + for _, request in pairs(jsonTable or {}) do + local eventKeys = {} + + -- If missing, try and automatically detect the event type based on provided args + if not EventHandler.Events[request.EventKey] then + if request.Args.Command then + local events = EventHandler.getEventsForCommand(request.Args.Command) + for _, event in pairs(events) do + table.insert(eventKeys, event.Key) + end + elseif request.Args.RewardId then + local events = EventHandler.getEventsForReward(request.Args.RewardId) + for _, event in pairs(events) do + table.insert(eventKeys, event.Key) + end + end + end + if #eventKeys == 0 then + table.insert(eventKeys, request.EventKey) + end + + -- Then add to the Requests queue + RequestHandler.addUpdateRequest(RequestHandler.IRequest:new({ + GUID = request.GUID, + EventKey = table.concat(eventKeys, ","), + CreatedAt = request.CreatedAt, + Username = request.Username, + Platform = request.Platform, + Args = request.Args, + })) + end +end + +---Checks if the request includes an input string, and if so get it ready to be processed +---@param request table IRequest object +---@return string sanitizedInput +function RequestHandler.sanitizeInput(request) + if request.SanitizedInput then + return request.SanitizedInput + end + if type(request.Args) == "table" and request.Args.Input ~= nil then + local input = tostring(request.Args.Input) + request.SanitizedInput = input:match("^%s*(.-)%s*$") or "" + else + request.SanitizedInput = "" + end + return request.SanitizedInput +end + +--- Processes all IRequests (if able), adding them to the Responses +function RequestHandler.processAllRequests() + -- Clear out expired cooldowns for recent commands + EventHandler.cleanupDuplicateCommandRequests() + + -- Sort requests by time created + local toProcess = {} + for _, request in pairs(RequestHandler.Requests) do + table.insert(toProcess, request) + end + table.sort(toProcess, function(a,b) return a.CreatedAt < b.CreatedAt end) + + for _, request in ipairs(toProcess) do + for _, eventKey in pairs(MiscUtils.split(request.EventKey, ",", true) or {}) do + local event = EventHandler.Events[eventKey] + local response = RequestHandler.processAndBuildResponse(request, event) + if not request.SentResponse then + RequestHandler.addUpdateResponse(response) + request.SentResponse = true + end + if response.StatusCode ~= RequestHandler.StatusCodes.PROCESSING then + RequestHandler.removeRequest(request.GUID) + end + end + end +end + +---Processes the Request as much as it can, returning a Response with a proper StatusCode +---@param request table IRequest +---@param event? table IEvent +---@return table response IResponse +function RequestHandler.processAndBuildResponse(request, event) + event = event or EventHandler.Events[request.EventKey] or EventHandler.Events.None + local response = RequestHandler.IResponse:new({ + GUID = request.GUID, + EventKey = event.Key, + Platform = request.Platform, + }) + if event.Type == EventHandler.EventTypes.Reward then + response.AdditionalInfo = { + RewardId = request.Args and request.Args["RewardId"] or nil, + RedemptionId = request.Args and request.Args["RedemptionId"] or nil, + AutoComplete = true, + } + end + + -- Check if the event is valid and the request is okay to process + if not EventHandler.isValidEvent(event) then + response.StatusCode = RequestHandler.StatusCodes.NOT_FOUND + return response + end + if not event.IsEnabled then + response.StatusCode = RequestHandler.StatusCodes.UNAVAILABLE + return response + end + if request.IsCancelled then + response.StatusCode = RequestHandler.StatusCodes.FAIL + request.Message = "Cancelled." + request.SentResponse = false + return response + end + + RequestHandler.sanitizeInput(request) + + -- Don't process recent similar command requests + if EventHandler.isDuplicateCommandRequest(event, request) then + response.StatusCode = RequestHandler.StatusCodes.ALREADY_REPORTED + return response + end + + -- Process the request and see if it's ready to be fulfilled + local readyToFulfill = not event.Process or event:Process(request) + if not readyToFulfill then + response.StatusCode = RequestHandler.StatusCodes.PROCESSING + return response + end + + -- Complete the request and determine the output information to send back + local result = event.Fulfill and event:Fulfill(request) or "" + if type(result) == "string" then + response.Message = RequestHandler.validateMessage(result, request.Platform) + elseif type(result) == "table" then + response.Message = RequestHandler.validateMessage(result.Message, request.Platform) + if type(result.AdditionalInfo) == "table" then + response.AdditionalInfo = response.AdditionalInfo or {} + for k, v in pairs(result.AdditionalInfo) do + response.AdditionalInfo[k] = v + end + end + if type(result.GlobalVars) == "table" then + response.GlobalVars = response.GlobalVars or {} + for k, v in pairs(result.GlobalVars) do + response.GlobalVars[k] = v + end + end + end + + response.StatusCode = RequestHandler.StatusCodes.SUCCESS + request.SentResponse = false + + return response +end + +---Ensures the 'msg' is valid for sending (doesn't exceed the character limit: cap) +---@param msg string +---@param platform string The streaming platform to which the message will be sent +---@return string +function RequestHandler.validateMessage(msg, platform) + msg = msg or "" + if not RequestHandler.REQUIRES_MESSAGE_CAP then + return msg + end + + local cap + if platform == RequestHandler.PLATFORM_YOUTUBE then + cap = RequestHandler.YOUTUBE_MESSAGE_CAP + else + cap = RequestHandler.TWITCH_MESSAGE_CAP + end + if #msg <= cap then + return msg + end + return msg:sub(1, cap - 4) .. "..." +end + +--- Saves the list of Requests to a data file +---@return boolean success +function RequestHandler.saveRequestsData() + RequestHandler.removedExcludedRequests() + local folderpath = Paths.FOLDERS.NETWORK_FOLDER .. "/" + local success = NetworkUtils.encodeToJsonFile(folderpath .. RequestHandler.SAVED_REQUESTS, RequestHandler.Requests) + RequestHandler.lastSaveTime = os.time() + return (success == true) +end + +--- Imports a list of IRequests from a data file; returns true if successful +---@return boolean success +function RequestHandler.loadRequestsData() + local folderpath = Paths.FOLDERS.NETWORK_FOLDER .. "/" + local requests = NetworkUtils.decodeJsonFile(folderpath .. RequestHandler.SAVED_REQUESTS) + if requests then + RequestHandler.Requests = requests + return true + else + return false + end +end + +--- If enough time has elapsed since the last auto-save, will save the Requests data +function RequestHandler.saveRequestsDataOnSchedule() + if (os.time() - RequestHandler.lastSaveTime) >= RequestHandler.SAVE_FREQUENCY then + RequestHandler.saveRequestsData() + end +end \ No newline at end of file diff --git a/ironmon_tracker/network/StreamerbotCodeImport.txt b/ironmon_tracker/network/StreamerbotCodeImport.txt new file mode 100644 index 00000000..561ca8d7 --- /dev/null +++ b/ironmon_tracker/network/StreamerbotCodeImport.txt @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/ironmon_tracker/network/Tracker-StreamerBot.cs b/ironmon_tracker/network/Tracker-StreamerBot.cs new file mode 100644 index 00000000..35668e9c --- /dev/null +++ b/ironmon_tracker/network/Tracker-StreamerBot.cs @@ -0,0 +1,785 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Runtime.Serialization.Json; +using Newtonsoft.Json; + +public class CPHInline +{ + // Internal Streamerbot Properties + private const string VERSION = "1.0.1"; // Used to compare against known version # in Tracker code, to check if this code needs updating + private const bool DEBUG_LOG_EVENTS = false; + private const string GVAR_ConnectionDataFolder = "connectionDataFolder"; // "data" folder override global variable; define is Streamerbot + private const string DATA_FOLDER = @"data"; // Located at ~/Streamer.bot/data/ + private const string INBOUND_FILENAME = @"NDS-Tracker-Responses.json"; // Located inside the DATA_FOLDER + private const string OUTBOUND_FILENAME = @"NDS-Tracker-Requests.json"; // Located inside the DATA_FOLDER + private const int TEXT_UPDATE_FREQUENCY = 1; // # of seconds + private const string COMMAND_ACTION_ID = "0f58bcaf-4a93-442b-b66d-774ee5ba954d"; + private const char COMMAND_PREFIX = '!'; + private const string INVISIBLE_CHAR = "󠀀"; // U+E0000 (this is *not* an empty string) + private const string SOURCE_STREAMERBOT = "Streamerbot"; + private const string REQUEST_COMPLETE = "Complete"; + //private const string PLATFORM_TWITCH = "twitch"; // Not currently unused for any explicit checks + private const string PLATFORM_YOUTUBE = "youtube"; + private const int TWITCH_MESSAGE_CAP = 499; + private const int YOUTUBE_MESSAGE_CAP = 200; + + // Internal Streamerbot Data Variables + private bool _isConnected { get; set; } + private string _commandRegex { get; set; } + + private string _inboundFile { get; set; } + private string _outboundFile { get; set; } + private Dictionary _allowedEvents { get; set; } + private Dictionary _commandRoles { get; set; } + private List _requests { get; set; } + private List _responses { get; set; } + private List _offlineRequests { get; set; } + private Dictionary _receivedResponses { get; set; } + private Vars VARS { get; set; } + + // Required Streamerbot Method: Run when application starts up + public void Init() + { + try + { + _isConnected = false; + _commandRegex = "^" + COMMAND_PREFIX + @"([A-Za-z0-9\-\.]+)\s*(.*)"; + _allowedEvents = new Dictionary(); + _commandRoles = new Dictionary() + { + { "Broadcaster", true }, // For now, this is always available + { "Everyone", true }, + { "Moderator", false }, + { "Vip", false }, + { "Subscriber", false } + }; + _requests = new List(); + _responses = new List(); + _offlineRequests = new List(); + _receivedResponses = new Dictionary(); + + VerifyOrCreateDataFiles(); + + // By default, newly imported commands are disabled; this force enables them for convenience + CPH.EnableCommand(COMMAND_ACTION_ID); + + if (!_isConnected) + { + SendStreamerbotStart(); + SendRewardsList(); + } + else + { + CPH.LogInfo($"START: Already connected to the Tracker."); + } + + CreateReoccuringFileReader(); + } catch (Exception e) {} + } + + // Required Streamerbot Method: Run when the application shuts down + public void Dispose() + { + try + { + SendStreamerbotStop(); + CancelReoccuringFileReader(); + } catch (Exception e) {} + } + + // Required Streamerbot Method: Run when the action is triggered + public bool Execute() + { + // While the Tracker is offline, don't queue up any new requests + if (!_isConnected) + return true; + + VARS = new Vars(args); + + try + { + // Determine what type of event was triggered + if (!string.IsNullOrEmpty(VARS.CommandId)) + ProcessCommandEvent(); + else if (!string.IsNullOrEmpty(VARS.RewardId)) + ProcessChannelRedeemEvent(); + } catch (Exception e) {} + + return true; // Required + } + + // https://wiki.streamer.bot/en/Commands + public void ProcessCommandEvent() + { + Match matchesCommand = Regex.Match(VARS.RawInput, _commandRegex, RegexOptions.IgnoreCase); + if (!matchesCommand.Success) + return; + + // Only process allowed commands (allowed list received on server startup) + var command = matchesCommand.Groups[1].Value.ToLower(); + if (!_allowedEvents.ContainsKey(command)) + return; + + // Check if this user has role permissions to use Tracker commands + if (!_commandRoles["Everyone"]) + { + bool allowBroadcaster = _commandRoles["Broadcaster"] && VARS.BroadcastUserId.Equals(VARS.UserId); + bool allowModerator = _commandRoles["Moderator"] && VARS.IsModerator; + bool allowVip = _commandRoles["Vip"] && VARS.IsVip; + bool allowSubscriber = _commandRoles["Subscriber"] && VARS.IsSubscribed; + if (!allowBroadcaster && !allowModerator && !allowVip && !allowSubscriber) + return; + } + + var input = matchesCommand.Groups[2].Value ?? string.Empty; + // Fix to stop 7TV spam prevention character + input = Regex.Replace(input, INVISIBLE_CHAR, string.Empty); + + var request = new Request() + { + GUID = Guid.NewGuid().ToString(), + EventKey = string.Empty, // Lazily ask the server to automatically figure out which command event is triggering + CreatedAt = GetTime(), + Username = VARS.User, + Platform = VARS.UserType, // 'twitch' or 'youtube' + Args = new Dictionary() + }; + request.Args.Add("Command", command); + request.Args.Add("Input", input); + request.Args.Add("Counter", VARS.Counter); + AddNewRequestAndSend(request); + } + + // https://wiki.streamer.bot/en/Platforms/Twitch/Channel-Point-Rewards + public void ProcessChannelRedeemEvent() + { + // Only process allowed channel redeems (allowed list received on server startup) + var rewardId = VARS.RewardId ?? string.Empty; + if (!_allowedEvents.ContainsKey(rewardId)) + return; + + var request = new Request() + { + GUID = Guid.NewGuid().ToString(), + EventKey = string.Empty, // Lazily ask the server to automatically figure out which channel reward is triggering + CreatedAt = GetTime(), + Username = VARS.User, + Platform = VARS.UserType, // 'twitch' or 'youtube' + Args = new Dictionary() + }; + request.Args.Add("RewardName", VARS.RewardName); + request.Args.Add("RewardId", rewardId); + request.Args.Add("RedemptionId", VARS.RedemptionId); // Required for fulfilling/cancelling the twitch reward later + request.Args.Add("Input", VARS.RawInput); + request.Args.Add("Counter", VARS.Counter); + AddNewRequestAndSend(request); + } + + private void SetOptionalGlobalVariables(Response response) + { + if (response == null || response.GlobalVars == null) + return; + + try + { + foreach(var globalVar in response.GlobalVars) + { + CPH.SetGlobalVar(globalVar.Key, globalVar.Value.ToString()); + } + } catch (Exception e) {} + } + + private void CompleteIfChannelRedeem(Response response, bool cancelInstead = false) + { + if (response == null || response.AdditionalInfo == null || !response.AdditionalInfo.ContainsKey("RewardId") || !response.AdditionalInfo.ContainsKey("RedemptionId")) + return; + + var rewardId = response.AdditionalInfo["RewardId"].ToString(); + var redemptionId = response.AdditionalInfo["RedemptionId"].ToString(); + var shouldComplete = true; + if (response.AdditionalInfo.ContainsKey("AutoComplete")) + shouldComplete = (response.AdditionalInfo["AutoComplete"] as bool?) ?? true; + + if (!shouldComplete || string.IsNullOrEmpty(rewardId) || string.IsNullOrEmpty(redemptionId)) + return; + + try + { + if (cancelInstead) + { + //CPH.UpdateRewardCooldown(rewardId, (long)1); // This permanently changes the cooldown, not what I want + CPH.TwitchRedemptionCancel(rewardId, redemptionId); + } + else + { + CPH.TwitchRedemptionFulfill(rewardId, redemptionId); + } + } catch (Exception e) {} + } + + private void AddNewRequestAndSend(Request request) + { + if (_isConnected) + { + _requests.Add(request); + WriteRequestsToOutbound(); + } + else + { + _offlineRequests.Add(request); + } + } + + public void WriteRequestsToOutbound() + { + try + { + using (StreamWriter file = File.CreateText(_outboundFile)) // Tracker-Requests + { + JsonSerializer serializer = new JsonSerializer(); + if (_requests == null) + serializer.Serialize(file, new List()); + else + serializer.Serialize(file, _requests); + } + } catch (Exception e) {} + } + + // https://wiki.streamer.bot/en/Settings/File-Folder-Watcher + public void ReadResponsesFromInbound() + { + try { + _responses = null; + using (StreamReader file = new StreamReader(_inboundFile)) // Tracker-Responses + { + string json = file.ReadToEnd(); + _responses = JsonConvert.DeserializeObject>(json); + } + if (_responses == null) + _responses = new List(); + + ProcessResponses(); + CleanupOldResponses(); + } catch (Exception e) {} + } + + public void ProcessResponses() + { + if (_responses == null || !_responses.Any()) + return; + + int timeNow = GetTime(); + bool updatedRequests = false; + foreach (var response in _responses) + { + if (_receivedResponses.ContainsKey(response.GUID + response.StatusCode)) + continue; + + bool specialResponse = CheckSpecialServerEvent(response); + + switch(response.StatusCode) + { + // PROCESSING = 102, -- The server (Tracker) has received and is processing the request, but no response is available yet + case 102: + break; + // SUCCESS = 200, -- The request succeeded and a response message is available + case 200: + if (!specialResponse) + { + SetOptionalGlobalVariables(response); + CompleteIfChannelRedeem(response); + if (!string.IsNullOrEmpty(response.Message)) + { + SendChatMessage(response.Message, response.Platform); + } + } + break; + // ALREADY_REPORTED = 208, -- The request is a duplicate of another recent request, no additional response message will be sent + case 208: + break; + // FAIL = 400, -- The server (Tracker) won't process, likely due to a client error with formatting the request + case 400: + // NOT_FOUND = 404, -- The server (Tracker) cannot find the requested resource or event + case 404: + // UNAVAILABLE = 503, -- The server (Tracker) is not able to handle the request, usually because its event hook disabled + case 503: + default: + CompleteIfChannelRedeem(response, true); // Don't cancel non-tracker rewards + break; + } + + _receivedResponses.Add(response.GUID + response.StatusCode, timeNow); + if (DEBUG_LOG_EVENTS && !string.IsNullOrEmpty(response.EventKey)) + CPH.LogInfo($"Tracker Event: {response.EventKey} [{response.StatusCode}] GUID:{response.GUID}, Message:'{response.Message}'"); + + // Don't remove special response/requests that are still processing + if (response.StatusCode != 102 || !specialResponse) + { + int numRemoved = _requests.RemoveAll(r => r.GUID.Equals(response.GUID)); + if (numRemoved > 0) + updatedRequests = true; + } + } + + if (updatedRequests) + WriteRequestsToOutbound(); + } + + private bool CheckSpecialServerEvent(Response response) + { + if (response.EventKey.Equals("TS_Start")) + { + if (!_isConnected) + { + _isConnected = true; + // Other on-start code here + CPH.LogInfo($"START: Successfully connected to the Tracker!"); + } + if (!response.Message.Contains(REQUEST_COMPLETE)) + { + SendStreamerbotStart(response.GUID); + } + return true; + } + else if (response.EventKey.Equals("TS_Stop")) + { + if (_isConnected) + { + _isConnected = false; + // Other on-stop code here + CPH.LogInfo($"STOP: Disconnected from the Tracker."); + } + return true; + } + else if (response.EventKey.Equals("TS_GetRewardsList")) + { + if (!response.Message.Contains(REQUEST_COMPLETE)) + { + SendRewardsList(response.GUID); + } + return true; + } + else if (response.EventKey.Equals("TS_UpdateEvents")) + { + ReceiveAllowedEvents(response); + return true; + } + return false; + } + + private void SendChatMessage(string msg, string platform) + { + // A simple check if the response was meant for Youtube platform only + if (!string.IsNullOrEmpty(platform) && platform.ToLower().Equals(PLATFORM_YOUTUBE)) + { + var chunks = SplitMessageIntoChunks(msg, YOUTUBE_MESSAGE_CAP); + foreach (string msgChunk in chunks) + CPH.SendYouTubeMessage(msgChunk.TrimStart(COMMAND_PREFIX), true); + } + else + { + var chunks = SplitMessageIntoChunks(msg, TWITCH_MESSAGE_CAP); + foreach (string msgChunk in chunks) + CPH.SendMessage(msgChunk.TrimStart(COMMAND_PREFIX), true); + } + } + + private void SendStreamerbotStart(string guid = "") + { + CPH.LogInfo($"START: Starting Tracker communication. Connecting..."); + // Don't send if already sent for this request/response(guid) + if (!string.IsNullOrEmpty(guid) && _requests.Any(r => r.GUID.Equals(guid))) + return; + + int now = GetTime(); + var requestStart = new Request() + { + GUID = string.IsNullOrEmpty(guid) ? Guid.NewGuid().ToString() : guid, + EventKey = "TS_Start", + CreatedAt = now - 1, // Prioritize resolving this request before any others made at this point in time + Username = "Streamer.bot Internal", + Platform = "None", + Args = new Dictionary() + }; + requestStart.Args.Add("Source", SOURCE_STREAMERBOT); + requestStart.Args.Add("Version", VERSION); + _requests.Add(requestStart); + + var requestEvents = new Request() + { + GUID = Guid.NewGuid().ToString(), + EventKey = "TS_UpdateEvents", + CreatedAt = now, + Username = "Streamer.bot Internal", + Platform = "None", + Args = new Dictionary() + }; + requestEvents.Args.Add("Source", SOURCE_STREAMERBOT); + _requests.Add(requestEvents); + + WriteRequestsToOutbound(); + } + + private void SendStreamerbotStop(string guid = "") + { + CPH.LogInfo($"STOP: Stopping Tracker communication. Disconnected."); + // Don't send if already sent for this request/response(guid) + if (!string.IsNullOrEmpty(guid) && _requests.Any(r => r.GUID.Equals(guid))) + return; + + var request = new Request() + { + GUID = string.IsNullOrEmpty(guid) ? Guid.NewGuid().ToString() : guid, + EventKey = "TS_Stop", + CreatedAt = GetTime() + 1, // Delay to resolve this request after any others made at this point in time + Username = "Streamer.bot Internal", + Platform = "None", + Args = new Dictionary() + }; + request.Args.Add("Source", SOURCE_STREAMERBOT); + _requests.Add(request); + WriteRequestsToOutbound(); + } + + private void SendRewardsList(string guid = "") + { + // Don't send if already sent for this request/response(guid) + if (!string.IsNullOrEmpty(guid) && _requests.Any(r => r.GUID.Equals(guid))) + return; + + // Get the current list of rewards from Twitch + var rewardsInternal = CPH.TwitchGetRewards(); + var rewards = new List(); + foreach(var reward in rewardsInternal) + { + rewards.Add(new TwitchReward(){ + Id = reward.Id, + Title = reward.Title, + }); + } + + var request = new Request() + { + GUID = string.IsNullOrEmpty(guid) ? Guid.NewGuid().ToString() : guid, + EventKey = "TS_GetRewardsList", + CreatedAt = GetTime(), + Username = "Streamer.bot Internal", + Platform = "None", + Args = new Dictionary() + }; + request.Args.Add("Source", SOURCE_STREAMERBOT); + request.Args.Add("Rewards", rewards); + _requests.Add(request); + WriteRequestsToOutbound(); + } + + private void ReceiveAllowedEvents(Response response) + { + if (response == null || response.AdditionalInfo == null) + return; + + if (response.AdditionalInfo.ContainsKey("AllowedEvents")) + { + _allowedEvents = new Dictionary(); + var commaSeparatedEvents = response.AdditionalInfo["AllowedEvents"].ToString(); + if (!string.IsNullOrEmpty(commaSeparatedEvents)) + { + foreach (var eventKey in commaSeparatedEvents.Split(',').ToList()) + { + var key = eventKey.Trim(); + if (!_allowedEvents.ContainsKey(key)) + _allowedEvents.Add(key, true); + } + } + } + if (response.AdditionalInfo.ContainsKey("CommandRoles")) + { + // Start with default values, then update accordingly + _commandRoles["Broadcaster"] = true; + _commandRoles["Everyone"] = true; + _commandRoles["Moderator"] = false; + _commandRoles["Vip"] = false; + _commandRoles["Subscriber"] = false; + + // Check if commands are limited to specific roles instead of allowing "Everyone" + var commaSeparatedRoles = response.AdditionalInfo["CommandRoles"].ToString(); + if (!string.IsNullOrEmpty(commaSeparatedRoles) && !commaSeparatedRoles.Contains("Everyone")) + { + _commandRoles["Everyone"] = false; + foreach (var roleKey in commaSeparatedRoles.Split(',').ToList()) + { + var role = roleKey.Trim(); + if (_commandRoles.ContainsKey(role)) + _commandRoles[role] = true; + } + } + } + } + + private void VerifyOrCreateDataFiles() + { + try + { + string dataDirectory = System.IO.Directory.GetCurrentDirectory(); // was Environment.CurrentDirectory + + // Workaround if running as administrator + if (dataDirectory.ToLower().Contains("windows") && dataDirectory.ToLower().Contains("system32")) + dataDirectory = System.AppDomain.CurrentDomain.BaseDirectory; + + // Check first if the user has defined alternative data folder + var directoryOverride = CPH.GetGlobalVar(GVAR_ConnectionDataFolder, true); + if (!string.IsNullOrEmpty(directoryOverride) && !directoryOverride.Equals("NONE") && Directory.Exists(directoryOverride)) + dataDirectory = directoryOverride; + + // Create required inbound/outbound files + _inboundFile = Path.Combine(dataDirectory, DATA_FOLDER, INBOUND_FILENAME); // Responses (incoming) + _outboundFile = Path.Combine(dataDirectory, DATA_FOLDER, OUTBOUND_FILENAME); // Requests (outgoing) + using(StreamWriter sw = File.AppendText(_inboundFile)){}; + using(StreamWriter sw = File.AppendText(_outboundFile)){}; + } catch (Exception e) {} + } + + private void CleanupOldResponses() + { + const int EXPIRE_TIME = 10 * 60; // recorded responses are kept for 10 minutes max + int timeNow = GetTime(); + foreach(var item in _receivedResponses.Where(kvp => (timeNow - kvp.Value) >= EXPIRE_TIME).ToList()) + { + _receivedResponses.Remove(item.Key); + } + } + + private int GetTime() + { + // Calculate current time + TimeSpan t = (DateTime.UtcNow - new DateTime(1970, 1, 1)); + return (int)t.TotalSeconds; + } + + private IEnumerable SplitMessageIntoChunks(string msg, int chunkSize) + { + if (string.IsNullOrEmpty(msg) || chunkSize < 1) + return Enumerable.Empty(); + + var charCount = 0; + var lines = msg.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + return lines.GroupBy(w => (charCount += (((charCount % chunkSize) + w.Length + 1 >= chunkSize) + ? chunkSize - (charCount % chunkSize) : 0) + w.Length + 1) / chunkSize) + .Select(g => string.Join(" ", g.ToArray())); + } + + private CancellationTokenSource _reoccuringTokenSrc { get; set; } + private void CreateReoccuringFileReader() + { + _reoccuringTokenSrc = new CancellationTokenSource(); + Task.Run(async () => { + while (_reoccuringTokenSrc != null && !_reoccuringTokenSrc.Token.IsCancellationRequested) + { + try + { + ReadResponsesFromInbound(); + } catch (Exception e) {} + await Task.Delay(TimeSpan.FromSeconds(TEXT_UPDATE_FREQUENCY), _reoccuringTokenSrc.Token); + } + if (_reoccuringTokenSrc != null) + _reoccuringTokenSrc.Dispose(); + }, _reoccuringTokenSrc.Token); + } + private void CancelReoccuringFileReader() + { + if (_reoccuringTokenSrc != null) + _reoccuringTokenSrc.Cancel(); + } + + public class Request + { + public string GUID { get; set; } + public string EventKey { get; set; } + public int CreatedAt { get; set; } + public string Username { get; set; } + public string Platform { get; set; } + public Dictionary Args { get; set; } + } + + public class Response + { + public string GUID { get; set; } + public string EventKey { get; set; } + public int CreatedAt { get; set; } + public int StatusCode { get; set; } + public string Message { get; set; } + public string Platform { get; set; } + public Dictionary? AdditionalInfo { get; set; } + public Dictionary? GlobalVars { get; set; } + } + + public class TwitchReward + { + public string Id { get; set; } + public string Title { get; set; } + + // public string? Prompt { get; set; } + // public int? Cost { get; set; } + // public bool? InputRequired { get; set; } + // public string? BackgroundColor { get; set; } + // public bool? Paused { get; set; } + // public bool? Enabled { get; set; } + // public bool? IsOurs { get; set; } // Created through Streamer.bot + } + + public class Vars + { + private const string TRUE_VALUE = "True"; + + public Vars(Dictionary args) + { + this.Args = args; + } + + private Dictionary Args + { + get; set; + } + + private string getValue(string key) + { + if (!string.IsNullOrEmpty(key) && Args.ContainsKey(key)) + return Args[key].ToString(); + else + return string.Empty; + } + + // Broadcaster Variables + public string BroadcastUser + { + get { return getValue("broadcastUser"); } + } + public string BroadcastUserName + { + get { return getValue("broadcastUserName"); } + } + public string BroadcastUserId + { + get { return getValue("broadcastUserId"); } + } + public bool BroadcastIsAffiliate + { + get { return getValue("broadcastIsAffiliate").Equals(TRUE_VALUE); } + } + public bool BroadcastIsPartner + { + get { return getValue("broadcastIsPartner").Equals(TRUE_VALUE); } + } + + + // Commands - https://wiki.streamer.bot/en/Triggers/Core/Commands/Command-Triggered + // The command that was used + public string Command + { + get { return getValue("command"); } + } + // The ID of the command + public string CommandId + { + get { return getValue("commandId"); } + } + // The message entered, if the command/redeem was a Starts With, this will be removed + public string RawInput + { + get { return getValue("rawInput"); } + } + // The message escaped + public string RawInputEscaped + { + get { return getValue("rawInputEscaped"); } + } + // The message URL encoded + public string RawInputUrlEncoded + { + get { return getValue("rawInputUrlEncoded"); } + } + // What role the user has (1-4); This doesn't appear to return anything other than an empty string + // public string Role + // { + // get { return getValue("role"); } + // } + + + // Channel Point Rewards + // String identifier for this redemption (used to refund reward) 4d9f236b-7486-481a-89af-1d03676d5275 + public string RedemptionId + { + get { return getValue("redemptionId"); } + } + // String identifier for this reward 44e86f71-8ace-4739-a123-3ff095489343 + public string RewardId + { + get { return getValue("rewardId"); } + } + // Name of the reward + public string RewardName + { + get { return getValue("rewardName"); } + } + // The verbiage shown on the channel point description + public string RewardPrompt + { + get { return getValue("rewardPrompt"); } + } + // The channel point cost of the redeemed reward + public string RewardCost + { + get { return getValue("rewardCost"); } + } + // The user that had redeemed the channel point + public string User + { + get { return getValue("user"); } + } + // User login name, e.g. on Twitch this is the username in all lowercase, useful for comparison + public string UserName + { + get { return getValue("userName"); } + } + // Unique user identifier + public string UserId + { + get { return getValue("userId"); } + } + // Specifies which streaming service the triggering user is coming from: twitch or youtube + public string UserType + { + get { return getValue("userType"); } + } + // Boolean value indicating the sender's subscription status + public bool IsSubscribed + { + get { return getValue("isSubscribed").Equals(TRUE_VALUE); } + } + // Boolean value indicating the sender's moderator status + public bool IsModerator + { + get { return getValue("isModerator").Equals(TRUE_VALUE); } + } + // Boolean value indicating the sender's VIP status + public bool IsVip + { + get { return getValue("isVip").Equals(TRUE_VALUE); } + } + // A running total of how many times a command/redeem has been run since application launch (if Persisted is checked, the total will be saved to settings.dat and read in at launch) + public string Counter + { + get { return getValue("counter"); } + } + // A running total of how many times a command/redeem has been run by this chat user since application launch (if UserPersisted is checked, the total will be saved to settings.dat and read in at launch) + public string UserCounter + { + get { return getValue("userCounter"); } + } + } +} \ No newline at end of file diff --git a/ironmon_tracker/utils/NetworkUtils.lua b/ironmon_tracker/utils/NetworkUtils.lua new file mode 100644 index 00000000..2a21c2ef --- /dev/null +++ b/ironmon_tracker/utils/NetworkUtils.lua @@ -0,0 +1,131 @@ +NetworkUtils = {} + +---@return string guid +function NetworkUtils.newGUID() + local template ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' + return (string.gsub(template, '[xy]', function (c) + local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb) + return string.format('%x', v) + end)) +end + +---Does a simple check to see if a folder at `folderpath` exists on the file system +---@param folderpath string +---@return boolean +function NetworkUtils.folderExists(folderpath) + if (folderpath or "") == "" then return false end + if not folderpath:find("[/\\]$") then + folderpath = folderpath .. "/" + end + + -- Hacky but simply way to check if a folder exists: try to rename it + -- The "code" return value only exists in Lua 5.2+, but not required to use here + local exists, err, code = os.rename(folderpath, folderpath) + -- Code 13 = Permission denied, but it exists + return exists or (not exists and code == 13) +end + +-- Searches `wordlist` for the closest matching `word` based on Levenshtein distance. Returns: key, result +-- If the minimum distance is greater than the `threshold`, the original 'word' is returned and key is nil +-- https://stackoverflow.com/questions/42681501/how-do-you-make-a-string-dictionary-function-in-lua +function NetworkUtils.getClosestWord(word, wordlist, threshold) + if word == nil or word == "" then return word end + threshold = threshold or 3 + local function min(a, b, c) return math.min(math.min(a, b), c) end + local function matrix(row, col) + local m = {} + for i = 1,row do + m[i] = {} + for j = 1,col do m[i][j] = 0 end + end + return m + end + local function lev(strA, strB) + local M = matrix(#strA + 1, #strB + 1) + local cost + local row, col = #M, #M[1] + for i = 1, row do M[i][1] = i - 1 end + for j = 1, col do M[1][j] = j - 1 end + for i = 2, row do + for j = 2, col do + if (strA:sub(i-1, i-1) == strB:sub(j-1, j-1)) then cost = 0 + else cost = 1 + end + M[i][j] = min(M[i-1][j] + 1, M[i][j-1] + 1, M[i-1][j-1] + cost) + end + end + return M[row][col] + end + local closestDistance = -1 + local closestWordKey + for key, val in pairs(wordlist) do + local levRes = lev(word, val) + if levRes < closestDistance or closestDistance == -1 then + closestDistance = levRes + closestWordKey = key + end + end + if closestDistance <= threshold then return closestWordKey, wordlist[closestWordKey] + else return nil, word + end +end + +--- Loads the external Json library into NetworkUtils.JsonLibrary +---@param forceLoad? boolean Optional, if true, forces the file to get reloaded even if already loaded +function NetworkUtils.setupJsonLibrary(forceLoad) + -- Skip loading if already loaded + if type(NetworkUtils.JsonLibrary) == "table" and not forceLoad then + return + end + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. "/Json.lua" + if FormsUtils.fileExists(filepath) then + NetworkUtils.JsonLibrary = dofile(filepath) + if type(NetworkUtils.JsonLibrary) ~= "table" then + NetworkUtils.JsonLibrary = nil + end + end +end + +--- Returns true if data is written to file, false if resulting json is empty, or nil if no file +---@param filepath string +---@param data table +---@return boolean|nil dataWritten +function NetworkUtils.encodeToJsonFile(filepath, data) + local file = filepath and io.open(filepath, "w") + if not file then + return nil + end + if not NetworkUtils.JsonLibrary then + return false + end + -- Empty Json is "[]" + local output = "[]" + pcall(function() + output = NetworkUtils.JsonLibrary.encode(data) or "[]" + end) + file:write(output) + file:close() + return (#output > 2) +end + +--- Returns a lua table of the decoded json string from a file, or nil if no file +---@param filepath string +---@return table|nil data +function NetworkUtils.decodeJsonFile(filepath) + local file = filepath and io.open(filepath, "r") + if not file then + return nil + end + if not NetworkUtils.JsonLibrary then + return {} + end + local input = file:read("*a") or "" + file:close() + local decodedTable = {} + if #input > 0 then + pcall(function() + decodedTable = NetworkUtils.JsonLibrary.decode(input) or {} + end) + end + return decodedTable +end \ No newline at end of file From fb69e2198c3e688e2b691a86190b95bccf4b8fb0 Mon Sep 17 00:00:00 2001 From: UTDZac Date: Tue, 24 Sep 2024 01:18:28 -0700 Subject: [PATCH 02/26] Network Cleanup and several EventData commands --- ironmon_tracker/Program.lua | 14 +- ironmon_tracker/Tracker.lua | 10 + ironmon_tracker/network/EventData.lua | 620 ++++++++++++--------- ironmon_tracker/network/EventHandler.lua | 44 +- ironmon_tracker/network/Network.lua | 50 +- ironmon_tracker/network/RequestHandler.lua | 12 +- ironmon_tracker/utils/NetworkUtils.lua | 32 +- 7 files changed, 471 insertions(+), 311 deletions(-) diff --git a/ironmon_tracker/Program.lua b/ironmon_tracker/Program.lua index db653e7a..c287a9d9 100644 --- a/ironmon_tracker/Program.lua +++ b/ironmon_tracker/Program.lua @@ -481,6 +481,10 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, return selectedPlayer end + function self.getPlayerPokemon() + return playerPokemon + end + local function getPokemonData(selected) if battleHandler:inBattleAndFetched() then local data = battleHandler:getActivePokemonInBattle(selected) @@ -613,6 +617,10 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, currentLocation = areaName end + function self.getCurrentMapID() + return currentMapID + end + function self.getCurrentLocation() return currentLocation end @@ -961,8 +969,6 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, AnimatedSpriteManager.advanceFrame() end - Network.initialize(self) - frameCounters = { restorePointUpdate = FrameCounter(30, updateRestorePoints), memoryReading = FrameCounter(30, readMemory, nil, true), @@ -1106,6 +1112,7 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, client.SetSoundOn(false) local logInfo = randomizerLogParser.parse(logPath) if logInfo ~= nil then + Network.Data.logInfo = logInfo local firstPokemonID = tracker.getFirstPokemonID() logInfo.setStarterNumberFromPlayerPokemonID(firstPokemonID) self.openScreen(self.UI_SCREENS.LOG_VIEWER_SCREEN) @@ -1123,6 +1130,9 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, randomizerLogParser = RandomizerLogParser(self) AnimatedSpriteManager.initialize(self.drawCurrentScreens, memoryAddresses, gameInfo, settings) + Network.initialize() + Network.linkData(self, tracker, battleHandler) + return self end diff --git a/ironmon_tracker/Tracker.lua b/ironmon_tracker/Tracker.lua index fef71edb..0409919a 100644 --- a/ironmon_tracker/Tracker.lua +++ b/ironmon_tracker/Tracker.lua @@ -208,6 +208,16 @@ local function Tracker() return trackedData.pokecenterCount end + function self.getTrackedIDs() + local ids = {} + for id, _ in pairs(trackedData.trackedPokemon or {}) do + if id > 0 and PokemonData.POKEMON[id + 1] then + table.insert(ids, id) + end + end + return ids + end + function self.getSortedTrackedIDs() local ids = {} local pokemon = trackedData.trackedPokemon diff --git a/ironmon_tracker/network/EventData.lua b/ironmon_tracker/network/EventData.lua index 33645c1e..0884eec8 100644 --- a/ironmon_tracker/network/EventData.lua +++ b/ironmon_tracker/network/EventData.lua @@ -78,7 +78,7 @@ local function findRouteId(name, threshold) if tonumber(name) ~= nil then name = string.format("route %s", name) end - local routes = gameInfo and gameInfo.LOCATION_DATA.locations or {} + local routes = Network.Data.gameInfo.LOCATION_DATA.locations or {} -- Format list of Routes as id, name pairs local routeNames = {} for id, route in pairs(routes) do @@ -89,6 +89,19 @@ local function findRouteId(name, threshold) return id or 0 end +---Searches for a Pokémon Type by name, finds the best match; returns nil if no match found +---@param name string? +---@param threshold number? Default threshold distance of 3 +---@return string? type PokemonData.Type +local function findPokemonType(name, threshold) + if name == nil or name == "" then + return nil + end + threshold = threshold or 3 + local _, type = NetworkUtils.getClosestWord(name:upper(), PokemonData.TYPE_LIST, threshold) + return type +end + -- The max # of items to show for any commands that output a list of items (try keep chat message output short) local MAX_ITEMS = 12 local OUTPUT_CHAR = ">" @@ -120,44 +133,39 @@ end local function getPokemonOrDefault(input) local id if (input or "") ~= "" then - id = findPokemonId(input) + id = findPokemonId(input) or -2 else - local pokemon = Tracker.getPokemon(1, true) or {} - id = pokemon.pokemonID + local pokemon = Network.Data.program.getPlayerPokemon() or {} + id = pokemon.pokemonID or -2 end - return PokemonData.POKEMON[id or false] + return PokemonData.POKEMON[id + 1], id end local function getMoveOrDefault(input) if (input or "") ~= "" then - return MoveData.Moves[findMoveId(input) or false] + local id = findMoveId(input) or -2 + return MoveData.MOVES[id + 1], id else - return nil + return nil, -1 end end local function getAbilityOrDefault(input) local id if (input or "") ~= "" then - id = findAbilityId(input) + id = findAbilityId(input) or -2 else - local pokemon = Tracker.getPokemon(1, true) or {} - if PokemonData.isValid(pokemon.pokemonID) then - id = PokemonData.getAbilityId(pokemon.pokemonID, pokemon.abilityNum) + local pokemon = Network.Data.program.getPlayerPokemon() or {} + if pokemon.pokemonID and PokemonData.POKEMON[pokemon.pokemonID + 1] then + id = pokemon.ability or -2 end end - return AbilityData.ABILITIES[id or false] + return AbilityData.ABILITIES[id + 1], id end local function getRouteIdOrDefault(input) if (input or "") ~= "" then local id = findRouteId(input) - -- Special check for Route 21 North/South in FRLG - if not RouteData.Info[id or false] and Utils.containsText(input, "21") then - -- Okay to default to something in route 21 - return (Utils.containsText(input, "north") and 109) or 219 - else - return id - end + return id else - return TrackerAPI.getMapId() + return Network.Data.program.getCurrentMapID() or -1 end end @@ -166,44 +174,46 @@ end ---@param params string? ---@return string response function EventData.getPokemon(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - - local pokemon = getPokemonOrDefault(params) - if not pokemon then + local pokemon, id = getPokemonOrDefault(params) + if id <= 0 or not pokemon then return buildDefaultResponse(params) end local info = {} - local types - if pokemon.types[2] ~= PokemonData.Types.EMPTY and pokemon.types[2] ~= pokemon.types[1] then - types = Utils.formatUTF8("%s/%s", PokemonData.getTypeResource(pokemon.types[1]), PokemonData.getTypeResource(pokemon.types[2])) + local typesText + if pokemon.type[2] ~= PokemonData.POKEMON_TYPES.EMPTY and pokemon.type[2] ~= pokemon.type[1] then + typesText = string.format("%s/%s", + NetworkUtils.firstToUpperEachWord(pokemon.type[1]:lower()), + NetworkUtils.firstToUpperEachWord(pokemon.type[2]:lower()) + ) else - types = PokemonData.getTypeResource(pokemon.types[1]) + typesText = NetworkUtils.firstToUpperEachWord(pokemon.type[1]:lower()) end - local coreInfo = string.format("%s #%03d (%s) %s: %s", + local coreInfo = string.format("%s #%03d (%s) BST: %s", pokemon.name, - pokemon.pokemonID, - types, - Resources.TrackerScreen.StatBST, + id, + typesText, pokemon.bst ) table.insert(info, coreInfo) - local evos = table.concat(Utils.getDetailedEvolutionsInfo(pokemon.evolution), ", ") - table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelEvolution, evos)) + local evos + if type(pokemon.evolution) == "table" then + evos = table.concat(pokemon.evolution, ", ") + else + evos = pokemon.evolution + end + table.insert(info, string.format("Evolution: %s", evos)) + local moveLevels = pokemon.movelvls[Network.Data.gameInfo.VERSION_GROUP] or {} local moves - if #pokemon.movelvls[GameSettings.versiongroup] > 0 then - moves = table.concat(pokemon.movelvls[GameSettings.versiongroup], ", ") + if #moveLevels > 0 then + moves = table.concat(moveLevels, ", ") else moves = "None." end - table.insert(info, string.format("%s. %s: %s", Resources.TrackerScreen.LevelAbbreviation, Resources.TrackerScreen.HeaderMoves, moves)) - local trackedPokemon = Tracker.Data.allPokemon[pokemon.pokemonID] or {} - if (trackedPokemon.eT or 0) > 0 then - table.insert(info, string.format("%s: %s", Resources.TrackerScreen.BattleSeenOnTrainers, trackedPokemon.eT)) - end - if (trackedPokemon.eW or 0) > 0 then - table.insert(info, string.format("%s: %s", Resources.TrackerScreen.BattleSeenInTheWild, trackedPokemon.eW)) + table.insert(info, string.format("Lv. Moves: %s", moves)) + local amountSeen = Network.Data.tracker.getAmountSeen(id) or 0 + if amountSeen > 0 then + table.insert(info, string.format("Amount seen: %s", amountSeen)) end return buildResponse(OUTPUT_CHAR, info) end @@ -211,15 +221,13 @@ end ---@param params string? ---@return string response function EventData.getBST(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - local pokemon = getPokemonOrDefault(params) - if not pokemon then + local pokemon, id = getPokemonOrDefault(params) + if id <= 0 or not pokemon then return buildDefaultResponse(params) end local info = {} - table.insert(info, string.format("%s: %s", Resources.TrackerScreen.StatBST, pokemon.bst)) + table.insert(info, string.format("BST: %s", pokemon.bst)) local prefix = string.format("%s %s", pokemon.name, OUTPUT_CHAR) return buildResponse(prefix, info) end @@ -227,60 +235,70 @@ end ---@param params string? ---@return string response function EventData.getWeak(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - local pokemon = getPokemonOrDefault(params) - if not pokemon then + local pokemon, id = getPokemonOrDefault(params) + if id <= 0 or not pokemon then return buildDefaultResponse(params) end local info = {} - local pokemonDefenses = PokemonData.getEffectiveness(pokemon.pokemonID) - local weak4x = Utils.firstToUpperEachWord(table.concat(pokemonDefenses[4] or {}, ", ")) - if not Utils.isNilOrEmpty(weak4x) then + local pokemonDefenses = MoveUtils.getTypeDefensesTable(pokemon) + local weak4x = NetworkUtils.firstToUpperEachWord(table.concat(pokemonDefenses["4x"] or {}, ", "):lower()) + if (weak4x or "") ~= "" then table.insert(info, string.format("[4x] %s", weak4x)) end - local weak2x = Utils.firstToUpperEachWord(table.concat(pokemonDefenses[2] or {}, ", ")) - if not Utils.isNilOrEmpty(weak2x) then + local weak2x = NetworkUtils.firstToUpperEachWord(table.concat(pokemonDefenses["2x"] or {}, ", "):lower()) + if (weak2x or "") ~= "" then table.insert(info, string.format("[2x] %s", weak2x)) end - local types - if pokemon.types[2] ~= PokemonData.Types.EMPTY and pokemon.types[2] ~= pokemon.types[1] then - types = Utils.formatUTF8("%s/%s", PokemonData.getTypeResource(pokemon.types[1]), PokemonData.getTypeResource(pokemon.types[2])) + local weak05 = NetworkUtils.firstToUpperEachWord(table.concat(pokemonDefenses["0.5x"] or {}, ", "):lower()) + if (weak05 or "") ~= "" then + table.insert(info, string.format("[0.5x] %s", weak05)) + end + local weak025 = NetworkUtils.firstToUpperEachWord(table.concat(pokemonDefenses["0.25x"] or {}, ", "):lower()) + if (weak025 or "") ~= "" then + table.insert(info, string.format("[0.25x] %s", weak025)) + end + local weak0 = NetworkUtils.firstToUpperEachWord(table.concat(pokemonDefenses["0x"] or {}, ", "):lower()) + if (weak0 or "") ~= "" then + table.insert(info, string.format("[0x] %s", weak0)) + end + local typesText + if pokemon.type[2] ~= PokemonData.POKEMON_TYPES.EMPTY and pokemon.type[2] ~= pokemon.type[1] then + typesText = string.format("%s/%s", + NetworkUtils.firstToUpperEachWord(pokemon.type[1]:lower()), + NetworkUtils.firstToUpperEachWord(pokemon.type[2]:lower()) + ) else - types = PokemonData.getTypeResource(pokemon.types[1]) + typesText = NetworkUtils.firstToUpperEachWord(pokemon.type[1]:lower()) end if #info == 0 then - table.insert(info, Resources.InfoScreen.LabelNoWeaknesses) + table.insert(info, "Has no weaknesses") end - local prefix = string.format("%s (%s) %s %s", pokemon.name, types, Resources.TypeDefensesScreen.Weaknesses, OUTPUT_CHAR) + local prefix = string.format("%s (%s) Weaknesses %s", pokemon.name, typesText, OUTPUT_CHAR) return buildResponse(prefix, info) end ---@param params string? ---@return string response function EventData.getMove(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - local move = getMoveOrDefault(params) - if not move then + local move, id = getMoveOrDefault(params) + if id <= 0 or not move then return buildDefaultResponse(params) end local info = {} - table.insert(info, string.format("%s: %s", - Resources.InfoScreen.LabelContact, - move.iscontact and Resources.AllScreens.Yes or Resources.AllScreens.No)) - table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelPP, move.pp or Constants.BLANKLINE)) - table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelPower, move.power or Constants.BLANKLINE)) - table.insert(info, string.format("%s: %s", Resources.TrackerScreen.HeaderAcc, move.accuracy or Constants.BLANKLINE)) - table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelMoveSummary, move.summary)) + local makesContact = NetworkUtils.containsText(move.description, "makes contact") + table.insert(info, string.format("Contact: %s", makesContact and "Yes" or "No")) + table.insert(info, string.format("PP: %s", move.pp or "---")) + table.insert(info, string.format("Power: %s", move.power or "---")) + table.insert(info, string.format("Acc: %s", move.accuracy or "---")) + table.insert(info, string.format("Move Summary: %s", move.description or "N/A")) local prefix = string.format("%s (%s, %s) %s", move.name, - Utils.firstToUpperEachWord(move.type), - Utils.firstToUpperEachWord(move.category), + NetworkUtils.firstToUpperEachWord(move.type:lower()), + NetworkUtils.firstToUpperEachWord(move.category:lower()), OUTPUT_CHAR) return buildResponse(prefix, info) end @@ -288,19 +306,13 @@ end ---@param params string? ---@return string response function EventData.getAbility(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - local ability = getAbilityOrDefault(params) - if not ability then + local ability, id = getAbilityOrDefault(params) + if id <= 0 or not ability then return buildDefaultResponse(params) end local info = {} - table.insert(info, string.format("%s: %s", ability.name, ability.description)) - -- Emerald only - if GameSettings.game == 2 and ability.descriptionEmerald then - table.insert(info, string.format("%s: %s", Resources.InfoScreen.LabelEmeraldAbility, ability.descriptionEmerald)) - end + table.insert(info, string.format("%s: %s", ability.name or "Unknown Ability", ability.description or "N/A")) return buildResponse(OUTPUT_CHAR, info) end @@ -310,11 +322,11 @@ function EventData.getRoute(params) -- TODO: Implement this function if true then return buildDefaultResponse(params) end -- Check for optional parameters - local paramsLower = Utils.toLowerUTF8(params or "") + local paramsLower = (params or ""):lower() local option for key, val in pairs(RouteData.EncounterArea or {}) do - if Utils.containsText(paramsLower, val, true) then - paramsLower = Utils.replaceText(paramsLower, Utils.toLowerUTF8(val), "", true) + if NetworkUtils.containsText(paramsLower, val) then + paramsLower = Utils.replaceText(paramsLower, val:lower(), "", true) option = key break end @@ -326,7 +338,7 @@ function EventData.getRoute(params) end local routeId = getRouteIdOrDefault(paramsLower) - local route = RouteData.Info[routeId or false] + local route = RouteData.Info[routeId or -1] if not route then return buildDefaultResponse(params) end @@ -354,7 +366,7 @@ function EventData.getRoute(params) table.insert(pokemonNames, PokemonData.Pokemon[pokemonId].name) end end - local wildsText = string.format("%s: %s/%s", "Wild Pokémon seen", #seenIds, #wildIds) + local wildsText = string.format("%s: %s/%s", "Wild Pok" .. Chars.accentedE .. "mon seen", #seenIds, #wildIds) if #seenIds > 0 then wildsText = wildsText .. string.format(" (%s)", table.concat(pokemonNames, ", ")) end @@ -363,7 +375,7 @@ function EventData.getRoute(params) local prefix if option then - prefix = string.format("%s: %s %s", route.name, Utils.firstToUpperEachWord(encounterArea), OUTPUT_CHAR) + prefix = string.format("%s: %s %s", route.name, NetworkUtils.firstToUpperEachWord(encounterArea), OUTPUT_CHAR) else prefix = string.format("%s %s", route.name, OUTPUT_CHAR) end @@ -376,7 +388,7 @@ function EventData.getDungeon(params) -- TODO: Implement this function if true then return buildDefaultResponse(params) end local routeId = getRouteIdOrDefault(params) - local route = RouteData.Info[routeId or false] + local route = RouteData.Info[routeId or -1] if not route then return buildDefaultResponse(params) end @@ -403,10 +415,10 @@ end function EventData.getUnfoughtTrainers(params) -- TODO: Implement this function if true then return buildDefaultResponse(params) end - local allowPartialDungeons = Utils.containsText(params, "dungeon", true) + local allowPartialDungeons = NetworkUtils.containsText(params, "dungeon") local includeSevii if GameSettings.game == 3 then - includeSevii = Utils.containsText(params, "sevii", true) + includeSevii = NetworkUtils.containsText(params, "sevii") else includeSevii = true -- to allow routes above the sevii route id for RSE end @@ -524,7 +536,7 @@ function EventData.getPivots(params) end end if #seenIds > 0 then - local route = RouteData.Info[mapId or false] or {} + local route = RouteData.Info[mapId or -1] or {} table.insert(info, string.format("%s: %s", route.name or "Unknown Route", table.concat(pokemonNames, ", "))) end end @@ -535,32 +547,46 @@ end ---@param params string? ---@return string response function EventData.getRevo(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end local pokemonID, targetEvoId - if not Utils.isNilOrEmpty(params) then - pokemonID = DataHelper.findPokemonId(params) + if (params or "") ~= "" then + pokemonID = findPokemonId(params) or -1 -- If more than one Pokémon name is provided, set the other as the target evo (i.e. "Eevee Vaporeon") - if pokemonID == 0 then - local s = Utils.split(params, " ", true) - pokemonID = DataHelper.findPokemonId(s[1]) - targetEvoId = DataHelper.findPokemonId(s[2]) + if pokemonID <= 0 then + local s = MiscUtils.split(params, " ", true) or {} + pokemonID = findPokemonId(s[1]) or -1 + targetEvoId = findPokemonId(s[2]) or -1 end else - local pokemon = Tracker.getPokemon(1, true) or {} - pokemonID = pokemon.pokemonID + local pokemon = Network.Data.program.getPlayerPokemon() or {} + pokemonID = pokemon.pokemonID or -1 + end + + local evoData = EvoData.EVOLUTIONS[pokemonID] or {} + -- Use first available target evo, if none specified + if not targetEvoId or targetEvoId <= 0 then + targetEvoId = 9999 + for revoId, _ in pairs(evoData) do + if revoId < targetEvoId then + targetEvoId = revoId + end + end end - local revo = PokemonRevoData.getEvoTable(pokemonID, targetEvoId) + + local pokemon = PokemonData.POKEMON[pokemonID + 1] or {} + local revo = evoData[targetEvoId] if not revo then - local pokemon = PokemonData.Pokemon[pokemonID or false] or {} - if pokemon.evolution == PokemonData.Evolutions.NONE then - local prefix = string.format("%s %s %s", pokemon.name, "Evos", OUTPUT_CHAR) + if pokemon.evolution == PokemonData.EVOLUTION_TYPES.NONE then + local prefix = string.format("%s Evos %s", pokemon.name or "N/A", OUTPUT_CHAR) return buildResponse(prefix, "Does not evolve.") else return buildDefaultResponse(pokemon.name or params) end end + table.sort(revo, function(a,b) + return a.percent > b.percent or (a.percent == b.percent) and a.id < b.id + end) + local info = {} local shortenPerc = function(p) if p < 0.01 then return "<0.01%" @@ -568,75 +594,142 @@ function EventData.getRevo(params) else return string.format("%.1f%%", p) end end local extraMons = 0 - for _, revoInfo in ipairs(revo or {}) do + for _, revoMon in ipairs(revo or {}) do if #info < MAX_ITEMS then - table.insert(info, string.format("%s %s", PokemonData.Pokemon[revoInfo.id].name, shortenPerc(revoInfo.perc))) + local mon = PokemonData.POKEMON[revoMon.id + 1] + if mon then + table.insert(info, string.format("%s %s", mon.name, shortenPerc(revoMon.percent))) + end else extraMons = extraMons + 1 end end if extraMons > 0 then - table.insert(info, string.format("(+%s more Pokémon)", extraMons)) + table.insert(info, string.format("(+%s more Pok" .. Chars.accentedE .. "mon)", extraMons)) end - local prefix = string.format("%s %s %s", PokemonData.Pokemon[pokemonID].name, "Evos", OUTPUT_CHAR) + local prefix = string.format("%s Evos %s", pokemon.name or "N/A", OUTPUT_CHAR) return buildResponse(prefix, info, ", ") end ---@param params string? ---@return string response function EventData.getCoverage(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end local calcFromLead = true local onlyFullyEvolved = false local moveTypes = {} - if not Utils.isNilOrEmpty(params) then - params = Utils.replaceText(params or "", ",%s*", " ") -- Remove any list commas - for _, word in ipairs(Utils.split(params, " ", true) or {}) do - if Utils.containsText(word, "evolve", true) or Utils.containsText(word, "fully", true) then + if params ~= nil and params ~= "" then + -- Remove any list commas + params = (params:gsub(",%s*", " ")) + for _, word in ipairs(MiscUtils.split(params, " ", true) or {}) do + if NetworkUtils.containsText(word, "evolve") or NetworkUtils.containsText(word, "fully") then onlyFullyEvolved = true else - local moveType = DataHelper.findPokemonType(word) + local moveType = findPokemonType(word) if moveType and moveType ~= "EMPTY" then calcFromLead = false - table.insert(moveTypes, PokemonData.Types[moveType] or moveType) + table.insert(moveTypes, PokemonData.POKEMON_TYPES[moveType] or moveType) end end end end - if calcFromLead then - moveTypes = CoverageCalcScreen.getPartyPokemonEffectiveMoveTypes(1) or {} + local leadPokemon = Network.Data.program.getPlayerPokemon() or {} + if calcFromLead and leadPokemon.moveIDs then + for _, moveID in pairs(leadPokemon.moveIDs) do + if moveID > 0 then + local moveData = MoveData.MOVES[moveID + 1] + local moveType = moveData.type + if moveData.name == "Hidden Power" then + moveType = Network.Data.tracker.getCurrentHiddenPowerType() + end + if moveData.category ~= MoveData.MOVE_CATEGORIES.STATUS and moveData.power ~= "---" then + if not MiscUtils.tableContains(moveTypes, moveType) then + table.insert(moveTypes, moveType) + end + end + end + end end if #moveTypes == 0 then return buildDefaultResponse(params) end + -- Copied most of the below code from CoverageCalcScreen + local effectivenessTable = { + [0.0] = { ids = {}, total = 0 }, + [0.25] = { ids = {}, total = 0 }, + [0.5] = { ids = {}, total = 0 }, + [1.0] = { ids = {}, total = 0 }, + [2.0] = { ids = {}, total = 0 }, + [4.0] = { ids = {}, total = 0 } + } + local function getMoveEffectivenessAgainstPokemon(moveType, pokemonData) + local effectiveness = 1.0 + for _, defenseType in pairs(pokemonData.type) do + if defenseType ~= PokemonData.POKEMON_TYPES.EMPTY and MoveData.EFFECTIVE_DATA[moveType][defenseType] then + effectiveness = effectiveness * MoveData.EFFECTIVE_DATA[moveType][defenseType] + end + end + if pokemonData.name == "Shedinja" and effectiveness < 2.0 then + return 0.0 + end + return effectiveness + end + local function calculateMovesAgainstPokemon(moveTypeList, internalId) + local max = 0.0 + for _, moveType in pairs(moveTypeList) do + local pokemonData = PokemonData.POKEMON[internalId] + local effectiveness = getMoveEffectivenessAgainstPokemon(moveType, pokemonData) + if effectiveness > max then + max = effectiveness + end + end + table.insert(effectivenessTable[max].ids, internalId) + effectivenessTable[max].total = effectivenessTable[max].total + 1 + end + + -- Check against all (most) pokemon + for internalId, pokemon in pairs(PokemonData.POKEMON) do + local valid = internalId > 1 + if PokemonData.ALTERNATE_FORMS[pokemon.name] and PokemonData.ALTERNATE_FORMS[pokemon.name].cosmetic == true then + valid = false + elseif onlyFullyEvolved then + valid = valid and (pokemon.evolution == PokemonData.EVOLUTION_TYPES.NONE) + end + if valid then + calculateMovesAgainstPokemon(moveTypes, internalId) + end + end + -- Not needed, unless you want to display a list of threats by most resistant + -- for _, data in pairs(effectivenessTable) do + -- table.sort(data.ids, function(id1, id2) + -- return PokemonData.POKEMON[id1].bst > PokemonData.POKEMON[id2].bst + -- end) + -- end + local info = {} - local coverageData = CoverageCalcScreen.calculateCoverageTable(moveTypes, onlyFullyEvolved) local multipliers = {} - for _, tab in pairs(CoverageCalcScreen.Tabs) do - table.insert(multipliers, tab) + for key, _ in pairs(effectivenessTable) do + table.insert(multipliers, key) end table.sort(multipliers, function(a,b) return a < b end) - for _, tab in ipairs(multipliers) do - local mons = coverageData[tab] or {} - if #mons > 0 then + for _, mult in ipairs(multipliers) do + local effectiveList = effectivenessTable[mult] or {} + if effectiveList.total and effectiveList.total > 0 then local format = "[%0dx] %s" - if tab == CoverageCalcScreen.Tabs.Half then + if mult == 0.5 then format = "[%0.1fx] %s" - elseif tab == CoverageCalcScreen.Tabs.Quarter then + elseif mult == 0.025 then format = "[%0.2fx] %s" end - table.insert(info, string.format(format, tab, #mons)) + table.insert(info, string.format(format, mult, effectiveList.total)) end end - local pokemon = Tracker.getPokemon(1, true) or {} - local typesText = Utils.firstToUpperEachWord(table.concat(moveTypes, ", ")) + local typesText = NetworkUtils.firstToUpperEachWord(table.concat(moveTypes, ", "):lower()) local fullyEvoText = onlyFullyEvolved and " Fully Evolved" or "" - local prefix = string.format("%s (%s)%s %s", "Coverage", typesText, fullyEvoText, OUTPUT_CHAR) - if calcFromLead and PokemonData.isValid(pokemon.pokemonID) then - prefix = string.format("%s's %s", PokemonData.Pokemon[pokemon.pokemonID].name, prefix) + local prefix = string.format("Coverage (%s)%s %s", typesText, fullyEvoText, OUTPUT_CHAR) + if calcFromLead and PokemonData.POKEMON[leadPokemon.pokemonID + 1] then + prefix = string.format("%s's %s", PokemonData.POKEMON[leadPokemon.pokemonID + 1].name, prefix) end return buildResponse(prefix, info, ", ") end @@ -649,12 +742,11 @@ function EventData.getHeals(params) local info = {} local displayHP, displayStatus, displayPP, displayBerries - if not Utils.isNilOrEmpty(params) then - local paramToLower = Utils.toLowerUTF8(params) - displayHP = Utils.containsText(paramToLower, "hp", true) - displayPP = Utils.containsText(paramToLower, "pp", true) - displayStatus = Utils.containsText(paramToLower, "status", true) - displayBerries = Utils.containsText(paramToLower, "berries", true) + if (params or "") ~= "" then + displayHP = NetworkUtils.containsText(params, "hp") + displayPP = NetworkUtils.containsText(params, "pp") + displayStatus = NetworkUtils.containsText(params, "status") + displayBerries = NetworkUtils.containsText(params, "berries") end -- Default to showing all (except redundant berries) if not (displayHP or displayPP or displayStatus or displayBerries) then @@ -726,7 +818,7 @@ function EventData.getHeals(params) if displayBerries and #berryItems > 0 then sortAndCombine("Berries", berryItems) end - local prefix = string.format("%s %s", Resources.TrackerScreen.HealsInBag, OUTPUT_CHAR) + local prefix = string.format("Heals %s", OUTPUT_CHAR) return buildResponse(prefix, info) end @@ -741,9 +833,9 @@ function EventData.getTMsHMs(params) local singleTmLookup local displayGym, displayNonGym, displayHM - if params and not Utils.isNilOrEmpty(params) then - displayGym = Utils.containsText(params, "gym", true) - displayHM = Utils.containsText(params, "hm", true) + if params and (params or "") ~= "" then + displayGym = NetworkUtils.containsText(params, "gym") + displayHM = NetworkUtils.containsText(params, "hm") singleTmLookup = tonumber(params:match("(%d+)") or "") end -- Default to showing just tms (gym & other) @@ -767,7 +859,7 @@ function EventData.getTMsHMs(params) if canSeeTM and MoveData.isValid(moveId) then textToAdd = MoveData.Moves[moveId].name else - textToAdd = string.format("%s %s", Constants.BLANKLINE, "(not acquired yet)") + textToAdd = string.format("%s %s", "---", "(not acquired yet)") end return buildResponse(prefix, string.format("%s %02d: %s", "TM", singleTmLookup, textToAdd)) end @@ -836,18 +928,16 @@ end ---@param params string? ---@return string response function EventData.getSearch(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - local helpResponse = "Search tracked info for a Pokémon, move, or ability." - if Utils.isNilOrEmpty(params, true) then + local helpResponse = "Search tracked info for a Pok" .. Chars.accentedE .. "mon, move, or ability." + if (params or "") == "" then return buildResponse(params, helpResponse) end local function getModeAndId(input, threshold) - local id = DataHelper.findPokemonId(input, threshold) + local id = findPokemonId(input, threshold) if id ~= 0 then return "pokemon", id end - id = DataHelper.findMoveId(input, threshold) + id = findMoveId(input, threshold) if id ~= 0 then return "move", id end - id = DataHelper.findAbilityId(input, threshold) + id = findAbilityId(input, threshold) if id ~= 0 then return "ability", id end return nil, 0 end @@ -860,102 +950,93 @@ function EventData.getSearch(params) end if not searchMode then local prefix = string.format("%s %s", params, OUTPUT_CHAR) - return buildResponse(prefix, "Can't find a Pokémon, move, or ability with that name.") + return buildResponse(prefix, "Can't find a Pok" .. Chars.accentedE .. "mon, move, or ability with that name.") end + local trackedIds = Network.Data.tracker.getTrackedIDs() or {} + local info = {} if searchMode == "pokemon" then - local pokemon = PokemonData.Pokemon[searchId] - if not pokemon then + local pokemon = PokemonData.POKEMON[searchId + 1] + if not pokemon or not MiscUtils.tableContains(trackedIds, searchId) then return buildDefaultResponse(params) end -- Tracked Abilities local trackedAbilities = {} - for _, ability in ipairs(Tracker.getAbilities(pokemon.pokemonID) or {}) do - if AbilityData.isValid(ability.id) then - table.insert(trackedAbilities, AbilityData.Abilities[ability.id].name) + for abilityId, _ in ipairs(Network.Data.tracker.getAbilities(searchId) or {}) do + if AbilityData.ABILITIES[abilityId + 1] then + table.insert(trackedAbilities, AbilityData.ABILITIES[abilityId + 1].name) end end if #trackedAbilities > 0 then - table.insert(info, string.format("%s: %s", "Abilities", table.concat(trackedAbilities, ", "))) + table.insert(info, string.format("Abilities: %s", table.concat(trackedAbilities, ", "))) end -- Tracked Stat Markings local statMarksToAdd = {} - local trackedStatMarkings = Tracker.getStatMarkings(pokemon.pokemonID) or {} - for _, statKey in ipairs(Constants.OrderedLists.STATSTAGES) do + local trackedStatMarkings = Network.Data.tracker.getStatPredictions(searchId) or {} + for _, statKey in ipairs({"HP", "ATK", "DEF", "SPA", "SPD", "SPE"}) do local markVal = trackedStatMarkings[statKey] - if markVal ~= 0 then - local marking = Constants.STAT_STATES[markVal] or {} - local symbol = string.sub(marking.text or " ", 1, 1) or "" - table.insert(statMarksToAdd, string.format("%s(%s)", Utils.toUpperUTF8(statKey), symbol)) + if markVal > 1 then + local marking = Graphics.MAIN_SCREEN_CONSTANTS.STAT_PREDICTION_STATES[markVal] or {} + if marking.text then + local symbol = string.sub(marking.text, 1, 1) + if symbol == "_" then + symbol = "-" + end + table.insert(statMarksToAdd, string.format("%s(%s)", statKey:upper(), symbol)) + end end end if #statMarksToAdd > 0 then - table.insert(info, string.format("%s: %s", "Stats", table.concat(statMarksToAdd, ", "))) + table.insert(info, string.format("Stats: %s", table.concat(statMarksToAdd, ", "))) end -- Tracked Moves local extra = 0 local trackedMoves = {} - for _, move in ipairs(Tracker.getMoves(pokemon.pokemonID) or {}) do - if MoveData.isValid(move.id) then + for _, moveInfo in ipairs(Network.Data.tracker.getMoves(searchId) or {}) do + local moveId, moveLv = moveInfo.move or 0, moveInfo.level or 0 + if moveId > 0 and moveLv > 0 and MoveData.MOVES[moveId + 1] then if #trackedMoves < MAX_ITEMS then - -- { id = moveId, level = level, minLv = level, maxLv = level, }, - local lvText - if move.minLv and move.maxLv and move.minLv ~= move.maxLv then - lvText = string.format(" (%s.%s-%s)", Resources.TrackerScreen.LevelAbbreviation, move.minLv, move.maxLv) - elseif move.level > 0 then - lvText = string.format(" (%s.%s)", Resources.TrackerScreen.LevelAbbreviation, move.level) - end - table.insert(trackedMoves, string.format("%s%s", MoveData.Moves[move.id].name, lvText or "")) + table.insert(trackedMoves, string.format("%s (Lv.%s)", MoveData.MOVES[moveId + 1].name, moveLv)) else extra = extra + 1 end end end if #trackedMoves > 0 then - table.insert(info, string.format("%s: %s", "Moves", table.concat(trackedMoves, ", "))) + table.insert(info, string.format("Moves: %s", table.concat(trackedMoves, ", "))) if extra > 0 then table.insert(info, string.format("(+%s more)", extra)) end end -- Tracked Encounters - local seenInWild = Tracker.getEncounters(pokemon.pokemonID, true) - local seenOnTrainers = Tracker.getEncounters(pokemon.pokemonID, false) - local trackedSeen = {} - if seenInWild > 0 then - table.insert(trackedSeen, string.format("%s in wild", seenInWild)) - end - if seenOnTrainers > 0 then - table.insert(trackedSeen, string.format("%s on trainers", seenOnTrainers)) - end - if #trackedSeen > 0 then - table.insert(info, string.format("%s: %s", "Seen", table.concat(trackedSeen, ", "))) + local amountSeen = Network.Data.tracker.getAmountSeen(searchId) or 0 + if amountSeen > 0 then + table.insert(info, string.format("Amount seen: %s", amountSeen)) end -- Tracked Notes - local trackedNote = Tracker.getNote(pokemon.pokemonID) - if #trackedNote > 0 then - table.insert(info, string.format("%s: %s", "Note", trackedNote)) + local trackedNote = Network.Data.tracker.getNote(searchId) + if trackedNote and trackedNote ~= "" then + table.insert(info, string.format("Note: %s", trackedNote)) end - local prefix = string.format("%s %s %s", "Tracked", pokemon.name, OUTPUT_CHAR) + local prefix = string.format("Tracked %s %s", pokemon.name, OUTPUT_CHAR) return buildResponse(prefix, info) elseif searchMode == "move" or searchMode == "moves" then - local move = MoveData.Moves[searchId] + local move = MoveData.MOVES[searchId + 1] if not move then return buildDefaultResponse(params) end - local moveId = tonumber(move.id) or 0 + local moveId = tonumber(searchId) or 0 local foundMons = {} - for pokemonID, trackedPokemon in pairs(Tracker.Data.allPokemon or {}) do - for _, trackedMove in ipairs(trackedPokemon.moves or {}) do - if trackedMove.id == moveId and trackedMove.level > 0 then - local lvText = tostring(trackedMove.level) - if trackedMove.minLv and trackedMove.maxLv and trackedMove.minLv ~= trackedMove.maxLv then - lvText = string.format("%s-%s", trackedMove.minLv, trackedMove.maxLv) + for _, pokemonID in pairs(trackedIds) do + for _, trackedMove in ipairs(Network.Data.tracker.getMoves(pokemonID) or {}) do + if trackedMove.move == moveId and trackedMove.level > 0 then + local pokemon = PokemonData.POKEMON[pokemonID + 1] + if pokemon then + local notes = string.format("%s (Lv.%s)", pokemon.name, trackedMove.level) + table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = notes}) + break end - local pokemon = PokemonData.Pokemon[pokemonID] - local notes = string.format("%s (%s.%s)", pokemon.name, Resources.TrackerScreen.LevelAbbreviation, lvText) - table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = notes}) - break end end end @@ -969,22 +1050,36 @@ function EventData.getSearch(params) end end if extra > 0 then - table.insert(info, string.format("(+%s more Pokémon)", extra)) + table.insert(info, string.format("(+%s more Pok" .. Chars.accentedE .. "mon)", extra)) end - local prefix = string.format("%s %s %s Pokémon:", move.name, OUTPUT_CHAR, #foundMons) + local prefix = string.format("%s %s %s Pok" .. Chars.accentedE .. "mon:", move.name, OUTPUT_CHAR, #foundMons) return buildResponse(prefix, info, ", ") elseif searchMode == "ability" or searchMode == "abilities" then - local ability = AbilityData.Abilities[searchId] + local ability = AbilityData.ABILITIES[searchId + 1] if not ability then return buildDefaultResponse(params) end local foundMons = {} - for pokemonID, trackedPokemon in pairs(Tracker.Data.allPokemon or {}) do - for _, trackedAbility in ipairs(trackedPokemon.abilities or {}) do - if trackedAbility.id == ability.id then - local pokemon = PokemonData.Pokemon[pokemonID] - table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = pokemon.name }) - break + for _, pokemonID in pairs(trackedIds) do + for abilityId, _ in ipairs(Network.Data.tracker.getAbilities(pokemonID) or {}) do + if abilityId == searchId then + local pokemon = PokemonData.POKEMON[pokemonID + 1] + if pokemon then + table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = pokemon.name }) + break + end + end + end + end + if #foundMons == 0 and ability.name then + -- Try searching through notes for ability names + for _, pokemonID in pairs(trackedIds) do + local note = Network.Data.tracker.getNote(pokemonID) + if note ~= "" and NetworkUtils.containsText(note, ability.name) then + local pokemon = PokemonData.POKEMON[pokemonID + 1] + if pokemon then + table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = pokemon.name }) + end end end end @@ -998,9 +1093,9 @@ function EventData.getSearch(params) end end if extra > 0 then - table.insert(info, string.format("(+%s more Pokémon)", extra)) + table.insert(info, string.format("(+%s more Pok" .. Chars.accentedE .. "mon)", extra)) end - local prefix = string.format("%s %s %s Pokémon:", ability.name, OUTPUT_CHAR, #foundMons) + local prefix = string.format("%s %s %s Pok" .. Chars.accentedE .. "mon:", ability.name, OUTPUT_CHAR, #foundMons) return buildResponse(prefix, info, ", ") end -- Unused @@ -1011,18 +1106,19 @@ end ---@param params string? ---@return string response function EventData.getSearchNotes(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - if Utils.isNilOrEmpty(params, true) then + if (params or "") == "" then return buildDefaultResponse(params) end local info = {} local foundMons = {} - for pokemonID, trackedPokemon in pairs(Tracker.Data.allPokemon or {}) do - if trackedPokemon.note and Utils.containsText(trackedPokemon.note, params, true) then - local pokemon = PokemonData.Pokemon[pokemonID] - table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = pokemon.name }) + for _, pokemonID in ipairs(Network.Data.tracker.getTrackedIDs() or {}) do + local note = Network.Data.tracker.getNote(pokemonID) + if note ~= "" and NetworkUtils.containsText(note, params) then + local pokemon = PokemonData.POKEMON[pokemonID + 1] + if pokemon then + table.insert(foundMons, { id = pokemonID, bst = tonumber(pokemon.bst or "0"), notes = pokemon.name }) + end end end table.sort(foundMons, function(a,b) return a.bst > b.bst or (a.bst == b.bst and a.id < b.id) end) @@ -1035,50 +1131,45 @@ function EventData.getSearchNotes(params) end end if extra > 0 then - table.insert(info, string.format("(+%s more Pokémon)", extra)) + table.insert(info, string.format("(+%s more Pok" .. Chars.accentedE .. "mon)", extra)) end - local prefix = string.format("%s: \"%s\" %s %s Pokémon:", "Note", params, OUTPUT_CHAR, #foundMons) + local prefix = string.format("%s: \"%s\" %s %s Pok" .. Chars.accentedE .. "mon:", "Note", params, OUTPUT_CHAR, #foundMons) return buildResponse(prefix, info, ", ") end ---@param params string? ---@return string response function EventData.getFavorites(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end + -- Read favorites directly from file, as they aren't really stored anywhere accessible + local fileName = "savedData" .. Paths.SLASH .. Network.Data.gameInfo.NAME .. ".faves" + local favesFromFile = MiscUtils.readStringFromFile(fileName) + if favesFromFile == nil or favesFromFile == "" then + return buildDefaultResponse(params) + end + local info = {} - local faveButtons = { - StreamerScreen.Buttons.PokemonFavorite1, - StreamerScreen.Buttons.PokemonFavorite2, - StreamerScreen.Buttons.PokemonFavorite3, - } - local favesList = {} - for i, button in ipairs(faveButtons or {}) do + local favesList = MiscUtils.split(favesFromFile, ",", true) or {} + for i = 1, Network.Data.gameInfo.GEN, 1 do + local pokemonID = tonumber(favesList[i]) or -2 local name - if PokemonData.isValid(button.pokemonID) then - name = PokemonData.Pokemon[button.pokemonID].name + if PokemonData.POKEMON[pokemonID + 1] then + name = PokemonData.POKEMON[pokemonID + 1].name else - name = Constants.BLANKLINE + name = "---" end - table.insert(favesList, string.format("#%s %s", i, name)) - end - if #favesList > 0 then - table.insert(info, table.concat(favesList, ", ")) + table.insert(info, string.format("#%s %s", i, name)) end - local prefix = string.format("%s %s", "Favorites", OUTPUT_CHAR) - return buildResponse(prefix, info) + local prefix = string.format("Favorites %s", OUTPUT_CHAR) + return buildResponse(prefix, info, ", ") end ---@param params string? ---@return string response function EventData.getTheme(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end local info = {} - local themeCode = Theme.exportThemeToText() - local themeName = Theme.getThemeNameFromCode(themeCode) - table.insert(info, string.format("%s: %s", themeName, themeCode)) - local prefix = string.format("%s %s", "Theme", OUTPUT_CHAR) + local themeCode = ThemeFactory.getThemeString() + table.insert(info, themeCode) + local prefix = string.format("%s %s", "Current Theme", OUTPUT_CHAR) return buildResponse(prefix, info) end @@ -1097,7 +1188,7 @@ function EventData.getGameStats(params) table.insert(info, string.format("%s: %s", statPair:getText(), statValue)) end end - local prefix = string.format("%s %s", Resources.GameOptionsScreen.ButtonGameStats, OUTPUT_CHAR) + local prefix = string.format("Game Stats %s", OUTPUT_CHAR) return buildResponse(prefix, info) end @@ -1106,7 +1197,7 @@ end function EventData.getProgress(params) -- TODO: Implement this function if true then return buildDefaultResponse(params) end - local includeSevii = Utils.containsText(params, "sevii", true) + local includeSevii = NetworkUtils.containsText(params, "sevii") local info = {} local badgesObtained, maxBadges = 0, 8 for i = 1, maxBadges, 1 do @@ -1146,7 +1237,7 @@ function EventData.getProgress(params) end end table.insert(info, string.format("%s: %s/%s (%0.1f%%)", --, Legendary: %s/%s (%0.1f%%)", - "Pokémon seen fully evolved", + "Pok" .. Chars.accentedE .. "mon seen fully evolved", fullyEvolvedSeen, fullyEvolvedTotal, fullyEvolvedSeen / fullyEvolvedTotal * 100)) @@ -1157,19 +1248,20 @@ end ---@param params string? ---@return string response function EventData.getLog(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end -- TODO: add "previous" as a parameter; requires storing this information somewhere local prefix = string.format("%s %s", "Log", OUTPUT_CHAR) - local hasParsedThisLog = RandomizerLog.Data.Settings and string.find(RandomizerLog.loadedLogPath or "", FileManager.PostFixes.AUTORANDOMIZED, 1, true) - if not hasParsedThisLog then + local pokemonList = Network.Data.logInfo and Network.Data.logInfo.getPokemon() or {} + if #pokemonList == 0 then return buildResponse(prefix, "This game's log file hasn't been opened yet.") end + local miscInfo = Network.Data.logInfo.getMiscInfo() or {} local info = {} - for _, button in ipairs(Utils.getSortedList(LogTabMisc.Buttons or {})) do - table.insert(info, string.format("%s %s", button:getText(), button:getValue())) - end + table.insert(info, string.format("Game: %s", Network.Data.gameInfo.NAME or "N/A")) + table.insert(info, string.format("Randomizer: %s", miscInfo.version or "N/A")) + table.insert(info, string.format("Random Seed: %s", miscInfo.seed or "N/A")) + table.insert(info, string.format("Settings String: %s", miscInfo.settingsString or "N/A")) + return buildResponse(prefix, info) end @@ -1202,8 +1294,8 @@ end function EventData.getAbout(params) local info = {} table.insert(info, string.format("Version: %s", MiscConstants.TRACKER_VERSION)) - table.insert(info, string.format("Game: %s", "HGSS" or GameSettings.gamename)) -- TODO: Fix - table.insert(info, string.format("Attempts: %s", 1234 or Main.currentSeed or 1)) -- TODO: Fix + table.insert(info, string.format("Game: %s", Network.Data.gameInfo.NAME or "N/A")) + table.insert(info, string.format("Attempts: %s", Network.Data.seedLogger.getTotalRuns() or 1)) table.insert(info, string.format("Streamerbot Code: v%s", Network.currentStreamerbotVersion or "N/A")) local prefix = string.format("NDS Ironmon Tracker %s", OUTPUT_CHAR) return buildResponse(prefix, info) diff --git a/ironmon_tracker/network/EventHandler.lua b/ironmon_tracker/network/EventHandler.lua index 72b703c0..cd30a735 100644 --- a/ironmon_tracker/network/EventHandler.lua +++ b/ironmon_tracker/network/EventHandler.lua @@ -404,7 +404,7 @@ EventHandler.CoreEvents = { Network.checkVersion(request.Args and request.Args.Version or "") RequestHandler.removedExcludedRequests() -- NOTE: If any screen is displaying connection status info, add code here to refresh it - print("[Stream Connect] Connected to Streamer.bot") + print("Stream Connect: Connected to Streamer.bot") return RequestHandler.REQUEST_COMPLETE end, }, @@ -464,22 +464,22 @@ EventHandler.DefaultEvents = { CMD_Pokemon = { Type = EventHandler.EventTypes.Command, Command = "!pokemon", - Name = "Pokémon Info", - Help = "name > Displays useful game info for a Pokémon.", + Name = "Pok" .. Chars.accentedE .. "mon Info", + Help = "name > Displays useful game info for a Pok" .. Chars.accentedE .. "mon.", Fulfill = function(self, request) return EventData.getPokemon(request.SanitizedInput) end, }, CMD_BST = { Type = EventHandler.EventTypes.Command, Command = "!bst", - Name = "Pokémon BST", - Help = "name > Displays the base stat total (BST) for a Pokémon.", + Name = "Pok" .. Chars.accentedE .. "mon BST", + Help = "name > Displays the base stat total (BST) for a Pok" .. Chars.accentedE .. "mon.", Fulfill = function(self, request) return EventData.getBST(request.SanitizedInput) end, }, CMD_Weak = { Type = EventHandler.EventTypes.Command, Command = "!weak", - Name = "Pokémon Weaknesses", - Help = "name > Displays the weaknesses for a Pokémon.", + Name = "Pok" .. Chars.accentedE .. "mon Weaknesses", + Help = "name > Displays the weaknesses for a Pok" .. Chars.accentedE .. "mon.", Fulfill = function(self, request) return EventData.getWeak(request.SanitizedInput) end, }, CMD_Move = { @@ -493,7 +493,7 @@ EventHandler.DefaultEvents = { Type = EventHandler.EventTypes.Command, Command = "!ability", Name = "Ability Info", - Help = "name > Displays game info for a Pokémon's ability.", + Help = "name > Displays game info for a Pok" .. Chars.accentedE .. "mon's ability.", Fulfill = function(self, request) return EventData.getAbility(request.SanitizedInput) end, }, CMD_Route = { @@ -527,15 +527,15 @@ EventHandler.DefaultEvents = { CMD_Revo = { Type = EventHandler.EventTypes.Command, Command = "!revo", - Name = "Pokémon Random Evolutions", - Help = "name [target-evo] > Displays randomized evolution possibilities for a Pokémon, and it's [target-evo] if more than one available.", + Name = "Pok" .. Chars.accentedE .. "mon Random Evolutions", + Help = "name [target-evo] > Displays randomized evolution possibilities for a Pok" .. Chars.accentedE .. "mon, and it's [target-evo] if more than one available.", Fulfill = function(self, request) return EventData.getRevo(request.SanitizedInput) end, }, CMD_Coverage = { Type = EventHandler.EventTypes.Command, Command = "!coverage", Name = "Move Coverage Effectiveness", - Help = "types [fully evolved] > For a list of move types, checks all Pokémon matchups (or only [fully evolved]) for effectiveness.", + Help = "types [fully evolved] > For a list of move types, checks all Pok" .. Chars.accentedE .. "mon matchups (or only [fully evolved]) for effectiveness.", Fulfill = function(self, request) return EventData.getCoverage(request.SanitizedInput) end, }, CMD_Heals = { @@ -556,14 +556,14 @@ EventHandler.DefaultEvents = { Type = EventHandler.EventTypes.Command, Command = "!search", Name = "Search Tracked Info", - Help = "searchterms > Search tracked info for a Pokémon, move, or ability.", + Help = "searchterms > Search tracked info for a Pok" .. Chars.accentedE .. "mon, move, or ability.", Fulfill = function(self, request) return EventData.getSearch(request.SanitizedInput) end, }, CMD_SearchNotes = { Type = EventHandler.EventTypes.Command, Command = "!searchnotes", - Name = "Search Notes on Pokémon", - Help = "notes > Displays a list of Pokémon with any matching notes.", + Name = "Search Notes on Pok" .. Chars.accentedE .. "mon", + Help = "notes > Displays a list of Pok" .. Chars.accentedE .. "mon with any matching notes.", Fulfill = function(self, request) return EventData.getSearchNotes(request.SanitizedInput) end, }, CMD_Favorites = { @@ -684,13 +684,13 @@ EventHandler.DefaultEvents = { Fulfill = function(self, request) local response = { AdditionalInfo = { AutoComplete = false } } if (request.SanitizedInput or "") == "" then - response.Message = string.format("> Unable to change a favorite, please enter a number (1, 2, or 3) followed by a Pokémon name.") + response.Message = string.format("> Unable to change a favorite, please enter a number (1, 2, or 3) followed by a Pok" .. Chars.accentedE .. "mon name.") return response end local slotNumber, pokemonName = request.SanitizedInput:match("^#?(%d*)%s*(%D.+)") local successMsg = changeStarterFavorite(pokemonName, slotNumber) if not successMsg then - response.Message = string.format("%s > Unable to change a favorite, please enter a number (1, 2, or 3) followed by a Pokémon name.", request.SanitizedInput) + response.Message = string.format("%s > Unable to change a favorite, please enter a number (1, 2, or 3) followed by a Pok" .. Chars.accentedE .. "mon name.", request.SanitizedInput) return response end if self.O_SendMessage then @@ -710,12 +710,12 @@ EventHandler.DefaultEvents = { Fulfill = function(self, request) local response = { AdditionalInfo = { AutoComplete = false } } if (request.SanitizedInput or "") == "" then - response.Message = string.format("> Unable to change favorite #1, please enter a valid Pokémon name.") + response.Message = string.format("> Unable to change favorite #1, please enter a valid Pok" .. Chars.accentedE .. "mon name.") return response end local successMsg = changeStarterFavorite(request.SanitizedInput, 1) if not successMsg then - response.Message = string.format("%s > Unable to change favorite #1, please enter a valid Pokémon name.", request.SanitizedInput) + response.Message = string.format("%s > Unable to change favorite #1, please enter a valid Pok" .. Chars.accentedE .. "mon name.", request.SanitizedInput) return response end if self.O_SendMessage then @@ -735,12 +735,12 @@ EventHandler.DefaultEvents = { Fulfill = function(self, request) local response = { AdditionalInfo = { AutoComplete = false } } if (request.SanitizedInput or "") == "" then - response.Message = string.format("> Unable to change favorite #2, please enter a valid Pokémon name.") + response.Message = string.format("> Unable to change favorite #2, please enter a valid Pok" .. Chars.accentedE .. "mon name.") return response end local successMsg = changeStarterFavorite(request.SanitizedInput, 2) if not successMsg then - response.Message = string.format("%s > Unable to change favorite #2, please enter a valid Pokémon name.", request.SanitizedInput) + response.Message = string.format("%s > Unable to change favorite #2, please enter a valid Pok" .. Chars.accentedE .. "mon name.", request.SanitizedInput) return response end if self.O_SendMessage then @@ -760,12 +760,12 @@ EventHandler.DefaultEvents = { Fulfill = function(self, request) local response = { AdditionalInfo = { AutoComplete = false } } if (request.SanitizedInput or "") == "" then - response.Message = string.format("> Unable to change favorite #3, please enter a valid Pokémon name.") + response.Message = string.format("> Unable to change favorite #3, please enter a valid Pok" .. Chars.accentedE .. "mon name.") return response end local successMsg = changeStarterFavorite(request.SanitizedInput, 3) if not successMsg then - response.Message = string.format("%s > Unable to change favorite #3, please enter a valid Pokémon name.", request.SanitizedInput) + response.Message = string.format("%s > Unable to change favorite #3, please enter a valid Pok" .. Chars.accentedE .. "mon name.", request.SanitizedInput) return response end if self.O_SendMessage then diff --git a/ironmon_tracker/network/Network.lua b/ironmon_tracker/network/Network.lua index 102ff78f..c8702408 100644 --- a/ironmon_tracker/network/Network.lua +++ b/ironmon_tracker/network/Network.lua @@ -78,14 +78,14 @@ function Network.IConnection:new(o) return o end -function Network.initialize(initialProgram) - Network.program = initialProgram - Network.iniParser = dofile(Paths.FOLDERS.DATA_FOLDER .. "/Inifile.lua") - - dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/Json.lua") - dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/EventData.lua") - dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/EventHandler.lua") - dofile(Paths.FOLDERS.NETWORK_FOLDER .. "/RequestHandler.lua") +function Network.initialize() + Network.Data = {} + Network.iniParser = dofile(Paths.FOLDERS.DATA_FOLDER .. Paths.SLASH .. "Inifile.lua") + + dofile(Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. "Json.lua") + dofile(Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. "EventData.lua") + dofile(Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. "EventHandler.lua") + dofile(Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. "RequestHandler.lua") NetworkUtils.setupJsonLibrary() Network.loadSettings() @@ -107,11 +107,27 @@ end function Network.startup() EventHandler.onStartup() -- Remove the delayed start frame counter; should not repeat - if Network.program and Network.program.frameCounters then - Network.program.frameCounters.networkStartup = nil + if Network.Data.program and Network.Data.program.frameCounters then + Network.Data.program.frameCounters.networkStartup = nil end end +--- Allow Network access to various data objects used throughout the software +function Network.linkData(program, tracker, battleHandler, randomizerLogParser) + if program == nil then + return + end + Network.Data = { + program = program, + gameInfo = program.getGameInfo(), + memoryAddresses = program.getAddresses(), + tracker = tracker, + battleHandler = battleHandler, + seedLogger = program.getSeedLogger(), + randomizerLogParser = randomizerLogParser, + } +end + ---Checks current version of the Tracker's Network code against the Streamerbot code version ---@param externalVersion string function Network.checkVersion(externalVersion) @@ -202,7 +218,7 @@ function Network.tryConnect() serverInfo = comm.socketServerGetInfo() or Network.SOCKET_SERVER_NOT_FOUND -- Might also test/try 'bool comm.socketServerIsConnected()' end - local ableToConnect = serverInfo and serverInfo:lower():find(Network.SOCKET_SERVER_NOT_FOUND, 1, true) ~= nil + local ableToConnect = NetworkUtils.containsText(serverInfo, Network.SOCKET_SERVER_NOT_FOUND) if ableToConnect then C.State = Network.ConnectionState.Listen comm.socketServerSetTimeout(500) -- # of milliseconds @@ -226,7 +242,7 @@ function Network.tryConnect() -- See HTTP WARNING at the top of this file pcall(function() result = comm.httpTest() or "N/A" end) end - local ableToConnect = result and result:lower():find("done testing", 1, true) ~= nil + local ableToConnect = NetworkUtils.containsText(result, "done testing") if ableToConnect then C.State = Network.ConnectionState.Listen comm.httpSetTimeout(500) -- # of milliseconds @@ -235,8 +251,8 @@ function Network.tryConnect() C.UpdateFrequency = Network.TEXT_UPDATE_FREQUENCY C.SendReceive = Network.updateByText local folder = Network.Options["DataFolder"] or "" - C.InboundFile = folder .. "/" .. Network.TEXT_INBOUND_FILE - C.OutboundFile = folder .. "/" .. Network.TEXT_OUTBOUND_FILE + C.InboundFile = folder .. Paths.SLASH .. Network.TEXT_INBOUND_FILE + C.OutboundFile = folder .. Paths.SLASH .. Network.TEXT_OUTBOUND_FILE local ableToConnect = (folder or "") ~= "" and NetworkUtils.folderExists(folder) if ableToConnect then C.State = Network.ConnectionState.Listen @@ -349,7 +365,7 @@ function Network.updateByHttp() end function Network.getStreamerbotCode() - local filepath = Paths.FOLDERS.NETWORK_FOLDER .. "/" .. Network.FILE_IMPORT_CODE + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. Network.FILE_IMPORT_CODE local lines = MiscUtils.readLinesFromFile(filepath) or {} return lines[1] or "" end @@ -378,7 +394,7 @@ end function Network.loadSettings() Network.MetaSettings = {} - local filepath = Paths.FOLDERS.NETWORK_FOLDER .. "/" .. Network.FILE_SETTINGS + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. Network.FILE_SETTINGS if not FormsUtils.fileExists(filepath) then return end @@ -408,7 +424,7 @@ function Network.saveSettings() settings.network[encodedKey] = val end - local filepath = Paths.FOLDERS.NETWORK_FOLDER .. "/" .. Network.FILE_SETTINGS + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. Network.FILE_SETTINGS Network.iniParser.save(filepath, settings) end diff --git a/ironmon_tracker/network/RequestHandler.lua b/ironmon_tracker/network/RequestHandler.lua index e434b578..faf72139 100644 --- a/ironmon_tracker/network/RequestHandler.lua +++ b/ironmon_tracker/network/RequestHandler.lua @@ -229,12 +229,16 @@ function RequestHandler.processAllRequests() for _, request in ipairs(toProcess) do for _, eventKey in pairs(MiscUtils.split(request.EventKey, ",", true) or {}) do local event = EventHandler.Events[eventKey] - local response = RequestHandler.processAndBuildResponse(request, event) + local response + -- Wrap request processing in error catch call. If fail, no response is returned (remove request) + -- pcall(function() -- TODO: Re-enable this for final commit + response = RequestHandler.processAndBuildResponse(request, event) + -- end) if not request.SentResponse then RequestHandler.addUpdateResponse(response) request.SentResponse = true end - if response.StatusCode ~= RequestHandler.StatusCodes.PROCESSING then + if not response or response.StatusCode ~= RequestHandler.StatusCodes.PROCESSING then RequestHandler.removeRequest(request.GUID) end end @@ -343,7 +347,7 @@ end ---@return boolean success function RequestHandler.saveRequestsData() RequestHandler.removedExcludedRequests() - local folderpath = Paths.FOLDERS.NETWORK_FOLDER .. "/" + local folderpath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH local success = NetworkUtils.encodeToJsonFile(folderpath .. RequestHandler.SAVED_REQUESTS, RequestHandler.Requests) RequestHandler.lastSaveTime = os.time() return (success == true) @@ -352,7 +356,7 @@ end --- Imports a list of IRequests from a data file; returns true if successful ---@return boolean success function RequestHandler.loadRequestsData() - local folderpath = Paths.FOLDERS.NETWORK_FOLDER .. "/" + local folderpath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH local requests = NetworkUtils.decodeJsonFile(folderpath .. RequestHandler.SAVED_REQUESTS) if requests then RequestHandler.Requests = requests diff --git a/ironmon_tracker/utils/NetworkUtils.lua b/ironmon_tracker/utils/NetworkUtils.lua index 2a21c2ef..c13d086c 100644 --- a/ironmon_tracker/utils/NetworkUtils.lua +++ b/ironmon_tracker/utils/NetworkUtils.lua @@ -1,5 +1,7 @@ NetworkUtils = {} +local JSON_FILENAME = "Json.lua" + ---@return string guid function NetworkUtils.newGUID() local template ='xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' @@ -15,7 +17,7 @@ end function NetworkUtils.folderExists(folderpath) if (folderpath or "") == "" then return false end if not folderpath:find("[/\\]$") then - folderpath = folderpath .. "/" + folderpath = folderpath .. Paths.SLASH end -- Hacky but simply way to check if a folder exists: try to rename it @@ -25,6 +27,23 @@ function NetworkUtils.folderExists(folderpath) return exists or (not exists and code == 13) end +---Checks if some text contains some other text; ignores case by default +---@param text? string +---@param searchString? string +---@param matchCase? boolean +---@return boolean +function NetworkUtils.containsText(text, searchString, matchCase) + if text == nil or text == "" or searchString == nil then + return false + end + if not matchCase then + text = text:lower() + searchString = searchString:lower() + end + -- Check whole word for matches, not just the start + return text:find(searchString, 1, true) ~= nil +end + -- Searches `wordlist` for the closest matching `word` based on Levenshtein distance. Returns: key, result -- If the minimum distance is greater than the `threshold`, the original 'word' is returned and key is nil -- https://stackoverflow.com/questions/42681501/how-do-you-make-a-string-dictionary-function-in-lua @@ -70,6 +89,15 @@ function NetworkUtils.getClosestWord(word, wordlist, threshold) end end +--- Alters the string by changing the first character of each word to uppercase +---@param str string? +---@return string? +function NetworkUtils.firstToUpperEachWord(str) + if str == nil or str == "" then return str end + str = string.gsub(" " .. str, "[%s%.%-]%l", string.upper):sub(2) + return str +end + --- Loads the external Json library into NetworkUtils.JsonLibrary ---@param forceLoad? boolean Optional, if true, forces the file to get reloaded even if already loaded function NetworkUtils.setupJsonLibrary(forceLoad) @@ -77,7 +105,7 @@ function NetworkUtils.setupJsonLibrary(forceLoad) if type(NetworkUtils.JsonLibrary) == "table" and not forceLoad then return end - local filepath = Paths.FOLDERS.NETWORK_FOLDER .. "/Json.lua" + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. JSON_FILENAME if FormsUtils.fileExists(filepath) then NetworkUtils.JsonLibrary = dofile(filepath) if type(NetworkUtils.JsonLibrary) ~= "table" then From d7abd86801097628b366e9e983cba1fd12c61014 Mon Sep 17 00:00:00 2001 From: UTDZac Date: Tue, 24 Sep 2024 12:00:30 -0700 Subject: [PATCH 03/26] Add remaining available Stream Connect commands The only important info missing is anything related to trainers being defeated. --- ironmon_tracker/Program.lua | 13 + ironmon_tracker/Tracker.lua | 4 +- ironmon_tracker/constants/ItemData.lua | 32 ++ ironmon_tracker/network/EventData.lua | 446 ++++++++--------------- ironmon_tracker/network/EventHandler.lua | 47 +-- 5 files changed, 219 insertions(+), 323 deletions(-) diff --git a/ironmon_tracker/Program.lua b/ironmon_tracker/Program.lua index c287a9d9..b2733912 100644 --- a/ironmon_tracker/Program.lua +++ b/ironmon_tracker/Program.lua @@ -65,6 +65,7 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, local lockedPokemonCopy = nil local selectedPlayer = self.SELECTED_PLAYERS.PLAYER local healingItems = nil + local ppItems = nil local inTrackedPokemonView = false local doneWithTitleScreen = false local inPastRunView = false @@ -326,6 +327,7 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, local function scanForHealingItems() healingItems = {} + ppItems = {} statusItems = {} local itemStart, berryStart = memoryAddresses.itemStartNoBattle, memoryAddresses.berryBagStart if battleHandler:inBattleAndFetched() then @@ -353,6 +355,9 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, if ItemData.HEALING_ITEMS[id] ~= nil then healingItems[id] = quantity end + if ItemData.PP_ITEMS[id] ~= nil then + ppItems[id] = quantity + end if ItemData.STATUS_ITEMS[id] ~= nil then statusItems[id] = quantity end @@ -730,6 +735,10 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, return healingItems end + function self.getPPItems() + return ppItems + end + function self.getStatusTotals() local total = 0 if statusItems ~= nil then @@ -941,6 +950,10 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, end end + function self.getBadges() + return badges + end + local function updateRestorePoints() self.UI_SCREEN_OBJECTS[self.UI_SCREENS.RESTORE_POINTS_SCREEN].update() if currentScreens[self.UI_SCREENS.RESTORE_POINTS_SCREEN] then diff --git a/ironmon_tracker/Tracker.lua b/ironmon_tracker/Tracker.lua index 0409919a..1ea3aa08 100644 --- a/ironmon_tracker/Tracker.lua +++ b/ironmon_tracker/Tracker.lua @@ -176,8 +176,8 @@ local function Tracker() return currentAreaName end - function self.getEncounterData() - return trackedData.encounterData[currentAreaName] + function self.getEncounterData(areaName) + return trackedData.encounterData[areaName or currentAreaName] end function self.updateCurrentAreaName(newAreaName) diff --git a/ironmon_tracker/constants/ItemData.lua b/ironmon_tracker/constants/ItemData.lua index 768f675c..8d7a38b2 100644 --- a/ironmon_tracker/constants/ItemData.lua +++ b/ironmon_tracker/constants/ItemData.lua @@ -257,6 +257,38 @@ ItemData.HEALING_ITEMS = } ) +ItemData.PP_ID_SORT_ORDER = { 41, 40, 39, 38, 154 } +ItemData.PP_ITEMS = + MiscUtils.readOnly( + { + [38] = { + name = "Ether", + amount = 10, + type = ItemData.HEALING_TYPE.CONSTANT + }, + [39] = { + name = "Max Ether", + amount = 100, + type = ItemData.HEALING_TYPE.PERCENTAGE + }, + [40] = { + name = "Elixir", + amount = 10, + type = ItemData.HEALING_TYPE.CONSTANT + }, + [41] = { + name = "Max Elixir", + amount = 100, + type = ItemData.HEALING_TYPE.PERCENTAGE + }, + [154] = { + name = "Leppa Berry", + amount = 10, + type = ItemData.HEALING_TYPE.CONSTANT + }, + } +) + ItemData.GEN_4_ITEMS = { [1] = { name = "Master Ball", diff --git a/ironmon_tracker/network/EventData.lua b/ironmon_tracker/network/EventData.lua index 0884eec8..e1dd908d 100644 --- a/ironmon_tracker/network/EventData.lua +++ b/ironmon_tracker/network/EventData.lua @@ -319,179 +319,126 @@ end ---@param params string? ---@return string response function EventData.getRoute(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end -- Check for optional parameters local paramsLower = (params or ""):lower() - local option - for key, val in pairs(RouteData.EncounterArea or {}) do - if NetworkUtils.containsText(paramsLower, val) then - paramsLower = Utils.replaceText(paramsLower, val:lower(), "", true) - option = key - break - end - end + -- local routeFilter + -- Types of route encounter data, allowing 'option' param to isolate those results: + -- Walking, Old Rod, Good Rod, Super Rod, Surfing, Rock Smash, Underwater(?) + -- for key, val in pairs({}) do + -- if NetworkUtils.containsText(paramsLower, val) then + -- -- Only check whole words + -- local replaceWholeWords = "%f[%a]" .. val:lower() .. "%f[%A]" + -- paramsLower = (paramsLower:gsub(replaceWholeWords, "")) + -- routeFilter = key + -- break + -- end + -- end -- If option keywords were removed, trim any whitespace - if option then - -- Removes duplicate, consecutive whitespaces, and leading/trailer whitespaces - paramsLower = ((paramsLower:gsub("(%s)%s+", "%1")):gsub("^%s*(.-)%s*$", "%1")) - end + -- if routeFilter then + -- -- Removes duplicate, consecutive whitespaces, and leading/trailer whitespaces + -- paramsLower = ((paramsLower:gsub("(%s)%s+", "%1")):gsub("^%s*(.-)%s*$", "%1")) + -- end local routeId = getRouteIdOrDefault(paramsLower) - local route = RouteData.Info[routeId or -1] + local route = Network.Data.gameInfo.LOCATION_DATA.locations[routeId or -1] if not route then return buildDefaultResponse(params) end local info = {} -- Check for trainers in the route, but only if a specific encounter area wasnt requested - if not option and route.trainers and #route.trainers > 0 then - local defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByLocation(routeId) - table.insert(info, string.format("%s: %s/%s", "Trainers defeated", #defeatedTrainers, totalTrainers)) - end + -- if not routeFilter and route.trainers and #route.trainers > 0 then + -- local defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByLocation(routeId) + -- table.insert(info, string.format("%s: %s/%s", "Trainers defeated", #defeatedTrainers, totalTrainers)) + -- end -- Check for wilds in the route - local encounterArea - if option then - encounterArea = RouteData.EncounterArea[option] or RouteData.EncounterArea.LAND - else - -- Default to the first area type (usually Walking) - encounterArea = RouteData.getNextAvailableEncounterArea(routeId, RouteData.EncounterArea.TRAINER) - end - local wildIds = RouteData.getEncounterAreaPokemon(routeId, encounterArea) - if #wildIds > 0 then - local seenIds = Tracker.getRouteEncounters(routeId, encounterArea or RouteData.EncounterArea.LAND) - local pokemonNames = {} - for _, pokemonId in ipairs(seenIds) do - if PokemonData.isValid(pokemonId) then - table.insert(pokemonNames, PokemonData.Pokemon[pokemonId].name) + local encounterArea = Network.Data.gameInfo.LOCATION_DATA.encounters[route.name or false] + local trackedArea = Network.Data.tracker.getEncounterData(route.name) + -- if routeFilter then + -- encounterArea = RouteData.EncounterArea[routeFilter] or RouteData.EncounterArea.LAND + -- else + -- -- Default to the first area type (usually Walking) + -- encounterArea = RouteData.getNextAvailableEncounterArea(routeId, RouteData.EncounterArea.TRAINER) + -- end + if encounterArea and encounterArea.vanillaData and trackedArea then + local wildMonsAndLevels = {} + for pokemonID, levelList in pairs(trackedArea.encountersSeen or {}) do + if PokemonData.POKEMON[pokemonID + 1] then + local monName = PokemonData.POKEMON[pokemonID + 1].name or "---" + local monWithLevel + if #levelList == 1 then + monWithLevel = string.format("%s (Lv.%s)", monName, levelList[1]) + else + monWithLevel = string.format("%s (Lv.%s-%s)", monName, levelList[1], levelList[#levelList]) + end + table.insert(wildMonsAndLevels, monWithLevel) end end - local wildsText = string.format("%s: %s/%s", "Wild Pok" .. Chars.accentedE .. "mon seen", #seenIds, #wildIds) - if #seenIds > 0 then - wildsText = wildsText .. string.format(" (%s)", table.concat(pokemonNames, ", ")) + local wildsText = string.format( + "Wild Pok" .. Chars.accentedE .. "mon seen: %s/%s", + #wildMonsAndLevels, + encounterArea.totalPokemon + ) + if #wildMonsAndLevels > 0 then + wildsText = wildsText .. string.format(", %s", table.concat(wildMonsAndLevels, ", ")) end table.insert(info, wildsText) end - local prefix - if option then - prefix = string.format("%s: %s %s", route.name, NetworkUtils.firstToUpperEachWord(encounterArea), OUTPUT_CHAR) - else - prefix = string.format("%s %s", route.name, OUTPUT_CHAR) - end + local prefix = string.format("%s %s", route.name, OUTPUT_CHAR) + -- if routeFilter then + -- prefix = string.format("%s: %s %s", route.name, NetworkUtils.firstToUpperEachWord(encounterArea), OUTPUT_CHAR) + -- else + -- prefix = string.format("%s %s", route.name, OUTPUT_CHAR) + -- end return buildResponse(prefix, info) end +--- Function not yet implemented ---@param params string? ---@return string response function EventData.getDungeon(params) -- TODO: Implement this function if true then return buildDefaultResponse(params) end local routeId = getRouteIdOrDefault(params) - local route = RouteData.Info[routeId or -1] + local route = Network.Data.gameInfo.LOCATION_DATA.locations[routeId or -1] if not route then return buildDefaultResponse(params) end local info = {} -- Check for trainers in the area/route - local defeatedTrainers, totalTrainers - if route.area ~= nil then - defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByCombinedArea(route.area) - elseif route.trainers and #route.trainers > 0 then - defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByLocation(routeId) - end - if defeatedTrainers and totalTrainers then - local trainersText = string.format("%s: %s/%s", "Trainers defeated", #defeatedTrainers, totalTrainers) - table.insert(info, trainersText) - end + -- local defeatedTrainers, totalTrainers + -- if route.area ~= nil then + -- defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByCombinedArea(route.area) + -- elseif route.trainers and #route.trainers > 0 then + -- defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByLocation(routeId) + -- end + -- if defeatedTrainers and totalTrainers then + -- local trainersText = string.format("%s: %s/%s", "Trainers defeated", #defeatedTrainers, totalTrainers) + -- table.insert(info, trainersText) + -- end local routeName = route.area and route.area.name or route.name local prefix = string.format("%s %s", routeName, OUTPUT_CHAR) return buildResponse(prefix, info) end +--- Function not yet implemented ---@param params string? ---@return string response function EventData.getUnfoughtTrainers(params) -- TODO: Implement this function if true then return buildDefaultResponse(params) end local allowPartialDungeons = NetworkUtils.containsText(params, "dungeon") - local includeSevii - if GameSettings.game == 3 then - includeSevii = NetworkUtils.containsText(params, "sevii") - else - includeSevii = true -- to allow routes above the sevii route id for RSE - end - local MAX_AREAS_TO_CHECK = 7 - local saveBlock1Addr = Utils.getSaveBlock1Addr() - local trainersToExclude = TrainerData.getExcludedTrainers() - local currentRouteId = TrackerAPI.getMapId() - -- For a given unfought trainer, this function returns unfought trainer counts for its route/area - local checkedIds = {} + -- Not implemented local function getUnfinishedRouteInfo(trainerId) - local trainer = TrainerData.Trainers[trainerId] or {} - local routeId = trainer.routeId or -1 - local route = RouteData.Info[routeId] or {} - - -- If sevii is excluded (default option), skip those routes and non-existent routes - if routeId == -1 or (routeId >= 230 and not includeSevii) then - return nil - end - -- Skip certain trainers, only checking unfought trainers - if checkedIds[trainerId] or trainersToExclude[trainerId] or not TrainerData.shouldUseTrainer(trainerId) then - return nil - end - if Program.hasDefeatedTrainer(trainerId, saveBlock1Addr) then - return nil - end - - -- Check area for defeated trainers and mark each trainer as checked - local defeatedTrainers = {} - local totalTrainers = 0 - local ifDungeonAndIncluded = true -- true for non-dungeons, otherwise gets excluded if partially completed - if route.area and #route.area > 0 then - defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByCombinedArea(route.area, saveBlock1Addr) - -- Don't include dungeons that are partially completed unless the player is currently there - if route.area.dungeon and #defeatedTrainers > 0 then - local isThere = false - for _, id in ipairs(route.area or {}) do - if id == currentRouteId then - isThere = true - break - end - end - ifDungeonAndIncluded = isThere or allowPartialDungeons - end - for _, areaRouteId in ipairs(route.area) do - local areaRoute = RouteData.Info[areaRouteId] or {} - for _, id in ipairs(areaRoute.trainers or {}) do - checkedIds[id] = true - end - end - elseif route.trainers and #route.trainers > 0 then - defeatedTrainers, totalTrainers = Program.getDefeatedTrainersByLocation(routeId, saveBlock1Addr) - -- Don't include dungeons that are partially completed unless the player is currently there - if route.dungeon and #defeatedTrainers > 0 and currentRouteId ~= routeId then - ifDungeonAndIncluded = allowPartialDungeons - end - for _, id in ipairs(route.trainers) do - checkedIds[id] = true - end - else - return nil - end - - -- Add to info if route/area has unfought trainers (not all defeated) - if #defeatedTrainers < totalTrainers and ifDungeonAndIncluded then - local routeName = route.area and route.area.name or route.name - return string.format("%s (%s/%s)", routeName, #defeatedTrainers, totalTrainers) - end + return nil end local info = {} - for _, trainerId in ipairs(TrainerData.OrderedIds or {}) do + for _, trainerId in ipairs({}) do local routeText = getUnfinishedRouteInfo(trainerId) if routeText ~= nil then table.insert(info, routeText) @@ -503,44 +450,36 @@ function EventData.getUnfoughtTrainers(params) end if #info == 0 then local reminderText = "" - if not allowPartialDungeons or not includeSevii then - reminderText = ' (Use param "dungeon" and/or "sevii" to check partially completed dungeons or Sevii Islands.)' + if not allowPartialDungeons then + reminderText = ' (Use param "dungeon" to check partially completed dungeons.)' end - table.insert(info, string.format("%s %s", "All available trainers have been defeated!", reminderText)) + table.insert(info, "All available trainers have been defeated!%s" .. reminderText) end - local prefix = string.format("%s %s", "Unfought Trainers", OUTPUT_CHAR) + local prefix = string.format("Unfought Trainers %s", OUTPUT_CHAR) return buildResponse(prefix, info, ", ") end ---@param params string? ---@return string response function EventData.getPivots(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end local info = {} - local mapIds - if GameSettings.game == 3 then -- FRLG - mapIds = { 89, 90, 110, 117 } -- Route 1, 2, 22, Viridian Forest - else -- RSE - local offset = GameSettings.versioncolor == "Emerald" and 0 or 1 -- offset all "mapId > 107" by +1 - mapIds = { 17, 18, 19, 20, 32, 135 + offset } -- Route 101, 102, 103, 104, 116, Petalburg Forest - end - for _, mapId in ipairs(mapIds) do + for _, routeName in ipairs(Network.Data.gameInfo.LOCATION_DATA.encounterAreaOrder or {}) do -- Check for tracked wild encounters in the route - local seenIds = Tracker.getRouteEncounters(mapId, RouteData.EncounterArea.LAND) - local pokemonNames = {} - for _, pokemonId in ipairs(seenIds) do - if PokemonData.isValid(pokemonId) then - table.insert(pokemonNames, PokemonData.Pokemon[pokemonId].name) + local trackedArea = Network.Data.tracker.getEncounterData(routeName) + if trackedArea then + local pokemonNames = {} + for pokemonID, _ in pairs(trackedArea.encountersSeen or {}) do + if PokemonData.POKEMON[pokemonID + 1] then + table.insert(pokemonNames, PokemonData.POKEMON[pokemonID + 1].name or "---") + end + end + if #pokemonNames > 0 then + table.insert(info, string.format("%s: %s", routeName, table.concat(pokemonNames, ", "))) end - end - if #seenIds > 0 then - local route = RouteData.Info[mapId or -1] or {} - table.insert(info, string.format("%s: %s", route.name or "Unknown Route", table.concat(pokemonNames, ", "))) end end - local prefix = string.format("%s %s", "Pivots", OUTPUT_CHAR) + local prefix = string.format("Pivots %s", OUTPUT_CHAR) return buildResponse(prefix, info) end @@ -737,10 +676,6 @@ end ---@param params string? ---@return string response function EventData.getHeals(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - local info = {} - local displayHP, displayStatus, displayPP, displayBerries if (params or "") ~= "" then displayHP = NetworkUtils.containsText(params, "hp") @@ -754,20 +689,22 @@ function EventData.getHeals(params) displayPP = true displayStatus = true end + + local info = {} local function sortFunc(a,b) return a.value > b.value or (a.value == b.value and a.id < b.id) end local function getSortableItem(id, quantity) - if not MiscData.Items[id or 0] or (quantity or 0) <= 0 then return nil end - local item = MiscData.HealingItems[id] or MiscData.PPItems[id] or MiscData.StatusItems[id] or {} - local text = MiscData.Items[item.id] + if not ItemData.ITEMS[id or -1] or (quantity or 0) <= 0 then return nil end + local item = ItemData.HEALING_ITEMS[id] or ItemData.PP_ITEMS[id] or ItemData.STATUS_ITEMS[id] or {} + local text = ItemData.ITEMS[id].name if quantity > 1 then text = string.format("%s (%s)", text, quantity) end local value = item.amount or 0 - if item.type == MiscData.HealingType.Percentage then + if item.type == ItemData.HEALING_TYPE.PERCENTAGE then value = value + 1000 - elseif item.type == MiscData.StatusType.All then -- The really good status items + elseif item.type == MiscData.STATUS_TYPE.ALL then -- The really good status items value = value + 2 - elseif MiscData.StatusItems[id] then -- All other status items + elseif ItemData.STATUS_ITEMS[id] then -- All other status items value = value + 1 end return { id = id, text = text, value = value } @@ -779,29 +716,32 @@ function EventData.getHeals(params) table.insert(info, string.format("[%s] %s", label, table.concat(t, ", "))) end local healingItems, ppItems, statusItems, berryItems = {}, {}, {}, {} - for id, quantity in pairs(Program.GameData.Items.HPHeals) do + for id, quantity in pairs(Network.Data.program.getHealingItems() or {}) do local itemInfo = getSortableItem(id, quantity) if itemInfo then table.insert(healingItems, itemInfo) - if displayBerries and MiscData.HealingItems[id].pocket == MiscData.BagPocket.Berries then + local itemData = ItemData.HEALING_ITEMS[id] + if displayBerries and itemData and NetworkUtils.containsText(itemData.name, "Berry") then table.insert(berryItems, itemInfo) end end end - for id, quantity in pairs(Program.GameData.Items.PPHeals) do + for id, quantity in pairs(Network.Data.program.getPPItems() or {}) do local itemInfo = getSortableItem(id, quantity) if itemInfo then table.insert(ppItems, itemInfo) - if displayBerries and MiscData.PPItems[id].pocket == MiscData.BagPocket.Berries then + local itemData = ItemData.PP_ITEMS[id] + if displayBerries and itemData and NetworkUtils.containsText(itemData.name, "Berry") then table.insert(berryItems, itemInfo) end end end - for id, quantity in pairs(Program.GameData.Items.StatusHeals) do + for id, quantity in pairs(Network.Data.program.getStatusItems() or {}) do local itemInfo = getSortableItem(id, quantity) if itemInfo then table.insert(statusItems, itemInfo) - if displayBerries and MiscData.StatusItems[id].pocket == MiscData.BagPocket.Berries then + local itemData = ItemData.STATUS_ITEMS[id] + if displayBerries and itemData and NetworkUtils.containsText(itemData.name, "Berry") then table.insert(berryItems, itemInfo) end end @@ -822,14 +762,14 @@ function EventData.getHeals(params) return buildResponse(prefix, info) end +--- Function not yet implemented ---@param params string? ---@return string response function EventData.getTMsHMs(params) -- TODO: Implement this function if true then return buildDefaultResponse(params) end local info = {} - local prefix = string.format("%s %s", "TMs", OUTPUT_CHAR) - local canSeeTM = Options["Open Book Play Mode"] + local prefix = string.format("TMs %s", OUTPUT_CHAR) local singleTmLookup local displayGym, displayNonGym, displayHM @@ -843,85 +783,8 @@ function EventData.getTMsHMs(params) displayGym = true displayNonGym = true end - local tms, hms = Program.getTMsHMsBagItems() - if singleTmLookup then - if not canSeeTM then - for _, item in ipairs(tms or {}) do - local tmInBag = item.id - 289 + 1 -- 289 is the item ID of the first TM - if singleTmLookup == tmInBag then - canSeeTM = true - break - end - end - end - local moveId = Program.getMoveIdFromTMHMNumber(singleTmLookup) - local textToAdd - if canSeeTM and MoveData.isValid(moveId) then - textToAdd = MoveData.Moves[moveId].name - else - textToAdd = string.format("%s %s", "---", "(not acquired yet)") - end - return buildResponse(prefix, string.format("%s %02d: %s", "TM", singleTmLookup, textToAdd)) - end - if displayGym or displayNonGym then - local isGymTm = {} - for _, gymInfo in ipairs(TrainerData.GymTMs) do - if gymInfo.number then - isGymTm[gymInfo.number] = true - end - end - local tmsObtained = {} - local otherTMs, gymTMs = {}, {} - for _, item in ipairs(tms or {}) do - local tmNumber = item.id - 289 + 1 -- 289 is the item ID of the first TM - local moveId = Program.getMoveIdFromTMHMNumber(tmNumber) - if MoveData.isValid(moveId) then - tmsObtained[tmNumber] = string.format("#%02d %s", tmNumber, MoveData.Moves[moveId].name) - if not isGymTm[tmNumber] then - table.insert(otherTMs, tmsObtained[tmNumber]) - end - end - end - if displayGym then - -- Get them sorted in Gym ordered - for _, gymInfo in ipairs(TrainerData.GymTMs) do - if tmsObtained[gymInfo.number] then - table.insert(gymTMs, tmsObtained[gymInfo.number]) - elseif canSeeTM then - local moveId = Program.getMoveIdFromTMHMNumber(gymInfo.number) - table.insert(gymTMs, string.format("#%02d %s", gymInfo.number, MoveData.Moves[moveId].name)) - end - end - local textToAdd = #gymTMs > 0 and table.concat(gymTMs, ", ") or "None" - table.insert(info, string.format("[%s] %s", "Gym", textToAdd)) - end - if displayNonGym then - local textToAdd - if #otherTMs > 0 then - local otherMax = math.min(#otherTMs, MAX_ITEMS - #gymTMs) - textToAdd = table.concat(otherTMs, ", ", 1, otherMax) - if #otherTMs > otherMax then - textToAdd = string.format("%s, (+%s more TMs)", textToAdd, #otherTMs - otherMax) - end - else - textToAdd = "None" - end - table.insert(info, string.format("[%s] %s", "Other", textToAdd)) - end - end - if displayHM then - local hmTexts = {} - for _, item in ipairs(hms or {}) do - local hmNumber = item.id - 339 + 1 -- 339 is the item ID of the first HM - local moveId = Program.getMoveIdFromTMHMNumber(hmNumber, true) - if MoveData.isValid(moveId) then - local hmText = string.format("%s (HM%02d)", MoveData.Moves[moveId].name, hmNumber) - table.insert(hmTexts, hmText) - end - end - local textToAdd = #hmTexts > 0 and table.concat(hmTexts, ", ") or "None" - table.insert(info, string.format("%s: %s", "HMs", textToAdd)) - end + -- local tms, hms = Network.Data.program.getTMsHMsBagItems() + return buildResponse(prefix, info) end @@ -1173,21 +1036,18 @@ function EventData.getTheme(params) return buildResponse(prefix, info) end +--- Function not yet implemented ---@param params string? ---@return string response function EventData.getGameStats(params) - -- TODO: Implement this function + -- NOTE: This datapoint is not implemented if true then return buildDefaultResponse(params) end local info = {} - for _, statPair in ipairs(StatsScreen.StatTables or {}) do - if type(statPair.getText) == "function" and type(statPair.getValue) == "function" then - local statValue = statPair.getValue() or 0 - if type(statValue) == "number" then - statValue = Utils.formatNumberWithCommas(statValue) - end - table.insert(info, string.format("%s: %s", statPair:getText(), statValue)) - end - end + + -- Can populate list with some fun statistics. GBA Tracker uses: + -- PlayTime, TotalAttempts, PCHealCount, NumTrainerBattles, NumWildEncounters + -- NumPokemonCaught, NumShopPurchases, NumGameSaves, TotalSteps, NumStrugglesUsed + local prefix = string.format("Game Stats %s", OUTPUT_CHAR) return buildResponse(prefix, info) end @@ -1195,53 +1055,57 @@ end ---@param params string? ---@return string response function EventData.getProgress(params) - -- TODO: Implement this function - if true then return buildDefaultResponse(params) end - local includeSevii = NetworkUtils.containsText(params, "sevii") local info = {} - local badgesObtained, maxBadges = 0, 8 - for i = 1, maxBadges, 1 do - local badgeButton = TrackerScreen.Buttons["badge" .. i] or {} - if (badgeButton.badgeState or 0) ~= 0 then - badgesObtained = badgesObtained + 1 - end - end - table.insert(info, string.format("%s: %s/%s", "Gym badges", badgesObtained, maxBadges)) - local saveBlock1Addr = Utils.getSaveBlock1Addr() - local totalDefeated, totalTrainers = 0, 0 - for mapId, route in pairs(RouteData.Info) do - -- Don't check sevii islands (id = 230+) by default - if mapId < 230 or includeSevii then - if route.trainers and #route.trainers > 0 then - local defeatedTrainers, totalInRoute = Program.getDefeatedTrainersByLocation(mapId, saveBlock1Addr) - totalDefeated = totalDefeated + #defeatedTrainers - totalTrainers = totalTrainers + totalInRoute + + -- Gym Badges + local badges = Network.Data.program.getBadges() + local setsToCheck = { badges.firstSet } + if Network.Data.gameInfo.NAME == "Pokemon HeartGold" or Network.Data.gameInfo.NAME == "Pokemon SoulSilver" then + table.insert(setsToCheck, badges.secondSet) + end + local badgesObtained, maxBadges = 0, 0 + for _, badgeSet in ipairs(setsToCheck) do + for _, val in ipairs(badgeSet) do + if val > 0 then + badgesObtained = badgesObtained + 1 end + maxBadges = maxBadges + 1 end end - table.insert(info, string.format("%s%s: %s/%s (%0.1f%%)", - "Trainers defeated", - includeSevii and ", including Sevii" or "", - totalDefeated, - totalTrainers, - totalDefeated / totalTrainers * 100)) + table.insert(info, string.format("Gym badges: %s/%s", badgesObtained, maxBadges)) + + -- Trainers Defeated + -- local totalDefeated, totalTrainers = 0, 0 + -- for mapId, route in pairs(RouteData.Info) do + -- if route.trainers and #route.trainers > 0 then + -- local defeatedTrainers, totalInRoute = Program.getDefeatedTrainersByLocation(mapId, saveBlock1Addr) + -- totalDefeated = totalDefeated + #defeatedTrainers + -- totalTrainers = totalTrainers + totalInRoute + -- end + -- end + -- table.insert(info, string.format("Trainers defeated: %s/%s (%0.1f%%)", + -- totalDefeated, + -- totalTrainers, + -- totalDefeated / totalTrainers * 100)) + + -- Pokemon seen fully evolved + local trackedIDs = Network.Data.tracker.getTrackedIDs() local fullyEvolvedSeen, fullyEvolvedTotal = 0, 0 - -- local legendarySeen, legendaryTotal = 0, 0 - for pokemonID, pokemon in ipairs(PokemonData.Pokemon) do - if pokemon.evolution == PokemonData.Evolutions.NONE then + -- local legendarySeen, legendaryTotal = 0, 0 --, Legendary: %s/%s (%0.1f%%)", + for pokemonID, pokemon in ipairs(PokemonData.POKEMON) do + if pokemon.evolution == PokemonData.EVOLUTION_TYPES.NONE then fullyEvolvedTotal = fullyEvolvedTotal + 1 - local trackedPokemon = Tracker.Data.allPokemon[pokemonID] or {} - if (trackedPokemon.eT or 0) > 0 then + if MiscUtils.tableContains(trackedIDs, pokemonID) then -- could be inefficient fullyEvolvedSeen = fullyEvolvedSeen + 1 end end end - table.insert(info, string.format("%s: %s/%s (%0.1f%%)", --, Legendary: %s/%s (%0.1f%%)", - "Pok" .. Chars.accentedE .. "mon seen fully evolved", + table.insert(info, string.format("Pok" .. Chars.accentedE .. "mon seen fully evolved: %s/%s (%0.1f%%)", fullyEvolvedSeen, fullyEvolvedTotal, fullyEvolvedSeen / fullyEvolvedTotal * 100)) - local prefix = string.format("%s %s", "Progress", OUTPUT_CHAR) + + local prefix = string.format("Progress %s", OUTPUT_CHAR) return buildResponse(prefix, info) end @@ -1268,7 +1132,7 @@ end ---@param params string? ---@return string response function EventData.getBallQueue(params) - local prefix = string.format("%s %s", "BallQueue", OUTPUT_CHAR) + local prefix = string.format("BallQueue %s", OUTPUT_CHAR) local info = {} @@ -1279,11 +1143,11 @@ function EventData.getBallQueue(params) if queueSize == 0 then return buildResponse(prefix, "The pick ball queue is empty.") end - table.insert(info, string.format("%s: %s", "Size", queueSize)) + table.insert(info, string.format("Size: %s", queueSize)) local request = EventHandler.Queues.BallRedeems.ActiveRequest if request and request.Username then - table.insert(info, string.format("%s: %s - %s", "Current pick", request.Username, request.SanitizedInput or "N/A")) + table.insert(info, string.format("Current pick: %s - %s", request.Username, request.SanitizedInput or "N/A")) end return buildResponse(prefix, info) diff --git a/ironmon_tracker/network/EventHandler.lua b/ironmon_tracker/network/EventHandler.lua index cd30a735..3da1234b 100644 --- a/ironmon_tracker/network/EventHandler.lua +++ b/ironmon_tracker/network/EventHandler.lua @@ -333,11 +333,14 @@ function EventHandler.addDefaultEvents() -- Make a copy of each default event, such that they can still be referenced without being changed. for key, event in pairs(EventHandler.DefaultEvents) do - event.IsEnabled = true - event.Key = key - local eventCopy = MiscUtils.deepCopy(event) - if eventCopy then - EventHandler.addNewEvent(eventCopy) + -- TODO: Reward events can't be used yet, as they require implementation and user configuration + if event.Type ~= EventHandler.EventTypes.Reward then + event.IsEnabled = true + event.Key = key + local eventCopy = MiscUtils.deepCopy(event) + if eventCopy then + EventHandler.addNewEvent(eventCopy) + end end end end @@ -545,13 +548,13 @@ EventHandler.DefaultEvents = { Help = "[hp pp status berries] > Displays all healing items in the bag, or only those for a specified [category].", Fulfill = function(self, request) return EventData.getHeals(request.SanitizedInput) end, }, - CMD_TMs = { - Type = EventHandler.EventTypes.Command, - Command = "!tms", - Name = "TM Lookup", - Help = "[gym hm #] > Displays all TMs in the bag, or only those for a specified [category] or TM #.", - Fulfill = function(self, request) return EventData.getTMsHMs(request.SanitizedInput) end, - }, + -- CMD_TMs = { + -- Type = EventHandler.EventTypes.Command, + -- Command = "!tms", + -- Name = "TM Lookup", + -- Help = "[gym hm #] > Displays all TMs in the bag, or only those for a specified [category] or TM #.", + -- Fulfill = function(self, request) return EventData.getTMsHMs(request.SanitizedInput) end, + -- }, CMD_Search = { Type = EventHandler.EventTypes.Command, Command = "!search", @@ -601,6 +604,7 @@ EventHandler.DefaultEvents = { Help = "> If the log has been opened, displays shareable randomizer settings from the log for current game.", Fulfill = function(self, request) return EventData.getLog(request.SanitizedInput) end, }, + -- NOTE: Enable this command only if rewards and pick ball queues are enabled -- CMD_BallQueue = { -- Type = EventHandler.EventTypes.Command, -- Command = "!ballqueue", @@ -790,24 +794,7 @@ EventHandler.DefaultEvents = { return response end -- Not implemented - return "" - end, - }, - CR_ChangeLanguage = { - Type = EventHandler.EventTypes.Reward, - Name = "Change Tracker Language", - RewardId = "", - Options = { "O_SendMessage", "O_AutoComplete", }, - O_SendMessage = true, - O_AutoComplete = true, - -- O_Duration = tostring(10 * 60), -- # of seconds - Fulfill = function(self, request) - local response = { AdditionalInfo = { AutoComplete = false } } - if (request.SanitizedInput or "") == "" then - response.Message = string.format("> Unable to change Tracker language, please enter a valid language name.") - return response - end - -- Not implemented + -- ThemeFactory.readThemeString(request.SanitizedInput, true) return "" end, }, From c1accabac78bbb242312faf287670d29da7ed9be Mon Sep 17 00:00:00 2001 From: UTDZac Date: Tue, 24 Sep 2024 16:35:09 -0700 Subject: [PATCH 04/26] Additional code cleanup and supporting funcs --- ironmon_tracker/Program.lua | 8 +- ironmon_tracker/network/EventHandler.lua | 8 +- ironmon_tracker/network/Network.lua | 224 +++++++++++++++--- ironmon_tracker/network/RequestHandler.lua | 6 +- .../network/StreamerbotCodeImport.txt | 2 +- 5 files changed, 207 insertions(+), 41 deletions(-) diff --git a/ironmon_tracker/Program.lua b/ironmon_tracker/Program.lua index b2733912..8042540f 100644 --- a/ironmon_tracker/Program.lua +++ b/ironmon_tracker/Program.lua @@ -982,6 +982,12 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, AnimatedSpriteManager.advanceFrame() end + local function delayedNetworkStartup() + Network.delayedStartupActions() + -- Remove the delayed start frame counter; only need to run startup once + frameCounters.networkStartup = nil + end + frameCounters = { restorePointUpdate = FrameCounter(30, updateRestorePoints), memoryReading = FrameCounter(30, readMemory, nil, true), @@ -995,7 +1001,7 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, true ), animatedSprites = FrameCounter(8, advanceAnimationFrame, nil, true), - networkStartup = FrameCounter(30, Network.startup, nil, true), + networkStartup = FrameCounter(30, delayedNetworkStartup, nil, true), networkUpdate = FrameCounter(10, Network.update, nil, true), } diff --git a/ironmon_tracker/network/EventHandler.lua b/ironmon_tracker/network/EventHandler.lua index 3da1234b..c808598d 100644 --- a/ironmon_tracker/network/EventHandler.lua +++ b/ironmon_tracker/network/EventHandler.lua @@ -407,7 +407,9 @@ EventHandler.CoreEvents = { Network.checkVersion(request.Args and request.Args.Version or "") RequestHandler.removedExcludedRequests() -- NOTE: If any screen is displaying connection status info, add code here to refresh it - print("Stream Connect: Connected to Streamer.bot") + if Network.printSuccessfulConnectMsg then + print("Stream Connect: Connected to Streamer.bot") + end return RequestHandler.REQUEST_COMPLETE end, }, @@ -604,7 +606,7 @@ EventHandler.DefaultEvents = { Help = "> If the log has been opened, displays shareable randomizer settings from the log for current game.", Fulfill = function(self, request) return EventData.getLog(request.SanitizedInput) end, }, - -- NOTE: Enable this command only if rewards and pick ball queues are enabled + -- NOTE: Enable this command only if rewards get enabled and pick ball reward(s) gets implemented -- CMD_BallQueue = { -- Type = EventHandler.EventTypes.Command, -- Command = "!ballqueue", @@ -629,6 +631,8 @@ EventHandler.DefaultEvents = { Fulfill = function(self, request) return EventData.getHelp(request.SanitizedInput) end, }, + -- NOTE: These are currently not used, as it requires UI for user configuration. + -- To add these back in, edit the codeline that exempts them from: EventHandler.addDefaultEvents() -- CR_: Channel Rewards (Point Redeems) CR_PickBallOnce = { Type = EventHandler.EventTypes.Reward, diff --git a/ironmon_tracker/network/Network.lua b/ironmon_tracker/network/Network.lua index c8702408..3014c34c 100644 --- a/ironmon_tracker/network/Network.lua +++ b/ironmon_tracker/network/Network.lua @@ -1,6 +1,7 @@ Network = { CurrentConnection = {}, lastUpdateTime = 0, + printSuccessfulConnectMsg = true, STREAMERBOT_VERSION = "1.0.1", -- Known streamerbot version. Update this value to inform user to update streamerbot code TEXT_UPDATE_FREQUENCY = 2, -- # of seconds SOCKET_UPDATE_FREQUENCY = 2, -- # of seconds @@ -32,6 +33,7 @@ Network.ConnectionState = { Established = 9, -- Both the server (Tracker) and client are connected; communication is open } +-- Options related to Network (most unused); gets saved in NetworkSettings.ini Network.Options = { ["AutoConnectStartup"] = true, ["ConnectionType"] = Network.ConnectionTypes.Text, @@ -104,15 +106,11 @@ function Network.initialize() end end -function Network.startup() +function Network.delayedStartupActions() -- DEBUG EventHandler.onStartup() - -- Remove the delayed start frame counter; should not repeat - if Network.Data.program and Network.Data.program.frameCounters then - Network.Data.program.frameCounters.networkStartup = nil - end end ---- Allow Network access to various data objects used throughout the software +--- Allow Network access references to various data objects used throughout the software function Network.linkData(program, tracker, battleHandler, randomizerLogParser) if program == nil then return @@ -128,6 +126,18 @@ function Network.linkData(program, tracker, battleHandler, randomizerLogParser) } end +---Returns a message regarding the status of the connection +---@return string +function Network.getConnectionStatusMsg() + if Network.CurrentConnection.State == Network.ConnectionState.Established then + return "Online: Connection established." + elseif Network.CurrentConnection.State == Network.ConnectionState.Listen then + return "Online: Waiting for connection..." + else + return "Offline." + end +end + ---Checks current version of the Tracker's Network code against the Streamerbot code version ---@param externalVersion string function Network.checkVersion(externalVersion) @@ -364,34 +374,6 @@ function Network.updateByHttp() end end -function Network.getStreamerbotCode() - local filepath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. Network.FILE_IMPORT_CODE - local lines = MiscUtils.readLinesFromFile(filepath) or {} - return lines[1] or "" -end - -function Network.openUpdateRequiredPrompt() - local form = FormsUtils.popupDialog("Streamerbot Update Required", 350, 150, FormsUtils.POPUP_DIALOG_TYPES.WARNING, true) - -- TODO: Fix - -- local x, y, lineHeight = 20, 20, 20 - -- form:createLabel(Resources.StreamConnect.PromptUpdateDesc1, x, y) - -- y = y + lineHeight - -- form:createLabel(Resources.StreamConnect.PromptUpdateDesc2, x, y) - -- y = y + lineHeight - -- -- Bottom row buttons - -- y = y + 10 - -- form:createButton(Resources.StreamConnect.PromptNetworkShowMe, 40, y, function() - -- form:destroy() - -- StreamConnectOverlay.openGetCodeWindow() - -- end) - -- form:createButton(Resources.StreamConnect.PromptNetworkTurnOff, 150, y, function() - -- Network.Options["AutoConnectStartup"] = false - -- Main.SaveSettings(true) - -- Network.closeConnections() - -- form:destroy() - -- end) -end - function Network.loadSettings() Network.MetaSettings = {} local filepath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. Network.FILE_SETTINGS @@ -428,6 +410,180 @@ function Network.saveSettings() Network.iniParser.save(filepath, settings) end +function Network.getStreamerbotCode() + local filepath = Paths.FOLDERS.NETWORK_FOLDER .. Paths.SLASH .. Network.FILE_IMPORT_CODE + return MiscUtils.readStringFromFile(filepath) or "" +end + +---Required update check if an update needs total change Streamerbot Code (tracker can only change its own tracker code) +---This requires the user to re-import the StreamerbotCodeImport.txt (which the tracker dev needs to regenerate) +function Network.openUpdateRequiredPrompt() + local form = forms.newform(350, 150, "Streamerbot Update Required", function() + client.unpause() + end) + local x, y, lineHeight = 20, 20, 20 + local lb1 = forms.label(form, "Streamerbot Tracker Integration code requires an update.", x, y) + y = y + lineHeight + local lb2 = forms.label(form, "You must re-import the code to continue using Stream Connect.", x, y) + y = y + lineHeight + -- Bottom row buttons + y = y + 10 + local btn1 = forms.button(form, "Show Me", function() + forms.destroy(form) + client.unpause() + Network.openGetCodeWindow() + end, 40, y) + local btn2 = forms.button(form, "Turn Off Stream Connect", function() + Network.Options["AutoConnectStartup"] = false + Network.saveSettings() + Network.closeConnections() + forms.destroy(form) + client.unpause() + end, 150, y) + + -- Autosize form control elements + forms.setproperty(lb1, "AutoSize", true) + forms.setproperty(lb2, "AutoSize", true) + forms.setproperty(btn1, "AutoSize", true) + forms.setproperty(btn2, "AutoSize", true) +end + +---Displays the full import code required to add actions/command triggers to Streamerbot. +---If the external Streamerbot code ever changes, this import code *must* be regenerated via export +function Network.openGetCodeWindow() + local form = forms.newform(800, 600, "Import to Streamerbot", function() + client.unpause() + end) + local x, y, lineHeight = 20, 15, 20 + local lb1 = forms.label(form, '1. On Streamerbot, click the IMPORT button at the top.', x, y) + y = y + lineHeight + local lb2 = forms.label(form, '2. Copy/paste the below code into the top textbox. Click "Import" then "OK".', x, y) + y = y + lineHeight + local lb3 = forms.label(form, '3. Restart Streamerbot (this is required).', x, y) + y = y + lineHeight + + local codeText = Network.getStreamerbotCode() + local txtbox1 = forms.textbox(form, codeText, 763, 442, "", x - 1, y, true, true, "Vertical") + + local lb4 = forms.label(form, string.format("Streamerbot Code Version: %s", Network.STREAMERBOT_VERSION), x, 530) + local btn1 = forms.button(form, "Close", function() + forms.destroy(form) + client.unpause() + end, 350, 530) + + -- Autosize form control elements + forms.setproperty(lb1, "AutoSize", true) + forms.setproperty(lb2, "AutoSize", true) + forms.setproperty(lb3, "AutoSize", true) + forms.setproperty(lb4, "AutoSize", true) + forms.setproperty(btn1, "AutoSize", true) +end + +---Opens an dialog prompt popup to configure Role Permissions used for commands. Several checkboxes +function Network.openCommandRolePermissionsPrompt() + local form = forms.newform(320, 255, "Edit Command Roles", function() + client.unpause() + end) + + local x, y = 20, 15 + local lineHeight = 21 + local commandLabel = string.format("Select user roles that can use Tracker chat commands:") + local lb1 = forms.label(form, commandLabel, x - 1, y - 1) + forms.setproperty(lb1, "AutoSize", true) + y = y + lineHeight + + -- Current role options, from the user settings + local currentRoles = {} + for _, roleKey in pairs(MiscUtils.split(Network.Options["CommandRoles"], ",", true) or {}) do + currentRoles[roleKey] = true + end + -- All available role options, in a predefined order + local orderedRoles = { "Broadcaster", "Moderator", "Vip", "Subscriber", --[["Custom",]] "Everyone" } + local roleCheckboxes = {} + local customRoleTextbox + + -- Enable or Disable all non-Everyone roles based on the state of Everyone role being allowed + local function enableDisableAll() + local allowEveryone = forms.ischecked(roleCheckboxes["Everyone"]) + for _, roleKey in ipairs(orderedRoles) do + if roleKey ~= "Everyone" and roleKey ~= "Broadcaster" then + forms.setproperty(roleCheckboxes[roleKey], "Enabled", not allowEveryone) + end + end + if customRoleTextbox then + forms.setproperty(customRoleTextbox, "Enabled", not allowEveryone) + end + end + + for i, roleKey in ipairs(orderedRoles) do + local roleLabel = roleKey + if roleKey == "Custom" then + roleLabel = "Custom Role:" + customRoleTextbox = forms.textbox(form, Network.Options["CustomCommandRole"], 120, 19, "", x + 143, y + lineHeight * (i - 1)) + end + local clickFunc = (roleKey == "Everyone" and enableDisableAll) or nil + roleCheckboxes[roleKey] = forms.checkbox(form, roleLabel, x, y + lineHeight * (i - 1)) + forms.setproperty(roleCheckboxes[roleKey], "AutoSize", true) + if clickFunc then + forms.addclick(roleCheckboxes[roleKey], clickFunc) + end + local roleAllowed = currentRoles["Everyone"] ~= nil or currentRoles[roleKey] ~= nil + forms.setproperty(roleCheckboxes[roleKey], "Checked", roleAllowed) + end + forms.setproperty(roleCheckboxes["Broadcaster"], "Checked", true) + forms.setproperty(roleCheckboxes["Broadcaster"], "Enabled", false) + + enableDisableAll() + + local buttonRowY = y + lineHeight * #orderedRoles + 15 + local btn1 = forms.button(form, "Save", function() + if forms.ischecked(roleCheckboxes["Everyone"]) then + Network.Options["CommandRoles"] = EventHandler.CommandRoles.Everyone + else + if forms.ischecked(roleCheckboxes["Custom"]) and customRoleTextbox then + Network.Options["CustomCommandRole"] = forms.gettext(customRoleTextbox) or "" + else + Network.Options["CustomCommandRole"] = "" + end + local allowedRoles = {} + for _, roleKey in ipairs(orderedRoles) do + if forms.ischecked(roleCheckboxes[roleKey]) then + if roleKey == "Custom" then + if (Network.Options["CustomCommandRole"] or "") ~= "" then + table.insert(allowedRoles, Network.Options["CustomCommandRole"]) + end + else + table.insert(allowedRoles, EventHandler.CommandRoles[roleKey]) + end + end + end + Network.Options["CommandRoles"] = table.concat(allowedRoles, ",") + end + Network.saveSettings() + RequestHandler.addUpdateRequest(RequestHandler.IRequest:new({ + EventKey = EventHandler.CoreEventKeys.UpdateEvents, + })) + forms.destroy(form) + client.unpause() + end, 30, buttonRowY) + local btn2 = forms.button(form, "(Default)", function() + for _, roleKey in ipairs(orderedRoles) do + forms.setproperty(roleCheckboxes[roleKey], "Checked", true) + end + forms.settext(customRoleTextbox, "") + enableDisableAll() + end, 120, buttonRowY) + local btn3 = forms.button(form, "Cancel", function() + forms.destroy(form) + client.unpause() + end, 210, buttonRowY) + + -- Autosize form control elements + forms.setproperty(btn1, "AutoSize", true) + forms.setproperty(btn2, "AutoSize", true) + forms.setproperty(btn3, "AutoSize", true) +end + -- Not supported -- [Web Sockets] Streamer.bot Docs diff --git a/ironmon_tracker/network/RequestHandler.lua b/ironmon_tracker/network/RequestHandler.lua index faf72139..c9d746bf 100644 --- a/ironmon_tracker/network/RequestHandler.lua +++ b/ironmon_tracker/network/RequestHandler.lua @@ -230,10 +230,10 @@ function RequestHandler.processAllRequests() for _, eventKey in pairs(MiscUtils.split(request.EventKey, ",", true) or {}) do local event = EventHandler.Events[eventKey] local response - -- Wrap request processing in error catch call. If fail, no response is returned (remove request) - -- pcall(function() -- TODO: Re-enable this for final commit + -- Wrap request processing in error catch call. If fail, no response is returned and request is removed + pcall(function() response = RequestHandler.processAndBuildResponse(request, event) - -- end) + end) if not request.SentResponse then RequestHandler.addUpdateResponse(response) request.SentResponse = true diff --git a/ironmon_tracker/network/StreamerbotCodeImport.txt b/ironmon_tracker/network/StreamerbotCodeImport.txt index 561ca8d7..7f6a8e2b 100644 --- a/ironmon_tracker/network/StreamerbotCodeImport.txt +++ b/ironmon_tracker/network/StreamerbotCodeImport.txt @@ -1 +1 @@ -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 \ No newline at end of file +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 \ No newline at end of file From 8d6bf52a4330b42671e57dc29ddbc8046a001cc1 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:52:49 -0400 Subject: [PATCH 05/26] add stream connect to extras screen --- ironmon_tracker/ui/ExtrasScreen.lua | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/ironmon_tracker/ui/ExtrasScreen.lua b/ironmon_tracker/ui/ExtrasScreen.lua index 73f1b4d8..cc5d34ae 100644 --- a/ironmon_tracker/ui/ExtrasScreen.lua +++ b/ironmon_tracker/ui/ExtrasScreen.lua @@ -19,8 +19,8 @@ local function ExtrasScreen(initialSettings, initialTracker, initialProgram) local self = {} local constants = { - MAIN_FRAME_HEIGHT = 230, - EXTRAS_HEIGHT = 176, + MAIN_FRAME_HEIGHT = 310, + EXTRAS_HEIGHT = 256, EXTRA_ENTRY_TITLE_ROW_HEIGHT = 21, EXTRA_ENTRY_TEXT_ROW_HEIGHT = 10, EXTRA_WIDTH = 124, @@ -89,6 +89,10 @@ local function ExtrasScreen(initialSettings, initialTracker, initialProgram) program.openScreen(program.UI_SCREENS.COVERAGE_CALC_SCREEN) end + local function OpenStreamerBotConfig() + program.openScreen(program.UI_SCREENS.STREAMERBOT_CONFIG_SCREEN) + end + local extras = { { name = "Coverage Calc", @@ -115,6 +119,18 @@ local function ExtrasScreen(initialSettings, initialTracker, initialProgram) useEnabledButton = true, buttonText = "Clear Tourney Scores", buttonFunction = onClearClick + }, + { + name = "Stream Connect", + iconImage = "streamerbot.png", + imageOffset = {x = 1, y = 1}, + descriptionRows = { + "Connects to streaming", + "services for chat interaction." + }, + settingsKey = "streamerbot", + buttonText = "Open Config", + buttonFunction = OpenStreamerBotConfig } } From 0d280e791e2c35244911e5036b8966b4226591e7 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:53:10 -0400 Subject: [PATCH 06/26] add new stream connect screen to main program --- ironmon_tracker/Program.lua | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ironmon_tracker/Program.lua b/ironmon_tracker/Program.lua index 8042540f..18a19755 100644 --- a/ironmon_tracker/Program.lua +++ b/ironmon_tracker/Program.lua @@ -25,7 +25,8 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, local ExtrasScreen = dofile(Paths.FOLDERS.UI_FOLDER .. "/ExtrasScreen.lua") local EvoDataScreen = dofile(Paths.FOLDERS.UI_FOLDER .. "/EvoDataScreen.lua") local CoverageCalcScreen = dofile(Paths.FOLDERS.UI_FOLDER .. "/CoverageCalcScreen.lua") - local TimerScreen = dofile(Paths.FOLDERS.UI_FOLDER .. "/TimerScreen.lua") + local TimerScreen = dofile(Paths.FOLDERS.UI_FOLDER .. "/TimerScreen.lua") + local StreamerbotConfigScreen = dofile(Paths.FOLDERS.UI_FOLDER .. "/StreamerbotConfigScreen.lua") local INI = dofile(Paths.FOLDERS.DATA_FOLDER .. "/Inifile.lua") local PokemonDataReader = dofile(Paths.FOLDERS.DATA_FOLDER .. "/PokemonDataReader.lua") @@ -132,7 +133,8 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, local blankInitialization = { [self.UI_SCREENS.QUICK_LOAD_SCREEN] = true, [self.UI_SCREENS.UPDATE_NOTES_SCREEN] = true, - [self.UI_SCREENS.RESTORE_POINTS_SCREEN] = true + [self.UI_SCREENS.RESTORE_POINTS_SCREEN] = true, + [self.UI_SCREENS.STREAMERBOT_CONFIG_SCREEN] = true } local seedLoggerInitialization = { [self.UI_SCREENS.STATISTICS_SCREEN] = true, @@ -260,7 +262,8 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, EXTRAS_SCREEN = 21, EVO_DATA_SCREEN = 22, COVERAGE_CALC_SCREEN = 23, - TIMER_SCREEN = 24 + TIMER_SCREEN = 24, + STREAMERBOT_CONFIG_SCREEN = 25 } self.UI_SCREEN_OBJECTS = { @@ -288,7 +291,8 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, [self.UI_SCREENS.EXTRAS_SCREEN] = ExtrasScreen(settings, tracker, self), [self.UI_SCREENS.EVO_DATA_SCREEN] = EvoDataScreen(settings, tracker, self), [self.UI_SCREENS.COVERAGE_CALC_SCREEN] = CoverageCalcScreen(settings, tracker, self), - [self.UI_SCREENS.TIMER_SCREEN] = TimerScreen(settings, tracker, self) + [self.UI_SCREENS.TIMER_SCREEN] = TimerScreen(settings, tracker, self), + [self.UI_SCREENS.STREAMERBOT_CONFIG_SCREEN] = StreamerbotConfigScreen(settings,tracker,self) } tourneyTracker = From 094fb62e42a4c93a3a7a72745a962668587141a1 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:53:35 -0400 Subject: [PATCH 07/26] fix parsing bug with keldeo R --- ironmon_tracker/RandomizerLogParser.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ironmon_tracker/RandomizerLogParser.lua b/ironmon_tracker/RandomizerLogParser.lua index 3330d4ac..5a01d923 100644 --- a/ironmon_tracker/RandomizerLogParser.lua +++ b/ironmon_tracker/RandomizerLogParser.lua @@ -262,6 +262,9 @@ local function RandomizerLogParser(initialProgram) end currentLineIndex = currentLineIndex + 1 end + if pokemonList[647] ~= nil then + pokemonIDMappings["keldeo-r"] = 647 + end return true end @@ -514,7 +517,7 @@ local function RandomizerLogParser(initialProgram) if self.LogParserConstants.SECTION_HEADER_TO_PARSE_FUNCTION[line] and not sectionHeaderStarts[line] then sectionHeaderStarts[line] = index + 1 totalFound = totalFound + 1 - if totalFound == #self.LogParserConstants.PREFERRED_PARSE_ORDER then + if totalFound == #self.LogParserConstants.PREFERRED_PARSE_ORDER - 1 then break end end From ed904595b25498a71149b3b968c993209a0fcd04 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:53:48 -0400 Subject: [PATCH 08/26] add Serpent class for faster table serialization --- ironmon_tracker/Serpent.lua | 176 ++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 ironmon_tracker/Serpent.lua diff --git a/ironmon_tracker/Serpent.lua b/ironmon_tracker/Serpent.lua new file mode 100644 index 00000000..05a0583b --- /dev/null +++ b/ironmon_tracker/Serpent.lua @@ -0,0 +1,176 @@ +--[[ +Serpent source is released under the MIT License + +Copyright (c) 2012-2018 Paul Kulchenko (paul@kulchenko.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +--]] + +local n, v = "serpent", "0.303" -- (C) 2012-18 Paul Kulchenko; MIT License +local c, d = "Paul Kulchenko", "Lua serializer and pretty printer" +local snum = {[tostring(1/0)]='1/0 --[[math.huge]]',[tostring(-1/0)]='-1/0 --[[-math.huge]]',[tostring(0/0)]='0/0'} +local badtype = {thread = true, userdata = true, cdata = true} +local getmetatable = debug and debug.getmetatable or getmetatable +local pairs = function(t) return next, t end -- avoid using __pairs in Lua 5.2+ +local keyword, globals, G = {}, {}, (_G or _ENV) +for _,k in ipairs({'and', 'break', 'do', 'else', 'elseif', 'end', 'false', + 'for', 'function', 'goto', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', + 'return', 'then', 'true', 'until', 'while'}) do keyword[k] = true end +for k,v in pairs(G) do globals[v] = k end -- build func to name mapping +for _,g in ipairs({'coroutine', 'debug', 'io', 'math', 'string', 'table', 'os'}) do + for k,v in pairs(type(G[g]) == 'table' and G[g] or {}) do globals[v] = g..'.'..k end end + +local function s(t, opts) + local name, indent, fatal, maxnum = opts.name, opts.indent, opts.fatal, opts.maxnum + local sparse, custom, huge = opts.sparse, opts.custom, not opts.nohuge + local space, maxl = (opts.compact and '' or ' '), (opts.maxlevel or math.huge) + local maxlen, metatostring = tonumber(opts.maxlength), opts.metatostring + local iname, comm = '_'..(name or ''), opts.comment and (tonumber(opts.comment) or math.huge) + local numformat = opts.numformat or "%.17g" + local seen, sref, syms, symn = {}, {'local '..iname..'={}'}, {}, 0 + local function gensym(val) return '_'..(tostring(tostring(val)):gsub("[^%w]",""):gsub("(%d%w+)", + -- tostring(val) is needed because __tostring may return a non-string value + function(s) if not syms[s] then symn = symn+1; syms[s] = symn end return tostring(syms[s]) end)) end + local function safestr(s) return type(s) == "number" and (huge and snum[tostring(s)] or numformat:format(s)) + or type(s) ~= "string" and tostring(s) -- escape NEWLINE/010 and EOF/026 + or ("%q"):format(s):gsub("\010","n"):gsub("\026","\\026") end + -- handle radix changes in some locales + if opts.fixradix and (".1f"):format(1.2) ~= "1.2" then + local origsafestr = safestr + safestr = function(s) return type(s) == "number" + and (nohuge and snum[tostring(s)] or numformat:format(s):gsub(",",".")) or origsafestr(s) + end + end + local function comment(s,l) return comm and (l or 0) < comm and ' --[['..select(2, pcall(tostring, s))..']]' or '' end + local function globerr(s,l) return globals[s] and globals[s]..comment(s,l) or not fatal + and safestr(select(2, pcall(tostring, s))) or error("Can't serialize "..tostring(s)) end + local function safename(path, name) -- generates foo.bar, foo[3], or foo['b a r'] + local n = name == nil and '' or name + local plain = type(n) == "string" and n:match("^[%l%u_][%w_]*$") and not keyword[n] + local safe = plain and n or '['..safestr(n)..']' + return (path or '')..(plain and path and '.' or '')..safe, safe end + local alphanumsort = type(opts.sortkeys) == 'function' and opts.sortkeys or function(k, o, n) -- k=keys, o=originaltable, n=padding + local maxn, to = tonumber(n) or 12, {number = 'a', string = 'b'} + local function padnum(d) return ("%0"..tostring(maxn).."d"):format(tonumber(d)) end + table.sort(k, function(a,b) + -- sort numeric keys first: k[key] is not nil for numerical keys + return (k[a] ~= nil and 0 or to[type(a)] or 'z')..(tostring(a):gsub("%d+",padnum)) + < (k[b] ~= nil and 0 or to[type(b)] or 'z')..(tostring(b):gsub("%d+",padnum)) end) end + local function val2str(t, name, indent, insref, path, plainindex, level) + local ttype, level, mt = type(t), (level or 0), getmetatable(t) + local spath, sname = safename(path, name) + local tag = plainindex and + ((type(name) == "number") and '' or name..space..'='..space) or + (name ~= nil and sname..space..'='..space or '') + if seen[t] then -- already seen this element + sref[#sref+1] = spath..space..'='..space..seen[t] + return tag..'nil'..comment('ref', level) + end + -- protect from those cases where __tostring may fail + if type(mt) == 'table' and metatostring ~= false then + local to, tr = pcall(function() return mt.__tostring(t) end) + local so, sr = pcall(function() return mt.__serialize(t) end) + if (to or so) then -- knows how to serialize itself + seen[t] = insref or spath + t = so and sr or tr + ttype = type(t) + end -- new value falls through to be serialized + end + if ttype == "table" then + if level >= maxl then return tag..'{}'..comment('maxlvl', level) end + seen[t] = insref or spath + if next(t) == nil then return tag..'{}'..comment(t, level) end -- table empty + if maxlen and maxlen < 0 then return tag..'{}'..comment('maxlen', level) end + local maxn, o, out = math.min(#t, maxnum or #t), {}, {} + for key = 1, maxn do o[key] = key end + if not maxnum or #o < maxnum then + local n = #o -- n = n + 1; o[n] is much faster than o[#o+1] on large tables + for key in pairs(t) do + if o[key] ~= key then n = n + 1; o[n] = key end + end + end + if maxnum and #o > maxnum then o[maxnum+1] = nil end + if opts.sortkeys and #o > maxn then alphanumsort(o, t, opts.sortkeys) end + local sparse = sparse and #o > maxn -- disable sparsness if only numeric keys (shorter output) + for n, key in ipairs(o) do + local value, ktype, plainindex = t[key], type(key), n <= maxn and not sparse + if opts.valignore and opts.valignore[value] -- skip ignored values; do nothing + or opts.keyallow and not opts.keyallow[key] + or opts.keyignore and opts.keyignore[key] + or opts.valtypeignore and opts.valtypeignore[type(value)] -- skipping ignored value types + or sparse and value == nil then -- skipping nils; do nothing + elseif ktype == 'table' or ktype == 'function' or badtype[ktype] then + if not seen[key] and not globals[key] then + sref[#sref+1] = 'placeholder' + local sname = safename(iname, gensym(key)) -- iname is table for local variables + sref[#sref] = val2str(key,sname,indent,sname,iname,true) + end + sref[#sref+1] = 'placeholder' + local path = seen[t]..'['..tostring(seen[key] or globals[key] or gensym(key))..']' + sref[#sref] = path..space..'='..space..tostring(seen[value] or val2str(value,nil,indent,path)) + else + out[#out+1] = val2str(value,key,indent,nil,seen[t],plainindex,level+1) + if maxlen then + maxlen = maxlen - #out[#out] + if maxlen < 0 then break end + end + end + end + local prefix = string.rep(indent or '', level) + local head = indent and '{\n'..prefix..indent or '{' + local body = table.concat(out, ','..(indent and '\n'..prefix..indent or space)) + local tail = indent and "\n"..prefix..'}' or '}' + return (custom and custom(tag,head,body,tail,level) or tag..head..body..tail)..comment(t, level) + elseif badtype[ttype] then + seen[t] = insref or spath + return tag..globerr(t, level) + elseif ttype == 'function' then + seen[t] = insref or spath + if opts.nocode then return tag.."function() --[[..skipped..]] end"..comment(t, level) end + local ok, res = pcall(string.dump, t) + local func = ok and "((loadstring or load)("..safestr(res)..",'@serialized'))"..comment(t, level) + return tag..(func or globerr(t, level)) + else return tag..safestr(t) end -- handle all other types + end + local sepr = indent and "\n" or ";"..space + local body = val2str(t, name, indent) -- this call also populates sref + local tail = #sref>1 and table.concat(sref, sepr)..sepr or '' + local warn = opts.comment and #sref>1 and space.."--[[incomplete output with shared/self-references skipped]]" or '' + return not name and body..warn or "do local "..body..sepr..tail.."return "..name..sepr.."end" +end + +local function deserialize(data, opts) + local env = (opts and opts.safe == false) and G + or setmetatable({}, { + __index = function(t,k) return t end, + __call = function(t,...) error("cannot call functions") end + }) + local f, res = (loadstring or load)('return '..data, nil, nil, env) + if not f then f, res = (loadstring or load)(data, nil, nil, env) end + if not f then return f, res end + if setfenv then setfenv(f, env) end + return pcall(f) +end + +local function merge(a, b) if b then for k,v in pairs(b) do a[k] = v end end; return a; end +return { _NAME = n, _COPYRIGHT = c, _DESCRIPTION = d, _VERSION = v, serialize = s, + load = deserialize, + dump = function(a, opts) return s(a, merge({name = '_', compact = true, sparse = true}, opts)) end, + line = function(a, opts) return s(a, merge({sortkeys = true, comment = true}, opts)) end, + block = function(a, opts) return s(a, merge({indent = ' ', sortkeys = true, comment = true}, opts)) end } \ No newline at end of file From a1645807ba7cf09c063be4ad9491893cba7d321b Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:54:39 -0400 Subject: [PATCH 09/26] add extra arg to xcopy command to help with annoying permissions issue --- ironmon_tracker/TrackerUpdater.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ironmon_tracker/TrackerUpdater.lua b/ironmon_tracker/TrackerUpdater.lua index 7cbe81bb..1c28d561 100644 --- a/ironmon_tracker/TrackerUpdater.lua +++ b/ironmon_tracker/TrackerUpdater.lua @@ -41,7 +41,7 @@ local function TrackerUpdater(initialSettings) string.format('del "%s\\.gitattributes" /q', folderName), string.format('del "%s\\.gitignore" /q', folderName), string.format('del "%s\\README.md" /q', folderName), - string.format('xcopy "%s" /s /y /q', folderName), + string.format('xcopy "%s" /s /y /q /c', folderName), string.format('rmdir "%s" /s /q', folderName), "echo; && echo Version update completed successfully.", "timeout /t 3) || pause" -- Pause if any of the commands fail, those grouped between ( ) From 7eafd3b3e139f50b4de36f7616b04dc36c69b967 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:54:45 -0400 Subject: [PATCH 10/26] streamerbot picture --- ironmon_tracker/images/icons/streamerbot.png | Bin 0 -> 216 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 ironmon_tracker/images/icons/streamerbot.png diff --git a/ironmon_tracker/images/icons/streamerbot.png b/ironmon_tracker/images/icons/streamerbot.png new file mode 100644 index 0000000000000000000000000000000000000000..f2f251d6050bf84e2fc1bd8daa3010467691f533 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^d?3uh3?wzC-F*zC7>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`211o(uwDk&)~3V+LR`#(^iIo9JMNL@*gU-19`{}~*#R(=8UISV`@i-86o z24TkI`72U@g07w}jv*4^zUNNzGAMAcIJEq$E?eJWbAnIGiFZ|q*D*`E5RvXPpT1wd zyRf?R$_FvCwSPa~JUDmv%q4RgeJejt6nx2OSj_0Wo?(sd=Xy1ufefCmelF{r5}E*B Cu}5tH literal 0 HcmV?d00001 From c0747d83d1b76dc241d46a83261c7d254b5f490e Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:55:12 -0400 Subject: [PATCH 11/26] add function to initialize lookup tables rather than create them during searches --- ironmon_tracker/network/EventData.lua | 72 +++++++++++++++------------ 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/ironmon_tracker/network/EventData.lua b/ironmon_tracker/network/EventData.lua index e1dd908d..f75dfe86 100644 --- a/ironmon_tracker/network/EventData.lua +++ b/ironmon_tracker/network/EventData.lua @@ -1,4 +1,34 @@ -EventData = {} +EventData = { + pokemonNames = {}, + moveNames = {}, + abilityNames = {}, + routeNames = {} +} + +function EventData.initializeLookupTables() + if #EventData.pokemonNames > 0 then + return + end + for id, pokemon in ipairs(PokemonData.POKEMON) do + if (pokemon.name ~= "---") then + EventData.pokemonNames[id - 1] = pokemon.name:lower() + end + end + for id, move in ipairs(MoveData.MOVES) do + if (move.name ~= "---") then + EventData.moveNames[id - 1] = move.name:lower() + end + end + for id, ability in ipairs(AbilityData.ABILITIES) do + if (ability.name ~= "---") then + EventData.abilityNames[id - 1] = ability.name:lower() + end + end + local routes = Network.Data.gameInfo.LOCATION_DATA.locations or {} + for id, route in pairs(routes) do + EventData.routeNames[id] = (route.name or "Unnamed Route"):lower() + end +end -- Helper Functions and Variables @@ -10,16 +40,10 @@ local function findPokemonId(name, threshold) if name == nil or name == "" then return 0 end - threshold = threshold or 3 - -- Format list of Pokemon as id, name pairs - local pokemonNames = {} - for id, pokemon in ipairs(PokemonData.POKEMON) do - if (pokemon.name ~= "---") then - pokemonNames[id - 1] = pokemon.name:lower() - end - end + threshold = threshold or 3 + -- Try and find a name match - local id, _ = NetworkUtils.getClosestWord(name:lower(), pokemonNames, threshold) + local id, _ = NetworkUtils.getClosestWord(name:lower(), EventData.pokemonNames, threshold) return id or 0 end @@ -32,15 +56,9 @@ local function findMoveId(name, threshold) return 0 end threshold = threshold or 3 - -- Format list of Moves as id, name pairs - local moveNames = {} - for id, move in ipairs(MoveData.MOVES) do - if (move.name ~= "---") then - moveNames[id - 1] = move.name:lower() - end - end + -- Try and find a name match - local id, _ = NetworkUtils.getClosestWord(name:lower(), moveNames, threshold) + local id, _ = NetworkUtils.getClosestWord(name:lower(), EventData.moveNames, threshold) return id or 0 end @@ -53,15 +71,9 @@ local function findAbilityId(name, threshold) return 0 end threshold = threshold or 3 - -- Format list of Abilities as id, name pairs - local abilityNames = {} - for id, ability in ipairs(AbilityData.ABILITIES) do - if (ability.name ~= "---") then - abilityNames[id - 1] = ability.name:lower() - end - end + -- Try and find a name match - local id, _ = NetworkUtils.getClosestWord(name:lower(), abilityNames, threshold) + local id, _ = NetworkUtils.getClosestWord(name:lower(), EventData.abilityNames, threshold) return id or 0 end @@ -78,14 +90,8 @@ local function findRouteId(name, threshold) if tonumber(name) ~= nil then name = string.format("route %s", name) end - local routes = Network.Data.gameInfo.LOCATION_DATA.locations or {} - -- Format list of Routes as id, name pairs - local routeNames = {} - for id, route in pairs(routes) do - routeNames[id] = (route.name or "Unnamed Route"):lower() - end -- Try and find a name match - local id, _ = NetworkUtils.getClosestWord(name:lower(), routeNames, threshold) + local id, _ = NetworkUtils.getClosestWord(name:lower(), EventData.routeNames, threshold) return id or 0 end From 3e3029406c4c3eba42742d87f1b7c4807462aae8 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:55:32 -0400 Subject: [PATCH 12/26] add helper function to get connection status color for UI --- ironmon_tracker/network/Network.lua | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/ironmon_tracker/network/Network.lua b/ironmon_tracker/network/Network.lua index 3014c34c..a9319fac 100644 --- a/ironmon_tracker/network/Network.lua +++ b/ironmon_tracker/network/Network.lua @@ -101,9 +101,10 @@ function Network.initialize() Network.requiresUpdating = false Network.lastUpdateTime = 0 Network.loadConnectionSettings() - if Network.Options["AutoConnectStartup"] then - Network.tryConnect() - end + if Network.Options["AutoConnectStartup"] then + Network.tryConnect() + end + end function Network.delayedStartupActions() -- DEBUG @@ -124,6 +125,7 @@ function Network.linkData(program, tracker, battleHandler, randomizerLogParser) seedLogger = program.getSeedLogger(), randomizerLogParser = randomizerLogParser, } + EventData.initializeLookupTables() end ---Returns a message regarding the status of the connection @@ -138,6 +140,18 @@ function Network.getConnectionStatusMsg() end end +--Returns the text color key for the current connection status +---@return string +function Network.getConnectionStatusColor() + if Network.CurrentConnection.State == Network.ConnectionState.Established then + return "Positive text color" + elseif Network.CurrentConnection.State == Network.ConnectionState.Listen then + return "Intermediate text color" + else + return "Negative text color" + end +end + ---Checks current version of the Tracker's Network code against the Streamerbot code version ---@param externalVersion string function Network.checkVersion(externalVersion) From 216f6190384cb0a352581a73be846c8a44535e6d Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:56:35 -0400 Subject: [PATCH 13/26] font style support (unused however) --- ironmon_tracker/ui/UIBaseClasses/TextStyle.lua | 12 +++++------- ironmon_tracker/utils/DrawingUtils.lua | 5 ++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/ironmon_tracker/ui/UIBaseClasses/TextStyle.lua b/ironmon_tracker/ui/UIBaseClasses/TextStyle.lua index 9e61e344..02c89aa4 100644 --- a/ironmon_tracker/ui/UIBaseClasses/TextStyle.lua +++ b/ironmon_tracker/ui/UIBaseClasses/TextStyle.lua @@ -3,13 +3,11 @@ local function TextStyle( initialFontFamily, initialTextColorKey, initialShadowColorKey, - shouldBeBolded, + initialFontStyle, useStrikethrough) local self = {} local bold = nil - if shouldBeBolded then - bold = "bold" - end + local fontStyle = initialFontStyle local strikethrough = useStrikethrough or false local fontSize = initialFontSize local fontFamily = initialFontFamily @@ -21,9 +19,6 @@ local function TextStyle( function self.setUseStrikethrough(newValue) strikethrough = newValue end - function self.isBolded() - return bold - end function self.getFontSize() return fontSize end @@ -42,6 +37,9 @@ local function TextStyle( function self.getShadowColorKey() return shadowColorKey end + function self.getFontStyle() + return fontStyle + end return self end diff --git a/ironmon_tracker/utils/DrawingUtils.lua b/ironmon_tracker/utils/DrawingUtils.lua index c2b63ed3..1093be73 100644 --- a/ironmon_tracker/utils/DrawingUtils.lua +++ b/ironmon_tracker/utils/DrawingUtils.lua @@ -275,7 +275,7 @@ function DrawingUtils.drawText(x, y, text, textStyle, shadowColor, justifiable, end end if drawShadow then - gui.drawText(x + spacing + 1, y + 1, text, shadowColor, nil, textStyle.getFontSize(), textStyle.getFontFamily()) + gui.drawText(x + spacing + 1, y + 1, text, shadowColor, nil, textStyle.getFontSize(), textStyle.getFontFamily(), textStyle.getFontStyle()) end if textStyle.isStrikethrough() then local moveWidth = DrawingUtils.calculateWordPixelLength(text) @@ -283,8 +283,7 @@ function DrawingUtils.drawText(x, y, text, textStyle, shadowColor, justifiable, gui.drawLine(x + 1, y + 6, x + moveWidth + 2, y + 6, opacity) color = settings.colorScheme["Negative text color"] end - local bolded = textStyle.isBolded() - gui.drawText(x + spacing, y, text, color, nil, textStyle.getFontSize(), textStyle.getFontFamily(), bolded) + gui.drawText(x + spacing, y, text, color, nil, textStyle.getFontSize(), textStyle.getFontFamily(), textStyle.getFontStyle()) end local function getPokemonPath(pokemonID, currentIconSet) From f6ddaf55d3060f3940a4490f5e7eb47b80e02cd5 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:56:43 -0400 Subject: [PATCH 14/26] streamerbot configuration screen --- .../ui/StreamerbotConfigScreen.lua | 496 ++++++++++++++++++ ironmon_tracker/utils/MiscUtils.lua | 24 +- 2 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 ironmon_tracker/ui/StreamerbotConfigScreen.lua diff --git a/ironmon_tracker/ui/StreamerbotConfigScreen.lua b/ironmon_tracker/ui/StreamerbotConfigScreen.lua new file mode 100644 index 00000000..9de43657 --- /dev/null +++ b/ironmon_tracker/ui/StreamerbotConfigScreen.lua @@ -0,0 +1,496 @@ +local function StreamerbotConfigScreen(initialSettings, initialTracker, initialProgram) + local Frame = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/Frame.lua") + local Box = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/Box.lua") + local Component = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/Component.lua") + local TextLabel = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/TextLabel.lua") + local TextField = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/TextField.lua") + local TextStyle = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/TextStyle.lua") + local Layout = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/Layout.lua") + local SettingToggleButton = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/SettingToggleButton.lua") + local MouseClickEventListener = dofile(Paths.FOLDERS.UI_BASE_CLASSES .. "/MouseClickEventListener.lua") + local settings = initialSettings + local tracker = initialTracker + local program = initialProgram + local currentButtonWaiting = { + button = nil, + settingKey = nil, + previousText = "" + } + local constants = { + MAIN_FRAME_HEIGHT = 192, + BOTTOM_FRAME_HEIGHT = 24, + TEXT_HEADER_HEIGHT = 18, + FOLDER_LABEL_WIDTH = 96, + BUTTON_SIZE = 10, + SMALL_BUTTON_WIDTH = 36, + SMALL_BUTTON_HEIGHT = 14, + STATUS_FOLDER_FRAME_HEIGHT = 104, + CODE_PERMISSIONS_FRAME_HEIGHT = 56, + PATH_SETUP_FRAME_HEIGHT = 40 + } + local ui = {} + local eventListeners = {} + local self = {} + + local function onGoBackClick() + program.setCurrentScreens({ program.UI_SCREENS.EXTRAS_SCREEN }) + program.drawCurrentScreens() + end + + local function onHelpClick() + local helpURL = "https://github.com/besteon/Ironmon-Tracker/wiki/Stream-Connect-Guide" + os.execute(string.format('start "" "%s"', helpURL)) + end + + local function onSetFolderClick() + local existingPath = tostring(Network.Options["DataFolder"]) or "" + local filterOptions = "Json File (*.JSON)|*.json|All files (*.*)|*.*" + local newPath = forms.openfile("SELECT ANY JSON FILE", existingPath, filterOptions) + if newPath ~= nil and newPath ~= "" then + newPath = newPath:sub(0, newPath:match("^.*()" .. Paths.SLASH) - 1) + if not string.find(Network.Options["DataFolder"], newPath) then + Network.closeConnections() + end + if newPath == nil or newPath == "" then + Network.closeConnections() + else + Network.Options["DataFolder"] = newPath + end + Network.saveSettings() + ui.controls.dataFolderPath.setText(FormsUtils.shortenFolderName(newPath)) + end + program.drawCurrentScreens() + end + + local function refreshStatus() + ui.controls.statusText.setText(Network.getConnectionStatusMsg()) + ui.controls.statusText.setTextColorKey(Network.getConnectionStatusColor()) + end + + local function updateConnectButton() + if Network.isConnected() then + ui.controls.connectDisconnect.setText("Disconnect") + ui.controls.connectDisconnect.setTextOffset({x=6,y=1}) + else + ui.controls.connectDisconnect.setText("Connect") + ui.controls.connectDisconnect.setTextOffset({x=11,y=1}) + end + refreshStatus() + end + + function self.initialize() + updateConnectButton() + ui.controls.dataFolderPath.setText(FormsUtils.shortenFolderName(Network.Options["DataFolder"])) + program.drawCurrentScreens() + end + + local function connectDisconnect() + if Network.isConnected() then + Network.closeConnections() + else + Network.tryConnect() + end + updateConnectButton() + program.drawCurrentScreens() + end + + local function createConnectButton() + ui.frames.connectFrame = + Frame( + Box( + {x = 0, y = 0}, + { + width = 0, + height = 0 + } + ), + Layout(Graphics.ALIGNMENT_TYPE.HORIZONTAL, 0, {x = 36, y = 0}), + ui.frames.statusFolderFrame + ) + ui.controls.connectDisconnect = + TextLabel( + Component( + ui.frames.connectFrame, + Box( + {x = 0, y = 0}, + { + width = 56, + height = constants.SMALL_BUTTON_HEIGHT + }, + "Top box background color", + "Top box border color", + true, + "Top box background color" + ) + ), + TextField( + "Connect", + {x = 6, y = 1}, + TextStyle( + Graphics.FONT.DEFAULT_FONT_SIZE, + Graphics.FONT.DEFAULT_FONT_FAMILY, + "Top box text color", + "Top box background color" + ) + ) + ) + table.insert(eventListeners, MouseClickEventListener(ui.controls.connectDisconnect, connectDisconnect)) + end + + local function createPathSetupFrame(parentFrame, labelName) + local pathFrame = + Frame( + Box( + {x = Graphics.SIZES.SCREEN_WIDTH, y = 0}, + { + width = Graphics.SIZES.MAIN_SCREEN_WIDTH - 2 * Graphics.SIZES.BORDER_MARGIN, + height = constants.PATH_SETUP_FRAME_HEIGHT + }, + nil, + nil + ), + Layout(Graphics.ALIGNMENT_TYPE.VERTICAL, 0, {x = -1, y = 4}), + parentFrame + ) + local pathSettingSetFrame = + Frame( + Box( + {x = Graphics.SIZES.SCREEN_WIDTH, y = 0}, + { + width = 0, + height = 16 + }, + nil, + nil + ), + Layout(Graphics.ALIGNMENT_TYPE.HORIZONTAL), + pathFrame + ) + local settingLabel = + TextLabel( + Component( + pathSettingSetFrame, + Box( + {x = 0, y = 0}, + { + width = 86, + height = 0 + }, + nil, + nil, + false + ) + ), + TextField( + labelName, + {x = 0, y = 1}, + TextStyle( + Graphics.FONT.DEFAULT_FONT_SIZE, + Graphics.FONT.DEFAULT_FONT_FAMILY, + "Top box text color", + "Top box background color" + ) + ) + ) + local setButton = + TextLabel( + Component( + pathSettingSetFrame, + Box( + {x = 0, y = 0}, + { + width = constants.SMALL_BUTTON_WIDTH, + height = constants.SMALL_BUTTON_HEIGHT + }, + "Top box background color", + "Top box border color", + true, + "Top box background color" + ) + ), + TextField( + "Set", + {x = 9, y = 1}, + TextStyle( + Graphics.FONT.DEFAULT_FONT_SIZE, + Graphics.FONT.DEFAULT_FONT_FAMILY, + "Top box text color", + "Top box background color" + ) + ) + ) + ui.controls.dataFolderPath = + TextLabel( + Component( + pathFrame, + Box( + {x = 0, y = 0}, + { + width = constants.FOLDER_LABEL_WIDTH, + height = 14 + }, + nil, + nil, + false + ) + ), + TextField( + Network.Options["DataFolder"], + {x = 4, y = 0}, + TextStyle( + Graphics.FONT.DEFAULT_FONT_SIZE, + Graphics.FONT.DEFAULT_FONT_FAMILY, + "Top box text color", + "Top box background color" + ) + ) + ) + table.insert(eventListeners, MouseClickEventListener(setButton, onSetFolderClick)) + end + + local function createCodePermissionsFrame() + ui.frames.codePermissionsFrame = + Frame( + Box( + {x = Graphics.SIZES.SCREEN_WIDTH, y = 0}, + { + width = Graphics.SIZES.MAIN_SCREEN_WIDTH - 2 * Graphics.SIZES.BORDER_MARGIN, + height = constants.CODE_PERMISSIONS_FRAME_HEIGHT + }, + "Top box background color","Top box border color" + ), + Layout(Graphics.ALIGNMENT_TYPE.VERTICAL, 4, {x = 15, y = 7}), + ui.frames.mainFrame + ) + local buttons = { + { + iconName = "UPDATER_ICON", + offset = {x = 2, y = 2}, + text = "Streamerbot Code", + onClick = Network.openGetCodeWindow + }, + { + iconName = "TRACKED_INFO_ICON", + offset = {x = 4, y = 3}, + text = "Role Permissions", + onClick = Network.openCommandRolePermissionsPrompt + } + } + for _, button in pairs(buttons) do + local frameInfo = + FrameFactory.createScreenOpeningFrame( + ui.frames.codePermissionsFrame, + 110, + 19, + button.iconName, + button.offset, + button.text + ) + table.insert(eventListeners, MouseClickEventListener(frameInfo.button, button.onClick)) + end + end + + local function createStatusFolderFrame() + ui.frames.statusFolderFrame = + Frame( + Box( + {x = Graphics.SIZES.SCREEN_WIDTH, y = 0}, + { + width = Graphics.SIZES.MAIN_SCREEN_WIDTH - 2 * Graphics.SIZES.BORDER_MARGIN, + height = constants.STATUS_FOLDER_FRAME_HEIGHT + }, + "Top box background color", + "Top box border color" + ), + Layout(Graphics.ALIGNMENT_TYPE.VERTICAL, 2, {x = Graphics.SIZES.BORDER_MARGIN, y = 7}), + ui.frames.mainFrame + ) + ui.controls.statusHeading = + TextLabel( + Component( + ui.frames.statusFolderFrame, + Box( + {x = 0, y = 0}, + { + width = 86, + height = constants.TEXT_HEADER_HEIGHT - 6 + } + ) + ), + TextField( + "Connection Status:", + {x = -1, y = -1}, + TextStyle( + Graphics.FONT.DEFAULT_FONT_SIZE, + Graphics.FONT.DEFAULT_FONT_FAMILY, + "Top box text color", + "Top box background color" + ) + ) + ) + ui.controls.statusText = + TextLabel( + Component( + ui.frames.statusFolderFrame, + Box( + {x = 0, y = 0}, + { + width = Graphics.SIZES.MAIN_SCREEN_WIDTH - 2 * Graphics.SIZES.BORDER_MARGIN, + height = constants.TEXT_HEADER_HEIGHT - 2 + }, + nil, + nil, + false + ) + ), + TextField( + "Online: Connection Established.", + {x = 3, y = 0}, + TextStyle( + Graphics.FONT.DEFAULT_FONT_SIZE, + Graphics.FONT.DEFAULT_FONT_FAMILY, + "Top box text color", + "Top box background color" + ) + ) + ) + + createPathSetupFrame(ui.frames.statusFolderFrame, "Connection Folder") + createConnectButton() + end + + local function initUI() + ui.controls = {} + ui.frames = {} + ui.frames.mainFrame = + Frame( + Box( + {x = Graphics.SIZES.SCREEN_WIDTH, y = 0}, + {width = Graphics.SIZES.MAIN_SCREEN_WIDTH, height = constants.MAIN_FRAME_HEIGHT}, + "Main background color", + nil + ), + Layout(Graphics.ALIGNMENT_TYPE.VERTICAL, 0, {x = Graphics.SIZES.BORDER_MARGIN, y = Graphics.SIZES.BORDER_MARGIN}), + nil + ) + ui.controls.mainHeading = + TextLabel( + Component( + ui.frames.mainFrame, + Box( + {x = 5, y = 5}, + { + width = Graphics.SIZES.MAIN_SCREEN_WIDTH - 2 * Graphics.SIZES.BORDER_MARGIN, + height = constants.TEXT_HEADER_HEIGHT + }, + "Top box background color", + "Top box border color", + false + ) + ), + TextField( + "Stream Connect", + {x = 24, y = 1}, + TextStyle(13, Graphics.FONT.DEFAULT_FONT_FAMILY, "Top box text color", "Top box background color") + ) + ) + --[[ + ui.frames.mainInnerFrame = + Frame( + Box( + {x = Graphics.SIZES.SCREEN_WIDTH, y = 0}, + { + width = Graphics.SIZES.MAIN_SCREEN_WIDTH - 2 * Graphics.SIZES.BORDER_MARGIN, + height = 140 + }, + "Top box background color", + "Top box border color" + ), + Layout(Graphics.ALIGNMENT_TYPE.VERTICAL, 0, {x = 0, y = 0}), + ui.frames.mainFrame + )--]] + createStatusFolderFrame() + createCodePermissionsFrame() + ui.frames.goBackFrame = + Frame( + Box( + {x = Graphics.SIZES.SCREEN_WIDTH, y = 0}, + { + width = Graphics.SIZES.MAIN_SCREEN_WIDTH - 2 * Graphics.SIZES.BORDER_MARGIN, + height = constants.BOTTOM_FRAME_HEIGHT + }, + "Top box background color", + "Top box border color" + ), + Layout(Graphics.ALIGNMENT_TYPE.HORIZONTAL, 60, {x = 5, y = Graphics.SIZES.BORDER_MARGIN}), + ui.frames.mainFrame + ) + ui.controls.helpButton = + TextLabel( + Component( + ui.frames.goBackFrame, + Box( + {x = 0, y = 0}, + {width = 30, height = 14}, + "Top box background color", + "Top box border color", + true, + "Top box background color" + ) + ), + TextField( + "Help", + {x = 5, y = 1}, + TextStyle( + Graphics.FONT.DEFAULT_FONT_SIZE, + Graphics.FONT.DEFAULT_FONT_FAMILY, + "Top box text color", + "Top box background color" + ) + ) + ) + table.insert(eventListeners, MouseClickEventListener(ui.controls.helpButton, onHelpClick)) + ui.controls.goBackButton = + TextLabel( + Component( + ui.frames.goBackFrame, + Box( + {x = 0, y = 0}, + {width = 40, height = 14}, + "Top box background color", + "Top box border color", + true, + "Top box background color" + ) + ), + TextField( + "Go back", + {x = 3, y = 1}, + TextStyle( + Graphics.FONT.DEFAULT_FONT_SIZE, + Graphics.FONT.DEFAULT_FONT_FAMILY, + "Top box text color", + "Top box background color" + ) + ) + ) + table.insert(eventListeners, MouseClickEventListener(ui.controls.goBackButton, onGoBackClick)) + --]] + end + + function self.runEventListeners() + for _, eventListener in pairs(eventListeners) do + eventListener.listen() + end + end + + function self.show() + refreshStatus() + ui.frames.mainFrame.show() + end + + initUI() + return self +end + +return StreamerbotConfigScreen diff --git a/ironmon_tracker/utils/MiscUtils.lua b/ironmon_tracker/utils/MiscUtils.lua index 1bbc9b7e..fa3028d7 100644 --- a/ironmon_tracker/utils/MiscUtils.lua +++ b/ironmon_tracker/utils/MiscUtils.lua @@ -1,5 +1,7 @@ MiscUtils = {} +Serpent = dofile(Paths.FOLDERS.DATA_FOLDER.."/Serpent.lua") + function MiscUtils.inlineIf(condition, T, F) if condition then return T @@ -274,7 +276,7 @@ function MiscUtils.validPokemonData(pokemonData) end local id = tonumber(pokemonData.pokemonID) local heldItem = tonumber(pokemonData.heldItem) - if id == nil or pokemonData.level > 100 or not AbilityData.ABILITIES[pokemonData.ability+1] then + if id == nil or pokemonData.level > 100 or not AbilityData.ABILITIES[pokemonData.ability + 1] then return false end if not PokemonData.POKEMON[id + 1] or heldItem > 650 then @@ -395,13 +397,31 @@ end function MiscUtils.saveTableToFile(fileName, tableData) local file = io.open(fileName, "w") if file ~= nil then - local data = Pickle.pickle(tableData) + local data = Serpent.dump(tableData) file:write(data) file:close() end end function MiscUtils.getTableFromFile(fileName) + local file = io.open(fileName, "r") + if file ~= nil then + local fileContents = file:read("*a") + file:close() + if fileContents ~= nil and fileContents ~= "" then + if fileContents:sub(1, 8) ~= "do local" then + return MiscUtils.getPickledTableFromFile(fileName) + end + local ok, res = Serpent.load(fileContents) + if not ok then + error("Error deserializing table: " .. res) + end + return res + end + end +end + +function MiscUtils.getPickledTableFromFile(fileName) local file = io.open(fileName, "r") if file ~= nil then local fileContents = file:read("*a") From fc86aac59735f36630a1dd3d11883d91b1ecd30b Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 01:59:55 -0400 Subject: [PATCH 15/26] why do i do this every single time --- ironmon_tracker/ui/StreamerbotConfigScreen.lua | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/ironmon_tracker/ui/StreamerbotConfigScreen.lua b/ironmon_tracker/ui/StreamerbotConfigScreen.lua index 9de43657..846fd071 100644 --- a/ironmon_tracker/ui/StreamerbotConfigScreen.lua +++ b/ironmon_tracker/ui/StreamerbotConfigScreen.lua @@ -17,7 +17,7 @@ local function StreamerbotConfigScreen(initialSettings, initialTracker, initialP previousText = "" } local constants = { - MAIN_FRAME_HEIGHT = 192, + MAIN_FRAME_HEIGHT = 212, BOTTOM_FRAME_HEIGHT = 24, TEXT_HEADER_HEIGHT = 18, FOLDER_LABEL_WIDTH = 96, @@ -394,21 +394,6 @@ local function StreamerbotConfigScreen(initialSettings, initialTracker, initialP TextStyle(13, Graphics.FONT.DEFAULT_FONT_FAMILY, "Top box text color", "Top box background color") ) ) - --[[ - ui.frames.mainInnerFrame = - Frame( - Box( - {x = Graphics.SIZES.SCREEN_WIDTH, y = 0}, - { - width = Graphics.SIZES.MAIN_SCREEN_WIDTH - 2 * Graphics.SIZES.BORDER_MARGIN, - height = 140 - }, - "Top box background color", - "Top box border color" - ), - Layout(Graphics.ALIGNMENT_TYPE.VERTICAL, 0, {x = 0, y = 0}), - ui.frames.mainFrame - )--]] createStatusFolderFrame() createCodePermissionsFrame() ui.frames.goBackFrame = From 41e568a7691c06a0e4f31b2c50d5bb8a6de0dec3 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 02:02:04 -0400 Subject: [PATCH 16/26] another oopsie --- ironmon_tracker/ui/StreamerbotConfigScreen.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ironmon_tracker/ui/StreamerbotConfigScreen.lua b/ironmon_tracker/ui/StreamerbotConfigScreen.lua index 846fd071..3a207163 100644 --- a/ironmon_tracker/ui/StreamerbotConfigScreen.lua +++ b/ironmon_tracker/ui/StreamerbotConfigScreen.lua @@ -22,7 +22,7 @@ local function StreamerbotConfigScreen(initialSettings, initialTracker, initialP TEXT_HEADER_HEIGHT = 18, FOLDER_LABEL_WIDTH = 96, BUTTON_SIZE = 10, - SMALL_BUTTON_WIDTH = 36, + SMALL_BUTTON_WIDTH = 34, SMALL_BUTTON_HEIGHT = 14, STATUS_FOLDER_FRAME_HEIGHT = 104, CODE_PERMISSIONS_FRAME_HEIGHT = 56, From 5bfaa74a4e76321d06d4254251659210effc5ac2 Mon Sep 17 00:00:00 2001 From: UTDZac Date: Wed, 25 Sep 2024 00:36:37 -0700 Subject: [PATCH 17/26] Fix dofile error and EventData calc bugs Move the Paths.lua dofile as soon as possible, to load the paths used for other files. Fixed EventData to re-init data about the current game. Fixed Route not displaying all the time; e.g. should at least show 0/12 encounters Fixed Coverage Calc bug 0.025 -> 0.25 (1/4) --- ironmon_tracker/Main.lua | 2 +- ironmon_tracker/network/EventData.lua | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/ironmon_tracker/Main.lua b/ironmon_tracker/Main.lua index 8113159a..661c4d13 100644 --- a/ironmon_tracker/Main.lua +++ b/ironmon_tracker/Main.lua @@ -12,12 +12,12 @@ local function Main() Chars.accentedE = "\233" end + dofile("ironmon_tracker/constants/Paths.lua") dofile("ironmon_tracker/utils/FormsUtils.lua") dofile("ironmon_tracker/utils/MiscUtils.lua") dofile("ironmon_tracker/constants/PlaythroughConstants.lua") dofile("ironmon_tracker/constants/MiscConstants.lua") - dofile("ironmon_tracker/constants/Paths.lua") dofile(Paths.FOLDERS.DATA_FOLDER .. "/Pickle.lua") dofile(Paths.FOLDERS.DATA_FOLDER .. "/QuickLoader.lua") dofile(Paths.FOLDERS.CONSTANTS_FOLDER .. "/MiscData.lua") diff --git a/ironmon_tracker/network/EventData.lua b/ironmon_tracker/network/EventData.lua index f75dfe86..e57119b8 100644 --- a/ironmon_tracker/network/EventData.lua +++ b/ironmon_tracker/network/EventData.lua @@ -6,9 +6,6 @@ EventData = { } function EventData.initializeLookupTables() - if #EventData.pokemonNames > 0 then - return - end for id, pokemon in ipairs(PokemonData.POKEMON) do if (pokemon.name ~= "---") then EventData.pokemonNames[id - 1] = pokemon.name:lower() @@ -359,14 +356,14 @@ function EventData.getRoute(params) -- end -- Check for wilds in the route local encounterArea = Network.Data.gameInfo.LOCATION_DATA.encounters[route.name or false] - local trackedArea = Network.Data.tracker.getEncounterData(route.name) + local trackedArea = Network.Data.tracker.getEncounterData(route.name) or {} -- if routeFilter then -- encounterArea = RouteData.EncounterArea[routeFilter] or RouteData.EncounterArea.LAND -- else -- -- Default to the first area type (usually Walking) -- encounterArea = RouteData.getNextAvailableEncounterArea(routeId, RouteData.EncounterArea.TRAINER) -- end - if encounterArea and encounterArea.vanillaData and trackedArea then + if encounterArea and encounterArea.vanillaData then local wildMonsAndLevels = {} for pokemonID, levelList in pairs(trackedArea.encountersSeen or {}) do if PokemonData.POKEMON[pokemonID + 1] then @@ -663,7 +660,7 @@ function EventData.getCoverage(params) local format = "[%0dx] %s" if mult == 0.5 then format = "[%0.1fx] %s" - elseif mult == 0.025 then + elseif mult == 0.25 then format = "[%0.2fx] %s" end table.insert(info, string.format(format, mult, effectiveList.total)) From 1d31cc5abbabbd487b19637a0a2a7745986d9f8b Mon Sep 17 00:00:00 2001 From: UTDZac Date: Wed, 25 Sep 2024 10:49:19 -0700 Subject: [PATCH 18/26] Performance improvements to !search command Improve the performance of the search command's fuzzy matching to determine the search mode. It now does a single linear pass instead of incremental multiple passes. --- ironmon_tracker/network/EventData.lua | 82 +++++++++++++++++--------- ironmon_tracker/utils/NetworkUtils.lua | 34 +++++++---- 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/ironmon_tracker/network/EventData.lua b/ironmon_tracker/network/EventData.lua index e57119b8..64c70289 100644 --- a/ironmon_tracker/network/EventData.lua +++ b/ironmon_tracker/network/EventData.lua @@ -33,54 +33,58 @@ end ---@param name string? ---@param threshold number? Default threshold distance of 3 ---@return number pokemonId +---@return number distance The Levenshtein distance between search word and matched word local function findPokemonId(name, threshold) if name == nil or name == "" then - return 0 + return 0, -1 end threshold = threshold or 3 -- Try and find a name match - local id, _ = NetworkUtils.getClosestWord(name:lower(), EventData.pokemonNames, threshold) - return id or 0 + local id, _, distance = NetworkUtils.getClosestWord(name:lower(), EventData.pokemonNames, threshold) + return id or 0, distance end ---Searches for a Move by name, finds the best match; returns 0 if no good match ---@param name string? ---@param threshold number? Default threshold distance of 3 ---@return number moveId +---@return number distance The Levenshtein distance between search word and matched word local function findMoveId(name, threshold) if name == nil or name == "" then - return 0 + return 0, -1 end threshold = threshold or 3 -- Try and find a name match - local id, _ = NetworkUtils.getClosestWord(name:lower(), EventData.moveNames, threshold) - return id or 0 + local id, _, distance = NetworkUtils.getClosestWord(name:lower(), EventData.moveNames, threshold) + return id or 0, distance end ---Searches for an Ability by name, finds the best match; returns 0 if no good match ---@param name string? ---@param threshold number? Default threshold distance of 3 ---@return number abilityId +---@return number distance The Levenshtein distance between search word and matched word local function findAbilityId(name, threshold) if name == nil or name == "" then - return 0 + return 0, -1 end threshold = threshold or 3 -- Try and find a name match - local id, _ = NetworkUtils.getClosestWord(name:lower(), EventData.abilityNames, threshold) - return id or 0 + local id, _, distance = NetworkUtils.getClosestWord(name:lower(), EventData.abilityNames, threshold) + return id or 0, distance end ---Searches for a Route by name, finds the best match; returns 0 if no good match ---@param name string? ---@param threshold number? Default threshold distance of 5! ---@return number mapId +---@return number distance The Levenshtein distance between search word and matched word local function findRouteId(name, threshold) if name == nil or name == "" then - return 0 + return 0, -1 end threshold = threshold or 5 -- If the lookup is just a route number, allow it to be searchable @@ -88,21 +92,22 @@ local function findRouteId(name, threshold) name = string.format("route %s", name) end -- Try and find a name match - local id, _ = NetworkUtils.getClosestWord(name:lower(), EventData.routeNames, threshold) - return id or 0 + local id, _, distance = NetworkUtils.getClosestWord(name:lower(), EventData.routeNames, threshold) + return id or 0, distance end ---Searches for a Pokémon Type by name, finds the best match; returns nil if no match found ---@param name string? ---@param threshold number? Default threshold distance of 3 ---@return string? type PokemonData.Type +---@return number distance The Levenshtein distance between search word and matched word local function findPokemonType(name, threshold) if name == nil or name == "" then - return nil + return nil, -1 end threshold = threshold or 3 - local _, type = NetworkUtils.getClosestWord(name:upper(), PokemonData.TYPE_LIST, threshold) - return type + local _, type, distance = NetworkUtils.getClosestWord(name:upper(), PokemonData.TYPE_LIST, threshold) + return type, distance end -- The max # of items to show for any commands that output a list of items (try keep chat message output short) @@ -798,22 +803,41 @@ function EventData.getSearch(params) if (params or "") == "" then return buildResponse(params, helpResponse) end - local function getModeAndId(input, threshold) - local id = findPokemonId(input, threshold) - if id ~= 0 then return "pokemon", id end - id = findMoveId(input, threshold) - if id ~= 0 then return "move", id end - id = findAbilityId(input, threshold) - if id ~= 0 then return "ability", id end - return nil, 0 - end - local searchMode, searchId - for i=1, 4, 1 do - searchMode, searchId = getModeAndId(params, i) - if searchMode then - break + + -- Determine if the search is for an ability, move, or pokemon + local function determineSearchMode(input) + local searchMode, searchId, closestDistance = nil, -1, 9999 + local tempId, tempDist = findAbilityId(input, 4) + if tempId ~= nil and tempDist < closestDistance then + searchMode = "ability" + searchId = tempId + closestDistance = tempDist + if closestDistance == 0 then -- exact match + return searchMode, searchId + end end + tempId, tempDist = findMoveId(input, 4) + if tempId ~= nil and tempDist < closestDistance then + searchMode = "move" + searchId = tempId + closestDistance = tempDist + if closestDistance == 0 then -- exact match + return searchMode, searchId + end + end + tempId, tempDist = findPokemonId(input, 4) + if tempId ~= nil and tempDist < closestDistance then + searchMode = "pokemon" + searchId = tempId + closestDistance = tempDist + if closestDistance == 0 then -- exact match + return searchMode, searchId + end + end + return searchMode, searchId end + + local searchMode, searchId = determineSearchMode(params) if not searchMode then local prefix = string.format("%s %s", params, OUTPUT_CHAR) return buildResponse(prefix, "Can't find a Pok" .. Chars.accentedE .. "mon, move, or ability with that name.") diff --git a/ironmon_tracker/utils/NetworkUtils.lua b/ironmon_tracker/utils/NetworkUtils.lua index c13d086c..6d5364fc 100644 --- a/ironmon_tracker/utils/NetworkUtils.lua +++ b/ironmon_tracker/utils/NetworkUtils.lua @@ -44,18 +44,28 @@ function NetworkUtils.containsText(text, searchString, matchCase) return text:find(searchString, 1, true) ~= nil end --- Searches `wordlist` for the closest matching `word` based on Levenshtein distance. Returns: key, result --- If the minimum distance is greater than the `threshold`, the original 'word' is returned and key is nil --- https://stackoverflow.com/questions/42681501/how-do-you-make-a-string-dictionary-function-in-lua +---Searches `wordlist` for the closest matching `word` based on Levenshtein distance. +---If the minimum distance is greater than the `threshold`, the original 'word' is returned and key is nil +---https://stackoverflow.com/questions/42681501/how-do-you-make-a-string-dictionary-function-in-lua +---@param word string +---@param wordlist table +---@param threshold number +---@return any key +---@return any result +---@return number distance function NetworkUtils.getClosestWord(word, wordlist, threshold) - if word == nil or word == "" then return word end + if word == nil or word == "" then + return word, nil, -1 + end threshold = threshold or 3 local function min(a, b, c) return math.min(math.min(a, b), c) end local function matrix(row, col) local m = {} - for i = 1,row do + for i = 1, row do m[i] = {} - for j = 1,col do m[i][j] = 0 end + for j = 1, col do + m[i][j] = 0 + end end return m end @@ -75,18 +85,22 @@ function NetworkUtils.getClosestWord(word, wordlist, threshold) end return M[row][col] end - local closestDistance = -1 + local closestDistance = 9999999 local closestWordKey for key, val in pairs(wordlist) do local levRes = lev(word, val) - if levRes < closestDistance or closestDistance == -1 then + if levRes < closestDistance then closestDistance = levRes closestWordKey = key + if closestDistance == 0 then -- exact match + break + end end end - if closestDistance <= threshold then return closestWordKey, wordlist[closestWordKey] - else return nil, word + if closestDistance <= threshold then + return closestWordKey, wordlist[closestWordKey], closestDistance end + return nil, word, closestDistance end --- Alters the string by changing the first character of each word to uppercase From 70754b2d8892d4bb73ce0b2aee8bd814400dfc35 Mon Sep 17 00:00:00 2001 From: UTDZac Date: Wed, 25 Sep 2024 11:27:20 -0700 Subject: [PATCH 19/26] Add proper error catching and output if failure Previously, any internal error would simply be caught and ignored, resulting in no output message returned with the response. Fixed to report that an error occurred, hopefully preventing subsequent similar requests, while also outputting useful error debug info to the console. --- ironmon_tracker/network/RequestHandler.lua | 74 ++++++++++++++-------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/ironmon_tracker/network/RequestHandler.lua b/ironmon_tracker/network/RequestHandler.lua index c9d746bf..643ca7c1 100644 --- a/ironmon_tracker/network/RequestHandler.lua +++ b/ironmon_tracker/network/RequestHandler.lua @@ -2,6 +2,7 @@ RequestHandler = { Requests = {}, -- A list of all known requests that still need to be processed Responses = {}, -- A list of all responses ready to be sent lastSaveTime = 0, + printEventErrors = true, SAVE_FREQUENCY = 60, -- Number of seconds to wait before saving Requests data to file REQUIRES_MESSAGE_CAP = false, -- Will shorten outgoing responses to message cap (not needed anymore, done on Streamerbot side) TWITCH_MESSAGE_CAP = 499, -- Maximum # of characters to allow for a given response message @@ -229,11 +230,7 @@ function RequestHandler.processAllRequests() for _, request in ipairs(toProcess) do for _, eventKey in pairs(MiscUtils.split(request.EventKey, ",", true) or {}) do local event = EventHandler.Events[eventKey] - local response - -- Wrap request processing in error catch call. If fail, no response is returned and request is removed - pcall(function() - response = RequestHandler.processAndBuildResponse(request, event) - end) + local response = RequestHandler.processAndBuildResponse(request, event) if not request.SentResponse then RequestHandler.addUpdateResponse(response) request.SentResponse = true @@ -288,32 +285,59 @@ function RequestHandler.processAndBuildResponse(request, event) return response end - -- Process the request and see if it's ready to be fulfilled - local readyToFulfill = not event.Process or event:Process(request) - if not readyToFulfill then - response.StatusCode = RequestHandler.StatusCodes.PROCESSING + local function onError(err) + -- not really success, but allows sending a response message + response.StatusCode = RequestHandler.StatusCodes.SUCCESS + response.Message = string.format("%s > Internal error executing '%s' event.", + response.EventKey, + event.Type + ) + request.SentResponse = false + if RequestHandler.printEventErrors then + print(string.format("[ERROR] %s\nParams: %s\nError: %s", + response.Message, + request.SanitizedInput, + tostring(err) + )) + end + end + + local readyToFulfill = false + -- Safely process and fulfill request. If any error occurs, report back the request failed + xpcall(function() + -- Process the request and see if it's ready to be fulfilled + readyToFulfill = not event.Process or event:Process(request) + if not readyToFulfill then + response.StatusCode = RequestHandler.StatusCodes.PROCESSING + return response + end + end, onError) + + if not readyToFulfill or response.StatusCode == RequestHandler.StatusCodes.SUCCESS then return response end - -- Complete the request and determine the output information to send back - local result = event.Fulfill and event:Fulfill(request) or "" - if type(result) == "string" then - response.Message = RequestHandler.validateMessage(result, request.Platform) - elseif type(result) == "table" then - response.Message = RequestHandler.validateMessage(result.Message, request.Platform) - if type(result.AdditionalInfo) == "table" then - response.AdditionalInfo = response.AdditionalInfo or {} - for k, v in pairs(result.AdditionalInfo) do - response.AdditionalInfo[k] = v + xpcall(function() + -- Complete the request and determine the output information to send back + local result = event.Fulfill and event:Fulfill(request) or "" + if type(result) == "string" then + response.Message = RequestHandler.validateMessage(result, request.Platform) + elseif type(result) == "table" then + response.Message = RequestHandler.validateMessage(result.Message, request.Platform) + if type(result.AdditionalInfo) == "table" then + response.AdditionalInfo = response.AdditionalInfo or {} + for k, v in pairs(result.AdditionalInfo) do + response.AdditionalInfo[k] = v + end end - end - if type(result.GlobalVars) == "table" then - response.GlobalVars = response.GlobalVars or {} - for k, v in pairs(result.GlobalVars) do - response.GlobalVars[k] = v + if type(result.GlobalVars) == "table" then + response.GlobalVars = response.GlobalVars or {} + for k, v in pairs(result.GlobalVars) do + response.GlobalVars[k] = v + end end end - end + end, onError) response.StatusCode = RequestHandler.StatusCodes.SUCCESS request.SentResponse = false From 0602aacdde7f3453ccdd660d28332ecc4d8d89dd Mon Sep 17 00:00:00 2001 From: UTDZac Date: Wed, 25 Sep 2024 11:34:28 -0700 Subject: [PATCH 20/26] Fix nil custom role box for role permissions --- ironmon_tracker/network/Network.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ironmon_tracker/network/Network.lua b/ironmon_tracker/network/Network.lua index a9319fac..feb4f352 100644 --- a/ironmon_tracker/network/Network.lua +++ b/ironmon_tracker/network/Network.lua @@ -584,7 +584,9 @@ function Network.openCommandRolePermissionsPrompt() for _, roleKey in ipairs(orderedRoles) do forms.setproperty(roleCheckboxes[roleKey], "Checked", true) end - forms.settext(customRoleTextbox, "") + if customRoleTextbox then + forms.settext(customRoleTextbox, "") + end enableDisableAll() end, 120, buttonRowY) local btn3 = forms.button(form, "Cancel", function() From 13f8b1b25439106721582e00e669878de18521b2 Mon Sep 17 00:00:00 2001 From: UTDZac Date: Wed, 25 Sep 2024 14:16:34 -0700 Subject: [PATCH 21/26] Update Stream Connect HELP to point to new wiki --- ironmon_tracker/ui/StreamerbotConfigScreen.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ironmon_tracker/ui/StreamerbotConfigScreen.lua b/ironmon_tracker/ui/StreamerbotConfigScreen.lua index 3a207163..98a987de 100644 --- a/ironmon_tracker/ui/StreamerbotConfigScreen.lua +++ b/ironmon_tracker/ui/StreamerbotConfigScreen.lua @@ -38,7 +38,7 @@ local function StreamerbotConfigScreen(initialSettings, initialTracker, initialP end local function onHelpClick() - local helpURL = "https://github.com/besteon/Ironmon-Tracker/wiki/Stream-Connect-Guide" + local helpURL = "https://github.com/Brian0255/NDS-Ironmon-Tracker/wiki/Stream-Connect-Guide" os.execute(string.format('start "" "%s"', helpURL)) end From c62172f9466c05856265e69a53c2c957fdc5b2a0 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 22:13:30 -0400 Subject: [PATCH 22/26] for gen 5, try to end the run only when the UI hp actually reaches 0 --- ironmon_tracker/BattleHandlerBase.lua | 11 +++++-- ironmon_tracker/BattleHandlerGen5.lua | 33 +++++++++++++++++++ ironmon_tracker/Program.lua | 16 ++++----- ironmon_tracker/constants/GameInfo.lua | 12 ++++--- ironmon_tracker/constants/MemoryAddresses.lua | 12 ++++--- 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/ironmon_tracker/BattleHandlerBase.lua b/ironmon_tracker/BattleHandlerBase.lua index 7eafc140..b737e83e 100644 --- a/ironmon_tracker/BattleHandlerBase.lua +++ b/ironmon_tracker/BattleHandlerBase.lua @@ -219,8 +219,12 @@ function BattleHandlerBase:_playerSlotHasFainted(slotIndex) end end +function BattleHandlerBase:_onPlayerSlotFainted() + self._program.onRunEnded() +end + function BattleHandlerBase:checkIfRunHasEnded() - if not self:inBattleAndFetched() then + if not self:inBattleAndFetched() or self._program.hasRunEnded() then return end if @@ -238,7 +242,7 @@ function BattleHandlerBase:checkIfRunHasEnded() end end if self:_playerSlotHasFainted(self._faintMonIndex) then - self._program.onRunEnded() + self:_onPlayerSlotFainted() end end @@ -427,7 +431,8 @@ function BattleHandlerBase:getActivePokemonInBattle(selected) end function BattleHandlerBase:runEvents() - for _, frameCounter in pairs(self._frameCounters) do + --shallow copy in the rare instance a frame counter is added when one is removed + for _, frameCounter in pairs(MiscUtils.shallowCopy(self._frameCounters)) do frameCounter.decrement() end for _, listener in pairs(self._joypadEvents) do diff --git a/ironmon_tracker/BattleHandlerGen5.lua b/ironmon_tracker/BattleHandlerGen5.lua index 6606d5b6..8ce4f64d 100644 --- a/ironmon_tracker/BattleHandlerGen5.lua +++ b/ironmon_tracker/BattleHandlerGen5.lua @@ -5,11 +5,15 @@ function BattleHandlerGen5:_setUpBattleVariables() self:_baseSetUpBattleVariables() local battleData = self:getBattleData() self._battlerAmount = 0 + self._playerReallyFainted = false + self._waitedForUIHP = false + self._framesWaitedForUI = 0 end function BattleHandlerGen5:new(o, gameInfo, memoryAddresses, pokemonDataReader, tracker, program, settings) self = BattleHandlerBase.new(self, o, gameInfo, memoryAddresses, pokemonDataReader, tracker, program, settings) self._activeSlotsInBattle = 0 + self._playerReallyFainted = false self.DOUBLE_TRIPLE_FLAG_TO_BATTLER_AMOUNT = { [0] = 1, [1] = 2, @@ -27,9 +31,30 @@ function BattleHandlerGen5:new(o, gameInfo, memoryAddresses, pokemonDataReader, } self._battlerAmount = 0 self._playerPartyPointers = {} + self._framesWaitedForUI = 0 return self end +function BattleHandlerGen5._checkUIHPZero(self) + self._framesWaitedForUI = self._framesWaitedForUI + 1 + local addressStart = Memory.read_pointer(self.memoryAddresses.someBattleUIPtr) + local UIHPAddress = addressStart + self._gameInfo.UI_HP_OFFSET + local HPValue = Memory.read_u8(UIHPAddress) + if HPValue == 0 or self._framesWaitedForUI >= 480 then + self._program.onRunEnded() + self:removeFrameCounter("checkUIHPZero") + end +end + +function BattleHandlerGen5._onBattleUIHPTick(self) + self:removeFrameCounter("battleUIHPTickStart") + self:addFrameCounter("checkUIHPZero", FrameCounter(1, self._checkUIHPZero, self)) +end + +function BattleHandlerGen5:_onPlayerSlotFainted() + self:addFrameCounter("battleUIHPTickStart",FrameCounter(3, self._onBattleUIHPTick, self)) +end + function BattleHandlerGen5:_readAmountOfBattlers() --more than 2 mean multi-player double/triple local currentPointer = self.memoryAddresses.mainBattleDataPtr + 0x18 @@ -55,6 +80,14 @@ function BattleHandlerGen5:_addBattlerSlot(battlerSlots, slot, battleDataPtr, ab } end +--called when pokemon HP is at 0, but UI needs to also read as 0 +function BattleHandlerGen5:_playerReallyFainted() + --here is how it works: + --internal pokemon HP is set to 0, then the UI slowly updates to match it + --it takes a few frames for the UI to initially start ticking (it is 0 otherwise) + --so we wait a few frames then start checking the UI every frame until it's 0 (or too many frames have passed) +end + function BattleHandlerGen5:_readBattleDataPtr(currentBattleDataPtr, amount, isEnemy, advanceAmount, abilityTriggerStart) local battleData = self:_getCorrectBattleData(isEnemy) local endPoint = currentBattleDataPtr + ((amount - 1) * advanceAmount) diff --git a/ironmon_tracker/Program.lua b/ironmon_tracker/Program.lua index 18a19755..5c33a71b 100644 --- a/ironmon_tracker/Program.lua +++ b/ironmon_tracker/Program.lua @@ -409,9 +409,13 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, return totals end - local function displayRunOver() - self.openScreen(self.UI_SCREENS.RUN_OVER_SCREEN) - frameCounters["displayRunOver"] = nil + local function displayRunOver() + self.openScreen(self.UI_SCREENS.RUN_OVER_SCREEN) + frameCounters["displayRunOver"] = nil + end + + function self.hasRunEnded() + return tracker.hasRunEnded() end function self.onRunEnded() @@ -435,11 +439,7 @@ local function Program(initialTracker, initialMemoryAddresses, initialGameInfo, tracker.updatePlaytime(gameInfo.NAME) local runOverMessage = seedLogger.getRandomRunOverMessage(pastRun) self.UI_SCREEN_OBJECTS[self.UI_SCREENS.RUN_OVER_SCREEN].initialize(runOverMessage) - if gameInfo.GEN == 5 then - frameCounters["displayRunOver"] = FrameCounter(60, displayRunOver) - else - self.openScreen(self.UI_SCREENS.RUN_OVER_SCREEN) - end + self.openScreen(self.UI_SCREENS.RUN_OVER_SCREEN) tracker.setRunOver() end diff --git a/ironmon_tracker/constants/GameInfo.lua b/ironmon_tracker/constants/GameInfo.lua index 7d3d4145..20e8d21f 100644 --- a/ironmon_tracker/constants/GameInfo.lua +++ b/ironmon_tracker/constants/GameInfo.lua @@ -109,7 +109,8 @@ GameInfo.GAME_INFO = { PIVOT_TYPES = { ["Grass/Cave"] = true, ["Doubles Grass"] = true - } + }, + UI_HP_OFFSET = 0x73D }, [GameInfo.VERSION_NUMBER.WHITE] = { GEN = 5, @@ -125,7 +126,8 @@ GameInfo.GAME_INFO = { PIVOT_TYPES = { ["Grass/Cave"] = true, ["Doubles Grass"] = true - } + }, + UI_HP_OFFSET = 0x73D }, [GameInfo.VERSION_NUMBER.BLACK2] = { GEN = 5, @@ -143,7 +145,8 @@ GameInfo.GAME_INFO = { ["Int. Grass"] = true, ["Grass/Cave"] = true, ["Doubles Grass"] = true - } + }, + UI_HP_OFFSET = 0x91 }, [GameInfo.VERSION_NUMBER.WHITE2] = { GEN = 5, @@ -159,6 +162,7 @@ GameInfo.GAME_INFO = { PIVOT_TYPES = { ["Grass/Cave"] = true, ["Doubles Grass"] = true - } + }, + UI_HP_OFFSET = 0x91 } } diff --git a/ironmon_tracker/constants/MemoryAddresses.lua b/ironmon_tracker/constants/MemoryAddresses.lua index 056e70da..6cf28ca7 100644 --- a/ironmon_tracker/constants/MemoryAddresses.lua +++ b/ironmon_tracker/constants/MemoryAddresses.lua @@ -201,7 +201,8 @@ MemoryAddresses[GameInfo.VERSION_NUMBER.BLACK] = { mapNPCIDStart = 0x2521EC, abilityTriggerStart = 0x2A6354, mainBattleDataPtr = 0x269838, - doubleTripleFlag = 0x2A62F8 + doubleTripleFlag = 0x2A62F8, + someBattleUIPtr = 0x294854 } } @@ -233,7 +234,8 @@ MemoryAddresses[GameInfo.VERSION_NUMBER.WHITE] = { mapNPCIDStart = 0x2521EC + 0x20, abilityTriggerStart = 0x2A6354 + 0x20, mainBattleDataPtr = 0x269838 + 0x20, - doubleTripleFlag = 0x2A62F8 + 0x20 + doubleTripleFlag = 0x2A62F8 + 0x20, + someBattleUIPtr = 0x294854 + 0x20 } } @@ -264,7 +266,8 @@ MemoryAddresses[GameInfo.VERSION_NUMBER.BLACK2] = { mapNPCIDStart = 0x23D9EC, abilityTriggerStart = 0x294E08, mainBattleDataPtr = 0x2573AC, - doubleTripleFlag = 0x294DA4 + doubleTripleFlag = 0x294DA4, + someBattleUIPtr = 0x294D8C } } @@ -295,7 +298,8 @@ MemoryAddresses[GameInfo.VERSION_NUMBER.WHITE2] = { mapNPCIDStart = 0x23D9EC + 0x80, abilityTriggerStart = 0x294E08 + 0x80, mainBattleDataPtr = 0x2573AC + 0x80, - doubleTripleFlag = 0x294DA4 + 0x80 + doubleTripleFlag = 0x294DA4 + 0x80, + someBattleUIPtr = 0x294D8C + 0x80 } } From 29dc77a48cf64485b0b30f152f4914015df41560 Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Wed, 25 Sep 2024 22:29:22 -0400 Subject: [PATCH 23/26] remove unused functions and move comments --- ironmon_tracker/BattleHandlerGen5.lua | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/ironmon_tracker/BattleHandlerGen5.lua b/ironmon_tracker/BattleHandlerGen5.lua index 8ce4f64d..ea0707d9 100644 --- a/ironmon_tracker/BattleHandlerGen5.lua +++ b/ironmon_tracker/BattleHandlerGen5.lua @@ -5,7 +5,6 @@ function BattleHandlerGen5:_setUpBattleVariables() self:_baseSetUpBattleVariables() local battleData = self:getBattleData() self._battlerAmount = 0 - self._playerReallyFainted = false self._waitedForUIHP = false self._framesWaitedForUI = 0 end @@ -13,7 +12,6 @@ end function BattleHandlerGen5:new(o, gameInfo, memoryAddresses, pokemonDataReader, tracker, program, settings) self = BattleHandlerBase.new(self, o, gameInfo, memoryAddresses, pokemonDataReader, tracker, program, settings) self._activeSlotsInBattle = 0 - self._playerReallyFainted = false self.DOUBLE_TRIPLE_FLAG_TO_BATTLER_AMOUNT = { [0] = 1, [1] = 2, @@ -52,6 +50,10 @@ function BattleHandlerGen5._onBattleUIHPTick(self) end function BattleHandlerGen5:_onPlayerSlotFainted() + --here is how it works: + --internal pokemon HP is set to 0, then the UI slowly updates to match it + --it takes a few frames for the UI to initially start ticking (it is 0 otherwise) + --so we wait a few frames then start checking the UI every frame until it's 0 (or too many frames have passed) self:addFrameCounter("battleUIHPTickStart",FrameCounter(3, self._onBattleUIHPTick, self)) end @@ -80,14 +82,6 @@ function BattleHandlerGen5:_addBattlerSlot(battlerSlots, slot, battleDataPtr, ab } end ---called when pokemon HP is at 0, but UI needs to also read as 0 -function BattleHandlerGen5:_playerReallyFainted() - --here is how it works: - --internal pokemon HP is set to 0, then the UI slowly updates to match it - --it takes a few frames for the UI to initially start ticking (it is 0 otherwise) - --so we wait a few frames then start checking the UI every frame until it's 0 (or too many frames have passed) -end - function BattleHandlerGen5:_readBattleDataPtr(currentBattleDataPtr, amount, isEnemy, advanceAmount, abilityTriggerStart) local battleData = self:_getCorrectBattleData(isEnemy) local endPoint = currentBattleDataPtr + ((amount - 1) * advanceAmount) From 214ff27edf520b47f1f7aa8c7941906fdd317893 Mon Sep 17 00:00:00 2001 From: UTDZac Date: Thu, 26 Sep 2024 14:04:15 -0700 Subject: [PATCH 24/26] Fix network data override requiring "data" folder If the user provided an override for the connection data folder via streamerbot action, it also required a "data" subfolder. This is not required by any means, so removing this just keeps things cleaner --- ironmon_tracker/network/StreamerbotCodeImport.txt | 2 +- ironmon_tracker/network/Tracker-StreamerBot.cs | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ironmon_tracker/network/StreamerbotCodeImport.txt b/ironmon_tracker/network/StreamerbotCodeImport.txt index 7f6a8e2b..9150a113 100644 --- a/ironmon_tracker/network/StreamerbotCodeImport.txt +++ b/ironmon_tracker/network/StreamerbotCodeImport.txt @@ -1 +1 @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/ironmon_tracker/network/Tracker-StreamerBot.cs b/ironmon_tracker/network/Tracker-StreamerBot.cs index 35668e9c..3ea2547d 100644 --- a/ironmon_tracker/network/Tracker-StreamerBot.cs +++ b/ironmon_tracker/network/Tracker-StreamerBot.cs @@ -534,11 +534,17 @@ private void VerifyOrCreateDataFiles() // Check first if the user has defined alternative data folder var directoryOverride = CPH.GetGlobalVar(GVAR_ConnectionDataFolder, true); if (!string.IsNullOrEmpty(directoryOverride) && !directoryOverride.Equals("NONE") && Directory.Exists(directoryOverride)) - dataDirectory = directoryOverride; + { + _inboundFile = Path.Combine(directoryOverride, INBOUND_FILENAME); // Responses (incoming) + _outboundFile = Path.Combine(directoryOverride, OUTBOUND_FILENAME); // Requests (outgoing) + } + else + { + _inboundFile = Path.Combine(dataDirectory, DATA_FOLDER, INBOUND_FILENAME); // Responses (incoming) + _outboundFile = Path.Combine(dataDirectory, DATA_FOLDER, OUTBOUND_FILENAME); // Requests (outgoing) + } // Create required inbound/outbound files - _inboundFile = Path.Combine(dataDirectory, DATA_FOLDER, INBOUND_FILENAME); // Responses (incoming) - _outboundFile = Path.Combine(dataDirectory, DATA_FOLDER, OUTBOUND_FILENAME); // Requests (outgoing) using(StreamWriter sw = File.AppendText(_inboundFile)){}; using(StreamWriter sw = File.AppendText(_outboundFile)){}; } catch (Exception e) {} From aa42eedad235ebe6af8f3fd4b76023b3be8bcbef Mon Sep 17 00:00:00 2001 From: Brian0255 Date: Thu, 26 Sep 2024 17:34:44 -0400 Subject: [PATCH 25/26] notes --- ironmon_tracker/constants/MiscConstants.lua | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ironmon_tracker/constants/MiscConstants.lua b/ironmon_tracker/constants/MiscConstants.lua index 42c9a1b7..f3fbb0bf 100644 --- a/ironmon_tracker/constants/MiscConstants.lua +++ b/ironmon_tracker/constants/MiscConstants.lua @@ -1,13 +1,14 @@ MiscConstants = {} -MiscConstants.TRACKER_VERSION = "6.2.7" +MiscConstants.TRACKER_VERSION = "6.3.0" MiscConstants.BIZHAWK_VERSION = client.getversion() MiscConstants.UPDATE_NOTES = { - "-- Fixed incorrect Route 204 Old Rod data in the Log Viewer.", - "-- Since the following abilities in Gen 4 were often revealing when they shouldn't be, they will not autotrack for the time being: Limber, Oblivious, Insomnia, Own Tempo, Magma Armor, Water Veil, and Vital Spirit.", - "-- If you see this happen for any other abilities not listed above, please let me know!" + "-- UTDZac has ported over a lightweight version of the GBA tracker's Stream Connect! You can set this up in the Extras menu.", + "-- The ominous freezing from the tracker saving large amounts of data should now be much less.", + "-- For Gen 5, the tracker should now only give you the Death Lag when your HP actually reaches 0.", + "-- Fixed a rare log viewer bug for Black/White 2." } MiscConstants.DEFAULT_SETTINGS = { From 0aa45c47f2b4acf5bf4245c0de261d506868a8fd Mon Sep 17 00:00:00 2001 From: UTDZac Date: Thu, 26 Sep 2024 14:53:27 -0700 Subject: [PATCH 26/26] Update Network.lua --- ironmon_tracker/network/Network.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ironmon_tracker/network/Network.lua b/ironmon_tracker/network/Network.lua index feb4f352..53be8273 100644 --- a/ironmon_tracker/network/Network.lua +++ b/ironmon_tracker/network/Network.lua @@ -435,6 +435,9 @@ function Network.openUpdateRequiredPrompt() local form = forms.newform(350, 150, "Streamerbot Update Required", function() client.unpause() end) + local clientCenter = FormsUtils.getCenter(350, 150) + forms.setlocation(form, clientCenter.xPos, clientCenter.yPos) + local x, y, lineHeight = 20, 20, 20 local lb1 = forms.label(form, "Streamerbot Tracker Integration code requires an update.", x, y) y = y + lineHeight @@ -468,6 +471,9 @@ function Network.openGetCodeWindow() local form = forms.newform(800, 600, "Import to Streamerbot", function() client.unpause() end) + local clientCenter = FormsUtils.getCenter(800, 600) + forms.setlocation(form, clientCenter.xPos, clientCenter.yPos) + local x, y, lineHeight = 20, 15, 20 local lb1 = forms.label(form, '1. On Streamerbot, click the IMPORT button at the top.', x, y) y = y + lineHeight @@ -498,6 +504,8 @@ function Network.openCommandRolePermissionsPrompt() local form = forms.newform(320, 255, "Edit Command Roles", function() client.unpause() end) + local clientCenter = FormsUtils.getCenter(320, 255) + forms.setlocation(form, clientCenter.xPos, clientCenter.yPos) local x, y = 20, 15 local lineHeight = 21