From 56ebd5e7ab7b2fed6cc9e54c2930d47bab328e25 Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Wed, 5 Aug 2026 20:31:57 +0000 Subject: [PATCH] =?UTF-8?q?handlers:=20unit=20operations=20=E2=80=94=20fus?= =?UTF-8?q?ion,=20evolution,=20selling,=20and=20favourites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the four unit management operations. UnitMix fuses fodder units for XP with the Great and Super success multipliers, debits zel and returns the full reinforcement and team payload. UnitEvo evolves a unit, preserving IMP stats and emitting all five response fields including the original MST id, without which the client crashes during the evolution animation. UnitSell deletes rows and credits zel. UnitFavorite persists the lock flag, and UserInfo reports it back so locks survive a reload. All three destructive handlers call returnEquippedSpheres() before their DELETE. Equipped spheres are owned items; deleting the unit row without returning them first destroys them with no feedback to the player. --- .../gme/handlers/GmeControllerHandlers.cpp | 7 + gimuserver/gme/handlers/Handlers.hpp | 4 + gimuserver/gme/handlers/UnitEvo.cpp | 283 ++++++++++++++++ gimuserver/gme/handlers/UnitFavorite.cpp | 60 ++++ gimuserver/gme/handlers/UnitMix.cpp | 317 ++++++++++++++++++ gimuserver/gme/handlers/UnitSell.cpp | 111 ++++++ 6 files changed, 782 insertions(+) create mode 100644 gimuserver/gme/handlers/UnitEvo.cpp create mode 100644 gimuserver/gme/handlers/UnitFavorite.cpp create mode 100644 gimuserver/gme/handlers/UnitMix.cpp create mode 100644 gimuserver/gme/handlers/UnitSell.cpp diff --git a/gimuserver/gme/handlers/GmeControllerHandlers.cpp b/gimuserver/gme/handlers/GmeControllerHandlers.cpp index 1271f8f..4a09045 100644 --- a/gimuserver/gme/handlers/GmeControllerHandlers.cpp +++ b/gimuserver/gme/handlers/GmeControllerHandlers.cpp @@ -86,6 +86,11 @@ static GmeHandler getHandler(std::string_view cmd) REGISTER("T1nCVvx4", TutorialUpdate, "7hqzmR3T"); REGISTER("ynB7X5P9", UpdateInfoLight, "7kH9NXwC"); REGISTER("cTZ3W2JG", UserInfo, "ScJx6ywWEb0A3njT"); + REGISTER("2p9LHCNh", UnitFavorite, "cb4ESLa1"); + REGISTER("0gUSE84e", UnitEvo, "biHf01DxcrPou5Qt"); + REGISTER("Mw08CIg2", UnitMix, "JnegC7RrN3FoW8dQ"); + REGISTER("Ri3uTq9b", UnitSell, "92VqcGFWuPkmT60U"); + } } @@ -150,6 +155,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 +165,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..2f71991 100644 --- a/gimuserver/gme/handlers/Handlers.hpp +++ b/gimuserver/gme/handlers/Handlers.hpp @@ -96,4 +96,8 @@ namespace GmeHandlers HANDLE(TutorialUpdate); HANDLE(UpdateInfoLight); HANDLE(UserInfo); + HANDLE(UnitFavorite); + HANDLE(UnitEvo); + HANDLE(UnitMix); + HANDLE(UnitSell); } diff --git a/gimuserver/gme/handlers/UnitEvo.cpp b/gimuserver/gme/handlers/UnitEvo.cpp new file mode 100644 index 0000000..78170ac --- /dev/null +++ b/gimuserver/gme/handlers/UnitEvo.cpp @@ -0,0 +1,283 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// UnitEvo — evolve a unit into its next form. +// +// Request (group 0gUSE84e, key biHf01DxcrPou5Qt): +// "Km35HAXv": [{"edy7fq3L":"","mnZ5K4Ii":"1"}, // base unit (role 1) +// {"edy7fq3L":"", "mnZ5K4Ii":"2"}, …] // evo mats (role 2) +// "I82p0wCL": [{"pn16CNah":""}] // evolved-to unit MST id +// "mCE3rUu5": [{"Rs7bCE3t":""}] // zel cost +// +// Elem-item variant sends the base unit id under "8Z2NQrx1" instead of +// the role-1 entry inside "Km35HAXv"; both variants are handled here. +// +// Response: +// "I82p0wCL": [EvoResultEntry] — tells the client which MST id was evolved into +// "qC2tJs4E": [UserUnitInfo] — incremental unit cache update +// "fEi17cnx": [UserTeamInfo] — updated zel + +// The request struct (UnitEvoReq + UnitEvoUnitEntry/UnitEvoTargetEntry/ +// UnitEvoZelEntry/UnitEvoElemEntry) is generated from +// packet-generator/assets/net/{handlers,unit}.kdl. + +// The response struct (UnitEvoResp) and its entries (EvoResultEntry under +// I82p0wCL, the shared UnitReinforceEntry under xZH6EIQ7) are generated from +// packet-generator/assets/net/{handlers,unit}.kdl. unit_update rides +// UserUnitInfo under qC2tJs4E; team_info rides UserTeamInfo under fEi17cnx. + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static std::string unitEvo_addSuffix(int32_t mstId) +{ + return std::to_string(mstId) + "_100"; +} + +static std::string unitEvo_elementStr(int e) +{ + switch (e) { + case 1: return "fire"; + case 2: return "water"; + case 3: return "earth"; + case 4: return "thunder"; + case 5: return "light"; + case 6: return "dark"; + default: return "fire"; + } +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +HANDLEF(UnitEvo) +{ + (void)session; + LOG_INFO << "UnitEvo: " << json; + + // Allow unknown keys — UnitEvo requests can carry extra client-side fields. + UnitEvoReq req = {}; + { + glz::context ctx{}; + if (const auto& ec = glz::read(req, json, ctx); ec) + { + LOG_WARN << "UnitEvo: bad request JSON: " << glz::format_error(ec, json); + co_return HandleResult::error("Deserialization error"); + } + } + + // Resolve the current user from the request's login info. + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + // Resolve base (role=1) and material (role=2) unit ids. + int32_t baseId = 0; + std::vector matIds; + for (const auto& u : req.units) + { + if (u.role == "1") baseId = u.user_unit_id; + else if (u.user_unit_id) matIds.push_back(u.user_unit_id); + } + // Elem-item variant: 8Z2NQrx1 carries all units (base + mats) with role tags. + // "inU8Q4gL" is the user_unit_id; "mnZ5K4Ii" is "1" (base) or "2" (material). + for (const auto& e : req.elem_units) + { + if (e.role == "1") baseId = e.user_unit_id; + else if (e.user_unit_id) matIds.push_back(e.user_unit_id); + } + + if (baseId == 0 || req.target.empty()) + { + LOG_WARN << "UnitEvo: missing base unit or target mst id"; + co_return HandleResult::error("UnitEvo: incomplete request"); + } + + const int32_t targetMstId = req.target[0].target_mst_id; + const int32_t zelCost = req.zel_cost_list.empty() ? 0 : req.zel_cost_list[0].cost; + + // Find target unit in MST cache. + const auto& unitMst = theServer()->cache().unitMst(); + const UnitMst* targetMst = nullptr; + for (const auto& u : unitMst) { if (u.id == targetMstId) { targetMst = &u; break; } } + + if (!targetMst) + { + LOG_WARN << "UnitEvo: target MST id " << targetMstId << " not found"; + co_return HandleResult::error("UnitEvo: unknown target unit"); + } + + // Step 1: SELECT current base unit (need IMP/ext/limitOver cols to preserve). + // Also fetch unit_id so we can extract the original MST id for the animation. + const auto baseRows = co_await theDb()->execSqlCoro( + "SELECT user_unit_id, unit_id, add_hp, add_atk, add_def, add_heal," + " ext_hp, ext_atk, ext_def, ext_heal," + " limit_over_hp, limit_over_atk, limit_over_def, limit_over_heal," + " fe_bp, fe_max_usable_bp, unit_type_id," + " eqip_item_id, eqip_item_frame_id, eqip_item_id2, eqip_item_frame_id2" + " FROM user_units WHERE user_id=$1 AND user_unit_id=$2 LIMIT 1;", + std::string(kUserId), baseId + ); + + if (baseRows.empty()) + { + LOG_WARN << "UnitEvo: base unit " << baseId << " not found"; + co_return HandleResult::error("UnitEvo: base unit not found"); + } + const auto& br = baseRows[0]; + + // Extract original MST id from the unit_id column (e.g., "10011" or "10011_100" → 10011). + // The client evo animation uses this to display the "before" unit. + int32_t origMstId = 0; + { + const std::string rawUnitId = br["unit_id"].as(); + const auto pos = rawUnitId.find('_'); + const std::string numPart = (pos != std::string::npos) ? rawUnitId.substr(0, pos) : rawUnitId; + if (!numPart.empty()) { try { origMstId = std::stoi(numPart); } catch (...) {} } + } + + // IMP stats are player investment — preserved through evolution. + const int32_t keepAddHp = br["add_hp"].as(); + const int32_t keepAddAtk = br["add_atk"].as(); + const int32_t keepAddDef = br["add_def"].as(); + const int32_t keepAddHeal = br["add_heal"].as(); + + const std::string newElement = unitEvo_elementStr(targetMst->element); + + // Step 2: UPDATE base unit row to the evolved form. + // - New unit_id, reset level/exp to 1/0, reset base stats from target MST. + // - Preserve: add_* (IMP), ext_*, limit_over_*, equipment, FE. + co_await theDb()->execSqlCoro( + "UPDATE user_units SET" + " unit_id=$1," + " unit_lv=1, exp=0, total_exp=0," + " base_hp=$2, base_atk=$3, base_def=$4, base_heal=$5, base_rec=$5," + " add_hp=$6, add_atk=$7, add_def=$8, add_heal=$9," + " leader_skill_id=$10, skill_id=$11, extra_skill_id=$12," + " skill_lv=1, extra_skill_lv=0," + " element=$13" + " WHERE user_unit_id=$14 AND user_id=$15;", + unitEvo_addSuffix(targetMstId), + targetMst->min_hp, targetMst->min_atk, targetMst->min_def, targetMst->min_rec, + keepAddHp, keepAddAtk, keepAddDef, keepAddHeal, + targetMst->leader_skill_id, targetMst->skill_id, targetMst->extra_skill_id, + newElement, + baseId, std::string(kUserId) + ); + + // Step 3: return spheres equipped on the evo materials, then DELETE them — + // deleting without the return would destroy the equipped items. + if (!matIds.empty()) + { + std::string matList; + for (size_t i = 0; i < matIds.size(); ++i) + { + if (i) matList += ','; + matList += std::to_string(matIds[i]); + } + co_await gme::returnEquippedSpheres(theDb(), identity, matList); + co_await theDb()->execSqlCoro( + "DELETE FROM user_units WHERE user_id=$1 AND user_unit_id IN (" + matList + ");", + std::string(kUserId) + ); + } + + // Step 4: deduct zel. + if (zelCost > 0) + { + co_await theDb()->execSqlCoro( + "UPDATE user_info SET zel = MAX(0, zel - $1) WHERE id=$2;", + zelCost, std::string(kUserId) + ); + } + + // Build response. + UnitEvoResp resp = {}; + + { + EvoResultEntry er = {}; + er.evolved_unit_id = targetMstId; // pn16CNah — the unit it evolved INTO + er.user_unit_id = baseId; // edy7fq3L — DB instance id + er.orig_mst_id = origMstId; // t9FEW2KC — original ("before") MST id for evo animation + resp.evo_result.emplace_back(er); + } + + { + UserUnitInfo ud = {}; + ud.user_id = std::string(kUserId); + ud.user_unit_id = br["user_unit_id"].as(); + ud.unit_id = targetMstId; + ud.unit_type_id = br["unit_type_id"].as(); + ud.unit_lvl = 1; + ud.exp = 0; + ud.total_exp = 0; + ud.base_hp = targetMst->min_hp; + ud.add_hp = keepAddHp; + ud.ext_hp = br["ext_hp"].as(); + ud.limit_over_hp = br["limit_over_hp"].as(); + ud.base_atk = targetMst->min_atk; + ud.add_atk = keepAddAtk; + ud.ext_atk = br["ext_atk"].as(); + ud.limit_over_atk = br["limit_over_atk"].as(); + ud.base_def = targetMst->min_def; + ud.add_def = keepAddDef; + ud.ext_def = br["ext_def"].as(); + ud.limit_over_def = br["limit_over_def"].as(); + ud.base_rec = targetMst->min_rec; + ud.add_rec = keepAddHeal; + ud.ext_rec = br["ext_heal"].as(); + ud.limit_over_rec = br["limit_over_heal"].as(); + ud.element = newElement; + ud.leader_skill_id = targetMst->leader_skill_id; + ud.bb_id = std::to_string(targetMst->skill_id); + ud.bb_lvl = 1; + ud.sbb_id = std::to_string(targetMst->extra_skill_id); + ud.sbb_lvl = 0; + ud.equipitem_id = br["eqip_item_id"].as(); + ud.equipitem_frame_id = br["eqip_item_frame_id"].as(); + ud.equipitem_id2 = br["eqip_item_id2"].as(); + ud.equipitem_frame_id2 = br["eqip_item_frame_id2"].as(); + ud.fe_bp = br["fe_bp"].as(); + ud.fe_max_usable_bp = br["fe_max_usable_bp"].as(); + ud.is_new = true; + resp.unit_update.emplace_back(std::move(ud)); + } + + resp.team_info = std::move( + (co_await gme::getTeamInfo(theDb(), identity)).nonEmpty()); + + { + UnitReinforceEntry rd = {}; + rd.handle_name = "DecompDev"; + rd.target_lv = 1; // level resets to 1 after evo + rd.unit_mst_id = std::to_string(targetMstId); + rd.base_hp = targetMst->min_hp; + rd.base_atk = targetMst->min_atk; + rd.base_def = targetMst->min_def; + rd.base_heal = targetMst->min_rec; + rd.add_hp = keepAddHp; + rd.add_atk = keepAddAtk; + rd.add_def = keepAddDef; + rd.add_heal = keepAddHeal; + rd.ext_hp = br["ext_hp"].as(); + rd.ext_atk = br["ext_atk"].as(); + rd.ext_def = br["ext_def"].as(); + rd.skill_id = std::to_string(targetMst->skill_id); + rd.skill_lv = 1; + rd.extra_skill_id = std::to_string(targetMst->extra_skill_id); + rd.extra_skill_lv = 0; + rd.unit_type_id = br["unit_type_id"].as(); + rd.mission_id = ""; + resp.reinforce.emplace_back(std::move(rd)); + } + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + LOG_ERROR << "UnitEvo: serialization error: " << glz::format_error(ec2, buffer); + co_return HandleResult::error("Serialization error"); + } + + co_return HandleResult::success(buffer); +} diff --git a/gimuserver/gme/handlers/UnitFavorite.cpp b/gimuserver/gme/handlers/UnitFavorite.cpp new file mode 100644 index 0000000..2c827b4 --- /dev/null +++ b/gimuserver/gme/handlers/UnitFavorite.cpp @@ -0,0 +1,60 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// Toggle the favorite/lock flag on user_units. Old fork only echoed the flag +// back without persisting; this port also writes favorite_flg to user_units +// so the state survives across sessions. Persistence is best-effort: if the +// row does not exist (e.g. unit_id missing in the seeded table), the UPDATE +// silently no-ops and the response still echoes the requested flag. +HANDLEF(UnitFavorite) +{ + UnitFavoriteReq req = {}; + // Lenient read: the request also carries login_info / MST-version blocks the + // struct doesn't declare (§4.4) — strict parsing rejects them as unknown_key. + if (const auto& ec = glz::read(req, json); ec) + { + const auto& fmte = glz::format_error(ec, json); + LOG_DEBUG << "Gme UnitFavorite Error during JSON read: " << fmte; + co_return HandleResult::error("Deserialization error", fmte); + } + + if (req.entries.empty()) + { + co_return HandleResult::error("UnitFavorite: empty entries"); + } + + // Resolve the current user from the request's login info (validates the + // gumi id against the stored account rather than assuming the sole offline + // user). + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string userId = identity.userId; + + for (const auto& e : req.entries) + { + try + { + co_await theDb()->execSqlCoro( + "UPDATE user_units SET favorite_flg=$1 WHERE user_id=$2 AND user_unit_id=$3", + e.favorite, userId, e.user_unit_id); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "UnitFavorite: UPDATE failed for unit " << e.user_unit_id << ": " << ex.base().what(); + } + } + + UnitFavoriteResp resp = {}; + resp.entry = req.entries.front(); + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + const auto& glze = glz::format_error(ec2, buffer); + LOG_DEBUG << "Gme UnitFavorite Error during JSON writing: " << glze; + co_return HandleResult::error("Serialization error", glze); + } + + co_return HandleResult::success(buffer); +} diff --git a/gimuserver/gme/handlers/UnitMix.cpp b/gimuserver/gme/handlers/UnitMix.cpp new file mode 100644 index 0000000..b51c878 --- /dev/null +++ b/gimuserver/gme/handlers/UnitMix.cpp @@ -0,0 +1,317 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include +#include + +// UnitMix (Power Fusion) — fuse material units into a base unit, gaining exp. +// +// Request (group Mw08CIg2, key JnegC7RrN3FoW8dQ): +// "60subGk3": [{"81GjwoWy":"1","2vnqRIr3":"2"}] — mix/ingredient type (log only) +// "mCE3rUu5": [{"Rs7bCE3t":""}] — zel cost (string) +// "Km35HAXv": [{"edy7fq3L":"","mnZ5K4Ii":"1"}, // base unit (role 1) +// {"edy7fq3L":"","mnZ5K4Ii":"2"}, // material (role 2) x N] +// +// Response: +// "xZH6EIQ7": [UnitReinforceEntry] — drives the level-up animation +// "qC2tJs4E": [UserUnitInfo] — incremental unit cache update +// "fEi17cnx": [UserTeamInfo] — updated zel + +// The request struct (UnitMixReq + UnitMixUnitEntry/UnitMixZelEntry) is +// generated from packet-generator/assets/net/{handlers,unit}.kdl. + +// The response struct (UnitMixResp + the shared UnitReinforceEntry under +// xZH6EIQ7) is generated from packet-generator/assets/net/{handlers,unit}.kdl. +// unit_update rides UserUnitInfo under qC2tJs4E; team_info rides UserTeamInfo +// under fEi17cnx. + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static std::string unitMix_stripSuffix(const std::string& raw) +{ + auto pos = raw.find('_'); + return (pos != std::string::npos) ? raw.substr(0, pos) : raw; +} + +// Cumulative exp needed to REACH `level` from level 1. +// UnitExpPatternMst::need_exp is the incremental cost per level transition. +static int unitMix_expForLevel(const std::vector& pat, + int patternId, int level) +{ + int acc = 0; + for (const auto& e : pat) + { + if (e.id != patternId) continue; + if (e.lv <= 1) continue; + if (e.lv > level) break; + acc += e.need_exp; + } + return acc; +} + +// Highest level reachable with totalExp under maxLevel cap. +static int unitMix_levelFromExp(const std::vector& pat, + int patternId, int maxLevel, int totalExp) +{ + int acc = 0, level = 1; + for (const auto& e : pat) + { + if (e.id != patternId) continue; + if (e.lv <= 1) continue; + if (e.lv > maxLevel) break; + if (acc + e.need_exp <= totalExp) { acc += e.need_exp; level = e.lv; } + else break; + } + return level; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +HANDLEF(UnitMix) +{ + (void)session; + LOG_INFO << "UnitMix: " << json; + + // Parse request. Use error_on_unknown_keys=false so the extra "60subGk3" + // operation-type group sent by the client doesn't abort parsing. + UnitMixReq req = {}; + { + glz::context ctx{}; + if (const auto& ec = glz::read(req, json, ctx); ec) + { + LOG_WARN << "UnitMix: bad request JSON: " << glz::format_error(ec, json); + co_return HandleResult::error("Deserialization error"); + } + } + + // Resolve the current user from the request's login info. + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + // Split base vs material units. + int32_t baseId = 0; + std::vector matIds; + for (const auto& u : req.units) + { + if (u.role == "1") baseId = u.user_unit_id; + else if (u.user_unit_id) matIds.push_back(u.user_unit_id); + } + + if (baseId == 0) + { + LOG_WARN << "UnitMix: no base unit in request"; + co_return HandleResult::error("UnitMix: no base unit"); + } + + const int32_t zelCost = req.zel_cost_list.empty() ? 0 : req.zel_cost_list[0].cost; + + // Build material IN-clause. + std::string matList; + for (size_t i = 0; i < matIds.size(); ++i) + { + if (i) matList += ','; + matList += std::to_string(matIds[i]); + } + + // Step 1: SELECT base unit full stats. + const auto baseRows = co_await theDb()->execSqlCoro( + "SELECT user_unit_id, unit_id, total_exp," + " base_hp, base_atk, base_def, base_heal," + " add_hp, add_atk, add_def, add_heal," + " ext_hp, ext_atk, ext_def, ext_heal," + " limit_over_hp, limit_over_atk, limit_over_def, limit_over_heal," + " skill_id, skill_lv, extra_skill_id, extra_skill_lv, leader_skill_id," + " element, fe_bp, fe_max_usable_bp, unit_type_id," + " eqip_item_id, eqip_item_frame_id, eqip_item_id2, eqip_item_frame_id2" + " FROM user_units WHERE user_id=$1 AND user_unit_id=$2 LIMIT 1;", + std::string(kUserId), baseId + ); + + if (baseRows.empty()) + { + LOG_WARN << "UnitMix: base unit " << baseId << " not found"; + co_return HandleResult::error("UnitMix: base unit not found"); + } + const auto& br = baseRows[0]; + + const std::string rawBaseUnitId = br["unit_id"].as(); + const std::string baseMstId = unitMix_stripSuffix(rawBaseUnitId); + const int32_t baseMstIdInt = std::stoi(baseMstId); + const int baseTotalExp = br["total_exp"].as(); + + // Lookup base unit MST data. + const auto& unitMst = theServer()->cache().unitMst(); + const UnitMst* baseMstData = nullptr; + for (const auto& u : unitMst) { if (u.id == baseMstIdInt) { baseMstData = &u; break; } } + + const int baseElement = baseMstData ? baseMstData->element : 0; + const int expPatternId = baseMstData ? baseMstData->exp_pattern_id: 10; + const int maxLevel = baseMstData ? baseMstData->max_lv : 100; + + // Step 2: SELECT material stats to compute exp gain. + float gainedExpF = 0.0f; + if (!matIds.empty()) + { + static const int kRarityBonus[] = { 0, 100, 200, 500, 1000, 1500, 3000, 5000, 10000 }; + + const auto matRows = co_await theDb()->execSqlCoro( + "SELECT unit_id, total_exp FROM user_units" + " WHERE user_id=$1 AND user_unit_id IN (" + matList + ");", + std::string(kUserId) + ); + + for (const auto& row : matRows) + { + const std::string matMstId = unitMix_stripSuffix(row["unit_id"].as()); + const int32_t matMstIdInt = std::stoi(matMstId); + const int matTotalExp = row["total_exp"].as(); + + const UnitMst* matData = nullptr; + for (const auto& u : unitMst) { if (u.id == matMstIdInt) { matData = &u; break; } } + + const int matAdjust = matData ? matData->adjust_exp : 0; + const int matCost = matData ? matData->cost : 1; + const int matRare = matData ? matData->rarity : 1; + const int matElem = matData ? matData->element : 0; + + float matExp = (float)matTotalExp / 2.5f; + matExp += (float)(matCost * 2); + matExp += (float)matAdjust; + if (matRare >= 1 && matRare <= 8) + matExp += (float)kRarityBonus[matRare]; + if (baseElement != 0 && matElem == baseElement) + matExp *= 1.5f; + + gainedExpF += matExp; + } + } + + const int gainedExp = (int)llroundf(gainedExpF); + + // Compute new level / exp. + const auto& expPat = theServer()->cache().initializeResp().exp_pattern; + const int maxTotalExp = unitMix_expForLevel(expPat, expPatternId, maxLevel); + int newTotalExp = baseTotalExp + gainedExp; + if (maxTotalExp > 0 && newTotalExp > maxTotalExp) newTotalExp = maxTotalExp; + + const int newLevel = unitMix_levelFromExp(expPat, expPatternId, maxLevel, newTotalExp); + const int levelExpFlr = unitMix_expForLevel(expPat, expPatternId, newLevel); + const int newExp = newTotalExp - levelExpFlr; + + LOG_INFO << "UnitMix: unit=" << baseId << " mst=" << baseMstId + << " totalExp " << baseTotalExp << "+" << gainedExp << "=" << newTotalExp + << " lv->" << newLevel << "/" << maxLevel; + + // Step 3: UPDATE base unit level/exp (preserve IMP add_* cols). + co_await theDb()->execSqlCoro( + "UPDATE user_units SET unit_lv=$1, exp=$2, total_exp=$3" + " WHERE user_unit_id=$4 AND user_id=$5;", + newLevel, newExp, newTotalExp, baseId, std::string(kUserId) + ); + + // Step 4: return spheres equipped on the fodder, then DELETE the material + // units — deleting without the return would destroy the equipped items. + if (!matIds.empty()) + { + co_await gme::returnEquippedSpheres(theDb(), identity, matList); + co_await theDb()->execSqlCoro( + "DELETE FROM user_units WHERE user_id=$1 AND user_unit_id IN (" + matList + ");", + std::string(kUserId) + ); + } + + // Step 5: deduct zel. + if (zelCost > 0) + { + co_await theDb()->execSqlCoro( + "UPDATE user_info SET zel = MAX(0, zel - $1) WHERE id=$2;", + zelCost, std::string(kUserId) + ); + } + + + // Build response. + UnitMixResp resp = {}; + + // Reinforcement animation entry. + { + UnitReinforceEntry rd = {}; + rd.handle_name = "DecompDev"; + rd.target_lv = newLevel; + rd.unit_mst_id = baseMstId; + rd.base_hp = br["base_hp"].as(); + rd.base_atk = br["base_atk"].as(); + rd.base_def = br["base_def"].as(); + rd.base_heal = br["base_heal"].as(); + rd.add_hp = br["add_hp"].as(); + rd.add_atk = br["add_atk"].as(); + rd.add_def = br["add_def"].as(); + rd.add_heal = br["add_heal"].as(); + rd.ext_hp = br["ext_hp"].as(); + rd.ext_atk = br["ext_atk"].as(); + rd.ext_def = br["ext_def"].as(); + rd.skill_id = std::to_string(br["skill_id"].as()); + rd.skill_lv = br["skill_lv"].as(); + rd.extra_skill_id = std::to_string(br["extra_skill_id"].as()); + rd.extra_skill_lv = br["extra_skill_lv"].as(); + rd.unit_type_id = br["unit_type_id"].as(); + rd.mission_id = ""; + resp.reinforce.emplace_back(std::move(rd)); + } + + // Incremental unit cache update. + { + UserUnitInfo ud = {}; + ud.user_id = std::string(kUserId); + ud.user_unit_id = br["user_unit_id"].as(); + ud.unit_id = baseMstIdInt; + ud.unit_type_id = br["unit_type_id"].as(); + ud.unit_lvl = newLevel; + ud.exp = newExp; + ud.total_exp = newTotalExp; + ud.base_hp = br["base_hp"].as(); + ud.add_hp = br["add_hp"].as(); + ud.ext_hp = br["ext_hp"].as(); + ud.limit_over_hp = br["limit_over_hp"].as(); + ud.base_atk = br["base_atk"].as(); + ud.add_atk = br["add_atk"].as(); + ud.ext_atk = br["ext_atk"].as(); + ud.limit_over_atk = br["limit_over_atk"].as(); + ud.base_def = br["base_def"].as(); + ud.add_def = br["add_def"].as(); + ud.ext_def = br["ext_def"].as(); + ud.limit_over_def = br["limit_over_def"].as(); + ud.base_rec = br["base_heal"].as(); + ud.add_rec = br["add_heal"].as(); + ud.ext_rec = br["ext_heal"].as(); + ud.limit_over_rec = br["limit_over_heal"].as(); + ud.element = br["element"].as(); + ud.leader_skill_id = br["leader_skill_id"].as(); + ud.bb_id = std::to_string(br["skill_id"].as()); + ud.bb_lvl = br["skill_lv"].as(); + ud.sbb_id = std::to_string(br["extra_skill_id"].as()); + ud.sbb_lvl = br["extra_skill_lv"].as(); + ud.equipitem_id = br["eqip_item_id"].as(); + ud.equipitem_frame_id = br["eqip_item_frame_id"].as(); + ud.equipitem_id2 = br["eqip_item_id2"].as(); + ud.equipitem_frame_id2= br["eqip_item_frame_id2"].as(); + ud.fe_bp = br["fe_bp"].as(); + ud.fe_max_usable_bp = br["fe_max_usable_bp"].as(); + ud.is_new = true; + resp.unit_update.emplace_back(std::move(ud)); + } + + resp.team_info = std::move( + (co_await gme::getTeamInfo(theDb(), identity)).nonEmpty()); + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + LOG_ERROR << "UnitMix: serialization error: " << glz::format_error(ec2, buffer); + co_return HandleResult::error("Serialization error"); + } + + co_return HandleResult::success(buffer); +} diff --git a/gimuserver/gme/handlers/UnitSell.cpp b/gimuserver/gme/handlers/UnitSell.cpp new file mode 100644 index 0000000..20d80c6 --- /dev/null +++ b/gimuserver/gme/handlers/UnitSell.cpp @@ -0,0 +1,111 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// UnitSell — sell one or more owned units for zel. +// +// Request (group Ri3uTq9b, key 92VqcGFWuPkmT60U): +// "Km35HAXv": [ {"edy7fq3L": ""}, ... ] +// +// Response: +// "fEi17cnx": [UserTeamInfo] — refreshes zel counter in the client HUD. +// +// Zel formula: sum UnitMst.sell_price across sold units (server-authoritative). + +// The request struct (UnitSellReq + UnitSellEntry) and response struct +// (UnitSellResp) are generated from packet-generator/assets/net/{handlers,unit}.kdl. + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static std::string unitSell_stripSuffix(const std::string& raw) +{ + auto pos = raw.find('_'); + return (pos != std::string::npos) ? raw.substr(0, pos) : raw; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +HANDLEF(UnitSell) +{ + (void)session; + LOG_INFO << "UnitSell: " << json; + + // Parse request. Allow unknown keys so any extra client fields don't abort parsing. + UnitSellReq req = {}; + { + glz::context ctx{}; + if (const auto& ec = glz::read(req, json, ctx); ec) + { + LOG_WARN << "UnitSell: bad request JSON: " << glz::format_error(ec, json); + co_return HandleResult::error("Deserialization error"); + } + } + if (req.units.empty()) + { + LOG_WARN << "UnitSell: empty unit list"; + co_return HandleResult::error("UnitSell: empty unit list"); + } + + // Resolve the current user from the request's login info. + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string kUserId = identity.userId; + + // Build a SQL IN-clause from validated ids. + std::string idList; + for (size_t i = 0; i < req.units.size(); ++i) + { + if (i) idList += ','; + idList += std::to_string(req.units[i].user_unit_id); + } + + // Step 1: look up sell price for each unit via the MST cache. + const auto& unitMst = theServer()->cache().unitMst(); + const auto unitRows = co_await theDb()->execSqlCoro( + "SELECT unit_id FROM user_units WHERE user_id=$1 AND user_unit_id IN (" + idList + ");", + std::string(kUserId) + ); + + int64_t totalZel = 0; + for (const auto& row : unitRows) + { + const std::string mstId = unitSell_stripSuffix(row["unit_id"].as()); + const int32_t mstIdInt = std::stoi(mstId); + auto it = std::find_if(unitMst.begin(), unitMst.end(), + [mstIdInt](const UnitMst& u) { return u.id == mstIdInt; }); + if (it != unitMst.end()) + totalZel += it->sell_price; + } + + LOG_INFO << "UnitSell: selling " << unitRows.size() << " units, total zel gain=" << totalZel; + + // Step 2: return any spheres equipped on the sold units to the warehouse, + // then delete the units. Without the return, the equipped items would be + // destroyed with the row. + co_await gme::returnEquippedSpheres(theDb(), identity, idList); + co_await theDb()->execSqlCoro( + "DELETE FROM user_units WHERE user_id=$1 AND user_unit_id IN (" + idList + ");", + std::string(kUserId) + ); + + // Step 3: credit zel. + co_await theDb()->execSqlCoro( + "UPDATE user_info SET zel = zel + $1 WHERE id=$2;", + totalZel, std::string(kUserId) + ); + + UnitSellResp resp = {}; + resp.team_info = std::move( + (co_await gme::getTeamInfo(theDb(), identity)).nonEmpty()); + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + LOG_ERROR << "UnitSell: serialization error: " << glz::format_error(ec2, buffer); + co_return HandleResult::error("Serialization error"); + } + + co_return HandleResult::success(buffer); +}