From debe703a5645002dd5353f13f14b283a8d5ec437 Mon Sep 17 00:00:00 2001 From: Evan Martine Date: Wed, 5 Aug 2026 20:31:58 +0000 Subject: [PATCH] handlers: report inventory, unit, town and campaign state from UserInfo Expands the UserInfo response to report the state the preceding handlers persist. Every block reads from a table created in the schema PR, so this is additive and returns empty arrays on a fresh account. Owned items populate warehouse_info, equip_info, item_dictionary_info and item_favorite from user_items, replacing the hardcoded potion and its TODO. Zero-quantity stacks are filtered off the wire but still feed the dictionary, so a species stays in the encyclopedia once seen. Cleared-mission history (UT1SVg59) comes from user_campaign_missions. This is the progression driver: the client evaluates feature unlocks against this list, so anything gated on "mission N cleared" stays locked without it. Favorited units (3kcmQy7B) are reported back so UnitFavorite locks survive a reload. Town state emits the three arrays together. Every entry in town_location_info requires a matching town_location_detail entry, because the client dereferences the detail for each location and an empty detail array crashes the town scene loader. Collected here rather than spread across the item, unit, town and campaign PRs so that only one pull request touches this file. --- gimuserver/gme/handlers/UserInfo.cpp | 166 +++++++++++++++++++++++++-- 1 file changed, 158 insertions(+), 8 deletions(-) diff --git a/gimuserver/gme/handlers/UserInfo.cpp b/gimuserver/gme/handlers/UserInfo.cpp index 43d5167..c7a11c7 100644 --- a/gimuserver/gme/handlers/UserInfo.cpp +++ b/gimuserver/gme/handlers/UserInfo.cpp @@ -4,6 +4,8 @@ #include #include +#include + HANDLEF(UserInfo) { UserInfoReq req = {}; @@ -57,16 +59,164 @@ HANDLEF(UserInfo) "user_unit_dictionary", { db::Lookup("user_id", identity.userId) })).data); - // TODO: Properly do this with an items SQL table. For now, to get past the - // tutorial, hard code it. - resp.equip_info = { - UserEquipItemInfo{ - .item_id = 20000, - .disp_order = 0, - .item_num = 1, - }, + // Owned items now come from the user_items table (seeded by the tutorial, + // grown by mission drops) instead of the old hardcoded potion. The full + // inventory populates warehouse_info; battle-consumable items (ItemMst + // item_type == 1, e.g. the tutorial healing potion) also populate + // equip_info so they appear in the mission item bar. Stacks at 0 keep + // their row (ItemSell / ItemSphereEqp decrement without deleting so + // instance ids stay stable) but are filtered off the wire; every species + // ever stacked still feeds the item dictionary, and favorited stacks are + // reported through item_favorite (VSRPkdId) so locks survive a reload. + auto warehouse = (co_await db::PacketInterfaceFor::read( + db, + "user_items", + { db::Lookup("user_id", identity.userId) })).data; + + const auto& itemMst = theServer()->cache().itemMst(); + const auto isBattleConsumable = [&itemMst](uint32_t itemId) { + for (const auto& m : itemMst) + { + if (m.id == itemId) + { + return m.item_type == 1; + } + } + return false; }; + uint32_t equipSlot = 0; + std::vector visibleStacks; + visibleStacks.reserve(warehouse.size()); + for (auto& stack : warehouse) + { + resp.item_dictionary_info.push_back(UserItemDictionaryInfo{ + .item_id = stack.item_id, + }); + + if (stack.item_num == 0) + continue; + + if (stack.favorite_flg != 0) + { + // "::" — the packet struct, not GmeHandlers::ItemFavorite (the + // handler declared in Handlers.hpp shadows it in this scope). + resp.item_favorite.push_back(::ItemFavorite{ + .instance_id = stack.instance_id, + .favorite = 1, + }); + } + + if (isBattleConsumable(stack.item_id)) + { + resp.equip_info.push_back(UserEquipItemInfo{ + .item_id = stack.item_id, + .disp_order = equipSlot++, + .item_num = stack.item_num, + }); + } + + visibleStacks.push_back(std::move(stack)); + } + resp.warehouse_info = std::move(visibleStacks); + + // Cleared-mission history (UT1SVg59) — THE progression driver. The client + // evaluates feature unlocks against this list: F_FUNCTION_RELEASE_MST rows + // (condition type 2 = "mission cleared") gate functions 8-19, and + // the hardcoded early-feature gates (town etc.) key off the same set plus + // tutorial_status. Backed by user_campaign_missions state=2 rows, written + // by MissionEnd and CampaignBattleEnd. + { + const auto rows = co_await db->execSqlCoro( + "SELECT mission_id, clear_count, last_cleared_at" + " FROM user_campaign_missions WHERE user_id = $1 AND state = 2;", + identity.userId); + for (const auto& row : rows) + { + ::UserClearMissionInfo cleared = {}; + cleared.user_id = identity.userId; + try + { + cleared.mission_id = std::stoi(row["mission_id"].as()); + } + catch (...) + { + continue; + } + cleared.clear_cnt = row["clear_count"].as(); + if (const auto epoch = row["last_cleared_at"].as(); epoch > 0) + { + // setClearDate is a string setter; "YYYY-MM-DD hh:mm:ss" until a + // capture proves otherwise (KDL doc marks it UNVERIFIED). + std::tm tmv = {}; + const time_t t = static_cast(epoch); + localtime_s(&tmv, &t); + char buf[24] = {}; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tmv); + cleared.clear_date = buf; + } + resp.clear_mission_info.push_back(std::move(cleared)); + } + } + + // Favorited/locked units (3kcmQy7B) — UnitFavorite persists the flag; + // reporting it back makes locks survive a reload. + { + const auto rows = co_await db->execSqlCoro( + "SELECT user_unit_id FROM user_units" + " WHERE user_id = $1 AND favorite_flg = 1;", + identity.userId); + for (const auto& row : rows) + { + resp.favorite.push_back(::UserFavorite{ + .user_unit_id = row["user_unit_id"].as(), + .favorite = 1, + .unit_img_type = 0, + }); + } + } + + // Town state — the three arrays travel together (§6.8): every location in + // town_location_info needs a matching town_location_detail entry or the + // town scene loader null-derefs. Rows are provisioned by the town unlock + // (CLI `unlocktown` for now); a fresh account has none and sends empty + // arrays, which the client treats as town-not-available. + { + const auto facilityRows = co_await db->execSqlCoro( + "SELECT facility_id, lv, karma FROM user_town_facilities WHERE user_id = $1;", + identity.userId); + for (const auto& row : facilityRows) + { + resp.town_facility_info.push_back(UserTownFacilityInfo{ + .user_id = identity.userId, + .facility_id = row["facility_id"].as(), + .lv = row["lv"].as(), + .karma = row["karma"].as(), + }); + } + + const auto locationRows = co_await db->execSqlCoro( + "SELECT location_id, lv, karma FROM user_town_locations WHERE user_id = $1;", + identity.userId); + for (const auto& row : locationRows) + { + const auto locationId = row["location_id"].as(); + resp.town_location_info.push_back(UserTownLocationInfo{ + .user_id = identity.userId, + .location_id = locationId, + .lv = row["lv"].as(), + .karma = row["karma"].as(), + }); + resp.town_location_detail.push_back(UserTownLocationDetail{ + .user_id = identity.userId, + .unk = locationId, + .unk2 = {}, // epoch — tap period not yet started + .unk3 = 0, // no taps accumulated + .unk4 = "", + }); + } + } + resp.campaign_info.current_day = 1; resp.campaign_info.total_days = 96; resp.campaign_info.first_for_the_day = false;