diff --git a/deploy/archive/unit.json b/deploy/archive/unit.json new file mode 100644 index 0000000..f184a6c --- /dev/null +++ b/deploy/archive/unit.json @@ -0,0 +1,11 @@ +[ + { + "unit_id": 50253, + "lord_stats": { + "hp": 4500, + "atk": 1340, + "def": 1340, + "rec": 1680 + } + } +] \ No newline at end of file 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..c2d4a42 --- /dev/null +++ b/gimuserver/archive/UnitArchiver.cpp @@ -0,0 +1,83 @@ +#include "UnitArchiver.hpp" + +#include + +#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(); + + 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)); + } +} + +bool UnitArchiver::Lookup(UnitRecord& record) +{ + // Callers should populate this key since we use it for lookups. + assert (record.unit_id > 0); + + const auto it = cache_.find(record.unit_id); + if (it == cache_.end()) + { + return false; + } + + record = it->second; + return true; +} + +bool UnitArchiver::Lookup(UserUnitInfo& unit) +{ + // Callers should populate these keys since we use them for lookups. + assert (unit.unit_id > 0); + assert (unit.unit_type_id > 0); + + UnitRecord record = { + .unit_id = unit.unit_id, + }; + + if (!Lookup(record)) + { + return false; + } + + // Grab the stats for the unit type. + const auto& stats = UnitTypeStats(record, unit.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; +} diff --git a/gimuserver/archive/UnitArchiver.hpp b/gimuserver/archive/UnitArchiver.hpp new file mode 100644 index 0000000..eda6526 --- /dev/null +++ b/gimuserver/archive/UnitArchiver.hpp @@ -0,0 +1,86 @@ +#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. + * + * The caller must set record.unit_id before calling. When found, the + * matching archive record replaces record. + * @param record Input lookup key and output archive record. + * @return True if the archive contains the unit, false otherwise. + */ + bool Lookup(UnitRecord& record); + + /*! + * Looks up a unit archive record and converts it to the DB-backed subset of + * UserUnitInfo. + * + * The caller must set unit.unit_id and unit.unit_type_id before calling. + * Ownership fields such as user_id and user_unit_id are not modified. + * @param unit Input lookup/type keys and output user unit info. + * @return True if the archive contains the unit, false otherwise. + */ + bool Lookup(UserUnitInfo& unit); + + /*! + * Gets the stat block for a unit type. + * + * Currently only unit_type_id 1 is understood and maps to lord_stats. + * @throws std::runtime_error when the unit type is not supported. + */ + static const UnitRecordStats& UnitTypeStats(const UnitRecord& unitRecord, UnitType unit_type_id) + { + switch (unit_type_id) + { + case 1: + return unitRecord.lord_stats; + default: + throw std::runtime_error("Unsupported unit type " + std::to_string(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/db/MigrationManager.cpp b/gimuserver/db/MigrationManager.cpp index 66e4ca4..9ff8679 100644 --- a/gimuserver/db/MigrationManager.cpp +++ b/gimuserver/db/MigrationManager.cpp @@ -49,17 +49,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/UserUnitService.cpp b/gimuserver/db/UserUnitService.cpp new file mode 100644 index 0000000..71af45a --- /dev/null +++ b/gimuserver/db/UserUnitService.cpp @@ -0,0 +1,107 @@ +#include "UserUnitService.hpp" + +#include + +drogon::Task UserUnitService::AddUnit(Database db, std::string_view user_id, const UserUnitInfo& unit) +{ + if (!db) + { + co_return false; + } + + try + { + const auto result = 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); + + co_return result.affectedRows() == 1; + } + catch (const Exception&) + { + co_return false; + } +} + +drogon::Task UserUnitService::getUnits( + Database db, + std::string_view user_id, + std::vector& units) +{ + if (!db) + { + co_return false; + } + + try + { + const auto result = co_await db->execSqlCoro( + "SELECT " + "user_unit_id, " + "user_id, " + "unit_id, " + "unit_type_id, " + "base_hp, " + "base_atk, " + "base_def, " + "base_rec, " + "ext_hp, " + "ext_atk, " + "ext_def, " + "ext_rec " + "FROM user_units " + "WHERE user_id = $1 " + "ORDER BY user_unit_id ASC;", + user_id); + + units.clear(); + units.reserve(result.size()); + + for (const auto& row : result) + { + // Keep this mapping in lockstep with the selected DB-backed fields. + UserUnitInfo unit = {}; + unit.user_unit_id = row["user_unit_id"].as(); + unit.user_id = row["user_id"].as(); + unit.unit_id = row["unit_id"].as(); + unit.unit_type_id = row["unit_type_id"].as(); + unit.base_hp = row["base_hp"].as(); + unit.base_atk = row["base_atk"].as(); + unit.base_def = row["base_def"].as(); + unit.base_rec = row["base_rec"].as(); + unit.ext_hp = row["ext_hp"].as(); + unit.ext_atk = row["ext_atk"].as(); + unit.ext_def = row["ext_def"].as(); + unit.ext_rec = row["ext_rec"].as(); + units.emplace_back(std::move(unit)); + } + + co_return true; + } + catch (const Exception&) + { + co_return false; + } +} diff --git a/gimuserver/db/UserUnitService.hpp b/gimuserver/db/UserUnitService.hpp new file mode 100644 index 0000000..911acf6 --- /dev/null +++ b/gimuserver/db/UserUnitService.hpp @@ -0,0 +1,48 @@ +#pragma once +#include +#include + +#include +#include +#include + +/*! +* Stateless database helpers for player-owned units. +* +* This service only persists the UserUnitInfo fields currently backed by the +* user_units table. Fields that are still network-only or not understood yet +* should stay outside this service until the schema is expanded. +*/ +class UserUnitService final +{ +public: + using Database = drogon::orm::DbClientPtr; + using Exception = drogon::orm::DrogonDbException; + + /*! + * 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. + * @return True if the unit was added successfully, false otherwise. + */ + static drogon::Task AddUnit(Database db, std::string_view user_id, const UserUnitInfo& unit); + + /*! + * Gets all units in the user's collection from the local database. + * Populates only the subset of UserUnitInfo fields currently backed by the + * user_units table; all other fields keep their default values. + * @param db Database pointer. + * @param user_id User ID. + * @param units Output units. + * @return True if the units were queried successfully, false otherwise. + */ + static drogon::Task getUnits(Database db, std::string_view user_id, std::vector& units); +}; diff --git a/gimuserver/drogon/GimuServer.cpp b/gimuserver/drogon/GimuServer.cpp index 7d34a0e..5211f6c 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/UserInfo.cpp b/gimuserver/gme/UserInfo.cpp index a6531c6..bf71c02 100644 --- a/gimuserver/gme/UserInfo.cpp +++ b/gimuserver/gme/UserInfo.cpp @@ -1,6 +1,9 @@ #include "App.hpp" #include "Handlers.hpp" +#include +#include + HANDLEF(UserInfo) { UserInfoReq req = {}; @@ -37,56 +40,44 @@ HANDLEF(UserInfo) resp.team_info.add_unit_count = 100; resp.team_info.max_unit_count = 100; - + const auto db = theDb(); + if (!co_await UserUnitService::getUnits(db, resp.login_info.user_id, resp.unit_info)) { - 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; - - 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; + co_return HandleResult::error("Database error", "Unable to load user units"); + } - d.unit_lv = 1; - d.new_flag = 1; + if (resp.unit_info.empty()) + { + UserUnitInfo d = { + .unit_id = 50253, + .unit_type_id = 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; + if (!UnitArchiver::Instance().Lookup(d)) + { + co_return HandleResult::error("Archive error", "Unable to find placeholder unit record"); + } + d.user_id = resp.login_info.user_id; - d.exp = 1; - d.total_exp = 1; + if (!co_await UserUnitService::AddUnit(db, resp.login_info.user_id, d)) + { + co_return HandleResult::error("Database error", "Unable to add placeholder user unit"); + } - d.unit_id = 50253; - resp.unit_info.emplace_back(d); + if (!co_await UserUnitService::getUnits(db, resp.login_info.user_id, resp.unit_info) || resp.unit_info.empty()) + { + co_return HandleResult::error("Database error", "Unable to reload user units"); + } } + 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); } 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..efeb88d 160000 --- a/packet-generator +++ b/packet-generator @@ -1 +1 @@ -Subproject commit 37256a25049637980fcac4c7f5fafa1654b45d2e +Subproject commit efeb88df71bc937f5304710b45ad240124e2c80a