Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions gimuserver/gme/handlers/AreaInfo.cpp
Original file line number Diff line number Diff line change
@@ -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("{}");
}
176 changes: 176 additions & 0 deletions gimuserver/gme/handlers/CampaignBattleEnd.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#include "App.hpp"
#include "Handlers.hpp"

#include <gimuserver/archive/MissionArchiver.hpp>
#include <gimuserver/gme/common/Common.hpp>
#include <chrono>
#include <optional>

// 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=<epoch>
// WHERE user_id=$1 AND mission_id=<active_mission_id>
// 2. UPDATE user_info SET zel = zel + <reward>
// 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<glz::opts{.error_on_unknown_keys = false}>(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<std::string>();
}
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> missionRecord;
try
{
missionRecord = MissionArchiver::instance().lookup(
static_cast<uint32_t>(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<int64_t>(
std::chrono::duration_cast<std::chrono::seconds>(
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<int64_t>(missionRecord->zel),
static_cast<int64_t>(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("{}"));
}
161 changes: 161 additions & 0 deletions gimuserver/gme/handlers/CampaignBattleStart.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#include "App.hpp"
#include "Handlers.hpp"

#include <gimuserver/gme/common/Common.hpp>

#include <ctime>

// 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<glz::opts{.error_on_unknown_keys = false}>(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<std::string>();
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<std::string>();
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<int32_t>(std::time(nullptr));
friend_entry.unit_id = mstId;
friend_entry.unit_lv = r["unit_lvl"].as<int32_t>();
friend_entry.base_hp = r["base_hp"].as<int32_t>();
friend_entry.add_hp = r["add_hp"].as<int32_t>();
friend_entry.ext_hp = r["ext_hp"].as<int32_t>();
friend_entry.base_atk = r["base_atk"].as<int32_t>();
friend_entry.add_atk = r["add_atk"].as<int32_t>();
friend_entry.ext_atk = r["ext_atk"].as<int32_t>();
friend_entry.base_def = r["base_def"].as<int32_t>();
friend_entry.add_def = r["add_def"].as<int32_t>();
friend_entry.ext_def = r["ext_def"].as<int32_t>();
friend_entry.base_heal = r["base_rec"].as<int32_t>();
friend_entry.add_heal = r["add_rec"].as<int32_t>();
friend_entry.ext_heal = r["ext_rec"].as<int32_t>();
friend_entry.skill_id = std::to_string(r["skill_id"].as<int32_t>());
friend_entry.skill_lv = r["skill_lv"].as<int32_t>();
friend_entry.extra_skill_id = std::to_string(r["extra_skill_id"].as<int32_t>());
friend_entry.extra_skill_lv = r["extra_skill_lv"].as<int32_t>();
friend_entry.unit_type_id = r["unit_type_id"].as<int32_t>();
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<FriendInfo>{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);
}
Loading