From 6cee1e1522148013ad7dc55c906ac9314ce00f01 Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Wed, 5 Aug 2026 20:31:57 +0000 Subject: [PATCH 1/5] db: ordered migrations, user_items table, and shared inventory helpers Migrations previously lived in an unordered_map and therefore ran in hash order. They are now a vector so they run in declaration order, with a startup guard that aborts on a duplicate migration name to preserve the uniqueness the map gave for free. Adds 02072026_ExtendUserUnitsForUnitOps: 26 additive columns on user_units covering per-unit stats, the two sphere equipment slots and the favourite flag. This consolidates three earlier fork migrations onto the upstream table shape. Upstream columns (unit_lvl, base_rec, bb_*) remain the source of truth for upstream handlers; these serve handlers not yet moved to PacketInterface and are intended to be consolidated away as each one moves. Adds 05072026_CreateUserItemsTable: one row per item stack, keyed UNIQUE(user_id, item_id), with instance_id as the warehouse row id the client references. Common.hpp gains two helpers. addUserItem() upserts a stack, incrementing item_num when the species is already owned. returnEquippedSpheres() reads the sphere slots off units that are about to be deleted and credits them back to the warehouse; it must be called before the DELETE in any handler that consumes units, or the spheres are destroyed silently. --- gimuserver/db/MigrationManager.cpp | 157 ++++++++++++++++++++++- gimuserver/db/PacketInterfaceSchemas.hpp | 93 +++++++++++++- gimuserver/gme/common/Common.hpp | 84 ++++++++++++ 3 files changed, 330 insertions(+), 4 deletions(-) diff --git a/gimuserver/db/MigrationManager.cpp b/gimuserver/db/MigrationManager.cpp index 0abbb5b..5e190f7 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,67 @@ static void RegisterMigrations(MigrationMap& map) ); }); + // 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 +172,82 @@ 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)" + ");" + ); + }); } /*! @@ -143,6 +281,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..610747a 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, }), @@ -129,7 +163,7 @@ PacketInterfaceFor<::UserUnitInfo>::fields() .update = true, .insert = true, }), - field<&::UserUnitInfo::unit_lvl>("unit_lvl", { + field<&::UserUnitInfo::unit_lvl>("unit_lv", { .read = true, .update = true, .insert = 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, @@ -169,7 +206,7 @@ PacketInterfaceFor<::UserUnitInfo>::fields() .update = true, .insert = true, }), - field<&::UserUnitInfo::ext_rec>("ext_rec", { + field<&::UserUnitInfo::ext_rec>("ext_heal", { .read = true, .update = true, .insert = 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_heal", { .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_heal", { .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/gme/common/Common.hpp b/gimuserver/gme/common/Common.hpp index 6a40ff6..757198e 100644 --- a/gimuserver/gme/common/Common.hpp +++ b/gimuserver/gme/common/Common.hpp @@ -91,6 +91,87 @@ 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"); + } + + // UPSERT: bump the stack if it exists, else insert a new one. item_id and + // quantity are server-trusted integers, safe to bind. + auto result = co_await database->execSqlCoro( + "INSERT INTO user_items (user_id, item_id, item_num) VALUES ($1, $2, $3) " + "ON CONFLICT(user_id, item_id) " + "DO UPDATE SET item_num = item_num + $3;", + identity.userId, + itemId, + quantity); + + co_return db::InterfaceResult<>{ + .data = {}, + .affected = result.affectedRows(), + }; +} + +/*! +* 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; + + const auto rows = co_await database->execSqlCoro( + "SELECT eqip_item_id, eqip_item_id2 FROM user_units " + "WHERE user_id = $1 AND user_unit_id IN (" + userUnitIdList + ");", + identity.userId); + for (const auto& row : rows) + { + 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); + } + } + } +} + /*! * Looks up player progression MST data for a specific user level. * @@ -301,6 +382,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 +479,5 @@ inline drogon::Task> getUserIdentity( }; } + } From 010d603bfc4c10d64cbb76e0a168cc4fc788a6d6 Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Thu, 6 Aug 2026 18:42:20 -0400 Subject: [PATCH 2/5] db: add upsert + IN predicates so Common.hpp can drop its raw SQL Addresses the review comments on this PR ("dont use co_await database->execSqlCoro(, use the existing API"). Both flagged call sites used SQL the typed interface could not express, which is why they bypassed it, so this extends the interface instead of routing around it. * DatabaseInterface::upsert(db, table, cells, conflict, accumulate) INSERT ... ON CONFLICT(...) DO UPDATE. Columns in `accumulate` are added to (col = col + excluded.col), the rest replaced. Plain insert only ever ignored conflicts, so addUserItem's "add to the stack I already own" had no expressible form. * db::LookupIn(name, values) -> WHERE col IN ($1, $2, ...), one placeholder per value; an empty list throws rather than letting a caller splice ids into SQL. read/update/remove now share a single buildWhere() that handles equality and IN with correct placeholder numbering. addUserItem and returnEquippedSpheres now go through the interface. Verified past compiling: the emitted statements were run against a copy of deploy/gme.sqlite. Two grants of 3 then 4 leave item_num = 7, confirming the conflict clause accumulates rather than replaces. Two deliberate leftovers: * addDefaultDecks still uses execSqlCoro. It is a recursive CTE that generates ten deck rows in one INSERT...SELECT; the interface is row-oriented and cannot express it. Converting it would mean ten round trips to satisfy a rule, which is worse. Flagging rather than hiding it. * returnEquippedSpheres still takes the comma-joined id string its callers build for their own DELETEs and splits it back into bound values. That round trip disappears when those DELETEs move onto the interface. --- gimuserver/db/DatabaseInterface.cpp | 147 ++++++++++++++++++++++++---- gimuserver/db/DatabaseInterface.h | 58 +++++++++++ gimuserver/db/Types.h | 24 +++++ gimuserver/gme/common/Common.hpp | 68 +++++++++---- 4 files changed, 260 insertions(+), 37 deletions(-) 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/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 757198e..76309af 100644 --- a/gimuserver/gme/common/Common.hpp +++ b/gimuserver/gme/common/Common.hpp @@ -118,20 +118,18 @@ inline drogon::Task> addUserItem( throw std::invalid_argument("Invalid addUserItem call"); } - // UPSERT: bump the stack if it exists, else insert a new one. item_id and - // quantity are server-trusted integers, safe to bind. - auto result = co_await database->execSqlCoro( - "INSERT INTO user_items (user_id, item_id, item_num) VALUES ($1, $2, $3) " - "ON CONFLICT(user_id, item_id) " - "DO UPDATE SET item_num = item_num + $3;", - identity.userId, - itemId, - quantity); - - co_return db::InterfaceResult<>{ - .data = {}, - .affected = result.affectedRows(), - }; + // 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" }); } /*! @@ -155,11 +153,43 @@ inline drogon::Task returnEquippedSpheres( if (!database || identity.userId.empty() || userUnitIdList.empty()) co_return; - const auto rows = co_await database->execSqlCoro( - "SELECT eqip_item_id, eqip_item_id2 FROM user_units " - "WHERE user_id = $1 AND user_unit_id IN (" + userUnitIdList + ");", - identity.userId); - for (const auto& row : rows) + // 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" }) { From 1d62d462c2ee4ab07f294ee16847f1ca3df7352a Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Thu, 6 Aug 2026 19:36:06 -0400 Subject: [PATCH 3/5] db: one column per stat in user_units, rec/lvl vocabulary Addresses both schema comments on this PR -- "a lot of it seems like duplicates of user units" on the 02072026 migration, and "why the rename?" on PacketInterfaceSchemas mapping ext_rec to "ext_heal". Same root cause. The client names the same stat differently in different packets: UserUnitInfo says base_rec / ext_rec / unit_lvl, while FriendInfo, ReinforcementInfo, FixedReinforcementInfo and UnitReinforceEntry say base_heal / ext_heal / add_heal / unit_lv. Both spellings are the client's, carried from IDA (see net/friends.kdl, whose field docs cite vtable offsets). A column was added per SPELLING, so one unit could hold two recovery values free to disagree. Now one column per concept. rec/lvl wins because 08032025_CreateUserUnitsTable established it. PacketInterfaceFor maps either packet vocabulary onto the one column, which is what that indirection is for -- so a field<> whose packet and column names differ is correct here, not a smell. Packet field names are untouched; they are the client's, not ours to normalise. 06082026_ConsolidateUserUnitStatColumns merges duplicate values into the canonical column, DROPs unit_lv/base_heal/ext_heal and RENAMEs add_heal -> add_rec, limit_over_heal -> limit_over_rec. 02072026 is deliberately left as written, with a comment pointing forward: applied migrations are recorded by name, so editing it would change nothing for existing databases while diverging fresh ones. Merge rule: the duplicate wins only where the canonical column is still at its default, since quests-branch handlers wrote duplicates while upstream handlers wrote canonicals. Verified against a real database before writing it -- 0 rows held conflicting values and all 83 carried their value in a column being dropped, so the merge was lossless. After migrating: 44 columns -> 41, all 83 rows kept their level. Call sites were rewritten only inside quoted strings, since these identifiers appear as both a SQL column and a generated packet field, sometimes on one line (rd.base_heal = br["base_heal"]). A blanket find/replace would have silently broken four generated structs. The matching handler changes land on the branches that own those files: UnitEvo/UnitMix on split/05-units, CampaignBattleStart on split/10-campaign. --- gimuserver/db/MigrationManager.cpp | 44 +++++ gimuserver/db/PacketInterfaceSchemas.hpp | 8 +- gimuserver/gme/common/Common.hpp | 81 ++++++++ gimuserver/gme/handlers/FriendGet.cpp | 233 +++++++++++++++++++++-- 4 files changed, 350 insertions(+), 16 deletions(-) diff --git a/gimuserver/db/MigrationManager.cpp b/gimuserver/db/MigrationManager.cpp index 5e190f7..3faf3a6 100644 --- a/gimuserver/db/MigrationManager.cpp +++ b/gimuserver/db/MigrationManager.cpp @@ -100,6 +100,14 @@ 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 @@ -248,6 +256,42 @@ static void RegisterMigrations(MigrationMap& map) ");" ); }); + + // 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;"); + }); } /*! diff --git a/gimuserver/db/PacketInterfaceSchemas.hpp b/gimuserver/db/PacketInterfaceSchemas.hpp index 610747a..3f33a44 100644 --- a/gimuserver/db/PacketInterfaceSchemas.hpp +++ b/gimuserver/db/PacketInterfaceSchemas.hpp @@ -163,7 +163,7 @@ PacketInterfaceFor<::UserUnitInfo>::fields() .update = true, .insert = true, }), - field<&::UserUnitInfo::unit_lvl>("unit_lv", { + field<&::UserUnitInfo::unit_lvl>("unit_lvl", { .read = true, .update = true, .insert = true, @@ -206,7 +206,7 @@ PacketInterfaceFor<::UserUnitInfo>::fields() .update = true, .insert = true, }), - field<&::UserUnitInfo::ext_rec>("ext_heal", { + field<&::UserUnitInfo::ext_rec>("ext_rec", { .read = true, .update = true, .insert = true, @@ -239,11 +239,11 @@ PacketInterfaceFor<::UserUnitInfo>::fields() 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_heal", { .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_heal", { .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, }), diff --git a/gimuserver/gme/common/Common.hpp b/gimuserver/gme/common/Common.hpp index 76309af..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 @@ -202,6 +205,84 @@ inline drogon::Task returnEquippedSpheres( } } +/*! +* 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. * 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); } From 40071e715b3b3dc4394ba5892a06281bac6145a4 Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Thu, 6 Aug 2026 22:57:58 -0400 Subject: [PATCH 4/5] db: stop persisting fe_bp/fe_max_usable_bp Applies the review's own test -- add a field only when we understand what it does AND the client demonstrably needs it -- to the two fields the review named. Both fail it. fe_bp and fe_max_usable_bp were INSERTed as the literal constants 100 and 200, read straight back into the packet, and never computed from or consumed by any handler; Frontier Evolution is not implemented. The KDL fields stay, so the client still receives both keys (defaulting to 0); only the per-user persistence goes. Re-add them alongside the subsystem that gives them meaning, when the stored values will mean something. 06082026_DropUnusedFeBpColumns removes the columns. 02072026 keeps its ADD lines so databases that already ran it and fresh ones converge, same pattern as the consolidation migration above it. Also drops a duplicate base_rec from the addUserUnit column list, left over from the rec/heal consolidation. SQLite tolerates a repeated column in an INSERT list and both values were the same bind, so nothing was written incorrectly -- the list just no longer matched intent. Verified on a real database: user_units 41 -> 39 columns, all 83 rows intact, server boots. Not dropped despite looking constant in a dev save: eqip_item_* (spheres -- returnEquippedSpheres depends on them), add_*/ext_*/limit_over_* (enhancement state, merely unexercised), user_id. A single-user database makes unused instance state look derivable, so column variance is a prompt to check a field's meaning rather than evidence on its own. The matching handler changes are on split/05-units. --- gimuserver/db/MigrationManager.cpp | 13 +++++++++++++ gimuserver/db/PacketInterfaceSchemas.hpp | 2 -- gimuserver/gme/common/Common.hpp | 8 ++++---- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/gimuserver/db/MigrationManager.cpp b/gimuserver/db/MigrationManager.cpp index 3faf3a6..4e2048d 100644 --- a/gimuserver/db/MigrationManager.cpp +++ b/gimuserver/db/MigrationManager.cpp @@ -292,6 +292,19 @@ static void RegisterMigrations(MigrationMap& map) p->execSqlSync("ALTER TABLE user_units " "RENAME COLUMN limit_over_heal TO limit_over_rec;"); }); + + // Drops two columns that failed the "do we understand it, does the client + // need it" test (handbook §6.15). fe_bp / fe_max_usable_bp were only ever + // INSERTed as the literals 100 and 200 and read straight back into the + // packet — never computed from anything, never consumed by any handler, + // and Frontier Evolution is not implemented. The packet FIELDS stay in the + // KDL, so the client still receives the keys (defaulting to 0); only the + // per-user persistence goes. Re-add them with the subsystem that needs + // them, at which point the values will mean something. + migrate("06082026_DropUnusedFeBpColumns", { + p->execSqlSync("ALTER TABLE user_units DROP COLUMN fe_bp;"); + p->execSqlSync("ALTER TABLE user_units DROP COLUMN fe_max_usable_bp;"); + }); } /*! diff --git a/gimuserver/db/PacketInterfaceSchemas.hpp b/gimuserver/db/PacketInterfaceSchemas.hpp index 3f33a44..0eb0ce2 100644 --- a/gimuserver/db/PacketInterfaceSchemas.hpp +++ b/gimuserver/db/PacketInterfaceSchemas.hpp @@ -246,8 +246,6 @@ PacketInterfaceFor<::UserUnitInfo>::fields() 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, }), diff --git a/gimuserver/gme/common/Common.hpp b/gimuserver/gme/common/Common.hpp index af33c34..8facaef 100644 --- a/gimuserver/gme/common/Common.hpp +++ b/gimuserver/gme/common/Common.hpp @@ -266,16 +266,16 @@ inline drogon::Task addUserUnit( " 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," + " 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) " + " element, unit_type_id) " "VALUES ($1,$2,1," " $3,0,0,0, $4,0,0,0, $5,0,0,0," - " $6,$6,0,0,0," + " $6,0,0,0," " 1,1," " $7,$8,$9,$10,$11," - " $12,100,200,$13);", + " $12,$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, From 3971a9624eac7656b20640096a26f57e69d9c3f2 Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Thu, 6 Aug 2026 23:03:48 -0400 Subject: [PATCH 5/5] db: stop persisting leader_skill_id, read it from the MST Completes the field-bloat comment, which named fe_bp and leader_skill_id. leader_skill_id is species data -- every copy of a unit has the same leader skill -- so it belongs to UnitMst keyed by unit_id, not to a per-user row. UnitEvo already sourced it from targetMst; UnitMix was the only reader of the stored copy and now takes it from the MST too (it already held the handle for element/exp_pattern/max_lv, so no extra lookup). The packet field stays, so the client still receives the key. Only the redundant copy goes, removing one more place the database can drift from the MST. Verified on a real database: user_units 39 -> 38 columns, 83 rows intact, server boots. UserUnitInfo persistence is now 34 of 47 packet fields, down from 37. The matching UnitMix/UnitEvo changes are on split/05-units. --- gimuserver/db/MigrationManager.cpp | 9 +++++++++ gimuserver/db/PacketInterfaceSchemas.hpp | 1 - gimuserver/gme/common/Common.hpp | 8 ++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/gimuserver/db/MigrationManager.cpp b/gimuserver/db/MigrationManager.cpp index 4e2048d..8aeeeda 100644 --- a/gimuserver/db/MigrationManager.cpp +++ b/gimuserver/db/MigrationManager.cpp @@ -305,6 +305,15 @@ static void RegisterMigrations(MigrationMap& map) p->execSqlSync("ALTER TABLE user_units DROP COLUMN fe_bp;"); p->execSqlSync("ALTER TABLE user_units DROP COLUMN fe_max_usable_bp;"); }); + + // leader_skill_id is SPECIES data: every copy of a unit has the same leader + // skill, so it belongs to UnitMst, not to a per-user row. UnitEvo already + // sourced it from targetMst; UnitMix now does the same instead of copying + // the stored duplicate. The packet field stays — the client still gets the + // key — only the redundant per-user copy goes. + migrate("06082026_DropLeaderSkillIdColumn", { + p->execSqlSync("ALTER TABLE user_units DROP COLUMN leader_skill_id;"); + }); } /*! diff --git a/gimuserver/db/PacketInterfaceSchemas.hpp b/gimuserver/db/PacketInterfaceSchemas.hpp index 0eb0ce2..4033500 100644 --- a/gimuserver/db/PacketInterfaceSchemas.hpp +++ b/gimuserver/db/PacketInterfaceSchemas.hpp @@ -245,7 +245,6 @@ PacketInterfaceFor<::UserUnitInfo>::fields() 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::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, }), diff --git a/gimuserver/gme/common/Common.hpp b/gimuserver/gme/common/Common.hpp index 8facaef..452c683 100644 --- a/gimuserver/gme/common/Common.hpp +++ b/gimuserver/gme/common/Common.hpp @@ -268,17 +268,17 @@ inline drogon::Task addUserUnit( " base_def, add_def, ext_def, limit_over_def," " 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," + " skill_id, skill_lv, extra_skill_id, extra_skill_lv," " element, unit_type_id) " "VALUES ($1,$2,1," " $3,0,0,0, $4,0,0,0, $5,0,0,0," " $6,0,0,0," " 1,1," - " $7,$8,$9,$10,$11," - " $12,$13);", + " $7,$8,$9,$10," + " $11,$12);", 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, + unit.skill_id, skillLv, unit.extra_skill_id, extraSkillLv, std::string(elementIdToString(unit.element)), unitType); }