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); +}