diff --git a/GWToolboxdll/CMakeLists.txt b/GWToolboxdll/CMakeLists.txt index c3e7a0614..da79510b4 100644 --- a/GWToolboxdll/CMakeLists.txt +++ b/GWToolboxdll/CMakeLists.txt @@ -159,6 +159,8 @@ file(GLOB SOURCES CONFIGURE_DEPENDS "Windows/*.cpp" "Windows/Hotkeys/*.h" "Windows/Hotkeys/*.cpp" + "Windows/Splits/*.h" + "Windows/Splits/*.cpp" "Windows/Pathfinding/*.h" "Windows/Pathfinding/*.hpp" "Windows/Pathfinding/*.cpp" diff --git a/GWToolboxdll/Modules/ChatCommands.cpp b/GWToolboxdll/Modules/ChatCommands.cpp index 526e9254e..6aa22d87d 100644 --- a/GWToolboxdll/Modules/ChatCommands.cpp +++ b/GWToolboxdll/Modules/ChatCommands.cpp @@ -1997,6 +1997,7 @@ void ChatCommands::Initialize() {L"xunlai", CmdChest}, {L"useskill", CmdUseSkill}, {L"scwiki", CmdSCWiki}, + {L"wiki", CmdWiki}, {L"load", CmdLoad}, {L"pingbuild", CmdPingBuild}, {L"quest", CmdPingQuest}, @@ -2706,6 +2707,25 @@ void CHAT_CMD_FUNC(ChatCommands::CmdSCWiki) } } +// Opens a page on the official wiki via the client's own Game_integration link +// handler (kOpenWikiUrl) rather than ShellExecute — same mechanism the skill +// listing's "Wiki" button uses. Multiple args are joined with '_' so a page +// title typed with real spaces (MediaWiki convention) still resolves. +void CHAT_CMD_FUNC(ChatCommands::CmdWiki) +{ + std::string page = "Main_Page"; + if (argc > 1) { + page.clear(); + for (int i = 1; i < argc; i++) { + if (i > 1) page += "_"; + page += TextUtils::WStringToString(argv[i]); + } + } + char url[256]; + snprintf(url, _countof(url), "https://wiki.guildwars.com/wiki/%s", page.c_str()); + GW::UI::SendUIMessage(GW::UI::UIMessage::kOpenWikiUrl, url); +} + void CHAT_CMD_FUNC(ChatCommands::CmdLoad) { if (argc == 1) return; diff --git a/GWToolboxdll/Modules/ChatCommands.h b/GWToolboxdll/Modules/ChatCommands.h index 99543fa14..16fe51140 100644 --- a/GWToolboxdll/Modules/ChatCommands.h +++ b/GWToolboxdll/Modules/ChatCommands.h @@ -69,6 +69,7 @@ class ChatCommands : public ToolboxModule { static void CHAT_CMD_FUNC(CmdToggle); static void CHAT_CMD_FUNC(CmdCamera); static void CHAT_CMD_FUNC(CmdSCWiki); + static void CHAT_CMD_FUNC(CmdWiki); static void CHAT_CMD_FUNC(CmdLoad); static void CHAT_CMD_FUNC(CmdPingBuild); static void CHAT_CMD_FUNC(CmdResize); diff --git a/GWToolboxdll/Modules/ToolboxSettings.cpp b/GWToolboxdll/Modules/ToolboxSettings.cpp index 01482a577..858525255 100644 --- a/GWToolboxdll/Modules/ToolboxSettings.cpp +++ b/GWToolboxdll/Modules/ToolboxSettings.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -75,6 +76,7 @@ #include #include #include +#include #include #include @@ -239,7 +241,9 @@ namespace { MaterialsWindow::Instance(), TradeWindow::Instance(), NotePadWindow::Instance(), + WebSocketModule::Instance(), ObjectiveTimerWindow::Instance(), + SplitsWindow::Instance(), FactionLeaderboardWindow::Instance(), DailyQuests::Instance(), FriendListWindow::Instance(), diff --git a/GWToolboxdll/Modules/WebSocketModule.cpp b/GWToolboxdll/Modules/WebSocketModule.cpp new file mode 100644 index 000000000..89e937674 --- /dev/null +++ b/GWToolboxdll/Modules/WebSocketModule.cpp @@ -0,0 +1,157 @@ +#include "stdafx.h" +#include "WebSocketModule.h" + +#include +#include +#include + +void WebSocketModule::Initialize() +{ + ToolboxModule::Initialize(); +} + +void WebSocketModule::Terminate() +{ + EnableServer(false); + ToolboxModule::Terminate(); +} + +void WebSocketModule::RegisterSettingsContent() +{ + // No section of our own — "Splits" already registers its own icon, so pass nullptr rather than risk the icon-mismatch assert. + ToolboxModule::RegisterSettingsContent( + "Splits", nullptr, + [this](const std::string&, const bool is_showing) { + if (is_showing) DrawSettings(); + }, + 0.1f); +} + +void WebSocketModule::LoadSettings(SettingsDoc& doc, ToolboxIni*) +{ + auto stored_mode = static_cast(mode_); + doc.Get(Name(), "mode", stored_mode); + if (stored_mode < 0 || stored_mode >= static_cast(Mode::Count)) + stored_mode = static_cast(Mode::None); + mode_ = static_cast(stored_mode); + doc.Get(Name(), "port", port_); + if (port_ <= 0) port_ = 9002; + EnableServer(mode_ != Mode::None); +} + +void WebSocketModule::SaveSettings(SettingsDoc& doc) +{ + doc.Set(Name(), "mode", static_cast(mode_)); + doc.Set(Name(), "port", port_); +} + +void WebSocketModule::EnableServer(const bool enable) +{ + port_ = std::max(port_, 0); + if (!enable) { + uWS::App* app = app_.exchange(nullptr); + uWS::Loop* loop = loop_.exchange(nullptr); + if (app && loop) { + // close()/delete must run on the loop's own thread — defer() is uWS's documented + // thread-safe handoff. This is what lets run() return so the thread can be joined below. + loop->defer([app]() { + app->close(); + delete app; + }); + } + if (server_thread_) { + ASSERT(server_thread_->joinable()); + server_thread_->join(); + delete server_thread_; + server_thread_ = nullptr; + } + return; + } + + if (server_thread_) return; + EnableServer(false); + const int port = port_; + server_thread_ = new std::thread([this, port]() { + // The app/loop need to be created in the thread that handles the websocket connections. + uWS::App* app = new uWS::App(); + loop_ = uWS::Loop::get(); + app_ = app; + app->ws( + "/*", + {/* Settings */ + .compression = uWS::SHARED_COMPRESSOR, + .maxPayloadLength = 16 * 1024, + .idleTimeout = 10, + .maxBackpressure = 1 * 1024 * 1024, + .sendPingsAutomatically = true, + /* Handlers */ + .upgrade = nullptr, + .open = [](auto ws) { ws->subscribe("objective_events"); }} + ) + .listen(port, + [port](auto* listen_socket) { + if (listen_socket) { + Log::Log("WebSocketModule listening on port %d", port); + } + }) + .run(); + }); +} + +void WebSocketModule::Send(const std::string_view msg, const std::string_view context) +{ + if (!context.empty()) last_command_ = std::string(context); + uWS::App* app = app_; + uWS::Loop* loop = loop_; + if (!app || !loop) return; + // publish() isn't safe to call off the loop's own thread — defer() marshals it over rather + // than calling app->publish() directly here on the main/game thread (this used to race with + // server_thread_; same issue OT's own inline version has always had, now fixed here). + std::string payload = mode_ == Mode::LiveSplitOneJSON + ? "{\"command\": \"" + std::string(msg) + "\"}" + : std::string(msg); + loop->defer([app, payload = std::move(payload)]() { + app->publish("objective_events", payload, uWS::OpCode::TEXT); + }); +} + +void WebSocketModule::DrawSettings() +{ + ImGui::Separator(); + bool enabled = mode_ != Mode::None; + if (ImGui::Checkbox("Enable LiveSplit websocket server", &enabled)) { + mode_ = enabled ? Mode::LiveSplitOneJSON : Mode::None; + EnableServer(enabled); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Broadcasts Start/Split/Reset commands to a connected LiveSplit instance.\n" + "Independent of ObjectiveTimerWindow's own websocket server (different port) —\n" + "safe to run both, or use this one alone once you no longer need OT."); + } + if (enabled) { + ImGui::Indent(); + if (ImGui::InputInt("Websocket server port", &port_)) { + EnableServer(false); + EnableServer(enabled); + } + ImGui::Text("Status: %s", app_ && server_thread_ ? "Running" : "Stopped"); + if (app_ && server_thread_) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "(Port %d)", port_); + } + if (ImGui::SmallButton("Restart")) { + EnableServer(false); + EnableServer(enabled); + } + ImGui::RadioButton("LiveSplit One JSON Format", reinterpret_cast(&mode_), static_cast(Mode::LiveSplitOneJSON)); + ImGui::RadioButton("LiveSplit Server Command Format", reinterpret_cast(&mode_), static_cast(Mode::LiveSplitServerCommand)); + // Safe: Mode is explicitly int-backed (see enum declaration), so this isn't punning across sizes/signedness. + ImGui::Spacing(); + if (last_command_.empty()) + ImGui::TextDisabled("Last command: none"); + else + ImGui::Text("Last command: %s", last_command_.c_str()); + ImGui::Unindent(); + } +} diff --git a/GWToolboxdll/Modules/WebSocketModule.h b/GWToolboxdll/Modules/WebSocketModule.h new file mode 100644 index 000000000..3afc38d17 --- /dev/null +++ b/GWToolboxdll/Modules/WebSocketModule.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include + +// --------------------------------------------------------------------------- +// Standalone LiveSplit-compatible websocket broadcaster. +// --------------------------------------------------------------------------- +// Split out of ObjectiveTimerWindow so Splits doesn't depend on OT; runs on its own port +// (9002, OT stays on 9001) so both can coexist until OT is eventually retired. +// +// No settings/window/widget of its own — enable/port/mode render inside Splits' own settings page instead (see RegisterSettingsContent()). +class WebSocketModule : public ToolboxModule { + WebSocketModule() = default; + ~WebSocketModule() override = default; + +public: + static WebSocketModule& Instance() + { + static WebSocketModule instance; + return instance; + } + + [[nodiscard]] const char* Name() const override { return "WebSocket"; } + [[nodiscard]] bool HasSettings() override { return false; } + + void Initialize() override; + void Terminate() override; + void RegisterSettingsContent() override; + void LoadSettings(SettingsDoc& doc, ToolboxIni* legacy) override; + void SaveSettings(SettingsDoc& doc) override; + + // Broadcasts a LiveSplit command ("start"/"split"/"reset"); context is an optional reason, unused on the wire but shown in the debug log. + void Send(std::string_view msg, std::string_view context = {}); + +private: + enum class Mode : int { None, LiveSplitOneJSON, LiveSplitServerCommand, Count }; // int-backed so ImGui::RadioButton can bind an int* without a punning cast + + void EnableServer(bool enable); + void DrawSettings(); // rendered inside Splits' settings section, see RegisterSettingsContent() + + std::thread* server_thread_ = nullptr; + // app_/loop_ are written on server_thread_ at startup and read from the main thread (Send/DrawSettings/EnableServer) + // while the server thread is alive, so they're atomic; app_ methods themselves aren't thread-safe, so any actual + // call into the app (publish, close) must be marshalled onto loop_ via defer() rather than invoked directly. + std::atomic app_ = nullptr; + std::atomic loop_ = nullptr; + Mode mode_ = Mode::None; + int port_ = 9002; + std::string last_command_; // shown in DrawSettings() so live testing can confirm sends are happening +}; diff --git a/GWToolboxdll/Utils/TextUtils.cpp b/GWToolboxdll/Utils/TextUtils.cpp index ba6545917..1e6d24cd5 100644 --- a/GWToolboxdll/Utils/TextUtils.cpp +++ b/GWToolboxdll/Utils/TextUtils.cpp @@ -808,4 +808,13 @@ namespace TextUtils { { return ltrim(rtrim(s, t), t); } + + bool CaseInsensitiveContains(const std::string_view haystack, const std::string_view needle) + { + if (needle.empty()) return true; + const auto it = std::search(haystack.begin(), haystack.end(), + needle.begin(), needle.end(), + [](const char a, const char b) { return tolower(static_cast(a)) == tolower(static_cast(b)); }); + return it != haystack.end(); + } } diff --git a/GWToolboxdll/Utils/TextUtils.h b/GWToolboxdll/Utils/TextUtils.h index b8e418761..e556d5305 100644 --- a/GWToolboxdll/Utils/TextUtils.h +++ b/GWToolboxdll/Utils/TextUtils.h @@ -33,6 +33,9 @@ namespace TextUtils { std::wstring Replace(const std::wstring_view subject, const std::wstring& pattern, const std::wstring& replacement); std::string Replace(const std::string_view subject, const std::string& pattern, const std::string& replacement); + // Case-insensitive substring search (ASCII only); empty needle always matches. Shared so filter boxes don't each reimplement the same std::search + tolower comparator. + bool CaseInsensitiveContains(std::string_view haystack, std::string_view needle); + template std::basic_string Base64Decode(std::string_view encoded) { diff --git a/GWToolboxdll/Windows/Splits/GoalClock.cpp b/GWToolboxdll/Windows/Splits/GoalClock.cpp new file mode 100644 index 000000000..7e53eab77 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalClock.cpp @@ -0,0 +1,32 @@ +#include "stdafx.h" +#include "GoalClock.h" + +void GoalClock::Start() { running_ = true; } +void GoalClock::Pause() { running_ = false; } +void GoalClock::Resume() { running_ = true; } + +void GoalClock::Reset() +{ + running_ = false; + real_elapsed_ = 0.0; + game_elapsed_ = 0.0; +} + +void GoalClock::AddRealTime(double delta) +{ + if (running_ && delta > 0.0) + real_elapsed_ += delta; +} + +void GoalClock::AddGameTime(double delta) +{ + if (running_ && delta > 0.0) + game_elapsed_ += delta; +} + +void GoalClock::Restore(double real_elapsed, double game_elapsed) +{ + real_elapsed_ = real_elapsed; + game_elapsed_ = game_elapsed; + running_ = true; +} diff --git a/GWToolboxdll/Windows/Splits/GoalClock.h b/GWToolboxdll/Windows/Splits/GoalClock.h new file mode 100644 index 000000000..62a23ad1f --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalClock.h @@ -0,0 +1,30 @@ +#pragma once + +// --------------------------------------------------------------------------- +// GoalClock — two independent timers. +// +// real_time — raw wall-clock; never paused after Start(). +// game_time — all pause rules applied (loading, cinematic). +// --------------------------------------------------------------------------- +class GoalClock { +public: + void Start(); + void Pause(); + void Resume(); + void Reset(); + + void AddRealTime(double delta); + void AddGameTime(double delta); + + // Restore clock state (crash-protection resume). + void Restore(double real_elapsed, double game_elapsed); + + [[nodiscard]] bool IsRunning() const { return running_; } + [[nodiscard]] double RealTime() const { return real_elapsed_; } + [[nodiscard]] double GameTime() const { return game_elapsed_; } + +private: + bool running_ = false; + double real_elapsed_ = 0.0; + double game_elapsed_ = 0.0; +}; diff --git a/GWToolboxdll/Windows/Splits/GoalEngine.cpp b/GWToolboxdll/Windows/Splits/GoalEngine.cpp new file mode 100644 index 000000000..c9e6d43fe --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalEngine.cpp @@ -0,0 +1,546 @@ +#include "stdafx.h" +#include "GoalEngine.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include + +void GoalEngine::Attach(GoalList* list) +{ + list_ = list; + Reset(); + // Goals marked starts_immediately begin at t=0 of the run (like OT's SetStarted() at set creation). + if (list_) { + for (auto& g : list_->goals) { + if (g.is_header) continue; + if (g.starts_immediately) { + g.start_real_time = 0.0; + g.start_game_time = 0.0; + g.status = GoalStatus::Started; + } + } + } +} + +void GoalEngine::Detach() +{ + list_ = nullptr; + started_ = false; +} + +void GoalEngine::Reset() +{ + started_ = false; + prev_map_ = GW::Constants::MapID::None; + last_real_ = 0.0; + last_game_ = 0.0; + mission_complete_map_ = GW::Constants::MapID::None; + mission_bonus_map_ = GW::Constants::MapID::None; + vanquish_complete_map_ = GW::Constants::MapID::None; + pending_bonus_check_map_ = GW::Constants::MapID::None; + bonus_check_timer_ = 0.f; + primary_obj_id_ = 0; + pending_incomplete_rezone_ = false; + pending_wrong_map_entered_ = false; + pending_run_start_ = false; + if (list_) list_->ResetRunState(); +} + +bool GoalEngine::ConsumeWrongMapEntered() +{ + const bool v = pending_wrong_map_entered_; + pending_wrong_map_entered_ = false; + return v; +} + +bool GoalEngine::ConsumeIncompleteRezone() +{ + const bool v = pending_incomplete_rezone_; + pending_incomplete_rezone_ = false; + return v; +} + +void GoalEngine::NotifyMissionComplete(GW::Constants::MapID map) +{ + mission_complete_map_ = map; + pending_bonus_check_map_ = map; +} + +// Reads CompletionWindow's data, not raw WorldContext — reading raw catches false positives right off kMissionComplete. +// Delegates to CompletionWindow::IsAreaComplete rather than hand-indexing the mission/bonus bitsets: it already knows EotN has no bonus bit at all (has_bonus = campaign != EyeOfTheNorth), which the old inline version didn't account for. +void GoalEngine::CheckPendingMissionBonus(const float delta) +{ + if (pending_bonus_check_map_ == GW::Constants::MapID::None) return; + bonus_check_timer_ += delta; + if (bonus_check_timer_ < 1.0f) return; + bonus_check_timer_ = 0.f; + // Must match CompletionWindow's own lookup key (GetCharContext, not PlayerMgr::GetPlayerName) or this grabs the wrong character. + const auto* char_context = GW::GetCharContext(); + if (!char_context) return; + if (CompletionWindow::IsAreaComplete(char_context->player_name, pending_bonus_check_map_, CompletionCheck::NormalMode)) { + mission_bonus_map_ = pending_bonus_check_map_; + pending_bonus_check_map_ = GW::Constants::MapID::None; + } +} + +void GoalEngine::NotifyObjectiveAdd(uint32_t obj_id, uint32_t type_flags) +{ + // No BULLET bit (0x1) = base/primary objective; its ObjectiveDone synthesizes MissionComplete/Bonus in Update() (Prophecies path). + if (!(type_flags & 0x1)) + primary_obj_id_ = obj_id; +} + +void GoalEngine::NotifyVanquishComplete(GW::Constants::MapID map) +{ + vanquish_complete_map_ = map; +} + +void GoalEngine::NotifyEvent(GoalTrigger::Type type, uint32_t id1, uint32_t id2, + const wchar_t* str, size_t str_len) +{ + PendingEvent ev; + ev.type = type; + ev.id1 = id1; + ev.id2 = id2; + if (str && str_len > 0) + ev.str.assign(str, str_len); + pending_events_.push_back(std::move(ev)); +} + +int GoalEngine::Update(const GoalClock& clock, + GW::Constants::MapID current_map, + bool just_entered_map, + bool came_from_explorable, + bool is_explorable, + int player_level, + float delta) +{ + if (!list_ || list_->goals.empty()) { + prev_map_ = current_map; + mission_complete_map_ = GW::Constants::MapID::None; + mission_bonus_map_ = GW::Constants::MapID::None; + pending_bonus_check_map_ = GW::Constants::MapID::None; + pending_events_.clear(); + return 0; + } + + if (!started_ && just_entered_map) + started_ = true; + + int fired = 0; + + // True for trigger types that barrier Pass 2 (an unmet ordered goal stops later goals firing out of sequence); mission/bonus/title/objective-event types never block. + auto is_ordered = [](GoalTrigger::Type type) -> bool { + switch (type) { + case GoalTrigger::Type::MissionComplete: + case GoalTrigger::Type::MissionBonus: + case GoalTrigger::Type::ReachTitleRank: + case GoalTrigger::Type::ObjectiveDone: + case GoalTrigger::Type::DoorOpen: + case GoalTrigger::Type::DoorClose: + case GoalTrigger::Type::AgentUpdateAllegiance: + case GoalTrigger::Type::DoACompleteZone: + case GoalTrigger::Type::DungeonReward: + case GoalTrigger::Type::ServerMessage: + case GoalTrigger::Type::DisplayDialogue: + case GoalTrigger::Type::ObjectiveStarted: + case GoalTrigger::Type::QuestPickup: + case GoalTrigger::Type::QuestComplete: + case GoalTrigger::Type::MobKill: + return false; + default: + return true; + } + }; + + // Map-transition trigger types — shared by both wrong-turn checks below (Pass 2's own-trigger check and the post-Pass-2 start_trigger check) so the two can't drift apart as trigger types are added. + auto is_map_enter = [](GoalTrigger::Type type) -> bool { + return type == GoalTrigger::Type::EnterExplorable || type == GoalTrigger::Type::EnterOutpost; + }; + auto is_map_enter_or_exit = [&](GoalTrigger::Type type) -> bool { + return is_map_enter(type) || + type == GoalTrigger::Type::ExitExplorable || type == GoalTrigger::Type::ExitOutpost; + }; + + // Returns true when trigger 't' matches any event currently in pending_events_. + auto matchesPendingTrigger = [&](const GoalTrigger& tr) -> bool { + for (const auto& ev : pending_events_) { + if (ev.type != tr.type) continue; + switch (tr.type) { + case GoalTrigger::Type::DungeonReward: + return true; + case GoalTrigger::Type::AgentUpdateAllegiance: + if (ev.id1 == tr.param1 && ev.id2 == tr.param2) return true; + break; + case GoalTrigger::Type::ServerMessage: + case GoalTrigger::Type::DisplayDialogue: + if (!tr.pattern.empty() && ev.str.size() >= tr.pattern.size() && + ev.str.compare(0, tr.pattern.size(), tr.pattern) == 0) + return true; + break; + default: + if (ev.id1 == tr.param1) return true; + break; + } + } + return false; + }; + + // Same trigger value (e.g. two legs both starting on the same hub MapEnter) — used so a repeated trigger only arms the earliest pending goal per tick. + auto triggersEqual = [](const GoalTrigger& a, const GoalTrigger& b) { + return a.type == b.type && a.map_id == b.map_id && a.param1 == b.param1 && + a.param2 == b.param2 && a.level == b.level && a.title_id == b.title_id && + a.hard_mode == b.hard_mode && a.pattern == b.pattern; + }; + + if (started_) { + CheckPendingMissionBonus(delta); + + // Standing on the first goal's map before Start means it never gets a real transition edge, so treat this one tick as if it did. + const bool effective_just_entered = just_entered_map || pending_run_start_; + pending_run_start_ = false; + + // Synthesizes MissionComplete/Bonus off the primary objective's ObjectiveDone (reliable server map_id) for missions where kMissionComplete reports it wrong/missing, e.g. GNW. + if (primary_obj_id_ != 0) { + for (const auto& ev : pending_events_) { + if (ev.type == GoalTrigger::Type::ObjectiveDone && + ev.id1 == primary_obj_id_ && ev.id2 != 0) + NotifyMissionComplete(static_cast(ev.id2)); + } + } + + // Pass 1: records when each objective begins; checked for all non-completed goals (supports parallel objectives like Deep rooms 1-4). + std::vector claimed_this_tick; + for (int i = 0; i < static_cast(list_->goals.size()); ++i) { + GoalEntry& g = list_->goals[i]; + if (g.is_header) continue; + if (g.status == GoalStatus::Completed) continue; + if (g.start_real_time >= 0.0) continue; // already started + if (!g.start_trigger.has_value()) continue; + const GoalTrigger& st = g.start_trigger.value(); + bool start_fire = false; + const GoalTrigger* fired_trigger = &st; + switch (st.type) { + case GoalTrigger::Type::MapEnter: + start_fire = effective_just_entered && (current_map == st.map_id); + break; + // Same map_id can be Outpost or Explorable at different points (e.g. ToPK's The_Underworld_PvP) — matches OT's own explorable-only gate before AddToPKObjectiveSet(). + case GoalTrigger::Type::EnterExplorable: + start_fire = effective_just_entered && is_explorable && (current_map == st.map_id); + break; + // Same reasoning as EnterExplorable above — Running's legs use this as a start_trigger now (see BatchColumn's preserve_order build). + case GoalTrigger::Type::EnterOutpost: + start_fire = effective_just_entered && !is_explorable && (current_map == st.map_id); + break; + default: + start_fire = matchesPendingTrigger(st); + // OR alternates (e.g. DoA's "360" room, reachable through any of 3 doors) — same idea as extra_triggers but for starting. + if (!start_fire) { + for (const auto& est : g.extra_start_triggers) { + if (matchesPendingTrigger(est)) { start_fire = true; fired_trigger = &est; break; } + } + } + break; + } + if (start_fire) { + bool already_claimed = false; + for (const GoalTrigger* claimed : claimed_this_tick) { + if (triggersEqual(*claimed, *fired_trigger)) { already_claimed = true; break; } + } + if (already_claimed) continue; + claimed_this_tick.push_back(fired_trigger); + + g.start_real_time = clock.RealTime(); + g.start_game_time = clock.GameTime(); + g.status = GoalStatus::Started; + CompletePreviousGoals(i, clock); + } + } + + // Pass 2: check end triggers — fires completion for the first matching goal. + for (int i = 0; i < static_cast(list_->goals.size()); ++i) { + GoalEntry& g = list_->goals[i]; + if (g.is_header) continue; + if (g.status == GoalStatus::Completed) continue; + // A goal with a start_trigger must be Started before it can complete, so start/end can't both fire on the same tick. + if (g.start_trigger.has_value() && g.status == GoalStatus::NotStarted) { + if (is_ordered(g.trigger.type)) break; + continue; + } + + bool fire = false; + const GoalTrigger& t = g.trigger; + + switch (t.type) { + case GoalTrigger::Type::MapEnter: + fire = effective_just_entered && current_map == t.map_id; + break; + + case GoalTrigger::Type::EnterExplorable: + fire = effective_just_entered && is_explorable && (current_map == t.map_id); + break; + + // Same map_id can be Outpost or Explorable (e.g. GNW); !is_explorable so this only fires for the town/staging entry, not the mission. + case GoalTrigger::Type::EnterOutpost: + fire = effective_just_entered && !is_explorable && (current_map == t.map_id); + break; + + case GoalTrigger::Type::ExitExplorable: + fire = just_entered_map && came_from_explorable && (prev_map_ == t.map_id); + break; + + case GoalTrigger::Type::VanquishComplete: + fire = (vanquish_complete_map_ == t.map_id); + break; + + case GoalTrigger::Type::MissionComplete: { + const bool hm_ok = !t.hard_mode || GW::PartyMgr::GetIsPartyInHardMode(); + fire = (mission_complete_map_ == t.map_id) && hm_ok; + if (mission_complete_map_ != GW::Constants::MapID::None) + debug_notes_.push_back({"P2MisComp", static_cast(mission_complete_map_), static_cast(t.map_id)}); + break; + } + + case GoalTrigger::Type::MissionBonus: { + const bool hm_ok = !t.hard_mode || GW::PartyMgr::GetIsPartyInHardMode(); + fire = (mission_bonus_map_ == t.map_id) && hm_ok; + if (mission_bonus_map_ != GW::Constants::MapID::None) + debug_notes_.push_back({"P2MisBon", static_cast(mission_bonus_map_), static_cast(t.map_id)}); + break; + } + + case GoalTrigger::Type::ReachLevel: + fire = (player_level >= t.level); + break; + + case GoalTrigger::Type::ExitOutpost: + fire = just_entered_map && (prev_map_ == t.map_id); + break; + + case GoalTrigger::Type::ReachTitleRank: { + // t.level is the target RANK (1-based), not a stored tier index — the tier-index anchor (max_title_tier_index) only exists once the title has any progress at all, so it's resolved here against the live Title* instead of at goal-creation time. This is what lets a goal be added for a title still at zero progress. + const GW::Title* title = GW::PlayerMgr::GetTitleTrack(t.title_id); + if (title && t.level > 0 && title->current_title_tier_index != 0) { + const uint32_t target_tier_idx = title->max_title_tier_index + static_cast(t.level - 1); + fire = title->current_title_tier_index >= target_tier_idx; + } + break; + } + + case GoalTrigger::Type::Manual: + break; + + // Preset-only triggers, matched against pending_events_ (also checks extra_triggers, OR semantics). + case GoalTrigger::Type::ObjectiveDone: + case GoalTrigger::Type::DoorOpen: + case GoalTrigger::Type::DoorClose: + case GoalTrigger::Type::AgentUpdateAllegiance: + case GoalTrigger::Type::DoACompleteZone: + case GoalTrigger::Type::DungeonReward: + case GoalTrigger::Type::ServerMessage: + case GoalTrigger::Type::DisplayDialogue: + case GoalTrigger::Type::CountdownStart: + case GoalTrigger::Type::QuestPickup: + case GoalTrigger::Type::QuestComplete: { + fire = matchesPendingTrigger(t); + if (!fire) { + for (const auto& et : g.extra_triggers) { + if ((fire = matchesPendingTrigger(et))) break; + } + } + break; + } + + // SkillLearnt has no event at all, it's pure state — must be polled. + case GoalTrigger::Type::SkillLearnt: + fire = GW::SkillbarMgr::GetIsSkillLearnt(static_cast(t.param1)); + break; + + // Counts toward param2 rather than first-match, since an AoE wipe can add multiple matching events to pending_events_ in one tick. + case GoalTrigger::Type::MobKill: { + int kills_this_tick = 0; + for (const auto& ev : pending_events_) { + if (ev.type == GoalTrigger::Type::MobKill && ev.id1 == t.param1) + ++kills_this_tick; + } + g.trigger_progress += kills_this_tick; + const uint32_t target = t.param2 > 0 ? t.param2 : 1; + fire = g.trigger_progress >= static_cast(target); + break; + } + + default: + break; + } + + if (fire) { + // Cascading end goals (auto_complete_previous, no start_trigger) must close previous legs first, so their segments use the pre-arrival last_real_. + if (g.auto_complete_previous != 0 && !g.start_trigger.has_value()) { + CompletePreviousGoals(i, clock); + FireGoal(i, clock); + } else { + FireGoal(i, clock); + CompletePreviousGoals(i, clock); + } + fired++; + // Pass 1 already handles the next leg's own start_trigger independently this same tick — no chaining needed here anymore. + if (is_ordered(t.type)) break; + } + if (!fire && is_ordered(g.trigger.type)) { + // Wrong turn — ApplyTimerPolicy decides who cares. start_real_time != clock.RealTime() protects a goal whose start_trigger just fired THIS tick (only matters for a route's first leg — a later leg's predecessor firing already breaks this loop first). + if (just_entered_map && g.start_real_time != clock.RealTime() && + is_map_enter_or_exit(g.trigger.type)) + pending_wrong_map_entered_ = true; + // A Started Manual goal shouldn't block subsequent auto-completing goals (e.g. an Add-End MapEnter with auto_complete_previous). + if (g.trigger.type == GoalTrigger::Type::Manual && + g.status == GoalStatus::Started) + continue; + break; + } + } // end Pass 2 + + // Wrong turn on a start_trigger (e.g. Running's leg entries): runs after Pass 2 so the current leg's own exit has already had a chance to complete this tick — otherwise this always sees the in-progress goal (Started, not Completed yet) and breaks there instead of reaching the real blocker. + if (just_entered_map) { + for (const auto& g : list_->goals) { + if (g.is_header) continue; + if (g.status == GoalStatus::Completed) continue; + if (g.status != GoalStatus::NotStarted) break; + if (!g.start_trigger.has_value()) break; + if (is_map_enter(g.start_trigger->type)) + pending_wrong_map_entered_ = true; + break; + } + } + + // Auto-fail: mirrors OT's StopObjectives — checks the first incomplete (not Started, since editor-built goals never reach Started before completing) goal; runs after Pass 2 to avoid a same-tick false positive. + if (just_entered_map && came_from_explorable) { + using TT = GoalTrigger::Type; + GW::Constants::MapID owning_header_map = GW::Constants::MapID::None; + // Map the current goal is active on: starts at the header's map, advances to each completed MapEnter goal's own target, so a normal level transition (e.g. CoF 1->2) isn't misread as abandonment. + GW::Constants::MapID segment_start_map = GW::Constants::MapID::None; + for (const auto& g : list_->goals) { + if (g.is_header) { + owning_header_map = g.trigger.map_id; + segment_start_map = g.trigger.map_id; + continue; + } + if (g.status == GoalStatus::Completed || g.status == GoalStatus::Failed) { + if (g.trigger.type == TT::MapEnter) segment_start_map = g.trigger.map_id; + continue; + } + bool map_matches = false; + if (g.trigger.type == TT::VanquishComplete || g.trigger.type == TT::MissionComplete || + g.trigger.type == TT::MissionBonus || g.trigger.type == TT::DungeonReward || + g.trigger.type == TT::CountdownStart) { + // CountdownStart (ToPK) carries its own level's real map_id directly, same as Mission/Bonus/VQ/DungeonReward. + map_matches = (g.trigger.map_id == prev_map_); + } else if (g.trigger.type == TT::ObjectiveDone || g.trigger.type == TT::DoorOpen || + g.trigger.type == TT::DisplayDialogue || g.trigger.type == TT::ServerMessage || + g.trigger.type == TT::DoACompleteZone || g.trigger.type == TT::AgentUpdateAllegiance) { + // DoACompleteZone/AgentUpdateAllegiance (DoA) use the header-map fallback too, since DoA's whole run shares one map_id across all 4 rotated zones. + map_matches = (owning_header_map != GW::Constants::MapID::None && + owning_header_map == prev_map_); + } else if (g.trigger.type == TT::MapEnter) { + // SC's per-level dungeon goals: this goal's own map_id is the *next* level, so segment_start_map (not owning_header_map) is the map it's active on. + map_matches = (segment_start_map != GW::Constants::MapID::None && + segment_start_map == prev_map_); + } + if (map_matches) pending_incomplete_rezone_ = true; + break; // only the current goal can be the one just abandoned + } + } + } + + mission_complete_map_ = GW::Constants::MapID::None; + mission_bonus_map_ = GW::Constants::MapID::None; + vanquish_complete_map_ = GW::Constants::MapID::None; + pending_events_.clear(); + if (current_map != GW::Constants::MapID::None) + prev_map_ = current_map; + + return fired; +} + +void GoalEngine::ForceStarted() +{ + started_ = true; + pending_run_start_ = true; +} + +void GoalEngine::TriggerManual(const GoalClock& clock) +{ + if (!list_) return; + for (int i = 0; i < static_cast(list_->goals.size()); ++i) { + GoalEntry& g = list_->goals[i]; + if (g.is_header) continue; + if (g.status != GoalStatus::Completed && g.trigger.type == GoalTrigger::Type::Manual) { + if (!started_) started_ = true; + FireGoal(i, clock); + CompletePreviousGoals(i, clock); + return; + } + } +} + +void GoalEngine::FireGoal(int index, const GoalClock& clock) +{ + GoalEntry& g = list_->goals[index]; + g.status = GoalStatus::Completed; + g.split.real_time = clock.RealTime(); + g.split.game_time = clock.GameTime(); + g.split.segment_real = clock.RealTime() - last_real_; + g.split.segment_game = clock.GameTime() - last_game_; + last_real_ = clock.RealTime(); + last_game_ = clock.GameTime(); + + // OT-style relay: next sequential non-header goal without an explicit start_trigger auto-starts now. + int next_i = index + 1; + while (next_i < static_cast(list_->goals.size()) && list_->goals[next_i].is_header) + ++next_i; + if (next_i < static_cast(list_->goals.size())) { + GoalEntry& nxt = list_->goals[next_i]; + if (nxt.start_real_time < 0.0 && !nxt.start_trigger.has_value()) { + nxt.start_real_time = g.split.real_time; + nxt.start_game_time = g.split.game_time; + if (nxt.status == GoalStatus::NotStarted) + nxt.status = GoalStatus::Started; + } + } +} + +void GoalEngine::CompletePreviousGoals(int index, const GoalClock& clock) +{ + const GoalEntry& g = list_->goals[index]; + if (g.auto_complete_previous == 0) return; + + const int from = (g.auto_complete_previous < 0) + ? 0 + : std::max(0, index - g.auto_complete_previous); + + for (int j = from; j < index; ++j) { + if (list_->goals[j].is_header) continue; + if (list_->goals[j].status != GoalStatus::Completed) + FireGoal(j, clock); + } +} + +void GoalEngine::FailRun(const GoalClock& clock) +{ + if (!list_) return; + for (auto& g : list_->goals) { + if (g.is_header) continue; + if (g.status != GoalStatus::Started) continue; + g.status = GoalStatus::Failed; + g.split.real_time = clock.RealTime(); + g.split.game_time = clock.GameTime(); + g.split.segment_real = clock.RealTime() - last_real_; + g.split.segment_game = clock.GameTime() - last_game_; + } +} diff --git a/GWToolboxdll/Windows/Splits/GoalEngine.h b/GWToolboxdll/Windows/Splits/GoalEngine.h new file mode 100644 index 000000000..3e8ef0036 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalEngine.h @@ -0,0 +1,96 @@ +#pragma once + +#include "GoalList.h" +#include "GoalClock.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// GoalEngine — checks conditions each frame, fires splits, tracks state. +// --------------------------------------------------------------------------- +class GoalEngine { +public: + void Attach(GoalList* list); + void Detach(); + + // Returns the count of goals that fired this tick (0 = none). + // Ordered types block all subsequent goals until met; unordered types (mission/bonus/title/objective) never block, so several can fire in one tick. + // is_explorable must be synchronous with just_entered_map, not a live GetInstanceType() poll (can lag a frame and miss the one-shot tick). + int Update(const GoalClock& clock, + GW::Constants::MapID current_map, + bool just_entered_map, + bool came_from_explorable, + bool is_explorable, + int player_level, + float delta); + + void TriggerManual(const GoalClock& clock); + + // Arms a pending bonus check (see CheckPendingMissionBonus) instead of reading it synchronously, which produces false positives. + void NotifyMissionComplete(GW::Constants::MapID map); + void NotifyVanquishComplete(GW::Constants::MapID map); + // Tracks the base/primary objective (no BULLET bit); its ObjectiveDone synthesizes MissionComplete+MissionBonus with the real server map_id. + void NotifyObjectiveAdd(uint32_t obj_id, uint32_t type_flags); + + // Generic event notification for preset-only triggers (DoorOpen, ObjectiveDone, etc.) + // str is only needed for ServerMessage/DisplayDialogue and must remain valid until Update() runs. + void NotifyEvent(GoalTrigger::Type type, uint32_t id1 = 0, uint32_t id2 = 0, + const wchar_t* str = nullptr, size_t str_len = 0); + + void Reset(); + void ForceStarted(); + + // Marks Started goals Failed with a split time; NotStarted/Completed goals are untouched. + void FailRun(const GoalClock& clock); + + // True if a Started Vanquish/Mission/Bonus goal's map was just left unfinished; detection only, caller decides policy since GoalEngine doesn't know auto_fail_on_rezone. Clears on read. + [[nodiscard]] bool ConsumeIncompleteRezone(); + + // Fires for any profile; only Running's caller-side policy acts on it. + [[nodiscard]] bool ConsumeWrongMapEntered(); + + // TEMPORARY diagnostic for the MissionComplete-not-firing investigation: Pass 2 appends here whenever it evaluates a MissionComplete goal against a non-None mission_complete_map_. Drained (and cleared) by SplitsWindow::Update() into PushDbgEvent right after calling Update() here. Remove once resolved. + struct DebugNote { const char* tag; uint32_t v1; uint32_t v2; }; + std::vector debug_notes_; + +private: + void FireGoal(int index, const GoalClock& clock); + // Throttled to once/second via bonus_check_timer_ — polls CompletionWindow (string/bitset scan), not free to run every tick for however long a bonus stays unearned. + void CheckPendingMissionBonus(float delta); + // Completes any not-yet-completed goals before `index` per its auto_complete_previous. + void CompletePreviousGoals(int index, const GoalClock& clock); + + struct PendingEvent { + GoalTrigger::Type type; + uint32_t id1; + uint32_t id2; + std::wstring str; // copy of string data for ServerMessage/DisplayDialogue + }; + + GoalList* list_ = nullptr; + bool started_ = false; + + GW::Constants::MapID prev_map_ = GW::Constants::MapID::None; + + double last_real_ = 0.0; + double last_game_ = 0.0; + + GW::Constants::MapID mission_complete_map_ = GW::Constants::MapID::None; + GW::Constants::MapID mission_bonus_map_ = GW::Constants::MapID::None; + GW::Constants::MapID vanquish_complete_map_ = GW::Constants::MapID::None; + // No timeout — a genuinely-unearned bonus just stays pending harmlessly for the rest of the run. + GW::Constants::MapID pending_bonus_check_map_ = GW::Constants::MapID::None; + float bonus_check_timer_ = 0.f; // accumulates delta; CheckPendingMissionBonus only polls once this hits 1s + // Base/primary objective id (no BULLET bit); its ObjectiveDone sets both mission_complete_map_/mission_bonus_map_ via the real server map_id. + uint32_t primary_obj_id_ = 0; + // See ConsumeIncompleteRezone(). + bool pending_incomplete_rezone_ = false; + // See ConsumeWrongMapEntered(). + bool pending_wrong_map_entered_ = false; + // One-shot: lets the first Enter-type goal fire if you're already standing on its map when the run starts/resumes, since a real zone-transition edge will never come. + bool pending_run_start_ = false; + + std::vector pending_events_; +}; diff --git a/GWToolboxdll/Windows/Splits/GoalEntry.h b/GWToolboxdll/Windows/Splits/GoalEntry.h new file mode 100644 index 000000000..34a735109 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalEntry.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// GoalTrigger — describes the condition that completes a split. +// --------------------------------------------------------------------------- +struct GoalTrigger { + // NOTE: a new type with a real start-to-complete gap (e.g. MissionComplete, MobKill) also needs a progress rule in SplitsWindow::ApplyTimerPolicy() — not compiler-checked. + enum class Type : uint8_t { + Manual = 0, + // No explorable/outpost distinction; Manual's Explorables/Towns pickers use EnterExplorable/EnterOutpost instead for that. + MapEnter = 1, + EnterExplorable = 2, // enter this map_id specifically as an Explorable instance — produced by the "Explorables" batch picker + ExitExplorable = 3, + VanquishComplete = 4, + MissionComplete = 5, + MissionBonus = 6, + ReachLevel = 7, + ExitOutpost = 8, + ReachTitleRank = 9, + + // Preset-only triggers (used by built-in elite/dungeon presets; not user-editable) + ObjectiveDone = 10, // param1 = objective_id + DoorOpen = 11, // param1 = object_id + DoorClose = 12, // param1 = object_id + AgentUpdateAllegiance = 13, // param1 = player_number, param2 = allegiance_bits + DoACompleteZone = 14, // param1 = zone message word + DungeonReward = 15, // no params — dungeon chest opened + ServerMessage = 16, // pattern = encoded wchar_t prefix to match + DisplayDialogue = 17, // pattern = encoded wchar_t prefix to match + CountdownStart = 18, // param1 = map_id (ToPK arena countdown) + ObjectiveStarted = 19, // param1 = objective_id (ObjectiveUpdateName packet) + + // User-editable, manual ID entry (no master list exists for either). + QuestPickup = 20, // param1 = quest_id; fires once off QuestAdd (event-driven) + QuestComplete = 21, // param1 = quest_id; fires off QuestRemove (event-driven, also fires on abandon) + SkillLearnt = 22, // param1 = skill_id; polled via GetIsSkillLearnt + // param1 = model_id (visible via Info window, no wiki needed), param2 = kill count (0->1); event-driven off AgentDied. + MobKill = 23, + // Outpost-specific MapEnter — some zones share a map_id between an Outpost and a later Explorable instance (e.g. GNW). + EnterOutpost = 24, + }; + + Type type = Type::Manual; + bool hard_mode = false; // MissionComplete/Bonus: require hard mode + GW::Constants::MapID map_id = GW::Constants::MapID::None; + int level = 0; + GW::Constants::TitleID title_id = GW::Constants::TitleID::None; + + // Preset-only fields (ObjectiveDone, DoorOpen/Close, AgentUpdateAllegiance, DoACompleteZone, ServerMessage, DisplayDialogue). + uint32_t param1 = 0; + uint32_t param2 = 0; + std::wstring pattern; // for ServerMessage/DisplayDialogue prefix matching +}; + +// --------------------------------------------------------------------------- +// CompletedSplit — time data recorded when a goal fires. +// --------------------------------------------------------------------------- +struct CompletedSplit { + double real_time = 0.0; + double game_time = 0.0; + double segment_real = 0.0; + double segment_game = 0.0; +}; + +// --------------------------------------------------------------------------- +// GoalStatus — lifecycle of a goal within a run (mirrors ObjectiveTimerWindow's Objective::Status, including Failed for aborted runs). +// --------------------------------------------------------------------------- +enum class GoalStatus : uint8_t { + NotStarted = 0, + Started = 1, + Completed = 2, + Failed = 3, +}; + +// --------------------------------------------------------------------------- +// GoalEntry — one row in a split list. +// --------------------------------------------------------------------------- +struct GoalEntry { + // Dynamic = Start/End/Duration alone, no PB/AVG/Last-Run comparison — for goals with an independent start that doesn't fit relay-chain segment math (parallel triggers, Quest pickup/complete). + enum class DisplayStyle : uint8_t { Splits = 0, Dynamic = 1 }; + + std::string label; + GoalTrigger trigger; + DisplayStyle display_style = DisplayStyle::Splits; + std::optional start_trigger; // records start_real_time when fired (OT-style per-objective start) + // OR-semantics alternates for start_trigger (preset-only) — e.g. DoA's "360" room, reachable through any of 3 doors. + std::vector extra_start_triggers; + bool starts_immediately = false; // start_real_time = 0 when the run is attached (fires at t=0) — never serialized, only valid the same tick a preset is built off a live signal + double start_real_time = -1.0; // < 0 = not yet fired. Set by GoalEngine. + double start_game_time = -1.0; + std::vector extra_triggers; // OR-semantics alternates for trigger (preset-only) + int auto_complete_previous = 0; // preset-only: also complete the previous N goals (-1 = all) on start/complete, mirrors OT's starting_completes_n_previous_objectives + bool is_header = false; // headers have no trigger; status/timing derives from descendants (indent > this entry's) + int indent = 0; // Visual/logical depth; headers at N own entries with indent > N. + GoalStatus status = GoalStatus::NotStarted; + CompletedSplit split = {}; + int trigger_progress = 0; // MobKill only: kill count toward trigger.param2, runtime-only, reset by ResetRunState() +}; diff --git a/GWToolboxdll/Windows/Splits/GoalList.cpp b/GWToolboxdll/Windows/Splits/GoalList.cpp new file mode 100644 index 000000000..9239af4a6 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalList.cpp @@ -0,0 +1,300 @@ +#include "stdafx.h" +#include "GoalList.h" + +#include +#include + +// --------------------------------------------------------------------------- +// JSON DTOs for split-list files (glaze reflection requires external linkage). +// --------------------------------------------------------------------------- +namespace GoalListJson { + struct SerializedTrigger { + std::string trigger_type = "Manual"; + std::optional map_id; + std::optional param1; + std::optional param2; + std::optional> pattern; + }; + + struct SerializedGoal { + std::string label; + std::string trigger_type = "Manual"; + int map_id = 0; + int level = 0; + uint32_t title_id = 0xffu; + std::optional hard_mode; + std::optional param1; + std::optional param2; + std::optional> pattern; + std::optional start_trigger; + std::optional> extra_start_triggers; + std::optional> extra_triggers; + std::optional auto_complete_previous; + std::optional is_header; + std::optional indent; + std::optional display_style; + }; + + struct SerializedReference { + std::vector splits; + }; + + struct SerializedGoalList { + std::string name; + std::vector goals; + std::optional reference; + std::optional is_preset; + }; + + std::vector EncodePattern(const std::wstring& pattern) + { + std::vector out; + out.reserve(pattern.size()); + for (wchar_t c : pattern) out.push_back(static_cast(c)); + return out; + } + + std::wstring DecodePattern(const std::vector& pattern) + { + std::wstring out; + out.reserve(pattern.size()); + for (uint16_t c : pattern) out += static_cast(c); + return out; + } +} +using namespace GoalListJson; + +// --------------------------------------------------------------------------- +// Serialization helpers +// --------------------------------------------------------------------------- +namespace { + // Single source of truth for the trigger type <-> string mapping, searched by both TriggerTypeName and TriggerTypeFromString below. Manual is deliberately absent — it's the implicit fallback on both sides (default enum value / unrecognized string). + struct TriggerTypeNameEntry { GoalTrigger::Type type; const char* name; }; + constexpr TriggerTypeNameEntry kTriggerTypeNames[] = { + { GoalTrigger::Type::MapEnter, "MapEnter" }, + { GoalTrigger::Type::EnterExplorable, "EnterExplorable" }, + { GoalTrigger::Type::ExitExplorable, "ExitExplorable" }, + { GoalTrigger::Type::VanquishComplete, "VanquishComplete" }, + { GoalTrigger::Type::MissionComplete, "MissionComplete" }, + { GoalTrigger::Type::MissionBonus, "MissionBonus" }, + { GoalTrigger::Type::ReachLevel, "ReachLevel" }, + { GoalTrigger::Type::ExitOutpost, "ExitOutpost" }, + { GoalTrigger::Type::ReachTitleRank, "ReachTitleRank" }, + { GoalTrigger::Type::ObjectiveDone, "ObjectiveDone" }, + { GoalTrigger::Type::DoorOpen, "DoorOpen" }, + { GoalTrigger::Type::DoorClose, "DoorClose" }, + { GoalTrigger::Type::AgentUpdateAllegiance, "AgentUpdateAllegiance" }, + { GoalTrigger::Type::DoACompleteZone, "DoACompleteZone" }, + { GoalTrigger::Type::DungeonReward, "DungeonReward" }, + { GoalTrigger::Type::ServerMessage, "ServerMessage" }, + { GoalTrigger::Type::DisplayDialogue, "DisplayDialogue" }, + { GoalTrigger::Type::CountdownStart, "CountdownStart" }, + { GoalTrigger::Type::ObjectiveStarted, "ObjectiveStarted" }, + { GoalTrigger::Type::QuestPickup, "QuestPickup" }, + { GoalTrigger::Type::QuestComplete, "QuestComplete" }, + { GoalTrigger::Type::SkillLearnt, "SkillLearnt" }, + { GoalTrigger::Type::MobKill, "MobKill" }, + { GoalTrigger::Type::EnterOutpost, "EnterOutpost" }, + }; +} + +static std::string TriggerTypeName(GoalTrigger::Type t) +{ + for (const auto& e : kTriggerTypeNames) + if (e.type == t) return e.name; + return "Manual"; +} + +static GoalTrigger::Type TriggerTypeFromString(const std::string& s) +{ + for (const auto& e : kTriggerTypeNames) + if (s == e.name) return e.type; + return GoalTrigger::Type::Manual; +} + +static SerializedTrigger ToSerialized(const GoalTrigger& t) +{ + SerializedTrigger jt; + jt.trigger_type = TriggerTypeName(t.type); + if (t.map_id != GW::Constants::MapID::None) jt.map_id = static_cast(t.map_id); + if (t.param1) jt.param1 = t.param1; + if (t.param2) jt.param2 = t.param2; + if (!t.pattern.empty()) jt.pattern = EncodePattern(t.pattern); + return jt; +} + +static GoalTrigger FromSerialized(const SerializedTrigger& jt) +{ + GoalTrigger t; + t.type = TriggerTypeFromString(jt.trigger_type); + t.map_id = static_cast(jt.map_id.value_or(0)); + t.param1 = jt.param1.value_or(0u); + t.param2 = jt.param2.value_or(0u); + if (jt.pattern) t.pattern = DecodePattern(*jt.pattern); + return t; +} + +// --------------------------------------------------------------------------- +void GoalList::ResetRunState() +{ + for (auto& g : goals) { + g.status = GoalStatus::NotStarted; + g.split = {}; + g.start_real_time = -1.0; + g.start_game_time = -1.0; + g.trigger_progress = 0; + } +} + +void GoalList::RenumberDuplicateLabels() +{ + // Strips a trailing " (N)" suffix this function previously added, so re-running it after a deletion renumbers from a clean base label. + auto strip_suffix = [](const std::string& label) -> std::string { + const size_t open = label.rfind(" ("); + if (open == std::string::npos || label.back() != ')') return label; + const size_t digits_begin = open + 2; + if (digits_begin >= label.size() - 1) return label; + for (size_t i = digits_begin; i < label.size() - 1; ++i) + if (!std::isdigit(static_cast(label[i]))) return label; + return label.substr(0, open); + }; + + std::vector base_labels(goals.size()); + std::unordered_map counts; + for (size_t i = 0; i < goals.size(); ++i) { + if (goals[i].is_header) continue; + base_labels[i] = strip_suffix(goals[i].label); + ++counts[base_labels[i]]; + } + + std::unordered_map seen; + for (size_t i = 0; i < goals.size(); ++i) { + if (goals[i].is_header) continue; + const std::string& base = base_labels[i]; + if (counts[base] <= 1) { + goals[i].label = base; + } else { + goals[i].label = base + " (" + std::to_string(++seen[base]) + ")"; + } + } +} + +bool GoalList::SaveToFile(const std::wstring& path) const +{ + SerializedGoalList j; + j.name = name; + if (is_preset) j.is_preset = true; + j.goals.reserve(goals.size()); + + for (const auto& g : goals) { + SerializedGoal jg; + jg.label = g.label; + jg.trigger_type = TriggerTypeName(g.trigger.type); + jg.map_id = static_cast(g.trigger.map_id); + jg.level = g.trigger.level; + jg.title_id = static_cast(g.trigger.title_id); + if (g.trigger.hard_mode) jg.hard_mode = true; + if (g.trigger.param1) jg.param1 = g.trigger.param1; + if (g.trigger.param2) jg.param2 = g.trigger.param2; + if (!g.trigger.pattern.empty()) jg.pattern = EncodePattern(g.trigger.pattern); + // starts_immediately is deliberately never serialized — see GoalEntry.h's own comment; it only means anything on the same tick a preset was built off a live signal. + if (g.auto_complete_previous != 0) jg.auto_complete_previous = g.auto_complete_previous; + if (g.is_header) jg.is_header = true; + if (g.indent != 0) jg.indent = g.indent; + if (g.display_style != GoalEntry::DisplayStyle::Splits) + jg.display_style = static_cast(g.display_style); + if (g.start_trigger.has_value()) jg.start_trigger = ToSerialized(g.start_trigger.value()); + if (!g.extra_start_triggers.empty()) { + std::vector jextra_starts; + jextra_starts.reserve(g.extra_start_triggers.size()); + for (const auto& est : g.extra_start_triggers) jextra_starts.push_back(ToSerialized(est)); + jg.extra_start_triggers = std::move(jextra_starts); + } + if (!g.extra_triggers.empty()) { + std::vector jextras; + jextras.reserve(g.extra_triggers.size()); + for (const auto& et : g.extra_triggers) jextras.push_back(ToSerialized(et)); + jg.extra_triggers = std::move(jextras); + } + j.goals.push_back(std::move(jg)); + } + + if (reference.has_value() && !reference->splits.empty()) { + SerializedReference jref; + jref.splits = reference->splits; + j.reference = std::move(jref); + } + + std::ofstream f(path); + if (!f.is_open()) return false; + f << glz::write(j).value_or(std::string{}); + return true; +} + +bool GoalList::LoadFromFile(const std::wstring& path) +{ + std::ifstream f(path); + if (!f.is_open()) return false; + + std::stringstream ss; + ss << f.rdbuf(); + + SerializedGoalList j; + constexpr glz::opts opts{.error_on_unknown_keys = false}; + if (glz::read(j, ss.str())) return false; + + name = j.name; + is_preset = j.is_preset.value_or(false); + goals.clear(); + goals.reserve(j.goals.size()); + + for (const auto& jg : j.goals) { + GoalEntry g; + g.label = jg.label; + g.trigger.type = TriggerTypeFromString(jg.trigger_type); + g.trigger.map_id = static_cast(jg.map_id); + g.trigger.level = jg.level; + g.trigger.title_id = static_cast(jg.title_id); + g.trigger.hard_mode = jg.hard_mode.value_or(false); + g.trigger.param1 = jg.param1.value_or(0u); + g.trigger.param2 = jg.param2.value_or(0u); + if (jg.pattern) g.trigger.pattern = DecodePattern(*jg.pattern); + g.auto_complete_previous = jg.auto_complete_previous.value_or(0); + g.is_header = jg.is_header.value_or(false); + g.indent = jg.indent.value_or(0); + g.display_style = static_cast(jg.display_style.value_or(0)); + if (jg.start_trigger) g.start_trigger = FromSerialized(*jg.start_trigger); + if (jg.extra_start_triggers) { + g.extra_start_triggers.reserve(jg.extra_start_triggers->size()); + for (const auto& je : *jg.extra_start_triggers) g.extra_start_triggers.push_back(FromSerialized(je)); + } + if (jg.extra_triggers) { + g.extra_triggers.reserve(jg.extra_triggers->size()); + for (const auto& je : *jg.extra_triggers) g.extra_triggers.push_back(FromSerialized(je)); + } + goals.push_back(std::move(g)); + } + + if (j.reference.has_value()) { + GoalReference ref; + ref.splits = j.reference->splits; + reference = std::move(ref); + } else { + reference.reset(); + } + + return true; +} + +std::vector> +GoalList::ListSaved(const std::wstring& folder) +{ + std::vector> result; + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator(folder, ec)) { + if (entry.path().extension() != L".json") continue; + if (entry.path().stem() == L"resume") continue; + result.emplace_back(entry.path().stem().string(), entry.path().wstring()); + } + return result; +} diff --git a/GWToolboxdll/Windows/Splits/GoalList.h b/GWToolboxdll/Windows/Splits/GoalList.h new file mode 100644 index 000000000..0148346a7 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/GoalList.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include "GoalEntry.h" + +// --------------------------------------------------------------------------- +// Reference splits embedded in a template — personal PB or an imported WR. +// --------------------------------------------------------------------------- +struct GoalReference { + std::vector splits; // real-time cumulative split per non-header goal +}; + +// --------------------------------------------------------------------------- +// GoalList — a named, ordered collection of goals. +// --------------------------------------------------------------------------- +struct GoalList { + bool is_preset = false; + std::string name; + std::vector goals; + std::optional reference; + + void ResetRunState(); + // Suffixes " (1)", " (2)", ... onto goals that share a label, in list order; + // strips the suffix back off when only one goal has that label. + void RenumberDuplicateLabels(); + + bool SaveToFile(const std::wstring& path) const; + bool LoadFromFile(const std::wstring& path); + + static std::vector> + ListSaved(const std::wstring& folder); +}; diff --git a/GWToolboxdll/Windows/Splits/Nuzlocke.cpp b/GWToolboxdll/Windows/Splits/Nuzlocke.cpp new file mode 100644 index 000000000..ed738afc9 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/Nuzlocke.cpp @@ -0,0 +1,339 @@ +#include "stdafx.h" + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + constexpr ImVec4 kNuzlockeAlive = ImVec4(1.f, 1.f, 1.f, 1.f); + constexpr ImVec4 kNuzlockeAvailable = ImVec4(0.35f, 1.f, 0.35f, 1.f); + constexpr ImVec4 kNuzlockeDead = ImVec4(1.f, 0.35f, 0.35f, 1.f); + + // Henchman names come from the game as "Name [Role Henchman]" — the icon conveys profession now, so the bracket is just noise. + std::wstring StripHenchBracket(const std::wstring& name) + { + std::wstring out = name; + if (const auto bracket = out.find(L'['); bracket != std::wstring::npos) { + out.erase(bracket); + while (!out.empty() && out.back() == L' ') out.pop_back(); + } + return out; + } + + // hero_info only lists heroes this account owns — without this check a partymate's same-named hero dying would increment our own death count. It also carries profession directly since AgentLiving::primary isn't reliably populated yet when PartyHeroAdd first fires (why hero icons were coming up blank). + const GW::HeroInfo* FindOwnedHeroInfo(const GW::Constants::HeroID hero_id) + { + const auto* world = GW::GetWorldContext(); + if (!world) return nullptr; + for (const auto& hi : world->hero_info) { + if (hi.hero_id == hero_id) return &hi; + } + return nullptr; + } +} + +// --------------------------------------------------------------------------- +// Nuzlocke: Death Rules (party death tracking) +// --------------------------------------------------------------------------- +void NuzlockeState::OnInstanceLoad() +{ + dead_agents.clear(); + // agent_ids for hireable town henchmen aren't stable across instances — drop cached decodes from the previous outpost so a reused id can't show a stale name. + city_hench_names.clear(); + // Same reason: a reused agent_id must not be mistaken for "same roster as last frame, already resolved" by Update()'s skip check. + last_town_hench_ids.clear(); + town_hench_all_resolved = false; + // Pre-seed self so they show up in the roster at full lives, same as heroes/henchmen — other players only appear once they've actually died. + if (const wchar_t* self_name = GW::PlayerMgr::GetPlayerName()) { + players.try_emplace(self_name, NuzlockeMember{self_name, 0}); + } +} + +void NuzlockeState::ResetProgress() +{ + heroes.clear(); + henches.clear(); + players.clear(); + dead_agents.clear(); + // Forces a town rescan, since the reseed above only covers current party members, not hireable-but-unrecruited henchmen. + last_town_hench_ids.clear(); + town_hench_all_resolved = false; + + // agents is deliberately left alone so it can reseed the display rosters below immediately instead of waiting on the next zone transition's Party*Add events. + for (const auto& [agent_id, identity] : agents) { + if (identity.is_hero) { + const auto [it, inserted] = heroes.try_emplace(identity.hero_id); + if (inserted) { + if (const auto* hero_info = FindOwnedHeroInfo(identity.hero_id)) + it->second.profession = hero_info->primary; + } + } else if (!identity.hench_name.empty()) { + henches.try_emplace(identity.hench_name, + NuzlockeMember{identity.hench_name, 0, identity.hench_profession}); + } + } + + if (const wchar_t* self_name = GW::PlayerMgr::GetPlayerName()) { + players.try_emplace(self_name, NuzlockeMember{self_name, 0}); + } +} + +std::wstring NuzlockeState::HenchKey(const std::wstring& raw_name) const +{ + return merge_hench_by_name ? StripHenchBracket(raw_name) : raw_name; +} + +void NuzlockeState::Update(const bool last_was_explorable) +{ + // Heroes/henchmen roster — replaces the old PartyHero/HenchmanAdd/Remove StoC hooks with a live diff against GetPartyInfo(). + if (const auto* party = GW::PartyMgr::GetPartyInfo()) { + std::unordered_set live_agents; + for (const auto& h : party->heroes) { + live_agents.insert(h.agent_id); + if (agents.contains(h.agent_id)) continue; + const auto* hero_info = FindOwnedHeroInfo(h.hero_id); + if (!hero_info) continue; // not ours — don't track/conflate partymates' heroes + agents[h.agent_id] = NuzlockeIdentity{true, h.hero_id, {}}; + const auto [it, inserted] = heroes.try_emplace(h.hero_id); // first-seen only; leaves existing death count alone + if (inserted) it->second.profession = hero_info->primary; + } + // Henchmen carry no owner field — they're party-wide slots controlled by whoever's leader, so "not ours" means "I'm not the leader," not a per-unit check. + if (GW::PartyMgr::GetIsLeader()) { + for (const auto& hm : party->henchmen) { + live_agents.insert(hm.agent_id); + if (agents.contains(hm.agent_id)) continue; + pending_hench_names.emplace_back( + hm.agent_id, std::make_unique(GW::Agents::GetAgentEncName(hm.agent_id))); + agents[hm.agent_id].hench_profession = static_cast(hm.profession); + } + } + // Anyone we were tracking who's no longer in the live roster just left the party. + std::erase_if(agents, [&](const auto& kv) { + if (live_agents.contains(kv.first)) return false; + std::erase_if(pending_hench_names, [&](const auto& p) { return p.first == kv.first; }); + return true; + }); + } + + if (!pending_hench_names.empty()) { + std::erase_if(pending_hench_names, [this](auto& p) { + auto& [agent_id, enc] = p; + const std::wstring raw_name = enc->wstring(); + if (raw_name.empty()) return false; // not decoded yet + + const std::wstring key = HenchKey(raw_name); + auto& identity = agents[agent_id]; + identity.is_hero = false; + identity.hench_name = key; + henches.try_emplace(key, NuzlockeMember{key, 0, identity.hench_profession}); + return true; + }); + } + + // Hireable roster is a town-only concept + if (last_was_explorable) { + // Deaths only count in explorables — polls GetIsDead() + for (const auto& [agent_id, identity] : agents) { + if (dead_agents.contains(agent_id)) continue; + const auto* agent = GW::Agents::GetAgentByID(agent_id); + const auto* living = agent ? agent->GetAsAgentLiving() : nullptr; + if (!living || !living->GetIsDead()) continue; + dead_agents.insert(agent_id); + if (identity.is_hero) { + heroes[identity.hero_id].deaths++; + } else { + const auto hit = henches.find(identity.hench_name); + if (hit != henches.end()) hit->second.deaths++; + } + } + // Self/other players + auto poll_player_death = [this](const uint32_t agent_id, const bool is_self) { + if (dead_agents.contains(agent_id)) return; + const auto* agent = GW::Agents::GetAgentByID(agent_id); + const auto* living = agent ? agent->GetAsAgentLiving() : nullptr; + if (!living || !living->IsPlayer() || !living->GetIsDead()) return; + dead_agents.insert(agent_id); + const wchar_t* raw_name = is_self ? GW::PlayerMgr::GetPlayerName() + : GW::PlayerMgr::GetPlayerName(living->login_number); + if (!raw_name) return; + const std::wstring name(raw_name); + players.try_emplace(name, NuzlockeMember{name, 0}).first->second.deaths++; + }; + const uint32_t self_id = GW::Agents::GetControlledCharacterId(); + poll_player_death(self_id, true); + // Party members only — was a full agent-array scan (every agent in the instance), which also meant a stranger dying elsewhere in a shared explorable could get misattributed as an "other party member" death. Same login_number->agent_id resolution as PartyStatisticsWindow.cpp. + if (const auto* party = GW::PartyMgr::GetPartyInfo()) { + for (const auto& p : party->players) { + const uint32_t agent_id = GW::Agents::GetAgentIdByLoginNumber(p.login_number); + if (agent_id != 0 && agent_id != self_id) poll_player_death(agent_id, false); + } + } + + city_hench_available.clear(); + last_town_hench_ids.clear(); + town_hench_all_resolved = false; + return; + } + const auto* world = GW::GetWorldContext(); + if (!world) return; + + // Keeps henchman name and icons up to date until fully resolved + const auto& ids = world->henchmen_agent_ids; + if (town_hench_all_resolved && + std::equal(ids.begin(), ids.end(), last_town_hench_ids.begin(), last_town_hench_ids.end())) + return; + + city_hench_available.clear(); + bool all_resolved = true; + for (const uint32_t agent_id : ids) { + auto& enc = city_hench_names[agent_id]; + if (!enc) enc = std::make_unique(GW::Agents::GetAgentEncName(agent_id)); + const std::wstring raw_name = enc->wstring(); + if (raw_name.empty()) { all_resolved = false; continue; } // not decoded yet this frame + + // Seed the roster just from being hireable here, not only from actually being hired — otherwise a henchman never brought into the party never shows up at all. + const std::wstring key = HenchKey(raw_name); + const auto it = henches.try_emplace(key, NuzlockeMember{key, 0}).first; + // These NPCs aren't party members, so there's no PartyInfo entry to read profession from — keep retrying every tick until AgentLiving::primary resolves instead of locking in a blank icon. + if (it->second.profession == GW::Constants::Profession::None) { + if (const auto* agent = GW::Agents::GetAgentByID(agent_id)) { + if (const auto* living = agent->GetAsAgentLiving()) + it->second.profession = static_cast(living->primary); + } + if (it->second.profession == GW::Constants::Profession::None) all_resolved = false; + } + city_hench_available.insert(StripHenchBracket(raw_name)); + } + last_town_hench_ids.assign(ids.begin(), ids.end()); + town_hench_all_resolved = all_resolved; +} + +void NuzlockeState::Draw() +{ + // Enable/lives settings live in Settings > Splits; this is display-only. Points is a separate module whose total draws in the header clock row instead — both are Manual-profile only. + if (!ImGui::CollapsingHeader("Death Rules")) return; + + if (heroes.empty() && henches.empty() && players.empty()) { + ImGui::TextDisabled("Nobody tracked yet this session."); + return; + } + + ImGui::TextColored(kNuzlockeAlive, "White"); + ImGui::SameLine(0, 4); ImGui::TextDisabled("alive"); + ImGui::SameLine(0, 12); ImGui::TextColored(kNuzlockeAvailable, "Green"); + ImGui::SameLine(0, 4); ImGui::TextDisabled("henchman hireable here"); + ImGui::SameLine(0, 12); ImGui::TextColored(kNuzlockeDead, "Red"); + ImGui::SameLine(0, 4); ImGui::TextDisabled("out of lives"); + + // Players info/lives, centered above the Henchmen/Heroes table. Each label is built once and reused for both the width measurement and the draw, instead of formatting each name twice. + if (!players.empty()) { + struct PlayerLabel { std::string text; int remaining; }; + std::vector labels; + labels.reserve(players.size()); + const float sep_w = ImGui::CalcTextSize(" ").x; + float textw = 0.f; + char buf[96]; + for (auto& [name, member] : players) { + const int remaining = player_lives - member.deaths; + snprintf(buf, sizeof(buf), "%s %d/%d", TextUtils::WStringToString(name).c_str(), + remaining > 0 ? remaining : 0, player_lives); + if (!labels.empty()) textw += sep_w; + textw += ImGui::CalcTextSize(buf).x; + labels.push_back({buf, remaining}); + } + const float avail = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.f, (avail - textw) * 0.5f)); + + bool first = true; + for (const auto& lbl : labels) { + if (!first) { + ImGui::SameLine(0, 0); + ImGui::TextUnformatted(" "); + ImGui::SameLine(0, 0); + } + first = false; + ImGui::TextColored(lbl.remaining <= 0 ? kNuzlockeDead : kNuzlockeAlive, "%s", lbl.text.c_str()); + } + } + + auto icon_size = ImGui::CalcTextSize(" "); + icon_size.x = icon_size.y; + + if (ImGui::BeginTable("nuzlocke_hench_hero_table", 2, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Henchmen"); + ImGui::TableSetupColumn("Heroes"); + ImGui::TableHeadersRow(); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + for (auto& [name, member] : henches) { + const int remaining = hench_lives - member.deaths; + const std::wstring display_name = StripHenchBracket(name); + + ImVec4 color = kNuzlockeAlive; + if (remaining <= 0) color = kNuzlockeDead; + else if (city_hench_available.contains(display_name)) color = kNuzlockeAvailable; + + ImGui::Image(*Resources::GetProfessionIcon(member.profession), icon_size); + ImGui::SameLine(); + ImGui::TextColored(color, "%s - %d/%d", TextUtils::WStringToString(display_name).c_str(), + remaining > 0 ? remaining : 0, hench_lives); + } + + ImGui::TableSetColumnIndex(1); + for (auto& [hero_id, member] : heroes) { + auto* name = Resources::GetHeroName(hero_id); + const int remaining = hero_lives - member.deaths; + // Only owned heroes ever make it into heroes (see FindOwnedHeroInfo), so there's no "not yours" case left to color here. + const ImVec4 color = remaining <= 0 ? kNuzlockeDead : kNuzlockeAlive; + + ImGui::Image(*Resources::GetProfessionIcon(member.profession), icon_size); + ImGui::SameLine(); + ImGui::TextColored(color, "%s - %d/%d", name ? name->string().c_str() : "(hero)", + remaining > 0 ? remaining : 0, hero_lives); + } + + ImGui::EndTable(); + } +} + +// --------------------------------------------------------------------------- +// Nuzlocke: Points +// --------------------------------------------------------------------------- +int NuzlockeState::TotalPoints(const GoalList& list) const +{ + using T = GoalTrigger::Type; + int total = 0; + for (const auto& g : list.goals) { + if (g.is_header || g.status != GoalStatus::Completed) continue; + switch (g.trigger.type) { + case T::Manual: total += goal_points.manual; break; + case T::MissionComplete: + case T::MissionBonus: total += goal_points.missions; break; + case T::MapEnter: total += goal_points.explorables; break; + case T::EnterExplorable: + case T::ExitExplorable: + case T::ExitOutpost: total += goal_points.towns; break; + case T::ReachTitleRank: total += goal_points.titles; break; + case T::ReachLevel: total += goal_points.reach_level; break; + case T::QuestPickup: + case T::QuestComplete: total += goal_points.quest; break; + case T::SkillLearnt: total += goal_points.skill_learnt; break; + default: break; // preset-only triggers (dungeons/elites) aren't scored + } + } + return total; +} diff --git a/GWToolboxdll/Windows/Splits/NuzlockeState.h b/GWToolboxdll/Windows/Splits/NuzlockeState.h new file mode 100644 index 000000000..f0ba4229d --- /dev/null +++ b/GWToolboxdll/Windows/Splits/NuzlockeState.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace GuiUtils { + class EncString; +} + +struct GoalList; // TotalPoints() only needs a reference — see GoalList.h + +// v1: roster is built lazily from whichever heroes/henchmen we've actually seen this session; no pre-seeded campaign roster. Deaths only count in explorables. +struct NuzlockeMember { + std::wstring name; + int deaths = 0; + // Heroes and henchmen only (real players don't get an icon); read once when first seen in NuzlockeUpdate()'s roster diff, since profession is available immediately even though henchman names decode async. + GW::Constants::Profession profession = GW::Constants::Profession::None; +}; + +struct NuzlockeIdentity { + bool is_hero = false; + GW::Constants::HeroID hero_id{}; + std::wstring hench_name; + GW::Constants::Profession hench_profession = GW::Constants::Profession::None; +}; + +// Point value per goal category, applied when a goal of that category completes. Global (not per-list) — a non-Nuzlocke list just scores 0 since these all default to 0. Fields mirror the Add Goal trigger dropdown. +struct NuzlockePointValues { + int manual = 0; + int missions = 0; // MissionComplete + MissionBonus + int explorables = 0; // MapEnter + int towns = 0; // EnterExplorable, ExitExplorable, ExitOutpost + int titles = 0; // ReachTitleRank + int reach_level = 0; + int quest = 0; // QuestPickup + QuestComplete + int skill_learnt = 0; +}; + +// All Death Rules + Points settings and runtime tracking for SplitsWindow's Nuzlocke feature. +struct NuzlockeState { + // Out-of-line (SplitsWindow.cpp): pending_hench_names/city_hench_names hold unique_ptr, only forward-declared here. + NuzlockeState(); + ~NuzlockeState(); + + // ---- Death Rules settings ---- todo maybe allow 0 to visually remove tracking later? + bool death_rules_enabled = false; + int hero_lives = 1; + int hench_lives = 1; + int player_lives = 1; // self and other players are always tracked, no opt-out toggle + // Merges Henchman with same name cross campaigns. Same named henchman in different campaigns have different agent_id's. + bool merge_hench_by_name = false; + + // ---- Death Rules runtime state ---- + std::map heroes; + std::map henches; + // Real players (self and/or others), keyed by character name; resolved directly from the agent at time of death (player names aren't encoded/localized like hero names, so no pre-registration or async decode needed). + std::map players; + // agent_id -> identity, only while that agent is actually in the party. + std::unordered_map agents; + // agent_ids already counted as dead — guards against re-counting the same death on a later poll tick. Cleared on new instance load. + std::unordered_set dead_agents; + // Henchman names decode asynchronously; polled from Update() until ready. + std::vector>> pending_hench_names; + // Hireable henchmen in the current outpost, keyed by agent_id so we don't re-issue a decode request for one already resolved. Cleared on every instance load since agent_ids aren't stable across instances. + std::unordered_map> city_hench_names; + // Stripped display-names of henchmen hireable in THIS outpost right now — recomputed only when the id list changes or something's still unresolved, not unconditionally every tick. Empty in explorables (hireable roster is a town-only concept). + std::unordered_set city_hench_available; + // Skip-check for the above: last frame's raw henchmen_agent_ids plus whether every one had a resolved profession icon. Never skips while anything's unresolved, so a hireable henchman's icon keeps retrying until AgentLiving::primary populates rather than getting stuck blank. Reset on instance load since agent_ids aren't stable across instances. + std::vector last_town_hench_ids; + bool town_hench_all_resolved = false; + + // ---- Points ---- + bool points_enabled = false; + NuzlockePointValues goal_points; + + // ---- Behavior (Windows/Splits/Nuzlocke.cpp) ---- + // Fresh instance: drop identity-agnostic caches (agent_ids aren't stable across instances) and pre-seed self at full lives. Caller gates this on death-rules-enabled. + void OnInstanceLoad(); + // Rebuilds heroes/henches/players display rosters from `agents` without touching it — used on profile switch / manual reset, where identities are already known but death counts should clear. + void ResetProgress(); + // Per-tick roster diff + death poll. last_was_explorable must reflect the CURRENT map (hireable-henchmen roster is a town-only concept). Caller gates this on death-rules-enabled. + void Update(bool last_was_explorable); + // Renders the "Death Rules" collapsing header; draws nothing if nobody's tracked yet. Caller gates this on death-rules-enabled. + void Draw(); + // Sum of point values (Settings > Splits > Nuzlocke > Points) for every Completed, non-header goal in list. + [[nodiscard]] int TotalPoints(const GoalList& list) const; + +private: + [[nodiscard]] std::wstring HenchKey(const std::wstring& raw_name) const; +}; diff --git a/GWToolboxdll/Windows/Splits/SCPresets.cpp b/GWToolboxdll/Windows/Splits/SCPresets.cpp new file mode 100644 index 000000000..5226c90c2 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/SCPresets.cpp @@ -0,0 +1,476 @@ +#include "stdafx.h" +#include "SCPresets.h" + +#include +#include + +namespace SCPresets { + +using T = GoalTrigger::Type; +using MapID = GW::Constants::MapID; + +const EliteCheckpoint kFow[11] = { + { .name = "ToC", .type = T::ObjectiveDone, .param1 = 309, .start_on_objective_started = true }, + { .name = "Wailing Lord", .type = T::ObjectiveDone, .param1 = 310, .start_on_objective_started = true }, + { .name = "Griffons", .type = T::ObjectiveDone, .param1 = 311, .start_on_objective_started = true }, + { .name = "Defend", .type = T::ObjectiveDone, .param1 = 312, .start_on_objective_started = true }, + { .name = "Forge", .type = T::ObjectiveDone, .param1 = 313, .start_on_objective_started = true }, + { .name = "Menzies", .type = T::ObjectiveDone, .param1 = 314, .start_on_objective_started = true }, + { .name = "Restore", .type = T::ObjectiveDone, .param1 = 315, .start_on_objective_started = true }, + { .name = "Khobay", .type = T::ObjectiveDone, .param1 = 316, .start_on_objective_started = true }, + { .name = "ToS", .type = T::ObjectiveDone, .param1 = 317, .start_on_objective_started = true }, + { .name = "Burning Forest", .type = T::ObjectiveDone, .param1 = 318, .start_on_objective_started = true }, + { .name = "The Hunt", .type = T::ObjectiveDone, .param1 = 319, .start_on_objective_started = true }, +}; + +const EliteCheckpoint kUw[11] = { + { .name = "Chamber", .type = T::ObjectiveDone, .param1 = 146, .start_on_objective_started = true }, + { .name = "Restore", .type = T::ObjectiveDone, .param1 = 147, .start_on_objective_started = true }, + { .name = "Escort", .type = T::ObjectiveDone, .param1 = 148, .start_on_objective_started = true }, + { .name = "UWG", .type = T::ObjectiveDone, .param1 = 149, .start_on_objective_started = true }, + { .name = "Vale", .type = T::ObjectiveDone, .param1 = 150, .start_on_objective_started = true }, + { .name = "Waste", .type = T::ObjectiveDone, .param1 = 151, .start_on_objective_started = true }, + { .name = "Pits", .type = T::ObjectiveDone, .param1 = 152, .start_on_objective_started = true }, + { .name = "Planes", .type = T::ObjectiveDone, .param1 = 153, .start_on_objective_started = true }, + { .name = "Mnts", .type = T::ObjectiveDone, .param1 = 154, .start_on_objective_started = true }, + { .name = "Pools", .type = T::ObjectiveDone, .param1 = 155, .start_on_objective_started = true }, + { .name = "Dhuum", .type = T::ObjectiveDone, .param1 = 157 }, +}; + +const EliteCheckpoint kUrgoz[11] = { + { .name = "Zone 1 | Weakness", .type = T::DoorOpen, .param1 = 45420, .starts_on_area_entry = true }, + { .name = "Zone 2 | Life Drain", .type = T::DoorOpen, .param1 = 11692 }, + { .name = "Zone 3 | Levers", .type = T::DoorOpen, .param1 = 54552 }, + { .name = "Zone 4 | Bridge Wolves", .type = T::DoorOpen, .param1 = 1760 }, + { .name = "Zone 5 | More Wolves", .type = T::DoorOpen, .param1 = 40330 }, + { .name = "Zone 6 | Energy Drain", .type = T::DoorOpen, .param1 = 60114 }, + { .name = "Zone 7 | Exhaustion", .type = T::DoorOpen, .param1 = 37191 }, + { .name = "Zone 8 | Pillars", .type = T::DoorOpen, .param1 = 35500 }, + { .name = "Zone 9 | Blood Drinkers", .type = T::DoorOpen, .param1 = 34278 }, + { .name = "Zone 10 | Bridge", .type = T::DoorOpen, .param1 = 15529, + .extra_param1_a = 45631, .extra_param1_b = 53071 }, + { .name = "Zone 11 | Urgoz", .type = T::ServerMessage, + .pattern = L"\x6C9C\x0\x0\x0\x0\x2810", .extra_pattern_a = L"\x6C9C\x0\x0\x0\x0\x1488" }, +}; + +const EliteCheckpoint kDeep[13] = { + { .name = "Room 1 | Soothing", .type = T::DoorOpen, .param1 = 12669, .extra_param1_a = 11692, .starts_on_area_entry = true }, + { .name = "Room 2 | Death", .type = T::DoorOpen, .param1 = 54552, .extra_param1_a = 1760, .starts_on_area_entry = true }, + { .name = "Room 3 | Surrender", .type = T::DoorOpen, .param1 = 45425, .extra_param1_a = 48290, .starts_on_area_entry = true }, + { .name = "Room 4 | Exposure", .type = T::DoorOpen, .param1 = 40330, .extra_param1_a = 60114, .starts_on_area_entry = true }, + { .name = "Room 5 | Pain", .type = T::DoorOpen, .param1 = 29594 }, + { .name = "Room 6 | Lethargy", .type = T::DoorOpen, .param1 = 49742 }, + { .name = "Room 7 | Depletion", .type = T::DoorOpen, .param1 = 55680 }, + { .name = "Room 8-9 | Failure/Shadows", .type = T::DisplayDialogue, .pattern = L"\x5339\xA7BA\xC67B\x5D81" }, + { .name = "Room 10 | Scorpion", .type = T::DoorOpen, .param1 = 28961 }, + { .name = "Room 11 | Fear", .type = T::DisplayDialogue, .pattern = L"\x533A\xED06\x815D\x5FFB" }, + { .name = "Room 12 | Depletion", .type = T::DisplayDialogue, .pattern = L"\x533B\xCAA6\xFDA9\x3277" }, + { .name = "Room 13-14 | Decay/Torment", .type = T::DisplayDialogue, .pattern = L"\x533D\x9EB1\x8BEE\x2637" }, + { .name = "Room 15 | Kanaxai", .type = T::ServerMessage, + .pattern = L"\x6D4D\x0\x0\x0\x0\x2810", .extra_pattern_a = L"\x6D4D\x0\x0\x0\x0\x1488" }, +}; + +const EliteArea kEliteAreas[4] = { + { "Fissure of Woe", kFow, std::size(kFow), MapID::The_Fissure_of_Woe }, + { "Underworld", kUw, std::size(kUw), MapID::The_Underworld }, + { "Urgoz's Warren", kUrgoz, std::size(kUrgoz), MapID::Urgozs_Warren }, + { "The Deep", kDeep, std::size(kDeep), MapID::The_Deep }, +}; + +// clang-format off +static const MapID kOozePitLevels[] = { MapID::Ooze_Pit }; +static const MapID kFronisLevels[] = { MapID::Fronis_Irontoes_Lair_mission }; +static const MapID kSnowmenLevels[] = { MapID::Secret_Lair_of_the_Snowmen }; +static const MapID kSepulchreLevels[] = { MapID::Sepulchre_of_Dragrimmar_Level_1, MapID::Sepulchre_of_Dragrimmar_Level_2 }; +static const MapID kBogrootLevels[] = { MapID::Bogroot_Growths_Level_1, MapID::Bogroot_Growths_Level_2 }; +static const MapID kArachniLevels[] = { MapID::Arachnis_Haunt_Level_1, MapID::Arachnis_Haunt_Level_2 }; +static const MapID kCatacombsLevels[] = { MapID::Catacombs_of_Kathandrax_Level_1, MapID::Catacombs_of_Kathandrax_Level_2, MapID::Catacombs_of_Kathandrax_Level_3 }; +static const MapID kRragarsLevels[] = { MapID::Rragars_Menagerie_Level_1, MapID::Rragars_Menagerie_Level_2, MapID::Rragars_Menagerie_Level_3 }; +static const MapID kCathedralLevels[] = { MapID::Cathedral_of_Flames_Level_1, MapID::Cathedral_of_Flames_Level_2, MapID::Cathedral_of_Flames_Level_3 }; +static const MapID kDarkrimeLevels[] = { MapID::Darkrime_Delves_Level_1, MapID::Darkrime_Delves_Level_2, MapID::Darkrime_Delves_Level_3 }; +static const MapID kRavensPointLevels[] = { MapID::Ravens_Point_Level_1, MapID::Ravens_Point_Level_2, MapID::Ravens_Point_Level_3 }; +static const MapID kVloxenLevels[] = { MapID::Vloxen_Excavations_Level_1, MapID::Vloxen_Excavations_Level_2, MapID::Vloxen_Excavations_Level_3 }; +static const MapID kBloodstoneLevels[] = { MapID::Bloodstone_Caves_Level_1, MapID::Bloodstone_Caves_Level_2, MapID::Bloodstone_Caves_Level_3 }; +static const MapID kShardsOfOrrLevels[] = { MapID::Shards_of_Orr_Level_1, MapID::Shards_of_Orr_Level_2, MapID::Shards_of_Orr_Level_3 }; +static const MapID kOolasLabLevels[] = { MapID::Oolas_Lab_Level_1, MapID::Oolas_Lab_Level_2, MapID::Oolas_Lab_Level_3 }; +static const MapID kHeartShiverLevels[] = { MapID::Heart_of_the_Shiverpeaks_Level_1, MapID::Heart_of_the_Shiverpeaks_Level_2, MapID::Heart_of_the_Shiverpeaks_Level_3 }; +static const MapID kForsakenLevels[] = { MapID::Forsaken_Tunnels_Level1, MapID::Forsaken_Tunnels_Level2, MapID::Forsaken_Tunnels_Level3 }; +static const MapID kForsakenPreLevels[] = { MapID::Forsaken_Tunnels_Presearing_Level1, MapID::Forsaken_Tunnels_Presearing_Level2, MapID::Forsaken_Tunnels_Presearing_Level3 }; +static const MapID kFrostmawsLevels[] = { MapID::Frostmaws_Burrows_Level_1, MapID::Frostmaws_Burrows_Level_2, MapID::Frostmaws_Burrows_Level_3, MapID::Frostmaws_Burrows_Level_4, MapID::Frostmaws_Burrows_Level_5 }; +static const MapID kSlaversExileLevels[] = { MapID::Slavers_Exile_Level_5 }; +// clang-format on + +const Dungeon kDungeons[20] = { + { kOozePitLevels, std::size(kOozePitLevels) }, + { kFronisLevels, std::size(kFronisLevels) }, + { kSnowmenLevels, std::size(kSnowmenLevels) }, + { kSepulchreLevels, std::size(kSepulchreLevels) }, + { kBogrootLevels, std::size(kBogrootLevels) }, + { kArachniLevels, std::size(kArachniLevels) }, + { kCatacombsLevels, std::size(kCatacombsLevels) }, + { kRragarsLevels, std::size(kRragarsLevels) }, + { kCathedralLevels, std::size(kCathedralLevels) }, + { kDarkrimeLevels, std::size(kDarkrimeLevels) }, + { kRavensPointLevels, std::size(kRavensPointLevels) }, + { kVloxenLevels, std::size(kVloxenLevels) }, + { kBloodstoneLevels, std::size(kBloodstoneLevels) }, + { kShardsOfOrrLevels, std::size(kShardsOfOrrLevels) }, + { kOolasLabLevels, std::size(kOolasLabLevels) }, + { kHeartShiverLevels, std::size(kHeartShiverLevels) }, + { kForsakenLevels, std::size(kForsakenLevels) }, + { kForsakenPreLevels, std::size(kForsakenPreLevels) }, + { kFrostmawsLevels, std::size(kFrostmawsLevels) }, + { kSlaversExileLevels, std::size(kSlaversExileLevels) }, +}; + +GoalEntry BuildCheckpointGoal(const EliteCheckpoint& c, GW::Constants::MapID area_map_id) +{ + GoalEntry g; + g.label = c.name; + g.trigger.type = c.type; + g.trigger.param1 = c.param1; + if (c.pattern) g.trigger.pattern = c.pattern; + if (c.extra_param1_a) { + GoalTrigger alt; alt.type = c.type; alt.param1 = c.extra_param1_a; + g.extra_triggers.push_back(alt); + } + if (c.extra_param1_b) { + GoalTrigger alt; alt.type = c.type; alt.param1 = c.extra_param1_b; + g.extra_triggers.push_back(alt); + } + if (c.extra_pattern_a) { + GoalTrigger alt; alt.type = c.type; alt.pattern = c.extra_pattern_a; + g.extra_triggers.push_back(alt); + } + if (c.starts_on_area_entry) { // real map-entry gated start, not starts_immediately — see the struct's own comment + g.start_trigger = GoalTrigger{}; + g.start_trigger->type = GoalTrigger::Type::MapEnter; + g.start_trigger->map_id = area_map_id; + } + if (c.start_on_objective_started) { + g.start_trigger = GoalTrigger{}; + g.start_trigger->type = GoalTrigger::Type::ObjectiveStarted; + g.start_trigger->param1 = c.param1; + } + return g; +} + +GoalList BuildDungeonPresetList(const Dungeon& dungeon) +{ + GoalList list; + list.is_preset = true; + list.name = Resources::GetMapName(dungeon.levels[0])->string(); + + if (dungeon.level_count <= 1) { + // Nothing to break down — same flat single goal as Manual's picker. + GoalEntry g; + g.label = list.name; + g.trigger.type = GoalTrigger::Type::DungeonReward; + g.trigger.map_id = dungeon.levels[0]; + list.goals.push_back(std::move(g)); + return list; + } + + GoalEntry hdr; + hdr.is_header = true; + hdr.label = list.name; + hdr.trigger.map_id = dungeon.levels[0]; // read by ApplyTimerPolicy's autostart/autofail, not the engine + list.goals.push_back(std::move(hdr)); + + for (size_t i = 0; i < dungeon.level_count; ++i) { + GoalEntry g; + char label[32]; + snprintf(label, sizeof(label), "Level %zu", i + 1); + g.label = label; + g.indent = 1; + if (i + 1 < dungeon.level_count) { + // Completes on the next level's own entry, matching OT's AddObjectiveAfterAll chaining. + g.trigger.type = GoalTrigger::Type::MapEnter; + g.trigger.map_id = dungeon.levels[i + 1]; + } else { + // Final level's own map (not a "next" map) — unused by Pass 2 but needed by the auto-fail-on-rezone check, same as Mission/Bonus/VQ goals. + g.trigger.type = GoalTrigger::Type::DungeonReward; + g.trigger.map_id = dungeon.levels[i]; + } + if (i == 0) { + // Real map-entry gated start, matching OT's objectives.front()->SetStarted() (only runs once the ObjectiveSet is created by loading into this dungeon). + g.start_trigger = GoalTrigger{}; + g.start_trigger->type = GoalTrigger::Type::MapEnter; + g.start_trigger->map_id = dungeon.levels[0]; + } + list.goals.push_back(std::move(g)); + } + return list; +} + +GoalList BuildEliteAreaPresetList(const EliteArea& area) +{ + GoalList list; + list.is_preset = true; + list.name = area.label; + + GoalEntry hdr; + hdr.is_header = true; + hdr.label = area.label; + hdr.trigger.map_id = area.map_id; // read by ApplyTimerPolicy's autostart/autofail, not the engine + list.goals.push_back(std::move(hdr)); + + for (size_t i = 0; i < area.count; ++i) { + GoalEntry g = BuildCheckpointGoal(area.checkpoints[i], area.map_id); + g.indent = 1; + list.goals.push_back(std::move(g)); + } + return list; +} + +std::optional BuildPresetForMap(GW::Constants::MapID map_id) +{ + for (const auto& dungeon : kDungeons) { + for (size_t i = 0; i < dungeon.level_count; ++i) { + if (dungeon.levels[i] == map_id) return BuildDungeonPresetList(dungeon); + } + } + for (const auto& area : kEliteAreas) { + if (area.map_id == map_id) return BuildEliteAreaPresetList(area); + } + for (const auto level : kToPKLevels) { + if (level == map_id) return BuildToPKPresetList(); + } + return std::nullopt; +} + +// --------------------------------------------------------------------------- +// Domain of Anguish +// --------------------------------------------------------------------------- +// Constants copied verbatim from ObjectiveTimerWindow.cpp's DoA_ObjId/DoorID enums and AddDoAObjectiveSet. Every row gets its own explicit start_trigger (not relay), since DoA's zone order rotates per run and relay would misdate a zone's start to whenever the previous one finished. +namespace { + // DoACompleteZone's param1 ("zone message word") — OT's DoA_ObjId enum. + constexpr uint32_t kDoAFoundry = 0x273F; + constexpr uint32_t kDoAVeil = 0x2740; + constexpr uint32_t kDoAGloom = 0x2741; + constexpr uint32_t kDoACity = 0x2742; + + constexpr uint32_t kDoAFoundryEntranceR1 = 39534; + constexpr uint32_t kDoAFoundryR1R2 = 6356; + constexpr uint32_t kDoAFoundryR2R3 = 45276; + constexpr uint32_t kDoAFoundryR3R4 = 55421; + constexpr uint32_t kDoAFoundryR4R5 = 49719; + constexpr uint32_t kDoAFoundryR5Bb = 45667; + constexpr uint32_t kDoACityEntrance = 63939; + constexpr uint32_t kDoACityWall = 54727; + constexpr uint32_t kDoAVeil360Left = 13005; + constexpr uint32_t kDoAVeil360Middle = 11772; + constexpr uint32_t kDoAVeil360Right = 28851; + constexpr uint32_t kDoAVeilDerv = 56510; + constexpr uint32_t kDoAVeilRanger = 4753; + constexpr uint32_t kDoAVeilTrenchNecro = 46650; + constexpr uint32_t kDoAVeilTrenchMes = 29594; + constexpr uint32_t kDoAVeilTrenchEle = 49742; + constexpr uint32_t kDoAVeilTrenchMonk = 55680; + constexpr uint32_t kDoAVeilTrenchGloom = 28961; + + constexpr uint32_t kDoABlackBeastModelId = 5221; + constexpr uint32_t kDoAAllyFlag = 0x6E6F6E63; + + constexpr wchar_t kDoAFuryDialogue[] = L"\x8101\x273D\x98DB\xB91A"; + constexpr wchar_t kDoACaveStartDialogue[] = L"\x8101\x5765\x9846\xA72B"; + constexpr wchar_t kDoACaveEndDialogue[] = L"\x8101\x5767\xA547\xB2C2"; + constexpr wchar_t kDoADarknessesDialogue[] = L"\x8101\x273B\xB5DB\x8B13"; + constexpr wchar_t kDoATendrilsDialogue[] = L"\x8101\x34C1\x9FA1\xED8F\x1BE4"; + + // Nearest-neighbor rotation detection matching AddDoAObjectiveSet's own starting_area lambda; if Mallyx's spawn is closest of all 5 candidates, this isn't a DoA run at all. + constexpr GW::Vec2f kDoAMallyxSpawn(-3931, -6214); + constexpr GW::Vec2f kDoAAreaSpawns[4] = { + {-10514, 15231}, // Foundry + {-18575, -8833}, // City + {364, -10445}, // Veil + {16034, 1244}, // Gloom + }; + + // Builds one DoA sub-objective matching OT's per-objective AddStartEvent/AddEndEvent pairs; start_extra/end_extra are alternate doors (OR semantics) for OT's multi-door starts/ends. + GoalEntry MakeDoAGoal(const char* label, + GoalTrigger::Type start_type, uint32_t start_param1, + std::initializer_list start_extra, + const wchar_t* start_pattern, + GoalTrigger::Type end_type, uint32_t end_param1, + std::initializer_list end_extra = {}, + const wchar_t* end_pattern = nullptr, uint32_t end_param2 = 0) + { + GoalEntry g; + g.label = label; + g.indent = 2; // nested two levels under BuildDoAPresetForZone's root header -> zone header -> room + g.trigger.type = end_type; + g.trigger.param1 = end_param1; + g.trigger.param2 = end_param2; + if (end_pattern) g.trigger.pattern = end_pattern; + for (const uint32_t extra : end_extra) { + GoalTrigger alt; + alt.type = end_type; + alt.param1 = extra; + g.extra_triggers.push_back(alt); + } + g.start_trigger = GoalTrigger{}; + g.start_trigger->type = start_type; + g.start_trigger->param1 = start_param1; + if (start_pattern) g.start_trigger->pattern = start_pattern; + for (const uint32_t extra : start_extra) { + GoalTrigger alt; + alt.type = start_type; + alt.param1 = extra; + g.extra_start_triggers.push_back(alt); + } + return g; + } + + std::vector BuildDoAFoundryChildren() + { + std::vector goals; + goals.push_back(MakeDoAGoal("Room 1", T::DoorClose, kDoAFoundryEntranceR1, {}, nullptr, + T::DoorOpen, kDoAFoundryR1R2)); + goals.push_back(MakeDoAGoal("Room 2", T::DoorClose, kDoAFoundryR1R2, {}, nullptr, + T::DoorOpen, kDoAFoundryR2R3)); + goals.push_back(MakeDoAGoal("Room 3", T::DoorClose, kDoAFoundryR2R3, {}, nullptr, + T::DoorOpen, kDoAFoundryR3R4)); + goals.push_back(MakeDoAGoal("Room 4", T::DoorClose, kDoAFoundryR3R4, {}, nullptr, + T::DoorOpen, kDoAFoundryR4R5)); + goals.push_back(MakeDoAGoal("Black Beast", T::DoorOpen, kDoAFoundryR5Bb, {}, nullptr, + T::AgentUpdateAllegiance, kDoABlackBeastModelId, {}, nullptr, kDoAAllyFlag)); + goals.push_back(MakeDoAGoal("Fury", T::DisplayDialogue, 0, {}, kDoAFuryDialogue, + T::DoACompleteZone, kDoAFoundry)); + return goals; + } + + std::vector BuildDoACityChildren() + { + std::vector goals; + goals.push_back(MakeDoAGoal("Outside", T::DoorOpen, kDoACityEntrance, {}, nullptr, + T::DoorOpen, kDoACityWall)); + goals.push_back(MakeDoAGoal("Inside", T::DoorOpen, kDoACityWall, {}, nullptr, + T::DoACompleteZone, kDoACity)); + return goals; + } + + std::vector BuildDoAVeilChildren() + { + std::vector goals; + // "360"/"Underlords"/"Lords" have no explicit end event in OT (informational start-only) — each one's completion here borrows the next row's own start condition instead. + goals.push_back(MakeDoAGoal("360", T::DoorOpen, kDoAVeil360Left, + {kDoAVeil360Middle, kDoAVeil360Right}, nullptr, + T::DoorOpen, kDoAVeilRanger, {kDoAVeilDerv})); + goals.push_back(MakeDoAGoal("Underlords", T::DoorOpen, kDoAVeilRanger, {kDoAVeilDerv}, nullptr, + T::DoorOpen, kDoAVeilTrenchGloom, + {kDoAVeilTrenchMonk, kDoAVeilTrenchEle, kDoAVeilTrenchMes, kDoAVeilTrenchNecro})); + goals.push_back(MakeDoAGoal("Lords", T::DoorOpen, kDoAVeilTrenchGloom, + {kDoAVeilTrenchMonk, kDoAVeilTrenchEle, kDoAVeilTrenchMes, kDoAVeilTrenchNecro}, + nullptr, + T::DisplayDialogue, 0, {}, kDoATendrilsDialogue)); + goals.push_back(MakeDoAGoal("Tendrils", T::DisplayDialogue, 0, {}, kDoATendrilsDialogue, + T::DoACompleteZone, kDoAVeil)); + return goals; + } + + std::vector BuildDoAGloomChildren() + { + std::vector goals; + goals.push_back(MakeDoAGoal("Cave", T::DisplayDialogue, 0, {}, kDoACaveStartDialogue, + T::DisplayDialogue, 0, {}, kDoACaveEndDialogue)); + goals.push_back(MakeDoAGoal("Darknesses", T::DisplayDialogue, 0, {}, kDoADarknessesDialogue, + T::DoACompleteZone, kDoAGloom)); + return goals; + } +} // namespace + +int DetectDoAStartingZone(const GW::Vec2f spawn) +{ + double best_dist = GW::GetDistance(spawn, kDoAMallyxSpawn); + int starting_area = -1; + for (int i = 0; i < 4; ++i) { + const float dist = GW::GetDistance(spawn, kDoAAreaSpawns[i]); + if (best_dist > dist) { + best_dist = dist; + starting_area = i; + } + } + return starting_area; // -1 = Mallyx, not DoA +} + +GoalList BuildDoAPresetForZone(const int starting_zone) +{ + struct ZoneBlock { + const char* label; + std::vector (*build)(); + }; + static const ZoneBlock kZones[4] = { + {"Foundry", BuildDoAFoundryChildren}, + {"City", BuildDoACityChildren}, + {"Veil", BuildDoAVeilChildren}, + {"Gloom", BuildDoAGloomChildren}, + }; + + GoalList list; + list.is_preset = true; + list.name = Resources::GetMapName(GW::Constants::MapID::Domain_of_Anguish)->string(); + + GoalEntry hdr; + hdr.is_header = true; + hdr.label = list.name; + hdr.trigger.map_id = GW::Constants::MapID::Domain_of_Anguish; + list.goals.push_back(std::move(hdr)); + + for (int i = 0; i < 4; ++i) { + const ZoneBlock& zone = kZones[(starting_zone + i) % 4]; + + GoalEntry zone_hdr; + zone_hdr.is_header = true; + zone_hdr.indent = 1; // child of the root header above, not a sibling — lets collapsing the root hide every zone + zone_hdr.label = zone.label; + zone_hdr.trigger.map_id = GW::Constants::MapID::Domain_of_Anguish; + list.goals.push_back(std::move(zone_hdr)); + + auto children = zone.build(); + // Whichever zone lands first in rotation: its entrance DoorClose may already have fired before this list finished (re-)attaching after a swap, and a past network event can't be recovered — starts_immediately is safe here since this whole builder only ever runs off a confirmed InstanceLoadFile signal, i.e. we already know we're in DoA right now. + if (i == 0 && !children.empty()) children.front().starts_immediately = true; + for (auto& g : children) list.goals.push_back(std::move(g)); + } + return list; +} + +// --------------------------------------------------------------------------- +// Tomb of the Primeval Kings +// --------------------------------------------------------------------------- +const MapID kToPKLevels[4] = { + MapID::The_Underworld_PvP, + MapID::Scarred_Earth, + MapID::The_Courtyard, + MapID::The_Hall_of_Heroes, +}; + +GoalList BuildToPKPresetList() +{ + GoalList list; + list.is_preset = true; + list.name = Resources::GetMapName(MapID::Tomb_of_the_Primeval_Kings)->string(); + + GoalEntry hdr; + hdr.is_header = true; + hdr.label = list.name; + hdr.trigger.map_id = kToPKLevels[0]; // entry map, read by ApplyTimerPolicy's autostart fallback + list.goals.push_back(std::move(hdr)); + + for (size_t i = 0; i < 4; ++i) { + GoalEntry g; + g.label = Resources::GetMapName(kToPKLevels[i])->string(); + g.indent = 1; + g.trigger.type = T::CountdownStart; + g.trigger.param1 = static_cast(kToPKLevels[i]); // matched via matchesPendingTrigger + g.trigger.map_id = kToPKLevels[i]; // read by GoalEngine's auto-fail rezone check, not Pass 2 + g.start_trigger = GoalTrigger{}; + // Only the entry map is explorable-ambiguous (shared with a non-ToPK outpost); the other 3 are only reached mid-run, so plain MapEnter is unambiguous there. + g.start_trigger->type = (i == 0) ? T::EnterExplorable : T::MapEnter; + g.start_trigger->map_id = kToPKLevels[i]; + list.goals.push_back(std::move(g)); + } + return list; +} + +} // namespace SCPresets diff --git a/GWToolboxdll/Windows/Splits/SCPresets.h b/GWToolboxdll/Windows/Splits/SCPresets.h new file mode 100644 index 000000000..83a125668 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/SCPresets.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "GoalEntry.h" +#include "GoalList.h" + +// --------------------------------------------------------------------------- +// SC preset data — shared by SplitsGoalListWindow's interactive pickers and +// SplitsWindow::ApplySCAutoLoadPreset; always built live, never cached to disk. +// Door/objective ids and dialogue patterns are copied verbatim from +// ObjectiveTimerWindow.cpp's own AddFoWObjectiveSet/AddUWObjectiveSet/ +// AddUrgozObjectiveSet/AddDeepObjectiveSet. +// --------------------------------------------------------------------------- +namespace SCPresets { + +struct EliteCheckpoint { + const char* name; + GoalTrigger::Type type; + uint32_t param1 = 0; + const wchar_t* pattern = nullptr; + uint32_t extra_param1_a = 0; // second alternative trigger (0 = none) + uint32_t extra_param1_b = 0; // third alternative trigger (0 = none) + const wchar_t* extra_pattern_a = nullptr; // second alternative pattern (nullptr = none) + // Uses a real MapEnter start_trigger, not starts_immediately (which fires at list-load time regardless of player location — confirmed live as a bug). + bool starts_on_area_entry = false; + bool start_on_objective_started = false; // start_trigger = ObjectiveStarted(param1) +}; + +struct EliteArea { + const char* label; + const EliteCheckpoint* checkpoints; + size_t count; + GW::Constants::MapID map_id; // stamped on the auto-created header (full-set pick) so ApplyTimerPolicy can auto-start/auto-fail on it, same as OT +}; + +extern const EliteCheckpoint kFow[11]; // OT's AddQuestObjective: every quest gets ObjectiveStarted (start) and ObjectiveDone (end) off the same objective_id +extern const EliteCheckpoint kUw[11]; // same ObjectiveDone/ObjectiveStarted mechanism as FoW, plus a final Dhuum-kill checkpoint (relay off Pools covers its start) +extern const EliteCheckpoint kUrgoz[11]; // each zone's completion is the door that opens the next (OT's AddObjectiveAfterAll chain); Zone 1 is OT's only explicit SetStarted() +extern const EliteCheckpoint kDeep[13]; // Rooms 1-4 are OT's explicit parallel SetStarted()s; relay covers Room 5 onward as a single-file chain + +// Fissure of Woe / Underworld / Urgoz's Warren / The Deep. ToPK deliberately excluded — its arenas are map-based (InstanceLoadInfo/CountdownStart), not an objective/door checklist. +extern const EliteArea kEliteAreas[4]; + +// Straight from ObjectiveTimerWindow::AddObjectiveSet()'s own AddDungeonObjectiveSet calls. levels[0] names the dungeon for Manual's flat picker, which doesn't break dungeons down by level. +struct Dungeon { + const GW::Constants::MapID* levels; + size_t level_count; +}; +extern const Dungeon kDungeons[20]; + +// Shared by the interactive picker's Add button and the preset generator so both produce identical goals; area_map_id only matters when starts_on_area_entry is set. +GoalEntry BuildCheckpointGoal(const EliteCheckpoint& c, GW::Constants::MapID area_map_id); + +// SC only: one goal per level (matching OT's AddDungeonObjectiveSet — each level completes via the next one's MapEnter, only the final level ends on the real DungeonReward chest). +GoalList BuildDungeonPresetList(const Dungeon& dungeon); + +// Header + every checkpoint for a full elite area (mirrors the interactive picker's "select all" path, including the header's map_id for autostart/autofail). +GoalList BuildEliteAreaPresetList(const EliteArea& area); + +// Builds the preset covering this map (checks every level of every dungeon, not just the first, so level 2+ still resolves), or std::nullopt if unknown. Always builds fresh, nothing to regenerate. +std::optional BuildPresetForMap(GW::Constants::MapID map_id); + +// Domain of Anguish: zone rotation is spawn-dependent, so unlike everything else here callers pre-build all 4 rotations ahead of time (see SplitsWindow::doa_preset_cache_) instead of building fresh at the zone-transition tick. +// -1 = Mallyx (not a DoA run), else 0-3. +int DetectDoAStartingZone(GW::Vec2f spawn); +GoalList BuildDoAPresetForZone(int starting_zone); + +// Tomb of the Primeval Kings: fixed order (no rotation) matching OT's AddToPKObjectiveSet. First map's entry is EnterExplorable since its map_id is shared with a non-ToPK outpost use. +extern const GW::Constants::MapID kToPKLevels[4]; +GoalList BuildToPKPresetList(); + +} // namespace SCPresets diff --git a/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.cpp b/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.cpp new file mode 100644 index 000000000..7ccf456c2 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.cpp @@ -0,0 +1,1943 @@ +#include "stdafx.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Routed through kOpenWikiUrl (same mechanism the skill listing's Wiki button uses) rather than ShellExecute; enqueued since this fires from an ImGui render callback, not the game thread. +static void OpenGameIntegrationWikiPage() +{ + GW::GameThread::Enqueue([] { + GW::UI::SendUIMessage(GW::UI::UIMessage::kOpenWikiUrl, + const_cast("https://wiki.guildwars.com/wiki/Guild_Wars_Wiki:Game_integration")); + }); +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- +static void FormatTime(char* buf, int bufsz, double seconds) +{ + const int h = static_cast(seconds) / 3600; + const int m = (static_cast(seconds) % 3600) / 60; + const int s = static_cast(seconds) % 60; + const int cs = static_cast(seconds * 100.0) % 100; + if (h > 0) + snprintf(buf, bufsz, "%d:%02d:%02d.%02d", h, m, s, cs); + else + snprintf(buf, bufsz, "%02d:%02d.%02d", m, s, cs); +} + +// 0 = "MM:SS.CC", 1 = "H:MM:SS.CC", 2 = "HH:MM:SS.CC" — matches FormatTime's own digit growth. +static int TimeDigitTier(double seconds) +{ + const int h = static_cast(seconds) / 3600; + return h >= 10 ? 2 : (h >= 1 ? 1 : 0); +} + +// Measured once per tier (font/DPI don't change mid-session) instead of every frame — widens the goal row's time columns only once the clock actually needs the extra digit, instead of always reserving room for the worst case. +static float TimeColumnWidth(int tier, bool with_sign) +{ + static float w[2][3] = {{-1.f, -1.f, -1.f}, {-1.f, -1.f, -1.f}}; + float& slot = w[with_sign ? 1 : 0][tier]; + if (slot < 0.f) { + static const char* plain[3] = {"88:88.88", "8:88:88.88", "88:88:88.88"}; + static const char* signed_[3] = {"+88:88.88", "+8:88:88.88", "+88:88:88.88"}; + slot = ImGui::CalcTextSize(with_sign ? signed_[tier] : plain[tier]).x + 12.f; + } + return slot; +} + +static void DrawPBDelta(double actual, double pb_split, ImVec4 col_ahead, ImVec4 col_behind) +{ + if (std::isnan(pb_split) || std::isnan(actual)) return; + const double delta = actual - pb_split; + char dbuf[32]; + FormatTime(dbuf, sizeof(dbuf), std::abs(delta)); + ImGui::TextColored(delta < 0.0 ? col_ahead : col_behind, delta < 0.0 ? "-%s" : "+%s", dbuf); +} + +// ============================================================================= +// LIVE WINDOW DRAW +// ============================================================================= + +// --------------------------------------------------------------------------- +// Main window Draw +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::Draw(SplitsWindow& plugin) +{ + const bool is_open = ImGui::Begin(plugin.Name(), plugin.GetVisiblePtr(), plugin.GetWinFlags()); + + if (!is_open) { + ImGui::End(); + return; + } + + // Resume modal + if (plugin.HasPendingResume()) + ImGui::OpenPopup("Resume Run?"); + if (ImGui::BeginPopupModal("Resume Run?", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Run '%s' was interrupted.", plugin.PendingResumeName()); + ImGui::Text("Resume from where you left off?"); + ImGui::Spacing(); + if (ImGui::Button("Resume", {110, 0})) { plugin.ApplyResume(); ImGui::CloseCurrentPopup(); } + ImGui::SameLine(); + if (ImGui::Button("Discard", {110, 0})) { plugin.DiscardResume(); ImGui::CloseCurrentPopup(); } + ImGui::EndPopup(); + } + + const GoalClock& clock = plugin.Clock(); + const GoalList* list = plugin.List(); + + // Header clock — shows whichever time the profile is set to display, plus run controls. + { + const SplitsProfile& hp = plugin.ActiveProfile(); + using TD = SplitsProfile::TimeDisplay; + const auto td = hp.time_display; + const float avail = ImGui::GetContentRegionAvail().x; + const float min_x = ImGui::GetCursorPosX(); + + char tbuf_r[32], tbuf_g[32]; + FormatTime(tbuf_r, sizeof(tbuf_r), clock.RealTime()); + FormatTime(tbuf_g, sizeof(tbuf_g), clock.GameTime()); + + float total_w; + if (td == TD::Both) { + total_w = ImGui::CalcTextSize("Real: ").x + ImGui::CalcTextSize(tbuf_r).x + + ImGui::CalcTextSize(" Game: ").x + ImGui::CalcTextSize(tbuf_g).x; + } else { + const bool gt = (td == TD::Game); + const char* lbl = gt ? "Game: " : "Real: "; + total_w = ImGui::CalcTextSize(lbl).x + ImGui::CalcTextSize(gt ? tbuf_g : tbuf_r).x; + } + + // Computed here (not down by the buttons themselves) so the clock text below can be clamped against it on a narrow window. + const bool running = plugin.Clock().IsRunning(); + const char* lbl0 = running ? "Pause" : "Start"; + const float fp_x = ImGui::GetStyle().FramePadding.x; + const float sp = ImGui::GetStyle().ItemSpacing.x; + const float bw0 = ImGui::CalcTextSize(lbl0).x + fp_x * 2.f; + const float bw1 = ImGui::CalcTextSize("Reset").x + fp_x * 2.f; + const float bw2 = ImGui::CalcTextSize("Split").x + fp_x * 2.f; + const float bwg = ImGui::CalcTextSize(ICON_FA_COGS).x + fp_x * 2.f; + const float buttons_w = bw0 + bw1 + bw2 + bwg + sp * 3.f; + const float button_x = min_x + avail - buttons_w; + + const float start_x = min_x + (avail - total_w) * 0.5f; + float clamped_start_x = start_x < min_x ? min_x : start_x; + // Centering can otherwise push the clock text right underneath the buttons once the window's too narrow to fit both. + const float max_start_x = button_x - total_w - sp; + if (clamped_start_x > max_start_x) clamped_start_x = max_start_x; + if (clamped_start_x < min_x) clamped_start_x = min_x; + + if (plugin.NuzlockePointsEnabled()) { + ImGui::SetCursorPosX(min_x); + ImGui::TextColored({0.6f, 0.85f, 1.f, 1.f}, "Points: %d", plugin.NuzlockeTotalPoints()); + ImGui::SameLine(0, 0); + } + + if (hp.show_paused_time) { + char pbuf[32]; FormatTime(pbuf, sizeof(pbuf), plugin.TotalPausedReal()); + char ptext[48]; snprintf(ptext, sizeof(ptext), "Paused: %s", pbuf); + const float pause_w = ImGui::CalcTextSize(ptext).x; + const float pause_x = clamped_start_x - 12.f - pause_w; + ImGui::SetCursorPosX(pause_x > min_x ? pause_x : min_x); + ImGui::TextColored({1.f, 0.8f, 0.3f, 1.f}, "%s", ptext); + ImGui::SameLine(0, 0); + } + + const ImVec4 real_col = ImGui::ColorConvertU32ToFloat4(plugin.ColorRealTime()); + const ImVec4 game_col = ImGui::ColorConvertU32ToFloat4(plugin.ColorGameTime()); + ImGui::SetCursorPosX(clamped_start_x); + if (td == TD::Both) { + ImGui::TextColored(real_col, "Real: %s", tbuf_r); + ImGui::SameLine(0, 0); + ImGui::TextColored(game_col, " Game: %s", tbuf_g); + } else { + const bool gt = (td == TD::Game); + ImGui::TextColored(gt ? game_col : real_col, gt ? "Game: %s" : "Real: %s", gt ? tbuf_g : tbuf_r); + } + + // Run controls + settings gear, right-aligned on the same row as the clock (widths/button_x computed above, alongside the clock-text clamp). + ImGui::SameLine(button_x); + if (ImGui::Button(lbl0, {bw0, 0})) plugin.StartRun(); + ImGui::SameLine(0, sp); + if (ImGui::Button("Reset", {bw1, 0})) plugin.ResetRun(); + ImGui::SameLine(0, sp); + if (ImGui::Button("Split", {bw2, 0})) plugin.TriggerManualSplit(); + ImGui::SameLine(0, sp); + if (ImGui::Button(ICON_FA_COGS, {bwg, 0})) + SettingsWindow::Instance().NavigateToSection(plugin.SettingsName()); + } + + ImGui::Separator(); + + if (plugin.RunComplete()) { + const float avail = ImGui::GetContentRegionAvail().x; + static const char* kMsg = "Run complete!"; + const float tw = ImGui::CalcTextSize(kMsg).x; + const float cx = ImGui::GetCursorPosX() + (avail - tw) * 0.5f; + ImGui::SetCursorPosX(cx > ImGui::GetCursorPosX() ? cx : ImGui::GetCursorPosX()); + ImGui::TextColored({0.4f, 1.f, 0.4f, 1.f}, "%s", kMsg); + ImGui::Separator(); + } + + if (plugin.RunFailed()) { + const float avail = ImGui::GetContentRegionAvail().x; + static const char* kMsg = "Run failed"; + const float tw = ImGui::CalcTextSize(kMsg).x; + const float cx = ImGui::GetCursorPosX() + (avail - tw) * 0.5f; + ImGui::SetCursorPosX(cx > ImGui::GetCursorPosX() ? cx : ImGui::GetCursorPosX()); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(plugin.ColorPbBehind()), "%s", kMsg); + ImGui::Separator(); + } + + // List picker — only visible when the run hasn't started so switching mid-run isn't possible. + if (plugin.Clock().RealTime() == 0.0) { + const char* current = (list && !list->name.empty()) ? list->name.c_str() : "(no list)"; + ImGui::SetNextItemWidth(-1.f); + if (ImGui::BeginCombo("##list_picker", current)) { + for (const auto& [name, path] : plugin.GetSavedLists()) { + const bool selected = list && list->name == name; + if (ImGui::Selectable(name.c_str(), selected)) + plugin.LoadActiveList(path); + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + ImGui::Separator(); + } + + if (!list || list->goals.empty()) { + ImGui::TextDisabled("No goal list loaded. Open Settings to create one."); + ImGui::Separator(); + plugin.DrawNuzlockeSection(); + ImGui::End(); + return; + } + + // Skip headers when computing the first non-finished goal. + int current_idx = -1; + for (int i = 0; i < static_cast(list->goals.size()); ++i) { + if (list->goals[i].is_header) continue; + const GoalStatus s = list->goals[i].status; + if (s != GoalStatus::Completed && s != GoalStatus::Failed) { current_idx = i; break; } + } + + // Manual/Running goals relay-start off the previous one, so this column never differs from it — SC's parallel start_trigger objectives (e.g. Deep rooms) are the only case where it can. + const SplitsProfile& profile = plugin.ActiveProfile(); + const std::vector& pb_real = plugin.CompareSplits(); + const std::vector& pb_game = plugin.CompareSplitsGame(); + const auto nan = std::numeric_limits::quiet_NaN(); + + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + const float pad_x = 2.f; + const float pad_y = 1.f; + const float win_x = ImGui::GetWindowPos().x; + const float avail_w = ImGui::GetContentRegionAvail().x; + + // pb_idx counts only non-header goals; PB arrays are indexed the same way. + int pb_idx = 0; + + for (int i = 0; i < static_cast(list->goals.size()); ) { + const auto& g = list->goals[i]; + + if (g.is_header) { + const bool hdr_open = DrawHeaderRow(*list, i, profile); + ++i; + if (!hdr_open) { + // collapsed — skip until next header at same/shallower indent, advancing pb_idx to keep PB alignment + while (i < static_cast(list->goals.size())) { + const auto& child = list->goals[i]; + if (child.is_header && child.indent <= g.indent) break; + if (!child.is_header) ++pb_idx; + ++i; + } + } + continue; + } + + auto pb_at_v = [&](const std::vector& v) -> double { + if (pb_idx < 0 || pb_idx >= static_cast(v.size())) return nan; + return v[static_cast(pb_idx)]; + }; + auto pb_seg_v = [&](const std::vector& v) -> double { + const double cur = pb_at_v(v); + if (std::isnan(cur)) return nan; + if (pb_idx == 0) return cur; + if (pb_idx - 1 >= static_cast(v.size())) return nan; + const double prev = v[static_cast(pb_idx - 1)]; + return std::isnan(prev) ? nan : (cur - prev); + }; + + const float row_y0 = ImGui::GetCursorScreenPos().y - pad_y; + + DrawGoalRow(g, clock, i, i == current_idx, + pb_at_v(pb_real), pb_seg_v(pb_real), + pb_at_v(pb_game), pb_seg_v(pb_game), + profile, plugin); + ++pb_idx; + ++i; + + const float row_y1 = ImGui::GetCursorScreenPos().y + pad_y; + draw_list->AddRect({win_x + pad_x, row_y0}, {win_x + avail_w - pad_x, row_y1}, + IM_COL32(80, 80, 80, 140), 2.f); + } + + ImGui::Separator(); + plugin.DrawNuzlockeSection(); + DrawRecentRunsSection(plugin); + + ImGui::End(); +} + +void SplitsGoalListWindow::DrawGoalRow(const GoalEntry& g, const GoalClock& clock, + int /*index*/, bool is_current, + double pb_split_real, double pb_seg_real, + double pb_split_game, double pb_seg_game, + const SplitsProfile& profile, SplitsWindow& plugin) +{ + const bool done = (g.status == GoalStatus::Completed || g.status == GoalStatus::Failed); + + ImVec4 color; + if (g.status == GoalStatus::Failed) color = ImGui::ColorConvertU32ToFloat4(plugin.ColorPbBehind()); + else if (g.status == GoalStatus::Completed) color = ImGui::ColorConvertU32ToFloat4(plugin.ColorCompleted()); + // added to support current highlighting for multiple goals at a time + else if (is_current || g.status == GoalStatus::Started) color = ImGui::ColorConvertU32ToFloat4(plugin.ColorActive()); + else color = ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled); + + using TD = SplitsProfile::TimeDisplay; + const auto td = profile.time_display; + const bool show_real = (td != TD::Game) && !(td == TD::Both && profile.both_header_only); + const bool show_game = (td != TD::Real); + + const ImVec4 real_col = ImGui::ColorConvertU32ToFloat4(plugin.ColorRealTime()); + const ImVec4 game_col = ImGui::ColorConvertU32ToFloat4(plugin.ColorGameTime()); + const ImVec4 ahead_col = ImGui::ColorConvertU32ToFloat4(plugin.ColorPbAhead()); + const ImVec4 behind_col = ImGui::ColorConvertU32ToFloat4(plugin.ColorPbBehind()); + auto muted = [](ImVec4 c) { return ImVec4{c.x, c.y, c.z, c.w * 0.55f}; }; + + if (g.display_style == GoalEntry::DisplayStyle::Dynamic || profile.dynamic_by_default) { + // Start/End/Duration for THIS goal alone, not "vs previous row" — needed for independent-start goals (Quest pickup, Deep's parallel rooms). + const bool started = g.start_real_time >= 0.0; + auto draw_or_dash = [&](const ImVec4& col, double t, bool valid) { + char buf[32]; + if (valid) FormatTime(buf, sizeof(buf), t); + else snprintf(buf, sizeof(buf), "--:--"); + ImGui::TextColored(col, "%s", buf); + }; + + const int tier = TimeDigitTier(std::max(clock.RealTime(), clock.GameTime())); + const float time_w = TimeColumnWidth(tier, /*with_sign=*/false); + const float dur_w = TimeColumnWidth(tier, /*with_sign=*/true); + + if (!ImGui::BeginTable("##goalrow_dyn", 4, ImGuiTableFlags_None)) return; + ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("start", ImGuiTableColumnFlags_WidthFixed, time_w); + ImGui::TableSetupColumn("end", ImGuiTableColumnFlags_WidthFixed, time_w); + ImGui::TableSetupColumn("dur", ImGuiTableColumnFlags_WidthFixed, dur_w); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + if (g.indent > 0) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + g.indent * 12.f); + ImGui::TextColored(color, "%s", g.label.c_str()); + + ImGui::TableSetColumnIndex(1); // Start + if (show_real) draw_or_dash(real_col, g.start_real_time, started); + if (show_game) draw_or_dash(game_col, g.start_game_time, started); + + ImGui::TableSetColumnIndex(2); // End + if (show_real) draw_or_dash(real_col, g.split.real_time, done); + if (show_game) draw_or_dash(game_col, g.split.game_time, done); + + ImGui::TableSetColumnIndex(3); // Duration = End - Start + const bool have_duration = started && done; + if (show_real) { + draw_or_dash(real_col, g.split.real_time - g.start_real_time, have_duration); + if (have_duration && profile.show_segment_pb) + DrawPBDelta(g.split.real_time - g.start_real_time, pb_seg_real, ahead_col, behind_col); + } + if (show_game) { + draw_or_dash(game_col, g.split.game_time - g.start_game_time, have_duration); + if (have_duration && profile.show_segment_pb) + DrawPBDelta(g.split.game_time - g.start_game_time, pb_seg_game, ahead_col, behind_col); + } + + ImGui::EndTable(); + return; + } + + if (!ImGui::BeginTable("##goalrow", 3, ImGuiTableFlags_None)) return; + ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("time", ImGuiTableColumnFlags_WidthFixed, 110.f); + ImGui::TableSetupColumn("seg", ImGuiTableColumnFlags_WidthFixed, profile.show_segment ? 80.f : 0.f); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + if (g.indent > 0) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + g.indent * 12.f); + ImGui::TextColored(color, "%s", g.label.c_str()); + + if (g.status != GoalStatus::Completed && g.trigger.type == GoalTrigger::Type::ReachTitleRank) { + auto* title = GW::PlayerMgr::GetTitleTrack(g.trigger.title_id); + if (title) { + if (title->current_points == 0) { + ImGui::TextDisabled("No progress detected"); + } else { + const bool at_max = (title->points_needed_next_rank == 0xFFFFFFFF); + char buf[96]; + if (title->is_percentage_based()) { + // Points are percentage × 10 (e.g. 863 = 86.3%) + const float cur = title->current_points * 0.1f; + const float next = at_max ? 100.0f : title->points_needed_next_rank * 0.1f; + if (at_max) + snprintf(buf, sizeof(buf), "%.1f%% (Max)", cur); + else + snprintf(buf, sizeof(buf), "%.1f%% / %.1f%%", cur, next); + } else { + auto* wc = GW::GetWorldContext(); + uint32_t cur_rank = 0; + if (wc && title->current_title_tier_index < wc->title_tiers.size()) + cur_rank = wc->title_tiers[title->current_title_tier_index].tier_number; + const uint32_t max_rank = title->max_title_rank; + if (at_max) + snprintf(buf, sizeof(buf), "rank %u/%u (Max) %u pts", + cur_rank, max_rank, title->current_points); + else + snprintf(buf, sizeof(buf), "rank %u/%u %u / %u", + cur_rank, max_rank, + title->current_points, title->points_needed_next_rank); + } + ImGui::TextDisabled("%s", buf); + } // end else (has progress) + } + } + + if (g.status != GoalStatus::Completed && g.trigger.type == GoalTrigger::Type::MobKill) { + const uint32_t target = g.trigger.param2 > 0 ? g.trigger.param2 : 1; + ImGui::TextDisabled("kills: %d / %u", g.trigger_progress, target); + } + + { + ImGui::TableSetColumnIndex(1); + if (done) { + if (show_real) { + char buf[32]; FormatTime(buf, sizeof(buf), g.split.real_time); + ImGui::TextColored(real_col, "%s", buf); + if (profile.show_split_pb) DrawPBDelta(g.split.real_time, pb_split_real, ahead_col, behind_col); + } + if (show_game) { + char buf[32]; FormatTime(buf, sizeof(buf), g.split.game_time); + ImGui::TextColored(game_col, "%s", buf); + if (profile.show_split_pb) DrawPBDelta(g.split.game_time, pb_split_game, ahead_col, behind_col); + } + } else if (is_current) { + ImGui::TextDisabled("---"); + } + } + + if (profile.show_segment) { + ImGui::TableSetColumnIndex(2); + if (done) { + if (show_real) { + char seg[32]; FormatTime(seg, sizeof(seg), g.split.segment_real); + ImGui::TextColored(muted(real_col), "+%s", seg); + if (profile.show_segment_pb) DrawPBDelta(g.split.segment_real, pb_seg_real, ahead_col, behind_col); + } + if (show_game) { + char seg[32]; FormatTime(seg, sizeof(seg), g.split.segment_game); + ImGui::TextColored(muted(game_col), "+%s", seg); + if (profile.show_segment_pb) DrawPBDelta(g.split.segment_game, pb_seg_game, ahead_col, behind_col); + } + } + } + + ImGui::EndTable(); +} + +bool SplitsGoalListWindow::DrawHeaderRow(const GoalList& list, int header_idx, const SplitsProfile& profile) +{ + const GoalEntry& h = list.goals[header_idx]; + + using TD = SplitsProfile::TimeDisplay; + const bool gt = (profile.time_display == TD::Game) || + (profile.time_display == TD::Both && profile.both_header_only); + + // Derive timing and status from all non-header descendants. + double start_t = -1.0, end_t = -1.0; + bool any_started = false, any_failed = false, all_done = true, has_any = false; + + for (int j = header_idx + 1; j < static_cast(list.goals.size()); ++j) { + const GoalEntry& child = list.goals[j]; + if (child.indent <= h.indent) break; + if (child.is_header) continue; + has_any = true; + + if (child.status == GoalStatus::Failed) any_failed = true; + if (child.status == GoalStatus::Started) any_started = true; + if (child.status != GoalStatus::Completed && child.status != GoalStatus::Failed) all_done = false; + + const double cs = gt ? child.start_game_time : child.start_real_time; + if (cs >= 0.0 && (start_t < 0.0 || cs < start_t)) start_t = cs; + + if (child.status == GoalStatus::Completed || child.status == GoalStatus::Failed) { + const double ce = gt ? child.split.game_time : child.split.real_time; + if (end_t < 0.0 || ce > end_t) end_t = ce; + } + } + + if (!has_any) all_done = false; + GoalStatus status = GoalStatus::NotStarted; + if (any_failed) status = GoalStatus::Failed; + else if (all_done && has_any) status = GoalStatus::Completed; + else if (any_started || start_t >= 0.0) status = GoalStatus::Started; + + // Build label — use ### so the ID stays stable while the time text changes. + char label[160]; + // List name is part of the ID so headers from different lists never share ImGui collapse state. + if (end_t >= 0.0) { + char tbuf[32]; FormatTime(tbuf, sizeof(tbuf), end_t); + if (status == GoalStatus::Failed) + snprintf(label, sizeof(label), "%s - %s [Failed]###%s_hdr%d", h.label.c_str(), tbuf, list.name.c_str(), header_idx); + else + snprintf(label, sizeof(label), "%s - %s###%s_hdr%d", h.label.c_str(), tbuf, list.name.c_str(), header_idx); + } else { + snprintf(label, sizeof(label), "%s###%s_hdr%d", h.label.c_str(), list.name.c_str(), header_idx); + } + + // Tint the bar red for failed runs; everything else uses the toolbox theme colour. + const bool push_color = (status == GoalStatus::Failed); + if (push_color) { + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.55f, 0.08f, 0.08f, 0.90f)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0.65f, 0.12f, 0.12f, 1.00f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0.75f, 0.18f, 0.18f, 1.00f)); + } + + const bool is_open = ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen); + + if (push_color) ImGui::PopStyleColor(3); + + return is_open; +} + +void SplitsGoalListWindow::DrawRecentRunsSection(SplitsWindow& plugin) +{ + const SplitsProfile& profile = plugin.ActiveProfile(); + if (!profile.show_recent_runs) return; + const auto& runs = plugin.RecentRuns(); + if (runs.empty()) return; + if (!ImGui::CollapsingHeader("Recent Runs")) return; + + const bool gt = (profile.time_display == SplitsProfile::TimeDisplay::Game); + for (size_t i = 0; i < runs.size(); ++i) { + const RecentRun& run = runs[i]; + ImGui::PushID(static_cast(i)); + + char tbuf[32]; + FormatTime(tbuf, sizeof(tbuf), run.total_real); + char dbuf[16] = ""; + if (run.utc_start > 0) { + const time_t t = static_cast(run.utc_start); + tm tm_buf{}; + localtime_s(&tm_buf, &t); + snprintf(dbuf, sizeof(dbuf), "%04d-%02d-%02d", tm_buf.tm_year + 1900, tm_buf.tm_mon + 1, tm_buf.tm_mday); + } + char label[96]; + snprintf(label, sizeof(label), "%s %s%s", tbuf, dbuf, run.failed ? " [Failed]" : ""); + + if (ImGui::CollapsingHeader(label)) { + for (const auto& g : run.goals) { + const ImVec4 col = g.completed + ? ImGui::ColorConvertU32ToFloat4(plugin.ColorCompleted()) + : ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled); + char gbuf[32]; + FormatTime(gbuf, sizeof(gbuf), gt ? g.game_time : g.real_time); + ImGui::TextColored(col, "%s", g.label.c_str()); + ImGui::SameLine(180.f); + ImGui::TextColored(col, "%s", gbuf); + } + } + ImGui::PopID(); + } +} + +void SplitsGoalListWindow::LoadSettings(SettingsDoc& /*doc*/, ToolboxIni* /*legacy*/) {} + +void SplitsGoalListWindow::SaveSettings(SettingsDoc& /*doc*/) {} + +// ============================================================================= +// SETTINGS DISPATCH & COLUMNS +// ============================================================================= + +// --------------------------------------------------------------------------- +// Settings panel +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawSettings(SplitsWindow& plugin) +{ + GoalList* list = plugin.List(); + if (list_name_buf_[0] == '\0' && !list->name.empty()) + snprintf(list_name_buf_, sizeof(list_name_buf_), "%s", list->name.c_str()); + + DrawProfileSwitcher(plugin); + ImGui::Separator(); + + ImGui::Columns(3, "settings_cols", false); + DrawTimeAndBehaviorColumn(plugin); + ImGui::NextColumn(); + DrawKeybindsAndColorsColumn(plugin); + ImGui::NextColumn(); + DrawGoalListManagementColumn(plugin); + ImGui::Columns(1); + ImGui::Separator(); + + // SC's lists are tool-generated (not meant for hand-editing) while Manual/Running are user-authored — the one place the two families diverge in shape. + if (plugin.ActiveProfile().dynamic_by_default) + DrawSCGoalsSummary(plugin); + else + DrawEditableGoalsList(plugin); + + ImGui::Separator(); + ImGui::TextUnformatted("Add Goal:"); + DrawStandardAddGoalForm(plugin); +} + +// --------------------------------------------------------------------------- +// DrawSettings sections — split out so each profile's UI lives in one named, +// self-contained method instead of conditionals scattered through a shared draw path. +// --------------------------------------------------------------------------- + +void SplitsGoalListWindow::DrawProfileSwitcher(SplitsWindow& plugin) +{ + ImGui::TextUnformatted("Profile:"); + for (int i = 0; i < kProfileCount; ++i) { + ImGui::SameLine(); + const bool active = (plugin.ActiveProfileIdx() == i); + if (active) { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_Header)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetStyleColorVec4(ImGuiCol_HeaderHovered)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::GetStyleColorVec4(ImGuiCol_HeaderActive)); + } + if (ImGui::Button(plugin.Profiles()[i].name.c_str())) { + plugin.SwitchProfile(i); + list_name_buf_[0] = '\0'; + } + if (active) { + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Active — auto-loads on next session"); + } + } +} + +void SplitsGoalListWindow::DrawTimeAndBehaviorColumn(SplitsWindow& plugin) +{ + SplitsProfile& p = plugin.ActiveProfile(); + using TD = SplitsProfile::TimeDisplay; + ImGui::TextUnformatted("Time:"); + ImGui::SameLine(); + if (ImGui::RadioButton("Game", p.time_display == TD::Game)) p.time_display = TD::Game; + ImGui::SameLine(); + if (ImGui::RadioButton("Real", p.time_display == TD::Real)) p.time_display = TD::Real; + ImGui::SameLine(); + if (ImGui::RadioButton("Both", p.time_display == TD::Both)) p.time_display = TD::Both; + if (p.time_display == TD::Both) { + ImGui::SameLine(0, 12.f); + ImGui::Checkbox("Clock only", &p.both_header_only); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Show Real+Game in the header clock only;\ngoal rows display game time."); + } + ImGui::TextUnformatted("Compare vs:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(110.f); + { + using CM = SplitsProfile::ComparisonMode; + static const char* cm_labels[] = { "PB", "Average", "Sum of Best" }; + int cm_idx = static_cast(p.comparison_mode); + if (ImGui::Combo("##cmpmode", &cm_idx, cm_labels, 3)) + p.comparison_mode = static_cast(cm_idx); + } + // Dynamic-style rows (SC, and Running's legs) have no separate "total"/"seg" columns to toggle — just their one Duration delta. Matches DrawGoalRow's own Dynamic condition (display_style == Dynamic || dynamic_by_default) — Running's legs are always display_style == Dynamic (see DrawMapBatchPicker's preserve_order build), hence checking sequential_route here too. + if (!p.dynamic_by_default && !p.sequential_route) { + ImGui::Checkbox("Show total delta", &p.show_split_pb); + ImGui::Checkbox("Show split column", &p.show_segment); + if (p.show_segment) { + ImGui::SameLine(); + ImGui::Checkbox("Show split delta", &p.show_segment_pb); + } + } else { + ImGui::Checkbox("Show duration delta", &p.show_segment_pb); + } + ImGui::Checkbox("Auto /age on completion", &p.auto_send_age); + ImGui::Checkbox("Show paused time", &p.show_paused_time); + ImGui::Checkbox("Show recent runs", &p.show_recent_runs); + + ImGui::Spacing(); + ImGui::Checkbox("Stop on party wipe", &p.stop_on_party_defeated); + ImGui::Checkbox("Auto-fail on leaving a zone early", &p.auto_fail_on_rezone); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("If you leave a zone before accomplishing all goals tied to that zone, the run will fail."); +} + +void SplitsGoalListWindow::DrawKeybindsAndColorsColumn(SplitsWindow& plugin) +{ + auto vk_name = [](int vk) -> const char* { + if (vk <= 0) return "None"; + static char buf[32]; + const LONG scan = MapVirtualKeyA(static_cast(vk), MAPVK_VK_TO_VSC) << 16; + if (scan && GetKeyNameTextA(scan, buf, sizeof(buf)) > 0) return buf; + snprintf(buf, sizeof(buf), "VK %d", vk); + return buf; + }; + + static int* capturing_key = nullptr; + static bool capturing_active = false; + + auto draw_keybind = [&](const char* label, int& key) { + char btn_lbl[64]; + snprintf(btn_lbl, sizeof(btn_lbl), "%s##kb_%s", vk_name(key), label); + const bool is_capturing = (capturing_key == &key); + if (is_capturing) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.7f, 0.3f, 0.3f, 1.f)); + ImGui::Button("Press key..."); + ImGui::PopStyleColor(); + for (int vk = 1; vk < 256; ++vk) { + if (vk == VK_LBUTTON || vk == VK_RBUTTON || vk == VK_MBUTTON) continue; + if (GetAsyncKeyState(vk) & 0x8000) { + key = (vk == VK_ESCAPE) ? 0 : vk; + capturing_key = nullptr; + capturing_active = false; + break; + } + } + } else { + if (ImGui::Button(btn_lbl)) { + capturing_key = &key; + capturing_active = true; + } + } + ImGui::SameLine(); + if (ImGui::SmallButton(("x##clr_" + std::string(label)).c_str())) key = 0; + ImGui::SameLine(); + ImGui::TextUnformatted(label); + }; + + ImGui::TextUnformatted("Keybinds:"); + draw_keybind("Start", plugin.KeyStart()); + draw_keybind("Reset", plugin.KeyReset()); + draw_keybind("Split", plugin.KeySplit()); + if (capturing_active) + ImGui::TextDisabled("(Esc to clear)"); + + ImGui::Spacing(); + // Shared across all profiles now — see ColorCompleted() etc. on SplitsWindow. + ImGui::ColorButtonPicker("Completed##sc", &plugin.ColorCompleted()); ImGui::SameLine(); ImGui::TextUnformatted("Completed"); + ImGui::SameLine(0, 12.f); + ImGui::ColorButtonPicker("Current##sc", &plugin.ColorActive()); ImGui::SameLine(); ImGui::TextUnformatted("Current"); + ImGui::ColorButtonPicker("Real##sc", &plugin.ColorRealTime()); ImGui::SameLine(); ImGui::TextUnformatted("Real"); + ImGui::SameLine(0, 12.f); + ImGui::ColorButtonPicker("Game##sc", &plugin.ColorGameTime()); ImGui::SameLine(); ImGui::TextUnformatted("Game"); + ImGui::ColorButtonPicker("Ahead##sc", &plugin.ColorPbAhead()); ImGui::SameLine(); ImGui::TextUnformatted("Ahead"); + ImGui::SameLine(0, 12.f); + ImGui::ColorButtonPicker("Behind##sc", &plugin.ColorPbBehind()); ImGui::SameLine(); ImGui::TextUnformatted("Behind"); + ImGui::Spacing(); +} + +void SplitsGoalListWindow::DrawGoalListManagementColumn(SplitsWindow& plugin) +{ + GoalList* list = plugin.List(); + + ImGui::TextUnformatted("Goal List"); + ImGui::SetNextItemWidth(130.f); + ImGui::InputText("##listname", list_name_buf_, sizeof(list_name_buf_)); + ImGui::SameLine(); + if (ImGui::Button("New")) { + plugin.NewActiveList(list_name_buf_); + list = plugin.List(); + } + ImGui::SameLine(); + if (ImGui::Button("Save")) { + list->name = list_name_buf_; + plugin.SaveActiveList(); + } + + const auto saved = plugin.GetSavedLists(); + if (!saved.empty()) { + ImGui::TextUnformatted("Load:"); + ImGui::Indent(); + for (const auto& [display, path] : saved) { + const bool is_active = (display == list->name); + if (is_active) { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_Header)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::GetStyleColorVec4(ImGuiCol_HeaderHovered)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::GetStyleColorVec4(ImGuiCol_HeaderActive)); + } + if (ImGui::Button(display.c_str())) { + plugin.LoadActiveList(path); + list = plugin.List(); + snprintf(list_name_buf_, sizeof(list_name_buf_), "%s", list->name.c_str()); + } + if (is_active) { + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Currently loaded"); + } + } + ImGui::Unindent(); + } +} + +// ============================================================================= +// GOAL LIST BODY & ADD-GOAL FORM +// ============================================================================= + +void SplitsGoalListWindow::DrawSCGoalsSummary(SplitsWindow& plugin) +{ + // SC lists are a complete, tool-generated structure — editing by hand would just break the relay chain the picker built, and the dropdown only offers fresh Dungeon/Elite Area picks anyway. No editable rows here; the live Splits window shows the actual goals once running. + GoalList* list = plugin.List(); + int goal_count = 0; + for (const auto& g : list->goals) if (!g.is_header) ++goal_count; + ImGui::Text("Goals: %d (%s)", goal_count, list->name.empty() ? "none loaded" : list->name.c_str()); + if (!list->name.empty()) { + ImGui::SameLine(); + // Clears the active list so ApplySCAutoLoadPreset's anchor-map_id match no longer sees it as "already correct" and rebuilds fresh on the next map entry. + if (ImGui::SmallButton("Use Default##sc")) plugin.NewActiveList(""); + } +} + +void SplitsGoalListWindow::DrawEditableGoalsList(SplitsWindow& plugin) +{ + ImGui::TextUnformatted("Goals:"); + GoalList* list = plugin.List(); + bool erased = false; + bool moved = false; + int first_goal_idx = -1; + for (int k = 0; k < static_cast(list->goals.size()); ++k) { + if (!list->goals[k].is_header) { first_goal_idx = k; break; } + } + for (int i = 0; i < static_cast(list->goals.size()) && !erased && !moved; ++i) { + auto& g = list->goals[i]; + ImGui::PushID(i); + + if (g.is_header) { + ImGui::TextColored({1.f, 0.85f, 0.4f, 1.f}, "[HDR]"); + } else { + const char* tname = "Man"; + switch (g.trigger.type) { + case GoalTrigger::Type::MapEnter: tname = "Map"; break; + case GoalTrigger::Type::EnterExplorable: tname = "Exp"; break; + case GoalTrigger::Type::ExitExplorable: tname = "Exit"; break; + case GoalTrigger::Type::EnterOutpost: tname = "Out"; break; + case GoalTrigger::Type::VanquishComplete: tname = "VQ"; break; + case GoalTrigger::Type::MissionComplete: tname = g.trigger.hard_mode ? "HM" : "Mis"; break; + case GoalTrigger::Type::MissionBonus: tname = g.trigger.hard_mode ? "HMB" : "Bon"; break; + case GoalTrigger::Type::ReachLevel: tname = "Lv"; break; + default: break; + } + ImGui::TextDisabled("[%s]", tname); + } + ImGui::SameLine(); + // Nested headers don't exist, so only non-header rows ever show the indent hint. + if (!g.is_header && g.indent > 0) { ImGui::TextDisabled("|"); ImGui::SameLine(0, 2); } + ImGui::TextUnformatted(g.label.c_str()); + ImGui::SameLine(); + + // indent/grouping is set programmatically (e.g. SC's presets), not exposed to users here — keep it simple. + + if (i > 0) { + if (ImGui::SmallButton("^##up")) { + std::swap(list->goals[static_cast(i)], list->goals[static_cast(i - 1)]); + moved = true; + } + } else { + ImGui::BeginDisabled(); + ImGui::SmallButton("^##up"); + ImGui::EndDisabled(); + } + ImGui::SameLine(); + + if (i + 1 < static_cast(list->goals.size())) { + if (ImGui::SmallButton("v##down")) { + std::swap(list->goals[static_cast(i)], list->goals[static_cast(i + 1)]); + moved = true; + } + } else { + ImGui::BeginDisabled(); + ImGui::SmallButton("v##down"); + ImGui::EndDisabled(); + } + ImGui::SameLine(); + + if (ImGui::SmallButton("X")) { + list->goals.erase(list->goals.begin() + i); + list->RenumberDuplicateLabels(); + erased = true; + } + + // Only the first goal's start behavior is ever in question — every later goal relays immediately off the previous one's completion (start_trigger isn't set through this editor). + if (i == first_goal_idx) { + if (plugin.ActiveProfile().sequential_route) { + ImGui::TextDisabled("Autostart --> movement detected in an explorable"); + } else if (g.starts_immediately) { + ImGui::TextDisabled("Autostart --> begins at run start"); + } else if (g.trigger.type == GoalTrigger::Type::MissionComplete || + g.trigger.type == GoalTrigger::Type::MissionBonus || + g.trigger.type == GoalTrigger::Type::VanquishComplete) { + const std::string& map_name = Resources::GetMapName(g.trigger.map_id)->string(); + ImGui::TextDisabled("Autostart --> entering %s", + map_name.empty() ? "target map" : map_name.c_str()); + } else { + ImGui::TextDisabled("Not an autostart goal \xe2\x80\x94 press Start manually"); + } + } + + ImGui::PopID(); + } +} + +void SplitsGoalListWindow::DrawStandardAddGoalForm(SplitsWindow& plugin) +{ + GoalList* list = plugin.List(); + SplitsProfile& p = plugin.ActiveProfile(); + + // Sentinel -1 = "Header" (no trigger), -2 = "Quest" (adds a QuestPickup + QuestComplete pair from one label/ID entry). + struct TriggerOpt { const char* label; int type_int; }; + static const TriggerOpt trigger_opts_full[] = { + { "Header", -1 }, + { "Manual", static_cast(GoalTrigger::Type::Manual) }, + { "Missions", static_cast(GoalTrigger::Type::MissionComplete) }, + { "Explorables", static_cast(GoalTrigger::Type::MapEnter) }, + { "Towns", static_cast(GoalTrigger::Type::EnterExplorable) }, + { "Titles", static_cast(GoalTrigger::Type::ReachTitleRank) }, + { "Reach Level", static_cast(GoalTrigger::Type::ReachLevel) }, + { "Quest", -2 }, + { "Skill Learnt", static_cast(GoalTrigger::Type::SkillLearnt) }, + { "Mob Kill", static_cast(GoalTrigger::Type::MobKill) }, + { "Dungeons", static_cast(GoalTrigger::Type::DungeonReward) }, + { "Elite Areas", static_cast(GoalTrigger::Type::ObjectiveDone) }, + }; + // SC is single-purpose (dungeons/elite areas only) — Manual's other trigger types aren't offered at all rather than shown-but-unused. + static const TriggerOpt trigger_opts_sc[] = { + { "Dungeons", static_cast(GoalTrigger::Type::DungeonReward) }, + { "Elite Areas", static_cast(GoalTrigger::Type::ObjectiveDone) }, + }; + // Running is a route — just picks which towns/explorables it enters/leaves, in order. + static const TriggerOpt trigger_opts_running[] = { + { "Header", -1 }, + { "Explorables", static_cast(GoalTrigger::Type::MapEnter) }, + { "Towns", static_cast(GoalTrigger::Type::EnterExplorable) }, + }; + const bool is_sc = p.dynamic_by_default; + const bool is_running = p.sequential_route; + const TriggerOpt* trigger_opts = is_sc ? trigger_opts_sc : is_running ? trigger_opts_running : trigger_opts_full; + int trigger_opts_count = is_sc ? static_cast(std::size(trigger_opts_sc)) + : is_running ? static_cast(std::size(trigger_opts_running)) + : static_cast(std::size(trigger_opts_full)); + // True when edit_trigger_type_ is one of opts' own entries — used below to reset a leftover Manual-only selection to a valid default when SC/Running becomes active, instead of listing each array's entries by hand (which can silently desync from the arrays above as options are added/removed). + auto is_valid_selection = [&](const TriggerOpt* opts, int count) { + for (int i = 0; i < count; ++i) + if (opts[i].type_int == edit_trigger_type_) return true; + return false; + }; + if (is_sc && !is_valid_selection(trigger_opts_sc, static_cast(std::size(trigger_opts_sc)))) + edit_trigger_type_ = trigger_opts_sc[0].type_int; + if (is_running && !is_valid_selection(trigger_opts_running, static_cast(std::size(trigger_opts_running)))) + edit_trigger_type_ = trigger_opts_running[1].type_int; + const char* current_trigger_label = trigger_opts[0].label; + for (int i = 0; i < trigger_opts_count; ++i) + if (trigger_opts[i].type_int == edit_trigger_type_) { current_trigger_label = trigger_opts[i].label; break; } + ImGui::SetNextItemWidth(220.f); + if (ImGui::BeginCombo("Trigger##add", current_trigger_label)) { + for (int i = 0; i < trigger_opts_count; ++i) { + const bool selected = (trigger_opts[i].type_int == edit_trigger_type_); + if (ImGui::Selectable(trigger_opts[i].label, selected)) + edit_trigger_type_ = trigger_opts[i].type_int; + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + const bool is_header_mode = (edit_trigger_type_ == -1); + const bool is_mission_batch = (edit_trigger_type_ == static_cast(GoalTrigger::Type::MissionComplete) || + edit_trigger_type_ == static_cast(GoalTrigger::Type::MissionBonus)); + const bool is_explorable_batch = (edit_trigger_type_ == static_cast(GoalTrigger::Type::MapEnter)); + const bool is_town_batch = (edit_trigger_type_ == static_cast(GoalTrigger::Type::EnterExplorable)); + const bool is_dungeon_batch = (edit_trigger_type_ == static_cast(GoalTrigger::Type::DungeonReward)); + const bool is_elite_area_batch = (edit_trigger_type_ == static_cast(GoalTrigger::Type::ObjectiveDone)); + const bool is_title_picker = (edit_trigger_type_ == static_cast(GoalTrigger::Type::ReachTitleRank)); + const bool needs_level = (edit_trigger_type_ == static_cast(GoalTrigger::Type::ReachLevel)); + const bool is_quest_mode = (edit_trigger_type_ == -2); + const bool needs_quest_id = is_quest_mode; + const bool needs_skill_id = (edit_trigger_type_ == static_cast(GoalTrigger::Type::SkillLearnt)); + const bool needs_mob_id = (edit_trigger_type_ == static_cast(GoalTrigger::Type::MobKill)); + + if (is_mission_batch) { + DrawMissionBatchPicker(plugin); + } else if (is_explorable_batch) { + DrawExplorableBatchPicker(plugin); + } else if (is_town_batch) { + DrawTownBatchPicker(plugin); + } else if (is_dungeon_batch) { + DrawDungeonBatchPicker(plugin); + } else if (is_elite_area_batch) { + DrawEliteAreaBatchPicker(plugin); + } else if (is_title_picker) { + DrawTitlePicker(plugin); + } else { + ImGui::SetNextItemWidth(200.f); + ImGui::InputText("Label##add", edit_label_, sizeof(edit_label_)); + if (needs_level) { + ImGui::SetNextItemWidth(100.f); + ImGui::InputInt("Level##add", &edit_level_); + if (edit_level_ < 1) edit_level_ = 1; + if (edit_level_ > 20) edit_level_ = 20; + } + if (needs_quest_id) { + ImGui::SetNextItemWidth(120.f); + ImGui::InputInt("Quest ID##add", &edit_quest_id_); + if (edit_quest_id_ < 0) edit_quest_id_ = 0; + } + if (needs_skill_id) { + ImGui::SetNextItemWidth(120.f); + ImGui::InputInt("Skill ID##add", &edit_skill_id_); + if (edit_skill_id_ < 0) edit_skill_id_ = 0; + } + if (needs_mob_id) { + ImGui::SetNextItemWidth(120.f); + ImGui::InputInt("Model ID##add", &edit_mob_id_); + if (edit_mob_id_ < 0) edit_mob_id_ = 0; + ImGui::SetNextItemWidth(120.f); + ImGui::InputInt("Kill Count##add", &edit_mob_kill_count_); + if (edit_mob_kill_count_ < 1) edit_mob_kill_count_ = 1; + } + const bool can_add = edit_label_[0] != '\0'; + if (!can_add) ImGui::BeginDisabled(); + if (is_header_mode) { + if (ImGui::Button("Add Header")) { + GoalEntry hdr; + hdr.is_header = true; + hdr.label = edit_label_; + list->goals.push_back(std::move(hdr)); + edit_label_[0] = '\0'; + } + } else if (is_quest_mode) { + if (ImGui::Button("Add Goal")) { + // Flat: two plain completion-triggered goals like every other Manual goal, so the list stays relay-chained/PB-comparable instead of mixing in Dynamic's look. + GoalEntry pickup; + pickup.label = std::string(edit_label_) + " Pickup"; + pickup.trigger.type = GoalTrigger::Type::QuestPickup; + pickup.trigger.map_id = GW::Constants::MapID::None; + pickup.trigger.param1 = static_cast(edit_quest_id_); + list->goals.push_back(std::move(pickup)); + + GoalEntry complete; + complete.label = std::string(edit_label_) + " Complete"; + complete.trigger.type = GoalTrigger::Type::QuestComplete; + complete.trigger.map_id = GW::Constants::MapID::None; + complete.trigger.param1 = static_cast(edit_quest_id_); + list->goals.push_back(std::move(complete)); + + list->RenumberDuplicateLabels(); + edit_label_[0] = '\0'; + } + } else { + if (ImGui::Button("Add Goal")) { + GoalEntry new_g; + new_g.label = edit_label_; + new_g.trigger.type = static_cast(edit_trigger_type_); + new_g.trigger.map_id = GW::Constants::MapID::None; + new_g.trigger.level = edit_level_; + if (needs_skill_id) new_g.trigger.param1 = static_cast(edit_skill_id_); + if (needs_mob_id) { + new_g.trigger.param1 = static_cast(edit_mob_id_); + new_g.trigger.param2 = static_cast(edit_mob_kill_count_); + } + list->goals.push_back(std::move(new_g)); + list->RenumberDuplicateLabels(); + edit_label_[0] = '\0'; + } + } + if (!can_add) ImGui::EndDisabled(); + + if (is_quest_mode || needs_skill_id) { + ImGui::SameLine(); + if (ImGui::Button("Find ID##wiki")) { + OpenGameIntegrationWikiPage(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Opens the wiki's Quest/Skill ID lookup tables\n(no in-game ID list exists for either)"); + } + } + if (needs_mob_id) { + ImGui::SameLine(); + if (ImGui::Button("Find ID##info")) { + InfoWindow::Instance().visible = true; + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Opens the Info window \xe2\x80\x94 target the mob in-game and\nread its Model ID off the Target section (no wiki needed)."); + } + } + } +} + +// --------------------------------------------------------------------------- +// ============================================================================= +// TRIGGER-TYPE PICKERS +// ============================================================================= + +// --------------------------------------------------------------------------- +// Title picker +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawTitlePicker(SplitsWindow& plugin) +{ + using TitleID = GW::Constants::TitleID; + + struct TitleEntry { TitleID id; std::unique_ptr enc; std::string name; }; + // Built once, lazily: every non-deprecated title, Copied from TitleTrackerWidget + static std::vector s_titles; + static bool s_titles_sorted = false; + if (s_titles.empty()) { + for (uint32_t i = 0; i <= static_cast(TitleID::Codex); ++i) { + const auto id = static_cast(i); + if (GW::PlayerMgr::IsDeprecatedTitle(id)) continue; + const auto* data = GW::PlayerMgr::GetTitleData(id); + if (!data) { s_titles.clear(); break; } // title data not loaded yet at all — retry next frame + TitleEntry e; + e.id = id; + e.enc = std::make_unique(data->name_id); + s_titles.push_back(std::move(e)); + } + } + // Names decode asynchronously — keep polling until every entry has one before sorting/displaying, so no two rows can show the same blank label at once (ImGui ID collision) mid-decode. + if (!s_titles.empty() && !s_titles_sorted) { + bool all_resolved = true; + for (auto& e : s_titles) { + if (e.name.empty()) { + e.name = e.enc->string(); + if (e.name.empty()) all_resolved = false; + } + } + if (all_resolved) { + std::sort(s_titles.begin(), s_titles.end(), [](const TitleEntry& a, const TitleEntry& b) { return a.name < b.name; }); + s_titles_sorted = true; + } + } + const int NUM_TITLES = s_titles_sorted ? static_cast(s_titles.size()) : 0; + + ImGui::SetNextItemWidth(-1.f); + ImGui::InputText("##titlefilter", title_filter_buf_, sizeof(title_filter_buf_)); + ImGui::SameLine(); if (ImGui::SmallButton("x##titlefx")) title_filter_buf_[0] = '\0'; + + const bool searching = title_filter_buf_[0] != '\0'; + + if (ImGui::BeginListBox("##titlelist", { -1.f, 180.f })) { + for (int i = 0; i < NUM_TITLES; ++i) { + const TitleEntry& e = s_titles[i]; + if (searching && !TextUtils::CaseInsensitiveContains(e.name, title_filter_buf_)) continue; + ImGui::PushID(static_cast(e.id)); + const bool selected = (edit_title_id_ == static_cast(e.id)); + if (ImGui::Selectable(e.name.c_str(), selected)) { + if (edit_title_id_ != static_cast(e.id)) { + edit_title_id_ = static_cast(e.id); + edit_title_rank_ = -1; // reset rank so it defaults to max for the new title + } + } + if (selected) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + ImGui::EndListBox(); + } + + const char* sel_title_name = nullptr; + for (int i = 0; i < NUM_TITLES; ++i) { + if (static_cast(s_titles[i].id) == edit_title_id_) { + sel_title_name = s_titles[i].name.c_str(); + break; + } + } + + auto* live_title = GW::PlayerMgr::GetTitleTrack(static_cast(edit_title_id_)); + // GetTitleTrack() returns null for a title with zero progress — that's not "disconnected", just unstarted. A goal can still target Rank 1; GoalEngine resolves the real tier-index anchor once progress starts. + const int total_ranks = live_title ? static_cast(live_title->max_title_rank) : 0; + const bool has_a_rank_to_pick = (live_title != nullptr) && total_ranks > 0; + const bool can_add = (sel_title_name != nullptr); + + // Rank selector: max_title_rank = total ranks; max_title_tier_index = global flat index of rank-1 (anchor, only known once progress exists — see GoalEngine's ReachTitleRank match). + if (sel_title_name && has_a_rank_to_pick) { + const int max_tier_base = static_cast(live_title->max_title_tier_index); + const bool pct_title = live_title->is_percentage_based(); + auto* wc = GW::GetWorldContext(); + + if (edit_title_rank_ <= 0 || edit_title_rank_ > total_ranks) + edit_title_rank_ = total_ranks; + + ImGui::Text("Target rank (1..%d):", total_ranks); + if (ImGui::BeginListBox("##rankbox", { -1.f, 80.f })) { + for (int r = 1; r <= total_ranks; ++r) { + char rank_label[48]; + const int tier_idx = max_tier_base + (r - 1); + if (pct_title && wc && tier_idx < static_cast(wc->title_tiers.size())) { + const float pct = wc->title_tiers[tier_idx].tier_number * 0.1f; + if (r == total_ranks) + snprintf(rank_label, sizeof(rank_label), "Rank %d — %.1f%% (Max)", r, pct); + else + snprintf(rank_label, sizeof(rank_label), "Rank %d — %.1f%%", r, pct); + } else { + if (r == total_ranks) + snprintf(rank_label, sizeof(rank_label), "Rank %d (Max)", r); + else + snprintf(rank_label, sizeof(rank_label), "Rank %d", r); + } + const bool rank_sel = (edit_title_rank_ == r); + if (ImGui::Selectable(rank_label, rank_sel)) + edit_title_rank_ = r; + if (rank_sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndListBox(); + } + } else if (sel_title_name) { + edit_title_rank_ = 1; // no progress yet, so nothing to pick from — target Rank 1, the common case for a fresh title + ImGui::TextDisabled("(no progress detected yet \xe2\x80\x94 will target Rank 1; reopen this once you've started the title to pick a different rank)"); + } + + if (!can_add) ImGui::BeginDisabled(); + if (ImGui::Button("Add Goal##titleadd")) { + char label_buf[160]; + if (has_a_rank_to_pick) + snprintf(label_buf, sizeof(label_buf), "%s r%d/%d", sel_title_name, edit_title_rank_, total_ranks); + else + snprintf(label_buf, sizeof(label_buf), "%s r%d", sel_title_name, edit_title_rank_); + + GoalEntry g; + g.label = label_buf; + g.trigger.type = GoalTrigger::Type::ReachTitleRank; + g.trigger.title_id = static_cast(edit_title_id_); + // Relative target rank (1-based), not a stored tier index — GoalEngine resolves the absolute anchor against the live Title* at match time (see its ReachTitleRank case for why). + g.trigger.level = edit_title_rank_; + g.trigger.map_id = GW::Constants::MapID::None; + + GoalList* list = plugin.List(); + const bool dup = std::any_of(list->goals.begin(), list->goals.end(), + [&g](const GoalEntry& e) { + return e.trigger.type == g.trigger.type && + e.trigger.title_id == g.trigger.title_id && + e.trigger.level == g.trigger.level; + }); + if (!dup) list->goals.push_back(std::move(g)); + } + if (!can_add) ImGui::EndDisabled(); +} + +namespace { +// Shared by DrawTownBatchPicker/DrawExplorableBatchPicker — consolidated from two near-identical ~180-line copies so a future column/filter change only needs to happen once. +struct BatchMapRow { int id; std::string name; GW::Region region; }; + +struct BatchColumn { + uint8_t bit; + GoalTrigger::Type trigger_type; + const char* header; // table column header, e.g. "Enter" + const char* quick_label; // bulk-select button suffix, e.g. "En" + const char* goal_prefix; // goal label prefix, e.g. "Enter " +}; + +constexpr GW::Constants::Campaign kBatchCampaigns[] = { + GW::Constants::Campaign::Prophecies, GW::Constants::Campaign::Factions, + GW::Constants::Campaign::Nightfall, GW::Constants::Campaign::EyeOfTheNorth, +}; +constexpr const char* kBatchCampaignLabels[] = { "Prophecies", "Factions", "Nightfall", "EotN" }; + +// type_ok's rank breaks ties on the same name_id (Town never sets rank, so ties keep whichever map id was encountered first). +std::vector BuildBatchMapRows( + const std::function& region_ok, + const std::function& type_ok) +{ + using MapID = GW::Constants::MapID; + struct Cand { int id; int rank; GW::Region region; }; + std::unordered_map best; + for (int id = 1; id < static_cast(MapID::Count); ++id) { + const auto mid = static_cast(id); + const auto* inf = GW::Map::GetMapInfo(mid); + if (!inf || !inf->name_id || !region_ok(*inf)) continue; + int rank = 0; + if (!type_ok(*inf, rank)) continue; + // skip duplicates with empty string+mapid + if (Resources::GetMapName(mid)->string().empty()) continue; + auto it = best.find(inf->name_id); + if (it == best.end() || rank < it->second.rank) + best[inf->name_id] = { id, rank, inf->region }; + } + std::vector out; + out.reserve(best.size()); + for (const auto& kv : best) + out.push_back({ kv.second.id, Resources::GetMapName(static_cast(kv.second.id))->string(), kv.second.region }); + std::sort(out.begin(), out.end(), [](const BatchMapRow& a, const BatchMapRow& b) { + if (a.region != b.region) return a.region < b.region; + return a.name < b.name; + }); + return out; +} + +void DrawMapBatchPicker(SplitsWindow& plugin, const char* id_prefix, + char* filter_buf, size_t filter_buf_size, + std::map& checked, + std::vector>& check_order, + const std::function& type_ok, + const std::vector& columns) +{ + using MapID = GW::Constants::MapID; + using Camp = GW::Constants::Campaign; + + // Running wants goals added in the order boxes were checked (an actual route), not the region/name sort Manual/SC want from a bulk pick. + const bool preserve_order = plugin.ActiveProfile().sequential_route; + + auto set_bit = [&](int id, uint8_t bit, bool on) { + uint8_t& bits = checked[id]; + const bool was_on = (bits & bit) != 0; + if (on == was_on) return; + if (on) { bits |= bit; check_order.push_back({ id, bit }); } + else { + bits &= ~bit; + const auto it = std::find(check_order.begin(), check_order.end(), std::make_pair(id, bit)); + if (it != check_order.end()) check_order.erase(it); + } + }; + + char filter_id[32]; snprintf(filter_id, sizeof(filter_id), "##%sfilter", id_prefix); + char clear_id[32]; snprintf(clear_id, sizeof(clear_id), "x##%sfx", id_prefix); + ImGui::SetNextItemWidth(-1.f); + ImGui::InputText(filter_id, filter_buf, filter_buf_size); + ImGui::SameLine(); if (ImGui::SmallButton(clear_id)) filter_buf[0] = '\0'; + + auto filter_rows = [&](const std::vector& rows) { + std::vector filtered; + for (const auto& r : rows) { + if (filter_buf[0] != '\0' && !TextUtils::CaseInsensitiveContains(r.name, filter_buf)) + continue; + filtered.push_back(&r); + } + return filtered; + }; + auto draw_bulk_buttons = [&](const std::vector& filtered) { + for (size_t ci = 0; ci < columns.size(); ++ci) { + if (ci) ImGui::SameLine(0, 10); + const uint8_t bit = columns[ci].bit; + char all_lbl[32]; snprintf(all_lbl, sizeof(all_lbl), "All %s", columns[ci].quick_label); + char none_lbl[32]; snprintf(none_lbl, sizeof(none_lbl), "None %s", columns[ci].quick_label); + if (ImGui::SmallButton(all_lbl)) { for (auto* r : filtered) set_bit(r->id, bit, true); } + ImGui::SameLine(); + if (ImGui::SmallButton(none_lbl)) { for (auto* r : filtered) set_bit(r->id, bit, false); } + } + }; + auto draw_table = [&](const char* table_id, const std::vector& filtered, bool show_region_header) { + constexpr ImGuiTableFlags tflags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (!ImGui::BeginTable(table_id, 1 + static_cast(columns.size()), tflags, { -1.f, 220.f })) return; + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Map", ImGuiTableColumnFlags_WidthStretch); + for (const auto& col : columns) + ImGui::TableSetupColumn(col.header, ImGuiTableColumnFlags_WidthFixed, 40.f); + ImGui::TableHeadersRow(); + + GW::Region prev_reg = static_cast(0xFFFFFFFFu); + for (const auto* r : filtered) { + ImGui::PushID(r->id); + if (show_region_header && filter_buf[0] == '\0' && r->region != prev_reg) { + prev_reg = r->region; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + const std::string& rname = Resources::GetRegionName(r->region)->string(); + if (!rname.empty()) ImGui::TextDisabled("%s", rname.c_str()); + } + ImGui::TableNextRow(); + const uint8_t bits = checked[r->id]; + ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(r->name.c_str()); + for (int ci = 0; ci < static_cast(columns.size()); ++ci) { + ImGui::TableSetColumnIndex(1 + ci); + ImGui::PushID(ci); + bool v = (bits & columns[ci].bit) != 0; + if (ImGui::Checkbox("##v", &v)) set_bit(r->id, columns[ci].bit, v); + ImGui::PopID(); + } + ImGui::PopID(); + } + ImGui::EndTable(); + }; + + char tabbar_id[32]; snprintf(tabbar_id, sizeof(tabbar_id), "##%s_campaigns", id_prefix); + char table_id[32]; snprintf(table_id, sizeof(table_id), "##%stbl", id_prefix); + char table_ps_id[32]; snprintf(table_ps_id, sizeof(table_ps_id), "##%stbl_ps", id_prefix); + + // AreaInfo never changes mid-session, so cache per (picker, campaign) instead of rescanning ~1300 MapIDs every frame. + static std::unordered_map> row_cache; + auto cached_rows = [&](const std::string& key, const std::function& region_ok) -> const std::vector& { + const auto it = row_cache.find(key); + if (it != row_cache.end()) return it->second; + return row_cache.emplace(key, BuildBatchMapRows(region_ok, type_ok)).first->second; + }; + + if (ImGui::BeginTabBar(tabbar_id)) { + for (int ci = 0; ci < static_cast(std::size(kBatchCampaigns)); ++ci) { + if (!ImGui::BeginTabItem(kBatchCampaignLabels[ci])) continue; + const Camp camp = kBatchCampaigns[ci]; + const auto& rows = cached_rows(std::string(id_prefix) + "_" + std::to_string(ci), + [camp](const GW::AreaInfo& inf) { return inf.GetIsOnWorldMap() && inf.campaign == camp; }); + const auto filtered = filter_rows(rows); + draw_bulk_buttons(filtered); + draw_table(table_id, filtered, /*show_region_header=*/true); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Pre-Searing")) { + const auto& ps_rows = cached_rows(std::string(id_prefix) + "_ps", + [](const GW::AreaInfo& inf) { return inf.region == GW::Region_Presearing; }); + const auto ps_filtered = filter_rows(ps_rows); + draw_bulk_buttons(ps_filtered); + draw_table(table_ps_id, ps_filtered, /*show_region_header=*/false); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + int total = 0; + for (const auto& [id, bits] : checked) + for (const auto& col : columns) + if (bits & col.bit) ++total; + + char add_id[32]; snprintf(add_id, sizeof(add_id), "##%sadd", id_prefix); + if (total == 0) ImGui::BeginDisabled(); + char add_lbl[64]; + snprintf(add_lbl, sizeof(add_lbl), "Add %d Goal%s%s", total, total == 1 ? "" : "s", add_id); + if (ImGui::Button(add_lbl)) { + struct Item { int id; const BatchColumn* col; Camp camp; GW::Region region; std::string name; }; + std::vector items; + items.reserve(static_cast(total)); + if (preserve_order) { + // Running: exact check order, this is a route, not a bulk pick. + for (const auto& [id, bit] : check_order) { + const auto col = std::find_if(columns.begin(), columns.end(), [bit](const BatchColumn& c) { return c.bit == bit; }); + if (col == columns.end()) continue; + const auto mid = static_cast(id); + const auto* inf = GW::Map::GetMapInfo(mid); + items.push_back({ id, &*col, inf ? inf->campaign : Camp::Prophecies, + inf ? inf->region : static_cast(0), + Resources::GetMapName(mid)->string() }); + } + } else { + for (const auto& [id, bits] : checked) { + if (!bits) continue; + const auto mid = static_cast(id); + const auto* inf = GW::Map::GetMapInfo(mid); + const Camp c = inf ? inf->campaign : Camp::Prophecies; + const GW::Region reg = inf ? inf->region : static_cast(0); + const std::string nm = Resources::GetMapName(mid)->string(); + for (const auto& col : columns) + if (bits & col.bit) items.push_back({ id, &col, c, reg, nm }); + } + // Column array index is the tie-break so goals for the same map keep the original column order (e.g. Enter before Leave). + std::sort(items.begin(), items.end(), [&](const Item& a, const Item& b) { + if (a.camp != b.camp) return a.camp < b.camp; + if (a.region != b.region) return a.region < b.region; + if (a.name != b.name) return a.name < b.name; + return (a.col - columns.data()) < (b.col - columns.data()); + }); + } + GoalList* list = plugin.List(); + for (const auto& item : items) { + GoalEntry g; + g.trigger.map_id = static_cast(item.id); + if (preserve_order) { + // One goal per leg — the Enter type becomes its start_trigger (see GoalEngine Pass 1), its own trigger is the matching Exit, so Duration shows time-spent same as SC's checkpoints. + g.label = item.name; + g.trigger.type = item.col->trigger_type == GoalTrigger::Type::EnterExplorable + ? GoalTrigger::Type::ExitExplorable : GoalTrigger::Type::ExitOutpost; + g.start_trigger = GoalTrigger{}; + g.start_trigger->type = item.col->trigger_type; + g.start_trigger->map_id = g.trigger.map_id; + g.display_style = GoalEntry::DisplayStyle::Dynamic; + } else { + g.label = std::string(item.col->goal_prefix) + item.name; + g.trigger.type = item.col->trigger_type; + } + list->goals.push_back(std::move(g)); + } + list->RenumberDuplicateLabels(); + check_order.clear(); + checked.clear(); + } + if (total == 0) ImGui::EndDisabled(); +} +} // namespace + +// --------------------------------------------------------------------------- +// Town batch picker +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawTownBatchPicker(SplitsWindow& plugin) +{ + static const BatchColumn kColumns[] = { + { 1, GoalTrigger::Type::EnterOutpost, "Enter", "En", "Enter " }, + { 2, GoalTrigger::Type::ExitOutpost, "Leave", "Lv", "Leave " }, + }; + // Running: one box per row — Add builds a single start_trigger/trigger pair goal (see DrawMapBatchPicker's preserve_order branch). + static const BatchColumn kColumnsRunning[] = { + { 1, GoalTrigger::Type::EnterOutpost, "Add", "Add", "" }, + }; + static const GW::RegionType s_town_types[] = { + GW::RegionType::City, GW::RegionType::Outpost, GW::RegionType::MissionOutpost, + GW::RegionType::Challenge, GW::RegionType::Marketplace, + GW::RegionType::HeroBattleOutpost, GW::RegionType::ZaishenBattle, + }; + auto type_ok = [](const GW::AreaInfo& inf, int&) { + for (auto t : s_town_types) if (inf.type == t) return true; + return false; + }; + const bool is_running = plugin.ActiveProfile().sequential_route; + DrawMapBatchPicker(plugin, "town", town_filter_buf_, sizeof(town_filter_buf_), + batch_town_checked_, batch_town_order_, type_ok, + is_running ? std::vector(std::begin(kColumnsRunning), std::end(kColumnsRunning)) + : std::vector(std::begin(kColumns), std::end(kColumns))); +} + +// --------------------------------------------------------------------------- +// Explorable batch picker +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawExplorableBatchPicker(SplitsWindow& plugin) +{ + static const BatchColumn kColumns[] = { + { 1, GoalTrigger::Type::EnterExplorable, "Enter", "En", "Enter " }, + { 2, GoalTrigger::Type::VanquishComplete, "VQ", "VQ", "VQ " }, + { 4, GoalTrigger::Type::ExitExplorable, "Leave", "Lv", "Leave " }, + }; + // Running has no use for VQ, and a leg is just "pass through" — one box builds a single start_trigger/trigger pair goal. + static const BatchColumn kColumnsRunning[] = { + { 1, GoalTrigger::Type::EnterExplorable, "Add", "Add", "" }, + }; + // Ties (same name_id) prefer ExplorableZone (rank 0) over MissionArea (rank 1). + auto type_ok = [](const GW::AreaInfo& inf, int& rank) { + if (inf.type != GW::RegionType::ExplorableZone && inf.type != GW::RegionType::MissionArea) return false; + rank = (inf.type == GW::RegionType::ExplorableZone) ? 0 : 1; + return true; + }; + const bool is_running = plugin.ActiveProfile().sequential_route; + DrawMapBatchPicker(plugin, "exp", exp_filter_buf_, sizeof(exp_filter_buf_), + batch_exp_checked_, batch_exp_order_, type_ok, + is_running ? std::vector(std::begin(kColumnsRunning), std::end(kColumnsRunning)) + : std::vector(std::begin(kColumns), std::end(kColumns))); +} + +// --------------------------------------------------------------------------- +// Mission batch picker +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawMissionBatchPicker(SplitsWindow& plugin) +{ + using MapID = GW::Constants::MapID; + using Camp = GW::Constants::Campaign; + + struct MissionRow { int id; std::string name; uint32_t chron; }; + + constexpr int NUM_CAMPS = 3; // EotN skipped (kBatchCampaigns/kBatchCampaignLabels defined above, shared with the Town/Explorable pickers) + + auto build_list = [](Camp camp) -> std::vector { + if (camp == Camp::Nightfall) { + static const MapID order[] = { + MapID::Chahbek_Village, MapID::Jokanur_Diggings, MapID::Blacktide_Den, + MapID::Consulate_Docks, MapID::Venta_Cemetery, MapID::Kodonur_Crossroads, + MapID::Pogahn_Passage, MapID::Rilohn_Refuge, MapID::Moddok_Crevice, + MapID::Tihark_Orchard, MapID::Dasha_Vestibule, MapID::Dzagonur_Bastion, + MapID::Grand_Court_of_Sebelkeh, MapID::Jennurs_Horde, MapID::Nundu_Bay, + MapID::Gate_of_Desolation, MapID::Ruins_of_Morah, MapID::Gate_of_Pain, + MapID::Gate_of_Madness, MapID::Abaddons_Gate, + }; + std::vector out; + out.reserve(std::size(order)); + for (uint32_t i = 0; i < static_cast(std::size(order)); ++i) + out.push_back({ static_cast(order[i]), Resources::GetMapName(order[i])->string(), i + 1 }); + return out; + } + if (camp == Camp::Factions) { + static const MapID order[] = { + MapID::Minister_Chos_Estate_outpost_mission, MapID::Zen_Daijun_outpost_mission, + MapID::Vizunah_Square_mission, MapID::Nahpui_Quarter_outpost_mission, + MapID::Tahnnakai_Temple_outpost_mission, MapID::Arborstone_outpost_mission, + MapID::Boreas_Seabed_outpost_mission, MapID::Sunjiang_District_outpost_mission, + MapID::The_Eternal_Grove_outpost_mission, MapID::Gyala_Hatchery_outpost_mission, + MapID::Unwaking_Waters_Kurzick_outpost, MapID::Raisu_Palace_outpost_mission, + MapID::Imperial_Sanctum_outpost_mission, + }; + std::vector out; + out.reserve(std::size(order)); + for (uint32_t i = 0; i < static_cast(std::size(order)); ++i) + out.push_back({ static_cast(order[i]), Resources::GetMapName(order[i])->string(), i + 1 }); + return out; + } + if (camp == Camp::Prophecies) { + static const MapID order[] = { + MapID::The_Great_Northern_Wall, MapID::Fort_Ranik, MapID::Ruins_of_Surmia, + MapID::Nolani_Academy, MapID::Borlis_Pass, MapID::The_Frost_Gate, + MapID::Gates_of_Kryta, MapID::DAlessio_Seaboard, MapID::Divinity_Coast, + MapID::The_Wilds, MapID::Bloodstone_Fen, MapID::Aurora_Glade, + MapID::Riverside_Province, MapID::Sanctum_Cay, MapID::Dunes_of_Despair, + MapID::Thirsty_River, MapID::Elona_Reach, MapID::Augury_Rock_outpost, + MapID::The_Dragons_Lair, MapID::Ice_Caves_of_Sorrow, MapID::Iron_Mines_of_Moladune, + MapID::Thunderhead_Keep, MapID::Ring_of_Fire, MapID::Abaddons_Mouth, + MapID::Hells_Precipice, + }; + std::vector out; + out.reserve(std::size(order)); + for (uint32_t i = 0; i < static_cast(std::size(order)); ++i) + out.push_back({ static_cast(order[i]), Resources::GetMapName(order[i])->string(), i + 1 }); + return out; + } + // Dynamic scan for other campaigns + auto type_rank = [](GW::RegionType t) { + switch (t) { + case GW::RegionType::EotnMission: return 0; + case GW::RegionType::CooperativeMission: return 1; + case GW::RegionType::EliteMission: return 1; + case GW::RegionType::MissionOutpost: return 2; + case GW::RegionType::Dungeon: return 3; + default: return 99; + } + }; + struct Candidate { int id; uint32_t chron; int rank; }; + std::unordered_map best; + for (int id = 1; id < static_cast(MapID::Count); ++id) { + const auto mid = static_cast(id); + const auto* info = GW::Map::GetMapInfo(mid); + if (!info || !info->name_id || !info->GetIsOnWorldMap() || info->campaign != camp) continue; + if (info->mission_chronology == 0) continue; + int rank = type_rank(info->type); + if (rank >= 99) continue; + auto it = best.find(info->name_id); + if (it == best.end() || rank < it->second.rank) + best[info->name_id] = { id, info->mission_chronology, rank }; + } + std::vector out; + out.reserve(best.size()); + for (const auto& kv : best) + out.push_back({ kv.second.id, Resources::GetMapName(static_cast(kv.second.id))->string(), kv.second.chron }); + std::sort(out.begin(), out.end(), + [](const MissionRow& a, const MissionRow& b) { return a.chron < b.chron; }); + return out; + }; + + ImGui::TextColored({1.f, 0.8f, 0.2f, 1.f}, + "Note: Bonus is read from the mission-complete bitmask, which never clears once earned. " + "On a character that's already earned a mission's bonus before, Bonus will always show " + "complete on every later attempt of that mission, whether or not it's actually re-earned that run."); + + if (ImGui::BeginTabBar("##batch_campaigns")) { + for (int ci = 0; ci < NUM_CAMPS; ++ci) { + if (!ImGui::BeginTabItem(kBatchCampaignLabels[ci])) continue; + const auto missions = build_list(kBatchCampaigns[ci]); + + if (ImGui::SmallButton("All M")) { for (const auto& m : missions) batch_mis_checked_.insert(m.id); } + ImGui::SameLine(); + if (ImGui::SmallButton("None M")) { for (const auto& m : missions) batch_mis_checked_.erase(m.id); } + ImGui::SameLine(0, 16); + if (ImGui::SmallButton("All B")) { for (const auto& m : missions) batch_bon_checked_.insert(m.id); } + ImGui::SameLine(); + if (ImGui::SmallButton("None B")) { for (const auto& m : missions) batch_bon_checked_.erase(m.id); } + ImGui::SameLine(0, 16); + ImGui::Checkbox("Hard Mode", &batch_hm_); + + constexpr ImGuiTableFlags tflags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("##missiontbl", 4, tflags, { -1.f, 220.f })) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("#", ImGuiTableColumnFlags_WidthFixed, 28.f); + ImGui::TableSetupColumn("Mission", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("M", ImGuiTableColumnFlags_WidthFixed, 28.f); + ImGui::TableSetupColumn("B", ImGuiTableColumnFlags_WidthFixed, 28.f); + ImGui::TableHeadersRow(); + + for (int mi = 0; mi < static_cast(missions.size()); ++mi) { + const auto& row = missions[mi]; + ImGui::PushID(row.id); + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); ImGui::TextDisabled("%d", mi + 1); + ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(row.name.c_str()); + ImGui::TableSetColumnIndex(2); + bool mc = batch_mis_checked_.count(row.id) > 0; + if (ImGui::Checkbox("##m", &mc)) { if (mc) batch_mis_checked_.insert(row.id); else batch_mis_checked_.erase(row.id); } + ImGui::TableSetColumnIndex(3); + bool bc = batch_bon_checked_.count(row.id) > 0; + if (ImGui::Checkbox("##b", &bc)) { if (bc) batch_bon_checked_.insert(row.id); else batch_bon_checked_.erase(row.id); } + ImGui::PopID(); + } + ImGui::EndTable(); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + const int total = static_cast(batch_mis_checked_.size() + batch_bon_checked_.size()); + if (total == 0) ImGui::BeginDisabled(); + char add_lbl[48]; + snprintf(add_lbl, sizeof(add_lbl), "Add %d Goal%s##batchadd", total, total == 1 ? "" : "s"); + if (ImGui::Button(add_lbl)) { + struct BatchItem { int id; bool bonus; Camp camp; uint32_t chron; }; + std::vector items; + items.reserve(total); + for (int id : batch_mis_checked_) { + const auto* info = GW::Map::GetMapInfo(static_cast(id)); + if (info) items.push_back({ id, false, info->campaign, info->mission_chronology }); + } + for (int id : batch_bon_checked_) { + const auto* info = GW::Map::GetMapInfo(static_cast(id)); + if (info) items.push_back({ id, true, info->campaign, info->mission_chronology }); + } + std::sort(items.begin(), items.end(), [](const BatchItem& a, const BatchItem& b) { + if (a.camp != b.camp) return a.camp < b.camp; + if (a.chron != b.chron) return a.chron < b.chron; + return !a.bonus; + }); + GoalList* list = plugin.List(); + for (const auto& item : items) { + GoalEntry g; + g.label = Resources::GetMapName(static_cast(item.id))->string(); + if (item.bonus) g.label += " - Bonus"; + if (batch_hm_) g.label += " (HM)"; + g.trigger.type = item.bonus ? GoalTrigger::Type::MissionBonus : GoalTrigger::Type::MissionComplete; + g.trigger.map_id = static_cast(item.id); + g.trigger.hard_mode = batch_hm_; + list->goals.push_back(std::move(g)); + } + list->RenumberDuplicateLabels(); + batch_mis_checked_.clear(); + batch_bon_checked_.clear(); + } + if (total == 0) ImGui::EndDisabled(); +} + +// --------------------------------------------------------------------------- +// Dungeon batch picker (EotN's 20 regular dungeons only — the 5 pre-EotN elite areas each need their own finish condition, see DrawEliteAreaBatchPicker below). +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawDungeonBatchPicker(SplitsWindow& plugin) +{ + using MapID = GW::Constants::MapID; + + struct DungeonRow { int id; std::string name; const SCPresets::Dungeon* dungeon; }; + std::vector rows; + rows.reserve(std::size(SCPresets::kDungeons)); + for (const auto& dungeon : SCPresets::kDungeons) + rows.push_back({ static_cast(dungeon.levels[0]), Resources::GetMapName(dungeon.levels[0])->string(), &dungeon }); + std::sort(rows.begin(), rows.end(), [](const DungeonRow& a, const DungeonRow& b) { return a.name < b.name; }); + + std::vector filtered; + for (const auto& r : rows) { + if (dungeon_filter_buf_[0] != '\0' && !TextUtils::CaseInsensitiveContains(r.name, dungeon_filter_buf_)) + continue; + filtered.push_back(&r); + } + + if (plugin.ActiveProfile().dynamic_by_default) { + // SC: single click = the full per-level breakdown, matching the generated preset exactly. No partial picks; rename before Save for something more specific (e.g. "2 Man CoF"). + ImGui::TextColored({1.f, 0.8f, 0.2f, 1.f}, + "Click a dungeon to start a new list with its full level breakdown. Rename it\n" + "below (e.g. \"2 Man CoF\") before saving if you want something more specific."); + ImGui::SetNextItemWidth(-1.f); + ImGui::InputText("##dungeonfilter", dungeon_filter_buf_, sizeof(dungeon_filter_buf_)); + ImGui::SameLine(); if (ImGui::SmallButton("x##dungeonfx")) dungeon_filter_buf_[0] = '\0'; + + ImGui::BeginChild("##dungeonlist_sc", { -1.f, 220.f }, true); + for (const auto* r : filtered) { + if (ImGui::Selectable(r->name.c_str())) { + plugin.SetActiveList(SCPresets::BuildDungeonPresetList(*r->dungeon)); + snprintf(list_name_buf_, sizeof(list_name_buf_), "%s", r->name.c_str()); + } + } + ImGui::EndChild(); + return; + } + + // Manual stays flat (unlike SC) — only the first level's map_id names the dungeon; the goal itself is one generic DungeonReward completion. + ImGui::TextColored({1.f, 0.8f, 0.2f, 1.f}, + "Note: completion is a generic \"dungeon reward chest opened\" signal, not specific to " + "this dungeon \xe2\x80\x94 correct as long as the list is run in order, same as any other " + "sequential Manual goal."); + + ImGui::SetNextItemWidth(-1.f); + ImGui::InputText("##dungeonfilter", dungeon_filter_buf_, sizeof(dungeon_filter_buf_)); + ImGui::SameLine(); if (ImGui::SmallButton("x##dungeonfx")) dungeon_filter_buf_[0] = '\0'; + + if (ImGui::SmallButton("All")) { for (const auto* r : filtered) batch_dungeon_checked_.insert(r->id); } + ImGui::SameLine(); + if (ImGui::SmallButton("None")) { for (const auto* r : filtered) batch_dungeon_checked_.erase(r->id); } + + constexpr ImGuiTableFlags tflags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("##dungeontbl", 2, tflags, { -1.f, 220.f })) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Dungeon", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Add", ImGuiTableColumnFlags_WidthFixed, 40.f); + ImGui::TableHeadersRow(); + + for (const auto* r : filtered) { + ImGui::PushID(r->id); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(r->name.c_str()); + ImGui::TableSetColumnIndex(1); + bool checked = batch_dungeon_checked_.count(r->id) > 0; + if (ImGui::Checkbox("##d", &checked)) { + if (checked) batch_dungeon_checked_.insert(r->id); + else batch_dungeon_checked_.erase(r->id); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + + const int total = static_cast(batch_dungeon_checked_.size()); + if (total == 0) ImGui::BeginDisabled(); + char add_lbl[48]; + snprintf(add_lbl, sizeof(add_lbl), "Add %d Goal%s##batchadddungeon", total, total == 1 ? "" : "s"); + if (ImGui::Button(add_lbl)) { + GoalList* list = plugin.List(); + // Iterate the name-sorted rows, not the raw std::set (which orders by map_id), so multi-add lands in a predictable order. + for (const auto& r : rows) { + if (!batch_dungeon_checked_.count(r.id)) continue; + GoalEntry g; + g.label = r.name; + g.trigger.type = GoalTrigger::Type::DungeonReward; + g.trigger.map_id = static_cast(r.id); + list->goals.push_back(std::move(g)); + } + list->RenumberDuplicateLabels(); + batch_dungeon_checked_.clear(); + } + if (total == 0) ImGui::EndDisabled(); +} + +// --------------------------------------------------------------------------- +// Elite area batch picker — Fissure of Woe, Underworld, Urgoz's Warren, The Deep. +// ToPK deferred: its arenas are map-based (InstanceLoadInfo/CountdownStart), not an objective/door checklist, so it doesn't fit this picker's shape. +// Each checkpoint is reduced from OT's own preset definitions to "what fires when this segment is done": FoW/UW use their ObjectiveDone objective_id; Urgoz/Deep use whatever door/dialogue/message OT uses to start the *next* segment. +// "Area complete" is a derived state (all checkpoints done, any order) rather than a single trigger — DrawHeaderRow's existing all_done aggregation handles it for free; checking every checkpoint + Add wraps them in a header, a partial pick adds flat goals instead. +// --------------------------------------------------------------------------- +void SplitsGoalListWindow::DrawEliteAreaBatchPicker(SplitsWindow& plugin) +{ + struct AreaTab { + const SCPresets::EliteArea* area; + std::set* checked; // keyed by checkpoint index, not param1 — safe across mixed trigger types + }; + const AreaTab areas[] = { + { &SCPresets::kEliteAreas[0], &batch_fow_checked_ }, + { &SCPresets::kEliteAreas[1], &batch_uw_checked_ }, + { &SCPresets::kEliteAreas[2], &batch_urgoz_checked_ }, + { &SCPresets::kEliteAreas[3], &batch_deep_checked_ }, + }; + + if (plugin.ActiveProfile().dynamic_by_default) { + // SC: single click = every checkpoint, matching the generated preset exactly. No partial picks; rename before Save for something more specific. + ImGui::TextColored({1.f, 0.8f, 0.2f, 1.f}, + "Click an area to start a new list with its full checkpoint set. Rename it below\n" + "(e.g. \"2 Man FoW\") before saving if you want something more specific."); + ImGui::BeginChild("##elitelist_sc", { -1.f, 220.f }, true); + for (const auto& tab : areas) { + if (ImGui::Selectable(tab.area->label)) { + plugin.SetActiveList(SCPresets::BuildEliteAreaPresetList(*tab.area)); + snprintf(list_name_buf_, sizeof(list_name_buf_), "%s", tab.area->label); + } + } + // DoA's rotation is spawn-dependent (see doa_preset_cache_), so unlike the areas above there's no single "right" list to auto-generate — these just let you preview/pre-build one of the 4 known rotations. If the real starting zone turns out different once you're actually in DoA, splits still fire and time correctly (each zone's own start_trigger is checked independent of list position), only the displayed order would read differently than what actually happened. + static const char* kDoAZoneNames[4] = { "Foundry", "City", "Veil", "Gloom" }; + const auto& doa_cache = plugin.DoAPresetCache(); + for (int i = 0; i < 4; ++i) { + char label[48]; + snprintf(label, sizeof(label), "Domain of Anguish (starts: %s)", kDoAZoneNames[i]); + if (ImGui::Selectable(label)) { + plugin.SetActiveList(doa_cache[static_cast(i)]); + snprintf(list_name_buf_, sizeof(list_name_buf_), "Domain of Anguish"); + } + } + ImGui::EndChild(); + return; + } + + if (ImGui::BeginTabBar("##elite_areas")) { + for (const auto& tab : areas) { + const auto& area = *tab.area; + if (!ImGui::BeginTabItem(area.label)) continue; + + ImGui::TextColored({1.f, 0.8f, 0.2f, 1.f}, + "For overall completion tracking (one header, complete once every checkpoint\n" + "below is done \xe2\x80\x94 order doesn't matter), check all of them. Checking only some\n" + "adds just those as individual flat goals, with no completion header."); + + if (ImGui::SmallButton("All")) { for (size_t i = 0; i < area.count; ++i) tab.checked->insert(static_cast(i)); } + ImGui::SameLine(); + if (ImGui::SmallButton("None")) { tab.checked->clear(); } + + constexpr ImGuiTableFlags tflags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("##elitetbl", 2, tflags, { -1.f, 220.f })) { + ImGui::TableSetupColumn("Checkpoint", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Add", ImGuiTableColumnFlags_WidthFixed, 40.f); + ImGui::TableHeadersRow(); + + for (size_t i = 0; i < area.count; ++i) { + ImGui::PushID(static_cast(i)); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(area.checkpoints[i].name); + ImGui::TableSetColumnIndex(1); + bool checked = tab.checked->count(static_cast(i)) > 0; + if (ImGui::Checkbox("##e", &checked)) { + if (checked) tab.checked->insert(static_cast(i)); + else tab.checked->erase(static_cast(i)); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + + const int total = static_cast(tab.checked->size()); + if (total == 0) ImGui::BeginDisabled(); + char add_lbl[48]; + snprintf(add_lbl, sizeof(add_lbl), "Add %d Goal%s##batchaddelite", total, total == 1 ? "" : "s"); + if (ImGui::Button(add_lbl)) { + GoalList* list = plugin.List(); + // Every checkpoint checked = the whole area, so wrap in a header; a partial pick is just flat goals. + const bool all_checked = (tab.checked->size() == area.count); + if (all_checked) { + GoalEntry hdr; + hdr.is_header = true; + hdr.label = area.label; + hdr.trigger.map_id = area.map_id; // read by ApplyTimerPolicy's autostart, not the engine + list->goals.push_back(std::move(hdr)); + } + for (size_t i = 0; i < area.count; ++i) { + if (!tab.checked->count(static_cast(i))) continue; + GoalEntry g = SCPresets::BuildCheckpointGoal(area.checkpoints[i], area.map_id); + g.indent = all_checked ? 1 : 0; + list->goals.push_back(std::move(g)); + } + list->RenumberDuplicateLabels(); + tab.checked->clear(); + } + if (total == 0) ImGui::EndDisabled(); + + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } +} diff --git a/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.h b/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.h new file mode 100644 index 000000000..d3b6e226d --- /dev/null +++ b/GWToolboxdll/Windows/Splits/SplitsGoalListWindow.h @@ -0,0 +1,91 @@ +#pragma once + +#include +#include +#include +#include + +// Forward declarations +class SplitsWindow; +class ToolboxIni; +class SettingsDoc; +struct GoalList; +struct GoalEntry; +class GoalClock; + +// --------------------------------------------------------------------------- +// SplitsGoalListWindow — runtime display + settings UI for the Splits window. +// Per-profile settings live on SplitsProfile (plugin.ActiveProfile()); colors are global on SplitsWindow itself. +// --------------------------------------------------------------------------- +class SplitsGoalListWindow { +public: + void Draw(SplitsWindow& window); + void DrawSettings(SplitsWindow& window); + // LoadSettings/SaveSettings for non-profile state (edit buffers, etc.) + void LoadSettings(SettingsDoc& doc, ToolboxIni* legacy); + void SaveSettings(SettingsDoc& doc); + +private: + bool DrawHeaderRow(const GoalList& list, int header_idx, const struct SplitsProfile& profile); + void DrawGoalRow(const GoalEntry& g, const GoalClock& clock, int index, + bool is_current, + double pb_split_real, double pb_seg_real, + double pb_split_game, double pb_seg_game, + const struct SplitsProfile& profile, SplitsWindow& plugin); + void DrawRecentRunsSection(SplitsWindow& plugin); + + // DrawSettings sections — one profile's UI per named method (see .cpp header comment). + void DrawProfileSwitcher(SplitsWindow& window); + void DrawTimeAndBehaviorColumn(SplitsWindow& window); + void DrawKeybindsAndColorsColumn(SplitsWindow& window); + void DrawGoalListManagementColumn(SplitsWindow& window); + void DrawSCGoalsSummary(SplitsWindow& window); + void DrawEditableGoalsList(SplitsWindow& window); + void DrawStandardAddGoalForm(SplitsWindow& window); + + void DrawMissionBatchPicker(SplitsWindow& window); + void DrawExplorableBatchPicker(SplitsWindow& window); + void DrawTownBatchPicker(SplitsWindow& window); + void DrawDungeonBatchPicker(SplitsWindow& window); + void DrawEliteAreaBatchPicker(SplitsWindow& window); + void DrawTitlePicker(SplitsWindow& window); + + char edit_label_[128] = {}; + int edit_trigger_type_ = 0; // -1 = Header (no trigger), -2 = Quest (Pickup+Complete pair) + int edit_level_ = 1; + char list_name_buf_[64] = {}; + + std::set batch_mis_checked_; + std::set batch_bon_checked_; + bool batch_hm_ = false; + + std::map batch_exp_checked_; + // Running only: records check order so its route can be added in click order instead of the region/name sort Manual/SC want. + std::vector> batch_exp_order_; + char exp_filter_buf_[128] = {}; + + std::map batch_town_checked_; + std::vector> batch_town_order_; + char town_filter_buf_[128] = {}; + + std::set batch_dungeon_checked_; + char dungeon_filter_buf_[128] = {}; + + // Elite area picker — keyed by checkpoint array index, not param1 (Urgoz/Deep mix trigger types whose param1/pattern values could otherwise collide). + std::set batch_fow_checked_; + std::set batch_uw_checked_; + std::set batch_urgoz_checked_; + std::set batch_deep_checked_; + + int edit_title_id_ = 0xff; + int edit_title_rank_ = -1; // 0-based tier index; -1 = unset (defaults to max on first use) + char title_filter_buf_[128] = {}; + + // Manual entry for both — matches wiki numbering, no master name list needed for either. + int edit_quest_id_ = 0; + int edit_skill_id_ = 0; + + // Model ID is visible live in-game via the Info window's target panel, no wiki needed. + int edit_mob_id_ = 0; + int edit_mob_kill_count_ = 1; +}; diff --git a/GWToolboxdll/Windows/Splits/SplitsProfile.cpp b/GWToolboxdll/Windows/Splits/SplitsProfile.cpp new file mode 100644 index 000000000..017ad40c4 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/SplitsProfile.cpp @@ -0,0 +1,93 @@ +#include "stdafx.h" +#include "SplitsProfile.h" + +namespace { +struct BoolField { const char* key; bool SplitsProfile::* member; }; +// Shared by LoadSettings/SaveSettings — one table instead of each key listed out separately on both sides. +constexpr BoolField kBoolFields[] = { + {"stop_on_party_defeated", &SplitsProfile::stop_on_party_defeated}, + {"auto_fail_on_rezone", &SplitsProfile::auto_fail_on_rezone}, + {"auto_send_age", &SplitsProfile::auto_send_age}, + {"both_header_only", &SplitsProfile::both_header_only}, + {"show_split_pb", &SplitsProfile::show_split_pb}, + {"show_segment", &SplitsProfile::show_segment}, + {"show_segment_pb", &SplitsProfile::show_segment_pb}, + {"show_paused_time", &SplitsProfile::show_paused_time}, + {"show_recent_runs", &SplitsProfile::show_recent_runs}, + {"dynamic_by_default", &SplitsProfile::dynamic_by_default}, + {"sequential_route", &SplitsProfile::sequential_route}, + {"auto_reset_on_complete", &SplitsProfile::auto_reset_on_complete}, +}; +} // namespace + +// --------------------------------------------------------------------------- +void SplitsProfile::LoadSettings(SettingsDoc& doc, ToolboxIni* ini, const char* section) +{ + for (const auto& f : kBoolFields) { + bool& field = this->*f.member; + if (!doc.Get(section, f.key, field)) + field = ini->GetBoolValue(section, f.key, field); + } + { + auto v = static_cast(time_display); + if (!doc.Get(section, "time_display", v)) { + const bool legacy_game = ini->GetBoolValue(section, "use_game_time", time_display == TimeDisplay::Game); + v = legacy_game ? static_cast(TimeDisplay::Game) : static_cast(TimeDisplay::Real); + } + if (v <= 2) time_display = static_cast(v); + } + { + auto v = static_cast(comparison_mode); + if (!doc.Get(section, "comparison_mode", v)) + v = static_cast(ini->GetLongValue(section, "comparison_mode", v)); + if (v <= 2) comparison_mode = static_cast(v); + } + if (!doc.Get(section, "last_list_name", last_list_name)) { + const char* v = ini->GetValue(section, "last_list_name", ""); + last_list_name = v ? v : ""; + } +} + +void SplitsProfile::SaveSettings(SettingsDoc& doc, const char* section) const +{ + for (const auto& f : kBoolFields) + doc.Set(section, f.key, this->*f.member); + doc.Set(section, "time_display", static_cast(time_display)); + doc.Set(section, "comparison_mode", static_cast(comparison_mode)); + doc.Set(section, "last_list_name", last_list_name); +} + +// --------------------------------------------------------------------------- +SplitsProfile MakeManualProfile() +{ + SplitsProfile p; + p.name = "Manual"; + p.stop_on_party_defeated = true; + p.auto_fail_on_rezone = true; + p.auto_send_age = false; + return p; +} + +SplitsProfile MakeRunningProfile() +{ + SplitsProfile p; + p.name = "Running"; + p.stop_on_party_defeated = false; + p.auto_fail_on_rezone = true; + p.auto_send_age = false; + p.show_segment = true; // "Split" column + p.sequential_route = true; + return p; +} + +SplitsProfile MakeSCProfile() +{ + SplitsProfile p; + p.name = "SC"; + p.stop_on_party_defeated = true; + p.auto_fail_on_rezone = true; + p.auto_send_age = false; + p.dynamic_by_default = true; // Start/End/Duration throughout — see field comment + p.auto_reset_on_complete = true; + return p; +} diff --git a/GWToolboxdll/Windows/Splits/SplitsProfile.h b/GWToolboxdll/Windows/Splits/SplitsProfile.h new file mode 100644 index 000000000..70e824358 --- /dev/null +++ b/GWToolboxdll/Windows/Splits/SplitsProfile.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +class ToolboxIni; +class SettingsDoc; + +// --------------------------------------------------------------------------- +// SplitsProfile — all per-profile settings: display, behaviour, timer rules. +// SplitsWindow owns an array of 3 of these (Manual/Running/SC) and hot-switches between them. +// --------------------------------------------------------------------------- +struct SplitsProfile { + // Human-readable label shown in the profile switcher. + std::string name; + + // ---- Behavioural flags ------------------------------------------------- + bool stop_on_party_defeated = true; + // Fails the run if a Vanquish/Mission/Bonus goal's map is left while still Started (attempted and abandoned); same whole-run-fails semantics as stop_on_party_defeated. + bool auto_fail_on_rezone = true; + bool auto_send_age = false; + + // ---- Display settings -------------------------------------------------- + enum class TimeDisplay : uint8_t { Real = 0, Game = 1, Both = 2 }; + TimeDisplay time_display = TimeDisplay::Game; + bool both_header_only = false; // Both: show Real+Game in clock only, rows stay game-only + // PB = fastest completed run; Average = mean of non-failed runs; SumOfBest = cumulative sum of each leg's fastest-ever segment (non-failed runs) — only the data source changes, same Ahead/Behind coloring throughout. + enum class ComparisonMode : uint8_t { PB = 0, Average = 1, SumOfBest = 2 }; + ComparisonMode comparison_mode = ComparisonMode::PB; + bool show_split_pb = true; // shows the cumulative-time-vs-comparison delta under the time column + bool show_segment = true; + bool show_segment_pb = true; // shows the segment-vs-comparison-segment delta under the split column + bool show_paused_time = false; // shows running total of manually-paused real time next to the clock + bool show_recent_runs = false; + // SC forces every goal to DisplayStyle::Dynamic regardless of its own field — SC's parallel/independent-start objectives don't fit PB/Average/Last-Run comparison math. Manual/Running leave this false. + bool dynamic_by_default = false; + // Running's zone-transition-chain behavior (movement-based autostart, ordered single-goal-per-leg picker, wrong-turn auto-fail) — read by name instead of comparing ActiveProfileIdx() to a raw index. Manual/SC leave this false. + bool sequential_route = false; + // After a run completes or fails, waits for the same re-entry signal that would start a fresh run, then resets and starts in one step — lets farming presets loop without a manual Reset. SC-only. + bool auto_reset_on_complete = false; + + // ---- Goal list binding ------------------------------------------------- + // Manual/Running: remembers the last-used list so it reloads on profile switch. + std::string last_list_name; + + void LoadSettings(SettingsDoc& doc, ToolboxIni* legacy, const char* section); + void SaveSettings(SettingsDoc& doc, const char* section) const; +}; + +// --------------------------------------------------------------------------- +// Factory helpers — produce profiles with sensible per-mode defaults. +// --------------------------------------------------------------------------- +SplitsProfile MakeManualProfile(); +SplitsProfile MakeRunningProfile(); +SplitsProfile MakeSCProfile(); diff --git a/GWToolboxdll/Windows/SplitsWindow.cpp b/GWToolboxdll/Windows/SplitsWindow.cpp new file mode 100644 index 000000000..c4ddf1d52 --- /dev/null +++ b/GWToolboxdll/Windows/SplitsWindow.cpp @@ -0,0 +1,1470 @@ +#include "stdafx.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +// --------------------------------------------------------------------------- +// JSON DTOs for resume.json / per-list run history (glaze reflection requires external linkage). +// --------------------------------------------------------------------------- +namespace SplitsWindowJson { + struct SerializedSplit { + double real_time = 0.0; + double game_time = 0.0; + double segment_real = 0.0; + double segment_game = 0.0; + // Only meaningful when status == "Started" — a Completed goal's own split fields above are its record. + double start_real_time = 0.0; + double start_game_time = 0.0; + int trigger_progress = 0; + std::string status; // "Started" or "Completed" — NotStarted goals are std::nullopt, not this + }; + + struct SerializedResume { + std::string list_name; + double real_time = 0.0; + double game_time = 0.0; + std::optional total_paused; + std::optional is_preset; // which folder list_name's file lives in — see SaveActiveList's Defaults\ split + std::vector> goals; + }; + + struct SerializedRunSplit { + double real_time = 0.0; + double game_time = 0.0; + }; + + struct SerializedRunGoal { + std::string label; + std::string status; // "Completed" / "Failed" / "Started" / "NotStarted" + double real_time = 0.0; + double game_time = 0.0; + }; + + struct SerializedRun { + double total_real = 0.0; + std::vector splits; + std::optional failed; + std::optional utc_start; + std::optional character_name; + std::optional> goals; + std::optional total_paused; + }; +} +using namespace SplitsWindowJson; + +// Shadow-step skills that should trigger auto-start the same as movement. +// Mirrors TimerLogic.cpp in GWChrono. +static const std::unordered_set kShadowStepSkills = { + 769, // Viper's Defense + 770, // Return + 771, // Aura of Displacement + 799, // Beguiling Haze + 815, // Scorpion Wire + 836, // Ride the Lightning + 925, // Recall + 952, // Death's Charge + 1032, // Heart of Shadow + 1040, // Spirit Walk + 1044, // Dark Prison + 1644, // Wastrel's Collapse + 1646, // Augury of Death + 1650, // Shadow Walk + 1651, // Death's Retreat + 1652, // Shadow Prison + 1653, // Swap + 1654, // Shadow Meld + 2052, // Shadow Fang + 2420, // Ebon Escape + 3428, // Shadow Theft +}; + +// Manual: maps with a "Time until mission start" ready-check dialog whose wait should pause game time. Vizunah Square/Unwaking Waters lock the party (PartyLock); Ascalon Academy's queue is solo and only sends the generic countdown packet — both funnel into in_mission_queue_. +static bool IsMissionQueueMap(GW::Constants::MapID map) +{ + static constexpr GW::Constants::MapID kQueueMaps[] = { + GW::Constants::MapID::Vizunah_Square_Local_Quarter_outpost, + GW::Constants::MapID::Vizunah_Square_Foreign_Quarter_outpost, + GW::Constants::MapID::Unwaking_Waters_Luxon_outpost, + GW::Constants::MapID::Unwaking_Waters_Kurzick_outpost, + GW::Constants::MapID::Ascalon_City_pre_searing, + }; + for (const auto m : kQueueMaps) { + if (map == m) return true; + } + return false; +} + +// ============================================================================= +// LIFECYCLE +// ============================================================================= + +// --------------------------------------------------------------------------- +// Initialize / Terminate +// --------------------------------------------------------------------------- +void SplitsWindow::Initialize() +{ + ToolboxWindow::Initialize(); + + // Built now, not at the zone-transition tick — see doa_preset_cache_'s own comment. + for (int i = 0; i < 4; ++i) + doa_preset_cache_[static_cast(i)] = SCPresets::BuildDoAPresetForZone(i); + + GW::UI::RegisterUIMessageCallback( + &on_mission_complete_, + GW::UI::UIMessage::kMissionComplete, + [this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) { + const auto map_id = static_cast(GW::Map::GetMapID()); + engine_.NotifyMissionComplete(static_cast(map_id)); + // map_id is GetMapID() at the exact moment kMissionComplete fired — logged so a goal that silently never completes can be diagnosed against its own trigger.map_id (some missions report a transient "_cinematic" map_id here). + PushDbgEvent("MissComplete", map_id, 0); + }); + + GW::UI::RegisterUIMessageCallback( + &on_vanquish_complete_, + GW::UI::UIMessage::kVanquishComplete, + [this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) { + const auto map_id = static_cast(GW::Map::GetMapID()); + engine_.NotifyVanquishComplete(static_cast(map_id)); + PushDbgEvent("VqComplete", map_id, 0); + }); + + GW::UI::RegisterUIMessageCallback( + &on_party_defeated_, + GW::UI::UIMessage::kPartyDefeated, + [this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) { + // Latched rather than acted on directly, so ApplyTimerPolicy() is the one place all auto-fail/auto-start decisions live. + pending_party_defeated_ = true; + }); + + // ObjectiveAdd: fires at mission start for each objective; type_flags 0x1 = bullet/sub-objective, 0x0 = base/primary objective — GoalEngine uses the base objective's completion to synthesize MissionComplete with a reliable map_id. + GW::UI::RegisterUIMessageCallback( + &on_objective_add_, + GW::UI::UIMessage::kObjectiveAdd, + [this](GW::HookStatus*, GW::UI::UIMessage, void* wparam, void*) { + const auto* p = static_cast(wparam); + engine_.NotifyObjectiveAdd(p->objective_id, p->type); + PushDbgEvent("ObjAdd", p->objective_id, p->type); + }); + + GW::UI::RegisterUIMessageCallback( + &on_objective_done_, + GW::UI::UIMessage::kObjectiveComplete, + [this](GW::HookStatus*, GW::UI::UIMessage, void* wparam, void*) { + const auto* p = static_cast(wparam); + const auto map_id = static_cast(GW::Map::GetMapID()); + engine_.NotifyEvent(GoalTrigger::Type::ObjectiveDone, p->objective_id, map_id); + PushDbgEvent("ObjDone", p->objective_id, map_id); + }); + + GW::UI::RegisterUIMessageCallback( + &on_objective_started_, + GW::UI::UIMessage::kObjectiveUpdated, + [this](GW::HookStatus*, GW::UI::UIMessage, void* wparam, void*) { + const auto* p = static_cast(wparam); + engine_.NotifyEvent(GoalTrigger::Type::ObjectiveStarted, p->objective_id); + PushDbgEvent("ObjStart", p->objective_id, 0); + }); + + GW::StoC::RegisterPacketCallback( + &on_door_, + [this](GW::HookStatus*, const GW::Packet::StoC::ManipulateMapObject* p) { + if (GW::Map::GetInstanceType() != GW::Constants::InstanceType::Explorable) return; + if (p->animation_type == 16 && p->animation_stage == 2) { + engine_.NotifyEvent(GoalTrigger::Type::DoorOpen, p->object_id); + PushDbgEvent("DoorOpen", p->object_id, 0); + } else if (p->animation_type == 3 && p->animation_stage == 2) { + engine_.NotifyEvent(GoalTrigger::Type::DoorClose, p->object_id); + PushDbgEvent("DoorClose", p->object_id, 0); + } + }); + + GW::StoC::RegisterPacketCallback( + &on_agent_allegiance_, + [this](GW::HookStatus*, const GW::Packet::StoC::AgentUpdateAllegiance* p) { + const auto* agent = GW::Agents::GetAgentByID(p->agent_id); + if (!agent) return; + const auto* living = agent->GetAsAgentLiving(); + if (!living) return; + engine_.NotifyEvent(GoalTrigger::Type::AgentUpdateAllegiance, living->player_number, p->allegiance_bits); + PushDbgEvent("AgentAllg", living->player_number, p->allegiance_bits); + }); + + GW::StoC::RegisterPacketCallback( + &on_doa_zone_, + [this](GW::HookStatus*, const GW::Packet::StoC::DoACompleteZone* p) { + if (p->message[0] != 0x8101) return; + engine_.NotifyEvent(GoalTrigger::Type::DoACompleteZone, p->message[1]); + PushDbgEvent("DoAZone", p->message[1], 0); + }); + + GW::UI::RegisterUIMessageCallback( + &on_dungeon_reward_, + GW::UI::UIMessage::kDungeonComplete, + [this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) { + engine_.NotifyEvent(GoalTrigger::Type::DungeonReward); + PushDbgEvent("DungeonRwd", 0, 0); + }); + + GW::StoC::RegisterPacketCallback( + &on_server_message_, + [this](GW::HookStatus*, GW::Packet::StoC::MessageServer*) { + const auto* buff = &GW::GetGameContext()->world->message_buff; + if (!buff || !buff->valid() || !buff->size()) return; + const wchar_t* msg = buff->begin(); + const auto len = wcslen(msg); + engine_.NotifyEvent(GoalTrigger::Type::ServerMessage, 0, 0, msg, len); + // v1/v2 can't hold a full pattern — length + first wchar is just enough to sanity-check which one fired during live testing. + PushDbgEvent("SrvMsg", static_cast(len), len ? msg[0] : 0); + }); + + GW::StoC::RegisterPacketCallback( + &on_display_dialogue_, + [this](GW::HookStatus*, const GW::Packet::StoC::DisplayDialogue* p) { + const auto len = wcslen(p->message); + engine_.NotifyEvent(GoalTrigger::Type::DisplayDialogue, 0, 0, p->message, len); + PushDbgEvent("DispDlg", static_cast(len), len ? p->message[0] : 0); + }); + + // ToPK/Ascalon Academy countdown — gated to mission queue maps so unrelated countdowns don't pause Manual's game time. + GW::StoC::RegisterPacketCallback( + &on_countdown_start_, GAME_SMSG_INSTANCE_COUNTDOWN, + [this](GW::HookStatus*, GW::Packet::StoC::PacketBase*) { + const auto map_id = static_cast(GW::Map::GetMapID()); + if (active_profile_idx_ == 0 && IsMissionQueueMap(static_cast(map_id))) + in_mission_queue_ = true; + engine_.NotifyEvent(GoalTrigger::Type::CountdownStart, map_id); + PushDbgEvent("Countdown", map_id, 0); + }); + + // Running: shadow-step auto-start — filter to local player only. + GW::UI::RegisterUIMessageCallback( + &on_skill_activate_, + GW::UI::UIMessage::kSkillActivated, + [this](GW::HookStatus*, GW::UI::UIMessage, void* wparam, void*) { + const auto* p = static_cast(wparam); + if (p->agent_id == GW::Agents::GetControlledCharacterId()) + pending_skill_id_ = static_cast(p->skill_id); + }); + + // Manual: party lock signals the mission-start ready-check queue is up/down, gated to mission queue maps so unrelated party locks don't affect game time. + GW::StoC::RegisterPacketCallback( + &on_party_lock_, + [this](GW::HookStatus*, const GW::Packet::StoC::PartyLock* p) { + if (!p->unk2) { + in_mission_queue_ = false; + } else if (active_profile_idx_ == 0 && IsMissionQueueMap(last_map_)) { + in_mission_queue_ = true; + } + }); + + // Zone entry — fires for every new instance including same-map re-entry (district change, new character into same starting map, etc.). + GW::StoC::RegisterPostPacketCallback( + &on_instance_load_info_, + [this](GW::HookStatus*, const GW::Packet::StoC::InstanceLoadInfo* p) { + pending_came_from_explorable_ = last_was_explorable_; + last_was_explorable_ = (p->is_explorable != 0); + last_map_ = static_cast(p->map_id); + in_mission_queue_ = false; + pending_map_enter_ = true; + // reset so a character switch re-seeds instead of inheriting the last character's level + player_level_ = 0; + if (NuzlockeDeathRulesEnabled()) nuzlocke_.OnInstanceLoad(); + }); + + // Transfer packet fires before InstanceLoadInfo — clear queue state immediately on any server transfer (covers district changes where the map doesn't change). + GW::StoC::RegisterPacketCallback( + &on_game_srv_transfer_, + [this](GW::HookStatus*, const GW::Packet::StoC::GameSrvTransfer*) { + in_mission_queue_ = false; + }); + + // DoA's zone rotation is spawn-dependent, not a fixed map_id lookup — latched independently of pending_map_enter_ and consumed by ApplySCAutoLoadPreset(). + GW::StoC::RegisterPostPacketCallback( + &on_instance_load_file_, + [this](GW::HookStatus*, const GW::Packet::StoC::InstanceLoadFile* p) { + pending_doa_file_id_ = p->map_fileID; + pending_doa_spawn_ = p->spawn_point; + // v2 = spawn.x only (v1/v2 can't hold both floats) — enough to sanity-check DetectDoAStartingZone's input during live testing without a debugger. + PushDbgEvent("InstLoadFile", p->map_fileID, static_cast(static_cast(p->spawn_point.x))); + }); + + // --- Challenge/Nuzlocke debug events --- + + // Debug-log only — no player_id in the wparam-less UI message, but nothing here reads it for real tracking either (nuzlocke_.players is seeded from GetPlayerName(), never from this event). + GW::UI::RegisterUIMessageCallback( + &on_party_player_add_, + GW::UI::UIMessage::kPartyAddPlayer, + [this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) { + PushDbgEvent("PlyAdd", 0, 0); + }); + + GW::UI::RegisterUIMessageCallback( + &on_party_player_remove_, + GW::UI::UIMessage::kPartyRemovePlayer, + [this](GW::HookStatus*, GW::UI::UIMessage, void*, void*) { + PushDbgEvent("PlyRem", 0, 0); + }); + + // AgentState fires frequently for all agents — filter to dead-state transitions only (bit 0x10). Nuzlocke death tracking polls GetIsDead() instead (see NuzlockeUpdate); this hook now only feeds MobKill. + GW::StoC::RegisterPacketCallback( + &on_agent_state_, + [this](GW::HookStatus*, const GW::Packet::StoC::AgentState* p) { + if (!(p->state & 0x10)) return; + // Model ID doubles as living->player_number for non-player agents — forward it so a MobKill goal can count it. Players excluded since their player_number is a login/party index, not a monster model. + if (const auto* agent = GW::Agents::GetAgentByID(p->agent_id)) { + if (const auto* living = agent->GetAsAgentLiving(); living && !living->IsPlayer()) + engine_.NotifyEvent(GoalTrigger::Type::MobKill, living->player_number); + } + PushDbgEvent("AgentDied", p->agent_id, p->state); + }); + + // GWCA has no name for this UI message yet — reverse-engineered as AgentLevelChanged (wparam = {uint32_t agent_id, uint32_t level}). Replaces polling GetControlledCharacter()->level every tick for ReachLevel. + GW::UI::RegisterUIMessageCallback( + &on_agent_level_changed_, + GW::UI::UIMessage::kMessage_0x10000014, + [this](GW::HookStatus*, GW::UI::UIMessage, void* wparam, void*) { + struct AgentLevelChanged { uint32_t agent_id; uint32_t level; }; + const auto* p = static_cast(wparam); + if (p->agent_id == GW::Agents::GetControlledCharacterId()) + player_level_ = static_cast(p->level); + }); + + // kQuestAdded fires on pickup, and again (re-announcing already-current log_state) as part of a full quest-log resync at zone transitions — not a live per-objective push. The IsCompleted() check here is a safety-net fallback (e.g. picked up already-complete); kQuestDetailsChanged below is the actual live "objective just finished" signal. + GW::UI::RegisterUIMessageCallback( + &on_quest_update_, + GW::UI::UIMessage::kQuestAdded, + [this](GW::HookStatus*, GW::UI::UIMessage, void* wparam, void*) { + const auto quest_id = *static_cast(wparam); + auto* quest = GW::QuestMgr::GetQuest(quest_id); + engine_.NotifyEvent(GoalTrigger::Type::QuestPickup, static_cast(quest_id)); + if (quest && quest->IsCompleted()) + engine_.NotifyEvent(GoalTrigger::Type::QuestComplete, static_cast(quest_id)); + PushDbgEvent("QuestUpd", static_cast(quest_id), quest ? quest->log_state : 0); + }); + + // kQuestDetailsChanged fires live when a quest's own state changes (e.g. an objective completing) — unlike kQuestAdded, not tied to a zone-transition resync. + GW::UI::RegisterUIMessageCallback( + &on_quest_details_changed_, + GW::UI::UIMessage::kQuestDetailsChanged, + [this](GW::HookStatus*, GW::UI::UIMessage, void* wparam, void*) { + const auto quest_id = *static_cast(wparam); + auto* quest = GW::QuestMgr::GetQuest(quest_id); + if (quest && quest->IsCompleted()) + engine_.NotifyEvent(GoalTrigger::Type::QuestComplete, static_cast(quest_id)); + PushDbgEvent("QuestDetail", static_cast(quest_id), quest ? quest->log_state : 0); + }); + + // kQuestRemoved fires on turn-in AND abandon — doesn't distinguish the two, so an abandoned QuestComplete goal fires early. Acceptable for run-tracking; revisit if it causes false splits. + GW::UI::RegisterUIMessageCallback( + &on_quest_remove_, + GW::UI::UIMessage::kQuestRemoved, + [this](GW::HookStatus*, GW::UI::UIMessage, void* wparam, void*) { + const auto quest_id = *static_cast(wparam); + engine_.NotifyEvent(GoalTrigger::Type::QuestComplete, static_cast(quest_id)); + PushDbgEvent("QuestRem", static_cast(quest_id), 0); + }); +} + + +// Defaulted here, not in the header, matching NuzlockeState's own out-of-line ctor/dtor below. +SplitsWindow::SplitsWindow() = default; +SplitsWindow::~SplitsWindow() = default; + +// Defaulted here, not in the header, since pending_hench_names/city_hench_names hold unique_ptr and EncString is only forward-declared in NuzlockeState.h. +NuzlockeState::NuzlockeState() = default; +NuzlockeState::~NuzlockeState() = default; + + +void SplitsWindow::Terminate() +{ + GW::UI::RemoveUIMessageCallback(&on_mission_complete_); + GW::UI::RemoveUIMessageCallback(&on_vanquish_complete_); + GW::UI::RemoveUIMessageCallback(&on_party_defeated_); + GW::UI::RemoveUIMessageCallback(&on_objective_add_); + GW::UI::RemoveUIMessageCallback(&on_objective_done_); + GW::UI::RemoveUIMessageCallback(&on_objective_started_); + GW::StoC::RemoveCallback(&on_door_); + GW::StoC::RemoveCallback(&on_agent_allegiance_); + GW::StoC::RemoveCallback(&on_doa_zone_); + GW::UI::RemoveUIMessageCallback(&on_dungeon_reward_); + GW::StoC::RemoveCallback(&on_server_message_); + GW::StoC::RemoveCallback(&on_display_dialogue_); + GW::StoC::RemoveCallback(GAME_SMSG_INSTANCE_COUNTDOWN, &on_countdown_start_); + GW::UI::RemoveUIMessageCallback(&on_skill_activate_); + GW::StoC::RemoveCallback(&on_party_lock_); + GW::StoC::RemoveCallback(&on_instance_load_info_); + GW::StoC::RemoveCallback(&on_game_srv_transfer_); + GW::StoC::RemoveCallback(&on_instance_load_file_); + GW::UI::RemoveUIMessageCallback(&on_party_player_add_); + GW::UI::RemoveUIMessageCallback(&on_party_player_remove_); + GW::StoC::RemoveCallback(&on_agent_state_); + GW::UI::RemoveUIMessageCallback(&on_agent_level_changed_); + GW::UI::RemoveUIMessageCallback(&on_quest_update_); + GW::UI::RemoveUIMessageCallback(&on_quest_details_changed_); + GW::UI::RemoveUIMessageCallback(&on_quest_remove_); + engine_.Detach(); + ToolboxWindow::Terminate(); +} + + +void SplitsWindow::PushDbgEvent(const char* tag, const uint32_t v1, const uint32_t v2) +{ + if (!debug_log_events_) return; + if (challenge_dbg_events_.size() >= 200) + challenge_dbg_events_.erase(challenge_dbg_events_.begin()); + challenge_dbg_events_.push_back({tag, v1, v2}); +} + + +// ============================================================================= +// SETTINGS +// ============================================================================= + +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- +void SplitsWindow::LoadSettings(SettingsDoc& doc, ToolboxIni* legacy) +{ + ToolboxWindow::LoadSettings(doc, legacy); + + // Establish data folders — profile subfolders created below + const auto splits_path = Resources::GetPath(L"splits"); + const auto runs_path = Resources::GetPath(L"splits\\runs"); + Resources::EnsureFolderExists(splits_path); + Resources::EnsureFolderExists(runs_path); + Resources::EnsureFolderExists(splits_path / L"manual"); + Resources::EnsureFolderExists(splits_path / L"running"); + Resources::EnsureFolderExists(splits_path / L"sc"); + Resources::EnsureFolderExists(splits_path / L"sc" / L"Defaults"); + Resources::EnsureFolderExists(runs_path / L"manual"); + Resources::EnsureFolderExists(runs_path / L"running"); + Resources::EnsureFolderExists(runs_path / L"sc"); + Resources::EnsureFolderExists(runs_path / L"sc" / L"Defaults"); + splits_folder_ = splits_path.wstring() + L"\\"; + runs_folder_ = runs_path.wstring() + L"\\"; + + auto get_long = [&](const char* key, long def) -> long { + long v = def; + if (!doc.Get(Name(), key, v)) v = legacy->GetLongValue(Name(), key, def); + return v; + }; + key_start_ = get_long("key_start", 0); + key_reset_ = get_long("key_reset", 0); + key_split_ = get_long("key_split", 0); + for (const auto& f : kColorFields) { + Color& field = this->*f.member; + if (!doc.Get(Name(), f.key, field)) + field = Colors::Load(legacy, Name(), f.key, field); + } + active_profile_idx_ = static_cast(get_long("active_profile", 0)); + if (active_profile_idx_ < 0 || active_profile_idx_ >= kProfileCount) active_profile_idx_ = 0; + for (int i = 0; i < kProfileCount; ++i) + profiles_[i].LoadSettings(doc, legacy, kProfileSections[i]); + + for (const auto& f : kNuzlockeBoolFields) doc.Get(Name(), f.key, nuzlocke_.*f.member); + for (const auto& f : kNuzlockeLivesFields) nuzlocke_.*f.member = static_cast(get_long(f.key, 1)); + for (const auto& f : kNuzlockePointFields) nuzlocke_.goal_points.*f.member = static_cast(get_long(f.key, 0)); + + doc.Get(Name(), "debug_log_events", debug_log_events_); + + engine_.Attach(&active_list_); + + // Crash-protection resume check + const std::wstring resume_path = splits_folder_ + L"resume.json"; + std::ifstream rf(resume_path); + if (rf.is_open()) { + std::stringstream ss; + ss << rf.rdbuf(); + rf.close(); + + SerializedResume j; + constexpr glz::opts opts{.error_on_unknown_keys = false}; + if (!glz::read(j, ss.str())) { + if (!j.list_name.empty()) { + // active_profile_idx_ is already restored above, so ActiveSplitsFolder() returns the correct profile subfolder. + const std::wstring list_path = ActiveSplitsFolder() + + std::wstring(j.list_name.begin(), j.list_name.end()) + L".json"; + if (std::filesystem::exists(list_path)) { + pending_resume_name_ = j.list_name; + pending_resume_data_ = ss.str(); + pending_resume_ = true; + } + } + } + } +} + + +void SplitsWindow::SaveSettings(SettingsDoc& doc) +{ + doc.Set(Name(), "key_start", key_start_); + doc.Set(Name(), "key_reset", key_reset_); + doc.Set(Name(), "key_split", key_split_); + for (const auto& f : kColorFields) + doc.Set(Name(), f.key, this->*f.member); + doc.Set(Name(), "active_profile", active_profile_idx_); + for (int i = 0; i < kProfileCount; ++i) + profiles_[i].SaveSettings(doc, kProfileSections[i]); + for (const auto& f : kNuzlockeBoolFields) doc.Set(Name(), f.key, nuzlocke_.*f.member); + for (const auto& f : kNuzlockeLivesFields) doc.Set(Name(), f.key, nuzlocke_.*f.member); + for (const auto& f : kNuzlockePointFields) doc.Set(Name(), f.key, nuzlocke_.goal_points.*f.member); + + doc.Set(Name(), "debug_log_events", debug_log_events_); + ToolboxWindow::SaveSettings(doc); +} + + +// ============================================================================= +// GOAL LIST & SAVED-LIST MANAGEMENT +// ============================================================================= + +// --------------------------------------------------------------------------- +// Goal list management +// --------------------------------------------------------------------------- +void SplitsWindow::NewActiveList(const char* name) +{ + engine_.Detach(); + active_list_ = GoalList{}; + active_list_.name = name ? name : "New List"; + clock_.Reset(); + engine_.Attach(&active_list_); + cached_saved_lists_dirty_ = true; +} + +void SplitsWindow::SaveActiveList(bool clear_preset) +{ + if (splits_folder_.empty() || active_list_.name.empty()) return; + // Own subfolder, not a filename prefix in the same folder — a preset and a user's own list can then share a display name (e.g. both called "Shards of Orr") with zero collision, in Explorer or in-app. + const std::wstring folder = active_list_.is_preset ? ActiveSplitsFolder() + L"Defaults\\" : ActiveSplitsFolder(); + // An explicit user Save always produces a normal, user-owned list — presets are never persisted to disk themselves (always rebuilt live via ApplySCAutoLoadPreset). UpdateReferenceIfPB's internal auto-save passes false so it doesn't silently reclassify an in-progress farming session's list partway through (which used to split its own run history across two files — see RunHistoryFilePath). + if (clear_preset) active_list_.is_preset = false; + const std::wstring wname(active_list_.name.begin(), active_list_.name.end()); + active_list_.SaveToFile(folder + wname + L".json"); + cached_saved_lists_dirty_ = true; +} + + +void SplitsWindow::ReplaceActiveList(const std::function& populate) +{ + engine_.Detach(); + populate(); + engine_.Attach(&active_list_); + LoadPB(); +} + + +void SplitsWindow::LoadActiveList(const std::wstring& path) +{ + DeleteResumeState(); + ReplaceActiveList([&] { active_list_.LoadFromFile(path); }); + clock_.Reset(); + ResetRunFlags(); +} + + +void SplitsWindow::SetActiveList(GoalList list) +{ + // Deliberately not NewActiveList() + mutating List()->goals afterward: NewActiveList() attaches the engine against an empty GoalList, so Attach()-time setup (starts_immediately, etc.) would silently never apply if goals were added after the fact. + DeleteResumeState(); + // User-initiated, not tool-managed — never leave is_preset set on what's about to become an ordinary editable list. + ReplaceActiveList([&] { active_list_ = std::move(list); active_list_.is_preset = false; }); + clock_.Reset(); + ResetRunFlags(); +} + + +std::vector> SplitsWindow::GetSavedLists() const +{ + if (splits_folder_.empty()) return {}; + const std::wstring folder = ActiveSplitsFolder(); + if (cached_saved_lists_dirty_ || folder != cached_saved_lists_folder_) { + // Presets now live in their own Defaults\ subfolder (see SaveActiveList), so the non-recursive directory scan below already excludes them without needing a name-based filter. + cached_saved_lists_ = GoalList::ListSaved(folder); + cached_saved_lists_folder_ = folder; + cached_saved_lists_dirty_ = false; + } + return cached_saved_lists_; +} + + +std::wstring SplitsWindow::RunHistoryFilePath() const +{ + std::wstring safe_name(active_list_.name.begin(), active_list_.name.end()); + for (auto& c : safe_name) { + if (c == L' ') c = L'_'; + else if (c == L'/' || c == L'\\' || c == L':' || c == L'*' || + c == L'?' || c == L'"' || c == L'<' || c == L'>' || c == L'|') + c = L'-'; + } + // Same Defaults\ subfolder split as SaveActiveList() — without it, a preset run and a user's own saved list sharing the same display name would collide onto one run-history file. + const std::wstring folder = active_list_.is_preset ? ActiveRunsFolder() + L"Defaults\\" : ActiveRunsFolder(); + return folder + safe_name + L".json"; +} + + +// --------------------------------------------------------------------------- +// Profile folder routing +// --------------------------------------------------------------------------- +std::wstring SplitsWindow::ActiveSplitsFolder() const +{ + return splits_folder_ + kProfileFolderNames[static_cast(active_profile_idx_)]; +} + +std::wstring SplitsWindow::ActiveRunsFolder() const +{ + return runs_folder_ + kProfileFolderNames[static_cast(active_profile_idx_)]; +} + + +// ============================================================================= +// PB / COMPARISON / RUN HISTORY +// ============================================================================= + +void SplitsWindow::LoadPB(bool refresh_comparisons) +{ + const auto nan = std::numeric_limits::quiet_NaN(); + pb_splits_.clear(); pb_splits_game_.clear(); pb_total_real_ = nan; + if (refresh_comparisons) { + avg_splits_.clear(); avg_splits_game_.clear(); + best_seg_splits_.clear(); best_seg_splits_game_.clear(); + } + + if (runs_folder_.empty() || active_list_.name.empty()) return; + + const std::wstring runs_path = RunHistoryFilePath(); + std::ifstream rf(runs_path); + if (!rf.is_open()) return; + + std::stringstream ss; + ss << rf.rdbuf(); + + std::vector runs; + constexpr glz::opts opts{.error_on_unknown_keys = false}; + if (glz::read(runs, ss.str())) return; + + // Arrays are indexed by non-header goals only (headers carry no split data). + int goal_count = 0; + for (const auto& g : active_list_.goals) if (!g.is_header) ++goal_count; + if (goal_count == 0) return; + + // PB: fastest non-failed run that reached every goal. + double best = std::numeric_limits::infinity(); + const SerializedRun* best_run = nullptr; + for (const auto& run : runs) { + if (run.failed.value_or(false)) continue; + if (static_cast(run.splits.size()) < goal_count) continue; + if (run.total_real < best) { best = run.total_real; best_run = &run; } + } + if (best_run) { + pb_total_real_ = best; + pb_splits_.resize(static_cast(goal_count), nan); + pb_splits_game_.resize(static_cast(goal_count), nan); + for (int i = 0; i < goal_count && i < static_cast(best_run->splits.size()); ++i) { + pb_splits_[static_cast(i)] = best_run->splits[static_cast(i)].real_time; + pb_splits_game_[static_cast(i)] = best_run->splits[static_cast(i)].game_time; + } + } + + // Unconditional, unlike Average/Sum of Best below — a just-finished run should show up here immediately, not be excluded from its own history. + recent_runs_.clear(); + constexpr size_t kMaxRecentRuns = 5; + const size_t take = std::min(kMaxRecentRuns, runs.size()); + recent_runs_.reserve(take); + for (size_t i = 0; i < take; ++i) { + const SerializedRun& run = runs[runs.size() - 1 - i]; // newest first + RecentRun rr; + rr.total_real = run.total_real; + rr.failed = run.failed.value_or(false); + rr.utc_start = run.utc_start.value_or(0); + if (run.goals) { + rr.goals.reserve(run.goals->size()); + for (const auto& g : *run.goals) + rr.goals.push_back({g.label, g.real_time, g.game_time, g.status == "Completed"}); + } + recent_runs_.push_back(std::move(rr)); + } + + if (!refresh_comparisons) return; + + // Average: mean of each goal index's time across every non-failed run that reached it — a run that only got to goal 3 of 5 still contributes to goals 0-2's average. + { + std::vector sum_real(static_cast(goal_count), 0.0); + std::vector sum_game(static_cast(goal_count), 0.0); + std::vector count(static_cast(goal_count), 0); + for (const auto& run : runs) { + if (run.failed.value_or(false)) continue; + for (int i = 0; i < goal_count && i < static_cast(run.splits.size()); ++i) { + sum_real[static_cast(i)] += run.splits[static_cast(i)].real_time; + sum_game[static_cast(i)] += run.splits[static_cast(i)].game_time; + ++count[static_cast(i)]; + } + } + avg_splits_.resize(static_cast(goal_count), nan); + avg_splits_game_.resize(static_cast(goal_count), nan); + for (int i = 0; i < goal_count; ++i) { + if (count[static_cast(i)] > 0) { + avg_splits_[static_cast(i)] = sum_real[static_cast(i)] / count[static_cast(i)]; + avg_splits_game_[static_cast(i)] = sum_game[static_cast(i)] / count[static_cast(i)]; + } + } + } + + // Sum of Best: cumulative sum of each leg's fastest-ever segment, non-failed complete runs only (same restriction as Average) — the theoretical best if every best segment lined up in one run. + { + const auto inf = std::numeric_limits::infinity(); + std::vector best_seg_real(static_cast(goal_count), inf); + std::vector best_seg_game(static_cast(goal_count), inf); + for (const auto& run : runs) { + if (run.failed.value_or(false)) continue; + if (static_cast(run.splits.size()) < goal_count) continue; + double prev_real = 0.0, prev_game = 0.0; + for (int i = 0; i < goal_count; ++i) { + const double seg_real = run.splits[static_cast(i)].real_time - prev_real; + const double seg_game = run.splits[static_cast(i)].game_time - prev_game; + if (seg_real < best_seg_real[static_cast(i)]) best_seg_real[static_cast(i)] = seg_real; + if (seg_game < best_seg_game[static_cast(i)]) best_seg_game[static_cast(i)] = seg_game; + prev_real = run.splits[static_cast(i)].real_time; + prev_game = run.splits[static_cast(i)].game_time; + } + } + best_seg_splits_.resize(static_cast(goal_count), nan); + best_seg_splits_game_.resize(static_cast(goal_count), nan); + double cum_real = 0.0, cum_game = 0.0; + for (int i = 0; i < goal_count; ++i) { + if (std::isinf(best_seg_real[static_cast(i)])) break; // no successful run ever reached this leg + cum_real += best_seg_real[static_cast(i)]; + cum_game += best_seg_game[static_cast(i)]; + best_seg_splits_[static_cast(i)] = cum_real; + best_seg_splits_game_[static_cast(i)] = cum_game; + } + } +} + + +const std::vector& SplitsWindow::CompareSplits() const +{ + using CM = SplitsProfile::ComparisonMode; + switch (ActiveProfile().comparison_mode) { + case CM::Average: return avg_splits_; + case CM::SumOfBest: return best_seg_splits_; + default: return pb_splits_; + } +} + + +const std::vector& SplitsWindow::CompareSplitsGame() const +{ + using CM = SplitsProfile::ComparisonMode; + switch (ActiveProfile().comparison_mode) { + case CM::Average: return avg_splits_game_; + case CM::SumOfBest: return best_seg_splits_game_; + default: return pb_splits_game_; + } +} + + +void SplitsWindow::UpdateReferenceIfPB() +{ + if (std::isnan(pb_total_real_) || pb_splits_.empty()) return; + + double ref_total = std::numeric_limits::infinity(); + if (active_list_.reference.has_value() && !active_list_.reference->splits.empty()) + ref_total = active_list_.reference->splits.back(); + + if (pb_total_real_ >= ref_total) return; + + GoalReference& ref = active_list_.reference.emplace(); + ref.splits = pb_splits_; + + SaveActiveList(/*clear_preset=*/false); +} + + +// --------------------------------------------------------------------------- +// SaveCompletedRun / FailRun / SaveRunToHistory +// --------------------------------------------------------------------------- +void SplitsWindow::SaveCompletedRun() +{ + run_complete_ = true; + clock_.Pause(); + SaveRunToHistory(/*failed=*/false); + LoadPB(/*refresh_comparisons=*/false); + UpdateReferenceIfPB(); + DeleteResumeState(); + if (ActiveProfile().auto_send_age) + GW::Chat::SendChat('/', L"age"); +} + + +void SplitsWindow::FailRun(const char* reason) +{ + // RealTime()>0, not just IsRunning() — Running's own auto-pause-on-leaving-explorable can fire earlier this same tick (e.g. a wrong turn into a town), so a run that's genuinely in progress but just paused this instant must still be failable. + if (run_complete_ || run_failed_ || (!clock_.IsRunning() && clock_.RealTime() <= 0.0)) return; + engine_.FailRun(clock_); + clock_.Pause(); + run_failed_ = true; + WebSocketModule::Instance().Send("reset", (std::string("Splits: Reset - ") + reason).c_str()); + SaveRunToHistory(/*failed=*/true); + DeleteResumeState(); +} + + +void SplitsWindow::SaveRunToHistory(bool failed) +{ + if (runs_folder_.empty() || active_list_.name.empty()) return; + + const std::wstring runs_path = RunHistoryFilePath(); + + std::vector runs; + { + std::ifstream rf(runs_path); + if (rf.is_open()) { + std::stringstream ss; + ss << rf.rdbuf(); + constexpr glz::opts opts{.error_on_unknown_keys = false}; + if (glz::read(runs, ss.str())) runs.clear(); + } + } + + auto status_name = [](GoalStatus s) -> std::string { + switch (s) { + case GoalStatus::Started: return "Started"; + case GoalStatus::Completed: return "Completed"; + case GoalStatus::Failed: return "Failed"; + default: return "NotStarted"; + } + }; + + SerializedRun run; + run.total_real = clock_.RealTime(); + run.failed = failed; + run.utc_start = run_start_unix_; + run.character_name = run_char_name_; + run.total_paused = total_paused_real_; + + // Headers carry no split data — PB/history arrays index only non-header goals. + std::vector rgoals; + for (const auto& g : active_list_.goals) { + if (g.is_header) continue; + run.splits.push_back(SerializedRunSplit{g.split.real_time, g.split.game_time}); + rgoals.push_back(SerializedRunGoal{g.label, status_name(g.status), + g.split.real_time, g.split.game_time}); + } + run.goals = std::move(rgoals); + + runs.push_back(std::move(run)); + constexpr size_t kMaxRuns = 200; + if (runs.size() > kMaxRuns) + runs.erase(runs.begin(), runs.end() - static_cast(kMaxRuns)); + + std::ofstream f(runs_path); + if (f.is_open()) + f << glz::write(runs).value_or(std::string{}); +} + + +// ============================================================================= +// CRASH PROTECTION / RESUME +// ============================================================================= + +// --------------------------------------------------------------------------- +// Crash protection +// --------------------------------------------------------------------------- +void SplitsWindow::SaveResumeState() +{ + if (splits_folder_.empty() || !clock_.IsRunning() || active_list_.name.empty()) return; + + SerializedResume j; + j.list_name = active_list_.name; + j.real_time = clock_.RealTime(); + j.game_time = clock_.GameTime(); + j.total_paused = total_paused_real_; + if (active_list_.is_preset) j.is_preset = true; + + j.goals.reserve(active_list_.goals.size()); + for (const auto& g : active_list_.goals) { + // Started (not just Completed) goals need saving too — otherwise a crash mid-goal (e.g. an SC room already entered, a partial MobKill count) silently reverts to NotStarted on resume. + if (g.status != GoalStatus::Completed && g.status != GoalStatus::Started) { + j.goals.emplace_back(std::nullopt); + } else { + SerializedSplit js{ + g.split.real_time, g.split.game_time, g.split.segment_real, g.split.segment_game, + g.start_real_time, g.start_game_time, g.trigger_progress, + g.status == GoalStatus::Completed ? "Completed" : "Started"}; + j.goals.push_back(std::move(js)); + } + } + + std::ofstream f(splits_folder_ + L"resume.json"); + if (f.is_open()) f << glz::write(j).value_or(std::string{}); +} + +void SplitsWindow::DeleteResumeState() +{ + pending_resume_ = false; + pending_resume_name_.clear(); + pending_resume_data_.clear(); + if (splits_folder_.empty()) return; + std::error_code ec; + std::filesystem::remove(splits_folder_ + L"resume.json", ec); +} + + +void SplitsWindow::ApplyResume() +{ + if (!pending_resume_) return; + pending_resume_ = false; + + SerializedResume j; + constexpr glz::opts opts{.error_on_unknown_keys = false}; + const bool parse_failed = static_cast(glz::read(j, pending_resume_data_)); + pending_resume_data_.clear(); + if (parse_failed) return; + + if (j.list_name.empty()) return; + const double real_time = j.real_time; + const double game_time = j.game_time; + + const bool was_preset = j.is_preset.value_or(false); + const std::wstring list_path = (was_preset ? ActiveSplitsFolder() + L"Defaults\\" : ActiveSplitsFolder()) + + std::wstring(j.list_name.begin(), j.list_name.end()) + L".json"; + + // LoadFromFile always reads is_preset back as false (SaveActiveList clears it before writing — see its own comment), so restore it from the resume snapshot instead of trusting the file body. + ReplaceActiveList([&] { active_list_.LoadFromFile(list_path); active_list_.is_preset = was_preset; }); + + for (size_t i = 0; i < j.goals.size() && i < active_list_.goals.size(); ++i) { + const auto& jg = j.goals[i]; + if (!jg.has_value()) continue; + auto& g = active_list_.goals[i]; + g.trigger_progress = jg->trigger_progress; + if (jg->status == "Started") { + g.status = GoalStatus::Started; + g.start_real_time = jg->start_real_time; + g.start_game_time = jg->start_game_time; + } else { + g.status = GoalStatus::Completed; + g.split.real_time = jg->real_time; + g.split.game_time = jg->game_time; + g.split.segment_real = jg->segment_real; + g.split.segment_game = jg->segment_game; + } + } + + clock_.Restore(real_time, game_time); + engine_.ForceStarted(); + total_paused_real_ = j.total_paused.value_or(0.0); + pending_map_enter_ = false; + pending_came_from_explorable_ = false; +} + + +void SplitsWindow::DiscardResume() +{ + pending_resume_ = false; + pending_resume_data_.clear(); + DeleteResumeState(); +} + + +// ============================================================================= +// RUN LIFECYCLE CONTROLS +// ============================================================================= + +void SplitsWindow::ResetRunFlags() +{ + run_complete_ = false; + run_failed_ = false; + running_awaiting_movement_ = false; + running_load_paused_ = false; + pending_skill_id_ = 0; + in_mission_queue_ = false; + manually_paused_ = false; + manual_pause_accum_ = 0.0; + total_paused_real_ = 0.0; + pending_map_enter_ = false; + pending_came_from_explorable_ = false; +} + + +void SplitsWindow::BeginRun(const char* reason) +{ + run_char_name_.clear(); + if (const wchar_t* wname = GW::PlayerMgr::GetPlayerName()) + run_char_name_ = TextUtils::WStringToString(wname); + run_start_unix_ = static_cast(time(nullptr)); + WebSocketModule::Instance().Send("reset", "Splits: Reset - run starting"); + WebSocketModule::Instance().Send("start", (std::string("Splits: Start - ") + reason).c_str()); + clock_.Start(); +} + + +// --------------------------------------------------------------------------- +// Controls +// --------------------------------------------------------------------------- +void SplitsWindow::StartRun() +{ + if (clock_.IsRunning()) { + // Pause — leave all run/goal state untouched. + clock_.Pause(); + manually_paused_ = true; + manual_pause_accum_ = 0.0; + return; + } + + if (manually_paused_) { + // Resume — keep all progress; just fold the pause into the running total and continue. + total_paused_real_ += manual_pause_accum_; + manually_paused_ = false; + clock_.Resume(); + return; + } + + if (running_load_paused_ || running_awaiting_movement_) { + // Mid-run: clock was auto-paused by leaving the explorable (running_load_paused_), or we're + // already armed waiting for movement in the next one (running_awaiting_movement_). Either way + // this isn't a fresh start — no-op rather than Attach()/Reset() wiping completed-goal progress. + // Update() resumes automatically once movement is detected in an explorable area. + return; + } + + // Fresh start. + engine_.Attach(&active_list_); + // Full refresh (not just the PB-only rescan SaveCompletedRun() does) so Average/Sum of Best pick up whatever run just finished, since the run about to start should compare against it. + LoadPB(); + BeginRun("manually started"); + engine_.ForceStarted(); +} + +void SplitsWindow::ResetRun() +{ + if (clock_.IsRunning() || run_complete_ || run_failed_) + WebSocketModule::Instance().Send("reset", "Splits: Reset - run reset"); + DeleteResumeState(); + ResetRunFlags(); + engine_.Reset(); + clock_.Reset(); + // last_map_ deliberately left untouched: re-entry only fires when InstanceLoadInfo arrives, so no spurious MapEnter re-trigger on reset. + // Full refresh, unlike SaveCompletedRun()'s PB-only rescan. + LoadPB(); + if (NuzlockeDeathRulesEnabled()) nuzlocke_.ResetProgress(); +} + +void SplitsWindow::TriggerManualSplit() +{ + engine_.TriggerManual(clock_); +} + +void SplitsWindow::SwitchProfile(int idx) +{ + if (idx < 0 || idx >= kProfileCount || idx == active_profile_idx_) return; + + if (!active_list_.name.empty()) + profiles_[active_profile_idx_].last_list_name = active_list_.name; + + active_profile_idx_ = idx; + + // Reset run state without clearing last_map_ — resetting it to None would trigger a spurious just_entered_map next tick, immediately re-loading presets. + DeleteResumeState(); + ResetRunFlags(); + engine_.Detach(); + engine_.Reset(); + clock_.Reset(); + + active_list_ = GoalList{}; + + const SplitsProfile& p = ActiveProfile(); + if (!p.last_list_name.empty() && !splits_folder_.empty()) { + const std::wstring path = ActiveSplitsFolder() + + std::wstring(p.last_list_name.begin(), p.last_list_name.end()) + L".json"; + if (std::filesystem::exists(path)) + LoadActiveList(path); + } +} + + +// ============================================================================= +// TICK LOOP & TIMER POLICY +// ============================================================================= + +// --------------------------------------------------------------------------- +// Update — called every frame +// --------------------------------------------------------------------------- +void SplitsWindow::Update(float delta) +{ + if (NuzlockeDeathRulesEnabled()) nuzlocke_.Update(last_was_explorable_); + + const auto instance_type = GW::Map::GetInstanceType(); + const bool is_explorable = (instance_type == GW::Constants::InstanceType::Explorable); + const bool is_loading = (instance_type == GW::Constants::InstanceType::Loading); + const bool in_cinematic = GW::Map::GetIsInCinematic(); + const bool is_running = ActiveProfile().sequential_route; + + // Consume bus-sourced map-entry flags (set by InstanceLoadInfo / GameSrvTransfer callbacks). + const bool just_entered_map = pending_map_enter_; + const bool came_from_explorable = pending_came_from_explorable_; + pending_map_enter_ = false; + pending_came_from_explorable_ = false; + + // Before engine_.Update() below, so a freshly-swapped-in preset is already attached in time for this same tick's Pass 1/autostart checks. + ApplySCAutoLoadPreset(just_entered_map); + + // Accumulate wall-clock time while manually paused (clock_.RealTime() is frozen during a manual pause, so it can't measure how long the pause lasted). + if (manually_paused_) + manual_pause_accum_ += static_cast(delta); + // Manual: pause game time during loading, cinematics, and mission-start queues. Running: game time is controlled entirely by the clock pause below (explorable only). + const bool time_paused = is_loading || in_cinematic + || (active_profile_idx_ == 0 && in_mission_queue_); + + clock_.AddRealTime(static_cast(delta)); + + if (!time_paused) + clock_.AddGameTime(static_cast(delta)); + + const GW::Agent* controlled = GW::Agents::GetControlledCharacter(); + const GW::AgentLiving* controlled_living = controlled ? controlled->GetAsAgentLiving() : nullptr; + // Falls back to a poll only until on_agent_level_changed_ seeds a real value — 0 never persists once a character exists (min level is 1). + if (player_level_ == 0 && controlled_living) + player_level_ = static_cast(controlled_living->level); + + // Running: clock only ticks in explorable areas (no loading, no town time). + if (is_running) { + if (!is_explorable && clock_.IsRunning() && !running_load_paused_) { + clock_.Pause(); + running_load_paused_ = true; + } + // Arm movement detector whenever in explorable and clock isn't active — matches GWChrono's continuous check so the player doesn't need to re-zone to arm. + if (is_explorable && !clock_.IsRunning() && !run_complete_ && !run_failed_) + running_awaiting_movement_ = true; + } + + // Running: movement or shadow step in an explorable starts or resumes the clock. + if (is_running && !run_complete_ && !run_failed_) { + if (running_awaiting_movement_ && is_explorable) { + const uint32_t skill = pending_skill_id_; + pending_skill_id_ = 0; + + bool triggered = skill != 0 && kShadowStepSkills.count(skill) != 0; + if (!triggered) { + triggered = controlled_living && + (controlled_living->GetIsMoving() || + controlled_living->model_state == 204 || + controlled_living->move_x != 0.f || + controlled_living->move_y != 0.f); + } + + if (triggered) { + running_awaiting_movement_ = false; + if (running_load_paused_) { + // Mid-run resume after a town or loading screen. + clock_.Resume(); + running_load_paused_ = false; + } else { + // Initial run start. + run_complete_ = false; + run_failed_ = false; + BeginRun("movement detected"); + engine_.ForceStarted(); + } + } + } else { + pending_skill_id_ = 0; + } + } + + // Needs both: IsRunning() catches the exact tick Start() just fired (RealTime() hasn't accumulated yet that tick); RealTime()>0 catches the exact tick Pause() just fired on leaving an explorable (IsRunning() already flipped false, but real_elapsed_ survives a pause). + const bool fire_map_enter = !is_running + ? just_entered_map + : (just_entered_map && (clock_.IsRunning() || clock_.RealTime() > 0.0)); + + // Synchronous last_was_explorable_, not the live-polled is_explorable above (see GoalEngine::Update's own comment). + const int fired = engine_.Update(clock_, last_map_, fire_map_enter, + came_from_explorable, last_was_explorable_, + player_level_, delta); + // TEMPORARY diagnostic for the MissionComplete-not-firing investigation — see GoalEngine::debug_notes_. + for (const auto& n : engine_.debug_notes_) PushDbgEvent(n.tag, n.v1, n.v2); + engine_.debug_notes_.clear(); + + if (clock_.IsRunning()) { + resume_save_timer_ += static_cast(delta); + if (fired > 0 || resume_save_timer_ >= 1.0f) { + SaveResumeState(); + resume_save_timer_ = 0.f; + } + if (fired > 0) + WebSocketModule::Instance().Send("split", "Splits: Split - goal complete"); + } + + ApplyTimerPolicy(just_entered_map); + + // Running: also check completion when a split just fired with the clock paused (final outpost entry). + if (!run_complete_ && !run_failed_ && !active_list_.goals.empty() && + (clock_.IsRunning() || (is_running && fired > 0))) { + bool all_done = true; + for (const auto& g : active_list_.goals) { + if (g.is_header) continue; + if (g.status != GoalStatus::Completed) { all_done = false; break; } + } + if (all_done) SaveCompletedRun(); + } + + // Keybind edge detection + auto poll_key = [](int vk, bool& prev) -> bool { + if (vk <= 0) { prev = false; return false; } + const bool held = (GetAsyncKeyState(vk) & 0x8000) != 0; + const bool fired2 = held && !prev; + prev = held; + return fired2; + }; + if (poll_key(key_start_, key_start_prev_)) StartRun(); + if (poll_key(key_reset_, key_reset_prev_)) ResetRun(); + if (poll_key(key_split_, key_split_prev_)) TriggerManualSplit(); +} + + +// --------------------------------------------------------------------------- +// Timer policy: auto-fail (party wipe, incomplete rezone) and Manual profile auto-start. See the header doc comment on ApplyTimerPolicy() for why this stays separate from GoalEngine's fire/complete switch. +// --------------------------------------------------------------------------- +void SplitsWindow::ApplyTimerPolicy(const bool just_entered_map) +{ + // ---- Auto-fail conditions ---- + const bool party_defeated = pending_party_defeated_; + pending_party_defeated_ = false; + if (party_defeated && ActiveProfile().stop_on_party_defeated && clock_.IsRunning()) + FailRun(); // default reason: "party defeated" + + // A VQ/Mission/Bonus goal was attempted and abandoned (rezoned out of its target map without completing it). Always drained so the flag can't go stale across ticks even when this behavior is turned off. + if (engine_.ConsumeIncompleteRezone() && ActiveProfile().auto_fail_on_rezone && clock_.IsRunning()) + FailRun("left the area without finishing the objective"); + + const bool is_running = ActiveProfile().sequential_route; + + // Wrong turn — Running only, no toggle unlike rezone above. RealTime()>0 too, not just IsRunning() — see FailRun()'s own comment, same auto-pause-same-tick race. + if (engine_.ConsumeWrongMapEntered() && is_running && (clock_.IsRunning() || clock_.RealTime() > 0.0)) + FailRun("wrong turn"); + + // run done + new loading screen = forget old run, reset, let auto-start try again + if (!is_running && (run_complete_ || run_failed_) && ActiveProfile().auto_reset_on_complete && just_entered_map) + ResetRun(); + + // ---- Manual/SC profiles: auto-start the clock ---- + // Auto-starts on the first goal firing; for Mission/Bonus/Vanquish/Dungeon/Titles/MobKill first goals, also starts on the earliest sign of an attempt (not just completion) so the clock covers the whole thing. Excludes manually_paused_ so a user pause isn't immediately undone. + // SC=OT here: OT's own timer auto-starts at map-load into the relevant explorable, same MapEnter-based early-start rule as Manual's Mission/Bonus/Vanquish, matched against the goal's own trigger.map_id (Dungeons) or its owning header's map_id (Elite Areas, see below). + // NOTE: intentionally separate from GoalEngine's fire/complete switch (Pass 2) — trigger firing and clock policy are independent pieces, so some duplication is expected. A new trigger type with a real attempt-to-complete gap needs a progress rule here too (see the matching note on GoalTrigger::Type). + if (!is_running && + !clock_.IsRunning() && !run_complete_ && !run_failed_ && !manually_paused_) { + bool should_start = false; + // Elite Areas checkpoints carry no map_id of their own — only the auto-created area header does. Remembered as we walk past each header so the first non-header goal can fall back to it. + GW::Constants::MapID owning_header_map = GW::Constants::MapID::None; + for (const auto& g : active_list_.goals) { + if (g.is_header) { + owning_header_map = g.trigger.map_id; + continue; + } + if (g.status == GoalStatus::Started || g.status == GoalStatus::Completed) { + should_start = true; + } else if (just_entered_map && last_was_explorable_) { + // last_was_explorable_ (set synchronously from InstanceLoadInfo) rather than the polled is_explorable/GetInstanceType(), which can still reflect the previous instance for a frame at the transition boundary and was silently failing this check. Also correctly excludes a mission map_id entered as an outpost/staging instance before the explorable unlocks. + using TT = GoalTrigger::Type; + const auto tt = g.trigger.type; + if (tt == TT::MissionComplete || tt == TT::MissionBonus || tt == TT::VanquishComplete || + tt == TT::DungeonReward) { + if (last_map_ == g.trigger.map_id) should_start = true; + } else if (owning_header_map != GW::Constants::MapID::None && last_map_ == owning_header_map) { + should_start = true; + } + } else if (g.trigger.type == GoalTrigger::Type::ReachTitleRank) { + // No map/zone signal to key off — start the instant any progress toward the title is detected, rather than waiting for the full rank to complete. + const GW::Title* title = GW::PlayerMgr::GetTitleTrack(g.trigger.title_id); + if (title && title->current_points > 0) should_start = true; + } else if (g.trigger.type == GoalTrigger::Type::MobKill) { + // Same reasoning as Mission/Bonus/Vanquish above: start on the first kill, not after the full trigger.param2 count completes. + if (g.trigger_progress > 0) should_start = true; + } + break; // only check the first non-header goal + } + if (should_start) BeginRun("first goal fired"); + } +} + + +void SplitsWindow::ApplySCAutoLoadPreset(const bool just_entered_map) +{ + if (active_profile_idx_ != 2) return; + + // Domain of Anguish, keyed off InstanceLoadFile's file_id (219215, same magic number as OT's CheckIsMapLoaded), not the map_id path below — DoA's zone rotation is spawn-dependent, so it can't be a static per-map lookup. Checked independently of just_entered_map since InstanceLoadFile can arrive on a different Update() tick than InstanceLoadInfo (server packet order isn't guaranteed), so this is its own one-shot latch instead. + if (pending_doa_file_id_ == 219215) { + const GW::Vec2f spawn = pending_doa_spawn_; + pending_doa_file_id_ = 0; // consume regardless of outcome (incl. Mallyx/no swap) + if (!clock_.IsRunning()) { + const int starting_zone = SCPresets::DetectDoAStartingZone(spawn); + if (starting_zone != -1) { // -1 = Mallyx, not DoA + // Copy from doa_preset_cache_, not a fresh SCPresets::BuildDoAPresetList(spawn) — building it here risks still being in progress when Room 1's door-close event fires. + GoalList doa_preset = doa_preset_cache_[static_cast(starting_zone)]; + if (active_list_.name != doa_preset.name && + (active_list_.name.empty() || active_list_.is_preset)) { + SetActiveList(std::move(doa_preset)); + active_list_.is_preset = true; + // starts_immediately (see BuildDoAPresetForZone) only sets Room 1's own status — Pass 2 still needs engine_.started_ true to actually evaluate its end trigger, and just_entered_map may not be true this exact tick. + engine_.ForceStarted(); + } + } + } + return; + } + + if (!just_entered_map || clock_.IsRunning()) return; + + auto preset = SCPresets::BuildPresetForMap(last_map_); + if (!preset) return; // not a tracked dungeon/elite area + + // Compare by anchor map_id (the header's, or the single goal's — every preset type stamps this as its dungeon/area identity), not by name or is_preset: a renamed or explicitly-loaded list for the SAME dungeon still counts as correct, but one for a DIFFERENT dungeon gets swapped regardless of how it was loaded. + auto anchor_map_id = [](const GoalList& list) { + return list.goals.empty() ? GW::Constants::MapID::None : list.goals.front().trigger.map_id; + }; + if (anchor_map_id(*preset) != GW::Constants::MapID::None && anchor_map_id(active_list_) == anchor_map_id(*preset)) + return; // already the right dungeon/area + + SetActiveList(std::move(*preset)); + // SetActiveList always clears is_preset, but this path is auto-detection, not a user pick, so it must stay swappable for the next dungeon/area the player walks into. + active_list_.is_preset = true; +} + + +// ============================================================================= +// DRAW +// ============================================================================= + +// --------------------------------------------------------------------------- +// Draw +// --------------------------------------------------------------------------- +void SplitsWindow::Draw(IDirect3DDevice9*) +{ + if (!visible) return; + window_.Draw(*this); +} + + +// --------------------------------------------------------------------------- +// Settings UI +// --------------------------------------------------------------------------- +void SplitsWindow::DrawSettingsInternal() +{ + window_.DrawSettings(*this); + + // Manual-only feature start to finish — hidden entirely outside Manual rather than shown disabled/inert, so there's no confusion about whether it's doing anything for Running/SC. + if (active_profile_idx_ == 0) { + ImGui::Separator(); + ImGui::TextUnformatted("Nuzlocke"); + ImGui::Indent(); + + // Death Rules and Points are independent modules — enabling one doesn't require the other. Death Rules draws its roster below the goal list; Points has no section of its own, shown left-aligned in the header clock row. + ImGui::Checkbox("Death Rules", &nuzlocke_.death_rules_enabled); + if (nuzlocke_.death_rules_enabled) { + ImGui::Indent(); + ImGui::SetNextItemWidth(120.f); + ImGui::InputInt("Hero lives", &nuzlocke_.hero_lives); + ImGui::SetNextItemWidth(120.f); + ImGui::InputInt("Henchman lives", &nuzlocke_.hench_lives); + if (nuzlocke_.hero_lives < 1) nuzlocke_.hero_lives = 1; + if (nuzlocke_.hench_lives < 1) nuzlocke_.hench_lives = 1; + + ImGui::SetNextItemWidth(120.f); + ImGui::InputInt("Player lives", &nuzlocke_.player_lives); + if (nuzlocke_.player_lives < 1) nuzlocke_.player_lives = 1; + + ImGui::Checkbox("Merge same-named henchmen across campaigns", &nuzlocke_.merge_hench_by_name); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Off: a henchman name reused by a different NPC/build in another campaign\n" + "or outpost (e.g. two different \"Eve\"s) tracks as a separate entry.\n" + "On: any henchman sharing that display name is folded into one entry,\n" + "sharing the same life count.\n" + "Only affects henchmen tracked from here on, not ones already seen this session."); + } + ImGui::Unindent(); + } + + ImGui::Checkbox("Points", &nuzlocke_.points_enabled); + if (nuzlocke_.points_enabled) { + ImGui::Indent(); + ImGui::TextDisabled("Leave at 0 for goal types you don't want scored."); + ImGui::SetNextItemWidth(100.f); ImGui::InputInt("Manual##nuzlocke_pts", &nuzlocke_.goal_points.manual); + ImGui::SetNextItemWidth(100.f); ImGui::InputInt("Missions", &nuzlocke_.goal_points.missions); + ImGui::SetNextItemWidth(100.f); ImGui::InputInt("Explorables", &nuzlocke_.goal_points.explorables); + ImGui::SetNextItemWidth(100.f); ImGui::InputInt("Towns", &nuzlocke_.goal_points.towns); + ImGui::SetNextItemWidth(100.f); ImGui::InputInt("Titles", &nuzlocke_.goal_points.titles); + ImGui::SetNextItemWidth(100.f); ImGui::InputInt("Reach Level", &nuzlocke_.goal_points.reach_level); + ImGui::SetNextItemWidth(100.f); ImGui::InputInt("Quest", &nuzlocke_.goal_points.quest); + ImGui::SetNextItemWidth(100.f); ImGui::InputInt("Skill Learnt", &nuzlocke_.goal_points.skill_learnt); + ImGui::Unindent(); + } + + ImGui::Unindent(); + } + + ImGui::Separator(); + if (ImGui::CollapsingHeader("Party / Quest Debug Log")) { + // Off by default, same idea as OT's show_debug_events but an in-UI list instead of Log::Info. Nothing is captured into challenge_dbg_events_ at all while off (see PushDbgEvent), not just hidden. + ImGui::Checkbox("Log events", &debug_log_events_); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Records every party/quest/preset event below as it fires.\nUse for debugging and to confirm hooks are actually firing during live testing."); + if (ImGui::Button("Clear##cdbg")) challenge_dbg_events_.clear(); + ImGui::SameLine(); + ImGui::TextDisabled("(%zu events)", challenge_dbg_events_.size()); + ImGui::TextDisabled("PlyAdd/Rem: v1=player_number | HeroAdd: v1=agent_id v2=hero_id | HenchAdd: v1=agent_id v2=profession | AgentDied: v1=agent_id v2=state | QuestUpd: v1=quest_id v2=log_state"); + ImGui::TextDisabled("ObjAdd: v1=objective_id v2=type_flags(0x1=bullet) | ObjDone: v1=objective_id v2=map_id | ObjStart: v1=objective_id"); + ImGui::TextDisabled("DoorOpen/Close: v1=object_id | AgentAllg: v1=player_number v2=allegiance_bits | DungeonRwd: (no params)"); + ImGui::TextDisabled("DoAZone: v1=zone message word | Countdown: v1=map_id | InstLoadFile: v1=file_id v2=spawn.x"); + ImGui::TextDisabled("SrvMsg/DispDlg: v1=pattern length v2=first wchar (not the full pattern)"); + ImGui::TextDisabled("MissComplete/MissBonus/VqComplete: v1=map_id (GetMapID() at that instant \xe2\x80\x94 compare against the goal's own map_id if it's not firing)"); + ImGui::BeginChild("##cdbglog", {0, 160}, true, ImGuiWindowFlags_HorizontalScrollbar); + for (const auto& e : challenge_dbg_events_) { + ImGui::Text("%-10s v1=%-6u (0x%04X) v2=%-6u (0x%04X)", + e.tag, e.v1, e.v1, e.v2, e.v2); + } + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.f) + ImGui::SetScrollHereY(1.0f); + ImGui::EndChild(); + } +} + + +// --------------------------------------------------------------------------- +// Nuzlocke — behavior lives in NuzlockeState (Windows/Splits/Nuzlocke.cpp); these are thin wrappers gated on profile/active-list state NuzlockeState doesn't own. +// --------------------------------------------------------------------------- +void SplitsWindow::DrawNuzlockeSection() +{ + if (NuzlockeDeathRulesEnabled()) nuzlocke_.Draw(); +} + +int SplitsWindow::NuzlockeTotalPoints() const +{ + return nuzlocke_.TotalPoints(active_list_); +} diff --git a/GWToolboxdll/Windows/SplitsWindow.h b/GWToolboxdll/Windows/SplitsWindow.h new file mode 100644 index 000000000..81c63d846 --- /dev/null +++ b/GWToolboxdll/Windows/SplitsWindow.h @@ -0,0 +1,319 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +inline constexpr int kProfileCount = 3; + +// Plain (non-optional) fields since this is UI-facing, not the JSON DTO (SplitsWindowJson::SerializedRun) it's built from. +struct RecentRunGoal { + std::string label; + double real_time = 0.0; + double game_time = 0.0; + bool completed = false; +}; +struct RecentRun { + double total_real = 0.0; + bool failed = false; + int64_t utc_start = 0; + std::vector goals; +}; + +// --------------------------------------------------------------------------- +// SplitsWindow — speedrun split timer, ported from the GWSplits plugin. +// --------------------------------------------------------------------------- +class SplitsWindow : public ToolboxWindow { + // Out-of-line, matching NuzlockeState's own EncString-dependent dtor (see NuzlockeState.h). + SplitsWindow(); + ~SplitsWindow() override; + +public: + static SplitsWindow& Instance() + { + static SplitsWindow instance; + return instance; + } + + [[nodiscard]] const char* Name() const override { return "Splits"; } + [[nodiscard]] const char* Icon() const override { return ICON_FA_STOPWATCH; } + + void Initialize() override; + void Terminate() override; + + void Update(float delta) override; + void Draw(IDirect3DDevice9* device) override; + void DrawSettingsInternal() override; + + void LoadSettings(SettingsDoc& doc, ToolboxIni* legacy) override; + void SaveSettings(SettingsDoc& doc) override; + + // Called by UI + void StartRun(); + void ResetRun(); + void TriggerManualSplit(); + // Switches active profile, resets run state, reloads any bound list. + void SwitchProfile(int idx); + + // Keybind accessors (VK codes; 0 = unbound) + int& KeyStart() { return key_start_; } + int& KeyReset() { return key_reset_; } + int& KeySplit() { return key_split_; } + + // Colors — shared across all profiles rather than per-profile; not enough value in letting Manual/Running/SC look different to justify tripling the config. + Color& ColorCompleted() { return color_completed_; } + Color& ColorActive() { return color_active_; } + Color& ColorRealTime() { return color_real_time_; } + Color& ColorGameTime() { return color_game_time_; } + Color& ColorPbAhead() { return color_pb_ahead_; } + Color& ColorPbBehind() { return color_pb_behind_; } + + // Active profile accessor (read/write for UI) + [[nodiscard]] SplitsProfile& ActiveProfile() { return profiles_[active_profile_idx_]; } + [[nodiscard]] const SplitsProfile& ActiveProfile() const { return profiles_[active_profile_idx_]; } + [[nodiscard]] int ActiveProfileIdx() const { return active_profile_idx_; } + [[nodiscard]] std::array& Profiles() { return profiles_; } + + // Read-only accessors for UI + [[nodiscard]] const GoalClock& Clock() const { return clock_; } + [[nodiscard]] GoalList* List() { return &active_list_; } + // All 4 DoA rotations, pre-built at Initialize() — for the Elite Area picker's manual "start at zone N" entries, since the real rotation is only known once you're actually in DoA. + [[nodiscard]] const std::array& DoAPresetCache() const { return doa_preset_cache_; } + [[nodiscard]] bool RunComplete() const { return run_complete_; } + [[nodiscard]] bool RunFailed() const { return run_failed_; } + // Includes the in-progress pause so the display ticks up live while paused, not just on resume. + [[nodiscard]] double TotalPausedReal() const { + return total_paused_real_ + (manually_paused_ ? manual_pause_accum_ : 0.0); + } + + void NewActiveList(const char* name); + // clear_preset: false for internal auto-saves (e.g. UpdateReferenceIfPB) that shouldn't silently turn an auto-detected preset into a plain user list mid-session — only an explicit user Save click should do that. + void SaveActiveList(bool clear_preset = true); + void LoadActiveList(const std::wstring& path); + // Replaces the active list wholesale with an already-built one (e.g. from SCPresets); see the .cpp for why this isn't just NewActiveList() + mutating List()->goals after. + void SetActiveList(GoalList list); + // Cached — GetSavedLists() is a real directory_iterator disk scan and used to run unconditionally every ImGui frame the Splits settings section was open. See the cache fields below for invalidation. + [[nodiscard]] std::vector> GetSavedLists() const; + + // Profile-specific subfolder paths for templates and run history. + [[nodiscard]] std::wstring ActiveSplitsFolder() const; + [[nodiscard]] std::wstring ActiveRunsFolder() const; + + // Comparison data actually fed to the goal list's Ahead/Behind display — selects among the PB/average/last-run splits per ActiveProfile().comparison_mode. + [[nodiscard]] const std::vector& CompareSplits() const; + [[nodiscard]] const std::vector& CompareSplitsGame() const; + + [[nodiscard]] const std::vector& RecentRuns() const { return recent_runs_; } + + [[nodiscard]] bool HasPendingResume() const { return pending_resume_; } + [[nodiscard]] const char* PendingResumeName() const { return pending_resume_name_.c_str(); } + void ApplyResume(); + void DiscardResume(); + + // Nuzlocke: two independently-enabled modules, Manual profile only — Running's sequential zone-transition splits don't fit Nuzlocke. Settings checkboxes stay editable regardless of profile; only runtime behavior is Manual-gated, via these two accessors rather than the raw fields directly. + void DrawNuzlockeSection(); + [[nodiscard]] bool NuzlockeDeathRulesEnabled() const { return nuzlocke_.death_rules_enabled && active_profile_idx_ == 0; } + [[nodiscard]] bool NuzlockePointsEnabled() const { return nuzlocke_.points_enabled && active_profile_idx_ == 0; } + // Sum of point values (Settings > Splits > Nuzlocke > Points) for every Completed, non-header goal in the active list. + [[nodiscard]] int NuzlockeTotalPoints() const; + +private: + GoalClock clock_; + GoalEngine engine_; + GoalList active_list_; + SplitsGoalListWindow window_; + + // GetSavedLists() cache. Invalidated whenever the requested folder differs from the cached one (handles profile switches) or cached_saved_lists_dirty_ is set (SaveActiveList()/NewActiveList()). + mutable std::vector> cached_saved_lists_; + mutable std::wstring cached_saved_lists_folder_; + mutable bool cached_saved_lists_dirty_ = true; + + // Profiles: 0=Manual, 1=Running, 2=SC + std::array profiles_ = { + MakeManualProfile(), MakeRunningProfile(), MakeSCProfile() + }; + int active_profile_idx_ = 0; + + GW::Constants::MapID last_map_ = GW::Constants::MapID::None; + bool last_was_explorable_ = false; + // event-driven cache of the controlled character's level, reset to 0 on zone load so a character switch re-seeds + int player_level_ = 0; + // Set by the InstanceLoadInfo bus event; consumed once per Update() tick. + bool pending_map_enter_ = false; + bool pending_came_from_explorable_ = false; + // Set by the PartyDefeated bus event; consumed by ApplyTimerPolicy(). + bool pending_party_defeated_ = false; + // Set by the InstanceLoadFile bus event (file_id + DoA spawn point); consumed by ApplySCAutoLoadPreset() independently of pending_map_enter_ since packet order isn't guaranteed to match InstanceLoadInfo's tick. + uint32_t pending_doa_file_id_ = 0; + GW::Vec2f pending_doa_spawn_ = {}; + // All 4 rotations pre-built at Initialize(), not at the zone-transition tick — building fresh there could still be in progress when Room 1's own door-close event fires, missing its one-shot start_trigger. + std::array doa_preset_cache_; + + int key_start_ = 0; + int key_reset_ = 0; + int key_split_ = 0; + bool key_start_prev_ = false; + bool key_reset_prev_ = false; + bool key_split_prev_ = false; + + Color color_completed_ = Colors::RGB(0, 255, 0 ); // goal label: done + Color color_active_ = Colors::RGB(255, 255, 255); // goal label: current objective + Color color_real_time_ = Colors::RGB(230, 230, 230); // real time text; segment uses muted + Color color_game_time_ = Colors::RGB(153, 217, 255); // game time text; segment uses muted + Color color_pb_ahead_ = Colors::RGB(255, 217, 0); // comparison delta: ahead (of PB/Average/Sum of Best per comparison_mode) + Color color_pb_behind_ = Colors::RGB(255, 102, 102); // comparison delta: behind; also failed goal label + + uint32_t pending_skill_id_ = 0; // skill fired by local player this tick (shadow step bus) + bool running_awaiting_movement_ = false; // armed after entering first Running goal's map + bool running_load_paused_ = false; // clock was paused by us on load-start; resume on player-struct ready + // Manual: "Time until mission start" ready-check dialog is up (Vizunah Square, Unwaking Waters, etc.) — game time shouldn't accumulate while waiting on other players here. + bool in_mission_queue_ = false; + // User-initiated pause (Pause button / keybind, toggled via StartRun()); tracked separately from clock_.RealTime() since a manual pause freezes real time too. + bool manually_paused_ = false; + double manual_pause_accum_ = 0.0; + double total_paused_real_ = 0.0; // running total across the whole run; persisted with the run + + std::wstring splits_folder_; + std::wstring runs_folder_; + + // Direct StoC/UIMessage hooks (registered/removed in Initialize()/Terminate()) — one per event type this window actually consumes. + GW::HookEntry on_mission_complete_; + GW::HookEntry on_objective_add_; + GW::HookEntry on_vanquish_complete_; + GW::HookEntry on_party_defeated_; + GW::HookEntry on_objective_done_; + GW::HookEntry on_objective_started_; + GW::HookEntry on_door_; + GW::HookEntry on_agent_allegiance_; + GW::HookEntry on_doa_zone_; + GW::HookEntry on_dungeon_reward_; + GW::HookEntry on_server_message_; + GW::HookEntry on_display_dialogue_; + GW::HookEntry on_countdown_start_; + GW::HookEntry on_skill_activate_; + GW::HookEntry on_party_lock_; + GW::HookEntry on_instance_load_info_; + GW::HookEntry on_instance_load_file_; + GW::HookEntry on_game_srv_transfer_; + GW::HookEntry on_party_player_add_; + GW::HookEntry on_party_player_remove_; + GW::HookEntry on_agent_state_; + GW::HookEntry on_quest_update_; + GW::HookEntry on_quest_details_changed_; + GW::HookEntry on_quest_remove_; + GW::HookEntry on_agent_level_changed_; + + void SaveResumeState(); + void DeleteResumeState(); + void SaveCompletedRun(); + void FailRun(const char* reason = "party defeated"); + void SaveRunToHistory(bool failed); + // refresh_comparisons=false only rescans pb_splits_/pb_total_real_ (for UpdateReferenceIfPB's is-this-a-new-PB check), leaving avg_splits_/best_seg_splits_ untouched so a just-finished run's "Run complete!" screen still compares against the PRE-run history rather than itself. + void LoadPB(bool refresh_comparisons = true); + void UpdateReferenceIfPB(); + // Shared by LoadActiveList/SetActiveList/ResetRun — resets every run-progress flag to its pre-run state (not the clock/engine, which each caller handles differently around this call). + void ResetRunFlags(); + // "A run just began" bookkeeping shared by StartRun(), Running's movement-detected start, and Manual/SC's first-goal-fired auto-start — each has a different trigger condition but does this same work once triggered. + void BeginRun(const char* reason); + // Detach -> populate -> Attach -> LoadPB, shared by LoadActiveList/SetActiveList/ApplyResume — each does something different afterward (Reset+ResetRunFlags vs. Restore+goal-status replay), but this core must run in this order regardless (Attach-time setup like starts_immediately needs active_list_ already populated). + void ReplaceActiveList(const std::function& populate); + // Filesystem-safe ".json" path under ActiveRunsFolder() — shared by LoadPB (reads it) and SaveRunToHistory (reads+writes it). + [[nodiscard]] std::wstring RunHistoryFilePath() const; + + // Timer policy: auto-fail conditions and Manual's per-goal-type auto-start rules. Deliberately separate from GoalEngine's fire/complete switch — trigger firing and clock policy are independent pieces, so some structural duplication between the two switches is accepted. + void ApplyTimerPolicy(bool just_entered_map); + + // SC only: swaps in the matching live-built preset when entering a dungeon/elite area with no run in progress. Runs before engine_.Update() each tick, never interrupts a run underway, and never overrides a non-preset (user-authored) list. DoA is handled first via pending_doa_file_id_/pending_doa_spawn_ since its zone rotation isn't map_id-driven. + void ApplySCAutoLoadPreset(bool just_entered_map); + + bool run_complete_ = false; + bool run_failed_ = false; + std::string run_char_name_; + int64_t run_start_unix_ = 0; + + std::vector pb_splits_; + std::vector pb_splits_game_; + double pb_total_real_ = std::numeric_limits::quiet_NaN(); + // Mean of every non-failed run's time at each goal index (a run reaching only goal 3 of 5 still contributes to goals 0-2). All three (pb/avg/best-seg) recompute together in LoadPB(). + std::vector avg_splits_; + std::vector avg_splits_game_; + // Cumulative sum of each leg's fastest-ever segment across non-failed runs — the theoretical best if every best segment lined up in one run. + std::vector best_seg_splits_; + std::vector best_seg_splits_game_; + + std::vector recent_runs_; + + float resume_save_timer_ = 0.f; + bool pending_resume_ = false; + std::string pending_resume_name_; + std::string pending_resume_data_; + + // Debug log for party/quest/death/objective/preset events, surfaced in settings to validate hooks. Off by default, same idea as OT's show_debug_events but rendered as an in-UI scrollable list instead of Log::Info. + bool debug_log_events_ = false; + struct ChallengeDbgEvent { + const char* tag; + uint32_t v1; + uint32_t v2; + }; + std::vector challenge_dbg_events_; + // No-op unless debug_log_events_ is on. Appends one entry, capping at 200 (drops oldest) — single place for the enable-check/cap/erase logic so every call site stays consistent. + void PushDbgEvent(const char* tag, uint32_t v1, uint32_t v2); + + // ---- Nuzlocke: Death Rules + Points state and behavior (see NuzlockeState.h / Windows/Splits/Nuzlocke.cpp) ---- + NuzlockeState nuzlocke_; + + static constexpr std::array kProfileSections = { + "Splits.Manual", "Splits.Running", "Splits.SC" + }; + // Shared by ActiveSplitsFolder()/ActiveRunsFolder() — one lookup instead of two matching 3-way if chains. + static constexpr std::array kProfileFolderNames = { + L"manual\\", L"running\\", L"sc\\" + }; + + struct ColorField { const char* key; Color SplitsWindow::* member; }; + // Shared by LoadSettings/SaveSettings so both iterate the same list instead of Save repeating it as 6 flat lines. + static constexpr std::array kColorFields = {{ + {"color_completed", &SplitsWindow::color_completed_}, + {"color_active", &SplitsWindow::color_active_}, + {"color_real_time", &SplitsWindow::color_real_time_}, + {"color_game_time", &SplitsWindow::color_game_time_}, + {"color_pb_ahead", &SplitsWindow::color_pb_ahead_}, + {"color_pb_behind", &SplitsWindow::color_pb_behind_}, + }}; + + // Shared by LoadSettings/SaveSettings — one table instead of each field written out separately on both sides. + struct NuzlockeBoolField { const char* key; bool NuzlockeState::* member; }; + static constexpr std::array kNuzlockeBoolFields = {{ + {"nuzlocke_death_rules_enabled", &NuzlockeState::death_rules_enabled}, + {"nuzlocke_merge_hench_by_name", &NuzlockeState::merge_hench_by_name}, + {"nuzlocke_points_enabled", &NuzlockeState::points_enabled}, + }}; + struct NuzlockeLivesField { const char* key; int NuzlockeState::* member; }; + static constexpr std::array kNuzlockeLivesFields = {{ + {"nuzlocke_hero_lives", &NuzlockeState::hero_lives}, + {"nuzlocke_hench_lives", &NuzlockeState::hench_lives}, + {"nuzlocke_player_lives", &NuzlockeState::player_lives}, + }}; + struct NuzlockePointField { const char* key; int NuzlockePointValues::* member; }; + static constexpr std::array kNuzlockePointFields = {{ + {"nuzlocke_points_manual", &NuzlockePointValues::manual}, + {"nuzlocke_points_missions", &NuzlockePointValues::missions}, + {"nuzlocke_points_explorables", &NuzlockePointValues::explorables}, + {"nuzlocke_points_towns", &NuzlockePointValues::towns}, + {"nuzlocke_points_titles", &NuzlockePointValues::titles}, + {"nuzlocke_points_reach_level", &NuzlockePointValues::reach_level}, + {"nuzlocke_points_quest", &NuzlockePointValues::quest}, + {"nuzlocke_points_skill_learnt", &NuzlockePointValues::skill_learnt}, + }}; +};