From 6626d847568992d3302403b557e30032b18b073e Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Wed, 5 Aug 2026 20:31:58 +0000 Subject: [PATCH 1/2] =?UTF-8?q?handlers:=20campaign=20subsystem=20?= =?UTF-8?q?=E2=80=94=20mission=20list,=20battle=20lifecycle,=20and=20rewar?= =?UTF-8?q?ds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the campaign subsystem. CampaignStart opens the campaign and returns the mission list. CampaignMissionGet reports per-mission progress, CampaignDeckGet the party deck. CampaignBattleStart locks the deck and begins the battle; CampaignBattleEnd persists the clear flag, credits the archive-driven reward and unlocks the following mission id. CampaignReceipt claims rewards and CampaignEnd exits. AreaInfo is the entry point for the quest menu and FixGiftInfo acknowledges the gift inbox. Adds 25042026_CreateUserCampaignTables for user_campaign_missions, user_campaign_decks and user_campaign_state. UserInfo emits cleared-mission history from user_campaign_missions. This is the progression driver: the client evaluates feature unlocks against this list, so functions gated on "mission N cleared" stay locked without it. CampaignStart and CampaignReceipt still return minimal payloads pending MST-driven reward tables. --- gimuserver/gme/handlers/AreaInfo.cpp | 12 ++ gimuserver/gme/handlers/CampaignBattleEnd.cpp | 176 ++++++++++++++++++ .../gme/handlers/CampaignBattleStart.cpp | 161 ++++++++++++++++ gimuserver/gme/handlers/CampaignDeckGet.cpp | 69 +++++++ gimuserver/gme/handlers/CampaignEnd.cpp | 36 ++++ .../gme/handlers/CampaignMissionGet.cpp | 57 ++++++ gimuserver/gme/handlers/CampaignReceipt.cpp | 74 ++++++++ gimuserver/gme/handlers/CampaignStart.cpp | 109 +++++++++++ gimuserver/gme/handlers/FixGiftInfo.cpp | 10 + .../gme/handlers/GmeControllerHandlers.cpp | 16 ++ gimuserver/gme/handlers/Handlers.hpp | 9 + 11 files changed, 729 insertions(+) create mode 100644 gimuserver/gme/handlers/AreaInfo.cpp create mode 100644 gimuserver/gme/handlers/CampaignBattleEnd.cpp create mode 100644 gimuserver/gme/handlers/CampaignBattleStart.cpp create mode 100644 gimuserver/gme/handlers/CampaignDeckGet.cpp create mode 100644 gimuserver/gme/handlers/CampaignEnd.cpp create mode 100644 gimuserver/gme/handlers/CampaignMissionGet.cpp create mode 100644 gimuserver/gme/handlers/CampaignReceipt.cpp create mode 100644 gimuserver/gme/handlers/CampaignStart.cpp create mode 100644 gimuserver/gme/handlers/FixGiftInfo.cpp diff --git a/gimuserver/gme/handlers/AreaInfo.cpp b/gimuserver/gme/handlers/AreaInfo.cpp new file mode 100644 index 0000000..8a379b1 --- /dev/null +++ b/gimuserver/gme/handlers/AreaInfo.cpp @@ -0,0 +1,12 @@ +#include "App.hpp" +#include "Handlers.hpp" + +// AreaInfo (Zds63G5y) — fired when the player taps the "Quest" button on the +// home screen. The client renders the world-map / area-select screen from its +// own local MST data; the server response only needs to not close the session. +// IDA-exported handler stub returns {} (no server-side area state needed yet). +HANDLEF(AreaInfo) +{ + LOG_INFO << "AreaInfo: " << json; + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/handlers/CampaignBattleEnd.cpp b/gimuserver/gme/handlers/CampaignBattleEnd.cpp new file mode 100644 index 0000000..061f825 --- /dev/null +++ b/gimuserver/gme/handlers/CampaignBattleEnd.cpp @@ -0,0 +1,176 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include +#include +#include +#include + +// CampaignBattleEnd (pTNB6yw3) — post-battle result handler. +// Marks the mission cleared, credits the cleared mission's zel + karma from the +// mission archive, and returns a fresh UserTeamInfo so the client HUD updates. +// +// Response keys: +// "fEi17cnx" — [UserTeamInfo] — refreshes zel / energy in HUD +// "4MCxgS5p" — CampaignReceiptResponse (stub payload — enough to unblock +// the receipt screen; real reward logic is future work) +// +// DB writes: +// 1. UPDATE user_campaign_missions SET state=2, clear_count+=1, +// attain_percent=100, last_cleared_at= +// WHERE user_id=$1 AND mission_id= +// 2. UPDATE user_info SET zel = zel + +// 3. UPDATE user_campaign_state SET active_mission_id='' + +// Client overflows zel/karma above this and resets to 0, so cap every credit. +static constexpr int64_t kMaxZelKarma = 99'999'999LL; + +// CampaignBattleEndReq (login_info + mission_id) is generated from the KDL +// (packet-generator/assets/net/handlers.kdl). + +// Response: CampaignReceiptResp (team_info under fEi17cnx + receipt stub under +// 4MCxgS5p) is generated from the KDL +// (packet-generator/assets/net/handlers.kdl) and shared with CampaignReceipt. + +HANDLEF(CampaignBattleEnd) +{ + LOG_INFO << "CampaignBattleEnd: " << json; + + // Parse — lenient so extra envelope keys don't abort. + CampaignBattleEndReq req{}; + glz::context ctx{}; + if (const auto ec = glz::read(req, json, ctx); ec) + { + LOG_WARN << "CampaignBattleEnd: parse error: " << glz::format_error(ec, json); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + // Read active_mission_id from state table if not in the request body. + std::string missionId = req.mission_id; + if (missionId.empty()) + { + try + { + const auto sr = co_await theDb()->execSqlCoro( + "SELECT active_mission_id FROM user_campaign_state WHERE user_id=$1;", + std::string(kUserId)); + if (!sr.empty()) + missionId = sr[0]["active_mission_id"].as(); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignBattleEnd: state SELECT failed: " << ex.base().what(); + } + } + + // Rewards are archive-driven: resolve the cleared mission's record up front + // and fail explicitly when it's missing, rather than crediting a silent + // default. Mirrors MissionEnd (9TvyNR5H). + std::optional missionRecord; + try + { + missionRecord = MissionArchiver::instance().lookup( + static_cast(std::stoul(missionId))); + } + catch (const std::exception&) + { + // std::stoul throws on an empty / non-numeric mission id. + } + if (!missionRecord) + { + co_return HandleResult::error("Archive error", + "CampaignBattleEnd: no mission archive record for mission '" + missionId + "'"); + } + + // Step 1: mark mission cleared. + if (!missionId.empty()) + { + try + { + const int64_t now = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + + co_await theDb()->execSqlCoro( + "UPDATE user_campaign_missions" + " SET state=2, attain_percent=100," + " clear_count = clear_count + 1," + " last_cleared_at = $3" + " WHERE user_id=$1 AND mission_id=$2;", + std::string(kUserId), missionId, now); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignBattleEnd: mission UPDATE failed: " << ex.base().what(); + } + + // Step 1b: unlock the next sequential mission, if any. This keeps the + // player on a one-mission-at-a-time progression: only the missions + // they've earned (cleared one earlier) become available. Pairs with + // the PermitPlace mission filter in UserInfo.cpp — the client only + // sees missions whose row exists in user_campaign_missions, so adding + // a row here is what makes the next mission visible on the map. + // + // INSERT OR IGNORE means we never downgrade a mission that's already + // available or cleared; we only add brand-new rows. + try + { + const int32_t curId = std::stoi(missionId); + const std::string nextId = std::to_string(curId + 1); + + co_await theDb()->execSqlCoro( + "INSERT OR IGNORE INTO user_campaign_missions" + " (user_id, mission_id, state, attain_percent)" + " VALUES ($1, $2, 1, 0);", + std::string(kUserId), nextId); + + LOG_INFO << "CampaignBattleEnd: cleared mission " << missionId + << " — unlocked next mission " << nextId; + } + catch (const std::exception& ex) + { + // std::stoi throws on non-numeric mission IDs (e.g. event campaigns + // with alphabetic suffixes). Skip the unlock step in that case. + LOG_WARN << "CampaignBattleEnd: next-mission unlock skipped: " << ex.what(); + } + } + + // Step 2: credit the cleared mission's zel + karma from the archive record. + try + { + co_await theDb()->execSqlCoro( + "UPDATE user_info" + " SET zel = MIN(zel + $1, $3)," + " karma = MIN(karma + $2, $3)" + " WHERE id=$4;", + static_cast(missionRecord->zel), + static_cast(missionRecord->karma), + kMaxZelKarma, std::string(kUserId)); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignBattleEnd: reward UPDATE failed: " << ex.base().what(); + } + + // Step 3: clear active mission state. + try + { + co_await theDb()->execSqlCoro( + "UPDATE user_campaign_state SET active_mission_id='', active_battle_seed=0" + " WHERE user_id=$1;", + std::string(kUserId)); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignBattleEnd: state clear failed: " << ex.base().what(); + } + + // Refreshed team_info (fEi17cnx) + receipt stub (4MCxgS5p) in one pass. + CampaignReceiptResp resp{}; + resp.team_info = std::move( + (co_await gme::getTeamInfo(theDb(), identity)).nonEmpty()); + + co_return HandleResult::success(glz::write_json(resp).value_or("{}")); +} diff --git a/gimuserver/gme/handlers/CampaignBattleStart.cpp b/gimuserver/gme/handlers/CampaignBattleStart.cpp new file mode 100644 index 0000000..3743d10 --- /dev/null +++ b/gimuserver/gme/handlers/CampaignBattleStart.cpp @@ -0,0 +1,161 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +#include + +// CampaignBattleStart (h1RjcD3S) — pre-battle request fired when the player +// confirms their deck and taps "Battle". Persists the active mission ID so +// CampaignBattleEnd knows which mission row to update. The client renders the +// battle entirely from its local stage/MST data; the server response only needs +// to not close the session (empty {} is sufficient for now — flesh out once a +// real capture is available). +// +// CampaignBattleStartReq (login_info + mission_id) is generated from the KDL +// (packet-generator/assets/net/handlers.kdl). + +HANDLEF(CampaignBattleStart) +{ + LOG_INFO << "CampaignBattleStart: " << json; + + CampaignBattleStartReq req{}; + { + glz::context ctx{}; + // Lenient parse — request carries IKqx1Cn9 + other unknown envelope keys. + if (const auto ec = glz::read(req, json, ctx); ec) + { + LOG_WARN << "CampaignBattleStart: parse error: " << glz::format_error(ec, json); + } + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + // Persist active mission so BattleEnd can update the right row. + if (!req.mission_id.empty()) + { + try + { + co_await theDb()->execSqlCoro( + "INSERT INTO user_campaign_state (user_id, active_mission_id)" + " VALUES ($1,$2)" + " ON CONFLICT(user_id) DO UPDATE SET active_mission_id=$2;", + std::string(kUserId), req.mission_id); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignBattleStart: state UPSERT failed: " << ex.base().what(); + } + } + + // Reinforce slot — tojMy68W (FriendInfoResponse). Mirrors the MissionStart + // emission so the campaign squad-select friend picker is also populated. + // Per the IDA audit (tools/ida/audits/tojMy68W_audit.txt) this feeds the + // FriendInfoList singleton that the squad-select UI consults. KDL schema + // is in packet-generator/assets/net/friends.kdl. + // + // Stands in for the player's highest-level unit since there's no friend + // system on the offline server. + FriendInfo friend_entry{}; + bool haveFriend = false; + try + { + const auto reinforceRows = co_await theDb()->execSqlCoro( + "SELECT user_unit_id, unit_id, unit_lv," + " base_hp, add_hp, ext_hp," + " base_atk, add_atk, ext_atk," + " base_def, add_def, ext_def," + " base_heal,add_heal,ext_heal," + " skill_id, skill_lv, extra_skill_id, extra_skill_lv," + " unit_type_id, element" + " FROM user_units" + " WHERE user_id=$1" + " ORDER BY unit_lv DESC, user_unit_id DESC LIMIT 1;", + std::string(kUserId)); + + if (!reinforceRows.empty()) + { + const auto& r = reinforceRows[0]; + const auto raw = r["unit_id"].as(); + const auto sep = raw.find('_'); + int32_t mstId = 0; + try { mstId = std::stoi(sep != std::string::npos ? raw.substr(0, sep) : raw); } + catch (...) {} + + // FriendInfo.element is uint32_t — invert the string form stored + // in user_units.element. + const auto elemStr = r["element"].as(); + int32_t elemId = 1; + if (elemStr == "fire") elemId = 1; + else if (elemStr == "water") elemId = 2; + else if (elemStr == "earth") elemId = 3; + else if (elemStr == "thunder") elemId = 4; + else if (elemStr == "light") elemId = 5; + else if (elemStr == "dark") elemId = 6; + + friend_entry.user_id = "n9ZMPC0t"; + friend_entry.handle_name = "DecompFriend"; + friend_entry.team_lv = 999; + friend_entry.friend_type = 1; + friend_entry.last_login_date = static_cast(std::time(nullptr)); + friend_entry.unit_id = mstId; + friend_entry.unit_lv = r["unit_lv"].as(); + friend_entry.base_hp = r["base_hp"].as(); + friend_entry.add_hp = r["add_hp"].as(); + friend_entry.ext_hp = r["ext_hp"].as(); + friend_entry.base_atk = r["base_atk"].as(); + friend_entry.add_atk = r["add_atk"].as(); + friend_entry.ext_atk = r["ext_atk"].as(); + friend_entry.base_def = r["base_def"].as(); + friend_entry.add_def = r["add_def"].as(); + friend_entry.ext_def = r["ext_def"].as(); + friend_entry.base_heal = r["base_heal"].as(); + friend_entry.add_heal = r["add_heal"].as(); + friend_entry.ext_heal = r["ext_heal"].as(); + friend_entry.skill_id = std::to_string(r["skill_id"].as()); + friend_entry.skill_lv = r["skill_lv"].as(); + friend_entry.extra_skill_id = std::to_string(r["extra_skill_id"].as()); + friend_entry.extra_skill_lv = r["extra_skill_lv"].as(); + friend_entry.unit_type_id = r["unit_type_id"].as(); + friend_entry.element = elemId; + friend_entry.friend_id = "DECOMP01"; + friend_entry.friend_message = "GG WP"; + friend_entry.favorite = 1; + friend_entry.priority = 1; + friend_entry.deck_no = 0; + friend_entry.guild_id = 0; + + haveFriend = true; + LOG_INFO << "CampaignBattleStart: tojMy68W friend slot populated from unit " + << mstId << " (lv " << friend_entry.unit_lv << ")"; + } + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignBattleStart: friend query failed: " << ex.base().what(); + } + + if (!haveFriend) + { + // No units to source the friend from (fresh account / cleared + // inventory) — preserve the original empty-OK response shape so + // the client doesn't get confused. + co_return HandleResult::success("{}"); + } + + std::string friendArrJson; + if (const auto ec = glz::write_json( + std::vector{friend_entry}, friendArrJson); ec) + { + LOG_WARN << "CampaignBattleStart: serialize tojMy68W: " + << glz::format_error(ec, friendArrJson); + co_return HandleResult::success("{}"); + } + + std::string resp = R"({"tojMy68W":)"; + resp += friendArrJson; + resp += '}'; + + co_return HandleResult::success(resp); +} diff --git a/gimuserver/gme/handlers/CampaignDeckGet.cpp b/gimuserver/gme/handlers/CampaignDeckGet.cpp new file mode 100644 index 0000000..05d2e4d --- /dev/null +++ b/gimuserver/gme/handlers/CampaignDeckGet.cpp @@ -0,0 +1,69 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// CampaignDeckGet (C3a0VnQK) — returns the campaign-specific party deck +// composition for the deck-select/edit screen. +// +// Response key "w5vHLT0q" — CampaignMissionPartyDeckInfoResponse: +// [{ "zsiAn9P1": "", "edy7fq3L": "", +// "gr48vsdJ": "", "XuJL4pc5": "", +// "a3qJ6QhX": "", "3WMz78t6": "", +// "gU2xtQ0V": "", "nIGZ1X9C": "" }, … ] + +// CampaignDeckMember + CampaignDeckGetResp are generated from the KDL +// (packet-generator/assets/net/handlers.kdl). + +HANDLEF(CampaignDeckGet) +{ + LOG_INFO << "CampaignDeckGet: " << json; + + CampaignDeckGetReq req{}; + { + glz::context ctx{}; + if (const auto ec = glz::read(req, json, ctx); ec) + LOG_WARN << "CampaignDeckGet: parse error: " << glz::format_error(ec, json); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + CampaignDeckGetResp resp{}; + + try + { + auto rows = co_await theDb()->execSqlCoro( + "SELECT deck_num, user_unit_id, member_type, disporder" + " FROM user_campaign_decks WHERE user_id=$1 ORDER BY deck_num, disporder;", + std::string(kUserId)); + + // Fall back to regular party deck when campaign deck is not yet configured. + if (rows.empty()) + { + rows = co_await theDb()->execSqlCoro( + "SELECT deck_num, user_unit_id, member_type, disp_order AS disporder" + " FROM user_decks WHERE user_id=$1 ORDER BY deck_num, disp_order;", + std::string(kUserId)); + } + + resp.members.reserve(rows.size()); + for (const auto& r : rows) + { + CampaignDeckMember m{}; + m.deck_num = r["deck_num"].as(); + m.user_unit_id = r["user_unit_id"].as(); + m.member_type = r["member_type"].as(); + m.disporder = r["disporder"].as(); + // HP/gauge left at 0 — client fills from its unit cache on entry. + resp.members.emplace_back(std::move(m)); + } + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignDeckGet: SELECT failed: " << ex.base().what(); + co_return HandleResult::success("{}"); + } + + co_return HandleResult::success(glz::write_json(resp).value_or("{}")); +} diff --git a/gimuserver/gme/handlers/CampaignEnd.cpp b/gimuserver/gme/handlers/CampaignEnd.cpp new file mode 100644 index 0000000..071e674 --- /dev/null +++ b/gimuserver/gme/handlers/CampaignEnd.cpp @@ -0,0 +1,36 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// CampaignEnd (jF9Kkro4) — fired when the player exits the Campaign menu. +// Clears any in-flight battle state. Empty {} response keeps the session +// alive (mirrors EventTokenInfo / TownUpdate stubs). +HANDLEF(CampaignEnd) +{ + LOG_INFO << "CampaignEnd: " << json; + + CampaignEndReq req{}; + { + glz::context ctx{}; + if (const auto ec = glz::read(req, json, ctx); ec) + LOG_WARN << "CampaignEnd: parse error: " << glz::format_error(ec, json); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + try + { + co_await theDb()->execSqlCoro( + "UPDATE user_campaign_state SET active_mission_id='', active_battle_seed=0" + " WHERE user_id=$1;", + std::string(kUserId)); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignEnd: state clear failed: " << ex.base().what(); + } + + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/handlers/CampaignMissionGet.cpp b/gimuserver/gme/handlers/CampaignMissionGet.cpp new file mode 100644 index 0000000..a0da8af --- /dev/null +++ b/gimuserver/gme/handlers/CampaignMissionGet.cpp @@ -0,0 +1,57 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// CampaignMissionGet (RSm6p2d4) — returns per-mission progress/state. +// Response is the CampaignMissionInfoResponse shape (group "2I9V0o6J"): +// [{ "j28VNcUW": , +// "HUo4T7i8": "", +// "JcKMjH64": "", +// "j0Uszek2": "" }, …] +// CampaignMissionEntry + CampaignMissionGetResp are generated from the KDL +// (packet-generator/assets/net/handlers.kdl). CampaignMissionEntry is shared +// with CampaignStart. + +HANDLEF(CampaignMissionGet) +{ + LOG_INFO << "CampaignMissionGet: " << json; + + CampaignMissionGetReq req{}; + { + glz::context ctx{}; + if (const auto ec = glz::read(req, json, ctx); ec) + LOG_WARN << "CampaignMissionGet: parse error: " << glz::format_error(ec, json); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + CampaignMissionGetResp resp{}; + + try + { + const auto rows = co_await theDb()->execSqlCoro( + "SELECT mission_id, attain_percent, state" + " FROM user_campaign_missions WHERE user_id=$1;", + std::string(kUserId)); + + resp.missions.reserve(rows.size()); + for (const auto& r : rows) + { + CampaignMissionEntry e{}; + e.mission_id = r["mission_id"].as(); + e.attain_percent = r["attain_percent"].as(); + e.state = r["state"].as(); + e.mission_on_flg = (e.state >= 1) ? "1" : "0"; + resp.missions.emplace_back(std::move(e)); + } + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignMissionGet: SELECT failed: " << ex.base().what(); + co_return HandleResult::success("{}"); + } + + co_return HandleResult::success(glz::write_json(resp).value_or("{}")); +} diff --git a/gimuserver/gme/handlers/CampaignReceipt.cpp b/gimuserver/gme/handlers/CampaignReceipt.cpp new file mode 100644 index 0000000..2dfbf32 --- /dev/null +++ b/gimuserver/gme/handlers/CampaignReceipt.cpp @@ -0,0 +1,74 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// CampaignReceipt (5Imq3wC0) — claims the reward for a cleared campaign mission. +// Marks the mission as reward_claimed in the DB, credits a fixed zel reward, +// and returns: +// "fEi17cnx" — [UserTeamInfo] — refreshes HUD zel/karma/exp +// "4MCxgS5p" — { "pCIRMw04": "" } — receipt payload (stub) + +static constexpr int64_t kReceiptZelReward = 1000; +static constexpr int64_t kReceiptKarmaReward = 200; + +// CampaignReceiptReq (login_info + mission_id) is generated from the KDL +// (packet-generator/assets/net/handlers.kdl). + +// CampaignReceiptResp (fEi17cnx team_info + 4MCxgS5p receipt stub) is generated +// from the KDL (packet-generator/assets/net/handlers.kdl) and shared with +// CampaignBattleEnd. + +HANDLEF(CampaignReceipt) +{ + LOG_INFO << "CampaignReceipt: " << json; + + CampaignReceiptReq req{}; + { + glz::context ctx{}; + if (const auto ec = glz::read(req, json, ctx); ec) + LOG_WARN << "CampaignReceipt: parse error: " << glz::format_error(ec, json); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + // Mark reward as claimed so the mission tile stops showing the receipt badge. + if (!req.mission_id.empty()) + { + try + { + co_await theDb()->execSqlCoro( + "UPDATE user_campaign_missions SET reward_claimed=1" + " WHERE user_id=$1 AND mission_id=$2;", + std::string(kUserId), req.mission_id); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignReceipt: mission UPDATE failed: " << ex.base().what(); + } + } + + // Credit receipt reward. + try + { + co_await theDb()->execSqlCoro( + "UPDATE user_info SET" + " zel = MIN(zel + $2, 99999999)," + " karma = MIN(karma + $3, 99999999)" + " WHERE id=$1;", + std::string(kUserId), kReceiptZelReward, kReceiptKarmaReward); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignReceipt: reward UPDATE failed: " << ex.base().what(); + } + + // Fetch fresh user_info so the HUD updates (team_info under fEi17cnx) and + // return it alongside the receipt stub (4MCxgS5p) in one serialization. + CampaignReceiptResp resp{}; + resp.team_info = std::move( + (co_await gme::getTeamInfo(theDb(), identity)).nonEmpty()); + + co_return HandleResult::success(glz::write_json(resp).value_or("{}")); +} diff --git a/gimuserver/gme/handlers/CampaignStart.cpp b/gimuserver/gme/handlers/CampaignStart.cpp new file mode 100644 index 0000000..9837bc1 --- /dev/null +++ b/gimuserver/gme/handlers/CampaignStart.cpp @@ -0,0 +1,109 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// CampaignStart (6Y0gaPQN) — fired when the player opens the Campaign menu. +// Returns the mission catalog (state, progress), party deck list, item slots, +// and misc event flags so the UI can render the mission-select screen. +// +// Response keys (from typed_responses/*.hpp): +// "2I9V0o6J" — CampaignMissionInfoResponse (mission state list) +// "hT95cq8K" — CampaignPartyDeckListResponse (party decks for select screen) +// "NjZ6ds1S" — CampaignEqpItemInfoResponse (equipped items — empty OK) +// "5EByfWJ4" — CampaignRsvItemInfoResponse (reserve items — empty OK) +// "Yusr3Zg5" — CampaignMissionDeckInfoResponse +// "RseDpY04" — CampaignMissionEventInfoResponse (single-object flags) +// "p04iC2wr" — CampaignRewardBonusInfoResponse (empty OK) + +// The response structs are generated from the KDL +// (packet-generator/assets/net/handlers.kdl): CampaignStartResp and its +// entries CampaignMissionEntry (2I9V0o6J, shared with CampaignMissionGet), +// CampaignStartDeckEntry (hT95cq8K), CampaignStartMissionDeckEntry (Yusr3Zg5), +// CampaignStartEventInfo (RseDpY04), and CampaignEmptyEntry (the always-empty +// NjZ6ds1S / 5EByfWJ4 / p04iC2wr slots). + +// --------------------------------------------------------------------------- +HANDLEF(CampaignStart) +{ + LOG_INFO << "CampaignStart: " << json; + + CampaignStartReq req{}; + { + glz::context ctx{}; + if (const auto ec = glz::read(req, json, ctx); ec) + LOG_WARN << "CampaignStart: parse error: " << glz::format_error(ec, json); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + CampaignStartResp resp{}; + + // Load mission progress. + try + { + const auto rows = co_await theDb()->execSqlCoro( + "SELECT mission_id, attain_percent, state" + " FROM user_campaign_missions WHERE user_id=$1;", + std::string(kUserId)); + + resp.missions.reserve(rows.size()); + for (const auto& r : rows) + { + CampaignMissionEntry e{}; + e.mission_id = r["mission_id"].as(); + e.attain_percent = r["attain_percent"].as(); + e.state = r["state"].as(); + e.mission_on_flg = (e.state >= 1) ? "1" : "0"; + resp.missions.emplace_back(std::move(e)); + } + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignStart: mission SELECT failed: " << ex.base().what(); + } + + // Load campaign party decks. If user_campaign_decks is empty (not yet + // edited), fall back to the regular user_decks so the deck panel + // isn't blank on first launch. + try + { + auto rows = co_await theDb()->execSqlCoro( + "SELECT deck_num, member_type, user_unit_id, disporder" + " FROM user_campaign_decks WHERE user_id=$1 ORDER BY deck_num, disporder;", + std::string(kUserId)); + + if (rows.empty()) + { + rows = co_await theDb()->execSqlCoro( + "SELECT deck_num, member_type, user_unit_id, disp_order AS disporder" + " FROM user_decks WHERE user_id=$1 ORDER BY deck_num, disp_order;", + std::string(kUserId)); + } + + for (const auto& r : rows) + { + CampaignStartDeckEntry e{}; + e.deck_num = r["deck_num"].as(); + e.member_type = r["member_type"].as(); + resp.party_decks.emplace_back(std::move(e)); + } + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "CampaignStart: deck SELECT failed: " << ex.base().what(); + } + + // Populate mission_decks (Yusr3Zg5) — one entry per deck slot (0-9) so + // the campaign deck-selector panel has something to render. + for (int i = 0; i < 10; ++i) + { + CampaignStartMissionDeckEntry md{}; + md.deck_num = i; + md.now_point_num = 0; + resp.mission_decks.emplace_back(md); + } + + co_return HandleResult::success(glz::write_json(resp).value_or("{}")); +} diff --git a/gimuserver/gme/handlers/FixGiftInfo.cpp b/gimuserver/gme/handlers/FixGiftInfo.cpp new file mode 100644 index 0000000..b378b09 --- /dev/null +++ b/gimuserver/gme/handlers/FixGiftInfo.cpp @@ -0,0 +1,10 @@ +#include "App.hpp" +#include "Handlers.hpp" + +// FixGiftInfo (gLRIn74v) — fired immediately after MissionEnd to reconcile +// the gift/reward inbox. Old-tree implementation returned {} unconditionally. +HANDLEF(FixGiftInfo) +{ + LOG_INFO << "FixGiftInfo: " << json; + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/handlers/GmeControllerHandlers.cpp b/gimuserver/gme/handlers/GmeControllerHandlers.cpp index 1271f8f..45aade8 100644 --- a/gimuserver/gme/handlers/GmeControllerHandlers.cpp +++ b/gimuserver/gme/handlers/GmeControllerHandlers.cpp @@ -87,6 +87,20 @@ static GmeHandler getHandler(std::string_view cmd) REGISTER("ynB7X5P9", UpdateInfoLight, "7kH9NXwC"); REGISTER("cTZ3W2JG", UserInfo, "ScJx6ywWEb0A3njT"); + // Quest / world-map entry point. + REGISTER("Zds63G5y", AreaInfo, "YfAh7gqojdXEtFR1"); + + // Campaign subsystem (see HANDLER_BLUEPRINT.md §7). + REGISTER("6Y0gaPQN", CampaignStart, "WM6yr4ej"); + REGISTER("RSm6p2d4", CampaignMissionGet, "5jzXN7AH"); + REGISTER("C3a0VnQK", CampaignDeckGet, "q2ZtYJ6P"); + REGISTER("h1RjcD3S", CampaignBattleStart, "4CKoVAq0"); + REGISTER("pTNB6yw3", CampaignBattleEnd, "t06HFsXP"); + REGISTER("5Imq3wC0", CampaignReceipt, "4DAgP80B"); + REGISTER("jF9Kkro4", CampaignEnd, "4X9tBSg8"); + + REGISTER("gLRIn74v", FixGiftInfo, "15gTE9ft"); + } } @@ -150,6 +164,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 +174,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..0e97889 100644 --- a/gimuserver/gme/handlers/Handlers.hpp +++ b/gimuserver/gme/handlers/Handlers.hpp @@ -96,4 +96,13 @@ namespace GmeHandlers HANDLE(TutorialUpdate); HANDLE(UpdateInfoLight); HANDLE(UserInfo); + HANDLE(AreaInfo); + HANDLE(CampaignStart); + HANDLE(CampaignMissionGet); + HANDLE(CampaignDeckGet); + HANDLE(CampaignBattleStart); + HANDLE(CampaignBattleEnd); + HANDLE(CampaignReceipt); + HANDLE(CampaignEnd); + HANDLE(FixGiftInfo); } From 175d813925ef8ef9390867b5f01ae51d287008b2 Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Thu, 6 Aug 2026 19:37:04 -0400 Subject: [PATCH 2/2] handlers: follow the user_units column consolidation split/03-schema-foundation collapses the duplicate stat columns in user_units onto one column per concept -- unit_lvl / base_rec / ext_rec, with add_heal and limit_over_heal renamed to add_rec / limit_over_rec. CampaignBattleStart selects those columns for the reinforcement unit, so it moves with the schema or breaks on rebase. Only the SQL text changes. friend_entry.unit_lv stays as-is: that is the FriendInfo packet field, and the client really does name the same stat unit_lv there while UserUnitInfo calls it unit_lvl. See the split/03 commit. --- gimuserver/gme/handlers/CampaignBattleStart.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/gimuserver/gme/handlers/CampaignBattleStart.cpp b/gimuserver/gme/handlers/CampaignBattleStart.cpp index 3743d10..d47ad0c 100644 --- a/gimuserver/gme/handlers/CampaignBattleStart.cpp +++ b/gimuserver/gme/handlers/CampaignBattleStart.cpp @@ -62,16 +62,16 @@ HANDLEF(CampaignBattleStart) try { const auto reinforceRows = co_await theDb()->execSqlCoro( - "SELECT user_unit_id, unit_id, unit_lv," + "SELECT user_unit_id, unit_id, unit_lvl," " base_hp, add_hp, ext_hp," " base_atk, add_atk, ext_atk," " base_def, add_def, ext_def," - " base_heal,add_heal,ext_heal," + " base_rec,add_rec,ext_rec," " skill_id, skill_lv, extra_skill_id, extra_skill_lv," " unit_type_id, element" " FROM user_units" " WHERE user_id=$1" - " ORDER BY unit_lv DESC, user_unit_id DESC LIMIT 1;", + " ORDER BY unit_lvl DESC, user_unit_id DESC LIMIT 1;", std::string(kUserId)); if (!reinforceRows.empty()) @@ -100,7 +100,7 @@ HANDLEF(CampaignBattleStart) friend_entry.friend_type = 1; friend_entry.last_login_date = static_cast(std::time(nullptr)); friend_entry.unit_id = mstId; - friend_entry.unit_lv = r["unit_lv"].as(); + friend_entry.unit_lv = r["unit_lvl"].as(); friend_entry.base_hp = r["base_hp"].as(); friend_entry.add_hp = r["add_hp"].as(); friend_entry.ext_hp = r["ext_hp"].as(); @@ -110,9 +110,9 @@ HANDLEF(CampaignBattleStart) friend_entry.base_def = r["base_def"].as(); friend_entry.add_def = r["add_def"].as(); friend_entry.ext_def = r["ext_def"].as(); - friend_entry.base_heal = r["base_heal"].as(); - friend_entry.add_heal = r["add_heal"].as(); - friend_entry.ext_heal = r["ext_heal"].as(); + friend_entry.base_heal = r["base_rec"].as(); + friend_entry.add_heal = r["add_rec"].as(); + friend_entry.ext_heal = r["ext_rec"].as(); friend_entry.skill_id = std::to_string(r["skill_id"].as()); friend_entry.skill_lv = r["skill_lv"].as(); friend_entry.extra_skill_id = std::to_string(r["extra_skill_id"].as());