diff --git a/deploy/archive/unit.json b/deploy/archive/unit.json new file mode 100644 index 0000000..b45c111 --- /dev/null +++ b/deploy/archive/unit.json @@ -0,0 +1,47 @@ +[ + { + "unit_id": 10011, + "lord_stats": { + "hp": 1948, + "atk": 700, + "def": 574, + "rec": 448 + } + }, + { + "unit_id": 20011, + "lord_stats": { + "hp": 1830, + "atk": 706, + "def": 468, + "rec": 693 + } + }, + { + "unit_id": 30011, + "lord_stats": { + "hp": 1876, + "atk": 720, + "def": 700, + "rec": 248 + } + }, + { + "unit_id": 40011, + "lord_stats": { + "hp": 2070, + "atk": 812, + "def": 420, + "rec": 210 + } + }, + { + "unit_id": 50253, + "lord_stats": { + "hp": 4500, + "atk": 1340, + "def": 1340, + "rec": 1680 + } + } +] diff --git a/deploy/config.json b/deploy/config.json index 2f9cbcd..46c1fc6 100644 --- a/deploy/config.json +++ b/deploy/config.json @@ -55,6 +55,7 @@ "game_version": 21900, // game version (must be this one or it might cry about an update) "notice_url": "http://ios21900.bfww.gumi.sg/pages/versioninfo", // notice url (this points to a drogon orm page) "mst_root": "./system", // MST cache directory + "archive_root": "./archive", // Server-owned curated archive data directory "initial_level": 1, "initial_free_gems": 5, "initial_zel": 10000000, diff --git a/generate.py b/generate.py index a529b79..9401086 100644 --- a/generate.py +++ b/generate.py @@ -5,24 +5,21 @@ import os from pathlib import Path -base_dir = "packet-generator/assets" -output_base = "gimuserver/packets" -kdl_file = "packet-generator/assets/all.kdl" +targets = [ + ("packet-generator/assets/all.kdl", "gimuserver/packets"), + ("packet-generator/assets/archive.kdl", "gimuserver/archive"), +] -p_kdl_file = Path(kdl_file) -kdl_dir = p_kdl_file.parent -relative_dir = os.path.relpath(kdl_dir, base_dir) -kdl_file_real = os.path.relpath(kdl_file, "packet-generator") -target_dir = os.path.join(output_base, relative_dir) +for kdl_file, target_dir in targets: + kdl_file_real = os.path.relpath(kdl_file, "packet-generator") + os.makedirs(target_dir, exist_ok=True) -os.makedirs(target_dir, exist_ok=True) - -try: - subprocess.run([ - "cargo", "run", "--", - "generate", "--cxx", "--glaze", - "-i", kdl_file_real, - "-o", f"../{target_dir}" - ], cwd="packet-generator", check=True) -except subprocess.CalledProcessError: - pass + try: + subprocess.run([ + "cargo", "run", "--", + "generate", "--cxx", "--glaze", + "-i", kdl_file_real, + "-o", f"../{target_dir}" + ], cwd="packet-generator", check=True) + except subprocess.CalledProcessError: + pass diff --git a/gimuserver/archive/.gitignore b/gimuserver/archive/.gitignore new file mode 100644 index 0000000..57d75cb --- /dev/null +++ b/gimuserver/archive/.gitignore @@ -0,0 +1 @@ +archive.hpp diff --git a/gimuserver/archive/UnitArchiver.cpp b/gimuserver/archive/UnitArchiver.cpp new file mode 100644 index 0000000..b812515 --- /dev/null +++ b/gimuserver/archive/UnitArchiver.cpp @@ -0,0 +1,101 @@ +#include "UnitArchiver.hpp" + +#include + +#include + +#include +#include +#include +#include + +UnitArchiver& UnitArchiver::instance() +{ + static UnitArchiver instance; + return instance; +} + +void UnitArchiver::setup(const Json::Value& serverObj) +{ + LOG_INFO << "Setting up unit archiver cache. " + "If you see this after initialization, it is a bug."; + + const auto archiveRoot = serverObj["archive_root"].asString(); + if (archiveRoot.empty()) + { + LOG_ERROR << "Unable to set up unit archiver cache: archive_root is empty"; + return; + } + + std::vector units; + try + { + units = LoadJson>(archiveRoot, "unit.json"); + } + catch (const std::exception& ex) + { + LOG_ERROR << "Unable to set up unit archiver cache: " << ex.what(); + return; + } + + cache_.clear(); + cache_.reserve(units.size()); + for (auto& unit : units) + { + cache_.insert_or_assign(unit.unit_id, std::move(unit)); + } +} + +std::optional UnitArchiver::lookup(UnitId unit_id) const +{ + if (unit_id == 0) + { + LOG_ERROR << "Invalid unit archive lookup: unit_id is 0"; + return std::nullopt; + } + + const auto it = cache_.find(unit_id); + if (it == cache_.end()) + { + LOG_ERROR << "Unable to find unit archive record " << unit_id; + return std::nullopt; + } + + return it->second; +} + +bool UnitArchiver::populatePacket( + const UnitRecord& unitRecord, + UnitType unit_type_id, + UserUnitInfo& unit) +{ + // Grab the stats for the given unit type. + const auto stats = unitTypeStats(unitRecord, unit_type_id); + if (!stats) + { + return false; + } + + unit.unit_id = unitRecord.unit_id; + unit.unit_type_id = unit_type_id; + unit.base_hp = stats->hp; + unit.base_atk = stats->atk; + unit.base_def = stats->def; + unit.base_rec = stats->rec; + return true; +} + +std::optional UnitArchiver::unitTypeStats( + const UnitRecord& unitRecord, + UnitType unit_type_id) +{ + switch (unit_type_id) + { + case 1: + return unitRecord.lord_stats; + default: + LOG_ERROR << "Unsupported unit type " << unit_type_id + << " for unit archive record " << unitRecord.unit_id; + return std::nullopt; + } +} diff --git a/gimuserver/archive/UnitArchiver.hpp b/gimuserver/archive/UnitArchiver.hpp new file mode 100644 index 0000000..8ab33b0 --- /dev/null +++ b/gimuserver/archive/UnitArchiver.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include + +/*! +* Loads and serves server-owned curated unit archive records. +* +* The archive is intentionally smaller than the official MST data. It contains +* only fields the server understands well enough to use when creating +* player-owned units. +*/ +class UnitArchiver final +{ +public: + using UnitId = uint32_t; + using UnitType = uint32_t; + + /*! + * Gets the process-wide unit archiver. + * + * Setup() must be called during server initialization before request handlers + * depend on archive data. + */ + static UnitArchiver& instance(); + + /*! + * Loads unit archive records from archive_root/unit.json. + * @param serverObj Server configuration object from the Drogon plugin config. + */ + void setup(const Json::Value& serverObj); + + /*! + * Looks up a unit archive record. + * + * @param unit_id Master unit ID from the unit archive. + * @return Matching immutable archive record, or std::nullopt when not found. + */ + std::optional lookup(UnitId unit_id) const; + + /*! + * Converts archive data to a subset of UserUnitInfo. + * + * Ownership fields such as user_id and user_unit_id are not modified. + * @param unitRecord Archive record to read from. + * @param unit_type_id Unit type to select from the archive stats. + * @param unit Packet/database object to populate. + * @return True if the packet was populated, false if the unit type is unsupported. + */ + static bool populatePacket( + const UnitRecord& unitRecord, + UnitType unit_type_id, + UserUnitInfo& unit); + + /*! + * Gets the stat block for a unit type. + * + * Currently only unit_type_id 1 is understood and maps to lord_stats. + * @return Matching stat block, or std::nullopt when the unit type is unsupported. + */ + static std::optional unitTypeStats( + const UnitRecord& unitRecord, + UnitType unit_type_id); + + UnitArchiver(const UnitArchiver&) = delete; + UnitArchiver& operator=(const UnitArchiver&) = delete; + +private: + using UnitRecordCache = std::unordered_map; + + UnitArchiver() = default; + + UnitRecordCache cache_; +}; diff --git a/gimuserver/controller/AccountController.cpp b/gimuserver/controller/AccountController.cpp index 363d141..0f212ba 100644 --- a/gimuserver/controller/AccountController.cpp +++ b/gimuserver/controller/AccountController.cpp @@ -1,6 +1,12 @@ #include "App.hpp" #include "AccountController.hpp" +#include +#include +#include + +#include + using namespace drogon; using namespace drogon::orm; @@ -18,46 +24,47 @@ Task<> AccountController::HandleGuest(HttpRequestPtr rq, std::functionexecSqlCoro("SELECT id FROM gumi_live_users WHERE id = $1", login.user_id); - if (result.empty()) { - /* - How should it work in an actual system that uses an external API for login? - - 1. Game client tries to login or make a new account "GuestLogin" - 2. Based from ak and other identifiers a userid and token is computed - 3. Token is passed back to the game - 4. The game will try to initialize with the gumi user token - 5. Once the token is validated, the initialize request should make a new user - - Here there is only the user creation step. - */ - - // NOTE: This should be de-hardcoded if someone plans to make a multiuser or multiprofile system. - - // User does not exist, add him to the table - co_await theDb()->execSqlCoro("INSERT INTO gumi_live_users(id) VALUES ($1);", login.user_id); - LOG_DEBUG << "AccountController: new user " << login.user_id; - } - - login.status = StatusEnum::Success; - login.token = "test_token"; // Note: Should we use a proper token with drogon session? - login.status_number = 0; - + login.user_id = currentGumiUser; } - catch (drogon::orm::DrogonDbException ex) + else { - LOG_ERROR << "AccountController: Query execution failed: " << ex.base().what(); + // No Gumi Live user exists yet, so we create one with a random ID and return that. + login.user_id = RandomId(); + + // Add him as the only local Gumi Live user. + co_await gme::nonEmpty(UserInfoService::addGumiUser( + theDb(), + login.user_id)); + + LOG_DEBUG << "AccountController: new user " << login.user_id; } + + login.status = StatusEnum::Success; + login.token = "test_token"; // Note: Should we use a proper token with drogon session? + login.status_number = 0; + } + catch (const DrogonDbException& ex) + { + LOG_ERROR << "AccountController: login failed " << ex.base().what(); } - catch (std::invalid_argument ex) + catch (const std::exception& ex) { - LOG_DEBUG << "AccountController: invalid parameters " << ex.what(); + LOG_ERROR << "AccountController: login failed " << ex.what(); } auto resp = HttpResponse::newHttpResponse(); diff --git a/gimuserver/db/MigrationManager.cpp b/gimuserver/db/MigrationManager.cpp index 66e4ca4..7f242a5 100644 --- a/gimuserver/db/MigrationManager.cpp +++ b/gimuserver/db/MigrationManager.cpp @@ -38,6 +38,7 @@ static void RegisterMigrations(MigrationMap& map) "free_gems INTEGER(4) NOT NULL DEFAULT 0," "paid_gems INTEGER(4) NOT NULL DEFAULT 0," "active_deck INTEGER(1) NOT NULL DEFAULT 0," + "tutorial_status INTEGER(3) NOT NULL DEFAULT 0," "summon_tickets INTEGER(4) NOT NULL DEFAULT 0," "rainbow_coins INTEGER(4) NOT NULL DEFAULT 0," "colosseum_tickets INTEGER(4) NOT NULL DEFAULT 0," @@ -49,17 +50,24 @@ static void RegisterMigrations(MigrationMap& map) ); }); -#if 0 migrate("08032025_CreateUserUnitsTable", { p->execSqlSync( "CREATE TABLE IF NOT EXISTS user_units (" - "id INTEGER PRIMARY KEY AUTOINCREMENT," // Add: Auto-incrementing primary key as per PR comment - "user_id TEXT NOT NULL," // Keep: Links unit to a user - "unit_id TEXT NOT NULL" // Keep: Stores the unit identifier + "user_unit_id INTEGER PRIMARY KEY AUTOINCREMENT," + "user_id TEXT NOT NULL," + "unit_id INTEGER NOT NULL," + "unit_type_id INTEGER NOT NULL," + "base_hp INTEGER NOT NULL," + "base_atk INTEGER NOT NULL," + "base_def INTEGER NOT NULL," + "base_rec INTEGER NOT NULL," + "ext_hp INTEGER NOT NULL DEFAULT 0," + "ext_atk INTEGER NOT NULL DEFAULT 0," + "ext_def INTEGER NOT NULL DEFAULT 0," + "ext_rec INTEGER NOT NULL DEFAULT 0" ");" ); }); -#endif } /*! diff --git a/gimuserver/db/PacketInterface.hpp b/gimuserver/db/PacketInterface.hpp new file mode 100644 index 0000000..a725ef9 --- /dev/null +++ b/gimuserver/db/PacketInterface.hpp @@ -0,0 +1,544 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/*! +* Packet-to-database bridge for packet structs backed by a SQL table. +* +* Each supported packet type specializes getPacketFields() below to describe +* which database columns can be read from or written to that packet. Callers +* should use the public read/update APIs instead of reaching into the mapping. +*/ +template +class PacketInterfaceFor +{ +public: + using Database = drogon::orm::DbClientPtr; + using Result = drogon::orm::Result; + using Row = drogon::orm::Row; + + /*! + * This interface is used only through static methods. + */ + PacketInterfaceFor() = delete; + + // Used to build a variant that bridges C++ types with SQL bind values for + // Drogon's ORM layer. Only types currently supported by the packet system + // are listed; extend as needed. + using FieldType = std::variant; + + /*! + * Optional owning list of database columns to operate on. + * + * Empty means "use every mapped field that is valid for the operation". + * updateFromPacket uses this to patch a small subset of a packet without + * first hydrating the full packet from the database. + */ + using Columns = std::vector; + + /*! + * Maps one database column to one packet field. + * + * A null read pointer means the column is write-only. A null write pointer + * means the column is read-only. This lets identity columns participate in + * SELECT queries without being overwritten by UPDATE queries. + */ + struct PacketField + { + // The literal name of the column in the database table (e.g., "username"). + std::string_view column; + + // Function pointer that reads a column from a DB row and assigns it to a struct member. + void (*read)(const Row& row, Packet& packet, const std::string_view column); + + // Function pointer that writes a struct member's value into a type-safe parameter vector. + void (*write)(const Packet& packet, std::vector& fields); + }; + + /*! + * Owning field list returned by getPacketFields(). + */ + using PacketFields = std::vector; + +private: + // Intentionally undefined for unsupported packet types. + static const PacketFields& getPacketFields(); + + /*! + * Filters a packet field mapping by database column name. + * @param fields Complete packet-to-column mapping for the packet type. + * @param columns Optional list of columns to keep. Empty keeps all fields. + */ + static PacketFields filterPacketFields( + const PacketFields& fields, + const Columns& columns) + { + PacketFields selected; + + if (columns.empty()) + { + selected.reserve(fields.size()); + selected.insert(selected.end(), fields.begin(), fields.end()); + return selected; + } + + selected.reserve(columns.size()); + for (const auto& column : columns) + { + const auto it = std::find_if( + fields.begin(), + fields.end(), + [column](const PacketField& field) { + return field.column == column; + }); + + if (it == fields.end()) + { + LOG_ERROR << "Unknown packet column '" << column << "'"; + return {}; + } + if (!it->write) + { + LOG_ERROR << "Packet column '" << column << "' is read-only"; + return {}; + } + + selected.push_back(*it); + } + + return selected; + } + + // Field declarations use these helpers when specializing getPacketFields(). + // Reads one typed DB column into one packet member. + template + static void readField(const Row& row, Packet& packet, const std::string_view column) + { + using T = std::remove_cvref_t; + packet.*Field = row[std::string(column)].as(); + } + + // Writes one packet member into the SQL bind-value list. + template + static void writeField(const Packet& packet, std::vector& fields) + { + fields.push_back(packet.*Field); + } + + /*! + * Iterates the field mapping to extract every readable column from a DB row into a packet. + * Fields whose read function pointer is null (write-only fields) are skipped. + * @param row The source database row containing the query results. + * @param packet The target packet instance being populated. + * @param fields The field mapping guiding the extraction. + */ + static void readFields( + const Row& row, + Packet& packet, + const PacketFields& fields) + { + for (const auto& field : fields) + { + // Some fields may be write-only, so we check if the read function is + // defined before calling it. + if (field.read) + { + field.read(row, packet, field.column); + } + } + } + + /*! + * Generates a comma-separated list of column names for a SELECT statement. + * Only readable columns (those with a non-null read function pointer) are included. + * @param fields The field mapping guiding the column list. + */ + static std::string selectList(const PacketFields& fields) + { + std::string sql; + for (const auto& field : fields) + { + // Only include fields that have a read function defined. + if (!field.read) + { + continue; + } + if (!sql.empty()) + { + sql += ", "; + } + sql += field.column; + } + return sql; + } + + /*! + * Serializes every writable field into a type-safe SQL parameter vector. + * @param packet The packet instance containing the source values. + * @param fields The field mapping guiding serialization. + */ + static std::vector writeFields( + const Packet& packet, + const PacketFields& fields) + { + std::vector f; + f.reserve(fields.size()); + + for (const auto& field : fields) + { + // Some fields may be read-only, so we check if the write function + // is defined before calling it. + if (field.write) + { + field.write(packet, f); + } + } + return f; + } + + /*! + * Generates assignment placeholders for an UPDATE SET clause. + * Only writable columns (those with a non-null write function pointer) are included. + * @param fields The field mapping guiding the clause. + */ + static std::string updateList(const PacketFields& fields) + { + std::string sql; + size_t idx = 1; + + for (const auto& field : fields) + { + // Only include fields that have a write function defined. + if (!field.write) + { + continue; + } + if (!sql.empty()) + { + sql += ", "; + } + sql += std::string(field.column) + " = $" + std::to_string(idx++); + } + return sql; + } + + // Ordered criteria keeps generated WHERE placeholders and bound values in sync. + using Criteria = std::map; + + /*! + * Generates a deterministic WHERE clause from criteria columns. + * @param criteria Map containing lookup constraints, automatically sorted alphabetically by key. + * @param from The SQL placeholder index offset ($1, $2, etc.) to start printing at. + */ + static std::string whereClause(const Criteria& criteria, const size_t from = 1) + { + if (criteria.empty()) + { + return ""; + } + std::string sql = " WHERE "; + size_t index = from; + + for (auto it = criteria.begin(); it != criteria.end(); ++it) + { + if (it != criteria.begin()) + { + sql += " AND "; + } + sql += std::string(it->first) + " = $" + std::to_string(index++); + } + return sql; + } + +public: + /*! + * Updates the given table using packet field mappings. + * + * By default this writes every writable field from the packet. Pass Columns + * to update only a named subset; this is useful for patching a single field + * without reading the whole packet first or risking default values from other + * packet members being written back to the database. + * + * @param db Database connection. + * @param table Table name to update. + * @param criteria WHERE clause constraints (column -> value map). + * @param packet Source packet to update the table with. + * @param columns Optional database column filter. Empty updates every writable field. + */ + static drogon::Task updateFromPacket( + const Database db, + const std::string_view table, + const Criteria criteria, + const Packet packet, + const Columns columns = {}) + { + const auto fields = filterPacketFields(getPacketFields(), columns); + const auto clause = updateList(fields); + const auto values = writeFields(packet, fields); + + // Fail-fast if + // - No database connection + // - No table name provided + // - No criteria provided (to prevent accidental updates) + // - No fields to update (to prevent invalid SQL) + // - No values to bind (to prevent invalid SQL) + if (!db || table.empty() || criteria.empty() || clause.empty() || values.empty()) + { + LOG_ERROR << "Invalid updateFromPacket call: " + << "db=" << static_cast(db) + << ", table='" << table << "'" + << ", criteria=" << criteria.size() + << ", clause_empty=" << clause.empty() + << ", values=" << values.size(); + throw std::invalid_argument("Invalid updateFromPacket call"); + } + + const auto sql = "UPDATE " + std::string(table) + " SET " + clause + + whereClause(criteria, /*from=*/values.size() + 1) + ";"; + + auto binder = *db << sql; + + // Bind payload parameters first ($1 to $N). + for (const auto& value : values) + { + std::visit([&binder](const auto& v) -> void { + binder << v; + }, value); + } + + // Bind lookup criteria parameters ($N+1 onwards). + for (const auto& [column, value] : criteria) + { + std::visit([&binder](const auto& v) -> void { + binder << v; + }, value); + } + + auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder)); + co_return result; + } + + /*! + * Reads the first matching row from the given table into a packet. + * + * This is a convenience wrapper over readToPackets for call sites that only + * care about fetching one row. + * + * @param db Database connection. + * @param table Table name to query. + * @param criteria WHERE clause constraints (column -> value map). + * @param packet Output packet to populate with. + */ + static drogon::Task readToPacket( + const Database db, + const std::string_view table, + const Criteria criteria, + Packet& packet) + { + std::vector packets{ packet }; + + auto result = co_await readToPackets(db, table, criteria, packets); + if (result.empty()) + { + co_return result; + } + + packet = std::move(packets.front()); + co_return result; + } + + /*! + * Reads every matching row from the given table into packet instances. + * + * The input vector may grow if the query returns more rows than it + * already has, but it is never shrunk by this function. + * + * @param db Database connection. + * @param table Table name to query. + * @param criteria WHERE clause constraints (column -> value map). + * @param packets Packet collection to update in place with query results. + */ + static drogon::Task readToPackets( + const Database db, + const std::string_view table, + const Criteria criteria, + std::vector& packets) + { + const auto fields = getPacketFields(); + const auto columnsList = selectList(fields); + + // Fail-fast if: + // - No database connection + // - No table name provided + // - No criteria provided (to prevent accidental reads) + // - No fields to read (to prevent invalid SQL) + if (!db || table.empty() || criteria.empty() || columnsList.empty()) + { + LOG_ERROR << "Invalid readToPackets call: " + << "db=" << static_cast(db) + << ", table='" << table << "'" + << ", criteria=" << criteria.size() + << ", columns_empty=" << columnsList.empty(); + throw std::invalid_argument("Invalid readToPackets call"); + } + + const std::string sql = "SELECT " + columnsList + + " FROM " + std::string(table) + + whereClause(criteria, /*from=*/1) + ";"; + + auto binder = *db << sql; + + for (const auto& [column, value] : criteria) + { + std::visit([&binder](const auto& v) -> void { + binder << v; + }, value); + } + + auto result = co_await drogon::orm::internal::SqlAwaiter(std::move(binder)); + if (result.size() > packets.size()) + { + packets.resize(result.size()); + } + + size_t rowIndex = 0; + for (const auto& row : result) + { + readFields(row, packets[rowIndex], fields); + ++rowIndex; + } + + co_return result; + } +}; + +/*! +* Specializations for supported packet types go here. Each specialization defines a static +* constexpr array of PacketField structs that map database columns to packet members. +*/ + +template <> +inline const PacketInterfaceFor::PacketFields& +PacketInterfaceFor::getPacketFields() +{ + static const PacketFields fields = { + { "id", &readField<&LoginInfoResp::user_id>, nullptr }, + { "gumi_user_id", &readField<&LoginInfoResp::gumi_live_userid>, nullptr }, + { + "username", + &readField<&LoginInfoResp::handle_name>, + &writeField<&LoginInfoResp::handle_name> + }, + { + "tutorial_status", + &readField<&LoginInfoResp::tutorial_status>, + &writeField<&LoginInfoResp::tutorial_status> + }, + }; + + return fields; +} + +template <> +inline const PacketInterfaceFor::PacketFields& +PacketInterfaceFor::getPacketFields() +{ + static const PacketFields fields = { + { "id", &readField<&UserTeamInfo::user_id>, nullptr }, + { + "level", + &readField<&UserTeamInfo::level>, + &writeField<&UserTeamInfo::level> + }, + { + "max_unit_count", + &readField<&UserTeamInfo::max_unit_count>, + &writeField<&UserTeamInfo::max_unit_count> + }, + { + "max_warehouse_count", + &readField<&UserTeamInfo::warehouse_count>, + &writeField<&UserTeamInfo::warehouse_count> + }, + { + "active_deck", + &readField<&UserTeamInfo::active_deck>, + &writeField<&UserTeamInfo::active_deck> + }, + }; + + return fields; +} + +template <> +inline const PacketInterfaceFor::PacketFields& +PacketInterfaceFor::getPacketFields() +{ + static const PacketFields fields = { + { "user_unit_id", &readField<&UserUnitInfo::user_unit_id>, nullptr }, + { "user_id", &readField<&UserUnitInfo::user_id>, nullptr }, + { + "unit_id", + &readField<&UserUnitInfo::unit_id>, + &writeField<&UserUnitInfo::unit_id> + }, + { + "unit_type_id", + &readField<&UserUnitInfo::unit_type_id>, + &writeField<&UserUnitInfo::unit_type_id> + }, + { + "base_hp", + &readField<&UserUnitInfo::base_hp>, + &writeField<&UserUnitInfo::base_hp> + }, + { + "base_atk", + &readField<&UserUnitInfo::base_atk>, + &writeField<&UserUnitInfo::base_atk> + }, + { + "base_def", + &readField<&UserUnitInfo::base_def>, + &writeField<&UserUnitInfo::base_def> + }, + { + "base_rec", + &readField<&UserUnitInfo::base_rec>, + &writeField<&UserUnitInfo::base_rec> + }, + { + "ext_hp", + &readField<&UserUnitInfo::ext_hp>, + &writeField<&UserUnitInfo::ext_hp> + }, + { + "ext_atk", + &readField<&UserUnitInfo::ext_atk>, + &writeField<&UserUnitInfo::ext_atk> + }, + { + "ext_def", + &readField<&UserUnitInfo::ext_def>, + &writeField<&UserUnitInfo::ext_def> + }, + { + "ext_rec", + &readField<&UserUnitInfo::ext_rec>, + &writeField<&UserUnitInfo::ext_rec> + }, + }; + + return fields; +} diff --git a/gimuserver/db/UserInfoService.cpp b/gimuserver/db/UserInfoService.cpp new file mode 100644 index 0000000..6b3666f --- /dev/null +++ b/gimuserver/db/UserInfoService.cpp @@ -0,0 +1,95 @@ +#include "UserInfoService.hpp" + +#include + +drogon::Task UserInfoService::fetchCurrentGumiUser( + const Database db, + std::string& gumi_user_id) +{ + if (!db) + { + LOG_ERROR << "Invalid fetchCurrentGumiUser call: " + << "db=" << static_cast(db); + throw std::invalid_argument("Invalid fetchCurrentGumiUser call"); + } + + auto result = co_await db->execSqlCoro("SELECT id FROM gumi_live_users LIMIT 1;"); + if (!result.empty()) + { + gumi_user_id = result.front()["id"].as(); + } + + co_return result; +} + +drogon::Task UserInfoService::fetchUserForGumiUser( + const Database db, + const std::string_view gumi_user_id, + std::string& user_id) +{ + if (!db || gumi_user_id.empty()) + { + LOG_ERROR << "Invalid fetchUserForGumiUser call: " + << "db=" << static_cast(db) + << ", gumi_user_id_empty=" << gumi_user_id.empty(); + throw std::invalid_argument("Invalid fetchUserForGumiUser call"); + } + + auto result = co_await db->execSqlCoro( + "SELECT id FROM userinfo WHERE gumi_user_id = $1 LIMIT 1;", + gumi_user_id); + if (!result.empty()) + { + user_id = result.front()["id"].as(); + } + + co_return result; +} + +drogon::Task UserInfoService::addGumiUser( + const Database db, + const std::string_view gumi_user_id) +{ + if (!db || gumi_user_id.empty()) + { + LOG_ERROR << "Invalid addGumiUser call: " + << "db=" << static_cast(db) + << ", gumi_user_id_empty=" << gumi_user_id.empty(); + throw std::invalid_argument("Invalid addGumiUser call"); + } + + co_return co_await db->execSqlCoro( + "INSERT INTO gumi_live_users(id) VALUES ($1);", + gumi_user_id); +} + +drogon::Task UserInfoService::addUser( + const Database db, + const std::string_view gumi_user_id, + const std::string_view user_id, + const std::string_view username) +{ + if (!db || gumi_user_id.empty() || user_id.empty() || username.empty()) + { + LOG_ERROR << "Invalid addUser call: " + << "db=" << static_cast(db) + << ", gumi_user_id_empty=" << gumi_user_id.empty() + << ", user_id_empty=" << user_id.empty() + << ", username_empty=" << username.empty(); + throw std::invalid_argument("Invalid addUser call"); + } + + co_return co_await db->execSqlCoro( + "INSERT INTO userinfo " + "(id, gumi_user_id, device_id, username, level, max_warehouse_count) " + "VALUES ($1, $2, $3, $4, $5, $6) " + "ON CONFLICT(id) DO UPDATE SET " + "gumi_user_id = excluded.gumi_user_id, " + "username = excluded.username;", + user_id, + gumi_user_id, + "", + username, + 1, + 100); +} diff --git a/gimuserver/db/UserInfoService.hpp b/gimuserver/db/UserInfoService.hpp new file mode 100644 index 0000000..74ae478 --- /dev/null +++ b/gimuserver/db/UserInfoService.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include + +#include +#include + +/*! +* Stateless database helpers for core player state. +*/ +class UserInfoService final +{ +public: + using Database = drogon::orm::DbClientPtr; + using Result = drogon::orm::Result; + + UserInfoService() = delete; + + /*! + * Fetches the first known Gumi Live user ID from the local database. + * @param db Database pointer. + * @param gumi_user_id Output Gumi Live user ID. Left unchanged if no row exists. + */ + static drogon::Task fetchCurrentGumiUser( + const Database db, + std::string& gumi_user_id); + + /*! + * Fetches the first game user ID owned by a Gumi Live user. + * @param db Database pointer. + * @param gumi_user_id Gumi Live user ID to look up. + * @param user_id Output game user ID. Left unchanged if no row exists. + */ + static drogon::Task fetchUserForGumiUser( + const Database db, + const std::string_view gumi_user_id, + std::string& user_id); + + /*! + * Adds a Gumi Live user row. + * @param db Database pointer. + * @param gumi_user_id Gumi Live user ID to persist. + */ + static drogon::Task addGumiUser( + const Database db, + const std::string_view gumi_user_id); + + /*! + * Adds a new game user row with default starting account values. + * @param db Database pointer. + * @param gumi_user_id Gumi Live user ID that owns this user. + * @param user_id Game user ID. + * @param username Initial in-game handle name. + */ + static drogon::Task addUser( + const Database db, + const std::string_view gumi_user_id, + const std::string_view user_id, + const std::string_view username); +}; diff --git a/gimuserver/db/UserUnitService.cpp b/gimuserver/db/UserUnitService.cpp new file mode 100644 index 0000000..8b7842c --- /dev/null +++ b/gimuserver/db/UserUnitService.cpp @@ -0,0 +1,43 @@ +#include "UserUnitService.hpp" + +#include + +drogon::Task UserUnitService::addUnit( + const Database db, + const std::string_view user_id, + const UserUnitInfo unit) +{ + if (!db || user_id.empty()) + { + LOG_ERROR << "Invalid addUnit call: " + << "db=" << static_cast(db) + << ", user_id_empty=" << user_id.empty(); + throw std::invalid_argument("Invalid addUnit call"); + } + + co_return co_await db->execSqlCoro( + "INSERT INTO user_units (" + "user_id, " + "unit_id, " + "unit_type_id, " + "base_hp, " + "base_atk, " + "base_def, " + "base_rec, " + "ext_hp, " + "ext_atk, " + "ext_def, " + "ext_rec" + ") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);", + user_id, + unit.unit_id, + unit.unit_type_id, + unit.base_hp, + unit.base_atk, + unit.base_def, + unit.base_rec, + unit.ext_hp, + unit.ext_atk, + unit.ext_def, + unit.ext_rec); +} diff --git a/gimuserver/db/UserUnitService.hpp b/gimuserver/db/UserUnitService.hpp new file mode 100644 index 0000000..c84172b --- /dev/null +++ b/gimuserver/db/UserUnitService.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include + +/*! +* Stateless database helpers for player-owned units. +*/ +class UserUnitService final +{ +public: + using Database = drogon::orm::DbClientPtr; + using Result = drogon::orm::Result; + + /*! + * This service is used only through static methods. + */ + UserUnitService() = delete; + + /*! + * Adds a unit to the user's collection. + * Persists only the subset of UserUnitInfo fields currently backed by the + * user_units table. + * @param db Database pointer. + * @param user_id User ID. + * @param unit Unit to add. + */ + static drogon::Task addUnit( + const Database db, + const std::string_view user_id, + const UserUnitInfo unit); +}; diff --git a/gimuserver/drogon/GimuServer.cpp b/gimuserver/drogon/GimuServer.cpp index 7d34a0e..c1fb397 100644 --- a/gimuserver/drogon/GimuServer.cpp +++ b/gimuserver/drogon/GimuServer.cpp @@ -1,6 +1,8 @@ #include "App.hpp" #include "GimuServer.hpp" +#include + GimuServer::GimuServer() : m_dlc_error_log(), m_have_log(false), m_cache() {} void GimuServer::initAndStart(const Json::Value& config) @@ -45,6 +47,7 @@ void GimuServer::initAndStart(const Json::Value& config) const auto& server = config["server"]; m_cache.Setup(server); + UnitArchiver::instance().setup(server); } void GimuServer::shutdown() {} diff --git a/gimuserver/drogon/ServerCache.cpp b/gimuserver/drogon/ServerCache.cpp index b74cf60..1fbae78 100644 --- a/gimuserver/drogon/ServerCache.cpp +++ b/gimuserver/drogon/ServerCache.cpp @@ -3,6 +3,7 @@ #include "ServerCacheMst.hpp" #include +#include /*! * Builds a JSON @@ -22,24 +23,6 @@ static std::string BuildJson(const T& d) return buffer; } -/*! -* Loads a JSON from the file system. -*/ -template -static T LoadJson(std::string_view mst_root, std::string_view file) -{ - T obj{}; - std::string path = std::string(mst_root) + "/" + std::string(file); - std::string buffer{}; - const auto& ec = glz::read_file_json(obj, path, buffer); - if (ec) - { - throw std::runtime_error(std::format("Cannot read JSON file \"{}\", error:\n{}", file, glz::format_error(ec, buffer))); - } - - return obj; -} - void ServerCache::Setup(const Json::Value& serverObj) { const auto& mstRoot = serverObj["mst_root"].asString(); @@ -85,7 +68,7 @@ void ServerCache::Setup(const Json::Value& serverObj) m_initrsp.gacha_effects = LoadJson(mstRoot, "gacha_effects.json").data; m_initrsp.gachas = LoadJson(mstRoot, "gacha.json").data; m_initrsp.npcs = LoadJson(mstRoot, "npc.json").data; - m_initrsp.banner_info = LoadJson (mstRoot, "banner_info.json").data; + m_initrsp.banner_info = LoadJson(mstRoot, "banner_info.json").data; m_initrsp.extra_passive_skills = LoadJson(mstRoot, "extra_passive_skills.json").data; m_initrsp.notice_info = LoadJson(mstRoot, "notice_info.json"); m_initrsp.defines = LoadJson(mstRoot, "defines.json"); @@ -129,3 +112,4 @@ void ServerCache::Setup(const Json::Value& serverObj) // --- } } + diff --git a/gimuserver/gme/GmeControllerHandlers.cpp b/gimuserver/gme/GmeControllerHandlers.cpp index 27f3458..880b589 100644 --- a/gimuserver/gme/GmeControllerHandlers.cpp +++ b/gimuserver/gme/GmeControllerHandlers.cpp @@ -72,6 +72,9 @@ static GmeHandler getHandler(std::string_view cmd) REGISTER("Uo86DcRh", GatchaList, "8JbxFvuSaB2CK7Ln"); REGISTER("NiYWKdzs", HomeInfo, "f6uOewOD"); REGISTER("jE6Sp0q4", MissionStart, "csiVLDKkxEwBfR70"); + REGISTER("TA4MnZX8", NgwordCheck, "r4Smw5TX"); + REGISTER("uV6yH5MX", CreateUser, "4agnATy2DrJsWzQk"); + REGISTER("T1nCVvx4", TutorialUpdate, "7hqzmR3T"); REGISTER("ynB7X5P9", UpdateInfoLight, "7kH9NXwC"); REGISTER("cTZ3W2JG", UserInfo, "ScJx6ywWEb0A3njT"); diff --git a/gimuserver/gme/Handlers.hpp b/gimuserver/gme/Handlers.hpp index c3bce4d..afa12eb 100644 --- a/gimuserver/gme/Handlers.hpp +++ b/gimuserver/gme/Handlers.hpp @@ -87,6 +87,9 @@ namespace GmeHandlers HANDLE(GatchaList); HANDLE(HomeInfo); HANDLE(MissionStart); + HANDLE(NgwordCheck); + HANDLE(CreateUser); + HANDLE(TutorialUpdate); HANDLE(UpdateInfoLight); HANDLE(UserInfo); } diff --git a/gimuserver/gme/Initialize.cpp b/gimuserver/gme/Initialize.cpp index 64bf9c2..2cdf2ac 100644 --- a/gimuserver/gme/Initialize.cpp +++ b/gimuserver/gme/Initialize.cpp @@ -1,6 +1,9 @@ #include "App.hpp" #include "Handlers.hpp" +#include "common.hpp" +#include +#include #include HANDLEF(Initialize) @@ -17,57 +20,57 @@ HANDLEF(Initialize) // NOTE: A real server would verify the gumi token first... // TODO: Handle MSTs to answer - InitializeResp resp = theServer()->cache().initializeResp(); // copy !! + // Copy the cached response and build on top of it. + InitializeResp resp = theServer()->cache().initializeResp(); -#if 0 - const auto& res = co_await theDb()->execSqlCoro("SELECT id, username, debug_mode FROM userinfo WHERE gumi_user_id=$1", req.login_info.gumi_live_userid); - if (res.empty()) + // This is something to do with account transfer, ignore for now. + resp.login_info.account_id = "12345678"; + // Assume we are a new user until proven otherwise. The game checks if these + // values are empty to decide whether to enter the tutorial flow or not. + resp.login_info.handle_name = ""; + resp.login_info.user_id = ""; + + // After GuestLogin, Initialize is the first encrypted GME request the client + // sends during normal startup. It receives the Gumi Live ID returned by the + // account login flow and decides whether the client should resume an existing + // game user or enter the new-user/tutorial flow. + std::string gumiUserId = req.login_info.gumi_live_userid; + if (gumiUserId.empty()) { - // Gumi user does not exist! Create a new user and add it to the database - - const auto& cache = theServer()->cache(); - const auto& scfg = cache.serverConfig(); - const auto& def = cache.initializeResp().defines; - - // No handle! We are a new user after all! - resp.login_info.account_id = "1111"; - - co_await theDb()->execSqlCoro("INSERT INTO userinfo (id, gumi_user_id, device_id, debug_mode, " - "level, " - "max_unit_count, max_friend_count, " - "zel, karma, brave_coin, " - "max_warehouse_count, free_gems, energy) " - "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13);", - // id, gumi_user_id, device_id, debug_mode - resp.login_info.account_id, req.login_info.gumi_live_userid, req.login_info.device_id, false, - // level - scfg.initialLevel, - // max_unit_count, max_friend_count - 50, 200, - // zel, karma, brave_coin - scfg.initialZel, scfg.initialKarma, scfg.initialBraveCoins, - // max_warehouse, free_gems, energy - 10, 5, 20); + LOG_ERROR << "Initialize request did not include gumi_live_userid. " + "We cannot proceed without this information."; + co_return HandleResult::error("Missing gumi_live_userid", "Initialize request did not include gumi_live_userid"); } - else + + // If the client sent us an explicit user id, we should verify that it matches + // what we have in the database. + CO_AWAIT_DB(UserInfoService::fetchUserForGumiUser( + theDb(), + gumiUserId, + resp.login_info.user_id)); + if (!req.login_info.user_id.empty() && resp.login_info.user_id != req.login_info.user_id) { - // only one query pls - const auto& sql = res[0]; - size_t col = 0; - resp.login_info.account_id = sql[col++].as(); - resp.login_info.handle_name = sql[col++].as(); - resp.login_info.debug_mode = sql[col++].as(); + LOG_ERROR << "User ID mismatch for Gumi Live user " << gumiUserId + << ": client sent " << req.login_info.user_id + << ", database has " << resp.login_info.user_id; + co_return HandleResult::error("User ID mismatch", "User ID mismatch for Gumi Live user " + std::string(gumiUserId)); } -#endif - - // TODO: GET THIS FROM A CACHE TOKEN ETC - resp.login_info.account_id = "12345678"; - resp.login_info.handle_name = "OfflineMod!"; - resp.login_info.user_id = "0839899613932562"; // I think this is a random UUID according to packet-gen - // TEMP HACK!! Skip tutorial flag and put a real name - resp.login_info.tutorial_end_flag = true; - resp.login_info.tutorial_status = 12; + // If we didn't find a user for this Gumi Live ID, we just return an empty user_id + // and let the client enter the tutorial flow. Otherwise, we return the user info + // stored in the database. + if (!resp.login_info.user_id.empty()) + { + CO_AWAIT_DB(gme::nonEmpty(PacketInterfaceFor::readToPacket( + theDb(), + "userinfo", + { + { "gumi_user_id", gumiUserId }, + { "id", resp.login_info.user_id }, + }, + resp.login_info))); + } + resp.login_info.tutorial_end_flag = gme::getTutorialEndFlag(resp.login_info.tutorial_status); //resp.user_info.gumi_live_token = req.user_info.gumi_live_token; //resp.user_info.gumi_live_userid = req.user_info.gumi_live_userid; diff --git a/gimuserver/gme/Tutorial.cpp b/gimuserver/gme/Tutorial.cpp new file mode 100644 index 0000000..36425ec --- /dev/null +++ b/gimuserver/gme/Tutorial.cpp @@ -0,0 +1,153 @@ +#include "App.hpp" +#include "Handlers.hpp" +#include "common.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace +{ +constexpr uint32_t TutorialStarterUnitType = 1; + +std::optional getStarterUnit(uint32_t element) +{ + switch (element) + { + case 1: + return 10011; + case 2: + return 20011; + case 3: + return 30011; + case 4: + return 40011; + default: + return std::nullopt; + } +} +} + +HANDLEF(NgwordCheck) +{ + co_return HandleResult::success("{}"); +} + +HANDLEF(CreateUser) +{ + CreateUserReq req = {}; + const auto& ec = glz::read_json(req, json); + if (ec) + { + const auto error = glz::format_error(ec, json); + LOG_ERROR << "CreateUserReq deserialization failed:\n" + << error << "\n" + << "Raw decrypted request JSON:\n" + << json; + co_return HandleResult::error("Deserialization error", error); + } + + const auto& handleName = req.login_info.handle_name; + const auto& gumiUserId = req.login_info.gumi_live_userid; + if (gumiUserId.empty()) + { + co_return HandleResult::error( + "Missing gumi_live_userid", + "CreateUser request did not include gumi_live_userid"); + } + if (handleName.empty()) + { + co_return HandleResult::error( + "Missing handle name", + "CreateUser request did not include a handle name"); + } + + const auto starterUnitId = getStarterUnit(req.selected_element.element); + if (!starterUnitId) + { + co_return HandleResult::error("Invalid tutorial starter element"); + } + + const auto unitRecord = UnitArchiver::instance().lookup(*starterUnitId); + if (!unitRecord) + { + co_return HandleResult::error("Archive error", "Unable to find tutorial starter unit"); + } + + UserUnitInfo unit; + if (!UnitArchiver::populatePacket(*unitRecord, TutorialStarterUnitType, unit)) + { + co_return HandleResult::error("Archive error", "Unable to populate tutorial starter unit"); + } + + // GuestLogin should have established the Gumi Live user before CreateUser runs. + std::string storedGumiUserId; + CO_AWAIT_DB(gme::nonEmpty(UserInfoService::fetchCurrentGumiUser( + theDb(), + storedGumiUserId))); + if (storedGumiUserId != gumiUserId) + { + co_return HandleResult::error( + "Gumi Live user mismatch", + "CreateUser gumi_live_userid does not match the active Gumi Live user"); + } + + std::string userId; + CO_AWAIT_DB(UserInfoService::fetchUserForGumiUser(theDb(), gumiUserId, userId)); + if (!userId.empty()) + { + co_return HandleResult::error( + "User already exists", + "CreateUser cannot create a second user for this Gumi Live user"); + } + + userId = RandomId(); + + // We need to wrap these operations in a transaction to avoid an invalid + // intermediate state. + CO_AWAIT_DB(gme::runTransaction( + theDb(), + // Add the user in the userinfo table. + [&](const auto transaction) + { + return gme::nonEmpty(UserInfoService::addUser( + transaction, + gumiUserId, + userId, + handleName)); + }, + // Update the tutorial status to mark as completed. + [&](const auto transaction) + { + return gme::nonEmpty(PacketInterfaceFor::updateFromPacket( + transaction, + "userinfo", + { + { "gumi_user_id", gumiUserId }, + { "id", userId }, + }, + LoginInfoResp{ .tutorial_status = 12 }, + { "tutorial_status" })); + }, + // Add the starter unit to the user's collection. + [&](const auto transaction) + { + return gme::nonEmpty(UserUnitService::addUnit( + transaction, + userId, + unit)); + })); + + co_return HandleResult::success("{}"); +} + +HANDLEF(TutorialUpdate) +{ + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/UserInfo.cpp b/gimuserver/gme/UserInfo.cpp index a6531c6..b194405 100644 --- a/gimuserver/gme/UserInfo.cpp +++ b/gimuserver/gme/UserInfo.cpp @@ -1,5 +1,9 @@ #include "App.hpp" #include "Handlers.hpp" +#include "common.hpp" + +#include +#include HANDLEF(UserInfo) { @@ -12,81 +16,74 @@ HANDLEF(UserInfo) co_return HandleResult::error("Deserialization error", fmte); } - UserInfoResp resp = theServer()->cache().userInfoResp(); // copy !! + // Copy the cached response and build on top of it. + UserInfoResp resp = theServer()->cache().userInfoResp(); // TODO: A real server should check if user_id == gumi token... - // TODO: GET THIS FROM A CACHE TOKEN ETC - resp.login_info.user_id = "0839899613932562"; // I think this is a random UUID according to packet-gen + const auto db = theDb(); + std::string gumiUserId = req.login_info.gumi_live_userid; + if (gumiUserId.empty()) + { + LOG_ERROR << "UserInfo request did not include gumi_live_userid. " + "We cannot proceed without this information."; + co_return HandleResult::error("Missing gumi_live_userid", "UserInfo request did not include gumi_live_userid"); + } - // TEMP HACK!! Skip tutorial flag and put a real name - resp.login_info.account_id = "12345678"; - resp.login_info.handle_name = "OfflineMod!"; - resp.login_info.tutorial_end_flag = true; - resp.login_info.tutorial_status = 0; - resp.login_info.feature_gate = 0; + // If the client sent us an explicit user id, we should verify that it matches + // what we have in the database. + CO_AWAIT_DB(gme::nonEmpty(UserInfoService::fetchUserForGumiUser( + theDb(), + gumiUserId, + resp.login_info.user_id))); + if (!req.login_info.user_id.empty() && resp.login_info.user_id != req.login_info.user_id) + { + LOG_ERROR << "User ID mismatch for Gumi Live user " << gumiUserId + << ": client sent " << req.login_info.user_id + << ", database has " << resp.login_info.user_id; + co_return HandleResult::error("User ID mismatch", "User ID mismatch for Gumi Live user " + std::string(gumiUserId)); + } - UserTeamInfo team = {}; + // We must have a valid user entry in the database at this point. + CO_AWAIT_DB(gme::nonEmpty(PacketInterfaceFor::readToPacket( + db, + "userinfo", + { + { "gumi_user_id", gumiUserId }, + { "id", resp.login_info.user_id }, + }, + resp.login_info))); + resp.login_info.tutorial_end_flag = gme::getTutorialEndFlag(resp.login_info.tutorial_status); + + CO_AWAIT_DB(gme::nonEmpty(PacketInterfaceFor::readToPacket( + db, + "userinfo", + { + { "gumi_user_id", gumiUserId }, + { "id", resp.login_info.user_id }, + }, + resp.team_info))); resp.team_info.reinforcement_deck.emplace_back(0); resp.team_info.reinforcement_deck.emplace_back(0); resp.team_info.reinforcement_deck.emplace_back(0); - resp.team_info.user_id = resp.login_info.user_id; - resp.team_info.level = 1; resp.team_info.exp = 0; - resp.team_info.warehouse_count = 100; resp.team_info.add_unit_count = 100; - resp.team_info.max_unit_count = 100; - - - { - UserUnitInfo d = {}; - d.user_id = resp.login_info.user_id; - d.user_unit_id = 100; - d.unit_type_id = 1; - d.element = "fire"; - d.base_hp = 1000; - d.add_hp = 1001; - d.ext_hp = 1002; - - d.base_def = 1100; - d.add_def = 1101; - d.ext_def = 1102; + resp.team_info.deck_cost = gme::deckCostForLevel(resp.team_info.level); - d.base_heal = 1200; - d.add_heal = 1201; - d.ext_heal = 1202; - - d.base_atk = 1300; - d.add_atk = 1301; - d.ext_atk = 1302; - - d.limit_over_atk = 1400; - d.limit_over_def = 1401; - d.limit_over_heal = 1402; - d.limit_over_hp = 1403; - - d.unit_lv = 1; - d.new_flag = 1; - - d.ext_count = 1500; - d.fe_bp = 100; - d.fe_used_bp = 0; - d.fe_max_usable_bp = 200; - d.unit_img_type = 0; - - - d.exp = 1; - d.total_exp = 1; - - d.unit_id = 50253; - resp.unit_info.emplace_back(d); - } + // We should always have at least one unit, since the game will not let users + // delete their only unit on the squad. + CO_AWAIT_DB(gme::nonEmpty(PacketInterfaceFor::readToPackets( + db, + "user_units", + { { "user_id", resp.login_info.user_id } }, + resp.unit_info))); + const auto activeUserUnitId = resp.unit_info.front().user_unit_id; for (int i = 0; i < 10; i++) { UserPartyDeckInfo deck = {}; deck.deck_num = i; deck.deck_type = 1; - deck.user_unit_id = 100; // Now maps to id from user_units + deck.user_unit_id = activeUserUnitId; resp.party_deck_info.emplace_back(deck); } @@ -95,7 +92,7 @@ HANDLEF(UserInfo) resp.campaign_info.first_for_the_day = true; resp.campaign_info.id = 1; - resp.summoner_journal.user_id = req.login_info.user_id; // we are really trusting the client here (bad) + resp.summoner_journal.user_id = resp.login_info.user_id; resp.signal_key.key = "5EdKHavF"; diff --git a/gimuserver/gme/common.hpp b/gimuserver/gme/common.hpp new file mode 100644 index 0000000..2aeface --- /dev/null +++ b/gimuserver/gme/common.hpp @@ -0,0 +1,114 @@ +#pragma once + +#include "Handlers.hpp" + +#include + +#include +#include +#include + +/*! +* Awaits a Drogon database task in a GME handler. Convenience macro to +* reduce boilerplate and enforce consistent error handling for database operations +* in handlers. +*/ +#define CO_AWAIT_DB(expression) \ + try \ + { \ + (void)co_await (expression); \ + } \ + catch (const drogon::orm::DrogonDbException& ex) \ + { \ + LOG_ERROR << "Database error: " << ex.base().what(); \ + co_return HandleResult::error("Database error", ex.base().what()); \ + } \ + catch (const std::exception& ex) \ + { \ + LOG_ERROR << "Database error: " << ex.what(); \ + co_return HandleResult::error("Database error", ex.what()); \ + } + +namespace gme +{ + +using Result = drogon::orm::Result; + +/*! +* Awaits a database operation and requires its result to contain or affect at +* least one row. +*/ +template +drogon::Task nonEmpty(Operation operation) +{ + auto result = co_await operation; + if (result.empty() && result.affectedRows() == 0) + { + throw std::runtime_error("Database query returned or affected no rows"); + } + + co_return result; +} + +/*! +* Runs database operations sequentially in one transaction. +* +* Each operation receives the transaction database client and must return a +* Drogon database task. Operations define their own result expectations. The +* transaction is rolled back before any failure is propagated to the caller; +* otherwise, Drogon commits it when the transaction's final owner releases it. +*/ +template +drogon::Task runTransaction( + const drogon::orm::DbClientPtr db, + Operations... operations) +{ + if (!db) + { + throw std::invalid_argument("Invalid runTransaction call"); + } + + auto transaction = co_await db->newTransactionCoro(); + try + { + (co_await operations(transaction), ...); + } + catch (...) + { + transaction->rollback(); + throw; + } +} + +/*! +* Derives the tutorial completion flag sent in LoginInfoResp. +* +* The database stores tutorial_status. Before a login-info packet is sent back, +* handlers use this helper to keep tutorial_end_flag consistent with that +* status. In the current server model, status 12 or greater means the tutorial +* is complete. +* @param status The tutorial_status to derive the end flag from. +*/ +constexpr bool getTutorialEndFlag(uint32_t status) +{ + return status >= 12; +} + +/*! +* Derives the deck cost for a given user level based on the progression data. +*/ +inline int32_t deckCostForLevel(int32_t level) +{ + const auto& progression = theServer()->cache().initializeResp().progression; + for (const auto& levelMst : progression) + { + if (levelMst.level == level) + { + return levelMst.deck_cost; + } + } + + return 0; +} + +} diff --git a/gimuserver/utils/JsonFile.hpp b/gimuserver/utils/JsonFile.hpp new file mode 100644 index 0000000..bb5cd61 --- /dev/null +++ b/gimuserver/utils/JsonFile.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include +#include +#include +#include + +template +inline T LoadJson(std::string_view path) +{ + T obj{}; + const std::string pathString(path); + std::string buffer; + const auto& ec = glz::read_file_json(obj, pathString, buffer); + if (ec) + { + throw std::runtime_error(std::format("Cannot read JSON file \"{}\", error:\n{}", pathString, glz::format_error(ec, buffer))); + } + + return obj; +} + +template +inline T LoadJson(std::string_view root, std::string_view file) +{ + return LoadJson(std::string(root) + "/" + std::string(file)); +} diff --git a/packet-generator b/packet-generator index 37256a2..6e3e523 160000 --- a/packet-generator +++ b/packet-generator @@ -1 +1 @@ -Subproject commit 37256a25049637980fcac4c7f5fafa1654b45d2e +Subproject commit 6e3e5238f7c59e8c682c5f26d40d532ff0b26f1c