diff --git a/gimuserver/db/DatabaseInterface.cpp b/gimuserver/db/DatabaseInterface.cpp index 7c1084c..5f6a8d2 100644 --- a/gimuserver/db/DatabaseInterface.cpp +++ b/gimuserver/db/DatabaseInterface.cpp @@ -2,27 +2,69 @@ #include +#include #include namespace db { +std::string DatabaseInterface::buildWhere( + const Cells& lookup, + size_t from, + Cells& binds) +{ + std::string sql; + size_t placeholder = from; + + for (const auto& cell : lookup) + { + if (!sql.empty()) + { + sql += " AND "; + } + + if (cell.use != Cell::Use::LookupIn) + { + sql += cell.name + " = $" + std::to_string(placeholder++); + binds.push_back(cell); + continue; + } + + // An empty IN list would either match nothing or, worse, invite the + // caller to splice the list into SQL themselves. Reject it instead. + if (cell.list.empty()) + { + LOG_ERROR << "Empty IN list for column: " << cell.name; + throw std::invalid_argument("Empty IN list for database lookup"); + } + + sql += cell.name + " IN ("; + for (size_t index = 0; index < cell.list.size(); ++index) + { + sql += (index == 0 ? "" : ", ") + std::string("$") + + std::to_string(placeholder++); + binds.push_back(Lookup(cell.name, cell.list[index])); + } + sql += ")"; + } + + return sql; +} + drogon::Task> DatabaseInterface::read( const Database database, const std::string table, const Cells cells) { const auto data = getCellsFor(cells); - const auto lookup = getCellsFor(cells); + const auto lookup = getLookupCells(cells); validate(database, table, data); const auto selectSql = "SELECT " + joinSql(data, [](const Cell& cell, const size_t index) { return std::string(index == 0 ? "" : ", ") + cell.name; }); - const auto whereSql = joinSql(lookup, [](const Cell& cell, const size_t index) { - return std::string(index == 0 ? "" : " AND ") + - cell.name + " = $" + std::to_string(index + 1); - }); + Cells binds; + const auto whereSql = buildWhere(lookup, 1, binds); // Empty lookup means the caller intentionally requested a table-wide read. const auto sql = selectSql + @@ -30,7 +72,7 @@ drogon::Task> DatabaseInterface::read( (lookup.empty() ? "" : " WHERE " + whereSql) + ";"; auto binder = *database << sql; - bind(binder, lookup); + bind(binder, binds); auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder)); co_return InterfaceResult{ @@ -45,17 +87,15 @@ drogon::Task> DatabaseInterface::update( const Cells cells) { const auto data = getCellsFor(cells); - const auto lookup = getCellsFor(cells); + const auto lookup = getLookupCells(cells); validate(database, table, data); const auto setSql = joinSql(data, [](const Cell& cell, const size_t index) { return std::string(index == 0 ? "" : ", ") + cell.name + " = $" + std::to_string(index + 1); }); - const auto whereSql = joinSql(lookup, [from = data.size() + 1](const Cell& cell, const size_t index) { - return std::string(index == 0 ? "" : " AND ") + - cell.name + " = $" + std::to_string(from + index); - }); + Cells binds; + const auto whereSql = buildWhere(lookup, data.size() + 1, binds); // Empty lookup means the caller intentionally requested a table-wide update. const auto sql = "UPDATE " + table + @@ -64,7 +104,7 @@ drogon::Task> DatabaseInterface::update( auto binder = *database << sql; bind(binder, data); - bind(binder, lookup); + bind(binder, binds); auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder)); co_return InterfaceResult<>{ @@ -107,24 +147,95 @@ drogon::Task> DatabaseInterface::insert( }; } +drogon::Task> DatabaseInterface::upsert( + const Database database, + const std::string table, + const Cells cells, + const Keys conflict, + const Keys accumulate) +{ + const auto data = getCellsFor(cells); + validate(database, table, data); + + if (conflict.empty()) + { + LOG_ERROR << "Upsert without a conflict target on table: " << table; + throw std::invalid_argument("Upsert requires a conflict target"); + } + + Keys keys; + keys.reserve(data.size()); + for (const auto& cell : data) + { + keys.push_back(cell.name); + } + + const auto isIn = [](const Keys& list, const Key& key) { + return std::find(list.begin(), list.end(), key) != list.end(); + }; + + // Conflict-target columns identify the row, so they are never reassigned. + std::string setSql; + for (const auto& key : keys) + { + if (isIn(conflict, key)) + { + continue; + } + + if (!setSql.empty()) + { + setSql += ", "; + } + + setSql += isIn(accumulate, key) + ? key + " = " + key + " + excluded." + key + : key + " = excluded." + key; + } + + const auto columnSql = joinSql(keys, [](const Key& key, const size_t index) { + return std::string(index == 0 ? "" : ", ") + key; + }); + const auto valueSql = joinSql(data, [](const auto&, const size_t index) { + return std::string(index == 0 ? "" : ", ") + "$" + std::to_string(index + 1); + }); + const auto conflictSql = joinSql(conflict, [](const Key& key, const size_t index) { + return std::string(index == 0 ? "" : ", ") + key; + }); + + // Every data column being part of the conflict target leaves nothing to + // assign; DO NOTHING is the correct degenerate form. + const auto sql = "INSERT INTO " + table + + " (" + columnSql + ") VALUES (" + valueSql + ")" + + " ON CONFLICT(" + conflictSql + ") DO " + + (setSql.empty() ? "NOTHING" : "UPDATE SET " + setSql) + ";"; + + auto binder = *database << sql; + bind(binder, data); + + auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder)); + co_return InterfaceResult<>{ + .data = {}, + .affected = result.affectedRows(), + }; +} + drogon::Task> DatabaseInterface::remove( const Database database, const std::string table, const Cells cells) { - const auto lookup = getCellsFor(cells); + const auto lookup = getLookupCells(cells); validate(database, table, lookup); - const auto whereSql = joinSql(lookup, [](const Cell& cell, const size_t index) { - return std::string(index == 0 ? "" : " AND ") + - cell.name + " = $" + std::to_string(index + 1); - }); + Cells binds; + const auto whereSql = buildWhere(lookup, 1, binds); const auto sql = "DELETE FROM " + table + " WHERE " + whereSql + ";"; auto binder = *database << sql; - bind(binder, lookup); + bind(binder, binds); auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder)); co_return InterfaceResult<>{ diff --git a/gimuserver/db/DatabaseInterface.h b/gimuserver/db/DatabaseInterface.h index 7fecefe..f40b3cd 100644 --- a/gimuserver/db/DatabaseInterface.h +++ b/gimuserver/db/DatabaseInterface.h @@ -72,6 +72,32 @@ class DatabaseInterface final const std::string table, const Cells cells); + /*! + * Inserts a row, or merges into the existing one on key conflict. + * + * Exists because plain insert ignores conflicting rows, which cannot express + * "add to the stack I already own". Without it callers fall back to raw + * execSqlCoro and the typed layer stops seeing their queries. + * + * Data cells are the inserted columns. On conflict with `conflict`, columns + * named in `accumulate` are ADDED to (col = col + excluded.col) and every + * other non-key data column is replaced. With an empty `accumulate` this is + * a plain insert-or-replace. + * + * @param database Database client or transaction to use. + * @param table SQL table name. + * @param cells Data cells for the insert. + * @param conflict Columns forming the conflict target. + * @param accumulate Data columns to accumulate instead of replace. + * @return Number of affected rows. + */ + static drogon::Task> upsert( + const Database database, + const std::string table, + const Cells cells, + const Keys conflict, + const Keys accumulate = {}); + /*! * Deletes rows from a table. * @@ -129,6 +155,38 @@ class DatabaseInterface final } } + /*! + * Builds a WHERE clause from lookup cells. + * + * Handles both equality (Lookup) and IN (LookupIn) predicates, numbering + * placeholders from `from` and appending the values to bind — one cell per + * placeholder, IN lists expanded — to `binds` in that same order. Callers + * then bind `binds` rather than the original lookup cells. + * + * @param lookup Lookup cells to turn into predicates. + * @param from First placeholder number to use. + * @param binds Receives the values to bind, in placeholder order. + * @return SQL predicate text, without the leading WHERE. + */ + static std::string buildWhere(const Cells& lookup, size_t from, Cells& binds); + + /*! + * Collects every predicate cell — equality and IN alike — in caller order. + */ + static Cells getLookupCells(const Cells& cells) + { + Cells output; + for (const auto& cell : cells) + { + if (cell.use == Use::Lookup || cell.use == Use::LookupIn) + { + output.push_back(cell); + } + } + + return output; + } + /*! * Filters mixed cells down to either lookup predicates or data values. */ diff --git a/gimuserver/db/MigrationManager.cpp b/gimuserver/db/MigrationManager.cpp index 0abbb5b..3faf3a6 100644 --- a/gimuserver/db/MigrationManager.cpp +++ b/gimuserver/db/MigrationManager.cpp @@ -1,9 +1,10 @@ #include "App.hpp" #include "MigrationManager.hpp" -using MigrationMap = std::unordered_map>; +using MigrationEntry = std::pair>; +using MigrationMap = std::vector; -#define migrate(name, func) map.insert_or_assign(name, [](drogon::orm::DbClientPtr& p) func ) +#define migrate(name, func) map.emplace_back(name, [](drogon::orm::DbClientPtr& p) func ) /*! * Register all the available migrations @@ -99,6 +100,75 @@ static void RegisterMigrations(MigrationMap& map) ); }); + // NOTE: the unit_lv / base_heal / ext_heal columns added here duplicate the + // unit_lvl / base_rec / ext_rec columns 08032025 already created, and + // add_heal / limit_over_heal use the wrong vocabulary for a column. + // 06082026_ConsolidateUserUnitStatColumns (bottom of this file) collapses + // all five. This migration is left as-is rather than corrected in place so + // that databases which already ran it and databases created fresh converge + // on the same schema. + // + // Extra user_units columns used by the quests-branch handlers + // (UnitMix/UnitEvo/UnitSell/UnitFavorite, GachaAction, FriendGet, + // CampaignBattleStart). Consolidates the former + // 13032025_AddStatsToUserUnitsTable + 09042026_AddSphereSlotsToUserUnits + + // 14042026_AddFavoriteFlgToUserUnits migrations onto the upstream table + // shape. Upstream columns (unit_lvl/base_rec/ext_rec/bb_*) remain the + // source of truth for upstream handlers; these serve the not-yet-ported + // quests handlers and are consolidated away as each moves to + // PacketInterface. + migrate("02072026_ExtendUserUnitsForUnitOps", { + p->execSqlSync("ALTER TABLE user_units ADD COLUMN unit_lv INTEGER NOT NULL DEFAULT 1"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN base_heal INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN add_hp INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN add_atk INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN add_def INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN add_heal INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN ext_heal INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN limit_over_hp INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN limit_over_atk INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN limit_over_def INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN limit_over_heal INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN exp INTEGER NOT NULL DEFAULT 1"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN total_exp INTEGER NOT NULL DEFAULT 1"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN skill_id INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN skill_lv INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN extra_skill_id INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN extra_skill_lv INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN leader_skill_id INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN element TEXT NOT NULL DEFAULT 'fire'"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN fe_bp INTEGER NOT NULL DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN fe_max_usable_bp INTEGER NOT NULL DEFAULT 200"); + // Sphere equipment slots (UserUnitInfo: Ge8Yo32T/0R3qTPK9, mZA7fH2v/RXfC31FA). + p->execSqlSync("ALTER TABLE user_units ADD COLUMN eqip_item_id INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN eqip_item_frame_id INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN eqip_item_id2 INTEGER NOT NULL DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN eqip_item_frame_id2 INTEGER NOT NULL DEFAULT 0"); + // Lock/favorite flag (UnitFavoriteRequest: req["3kcmQy7B"][0]["5JbjC3Pp"]). + p->execSqlSync("ALTER TABLE user_units ADD COLUMN favorite_flg INTEGER NOT NULL DEFAULT 0"); + }); + + migrate("25042026_CreateUserTownTables", { + p->execSqlSync( + "CREATE TABLE IF NOT EXISTS user_town_facilities (" + "user_id TEXT NOT NULL," + "facility_id INTEGER NOT NULL," + "lv INTEGER NOT NULL DEFAULT 1," + "karma INTEGER NOT NULL DEFAULT 0," + "PRIMARY KEY (user_id, facility_id)" + ");" + ); + p->execSqlSync( + "CREATE TABLE IF NOT EXISTS user_town_locations (" + "user_id TEXT NOT NULL," + "location_id INTEGER NOT NULL," + "lv INTEGER NOT NULL DEFAULT 1," + "karma INTEGER NOT NULL DEFAULT 0," + "PRIMARY KEY (user_id, location_id)" + ");" + ); + }); + migrate("03072026_CreateUserUnitDictionaryTable", { p->execSqlSync( "CREATE TABLE IF NOT EXISTS user_unit_dictionary (" @@ -110,6 +180,118 @@ static void RegisterMigrations(MigrationMap& map) ); }); + // Owned-item inventory: potions, materials, spheres. One row per stack. + // instance_id is the warehouse row id the client references (UserWarehouse + // n6E8iMf3 / legacy ItemSphereEqp wh ids). Populated naturally — the + // tutorial seeds a test potion in CreateUser, mission drops append here. + migrate("05072026_CreateUserItemsTable", { + p->execSqlSync( + "CREATE TABLE IF NOT EXISTS user_items (" + "instance_id INTEGER PRIMARY KEY AUTOINCREMENT," + "user_id TEXT NOT NULL," + "item_id INTEGER NOT NULL," + "item_num INTEGER NOT NULL DEFAULT 1," + "favorite_flg INTEGER NOT NULL DEFAULT 0," + "disp_order INTEGER NOT NULL DEFAULT 0," + "UNIQUE(user_id, item_id)" + ");" + ); + }); + + // Viewed-cutscene state: one row per scenario the user has watched. + // GetScenarioPlayingInfo returns this set (sBbp47fi) so the client skips + // already-seen cutscenes; RaidUpScenarioInfo appends to it. Structural + // only — never seeded; a fresh account sees every cutscene once. + migrate("17072026_CreateUserScenariosTable", { + p->execSqlSync( + "CREATE TABLE IF NOT EXISTS user_scenarios (" + "user_id TEXT NOT NULL," + "scenario_id INTEGER NOT NULL," + "viewed_at INTEGER NOT NULL DEFAULT 0," + "PRIMARY KEY (user_id, scenario_id)" + ");" + ); + }); + + migrate("25042026_CreateUserCampaignTables", { + p->execSqlSync( + "CREATE TABLE IF NOT EXISTS user_campaign_missions (" + "user_id TEXT NOT NULL," + "mission_id TEXT NOT NULL," + "state INTEGER NOT NULL DEFAULT 0," + "attain_percent INTEGER NOT NULL DEFAULT 0," + "clear_count INTEGER NOT NULL DEFAULT 0," + "last_cleared_at INTEGER NOT NULL DEFAULT 0," + "reward_claimed INTEGER NOT NULL DEFAULT 0," + "PRIMARY KEY (user_id, mission_id)" + ");" + ); + p->execSqlSync( + "CREATE TABLE IF NOT EXISTS user_campaign_decks (" + "user_id TEXT NOT NULL," + "deck_num INTEGER NOT NULL," + "member_type INTEGER NOT NULL," + "user_unit_id INTEGER NOT NULL," + "disporder INTEGER NOT NULL DEFAULT 0," + "PRIMARY KEY (user_id, deck_num, disporder)" + ");" + ); + p->execSqlSync( + "CREATE TABLE IF NOT EXISTS user_campaign_state (" + "user_id TEXT PRIMARY KEY," + "active_mission_id TEXT NOT NULL DEFAULT ''," + "active_battle_seed INTEGER NOT NULL DEFAULT 0," + "saved_state TEXT NOT NULL DEFAULT ''" + ");" + ); + }); + + migrate("13052026_CreateUserSummonTicketsV2", { + p->execSqlSync( + "CREATE TABLE IF NOT EXISTS user_summon_tickets_v2 (" + "user_id TEXT NOT NULL," + "ticket_id INTEGER NOT NULL," + "count INTEGER NOT NULL DEFAULT 0," + "PRIMARY KEY (user_id, ticket_id)" + ");" + ); + }); + + // Collapses the duplicate stat columns 02072026 added beside the ones + // 08032025 already created. They exist because the client names the same + // stat differently in different packets — UserUnitInfo says base_rec / + // ext_rec / unit_lvl, while FriendInfo and ReinforcementInfo say base_heal + // / ext_heal / unit_lv (both spellings come from IDA; see + // net/friends.kdl). A column was added per spelling, so a unit could hold + // two recovery values that disagree. + // + // One column per concept from here. The rec/lvl spelling wins because + // 08032025_CreateUserUnitsTable established it; PacketInterfaceFor maps + // either packet vocabulary onto it, which is what it is for. + // + // Merge rule: the duplicate wins only where the canonical column is still + // at its default, since the quests-branch handlers wrote the duplicates + // while upstream handlers wrote the canonical ones. Where both hold real + // values they are expected to agree; if they do not, the canonical value is + // kept. + migrate("06082026_ConsolidateUserUnitStatColumns", { + p->execSqlSync("UPDATE user_units SET unit_lvl = unit_lv " + "WHERE unit_lvl = 0 AND unit_lv != 0;"); + p->execSqlSync("UPDATE user_units SET base_rec = base_heal " + "WHERE base_rec = 0 AND base_heal != 0;"); + p->execSqlSync("UPDATE user_units SET ext_rec = ext_heal " + "WHERE ext_rec = 0 AND ext_heal != 0;"); + + p->execSqlSync("ALTER TABLE user_units DROP COLUMN unit_lv;"); + p->execSqlSync("ALTER TABLE user_units DROP COLUMN base_heal;"); + p->execSqlSync("ALTER TABLE user_units DROP COLUMN ext_heal;"); + + // These two had no canonical counterpart — they are not duplicates, + // just the wrong vocabulary for a column. + p->execSqlSync("ALTER TABLE user_units RENAME COLUMN add_heal TO add_rec;"); + p->execSqlSync("ALTER TABLE user_units " + "RENAME COLUMN limit_over_heal TO limit_over_rec;"); + }); } /*! @@ -143,6 +325,21 @@ void MigrationManager::RunMigrations(drogon::orm::DbClientPtr ptr) MigrationMap migrations; RegisterMigrations(migrations); + // Migrations are a vector so they run in declared order (a hash map ran them + // unordered). The vector doesn't dedup, so guard the uniqueness the map used + // to give us: a duplicate name would run twice / mask an intended migration. + std::vector seenNames; + for (const auto& [name, _] : migrations) + { + if (std::find(seenNames.begin(), seenNames.end(), name) != seenNames.end()) + { + LOG_ERROR << "Duplicate migration name: " << name; + drogon::app().quit(); + return; + } + seenNames.push_back(name); + } + std::vector runnedMigratons; GetMigrationStatus(ptr, runnedMigratons); diff --git a/gimuserver/db/PacketInterfaceSchemas.hpp b/gimuserver/db/PacketInterfaceSchemas.hpp index 16d9633..3f33a44 100644 --- a/gimuserver/db/PacketInterfaceSchemas.hpp +++ b/gimuserver/db/PacketInterfaceSchemas.hpp @@ -86,6 +86,11 @@ PacketInterfaceFor<::UserTeamInfo>::fields() .update = true, .insert = true, }), + // The client's gem HUD reads brave_coin (03UGMHxF), so dev wires the gems + // column here on purpose (the "BraveCoin" readParam setter name is a red + // herring for this client). Do NOT point this at a real brave_coin column + // — that zeroed the HUD and broke gems. Kept mapped to `gems` with + // paid_gems/free_gems so every gem field the client might read shows gems. field<&::UserTeamInfo::brave_coin>("gems", { .read = true, .update = true, @@ -96,6 +101,35 @@ PacketInterfaceFor<::UserTeamInfo>::fields() .update = true, .insert = true, }), + field<&::UserTeamInfo::summon_ticket>("summon_tickets", { + .read = true, + }), + field<&::UserTeamInfo::rainbow_coin>("rainbow_coins", { + .read = true, + }), + field<&::UserTeamInfo::colosseum_ticket>("colosseum_tickets", { + .read = true, + }), + field<&::UserTeamInfo::brave_points_total>("total_brave_points", { + .read = true, + }), + field<&::UserTeamInfo::current_brave_points>("avail_brave_points", { + .read = true, + }), + field<&::UserTeamInfo::want_gift>("want_gift", { + .read = true, + }), + // Send the single `gems` column to BOTH gem fields. The HUD reads FREE + // gems (92uj7oXB) — it was being sent as 0, which is why the balance + // never updated (while zel, whose field IS read, did). The summon/paid + // path reads PAID gems (d37CaiX1) — the user could summon earlier when + // only paid was populated, so keep it set too. A captured production + // team_info has both populated (free=10000, paid=20000); mirroring one + // column to both keeps display + summon working without double-counting + // (the HUD shows free, not the sum). + field<&::UserTeamInfo::free_gems>("gems", { + .read = true, + }), field<&::UserTeamInfo::paid_gems>("gems", { .read = true, }), @@ -149,6 +183,9 @@ PacketInterfaceFor<::UserUnitInfo>::fields() .update = true, .insert = true, }), + // base_rec is INTEGER NOT NULL with NO default, so the INSERT must keep + // providing it (INSERT OR IGNORE silently drops the row otherwise). Read + // stays on the canonical column; addUserUnit writes it. field<&::UserUnitInfo::base_rec>("base_rec", { .read = true, .update = true, @@ -194,6 +231,27 @@ PacketInterfaceFor<::UserUnitInfo>::fields() .update = true, .insert = true, }), + // Extras the read/display path was missing — mapped to the quests mirror + // columns the unit handlers write, so units keep full stats/level/element + // across a UserInfo reload (previously lost to column defaults). + field<&::UserUnitInfo::exp>("exp", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::total_exp>("total_exp", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::add_hp>("add_hp", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::add_atk>("add_atk", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::add_def>("add_def", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::add_rec>("add_rec", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::limit_over_hp>("limit_over_hp", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::limit_over_atk>("limit_over_atk", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::limit_over_def>("limit_over_def", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::limit_over_rec>("limit_over_rec", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::element>("element", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::leader_skill_id>("leader_skill_id", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::fe_bp>("fe_bp", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::fe_max_usable_bp>("fe_max_usable_bp", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::equipitem_id>("eqip_item_id", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::equipitem_frame_id>("eqip_item_frame_id", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::equipitem_id2>("eqip_item_id2", { .read = true, .update = true, .insert = true, }), + field<&::UserUnitInfo::equipitem_frame_id2>("eqip_item_frame_id2", { .read = true, .update = true, .insert = true, }), field<&::UserUnitInfo::is_new>("new", { .read = true, .update = true, @@ -226,6 +284,37 @@ PacketInterfaceFor<::UserUnitDictionary>::fields() }; } +/*! +* Database mapping for owned item stacks stored in user_items. +*/ +template <> +inline PacketInterfaceFor<::UserWarehouseInfo>::Fields +PacketInterfaceFor<::UserWarehouseInfo>::fields() +{ + return { + field<&::UserWarehouseInfo::instance_id>("instance_id", { + .read = true, + }), + field<&::UserWarehouseInfo::item_id>("item_id", { + .read = true, + .insert = true, + }), + field<&::UserWarehouseInfo::item_num>("item_num", { + .read = true, + .update = true, + .insert = true, + }), + field<&::UserWarehouseInfo::favorite_flg>("favorite_flg", { + .read = true, + .update = true, + }), + field<&::UserWarehouseInfo::disp_order>("disp_order", { + .read = true, + .update = true, + }), + }; +} + /*! * Database mapping for party deck slots stored in user_decks. */ diff --git a/gimuserver/db/Types.h b/gimuserver/db/Types.h index 12df0c6..ccf1482 100644 --- a/gimuserver/db/Types.h +++ b/gimuserver/db/Types.h @@ -34,11 +34,15 @@ struct Cell { Lookup, Data, + // WHERE IN (...), values taken from `list` rather than `value`. + LookupIn, }; Use use; Key name; Value value; + // Only used by LookupIn cells; empty for every other use. + Values list; }; using Cells = std::vector; @@ -54,6 +58,26 @@ inline Cell Lookup(Key name, Value value) }; } +/*! +* Builds a lookup cell for a WHERE ... IN (...) predicate. +* +* Every value is bound as its own placeholder, so callers never interpolate an +* id list into SQL by hand. An empty list is rejected by the interface rather +* than silently widening the query. +* +* @param name SQL column name. +* @param values Values the column must match. +*/ +inline Cell LookupIn(Key name, Values values) +{ + return { + .use = Cell::Use::LookupIn, + .name = std::move(name), + .value = std::monostate{}, + .list = std::move(values), + }; +} + /*! * Builds a data cell for SELECT, UPDATE, or INSERT values. */ diff --git a/gimuserver/gme/common/Common.hpp b/gimuserver/gme/common/Common.hpp index 6a40ff6..af33c34 100644 --- a/gimuserver/gme/common/Common.hpp +++ b/gimuserver/gme/common/Common.hpp @@ -3,11 +3,14 @@ #include #include #include +#include #include #include +#include #include +#include #include #include #include @@ -91,6 +94,195 @@ inline drogon::Task> addUserUnit( }; } +/*! +* Credits an item stack to the owning user's warehouse. +* +* Stacks are keyed by (user_id, item_id): a repeat drop increments item_num +* rather than creating a second row. Returns the resulting quantity. +* +* @param database Database client or transaction to use. +* @param identity Resolved user identity that owns the item. +* @param itemId Item master id to credit. +* @param quantity Amount to add (default 1). +* @return Number of affected rows. +*/ +inline drogon::Task> addUserItem( + const db::Database database, + const UserIdentity identity, + const uint32_t itemId, + const uint32_t quantity = 1) +{ + if (!database || identity.userId.empty() || itemId == 0) + { + LOG_ERROR << "Invalid addUserItem call: " + << "db=" << static_cast(database) + << ", user_id_empty=" << identity.userId.empty() + << ", item_id=" << itemId; + throw std::invalid_argument("Invalid addUserItem call"); + } + + // Bump the stack if it exists, else insert a new one. item_num accumulates + // rather than being replaced, so repeated grants stack. + co_return co_await db::DatabaseInterface::upsert( + database, + "user_items", + { + db::Data("user_id", identity.userId), + db::Data("item_id", itemId), + db::Data("item_num", quantity), + }, + { "user_id", "item_id" }, + { "item_num" }); +} + +/*! +* Returns any spheres equipped on soon-to-be-consumed units to the owner's +* warehouse. +* +* UnitSell / UnitMix / UnitEvo delete user_units rows (sold units, fusion +* fodder, evo materials). Spheres equipped on those units are owned items — +* deleting the row without this call would destroy them silently. Call BEFORE +* the DELETE, with the same pre-validated integer id list its IN clause uses. +* +* @param database Database client or transaction to use. +* @param identity Resolved user identity that owns the units. +* @param userUnitIdList Comma-joined user_unit_id list (validated integers). +*/ +inline drogon::Task returnEquippedSpheres( + const db::Database database, + const UserIdentity identity, + const std::string& userUnitIdList) +{ + if (!database || identity.userId.empty() || userUnitIdList.empty()) + co_return; + + // Callers hand us the same comma-joined id string their DELETE uses, so + // split it back into bound values rather than splicing it into SQL. When + // those DELETEs move onto the typed interface this should take the id + // vector directly and the round trip disappears. + db::Values userUnitIds; + for (size_t start = 0; start <= userUnitIdList.size();) + { + const auto end = userUnitIdList.find(',', start); + const auto token = userUnitIdList.substr( + start, end == std::string::npos ? std::string::npos : end - start); + if (!token.empty()) + { + userUnitIds.emplace_back( + static_cast(std::stoull(token))); + } + + if (end == std::string::npos) + { + break; + } + + start = end + 1; + } + + if (userUnitIds.empty()) + co_return; + + const auto result = co_await db::DatabaseInterface::read( + database, + "user_units", + { + db::Data("eqip_item_id"), + db::Data("eqip_item_id2"), + db::Lookup("user_id", identity.userId), + db::LookupIn("user_unit_id", userUnitIds), + }); + for (const auto& row : result.data) + { + for (const auto col : { "eqip_item_id", "eqip_item_id2" }) + { + const auto itemId = row[col].as(); + if (itemId != 0) + { + co_await addUserItem(database, identity, itemId, 1); + } + } + } +} + +/*! +* Maps a numeric element id (UnitMst.element) to the string form stored in +* user_units.element. +* +* @param id Element id 1-6. +* @return Element name; "fire" for out-of-range ids. +*/ +inline std::string_view elementIdToString(const int32_t id) +{ + switch (id) + { + case 2: return "water"; + case 3: return "earth"; + case 4: return "thunder"; + case 5: return "light"; + case 6: return "dark"; + default: return "fire"; + } +} + +/*! +* Grants a fresh level-1 unit to the user from its UnitMst row. +* +* Column mapping mirrors the debug CLI's InsertUnitFromMst (the proven insert +* shape for this schema): base stats from the MST minimums, skill levels 10 +* when the unit has the skill, and a random unit type (1-6) — types are rolled +* on acquisition, matching live behaviour. Reward flows (CampaignReceipt) use +* this for present_type=6 unit rewards. +* +* @param database Database client or transaction to use. +* @param identity Resolved user identity that receives the unit. +* @param unit Unit master row to instantiate. +*/ +inline drogon::Task addUserUnit( + const db::Database database, + const UserIdentity identity, + const UnitMst& unit) +{ + if (!database || identity.userId.empty()) + { + LOG_ERROR << "Invalid addUserUnit call: " + << "db=" << static_cast(database) + << ", user_id_empty=" << identity.userId.empty(); + throw std::invalid_argument("Invalid addUserUnit call"); + } + + const int32_t skillLv = unit.skill_id > 0 ? 10 : 0; + const int32_t extraSkillLv = unit.extra_skill_id > 0 ? 10 : 0; + + int32_t unitType = 1; + { + std::lock_guard lock(RandomMutex()); + unitType = std::uniform_int_distribution(1, 6)(RandomEngine()); + } + + co_await database->execSqlCoro( + "INSERT INTO user_units " + "(user_id, unit_id, unit_lvl," + " base_hp, add_hp, ext_hp, limit_over_hp," + " base_atk, add_atk, ext_atk, limit_over_atk," + " base_def, add_def, ext_def, limit_over_def," + " base_rec, base_rec,add_rec,ext_rec,limit_over_rec," + " exp, total_exp," + " skill_id, skill_lv, extra_skill_id, extra_skill_lv, leader_skill_id," + " element, fe_bp, fe_max_usable_bp, unit_type_id) " + "VALUES ($1,$2,1," + " $3,0,0,0, $4,0,0,0, $5,0,0,0," + " $6,$6,0,0,0," + " 1,1," + " $7,$8,$9,$10,$11," + " $12,100,200,$13);", + identity.userId, std::to_string(unit.id), + unit.min_hp, unit.min_atk, unit.min_def, unit.min_rec, + unit.skill_id, skillLv, unit.extra_skill_id, extraSkillLv, unit.leader_skill_id, + std::string(elementIdToString(unit.element)), + unitType); +} + /*! * Looks up player progression MST data for a specific user level. * @@ -301,6 +493,8 @@ inline drogon::Task> getTeamInfo( { packet.deck_cost = mst->deck_cost; packet.max_action_point = mst->energy; + packet.max_friend_count = mst->friend_count; + packet.add_friend_count = mst->add_friend_count; } // Calculate the current energy points of the user. @@ -396,4 +590,5 @@ inline drogon::Task> getUserIdentity( }; } + } diff --git a/gimuserver/gme/handlers/FriendGet.cpp b/gimuserver/gme/handlers/FriendGet.cpp index 282102b..bb1c655 100644 --- a/gimuserver/gme/handlers/FriendGet.cpp +++ b/gimuserver/gme/handlers/FriendGet.cpp @@ -1,18 +1,227 @@ #include "App.hpp" #include "Handlers.hpp" +#include + +#include + +// FriendGet (2o4axPIC) — fires when the player opens the Reinforcement / +// "Choose a Helper" screen during squad selection (and also after +// MissionEnd to refresh the friend list for the next mission). +// +// Wire shape: the captured production response (BF-WorkingDir/server/deploy/ +// log_res/2o4axPIC_*.json) emits {"xZH6EIQ7":[...]} — so the canonical +// response key is xZH6EIQ7 (populates ReinforcementInfoList). Per the IDA +// dispatch table (tools/ida/audits/tojMy68W_audit.txt around line 114-117), +// the binary's master GameResponseParser::getResponseObject maps xZH6EIQ7 → +// ReinforcementInfoResponse → its readParam populates ReinforcementInfoList, +// which the picker is the only confirmed consumer of for the reinforce/ +// helper UI. +// +// We also emit tojMy68W (FriendInfoResponse → FriendInfoList) with the same +// source data so any other UI that reads from FriendInfoList (general +// friend roster, friend-detail dialog) sees a populated list too. Cheap to +// duplicate since both classes have nearly identical schemas; only the +// destination singleton differs. +// +// Request body carries a "StQIyohe":[{"jkldTrhL":"N"}] mode flag — observed +// values 0 and 2. Probably toggles between full friend list and +// reinforce-eligible-only. We currently ignore the mode and always return +// the same single fake friend. +// +// Offline-server stand-in: one entry sourced from the player's active-deck +// leader (fall back to highest-level unit). When a curated pre-made friend +// set lands later, replace the single-row SQL with a loop emitting multiple +// entries to both arrays. + +// Helper: convert user_units.element string ("fire"/"water"/...) to the +// integer element id FriendInfo / ReinforcementInfo expect. +static int32_t friendGet_elementToInt(const std::string& s) +{ + if (s == "fire") return 1; + if (s == "water") return 2; + if (s == "earth") return 3; + if (s == "thunder") return 4; + if (s == "light") return 5; + if (s == "dark") return 6; + return 1; +} + HANDLEF(FriendGet) { - FriendGetResp resp{}; - - std::string buffer{}; - const auto& ec2 = glz::write_json(resp, buffer); - if (ec2) - { - const auto& glze = glz::format_error(ec2, buffer); - LOG_DEBUG << "Gme FriendGet Error during JSON writing: " << glze; - co_return HandleResult::error("Serialization error", glze); - } - - co_return HandleResult::success(buffer); + (void)session; + LOG_INFO << "FriendGet: " << json; + + // Identity-only request (no body beyond the login-info tag). + FriendGetReq req = {}; + { + glz::context ctx{}; + if (const auto& ec = glz::read(req, json, ctx); ec) + LOG_WARN << "FriendGet: 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; + + FriendGetResp resp{}; + + try + { + // Active-deck leader first, fall back to highest-level unit so the + // friend slot always has data. Matches MissionStart's query shape. + auto rows = co_await theDb()->execSqlCoro( + "SELECT uu.user_unit_id, uu.unit_id, uu.unit_lvl," + " uu.base_hp, uu.add_hp, uu.ext_hp," + " uu.base_atk, uu.add_atk, uu.ext_atk," + " uu.base_def, uu.add_def, uu.ext_def," + " uu.base_rec,uu.add_rec,uu.ext_rec," + " uu.skill_id, uu.skill_lv, uu.extra_skill_id, uu.extra_skill_lv," + " uu.unit_type_id, uu.element" + " FROM user_decks pd" + " JOIN user_units uu ON uu.user_unit_id = pd.user_unit_id" + " JOIN user_info ui ON ui.id = pd.user_id" + " WHERE pd.user_id=$1 AND pd.deck_num=ui.active_deck AND pd.member_type=0" + " LIMIT 1;", + std::string(kUserId)); + + if (rows.empty()) + { + rows = 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 (!rows.empty()) + { + const auto& r = rows[0]; + + // user_units.unit_id may carry "_100" suffix on evolved forms; + // strip for the MST id on the wire. + 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 (...) {} + + const int32_t elemId = friendGet_elementToInt(r["element"].as()); + const int32_t unitLv = r["unit_lvl"].as(); + const int32_t baseHp = r["base_hp"].as(); + const int32_t addHp = r["add_hp"].as(); + const int32_t extHp = r["ext_hp"].as(); + const int32_t baseAtk = r["base_atk"].as(); + const int32_t addAtk = r["add_atk"].as(); + const int32_t extAtk = r["ext_atk"].as(); + const int32_t baseDef = r["base_def"].as(); + const int32_t addDef = r["add_def"].as(); + const int32_t extDef = r["ext_def"].as(); + const int32_t baseHeal = r["base_rec"].as(); + const int32_t addHeal = r["add_rec"].as(); + const int32_t extHeal = r["ext_rec"].as(); + const int32_t skillId = r["skill_id"].as(); + const int32_t skillLv = r["skill_lv"].as(); + const int32_t extraSkillId = r["extra_skill_id"].as(); + const int32_t extraSkillLv = r["extra_skill_lv"].as(); + const int32_t unitTypeId = r["unit_type_id"].as(); + const int32_t loginTimestamp = static_cast(std::time(nullptr)); + + // === ReinforcementInfo entry (xZH6EIQ7) — the production-canonical + // shape for FriendGet responses. + ReinforcementInfo ri{}; + ri.user_id = "n9ZMPC0t"; // placeholder friend account id + ri.handle_name = "DecompFriend"; + ri.team_lv = 999; + ri.target_lv = unitLv; + ri.friend_type = 1; // 1 = friend (UNVERIFIED enum) + ri.last_login_date = loginTimestamp; + ri.unit_id = mstId; + ri.base_hp = baseHp; + ri.add_hp = addHp; + ri.ext_hp = extHp; + ri.base_atk = baseAtk; + ri.add_atk = addAtk; + ri.ext_atk = extAtk; + ri.base_def = baseDef; + ri.add_def = addDef; + ri.ext_def = extDef; + ri.base_heal = baseHeal; + ri.add_heal = addHeal; + ri.ext_heal = extHeal; + ri.friend_point = 0; + ri.normal_friend_point = 0; + ri.skill_id = skillId; + ri.skill_lv = skillLv; + ri.unit_type_id = unitTypeId; + ri.extra_skill_id = extraSkillId; + ri.extra_skill_lv = extraSkillLv; + ri.user_unit_id = 999999; // fake friend-row id + ri.mission_id = ""; + resp.reinforce_info.emplace_back(std::move(ri)); + + // === FriendInfo entry (tojMy68W) — same data into FriendInfoList + // for any UI consumer that reads from there. + FriendInfo fi{}; + fi.user_id = "n9ZMPC0t"; + fi.handle_name = "DecompFriend"; + fi.team_lv = 999; + fi.friend_type = 1; + fi.last_login_date = loginTimestamp; + fi.unit_id = mstId; + fi.unit_lv = unitLv; + fi.base_hp = baseHp; + fi.add_hp = addHp; + fi.ext_hp = extHp; + fi.base_atk = baseAtk; + fi.add_atk = addAtk; + fi.ext_atk = extAtk; + fi.base_def = baseDef; + fi.add_def = addDef; + fi.ext_def = extDef; + fi.base_heal = baseHeal; + fi.add_heal = addHeal; + fi.ext_heal = extHeal; + fi.skill_id = std::to_string(skillId); + fi.skill_lv = skillLv; + fi.extra_skill_id = std::to_string(extraSkillId); + fi.extra_skill_lv = extraSkillLv; + fi.unit_type_id = unitTypeId; + fi.element = elemId; + fi.friend_id = "DECOMP01"; + fi.friend_message = "GG WP"; + fi.favorite = 1; + fi.priority = 1; + fi.deck_no = 0; + fi.guild_id = 0; + resp.friend_info.emplace_back(std::move(fi)); + + LOG_INFO << "FriendGet: returning 1 friend (unit " << mstId + << " lv " << unitLv << ") under both xZH6EIQ7 and tojMy68W"; + } + else + { + LOG_WARN << "FriendGet: no units in inventory — returning empty friend list"; + } + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "FriendGet: friend query failed: " << ex.base().what(); + } + + std::string buffer{}; + if (const auto& ec = glz::write_json(resp, buffer); ec) + { + const auto& glze = glz::format_error(ec, buffer); + LOG_ERROR << "FriendGet: serialization error: " << glze; + co_return HandleResult::error("Serialization error", glze); + } + + co_return HandleResult::success(buffer); }