diff --git a/gimuserver/gme/handlers/GmeControllerHandlers.cpp b/gimuserver/gme/handlers/GmeControllerHandlers.cpp index 1271f8f..3ac0dfc 100644 --- a/gimuserver/gme/handlers/GmeControllerHandlers.cpp +++ b/gimuserver/gme/handlers/GmeControllerHandlers.cpp @@ -80,6 +80,11 @@ static GmeHandler getHandler(std::string_view cmd) REGISTER("NiYWKdzs", HomeInfo, "f6uOewOD"); REGISTER("9TvyNR5H", MissionEnd, "oINq0rfUFPx5MgmT"); REGISTER("jE6Sp0q4", MissionStart, "csiVLDKkxEwBfR70"); + REGISTER("ruoB7bD8", ItemEdit, "DHEfRexCu0q5TAQm"); + REGISTER("0IXGiC9t", ItemSphereEqp, "CZE56XAY"); + REGISTER("I8il6EiI", ItemFavorite, "aRoIftRy"); + REGISTER("qDQerU74", ItemSell, "73aFNjPu"); + REGISTER("4P5GELTF", ItemMix, "AFqKIJ8Z4mHPB9xg"); REGISTER("TA4MnZX8", NgwordCheck, "r4Smw5TX"); REGISTER("uV6yH5MX", CreateUser, "4agnATy2DrJsWzQk"); REGISTER("d36DaiJl", TutorialSkip, "p3qD61db"); @@ -87,6 +92,7 @@ static GmeHandler getHandler(std::string_view cmd) REGISTER("ynB7X5P9", UpdateInfoLight, "7kH9NXwC"); REGISTER("cTZ3W2JG", UserInfo, "ScJx6ywWEb0A3njT"); + } } @@ -150,6 +156,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 +166,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..088e5e8 100644 --- a/gimuserver/gme/handlers/Handlers.hpp +++ b/gimuserver/gme/handlers/Handlers.hpp @@ -90,6 +90,11 @@ namespace GmeHandlers HANDLE(HomeInfo); HANDLE(MissionEnd); HANDLE(MissionStart); + HANDLE(ItemEdit); + HANDLE(ItemSphereEqp); + HANDLE(ItemFavorite); + HANDLE(ItemSell); + HANDLE(ItemMix); HANDLE(NgwordCheck); HANDLE(CreateUser); HANDLE(TutorialSkip); diff --git a/gimuserver/gme/handlers/ItemEdit.cpp b/gimuserver/gme/handlers/ItemEdit.cpp new file mode 100644 index 0000000..1b0b83d --- /dev/null +++ b/gimuserver/gme/handlers/ItemEdit.cpp @@ -0,0 +1,27 @@ +#include "App.hpp" +#include "Handlers.hpp" + +// ItemEdit (ruoB7bD8) — fired during mission prep when the player sets the +// battle-item loadout (potions/spheres carried into the fight), alongside the +// party/reinforcement select. The request body (ItemEditRequest::createBody, +// bfdata/createbody/ItemEditRequest.txt) is UserEquipItemInfo + bonus-item +// lists; the client tracks the loadout itself and MissionEnd reports what was +// consumed, so no response body is required. +// +// The legacy server (BF-WorkingDir ItemEditRequestHandler) simply acked with an +// empty OK — matched here. Without this handler the dispatcher returns +// "Unsupported request: ruoB7bD8", which the client renders as a Close dialog +// back to home on quest entry. +// +// GroupId = "ruoB7bD8", AES key = "DHEfRexCu0q5TAQm" (from the legacy +// ItemEditRequestHandler GetGroupId/GetAesKey). +// +// TODO (optional): persist the selected loadout to user_items equip state so it +// survives a reload; the client re-sends it each mission, so an ack suffices for +// now. +HANDLEF(ItemEdit) +{ + (void)session; + LOG_INFO << "ItemEdit: " << json; + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/handlers/ItemFavorite.cpp b/gimuserver/gme/handlers/ItemFavorite.cpp new file mode 100644 index 0000000..4a6fad3 --- /dev/null +++ b/gimuserver/gme/handlers/ItemFavorite.cpp @@ -0,0 +1,69 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +// ItemFavorite (I8il6EiI) — favorite/lock warehouse item stacks. The client +// sends the COMPLETE current favorite set on every call (only flag=1 entries; +// ItemFavoriteRequest::createBody skips rows whose getFavoFlg() != 1), so the +// server resets favorite_flg for the user and re-marks the listed instance +// ids. An empty list is therefore valid — it means "nothing is favorited". +// +// Request : {"VSRPkdId":[{"n6E8iMf3":"","5JbjC3Pp":"1"},...]} +// Response: same shape echoed back (legacy ItemFavoriteResponse serialised +// an array of instance ids under VSRPkdId; the client readParam +// reads n6E8iMf3 + 5JbjC3Pp per node). +// +// The legacy fork acked without persisting; this port writes user_items. +// favorite_flg so locks survive a reload (UserInfo reports them back through +// UserInfoResp.item_favorite). +// +// GroupId = "I8il6EiI", AES key = "aRoIftRy" (legacy ItemFavoriteRequestHandler). +HANDLEF(ItemFavorite) +{ + (void)session; + LOG_INFO << "ItemFavorite: " << json; + + ItemFavoriteReq req = {}; + if (const auto& ec = glz::read(req, json); ec) + { + const auto& fmte = glz::format_error(ec, json); + LOG_DEBUG << "Gme ItemFavorite Error during JSON read: " << fmte; + co_return HandleResult::error("Deserialization error", fmte); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string userId = identity.userId; + + try + { + // The request is the full favorite set: clear-then-set. + co_await theDb()->execSqlCoro( + "UPDATE user_items SET favorite_flg = 0 WHERE user_id = $1;", userId); + + for (const auto& e : req.entries) + { + co_await theDb()->execSqlCoro( + "UPDATE user_items SET favorite_flg = $1 " + "WHERE user_id = $2 AND instance_id = $3;", + e.favorite, userId, e.instance_id); + } + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_ERROR << "ItemFavorite: DB error: " << ex.base().what(); + } + + ItemFavoriteResp resp = {}; + resp.entries = req.entries; + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + const auto& glze = glz::format_error(ec2, buffer); + LOG_DEBUG << "Gme ItemFavorite Error during JSON writing: " << glze; + co_return HandleResult::error("Serialization error", glze); + } + + co_return HandleResult::success(buffer); +} diff --git a/gimuserver/gme/handlers/ItemMix.cpp b/gimuserver/gme/handlers/ItemMix.cpp new file mode 100644 index 0000000..52ec7ef --- /dev/null +++ b/gimuserver/gme/handlers/ItemMix.cpp @@ -0,0 +1,26 @@ +#include "App.hpp" +#include "Handlers.hpp" + +// ItemMix (4P5GELTF) — item synthesis at the town Synthesis facility. The +// request body (ItemMixRequest::createBody, bfdata/createbody/ItemMixRequest.txt) +// is a single node under "JTf2jY5o" carrying ItemMixRequest::itemCsvData — +// an opaque CSV whose split format is UNVERIFIED (no capture yet; per +// handbook §3.4 we do not guess wire semantics). +// +// The legacy fork acked with an empty OK and the client applies the craft +// locally, so an ack keeps the synthesis menu from erroring. Materials are +// NOT consumed and the crafted item is NOT credited server-side until the +// CSV format is decoded — the INFO log below records the exact body, so the +// first real synthesis attempt in the client produces the capture needed to +// finish this handler (deploy/log/http_log_4P5GELTF_*.log). +// +// TODO(capture): decode itemCsvData → consume recipe materials from +// user_items via recipe_mst, credit the crafted item via gme::addUserItem. +// +// GroupId = "4P5GELTF", AES key = "AFqKIJ8Z4mHPB9xg" (legacy ItemMixRequestHandler). +HANDLEF(ItemMix) +{ + (void)session; + LOG_INFO << "ItemMix (CAPTURE — itemCsvData format unverified): " << json; + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/handlers/ItemSell.cpp b/gimuserver/gme/handlers/ItemSell.cpp new file mode 100644 index 0000000..cd809ac --- /dev/null +++ b/gimuserver/gme/handlers/ItemSell.cpp @@ -0,0 +1,104 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +#include + +// ItemSell (qDQerU74) — sell warehouse item stacks for zel. Request +// (ItemSellRequest::createBody, bfdata/createbody/ItemSellRequest.txt): +// +// "M73i1c5U": [{"n6E8iMf3":"","wgV86x1q":""}, ...] +// +// Zel credit per unit is ItemMst.sell_price (eKtE6k0n, legacy sellPrice). +// Stacks are decremented but the row is kept at 0 so instance ids stay stable +// for the client's local warehouse model; UserInfo filters zero stacks off +// the wire. +// +// Response: empty OK — the legacy fork acked without a body and the client +// applies the zel/count changes from its own state. +// +// GroupId = "qDQerU74", AES key = "73aFNjPu" (legacy ItemSellRequestHandler). +HANDLEF(ItemSell) +{ + (void)session; + LOG_INFO << "ItemSell: " << json; + + ItemSellReq req = {}; + if (const auto& ec = glz::read(req, json); ec) + { + const auto& fmte = glz::format_error(ec, json); + LOG_DEBUG << "Gme ItemSell Error during JSON read: " << fmte; + co_return HandleResult::error("Deserialization error", fmte); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string userId = identity.userId; + + const auto& itemMst = theServer()->cache().itemMst(); + const auto sellPrice = [&itemMst](uint32_t itemId) -> int64_t { + for (const auto& m : itemMst) + { + if (m.id == static_cast(itemId)) + return m.sell_price; + } + LOG_WARN << "ItemSell: item " << itemId << " not in ItemMst — sell price defaults to 0"; + return 0; + }; + + int64_t zelCredit = 0; + for (const auto& e : req.entries) + { + try + { + const auto rows = co_await theDb()->execSqlCoro( + "SELECT item_id, item_num FROM user_items " + "WHERE user_id = $1 AND instance_id = $2;", + userId, e.instance_id); + if (rows.size() == 0) + { + LOG_WARN << "ItemSell: instance " << e.instance_id << " not found"; + continue; + } + + const uint32_t itemId = rows[0]["item_id"].as(); + const uint32_t held = rows[0]["item_num"].as(); + const uint32_t sold = std::min(held, e.item_num); + if (sold == 0) + continue; + + co_await theDb()->execSqlCoro( + "UPDATE user_items SET item_num = item_num - $1 " + "WHERE user_id = $2 AND instance_id = $3;", + sold, userId, e.instance_id); + + zelCredit += sellPrice(itemId) * sold; + LOG_INFO << "ItemSell: sold " << sold << "x item " << itemId + << " (instance " << e.instance_id << ")"; + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_ERROR << "ItemSell: DB error for instance " << e.instance_id + << ": " << ex.base().what(); + } + } + + if (zelCredit > 0) + { + try + { + // Same display-overflow cap the debug CLI enforces: the client + // wraps zel above 99'999'999 back to 0, which breaks cost checks. + co_await theDb()->execSqlCoro( + "UPDATE user_info SET zel = MIN(zel + $1, 99999999) WHERE id = $2;", + zelCredit, userId); + LOG_INFO << "ItemSell: credited " << zelCredit << " zel"; + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_ERROR << "ItemSell: zel credit failed: " << ex.base().what(); + } + } + + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/handlers/ItemSphereEqp.cpp b/gimuserver/gme/handlers/ItemSphereEqp.cpp new file mode 100644 index 0000000..f10e3b1 --- /dev/null +++ b/gimuserver/gme/handlers/ItemSphereEqp.cpp @@ -0,0 +1,152 @@ +#include "App.hpp" +#include "Handlers.hpp" + +#include + +#include +#include +#include + +// ItemSphereEqp (0IXGiC9t) — equip/unequip spheres (equipment items) on units +// from the sphere menu. Request (ItemSphereEqpRequest::createBody, +// bfdata/createbody/ItemSphereEqpRequest.txt): +// +// "wx1ZLFj9": [{"a2utCvs8": "wh1:wh2:userUnitId:itemId1:itemId2[,...]"}] +// +// Each comma segment updates one unit's two sphere slots; itemId 0 clears a +// slot. wh1/wh2 are the client's warehouse instance ids (informational — the +// authoritative join is the master item id). The frame id stored alongside +// each slot is ItemMst.sphere_type, the sphere-category icon the client +// renders on the unit (legacy MstConfig::GetItemSphereType). +// +// Warehouse accounting mirrors the legacy handler: equipping consumes one from +// the sphere's stack, unequipping returns it. Rows are decremented to 0 rather +// than deleted so instance ids stay stable for the client's local warehouse +// model; UserInfo filters zero stacks off the wire. +// +// Response: empty OK (legacy-verified — the client applies the change from its +// own state). +// +// GroupId = "0IXGiC9t", AES key = "CZE56XAY" (legacy ItemSphereEqpRequestHandler). +HANDLEF(ItemSphereEqp) +{ + (void)session; + LOG_INFO << "ItemSphereEqp: " << json; + + ItemSphereEqpReq req = {}; + if (const auto& ec = glz::read(req, json); ec) + { + const auto& fmte = glz::format_error(ec, json); + LOG_DEBUG << "Gme ItemSphereEqp Error during JSON read: " << fmte; + co_return HandleResult::error("Deserialization error", fmte); + } + + const auto identity = (co_await gme::getUserIdentity(theDb(), req.login_info)).nonEmpty(); + const std::string userId = identity.userId; + + const auto& itemMst = theServer()->cache().itemMst(); + const auto sphereFrame = [&itemMst](uint32_t itemId) -> int32_t { + if (itemId == 0) + return 0; + for (const auto& m : itemMst) + { + if (m.id == static_cast(itemId)) + return m.sphere_type; + } + LOG_WARN << "ItemSphereEqp: item " << itemId << " not in ItemMst — frame defaults to 0"; + return 0; + }; + + // user_items stack adjustment for one slot change: return the previously + // equipped sphere (UPSERT +1), consume the newly equipped one (-1, floored + // at 0, row kept so the instance id survives). + const auto adjustWarehouse = [&](uint32_t oldId, uint32_t newId) -> drogon::Task { + if (oldId == newId) + co_return; + if (oldId != 0) + { + co_await gme::addUserItem(theDb(), identity, oldId, 1); + } + if (newId != 0) + { + co_await theDb()->execSqlCoro( + "UPDATE user_items SET item_num = MAX(0, item_num - 1) " + "WHERE user_id = $1 AND item_id = $2;", + userId, newId); + } + }; + + for (const auto& node : req.nodes) + { + // packed = "wh1:wh2:userUnitId:itemId1:itemId2[,wh1:wh2:...]" + std::istringstream ops(node.packed); + std::string segment; + while (std::getline(ops, segment, ',')) + { + if (segment.empty()) + continue; + + std::vector parts; + std::istringstream seg(segment); + std::string token; + while (std::getline(seg, token, ':')) + parts.push_back(token); + + if (parts.size() < 5) + { + LOG_WARN << "ItemSphereEqp: segment has fewer than 5 parts: " << segment; + continue; + } + + uint32_t userUnitId = 0, itemId1 = 0, itemId2 = 0; + try { userUnitId = static_cast(std::stoul(parts[2])); } catch (...) {} + try { itemId1 = static_cast(std::stoul(parts[3])); } catch (...) {} + try { itemId2 = static_cast(std::stoul(parts[4])); } catch (...) {} + + if (userUnitId == 0) + { + LOG_WARN << "ItemSphereEqp: bad user_unit_id in segment: " << segment; + continue; + } + + const int32_t frame1 = sphereFrame(itemId1); + const int32_t frame2 = sphereFrame(itemId2); + + try + { + const auto oldRows = co_await theDb()->execSqlCoro( + "SELECT eqip_item_id, eqip_item_id2 FROM user_units " + "WHERE user_unit_id = $1 AND user_id = $2;", + userUnitId, userId); + if (oldRows.size() == 0) + { + LOG_WARN << "ItemSphereEqp: unit " << userUnitId << " not found"; + continue; + } + const uint32_t oldItem1 = oldRows[0]["eqip_item_id"].as(); + const uint32_t oldItem2 = oldRows[0]["eqip_item_id2"].as(); + + co_await theDb()->execSqlCoro( + "UPDATE user_units " + "SET eqip_item_id = $1, eqip_item_frame_id = $2, " + " eqip_item_id2 = $3, eqip_item_frame_id2 = $4 " + "WHERE user_unit_id = $5 AND user_id = $6;", + itemId1, frame1, itemId2, frame2, userUnitId, userId); + + co_await adjustWarehouse(oldItem1, itemId1); + co_await adjustWarehouse(oldItem2, itemId2); + + LOG_INFO << "ItemSphereEqp: unit " << userUnitId + << " slot1=(" << itemId1 << ",frame=" << frame1 << ")" + << " slot2=(" << itemId2 << ",frame=" << frame2 << ")"; + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_ERROR << "ItemSphereEqp: DB error for unit " << userUnitId + << ": " << ex.base().what(); + } + } + } + + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/handlers/Tutorial.cpp b/gimuserver/gme/handlers/Tutorial.cpp index 9d01a9e..6c45eb2 100644 --- a/gimuserver/gme/handlers/Tutorial.cpp +++ b/gimuserver/gme/handlers/Tutorial.cpp @@ -9,6 +9,10 @@ namespace { +// The healing potion every first-time player receives; the early tutorial +// uses it to explain usable battle items. +constexpr uint32_t kTutorialPotionItemId = 20000; + std::optional getStarterUnit(uint32_t element) { switch (element) @@ -147,6 +151,12 @@ HANDLEF(CreateUser) .disp_order = 1, }, { db::Data("user_id", identity.userId) })).nonEmpty(); + + // Every first-time player gets one healing potion (item 20000), + // the item the early tutorial uses to explain usable battle + // items. Natural provisioning — replaces the old UserInfo + // hardcode that faked this row for the tutorial account. + (co_await gme::addUserItem(transaction, identity, kTutorialPotionItemId)); } catch (...) {