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..d47ad0c --- /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_lvl," + " base_hp, add_hp, ext_hp," + " base_atk, add_atk, ext_atk," + " base_def, add_def, ext_def," + " 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_lvl 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_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(); + 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_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()); + 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); }