Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 158 additions & 8 deletions gimuserver/gme/handlers/UserInfo.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#include <gimuserver/db/PacketInterface.hpp>
#include <gimuserver/gme/common/Common.hpp>

#include <ctime>

HANDLEF(UserInfo)
{
UserInfoReq req = {};
Expand Down Expand Up @@ -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<UserWarehouseInfo>::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<UserWarehouseInfo> 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 <param> 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<std::string>());
}
catch (...)
{
continue;
}
cleared.clear_cnt = row["clear_count"].as<int32_t>();
if (const auto epoch = row["last_cleared_at"].as<int64_t>(); 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<time_t>(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<int32_t>(),
.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<int32_t>(),
.lv = row["lv"].as<int32_t>(),
.karma = row["karma"].as<int32_t>(),
});
}

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<int32_t>();
resp.town_location_info.push_back(UserTownLocationInfo{
.user_id = identity.userId,
.location_id = locationId,
.lv = row["lv"].as<int32_t>(),
.karma = row["karma"].as<int32_t>(),
});
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;
Expand Down