From 47ef43cf35405fc476b85d2ad50bde8922b463ea Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Wed, 5 Aug 2026 20:31:57 +0000 Subject: [PATCH] handlers: cutscene viewed-state and world-map entry acknowledgements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds persistent cutscene viewed-state so the client can skip scenarios the player has already watched. GetScenarioPlayingInfo returns the recorded set and RaidUpScenarioInfo appends to it, both backed by a new user_scenarios table (17072026_CreateUserScenariosTable). The table is structural and never seeded, so a fresh account still sees every cutscene once. Also registers four acknowledgement stubs fired during the Grand Gaia entry sequence — DungeonEventUpdate, UpdatePermitPlaceInfo, UpdateEventInfo and Chronology. Without them the client receives a Close error and retries at roughly 20 requests a second. --- .../gme/handlers/DungeonEventUpdate.cpp | 42 +++++++ .../gme/handlers/GmeControllerHandlers.cpp | 11 ++ gimuserver/gme/handlers/Handlers.hpp | 6 + gimuserver/gme/handlers/Scenario.cpp | 114 ++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 gimuserver/gme/handlers/DungeonEventUpdate.cpp create mode 100644 gimuserver/gme/handlers/Scenario.cpp diff --git a/gimuserver/gme/handlers/DungeonEventUpdate.cpp b/gimuserver/gme/handlers/DungeonEventUpdate.cpp new file mode 100644 index 0000000..5ee0d97 --- /dev/null +++ b/gimuserver/gme/handlers/DungeonEventUpdate.cpp @@ -0,0 +1,42 @@ +#include "App.hpp" +#include "Handlers.hpp" + +// DungeonEventUpdate (BjAt1D6b / k5EiNe9x) — fires when the client enters +// the Grand Gaia world map after the opening cutscene. The original handler +// returns an empty JSON object; no fields are read by the client from this +// response. Without this handler the server returns "Unsupported request" +// and the client hard-crashes out of the cutscene. +HANDLEF(DungeonEventUpdate) +{ + LOG_INFO << "DungeonEventUpdate: " << json; + co_return HandleResult::success("{}"); +} + +// GetScenarioPlayingInfo now lives in Scenario.cpp — it returns the real +// viewed-cutscene set from user_scenarios instead of the old {} stub. + +// UpdatePermitPlaceInfo (1MJT6L3W / 3zip5Htw) — sent after entering an +// area to refresh the server-side permit-place allow-list. Original handler +// returns {}. Our PermitPlace is injected once in UserInfo so no update +// action is required here. +HANDLEF(UpdatePermitPlaceInfo) +{ + LOG_INFO << "UpdatePermitPlaceInfo: " << json; + co_return HandleResult::success("{}"); +} + +// UpdateEventInfo (rCB7ZI8x / L1o4eGbi) — updates the client's view of +// active event data. Original handler returns {}. +HANDLEF(UpdateEventInfo) +{ + LOG_INFO << "UpdateEventInfo: " << json; + co_return HandleResult::success("{}"); +} + +// Chronology (5o8ZlDGX / SNrhAG29) — Chronology timeline feature endpoint. +// Original handler returns {}. +HANDLEF(Chronology) +{ + LOG_INFO << "Chronology: " << json; + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/handlers/GmeControllerHandlers.cpp b/gimuserver/gme/handlers/GmeControllerHandlers.cpp index 1271f8f..8dce284 100644 --- a/gimuserver/gme/handlers/GmeControllerHandlers.cpp +++ b/gimuserver/gme/handlers/GmeControllerHandlers.cpp @@ -87,6 +87,15 @@ static GmeHandler getHandler(std::string_view cmd) REGISTER("ynB7X5P9", UpdateInfoLight, "7kH9NXwC"); REGISTER("cTZ3W2JG", UserInfo, "ScJx6ywWEb0A3njT"); + + // World-map / Grand Gaia entry sequence stubs. + REGISTER("BjAt1D6b", DungeonEventUpdate, "k5EiNe9x"); + REGISTER("VRfsv4e3", GetScenarioPlayingInfo, "Bh4WqR01"); + REGISTER("R38qvphm", RaidUpScenarioInfo, "72EyFbW8"); + REGISTER("1MJT6L3W", UpdatePermitPlaceInfo, "3zip5Htw"); + REGISTER("rCB7ZI8x", UpdateEventInfo, "L1o4eGbi"); + REGISTER("5o8ZlDGX", Chronology, "SNrhAG29"); + } } @@ -150,6 +159,7 @@ drogon::Task GmeController::Handle(drogon::SessionPtr session, const catch (const drogon::orm::DrogonDbException& ex) { LOG_ERROR << "Handler error " << header.id << " (" << handler.name << ") database exception: " << ex.base().what(); + logReq << "EXCEPTION (db): " << ex.base().what() << "\n"; GmeError err{}; err.cmd = GmeErrorCommand::Close; err.flag = GmeErrorFlags::IsInError; @@ -159,6 +169,7 @@ drogon::Task GmeController::Handle(drogon::SessionPtr session, const catch (const std::exception& ex) { LOG_ERROR << "Handler error " << header.id << " (" << handler.name << ") exception: " << ex.what(); + logReq << "EXCEPTION (std): " << ex.what() << "\n"; GmeError err{}; err.cmd = GmeErrorCommand::Close; err.flag = GmeErrorFlags::IsInError; diff --git a/gimuserver/gme/handlers/Handlers.hpp b/gimuserver/gme/handlers/Handlers.hpp index e8eb93e..9ab94bf 100644 --- a/gimuserver/gme/handlers/Handlers.hpp +++ b/gimuserver/gme/handlers/Handlers.hpp @@ -96,4 +96,10 @@ namespace GmeHandlers HANDLE(TutorialUpdate); HANDLE(UpdateInfoLight); HANDLE(UserInfo); + HANDLE(DungeonEventUpdate); + HANDLE(GetScenarioPlayingInfo); + HANDLE(RaidUpScenarioInfo); + HANDLE(UpdatePermitPlaceInfo); + HANDLE(UpdateEventInfo); + HANDLE(Chronology); } diff --git a/gimuserver/gme/handlers/Scenario.cpp b/gimuserver/gme/handlers/Scenario.cpp new file mode 100644 index 0000000..9484b09 --- /dev/null +++ b/gimuserver/gme/handlers/Scenario.cpp @@ -0,0 +1,114 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +#include + +// Cutscene viewed-state persistence. The cutscene DATA is client-bundled +// (F_SCENARIO_MST: scenario id -> script file "mapN-*.txt" + BGM, reached via +// the CDN .dat pipeline — no MstResponse exists); the SERVER owns which +// scenarios this user has already watched, in the user_scenarios table. +// +// Flow: the client queries GetScenarioPlayingInfo during boot/scene entry and +// skips any cutscene whose id is in the returned sBbp47fi set; when a new +// cutscene finishes it reports the id via RaidUpScenarioInfo ("Up" = update — +// generic scenario reporter despite the Raid prefix). The legacy fork +// stubbed both with {}, which is why cutscenes replayed on every boot. + +// GetScenarioPlayingInfo (VRfsv4e3 / Bh4WqR01) — bare request (identity tags +// only); returns the viewed-scenario set. +HANDLEF(GetScenarioPlayingInfo) +{ + (void)session; + LOG_INFO << "GetScenarioPlayingInfo: " << json; + + // Identity-only request (no body beyond the login-info tag). + GetScenarioPlayingInfoReq req = {}; + { + glz::context ctx{}; + if (const auto& ec = glz::read(req, json, ctx); ec) + LOG_WARN << "GetScenarioPlayingInfo: parse error: " << glz::format_error(ec, json); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string userId = identity.userId; + + GetScenarioPlayingInfoResp resp = {}; + { + const auto rows = co_await theDb()->execSqlCoro( + "SELECT scenario_id FROM user_scenarios WHERE user_id = $1;", userId); + for (const auto& row : rows) + { + resp.scenarios.push_back(::UserScenarioInfo{ + .scenario_id = row["scenario_id"].as(), + }); + } + } + + std::string buffer{}; + if (const auto& ec = glz::write_json(resp, buffer); ec) + { + const auto& glze = glz::format_error(ec, buffer); + LOG_DEBUG << "Gme GetScenarioPlayingInfo Error during JSON writing: " << glze; + co_return HandleResult::error("Serialization error", glze); + } + + co_return HandleResult::success(buffer); +} + +// RaidUpScenarioInfo (R38qvphm / 72EyFbW8) — stores a viewed scenario id. +// Request: {"1ry6BKoe":[{"N4XVE1uA":""}]}. The N4XVE1uA payload +// format is UNVERIFIED (expected: bare scenario id, quoted) — the INFO log +// below is the capture; non-numeric payloads are logged and skipped rather +// than guessed at (handbook §3.4 anti-pattern). +HANDLEF(RaidUpScenarioInfo) +{ + (void)session; + LOG_INFO << "RaidUpScenarioInfo (CAPTURE — N4XVE1uA format unverified): " << json; + + RaidUpScenarioInfoReq req = {}; + if (const auto& ec = glz::read(req, json); ec) + { + const auto& fmte = glz::format_error(ec, json); + LOG_DEBUG << "Gme RaidUpScenarioInfo Error during JSON read: " << fmte; + co_return HandleResult::error("Deserialization error", fmte); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string userId = identity.userId; + + const auto now = static_cast(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + + for (const auto& node : req.nodes) + { + if (node.scenario_info.empty()) + continue; + + uint32_t scenarioId = 0; + try { scenarioId = static_cast(std::stoul(node.scenario_info)); } + catch (...) + { + LOG_WARN << "RaidUpScenarioInfo: non-numeric N4XVE1uA payload (composite " + "format? capture above): " << node.scenario_info; + continue; + } + + try + { + co_await theDb()->execSqlCoro( + "INSERT OR IGNORE INTO user_scenarios (user_id, scenario_id, viewed_at) " + "VALUES ($1, $2, $3);", + userId, scenarioId, now); + LOG_INFO << "RaidUpScenarioInfo: stored viewed scenario " << scenarioId; + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_ERROR << "RaidUpScenarioInfo: store failed for " << scenarioId + << ": " << ex.base().what(); + } + } + + co_return HandleResult::success("{}"); +}