diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..cb01ddb --- /dev/null +++ b/BUILD.md @@ -0,0 +1,109 @@ +# Building the Decompfrontier Server (Windows) + +This repo uses CMake Presets + vcpkg. On Windows the only generator is **Ninja Multi-Config** — there is no Visual Studio solution preset. If you prefer the VS IDE, open the folder with `File > Open Folder` and VS will pick up the presets automatically. + +## Prerequisites + +1. **Visual Studio 2022 or 2026** with the "Desktop development with C++" workload. + The toolset version doesn't matter — Ninja picks up whatever `cl.exe` is on `PATH`. +2. **CMake** 3.21 or newer (ships with VS). +3. **Ninja** (ships with VS; or `choco install ninja`). +4. **vcpkg**. Set `VCPKG_ROOT` in your environment to the vcpkg checkout. +5. **Rust toolchain** (`cargo` on `PATH`). Install from . The build compiles the Rust-based `packet-generator` CLI from source on the first build. + +## One-time setup + +From any shell: + +```cmd +git clone https://github.com/microsoft/vcpkg %USERPROFILE%\vcpkg +%USERPROFILE%\vcpkg\bootstrap-vcpkg.bat +setx VCPKG_ROOT %USERPROFILE%\vcpkg +``` + +Restart your shell so `VCPKG_ROOT` is visible. + +## Configuring and building + +Every command below must run from an **x64 Native Tools Command Prompt** — specifically one where `VsDevCmd.bat -arch=amd64 -host_arch=amd64` has been sourced. This ensures the **x64-hosted** `cl.exe` (`Hostx64\x64\cl.exe`) is on PATH. + +> **Common mistake:** The regular "Developer PowerShell for VS 2026" and the default "Developer Command Prompt" both put the **x86-hosted** `cl.exe` on PATH. CMake then compiles x86 objects but tries to link them as x64, giving `LNK1112: module machine type 'x86' conflicts with target machine type 'x64'`. Always use one of the options below. + +**Option A — Start menu shortcut (easiest):** +Launch `x64 Native Tools Command Prompt for VS 2026` from the Start menu. + +**Option B — From any shell:** +```cmd +call "C:\Program Files\Microsoft Visual Studio\18\Community\Common7\Tools\VsDevCmd.bat" -arch=amd64 -host_arch=amd64 +``` + +Then build: + +```cmd +cd C:\path\to\BF-WorkingDirRust + +:: Configure (first time, or after editing CMake files) +cmake --preset debug-win64 + +:: Build Debug +cmake --build --preset debug-win64-debug + +:: Build Release +cmake --build --preset debug-win64-release +``` + +`rebuild.bat` handles all of this automatically — it calls `VsDevCmd.bat -arch=amd64 -host_arch=amd64` internally, so you can run it from any prompt. + +Artifacts land under `out/build/debug-win64/`. + +### Linux + +```bash +cmake --preset debug-lnx64 +cmake --build --preset debug-lnx64 +``` + +## Presets at a glance + +| Preset | Generator | Triplet | Frontend | +|---|---|---|---| +| `debug-win64` | Ninja Multi-Config | `x64-windows` | `STANDALONE` | +| `release-win32` | Ninja Multi-Config | `x86-windows-static` | `PROXYAPPX` | +| `debug-lnx64` | Ninja | `x64-linux` | `STANDALONE` | + +Both Windows presets produce Debug **and** Release binaries from one build tree — pick the configuration at build time via `--config Debug` / `--config Release`, or use the paired build presets (`debug-win64-debug`, `debug-win64-release`, etc.). + +## The packet-generator step + +The Rust CLI at `packet-generator/` compiles the KDL schemas in `packet-generator/assets/` into a single C++ header at `gimuserver/packets/all.hpp`. CMake runs this automatically via the `pkgen_generate` custom target whenever a `.kdl` file changes. + +- **First build is slow** (2–5 min) because Cargo compiles the generator itself. +- **Subsequent builds re-run the generator only when a `.kdl` file is newer than `gimuserver/packets/all.hpp`.** +- **Generated file is gitignored** — `gimuserver/packets/.gitignore` excludes `*.hpp`. + +To invoke the generator by hand (rarely needed): + +```cmd +cd packet-generator +cargo run --release -- generate --cxx --glaze -i assets/all.kdl -o ../gimuserver/packets +``` + +## Troubleshooting + +**`MSB8020: build tools for Visual Studio 2022 (Platform Toolset = 'v143') cannot be found`** +You're on an old preset that pinned the VS 17 2022 generator. Pull the latest presets — they all use Ninja Multi-Config now, which is toolset-agnostic. + +**`LNK1112: module machine type 'x86' conflicts with target machine type 'x64'`** +You ran CMake from a shell that has the **x86-hosted** `cl.exe` on PATH (e.g. regular Developer PowerShell or the default Developer Command Prompt). Delete `out/`, then re-run from the **x64 Native Tools Command Prompt for VS 2026** or via `rebuild.bat`. + +**`cl : command line error D8021 : invalid numeric argument`** or linker complains it can't find `kernel32.lib` +You're not in a Developer Command Prompt at all. `cl.exe` needs the VC environment set up first. + +**`cargo: command not found`** +Rust isn't on `PATH`. Install via and restart the shell. + +**First build hangs at `Regenerating C++ packet headers from KDL schemas`** +Cargo is compiling `packet-generator` from source. Wait it out; subsequent builds are fast. + +**Generated types don't exist after editing a KDL file** +Build again — the custom target re-runs on any `.kdl` change. If it still doesn't regenerate, delete `gimuserver/packets/all.hpp` and build; that forces a rerun. diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d2efb7..6d1db96 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,57 @@ find_package(unofficial-sqlite3 CONFIG REQUIRED) find_package(glaze CONFIG REQUIRED) # c++ JSON abstraction add_subdirectory(packet-generator/assets/runtime/cpp) + +# KDL -> C++ codegen. The packet-generator Rust CLI compiles assets/all.kdl +# into a single flat header (all.hpp) that gimuserver includes. The first +# build compiles the Rust generator itself (one-time, a few minutes); every +# subsequent build only re-runs it when a .kdl file has changed. +find_program(CARGO_EXECUTABLE cargo + DOC "Rust/Cargo toolchain, required to build packet-generator" +) +if (NOT CARGO_EXECUTABLE) + message(FATAL_ERROR + "cargo not found on PATH. Install Rust from https://rustup.rs/ " + "and re-run CMake configure." + ) +endif() + +set(PKGEN_SRC_DIR "${CMAKE_SOURCE_DIR}/packet-generator") +set(PKGEN_ENTRY "assets/all.kdl") +# App.hpp includes , so emit directly into the +# source tree under gimuserver/packets/. That directory has a .gitignore +# (*.hpp) that keeps the generated header out of version control. +set(PKGEN_OUT_DIR "${CMAKE_SOURCE_DIR}/gimuserver/packets") +set(PKGEN_OUT_HEADER "${PKGEN_OUT_DIR}/all.hpp") + +file(GLOB_RECURSE KDL_SCHEMAS CONFIGURE_DEPENDS + "${PKGEN_SRC_DIR}/assets/*.kdl" +) + +file(MAKE_DIRECTORY "${PKGEN_OUT_DIR}") + +add_custom_command( + OUTPUT "${PKGEN_OUT_HEADER}" + COMMAND "${CARGO_EXECUTABLE}" run --release -- + generate --cxx --glaze + -i "${PKGEN_ENTRY}" + -o "${PKGEN_OUT_DIR}" + WORKING_DIRECTORY "${PKGEN_SRC_DIR}" + DEPENDS ${KDL_SCHEMAS} + COMMENT "Regenerating C++ packet headers from KDL schemas" + VERBATIM +) + +add_custom_target(pkgen_generate ALL + DEPENDS "${PKGEN_OUT_HEADER}" + SOURCES ${KDL_SCHEMAS} +) + +message(STATUS + "packet-generator: first build compiles the Rust generator (~2-5 min); " + "subsequent builds only re-run it when .kdl files change." +) + add_subdirectory(gimuserver) if (STANDALONE) diff --git a/CMakePresets.json b/CMakePresets.json index 7dac0e0..0eaa67f 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -16,7 +16,7 @@ { "name": "default-debug", "displayName": "Config Debug", - "description": "General default debug configuration", + "description": "General default debug configuration (single-config generators only)", "inherits": "default-base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" @@ -26,7 +26,7 @@ { "name": "default-release", "displayName": "Config Release", - "description": "General default release configuration", + "description": "General default release configuration (single-config generators only)", "inherits": "default-base", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" @@ -35,10 +35,11 @@ }, { "name": "debug-win64", - "displayName": "Development config for Windows (64-bit)", - "inherits": "default-debug", - "generator": "Visual Studio 17 2022", - "architecture": "x64", + "displayName": "Development config for Windows (Ninja Multi-Config)", + "description": "Multi-config (Debug + Release) build for Windows x64. Must be run from an x64 Native Tools environment (VsDevCmd.bat -arch=amd64 -host_arch=amd64) so the x64-hosted cl.exe is on PATH. Do NOT set CMAKE_C/CXX_COMPILER here — let CMake find the compiler from the environment so it inherits the correct host/target architecture.", + "inherits": "default-base", + "generator": "Ninja Multi-Config", + "binaryDir": "${sourceDir}/out/build/${presetName}", "condition": { "type": "equals", "lhs": "${hostSystemName}", @@ -78,10 +79,15 @@ }, { "name": "release-win32", - "displayName": "Deployment for Brave Frontier for Windows", - "inherits": "default-release", - "generator": "Visual Studio 17 2022", - "architecture": "x64", + "displayName": "Deployment for Brave Frontier for Windows (APPX)", + "description": "Multi-config build producing the APPX-embedded server. Requires running from a Developer Command Prompt for Visual Studio.", + "inherits": "default-base", + "generator": "Ninja Multi-Config", + "binaryDir": "${sourceDir}/out/build/${presetName}", + "architecture": { + "value": "x64", + "strategy": "external" + }, "condition": { "type": "equals", "lhs": "${hostSystemName}", @@ -98,5 +104,31 @@ } } } + ], + "buildPresets": [ + { + "name": "debug-win64-debug", + "configurePreset": "debug-win64", + "configuration": "Debug" + }, + { + "name": "debug-win64-release", + "configurePreset": "debug-win64", + "configuration": "Release" + }, + { + "name": "release-win32-debug", + "configurePreset": "release-win32", + "configuration": "Debug" + }, + { + "name": "release-win32-release", + "configurePreset": "release-win32", + "configuration": "Release" + }, + { + "name": "debug-lnx64", + "configurePreset": "debug-lnx64" + } ] } diff --git a/MIGRATION_NOTES.md b/MIGRATION_NOTES.md new file mode 100644 index 0000000..94af3fb --- /dev/null +++ b/MIGRATION_NOTES.md @@ -0,0 +1,210 @@ +# Migration Notes + +Companion to `BUILD.md`. Targets someone who has landed from the older Seltraeh/server fork (at `C:\Users\Evan\BF\BF-WorkingDir\server\`) and needs to port its hand-written handlers onto the new, KDL-driven architecture. + +If you are reverse-engineering a brand-new request from scratch, the old tree's `HANDLER_REVERSING_PLAYBOOK.md` and `HANDLER_BLUEPRINT.md` still apply — with the response-construction step swapped out for this doc's KDL recipe. + +--- + +## What changed between the two trees + +| Dimension | Old fork (`BF-WorkingDir/server`) | New tree (this repo) | +|---|---|---| +| **Request parsing** | `req["Km35HAXv"][0]["edy7fq3L"].asUInt()` by hand | `FooReq req{}; glz::read_json(req, json)` | +| **Response building** | `Json::Value res; res["fEi17cnx"] = ...; snap->Serialize(res);` | Populate `FooResp` struct; `glz::write_json(resp)` | +| **Handler base class** | Virtual `HandlerBase` with `GetGroupId()` / `GetAesKey()` | `HANDLEF(Name)` macro emitting a coroutine that returns `HandleResult`. **AES key lives on the `REGISTER(...)` line, not on the handler class.** | +| **Registration** | `REGISTER_HANDLER(FooRequestHandler)` plus auto-registration | Single line in `gimuserver/gme/GmeControllerHandlers.cpp`: `REGISTER("<8-char ID>", FuncName, "");` | +| **Async DB** | Callback pyramids: `GME_DB->execSqlAsync(sql, succ_cb, err_cb, args...)` nested two or three deep | C++20 coroutines: linear `co_await`. No pyramid. | +| **Crypto** | `BfCrypt::ReadGME` / `BfCrypt::BuildGME` | Same names, unchanged. | +| **Handlers wired** | 288 | 12 (see list below) | +| **Response structs hand-coded** | 326 | 0 — all from KDL | +| **MST container** | `gimuserver/system/MstConfig.hpp` with hand-typed structs (`UnitMstData`, `ItemMstEntry`, etc.) | `gimuserver/drogon/ServerCacheMst.hpp` with an `auto_cache(key, name)` macro; MST structs come from `packet-generator/assets/mst/*.kdl`. **`UnitMst` is `auto_cache`d but not loaded** — the `LoadJson` call in `ServerCache.cpp` is commented out pending a hashed-key `unit.json` source file (see "Outstanding blockers" below). | + +### Handlers already ported + +`Initialize`, `BadgeInfo`, `ControlCenterEnter`, `DeckEdit`, `FriendGet`, `GatchaList`, `HomeInfo`, `MissionStart`, `UpdateInfoLight`, `UserInfo`, `UnitFavorite`, `UnitEvo` (stub — see notes). + +Wired in `gimuserver/gme/GmeControllerHandlers.cpp`. Skip these when porting. `UnitEvo` is registered but currently returns `{}`; the real implementation is gated on `UnitMst` being loaded. + +### Database schema + +The `user_units` table is now created by `gimuserver/db/MigrationManager.cpp` migrations (previously `#if 0`'d out). Schema mirrors the old fork: base columns (`id`, `user_id`, `unit_id`), 28 stat columns (level, base/add/ext/limit_over for hp/atk/def/heal, exp, skill ids, element, fe_bp, unit_type_id), 4 sphere-equip slots, and `favorite_flg`. AUTOINCREMENT is bumped to ≥10000 via a sentinel insert/delete — the client crashes on instance ids below ~10407. + +### Outstanding blockers + +**Hashed-key `unit.json` MST file.** The new tree's `deploy/system/` does not contain a wire-format `unit.json` for `LoadJson` to consume. The old fork ships friendly-key `F_UNIT_MST_*.json`; the field map for converting friendly → hashed lives in `packet-generator/assets/mst/unit.kdl`. Until this file is produced, `ServerCache::unitMst()` returns an empty vector and any handler that does an MST lookup (`UnitMix`, `UnitEvo` real impl, `UnitSell`) will silently no-op. + +--- + +## Handler shape, side by side + +**Old fork** — `UnitFavoriteRequestHandler::Handle`: + +```cpp +void Handler::UnitFavoriteRequestHandler::Handle(UserInfo& user, DrogonCallback cb, + const Json::Value& req) const +{ + const uint32_t userUnitId = req["Km35HAXv"][0]["edy7fq3L"].asUInt(); + const bool favorite = req["Zf3Cq6t9"].asBool(); // example + + GME_DB->execSqlAsync( + "UPDATE user_units SET favorite=? WHERE user_id=? AND id=?", + [cb](const drogon::orm::Result&) { + Json::Value res; + res["Zf3Cq6t9"] = true; + cb(newGmeOkResponse(GetGroupId().c_str(), GetAesKey().c_str(), res)); + }, + [cb](const drogon::orm::DrogonDbException& e) { + Json::Value res; + cb(newGmeOkResponse(GetGroupId().c_str(), GetAesKey().c_str(), res)); + }, + favorite, user.info.userID, userUnitId + ); +} +``` + +**New tree** — same handler as a coroutine: + +```cpp +// gimuserver/gme/UnitFavorite.cpp +#include "App.hpp" +#include "Handlers.hpp" + +HANDLEF(UnitFavorite) +{ + UnitFavoriteReq req{}; + if (const auto ec = glz::read_json(req, json); ec) { + co_return HandleResult::error("malformed request"); + } + + auto db = drogon::app().getDbClient(); + co_await db->execSqlCoro( + "UPDATE user_units SET favorite=? WHERE user_id=? AND id=?", + req.favorite, session->get("userID"), req.user_unit_id + ); + + UnitFavoriteResp resp{}; + resp.favorite = req.favorite; + co_return HandleResult::success(glz::write_json(resp)); +} +``` + +Plus one line in `GmeControllerHandlers.cpp`: + +```cpp +REGISTER("<8-char request ID>", UnitFavorite, "<16-char AES key>"); +``` + +The AES key carries over unchanged from the old `GetAesKey()`. + +--- + +## Coroutine translation traps + +Three shapes in the old code that don't translate mechanically: + +1. **Fire-and-forget writes.** Old code occasionally dispatches a DB write and returns a response *without* awaiting the write — typically for telemetry, logging, or non-critical state. Mechanically translating to `co_await` serializes the response behind the write. + **Fix:** keep it fire-and-forget with `drogon::app().getLoop()->queueInLoop([]{ /* write */ });` before you build and return the response. + +2. **Implicit transaction boundaries.** The old pyramid style sometimes held an implicit per-connection transaction across the callback chain. The coroutine form doesn't — every `co_await` can land on a different connection. + **Fix:** when the write sequence must be atomic (e.g. `UnitMix` DELETEing sacrificed units **and** crediting XP to the target), wrap in an explicit `drogon::orm::Transaction`. + +3. **Dynamic AES keys.** The new `REGISTER("id", func, "literal_key")` form bakes the AES key as a string literal at compile time. If any old handler returned a non-literal from `GetAesKey()` (session-derived, per-user-state), the one-line `REGISTER` can't represent it. + **Audit before porting:** `rg -n 'GetAesKey\b' C:\Users\Evan\BF\BF-WorkingDir\server\gimuserver\gme\handlers\` and look for any return that isn't a string literal. No known cases today, but confirm per batch. + +--- + +## How to build a KDL schema + +The language reference lives in the upstream packet-generator repo's HOWTO. What follows is the **repo-local** recipe: the exact steps that have worked for porting handlers on this branch, in order. + +1. **Find the captured request/response JSON.** + `C:\Users\Evan\BF\BF-WorkingDir\server\deploy\log_req\<8-char ID>_*.json` and `log_res\<8-char ID>_*.json`. The old tree has 1,056 request and 1,044 response captures — ample ground truth. + +2. **Harvest hashed keys from the old response struct.** + Open `C:\Users\Evan\BF\BF-WorkingDir\server\gimuserver\gme\response\Foo*.hpp`. The `Serialize(Json::Value&)` method spells every hashed key. Copy them to a scratchpad. + +3. **Pick a target KDL file.** + If the handler belongs to an existing feature topic, extend the relevant file under `packet-generator/assets/net/` (e.g. `user.kdl`, `items.kdl`, `gacha.kdl`). Otherwise author a new `foo.kdl` and add `import "net/foo.kdl"` to `packet-generator/assets/all.kdl`. + +4. **Author `FooReq` and `FooResp`.** + ```kdl + json FooReq { + doc "Short summary of this request" + + field user_unit_id type="i32::str" { + key "edy7fq3L" + doc "Target unit ID (quoted int on the wire)" + } + field favorite type="bool::int" { + key "Zf3Cq6t9" + doc "1 to favorite, 0 to unfavorite" + } + } + ``` + Every node needs a `doc`. The generator fails on a missing `doc` — that's intentional, so future-you can grep for what a hashed key means. + +5. **Pick type modifiers carefully.** + - Quoted integers → `i32::str` / `i64::str`. + - IDs, zel, timestamps → `i64` or `u64` (i32 overflows). + - Comma-joined arrays → `[i32::int]::sep(comma)`. Also `sep(at)` → `@`, `sep(colon)` → `|`. + - Always-one-element arrays → `[Foo]::size(1)` — see `packet-generator/assets/net/gme.kdl` for examples. + - `bool::int` for `0`/`1`, `bool::str` for `"false"`/`"true"`. + +6. **Rebuild.** + `cmake --build --preset debug-win64-debug` — the `pkgen_generate` target re-runs and rewrites `gimuserver/packets/all.hpp`. Read that file for the generated struct and confirm the shape. + +7. **Write the handler.** + Create `gimuserver/gme/Foo.cpp` mirroring `gimuserver/gme/Initialize.cpp`. Keep any business logic (MST lookups, DB work, XP math) **verbatim** from the old handler — the only parts that change are request parse (was `req["..."]`, now `glz::read_json`) and response build (was `Json::Value res["..."]`, now `glz::write_json(FooResp{...})`). + +8. **Wire it.** + Add a `REGISTER(...)` line in `gimuserver/gme/GmeControllerHandlers.cpp` between the existing entries. ID comes from the client capture; AES key is the string literal that was returned from the old handler's `GetAesKey()`. + +9. **Golden-diff test.** + Launch both servers side by side. Trigger the same action in the client. Diff the decrypted request and response JSON between the two captures. Any diff that isn't a deliberate improvement is a migration bug. + +--- + +## Porting order (big picture) + +1. **Dry-run: `UnitFavorite`.** Single DB column toggle, no formula math, tiny KDL. Validates the whole workflow before tackling anything larger. +2. **Prerequisite: surface `UnitMst` in `ServerCacheMst`.** ✅ Partially done. `auto_cache("2r9cNSdt", UnitMst)` is wired in `ServerCacheMst.hpp` and the `unitMst()` getter exists in `ServerCache.hpp/.cpp`. The `LoadJson(mstRoot, "unit.json")` line is **commented out** because no hashed-key `unit.json` exists in `deploy/system/`. Producing that file (friendly-key `F_UNIT_MST_*.json` → hashed wire format using the `unit.kdl` field map) is the gating task before any of the unit-math handlers below. +3. **Unit verbs**, in this order: `UnitMix` → `UnitEvo` → `UnitSell`. Mix validates the XP-math translation; Evo reuses Mix infrastructure for DELETE; Sell is simplest but depends on `UnitMst.sell_price`, so do it last in the family. +4. **Item verbs**: `ItemSphereEqp`, `ItemEdit`, `ItemSell`. +5. **Mission / Campaign Start+End, Gift/Receipt.** Audit the new tree for a `UserState::clear` equivalent before porting — old code relied on that helper. +6. **Arena / Challenge Arena / Colosseum / Vortex.** Each gets its own `.kdl` under `assets/net/`. +7. **Raid.** 40+ handlers — largest single chunk. Port last. + +Don't try to batch more than one feature family per branch. + +--- + +## Static data dedup rule + +The old tree's `server/deploy/system/` has 61 MST JSON files. Many are already present in the new `deploy/system/`. Before copying anything, check: + +1. **Exists in both, identical** → leave alone. +2. **Missing from new tree** → copy from old tree. +3. **Different contents** → investigate. Use these tiebreakers in order: + - Size or row-count ratio (old > 2× new, or vice versa → flag for manual review). + - Cross-check against captures in `deploy/log_res/`: whichever JSON's field values match the captures wins. + - `git log -- deploy/system/` on the new tree: a single commit with "stub", "placeholder", or "todo" in the message tells you to keep the old file. + +A dedup helper script at `scripts/compare_deploy_data.py` (to be written before the first static-data port) automates the size/row-count report. + +--- + +## Quick references + +| Concern | Where to look | +|---|---| +| New-tree handler registration | `gimuserver/gme/GmeControllerHandlers.cpp` | +| Generated C++ from KDL | `gimuserver/packets/all.hpp` (gitignored) | +| KDL schemas | `packet-generator/assets/net/*.kdl`, `packet-generator/assets/mst/*.kdl` | +| MST caching | `gimuserver/drogon/ServerCacheMst.hpp`, `ServerCache.cpp` | +| Crypto | `gimuserver/utils/BfCrypt.hpp` (unchanged from old tree) | +| Sample ported handler | `gimuserver/gme/Initialize.cpp` | +| Old handler reference | `C:\Users\Evan\BF\BF-WorkingDir\server\gimuserver\gme\handlers\` | +| Old response reference | `C:\Users\Evan\BF\BF-WorkingDir\server\gimuserver\gme\response\` | +| Old reverse-engineering playbooks | `C:\Users\Evan\BF\BF-WorkingDir\server\HANDLER_BLUEPRINT.md`, `HANDLER_REVERSING_PLAYBOOK.md` | +| Upstream migration guide | `C:\Users\Evan\Downloads\Decompfrontier_Server_Migration_Guide (1).docx` | diff --git a/generate.py b/generate.py deleted file mode 100644 index a529b79..0000000 --- a/generate.py +++ /dev/null @@ -1,28 +0,0 @@ -### TODO(arves): THIS SCRIPT MUST BE REMOVED !!! - -import subprocess -import glob -import os -from pathlib import Path - -base_dir = "packet-generator/assets" -output_base = "gimuserver/packets" -kdl_file = "packet-generator/assets/all.kdl" - -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) - -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 diff --git a/gimuserver/CMakeLists.txt b/gimuserver/CMakeLists.txt index adf08cf..287005e 100644 --- a/gimuserver/CMakeLists.txt +++ b/gimuserver/CMakeLists.txt @@ -1,7 +1,13 @@ file(GLOB_RECURSE SRC "*.cpp" "*.hpp") source_group(TREE "${CMAKE_CURRENT_LIST_DIR}" FILES ${SRC}) add_library(gimuserver STATIC ${SRC}) -target_link_libraries(gimuserver PUBLIC + +# The generated header (packets/all.hpp) is produced by pkgen_generate, +# declared at the project root. Everything in gimuserver transitively +# depends on it via App.hpp, so pin the build-order edge here. +add_dependencies(gimuserver pkgen_generate) + +target_link_libraries(gimuserver PUBLIC # third-party libraries Drogon::Drogon cryptopp::cryptopp @@ -11,10 +17,9 @@ target_link_libraries(gimuserver PUBLIC pkgen_cpp ) -target_include_directories(gimuserver +target_include_directories(gimuserver PUBLIC ../ - ${CMAKE_CURRENT_BINARY_DIR}/generated/ PRIVATE . ) diff --git a/gimuserver/db/MigrationManager.cpp b/gimuserver/db/MigrationManager.cpp index 66e4ca4..9c7c42e 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 @@ -49,17 +50,99 @@ 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 + "id INTEGER PRIMARY KEY AUTOINCREMENT," + "user_id TEXT NOT NULL," + "unit_id TEXT NOT NULL," + "UNIQUE(user_id, unit_id)" ");" ); + // Bump AUTOINCREMENT to >= 10000; the client crashes on instance ids < ~10407. + p->execSqlSync( + "INSERT INTO user_units (id, user_id, unit_id) VALUES (10000, '__sentinel__', '__sentinel__');" + ); + p->execSqlSync( + "DELETE FROM user_units WHERE user_id = '__sentinel__';" + ); + }); + + migrate("13032025_AddStatsToUserUnitsTable", { + p->execSqlSync("ALTER TABLE user_units ADD COLUMN unit_lv INTEGER DEFAULT 1"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN base_hp INTEGER DEFAULT 1000"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN add_hp INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN ext_hp INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN limit_over_hp INTEGER DEFAULT 200"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN base_atk INTEGER DEFAULT 1000"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN add_atk INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN ext_atk INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN limit_over_atk INTEGER DEFAULT 200"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN base_def INTEGER DEFAULT 1000"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN add_def INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN ext_def INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN limit_over_def INTEGER DEFAULT 200"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN base_heal INTEGER DEFAULT 1000"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN add_heal INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN ext_heal INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN limit_over_heal INTEGER DEFAULT 200"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN exp INTEGER DEFAULT 1"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN total_exp INTEGER DEFAULT 1"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN skill_id INTEGER DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN skill_lv INTEGER DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN extra_skill_id INTEGER DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN extra_skill_lv INTEGER DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN leader_skill_id INTEGER DEFAULT 0"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN element TEXT DEFAULT 'fire'"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN fe_bp INTEGER DEFAULT 100"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN fe_max_usable_bp INTEGER DEFAULT 200"); + p->execSqlSync("ALTER TABLE user_units ADD COLUMN unit_type_id INTEGER DEFAULT 1"); + }); + + // Sphere equipment slots (UserUnitInfo: Ge8Yo32T/0R3qTPK9, mZA7fH2v/RXfC31FA). + migrate("09042026_AddSphereSlotsToUserUnits", { + 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"]). + migrate("14042026_AddFavoriteFlgToUserUnits", { + p->execSqlSync("ALTER TABLE user_units ADD COLUMN favorite_flg INTEGER NOT NULL DEFAULT 0"); + }); + + // Bump free_gems to 95 000 on any DB where the seed row already existed + // with the old value of 0. The original SeedDefaultUserInfo INSERT used + // free_gems=0; this migration is a targeted one-shot fix. + migrate("22042026_SetFreeGems", { + p->execSqlSync( + "UPDATE userinfo SET free_gems=95000 WHERE id='12345678';" + ); + }); + + migrate("21042026_SeedDefaultUserInfo", { + p->execSqlSync( + "INSERT OR IGNORE INTO userinfo (" + "id, gumi_user_id, device_id, username, level, exp," + "max_unit_count, max_friend_count, zel, karma, brave_coin," + "max_warehouse_count, free_gems, paid_gems, energy" + ") VALUES (" + "'12345678','12345678','offline','DecompDev',900,1009680," + "200,100,99000000,99000000,0," + "200,95000,99000,398);" + ); + p->execSqlSync( + "UPDATE userinfo SET" + " username='DecompDev'," + " level=900," + " zel=99000000," + " karma=99000000," + " paid_gems=99000," + " free_gems=95000" + " WHERE id='12345678';" + ); }); -#endif } /*! diff --git a/gimuserver/drogon/GimuServer.cpp b/gimuserver/drogon/GimuServer.cpp index 7d34a0e..cb9cd06 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) @@ -47,4 +49,79 @@ void GimuServer::initAndStart(const Json::Value& config) m_cache.Setup(server); } +/*! +* Maps a UnitMst element integer to the wire-format string expected by the client. +*/ +static std::string_view ElementIdToString(int32_t id) +{ + switch (id) + { + case 1: return "fire"; + case 2: return "water"; + case 3: return "earth"; + case 4: return "thunder"; + case 5: return "light"; + case 6: return "dark"; + default: return "fire"; + } +} + +void GimuServer::SeedDefaultUnits(drogon::orm::DbClientPtr db) +{ + const std::string userId = "0839899613932562"; + + const auto& mst = m_cache.unitMst(); + if (mst.empty()) + { + LOG_WARN << "SeedDefaultUnits: UnitMst is empty, skipping unit seed"; + return; + } + + // Idempotency check — skip if this user already owns units + const auto countRes = db->execSqlSync( + "SELECT COUNT(*) FROM user_units WHERE user_id=$1;", userId); + if (countRes[0][0].as() > 0) + { + LOG_INFO << "SeedDefaultUnits: units already present, skipping"; + return; + } + + // Randomise unit_type_id (1-6: Lord/Anima/Breaker/Guardian/Oracle/Rex) + // so that stat changes are visually distinguishable across the inventory. + std::mt19937 rng(std::random_device{}()); + std::uniform_int_distribution typeDist(1, 6); + + int seeded = 0; + for (const auto& unit : mst) + { + if (unit.id == 1) continue; // skip summoner NPC + if (seeded >= 100) break; + + db->execSqlSync( + "INSERT INTO user_units " + "(user_id, unit_id, unit_lv," + " 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_heal,add_heal,ext_heal,limit_over_heal," + " 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,0,0,0," + " 1,1," + " $7,0,$8,0,$9," + " $10,100,200,$11);", + userId, std::to_string(unit.id), + unit.min_hp, unit.min_atk, unit.min_def, unit.min_rec, + unit.skill_id, unit.extra_skill_id, unit.leader_skill_id, + std::string(ElementIdToString(unit.element)), + typeDist(rng) + ); + ++seeded; + } + + LOG_INFO << "SeedDefaultUnits: seeded " << seeded << " units for user " << userId; +} + void GimuServer::shutdown() {} diff --git a/gimuserver/drogon/GimuServer.hpp b/gimuserver/drogon/GimuServer.hpp index b9656f8..a4ec72a 100644 --- a/gimuserver/drogon/GimuServer.hpp +++ b/gimuserver/drogon/GimuServer.hpp @@ -65,7 +65,17 @@ class GimuServer final : public drogon::Plugin return log; } + /*! + * Seeds the default user's unit inventory from the cached UnitMst if the + * user has no rows in user_units yet. Must be called after migrations have + * run (i.e. from registerBeginningAdvice, not initAndStart). No-op if units + * already exist. + * @param db Synchronous DB client (from drogon::app().getDbClient()) + */ + void SeedDefaultUnits(drogon::orm::DbClientPtr db); + private: + /*! * DLC error file. */ diff --git a/gimuserver/drogon/ServerCache.cpp b/gimuserver/drogon/ServerCache.cpp index b74cf60..13a09e9 100644 --- a/gimuserver/drogon/ServerCache.cpp +++ b/gimuserver/drogon/ServerCache.cpp @@ -122,6 +122,8 @@ void ServerCache::Setup(const Json::Value& serverObj) m_userrsp.summon_ticket_v2 = LoadJson(mstRoot, "summon_tickets_v2.json").data; m_userrsp.resummon_gacha = LoadJson(mstRoot, "resummon_gacha.json").data; + m_unitMst = LoadJson(mstRoot, "unit.json").data; + // TODO(arves): move this to generated per-used as there's no support for the claim m_initrsp.daily_task_bonuses = LoadJson(mstRoot, "TEMP_daily_tasks_bonus.json"); m_initrsp.daily_task_prizes = LoadJson(mstRoot, "TEMP_daily_tasks_prizes.json").data; diff --git a/gimuserver/drogon/ServerCache.hpp b/gimuserver/drogon/ServerCache.hpp index bdd7d58..a482e81 100644 --- a/gimuserver/drogon/ServerCache.hpp +++ b/gimuserver/drogon/ServerCache.hpp @@ -50,6 +50,14 @@ class ServerCache final : public trantor::NonCopyable */ inline const auto& serverConfig() const { return m_serverConfig; } + /*! + * Unit master data (F_UNIT_MST). Empty until deploy/system/unit.json + * (hashed-key format, wrapper key "2r9cNSdt") is added and the loader + * in ServerCache::Setup is uncommented. + * @return Vector of UnitMst entries + */ + inline const auto& unitMst() const { return m_unitMst; } + private: /*! * DLS cached JSON. @@ -80,4 +88,9 @@ class ServerCache final : public trantor::NonCopyable * User info response. */ UserInfoResp m_userrsp; + + /*! + * Unit master data, keyed/iterated by Unit handler ports. + */ + std::vector m_unitMst; }; diff --git a/gimuserver/drogon/ServerCacheMst.hpp b/gimuserver/drogon/ServerCacheMst.hpp index 0aa57e2..7aaaf8d 100644 --- a/gimuserver/drogon/ServerCacheMst.hpp +++ b/gimuserver/drogon/ServerCacheMst.hpp @@ -54,3 +54,4 @@ auto_cache("da3qD39b", ResummonGachaMst) auto_cache("5C9LuNrk", HelpSubMst) auto_cache("P8V71kbw", ChallengeRankRewardMst) auto_cache("nUmaEC41", ChallengeMvpMst) +auto_cache("2r9cNSdt", UnitMst) diff --git a/gimuserver/gme/ChallengeArenaResetInfo.cpp b/gimuserver/gme/ChallengeArenaResetInfo.cpp new file mode 100644 index 0000000..0f20cca --- /dev/null +++ b/gimuserver/gme/ChallengeArenaResetInfo.cpp @@ -0,0 +1,9 @@ +#include "App.hpp" +#include "Handlers.hpp" + +HANDLEF(ChallengeArenaResetInfo) +{ + (void)session; + (void)json; + co_return HandleResult::success("{}"); +} diff --git a/gimuserver/gme/GmeControllerHandlers.cpp b/gimuserver/gme/GmeControllerHandlers.cpp index 27f3458..d71241d 100644 --- a/gimuserver/gme/GmeControllerHandlers.cpp +++ b/gimuserver/gme/GmeControllerHandlers.cpp @@ -74,6 +74,11 @@ static GmeHandler getHandler(std::string_view cmd) REGISTER("jE6Sp0q4", MissionStart, "csiVLDKkxEwBfR70"); REGISTER("ynB7X5P9", UpdateInfoLight, "7kH9NXwC"); REGISTER("cTZ3W2JG", UserInfo, "ScJx6ywWEb0A3njT"); + REGISTER("2p9LHCNh", UnitFavorite, "cb4ESLa1"); + REGISTER("0gUSE84e", UnitEvo, "biHf01DxcrPou5Qt"); + REGISTER("Mw08CIg2", UnitMix, "JnegC7RrN3FoW8dQ"); + REGISTER("Ri3uTq9b", UnitSell, "92VqcGFWuPkmT60U"); + REGISTER("Zw3WIoWu", ChallengeArenaResetInfo, "KlwYMGF1"); } } diff --git a/gimuserver/gme/Handlers.hpp b/gimuserver/gme/Handlers.hpp index c3bce4d..de04438 100644 --- a/gimuserver/gme/Handlers.hpp +++ b/gimuserver/gme/Handlers.hpp @@ -89,4 +89,9 @@ namespace GmeHandlers HANDLE(MissionStart); HANDLE(UpdateInfoLight); HANDLE(UserInfo); + HANDLE(UnitFavorite); + HANDLE(UnitEvo); + HANDLE(UnitMix); + HANDLE(UnitSell); + HANDLE(ChallengeArenaResetInfo); } diff --git a/gimuserver/gme/Initialize.cpp b/gimuserver/gme/Initialize.cpp index 64bf9c2..82ecfcf 100644 --- a/gimuserver/gme/Initialize.cpp +++ b/gimuserver/gme/Initialize.cpp @@ -19,55 +19,18 @@ HANDLEF(Initialize) InitializeResp resp = theServer()->cache().initializeResp(); // copy !! -#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()) - { - // 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); - } - else - { - // 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(); - } -#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 + const auto& userRows = co_await theDb()->execSqlCoro( + "SELECT username, debug_mode FROM userinfo WHERE id=$1;", + std::string("12345678") + ); + const auto& userRow = userRows.at(0); + + resp.login_info.account_id = "12345678"; + resp.login_info.user_id = "0839899613932562"; // packet-gen UUID + resp.login_info.handle_name = userRow["username"].as(); + resp.login_info.debug_mode = userRow["debug_mode"].as(); resp.login_info.tutorial_end_flag = true; - resp.login_info.tutorial_status = 12; + resp.login_info.tutorial_status = 12; //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/UnitEvo.cpp b/gimuserver/gme/UnitEvo.cpp new file mode 100644 index 0000000..6a3eae9 --- /dev/null +++ b/gimuserver/gme/UnitEvo.cpp @@ -0,0 +1,455 @@ +#include "App.hpp" +#include "Handlers.hpp" + +// UnitEvo — evolve a unit into its next form. +// +// Request (group 0gUSE84e, key biHf01DxcrPou5Qt): +// "Km35HAXv": [{"edy7fq3L":"","mnZ5K4Ii":"1"}, // base unit (role 1) +// {"edy7fq3L":"", "mnZ5K4Ii":"2"}, …] // evo mats (role 2) +// "I82p0wCL": [{"pn16CNah":""}] // evolved-to unit MST id +// "mCE3rUu5": [{"Rs7bCE3t":""}] // zel cost +// +// Elem-item variant sends the base unit id under "8Z2NQrx1" instead of +// the role-1 entry inside "Km35HAXv"; both variants are handled here. +// +// Response: +// "I82p0wCL": [EvoResultEntry] — tells the client which MST id was evolved into +// "qC2tJs4E": [UserUnitInfo] — incremental unit cache update +// "fEi17cnx": [UserTeamInfo] — updated zel + +// --------------------------------------------------------------------------- +// Request parsing structs +// --------------------------------------------------------------------------- +struct UnitEvoUnitEntry { + int32_t user_unit_id = 0; + std::string role; +}; +template <> struct glz::meta { + using T = UnitEvoUnitEntry; + static constexpr auto value = glz::object( + "edy7fq3L", glz::quoted_num<&T::user_unit_id>, + "mnZ5K4Ii", &T::role + ); +}; + +struct UnitEvoTargetEntry { + int32_t target_mst_id = 0; +}; +template <> struct glz::meta { + using T = UnitEvoTargetEntry; + static constexpr auto value = glz::object( + "pn16CNah", glz::quoted_num<&T::target_mst_id> + ); +}; + +struct UnitEvoZelEntry { + int32_t cost = 0; +}; +template <> struct glz::meta { + using T = UnitEvoZelEntry; + static constexpr auto value = glz::object( + "Rs7bCE3t", glz::quoted_num<&T::cost> + ); +}; + +struct UnitEvoElemEntry { + int32_t user_unit_id = 0; + std::string role; +}; +template <> struct glz::meta { + using T = UnitEvoElemEntry; + static constexpr auto value = glz::object( + "inU8Q4gL", glz::quoted_num<&T::user_unit_id>, + "mnZ5K4Ii", &T::role + ); +}; + +struct UnitEvoReqFull { + std::vector units; + std::vector target; + std::vector zel_cost_list; + std::vector elem_units; +}; +template <> struct glz::meta { + using T = UnitEvoReqFull; + static constexpr auto value = glz::object( + "Km35HAXv", &T::units, + "I82p0wCL", &T::target, + "mCE3rUu5", &T::zel_cost_list, + "8Z2NQrx1", &T::elem_units + ); +}; + +// --------------------------------------------------------------------------- +// Response structs +// --------------------------------------------------------------------------- +struct EvoResultEntry { + int32_t evolved_unit_id = 0; // pn16CNah — target MST id (what it evolved into) + int32_t user_unit_id = 0; // edy7fq3L — DB instance id of the evolved unit + int32_t unk_t9FEW2KC = 0; // t9FEW2KC — unknown, send 0 + int32_t unk_u1ECvfg8 = 0; // u1ECvfg8 — unknown, send 0 + int32_t unk_dV3qji4I = 0; // dV3qji4I — unknown, send 0 +}; +template <> struct glz::meta { + using T = EvoResultEntry; + static constexpr auto value = glz::object( + "pn16CNah", glz::quoted_num<&T::evolved_unit_id>, + "edy7fq3L", glz::quoted_num<&T::user_unit_id>, + "t9FEW2KC", glz::quoted_num<&T::unk_t9FEW2KC>, + "u1ECvfg8", glz::quoted_num<&T::unk_u1ECvfg8>, + "dV3qji4I", glz::quoted_num<&T::unk_dV3qji4I> + ); +}; + +// EvoReinforceEntry — xZH6EIQ7 payload (same wire format as MixReinforceEntry). +// The client's animation / stat-display code expects this key after every evo. +struct EvoReinforceEntry { + std::string handle_name; + int32_t target_lv = 0; + std::string unit_mst_id; + int32_t base_hp=0, base_atk=0, base_def=0, base_heal=0; + int32_t add_hp=0, add_atk=0, add_def=0, add_heal=0; + int32_t ext_hp=0, ext_atk=0, ext_def=0; + std::string skill_id, extra_skill_id; + int32_t skill_lv=0, extra_skill_lv=0, unit_type_id=0; + std::string mission_id; +}; +template <> struct glz::meta { + using T = EvoReinforceEntry; + static constexpr auto value = glz::object( + "B5JQyV8j", &T::handle_name, + "4A6LzBxr", glz::quoted_num<&T::target_lv>, + "pn16CNah", &T::unit_mst_id, + "e7DK0FQT", glz::quoted_num<&T::base_hp>, + "67CApcti", glz::quoted_num<&T::base_atk>, + "q08xLEsy", glz::quoted_num<&T::base_def>, + "PWXu25cg", glz::quoted_num<&T::base_heal>, + "cuIWp89g", glz::quoted_num<&T::add_hp>, + "RT4CtH5d", glz::quoted_num<&T::add_atk>, + "GcMD0hy6", glz::quoted_num<&T::add_def>, + "C1HZr3pb", glz::quoted_num<&T::add_heal>, + "TokWs1B3", glz::quoted_num<&T::ext_hp>, + "t4m1RH6Y", glz::quoted_num<&T::ext_atk>, + "e6mY8Z0k", glz::quoted_num<&T::ext_def>, + "nj9Lw7mV", &T::skill_id, + "3NbeC8AB", glz::quoted_num<&T::skill_lv>, + "iEFZ6H19", &T::extra_skill_id, + "RQ5GnFE2", glz::quoted_num<&T::extra_skill_lv>, + "nBTx56W9", glz::quoted_num<&T::unit_type_id>, + "Ge8Yo32T", &T::mission_id + ); +}; + +struct UnitEvoRespBody { + std::vector evo_result; + std::vector unit_update; + UserTeamInfo team_info = {}; + std::vector reinforce; +}; +template <> struct glz::meta { + using T = UnitEvoRespBody; + static constexpr auto value = glz::object( + "I82p0wCL", &T::evo_result, + "qC2tJs4E", &T::unit_update, + "fEi17cnx", pkg::glaze::single_array<&T::team_info>(), + "xZH6EIQ7", &T::reinforce + ); +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static std::string unitEvo_addSuffix(int32_t mstId) +{ + return std::to_string(mstId) + "_100"; +} + +static UserTeamInfo unitEvo_buildTeamInfo(const drogon::orm::Row& row, + const std::vector& prog) +{ + const int32_t level = row["level"].as(); + const UserLevelMst* lv = nullptr; + for (const auto& e : prog) { if (e.level == level) { lv = &e; break; } } + + UserTeamInfo ti = {}; + ti.user_id = "0839899613932562"; + ti.level = level; + ti.exp = row["exp"].as(); + ti.zel = row["zel"].as(); + ti.karma = row["karma"].as(); + ti.brave_coin = row["brave_coin"].as(); + ti.action_point = row["energy"].as(); + ti.max_action_point = lv ? lv->action_points : 100; + ti.deck_cost = lv ? lv->deck_cost : 20; + ti.max_friend_count = lv ? lv->friend_count : 50; + ti.add_friend_count = lv ? lv->add_friend_count : 0; + ti.max_unit_count = row["max_unit_count"].as(); + ti.warehouse_count = row["max_warehouse_count"].as(); + ti.active_deck = row["active_deck"].as(); + ti.summon_ticket = row["summon_tickets"].as(); + ti.rainbow_coin = row["rainbow_coins"].as(); + ti.colosseum_ticket = row["colosseum_tickets"].as(); + ti.brave_points_total = row["total_brave_points"].as(); + ti.current_brave_points = row["avail_brave_points"].as(); + ti.want_gift = row["want_gift"].as(); + ti.paid_gems = row["paid_gems"].as(); + ti.free_gems = row["free_gems"].as(); + ti.reinforcement_deck.emplace_back(0); + ti.reinforcement_deck.emplace_back(0); + ti.reinforcement_deck.emplace_back(0); + return ti; +} + +static std::string unitEvo_elementStr(int e) +{ + switch (e) { + case 1: return "fire"; + case 2: return "water"; + case 3: return "earth"; + case 4: return "thunder"; + case 5: return "light"; + case 6: return "dark"; + default: return "fire"; + } +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +HANDLEF(UnitEvo) +{ + (void)session; + LOG_INFO << "UnitEvo: " << json; + + static constexpr std::string_view kUserId = "0839899613932562"; + static constexpr std::string_view kInfoId = "12345678"; + + // Allow unknown keys — UnitEvo requests can carry extra client-side fields. + UnitEvoReqFull req = {}; + { + glz::context ctx{}; + if (const auto& ec = glz::read(req, json, ctx); ec) + { + LOG_WARN << "UnitEvo: bad request JSON: " << glz::format_error(ec, json); + co_return HandleResult::error("Deserialization error"); + } + } + + // Resolve base (role=1) and material (role=2) unit ids. + int32_t baseId = 0; + std::vector matIds; + for (const auto& u : req.units) + { + if (u.role == "1") baseId = u.user_unit_id; + else if (u.user_unit_id) matIds.push_back(u.user_unit_id); + } + // Elem-item variant: 8Z2NQrx1 carries all units (base + mats) with role tags. + // "inU8Q4gL" is the user_unit_id; "mnZ5K4Ii" is "1" (base) or "2" (material). + for (const auto& e : req.elem_units) + { + if (e.role == "1") baseId = e.user_unit_id; + else if (e.user_unit_id) matIds.push_back(e.user_unit_id); + } + + if (baseId == 0 || req.target.empty()) + { + LOG_WARN << "UnitEvo: missing base unit or target mst id"; + co_return HandleResult::error("UnitEvo: incomplete request"); + } + + const int32_t targetMstId = req.target[0].target_mst_id; + const int32_t zelCost = req.zel_cost_list.empty() ? 0 : req.zel_cost_list[0].cost; + + // Find target unit in MST cache. + const auto& unitMst = theServer()->cache().unitMst(); + const UnitMst* targetMst = nullptr; + for (const auto& u : unitMst) { if (u.id == targetMstId) { targetMst = &u; break; } } + + if (!targetMst) + { + LOG_WARN << "UnitEvo: target MST id " << targetMstId << " not found"; + co_return HandleResult::error("UnitEvo: unknown target unit"); + } + + // Step 1: SELECT current base unit (need IMP/ext/limitOver cols to preserve). + // Also fetch unit_id so we can extract the original MST id for the animation. + const auto baseRows = co_await theDb()->execSqlCoro( + "SELECT id, unit_id, add_hp, add_atk, add_def, add_heal," + " ext_hp, ext_atk, ext_def, ext_heal," + " limit_over_hp, limit_over_atk, limit_over_def, limit_over_heal," + " fe_bp, fe_max_usable_bp, unit_type_id," + " eqip_item_id, eqip_item_frame_id, eqip_item_id2, eqip_item_frame_id2" + " FROM user_units WHERE user_id=$1 AND id=$2 LIMIT 1;", + std::string(kUserId), baseId + ); + + if (baseRows.empty()) + { + LOG_WARN << "UnitEvo: base unit " << baseId << " not found"; + co_return HandleResult::error("UnitEvo: base unit not found"); + } + const auto& br = baseRows[0]; + + // Extract original MST id from the unit_id column (e.g., "10011" or "10011_100" → 10011). + // The client evo animation uses this to display the "before" unit. + int32_t origMstId = 0; + { + const std::string rawUnitId = br["unit_id"].as(); + const auto pos = rawUnitId.find('_'); + const std::string numPart = (pos != std::string::npos) ? rawUnitId.substr(0, pos) : rawUnitId; + if (!numPart.empty()) { try { origMstId = std::stoi(numPart); } catch (...) {} } + } + + // IMP stats are player investment — preserved through evolution. + const int32_t keepAddHp = br["add_hp"].as(); + const int32_t keepAddAtk = br["add_atk"].as(); + const int32_t keepAddDef = br["add_def"].as(); + const int32_t keepAddHeal = br["add_heal"].as(); + + const std::string newElement = unitEvo_elementStr(targetMst->element); + + // Step 2: UPDATE base unit row to the evolved form. + // - New unit_id, reset level/exp to 1/0, reset base stats from target MST. + // - Preserve: add_* (IMP), ext_*, limit_over_*, equipment, FE. + co_await theDb()->execSqlCoro( + "UPDATE user_units SET" + " unit_id=$1," + " unit_lv=1, exp=0, total_exp=0," + " base_hp=$2, base_atk=$3, base_def=$4, base_heal=$5," + " add_hp=$6, add_atk=$7, add_def=$8, add_heal=$9," + " leader_skill_id=$10, skill_id=$11, extra_skill_id=$12," + " skill_lv=1, extra_skill_lv=0," + " element=$13" + " WHERE id=$14 AND user_id=$15;", + unitEvo_addSuffix(targetMstId), + targetMst->min_hp, targetMst->min_atk, targetMst->min_def, targetMst->min_rec, + keepAddHp, keepAddAtk, keepAddDef, keepAddHeal, + targetMst->leader_skill_id, targetMst->skill_id, targetMst->extra_skill_id, + newElement, + baseId, std::string(kUserId) + ); + + // Step 3: DELETE evo material units. + if (!matIds.empty()) + { + std::string matList; + for (size_t i = 0; i < matIds.size(); ++i) + { + if (i) matList += ','; + matList += std::to_string(matIds[i]); + } + co_await theDb()->execSqlCoro( + "DELETE FROM user_units WHERE user_id=$1 AND id IN (" + matList + ");", + std::string(kUserId) + ); + } + + // Step 4: deduct zel. + if (zelCost > 0) + { + co_await theDb()->execSqlCoro( + "UPDATE userinfo SET zel = MAX(0, zel - $1) WHERE id=$2;", + zelCost, std::string(kInfoId) + ); + } + + // Step 5: fresh userinfo for team_info. + const auto infoRows = co_await theDb()->execSqlCoro( + "SELECT level, exp, zel, karma, brave_coin, free_gems, paid_gems, energy," + " max_unit_count, max_warehouse_count, summon_tickets, rainbow_coins," + " colosseum_tickets, total_brave_points, avail_brave_points," + " active_deck, want_gift FROM userinfo WHERE id=$1;", + std::string(kInfoId) + ); + + // Build response. + UnitEvoRespBody resp = {}; + + { + EvoResultEntry er = {}; + er.evolved_unit_id = targetMstId; // pn16CNah — the unit it evolved INTO + er.user_unit_id = baseId; // edy7fq3L — DB instance id + er.unk_t9FEW2KC = origMstId; // t9FEW2KC — original ("before") MST id for evo animation + resp.evo_result.emplace_back(er); + } + + { + UserUnitInfo ud = {}; + ud.user_id = std::string(kUserId); + ud.user_unit_id = br["id"].as(); + ud.unit_id = targetMstId; + ud.unit_type_id = br["unit_type_id"].as(); + ud.unit_lv = 1; + ud.exp = 0; + ud.total_exp = 0; + ud.base_hp = targetMst->min_hp; + ud.add_hp = keepAddHp; + ud.ext_hp = br["ext_hp"].as(); + ud.limit_over_hp = br["limit_over_hp"].as(); + ud.base_atk = targetMst->min_atk; + ud.add_atk = keepAddAtk; + ud.ext_atk = br["ext_atk"].as(); + ud.limit_over_atk = br["limit_over_atk"].as(); + ud.base_def = targetMst->min_def; + ud.add_def = keepAddDef; + ud.ext_def = br["ext_def"].as(); + ud.limit_over_def = br["limit_over_def"].as(); + ud.base_heal = targetMst->min_rec; + ud.add_heal = keepAddHeal; + ud.ext_heal = br["ext_heal"].as(); + ud.limit_over_heal = br["limit_over_heal"].as(); + ud.element = newElement; + ud.leader_skill_id = targetMst->leader_skill_id; + ud.skill_id = targetMst->skill_id; + ud.skill_lv = 1; + ud.extra_skill_id = targetMst->extra_skill_id; + ud.extra_skill_lv = 0; + ud.equipitem_id = br["eqip_item_id"].as(); + ud.equipitem_frame_id = br["eqip_item_frame_id"].as(); + ud.equipitem_id2 = br["eqip_item_id2"].as(); + ud.equipitem_frame_id2 = br["eqip_item_frame_id2"].as(); + ud.fe_bp = br["fe_bp"].as(); + ud.fe_max_usable_bp = br["fe_max_usable_bp"].as(); + ud.new_flag = true; + resp.unit_update.emplace_back(std::move(ud)); + } + + resp.team_info = unitEvo_buildTeamInfo( + infoRows.at(0), + theServer()->cache().initializeResp().progression + ); + + { + EvoReinforceEntry rd = {}; + rd.handle_name = "DecompDev"; + rd.target_lv = 1; // level resets to 1 after evo + rd.unit_mst_id = std::to_string(targetMstId); + rd.base_hp = targetMst->min_hp; + rd.base_atk = targetMst->min_atk; + rd.base_def = targetMst->min_def; + rd.base_heal = targetMst->min_rec; + rd.add_hp = keepAddHp; + rd.add_atk = keepAddAtk; + rd.add_def = keepAddDef; + rd.add_heal = keepAddHeal; + rd.ext_hp = br["ext_hp"].as(); + rd.ext_atk = br["ext_atk"].as(); + rd.ext_def = br["ext_def"].as(); + rd.skill_id = std::to_string(targetMst->skill_id); + rd.skill_lv = 1; + rd.extra_skill_id = std::to_string(targetMst->extra_skill_id); + rd.extra_skill_lv = 0; + rd.unit_type_id = br["unit_type_id"].as(); + rd.mission_id = ""; + resp.reinforce.emplace_back(std::move(rd)); + } + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + LOG_ERROR << "UnitEvo: serialization error: " << glz::format_error(ec2, buffer); + co_return HandleResult::error("Serialization error"); + } + + co_return HandleResult::success(buffer); +} diff --git a/gimuserver/gme/UnitFavorite.cpp b/gimuserver/gme/UnitFavorite.cpp new file mode 100644 index 0000000..8d11e9f --- /dev/null +++ b/gimuserver/gme/UnitFavorite.cpp @@ -0,0 +1,54 @@ +#include "App.hpp" +#include "Handlers.hpp" + +// Toggle the favorite/lock flag on user_units. Old fork only echoed the flag +// back without persisting; this port also writes favorite_flg to user_units +// so the state survives across sessions. Persistence is best-effort: if the +// row does not exist (e.g. unit_id missing in the seeded table), the UPDATE +// silently no-ops and the response still echoes the requested flag. +HANDLEF(UnitFavorite) +{ + UnitFavoriteReq req = {}; + if (const auto& ec = glz::read_json(req, json); ec) + { + const auto& fmte = glz::format_error(ec, json); + LOG_DEBUG << "Gme UnitFavorite Error during JSON read: " << fmte; + co_return HandleResult::error("Deserialization error", fmte); + } + + if (req.entries.empty()) + { + co_return HandleResult::error("UnitFavorite: empty entries"); + } + + // TODO: replace with session-derived user id once auth is wired up. + // Matches the placeholder used elsewhere in this tree (UserInfo.cpp). + const std::string userId = "0839899613932562"; + + for (const auto& e : req.entries) + { + try + { + co_await theDb()->execSqlCoro( + "UPDATE user_units SET favorite_flg=$1 WHERE user_id=$2 AND id=$3", + e.favorite, userId, e.user_unit_id); + } + catch (const drogon::orm::DrogonDbException& ex) + { + LOG_WARN << "UnitFavorite: UPDATE failed for unit " << e.user_unit_id << ": " << ex.base().what(); + } + } + + UnitFavoriteResp resp = {}; + resp.entry = req.entries.front(); + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + const auto& glze = glz::format_error(ec2, buffer); + LOG_DEBUG << "Gme UnitFavorite Error during JSON writing: " << glze; + co_return HandleResult::error("Serialization error", glze); + } + + co_return HandleResult::success(buffer); +} diff --git a/gimuserver/gme/UnitMix.cpp b/gimuserver/gme/UnitMix.cpp new file mode 100644 index 0000000..26702a0 --- /dev/null +++ b/gimuserver/gme/UnitMix.cpp @@ -0,0 +1,455 @@ +#include "App.hpp" +#include "Handlers.hpp" +#include + +// UnitMix (Power Fusion) — fuse material units into a base unit, gaining exp. +// +// Request (group Mw08CIg2, key JnegC7RrN3FoW8dQ): +// "60subGk3": [{"81GjwoWy":"1","2vnqRIr3":"2"}] — mix/ingredient type (log only) +// "mCE3rUu5": [{"Rs7bCE3t":""}] — zel cost (string) +// "Km35HAXv": [{"edy7fq3L":"","mnZ5K4Ii":"1"}, // base unit (role 1) +// {"edy7fq3L":"","mnZ5K4Ii":"2"}, // material (role 2) x N] +// +// Response: +// "xZH6EIQ7": [MixReinforceEntry] — drives the level-up animation +// "qC2tJs4E": [UserUnitInfo] — incremental unit cache update +// "fEi17cnx": [UserTeamInfo] — updated zel + +// --------------------------------------------------------------------------- +// Request parsing structs +// --------------------------------------------------------------------------- +struct UnitMixUnitEntry { + int32_t user_unit_id = 0; + std::string role; // "1" = base, "2" = material +}; +template <> struct glz::meta { + using T = UnitMixUnitEntry; + static constexpr auto value = glz::object( + "edy7fq3L", glz::quoted_num<&T::user_unit_id>, + "mnZ5K4Ii", &T::role + ); +}; + +struct UnitMixZelEntry { + int32_t cost = 0; +}; +template <> struct glz::meta { + using T = UnitMixZelEntry; + static constexpr auto value = glz::object( + "Rs7bCE3t", glz::quoted_num<&T::cost> + ); +}; + +struct UnitMixReqBody { + std::vector units; + std::vector zel_cost_list; +}; +template <> struct glz::meta { + using T = UnitMixReqBody; + static constexpr auto value = glz::object( + "Km35HAXv", &T::units, + "mCE3rUu5", &T::zel_cost_list + ); +}; + +// --------------------------------------------------------------------------- +// Response structs +// --------------------------------------------------------------------------- +// Reinforcement animation entry (xZH6EIQ7 array element). +struct MixReinforceEntry { + std::string handle_name; + int32_t target_lv = 0; + std::string unit_mst_id; // plain MST id (no _100 suffix) + int32_t base_hp = 0, base_atk = 0, base_def = 0, base_heal = 0; + int32_t add_hp = 0, add_atk = 0, add_def = 0, add_heal = 0; + int32_t ext_hp = 0, ext_atk = 0, ext_def = 0; + std::string skill_id, extra_skill_id; + int32_t skill_lv = 0, extra_skill_lv = 0, unit_type_id = 0; + std::string mission_id; +}; +template <> struct glz::meta { + using T = MixReinforceEntry; + static constexpr auto value = glz::object( + "B5JQyV8j", &T::handle_name, + "4A6LzBxr", glz::quoted_num<&T::target_lv>, + "pn16CNah", &T::unit_mst_id, + "e7DK0FQT", glz::quoted_num<&T::base_hp>, + "67CApcti", glz::quoted_num<&T::base_atk>, + "q08xLEsy", glz::quoted_num<&T::base_def>, + "PWXu25cg", glz::quoted_num<&T::base_heal>, + "cuIWp89g", glz::quoted_num<&T::add_hp>, + "RT4CtH5d", glz::quoted_num<&T::add_atk>, + "GcMD0hy6", glz::quoted_num<&T::add_def>, + "C1HZr3pb", glz::quoted_num<&T::add_heal>, + "TokWs1B3", glz::quoted_num<&T::ext_hp>, + "t4m1RH6Y", glz::quoted_num<&T::ext_atk>, + "e6mY8Z0k", glz::quoted_num<&T::ext_def>, + "nj9Lw7mV", &T::skill_id, + "3NbeC8AB", glz::quoted_num<&T::skill_lv>, + "iEFZ6H19", &T::extra_skill_id, + "RQ5GnFE2", glz::quoted_num<&T::extra_skill_lv>, + "nBTx56W9", glz::quoted_num<&T::unit_type_id>, + "Ge8Yo32T", &T::mission_id + ); +}; + +// Incremental unit cache update (qC2tJs4E). Uses the same UserUnitInfo fields +// as 4ceMWH6k but under a different wrapper key so only the fused unit is +// updated rather than the entire client unit list being replaced. +struct UnitMixUnitUpdate { + std::vector entries; +}; +template <> struct glz::meta { + using T = UnitMixUnitUpdate; + static constexpr auto value = glz::object( + "qC2tJs4E", &T::entries + ); +}; + +struct UnitMixRespBody { + std::vector reinforce; + std::vector unit_update; + UserTeamInfo team_info = {}; +}; +template <> struct glz::meta { + using T = UnitMixRespBody; + static constexpr auto value = glz::object( + "xZH6EIQ7", &T::reinforce, + "qC2tJs4E", &T::unit_update, + "fEi17cnx", pkg::glaze::single_array<&T::team_info>() + ); +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static std::string unitMix_stripSuffix(const std::string& raw) +{ + auto pos = raw.find('_'); + return (pos != std::string::npos) ? raw.substr(0, pos) : raw; +} + +// Cumulative exp needed to REACH `level` from level 1. +// UnitExpPatternMst::need_exp is the incremental cost per level transition. +static int unitMix_expForLevel(const std::vector& pat, + int patternId, int level) +{ + int acc = 0; + for (const auto& e : pat) + { + if (e.id != patternId) continue; + if (e.lv <= 1) continue; + if (e.lv > level) break; + acc += e.need_exp; + } + return acc; +} + +// Highest level reachable with totalExp under maxLevel cap. +static int unitMix_levelFromExp(const std::vector& pat, + int patternId, int maxLevel, int totalExp) +{ + int acc = 0, level = 1; + for (const auto& e : pat) + { + if (e.id != patternId) continue; + if (e.lv <= 1) continue; + if (e.lv > maxLevel) break; + if (acc + e.need_exp <= totalExp) { acc += e.need_exp; level = e.lv; } + else break; + } + return level; +} + +static UserTeamInfo unitMix_buildTeamInfo(const drogon::orm::Row& row, + const std::vector& prog) +{ + const int32_t level = row["level"].as(); + const UserLevelMst* lv = nullptr; + for (const auto& e : prog) { if (e.level == level) { lv = &e; break; } } + + UserTeamInfo ti = {}; + ti.user_id = "0839899613932562"; + ti.level = level; + ti.exp = row["exp"].as(); + ti.zel = row["zel"].as(); + ti.karma = row["karma"].as(); + ti.brave_coin = row["brave_coin"].as(); + ti.action_point = row["energy"].as(); + ti.max_action_point = lv ? lv->action_points : 100; + ti.deck_cost = lv ? lv->deck_cost : 20; + ti.max_friend_count = lv ? lv->friend_count : 50; + ti.add_friend_count = lv ? lv->add_friend_count : 0; + ti.max_unit_count = row["max_unit_count"].as(); + ti.warehouse_count = row["max_warehouse_count"].as(); + ti.active_deck = row["active_deck"].as(); + ti.summon_ticket = row["summon_tickets"].as(); + ti.rainbow_coin = row["rainbow_coins"].as(); + ti.colosseum_ticket = row["colosseum_tickets"].as(); + ti.brave_points_total = row["total_brave_points"].as(); + ti.current_brave_points = row["avail_brave_points"].as(); + ti.want_gift = row["want_gift"].as(); + ti.paid_gems = row["paid_gems"].as(); + ti.free_gems = row["free_gems"].as(); + ti.reinforcement_deck.emplace_back(0); + ti.reinforcement_deck.emplace_back(0); + ti.reinforcement_deck.emplace_back(0); + return ti; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +HANDLEF(UnitMix) +{ + (void)session; + LOG_INFO << "UnitMix: " << json; + + static constexpr std::string_view kUserId = "0839899613932562"; + static constexpr std::string_view kInfoId = "12345678"; + + // Parse request. Use error_on_unknown_keys=false so the extra "60subGk3" + // operation-type group sent by the client doesn't abort parsing. + UnitMixReqBody req = {}; + { + glz::context ctx{}; + if (const auto& ec = glz::read(req, json, ctx); ec) + { + LOG_WARN << "UnitMix: bad request JSON: " << glz::format_error(ec, json); + co_return HandleResult::error("Deserialization error"); + } + } + + // Split base vs material units. + int32_t baseId = 0; + std::vector matIds; + for (const auto& u : req.units) + { + if (u.role == "1") baseId = u.user_unit_id; + else if (u.user_unit_id) matIds.push_back(u.user_unit_id); + } + + if (baseId == 0) + { + LOG_WARN << "UnitMix: no base unit in request"; + co_return HandleResult::error("UnitMix: no base unit"); + } + + const int32_t zelCost = req.zel_cost_list.empty() ? 0 : req.zel_cost_list[0].cost; + + // Build material IN-clause. + std::string matList; + for (size_t i = 0; i < matIds.size(); ++i) + { + if (i) matList += ','; + matList += std::to_string(matIds[i]); + } + + // Step 1: SELECT base unit full stats. + const auto baseRows = co_await theDb()->execSqlCoro( + "SELECT id, unit_id, total_exp," + " base_hp, base_atk, base_def, base_heal," + " add_hp, add_atk, add_def, add_heal," + " ext_hp, ext_atk, ext_def, ext_heal," + " limit_over_hp, limit_over_atk, limit_over_def, limit_over_heal," + " skill_id, skill_lv, extra_skill_id, extra_skill_lv, leader_skill_id," + " element, fe_bp, fe_max_usable_bp, unit_type_id," + " eqip_item_id, eqip_item_frame_id, eqip_item_id2, eqip_item_frame_id2" + " FROM user_units WHERE user_id=$1 AND id=$2 LIMIT 1;", + std::string(kUserId), baseId + ); + + if (baseRows.empty()) + { + LOG_WARN << "UnitMix: base unit " << baseId << " not found"; + co_return HandleResult::error("UnitMix: base unit not found"); + } + const auto& br = baseRows[0]; + + const std::string rawBaseUnitId = br["unit_id"].as(); + const std::string baseMstId = unitMix_stripSuffix(rawBaseUnitId); + const int32_t baseMstIdInt = std::stoi(baseMstId); + const int baseTotalExp = br["total_exp"].as(); + + // Lookup base unit MST data. + const auto& unitMst = theServer()->cache().unitMst(); + const UnitMst* baseMstData = nullptr; + for (const auto& u : unitMst) { if (u.id == baseMstIdInt) { baseMstData = &u; break; } } + + const int baseElement = baseMstData ? baseMstData->element : 0; + const int expPatternId = baseMstData ? baseMstData->exp_pattern_id: 10; + const int maxLevel = baseMstData ? baseMstData->max_lv : 100; + + // Step 2: SELECT material stats to compute exp gain. + float gainedExpF = 0.0f; + if (!matIds.empty()) + { + static const int kRarityBonus[] = { 0, 100, 200, 500, 1000, 1500, 3000, 5000, 10000 }; + + const auto matRows = co_await theDb()->execSqlCoro( + "SELECT unit_id, total_exp FROM user_units" + " WHERE user_id=$1 AND id IN (" + matList + ");", + std::string(kUserId) + ); + + for (const auto& row : matRows) + { + const std::string matMstId = unitMix_stripSuffix(row["unit_id"].as()); + const int32_t matMstIdInt = std::stoi(matMstId); + const int matTotalExp = row["total_exp"].as(); + + const UnitMst* matData = nullptr; + for (const auto& u : unitMst) { if (u.id == matMstIdInt) { matData = &u; break; } } + + const int matAdjust = matData ? matData->adjust_exp : 0; + const int matCost = matData ? matData->cost : 1; + const int matRare = matData ? matData->rarity : 1; + const int matElem = matData ? matData->element : 0; + + float matExp = (float)matTotalExp / 2.5f; + matExp += (float)(matCost * 2); + matExp += (float)matAdjust; + if (matRare >= 1 && matRare <= 8) + matExp += (float)kRarityBonus[matRare]; + if (baseElement != 0 && matElem == baseElement) + matExp *= 1.5f; + + gainedExpF += matExp; + } + } + + const int gainedExp = (int)llroundf(gainedExpF); + + // Compute new level / exp. + const auto& expPat = theServer()->cache().initializeResp().exp_pattern; + const int maxTotalExp = unitMix_expForLevel(expPat, expPatternId, maxLevel); + int newTotalExp = baseTotalExp + gainedExp; + if (maxTotalExp > 0 && newTotalExp > maxTotalExp) newTotalExp = maxTotalExp; + + const int newLevel = unitMix_levelFromExp(expPat, expPatternId, maxLevel, newTotalExp); + const int levelExpFlr = unitMix_expForLevel(expPat, expPatternId, newLevel); + const int newExp = newTotalExp - levelExpFlr; + + LOG_INFO << "UnitMix: unit=" << baseId << " mst=" << baseMstId + << " totalExp " << baseTotalExp << "+" << gainedExp << "=" << newTotalExp + << " lv->" << newLevel << "/" << maxLevel; + + // Step 3: UPDATE base unit level/exp (preserve IMP add_* cols). + co_await theDb()->execSqlCoro( + "UPDATE user_units SET unit_lv=$1, exp=$2, total_exp=$3" + " WHERE id=$4 AND user_id=$5;", + newLevel, newExp, newTotalExp, baseId, std::string(kUserId) + ); + + // Step 4: DELETE material units. + if (!matIds.empty()) + { + co_await theDb()->execSqlCoro( + "DELETE FROM user_units WHERE user_id=$1 AND id IN (" + matList + ");", + std::string(kUserId) + ); + } + + // Step 5: deduct zel. + if (zelCost > 0) + { + co_await theDb()->execSqlCoro( + "UPDATE userinfo SET zel = MAX(0, zel - $1) WHERE id=$2;", + zelCost, std::string(kInfoId) + ); + } + + // Step 6: fetch fresh userinfo for team_info. + const auto infoRows = co_await theDb()->execSqlCoro( + "SELECT level, exp, zel, karma, brave_coin, free_gems, paid_gems, energy," + " max_unit_count, max_warehouse_count, summon_tickets, rainbow_coins," + " colosseum_tickets, total_brave_points, avail_brave_points," + " active_deck, want_gift FROM userinfo WHERE id=$1;", + std::string(kInfoId) + ); + + // Build response. + UnitMixRespBody resp = {}; + + // Reinforcement animation entry. + { + MixReinforceEntry rd = {}; + rd.handle_name = "DecompDev"; + rd.target_lv = newLevel; + rd.unit_mst_id = baseMstId; + rd.base_hp = br["base_hp"].as(); + rd.base_atk = br["base_atk"].as(); + rd.base_def = br["base_def"].as(); + rd.base_heal = br["base_heal"].as(); + rd.add_hp = br["add_hp"].as(); + rd.add_atk = br["add_atk"].as(); + rd.add_def = br["add_def"].as(); + rd.add_heal = br["add_heal"].as(); + rd.ext_hp = br["ext_hp"].as(); + rd.ext_atk = br["ext_atk"].as(); + rd.ext_def = br["ext_def"].as(); + rd.skill_id = std::to_string(br["skill_id"].as()); + rd.skill_lv = br["skill_lv"].as(); + rd.extra_skill_id = std::to_string(br["extra_skill_id"].as()); + rd.extra_skill_lv = br["extra_skill_lv"].as(); + rd.unit_type_id = br["unit_type_id"].as(); + rd.mission_id = ""; + resp.reinforce.emplace_back(std::move(rd)); + } + + // Incremental unit cache update. + { + UserUnitInfo ud = {}; + ud.user_id = std::string(kUserId); + ud.user_unit_id = br["id"].as(); + ud.unit_id = baseMstIdInt; + ud.unit_type_id = br["unit_type_id"].as(); + ud.unit_lv = newLevel; + ud.exp = newExp; + ud.total_exp = newTotalExp; + ud.base_hp = br["base_hp"].as(); + ud.add_hp = br["add_hp"].as(); + ud.ext_hp = br["ext_hp"].as(); + ud.limit_over_hp = br["limit_over_hp"].as(); + ud.base_atk = br["base_atk"].as(); + ud.add_atk = br["add_atk"].as(); + ud.ext_atk = br["ext_atk"].as(); + ud.limit_over_atk = br["limit_over_atk"].as(); + ud.base_def = br["base_def"].as(); + ud.add_def = br["add_def"].as(); + ud.ext_def = br["ext_def"].as(); + ud.limit_over_def = br["limit_over_def"].as(); + ud.base_heal = br["base_heal"].as(); + ud.add_heal = br["add_heal"].as(); + ud.ext_heal = br["ext_heal"].as(); + ud.limit_over_heal = br["limit_over_heal"].as(); + ud.element = br["element"].as(); + ud.leader_skill_id = br["leader_skill_id"].as(); + ud.skill_id = br["skill_id"].as(); + ud.skill_lv = br["skill_lv"].as(); + ud.extra_skill_id = br["extra_skill_id"].as(); + ud.extra_skill_lv = br["extra_skill_lv"].as(); + ud.equipitem_id = br["eqip_item_id"].as(); + ud.equipitem_frame_id = br["eqip_item_frame_id"].as(); + ud.equipitem_id2 = br["eqip_item_id2"].as(); + ud.equipitem_frame_id2= br["eqip_item_frame_id2"].as(); + ud.fe_bp = br["fe_bp"].as(); + ud.fe_max_usable_bp = br["fe_max_usable_bp"].as(); + ud.new_flag = true; + resp.unit_update.emplace_back(std::move(ud)); + } + + resp.team_info = unitMix_buildTeamInfo( + infoRows.at(0), + theServer()->cache().initializeResp().progression + ); + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + LOG_ERROR << "UnitMix: serialization error: " << glz::format_error(ec2, buffer); + co_return HandleResult::error("Serialization error"); + } + + co_return HandleResult::success(buffer); +} diff --git a/gimuserver/gme/UnitSell.cpp b/gimuserver/gme/UnitSell.cpp new file mode 100644 index 0000000..5cea009 --- /dev/null +++ b/gimuserver/gme/UnitSell.cpp @@ -0,0 +1,184 @@ +#include "App.hpp" +#include "Handlers.hpp" + +// UnitSell — sell one or more owned units for zel. +// +// Request (group Ri3uTq9b, key 92VqcGFWuPkmT60U): +// "Km35HAXv": [ {"edy7fq3L": ""}, ... ] +// +// Response: +// "fEi17cnx": [UserTeamInfo] — refreshes zel counter in the client HUD. +// +// Zel formula: sum UnitMst.sell_price across sold units (server-authoritative). + +// --------------------------------------------------------------------------- +// Request parsing structs (file-scope so glz::meta<> specialisations compile) +// --------------------------------------------------------------------------- +struct UnitSellEntry { + int32_t user_unit_id = 0; +}; +template <> struct glz::meta { + using T = UnitSellEntry; + static constexpr auto value = glz::object( + "edy7fq3L", glz::quoted_num<&T::user_unit_id> + ); +}; +struct UnitSellReqBody { + std::vector units; +}; +template <> struct glz::meta { + using T = UnitSellReqBody; + static constexpr auto value = glz::object( + "Km35HAXv", &T::units + ); +}; + +// --------------------------------------------------------------------------- +// Response wrapper (only fEi17cnx needed for sell) +// --------------------------------------------------------------------------- +struct UnitSellRespBody { + UserTeamInfo team_info = {}; +}; +template <> struct glz::meta { + using T = UnitSellRespBody; + static constexpr auto value = glz::object( + "fEi17cnx", pkg::glaze::single_array<&T::team_info>() + ); +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +static std::string unitSell_stripSuffix(const std::string& raw) +{ + auto pos = raw.find('_'); + return (pos != std::string::npos) ? raw.substr(0, pos) : raw; +} + +static UserTeamInfo unitSell_buildTeamInfo(const drogon::orm::Row& row, + const std::vector& prog) +{ + const int32_t level = row["level"].as(); + const UserLevelMst* lv = nullptr; + for (const auto& e : prog) { if (e.level == level) { lv = &e; break; } } + + UserTeamInfo ti = {}; + ti.user_id = "0839899613932562"; + ti.level = level; + ti.exp = row["exp"].as(); + ti.zel = row["zel"].as(); + ti.karma = row["karma"].as(); + ti.brave_coin = row["brave_coin"].as(); + ti.action_point = row["energy"].as(); + ti.max_action_point = lv ? lv->action_points : 100; + ti.deck_cost = lv ? lv->deck_cost : 20; + ti.max_friend_count = lv ? lv->friend_count : 50; + ti.add_friend_count = lv ? lv->add_friend_count : 0; + ti.max_unit_count = row["max_unit_count"].as(); + ti.warehouse_count = row["max_warehouse_count"].as(); + ti.active_deck = row["active_deck"].as(); + ti.summon_ticket = row["summon_tickets"].as(); + ti.rainbow_coin = row["rainbow_coins"].as(); + ti.colosseum_ticket = row["colosseum_tickets"].as(); + ti.brave_points_total = row["total_brave_points"].as(); + ti.current_brave_points = row["avail_brave_points"].as(); + ti.want_gift = row["want_gift"].as(); + ti.paid_gems = row["paid_gems"].as(); + ti.free_gems = row["free_gems"].as(); + ti.reinforcement_deck.emplace_back(0); + ti.reinforcement_deck.emplace_back(0); + ti.reinforcement_deck.emplace_back(0); + return ti; +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +HANDLEF(UnitSell) +{ + (void)session; + LOG_INFO << "UnitSell: " << json; + + static constexpr std::string_view kUserId = "0839899613932562"; + static constexpr std::string_view kInfoId = "12345678"; + + // Parse request. Allow unknown keys so any extra client fields don't abort parsing. + UnitSellReqBody req = {}; + { + glz::context ctx{}; + if (const auto& ec = glz::read(req, json, ctx); ec) + { + LOG_WARN << "UnitSell: bad request JSON: " << glz::format_error(ec, json); + co_return HandleResult::error("Deserialization error"); + } + } + if (req.units.empty()) + { + LOG_WARN << "UnitSell: empty unit list"; + co_return HandleResult::error("UnitSell: empty unit list"); + } + + // Build a SQL IN-clause from validated ids. + std::string idList; + for (size_t i = 0; i < req.units.size(); ++i) + { + if (i) idList += ','; + idList += std::to_string(req.units[i].user_unit_id); + } + + // Step 1: look up sell price for each unit via the MST cache. + const auto& unitMst = theServer()->cache().unitMst(); + const auto unitRows = co_await theDb()->execSqlCoro( + "SELECT unit_id FROM user_units WHERE user_id=$1 AND id IN (" + idList + ");", + std::string(kUserId) + ); + + int64_t totalZel = 0; + for (const auto& row : unitRows) + { + const std::string mstId = unitSell_stripSuffix(row["unit_id"].as()); + const int32_t mstIdInt = std::stoi(mstId); + auto it = std::find_if(unitMst.begin(), unitMst.end(), + [mstIdInt](const UnitMst& u) { return u.id == mstIdInt; }); + if (it != unitMst.end()) + totalZel += it->sell_price; + } + + LOG_INFO << "UnitSell: selling " << unitRows.size() << " units, total zel gain=" << totalZel; + + // Step 2: delete sold units. + co_await theDb()->execSqlCoro( + "DELETE FROM user_units WHERE user_id=$1 AND id IN (" + idList + ");", + std::string(kUserId) + ); + + // Step 3: credit zel. + co_await theDb()->execSqlCoro( + "UPDATE userinfo SET zel = zel + $1 WHERE id=$2;", + totalZel, std::string(kInfoId) + ); + + // Step 4: fetch fresh userinfo to build accurate team_info. + const auto infoRows = co_await theDb()->execSqlCoro( + "SELECT level, exp, zel, karma, brave_coin, free_gems, paid_gems, energy," + " max_unit_count, max_warehouse_count, summon_tickets, rainbow_coins," + " colosseum_tickets, total_brave_points, avail_brave_points," + " active_deck, want_gift FROM userinfo WHERE id=$1;", + std::string(kInfoId) + ); + + UnitSellRespBody resp = {}; + resp.team_info = unitSell_buildTeamInfo( + infoRows.at(0), + theServer()->cache().initializeResp().progression + ); + + std::string buffer{}; + if (const auto& ec2 = glz::write_json(resp, buffer); ec2) + { + LOG_ERROR << "UnitSell: serialization error: " << glz::format_error(ec2, buffer); + co_return HandleResult::error("Serialization error"); + } + + co_return HandleResult::success(buffer); +} diff --git a/gimuserver/gme/UserInfo.cpp b/gimuserver/gme/UserInfo.cpp index a6531c6..3d4395e 100644 --- a/gimuserver/gme/UserInfo.cpp +++ b/gimuserver/gme/UserInfo.cpp @@ -14,79 +14,138 @@ HANDLEF(UserInfo) UserInfoResp resp = theServer()->cache().userInfoResp(); // copy !! - // 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 - - // 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; - - UserTeamInfo team = {}; - 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; - - + const auto& infoRows = co_await theDb()->execSqlCoro( + "SELECT username, level, exp, zel, karma, brave_coin," + " free_gems, paid_gems, energy," + " max_unit_count, max_warehouse_count," + " summon_tickets, rainbow_coins, colosseum_tickets," + " total_brave_points, avail_brave_points, active_deck, want_gift" + " FROM userinfo WHERE id=$1;", + std::string("12345678") + ); + const auto& infoRow = infoRows.at(0); + const int32_t level = infoRow["level"].as(); + + // Level-gated caps from the already-cached user_level.json MST. + const auto& prog = theServer()->cache().initializeResp().progression; + const UserLevelMst* lv = nullptr; + for (const auto& e : prog) { if (e.level == level) { lv = &e; break; } } + const int32_t maxAP = lv ? lv->action_points : 100; + const int32_t deckCost = lv ? lv->deck_cost : 20; + const int32_t friendBase = lv ? lv->friend_count : 50; + const int32_t friendAdd = lv ? lv->add_friend_count : 0; + + resp.login_info.user_id = "0839899613932562"; // packet-gen UUID + resp.login_info.account_id = "12345678"; + resp.login_info.handle_name = infoRow["username"].as(); + resp.login_info.tutorial_end_flag = true; + resp.login_info.tutorial_status = 0; + resp.login_info.feature_gate = 0; + + auto& ti = resp.team_info; + ti.user_id = resp.login_info.user_id; + ti.level = level; + ti.exp = infoRow["exp"].as(); + ti.zel = infoRow["zel"].as(); + ti.karma = infoRow["karma"].as(); + ti.brave_coin = infoRow["brave_coin"].as(); + ti.action_point = infoRow["energy"].as(); + ti.max_action_point = maxAP; + ti.deck_cost = deckCost; + ti.max_friend_count = friendBase; + ti.add_friend_count = friendAdd; + ti.max_unit_count = infoRow["max_unit_count"].as(); + ti.add_unit_count = 0; + ti.warehouse_count = infoRow["max_warehouse_count"].as(); + ti.add_warehouse_count = 0; + ti.active_deck = infoRow["active_deck"].as(); + ti.summon_ticket = infoRow["summon_tickets"].as(); + ti.rainbow_coin = infoRow["rainbow_coins"].as(); + ti.colosseum_ticket = infoRow["colosseum_tickets"].as(); + ti.brave_points_total = infoRow["total_brave_points"].as(); + ti.current_brave_points = infoRow["avail_brave_points"].as(); + ti.want_gift = infoRow["want_gift"].as(); + ti.paid_gems = infoRow["paid_gems"].as(); + ti.free_gems = infoRow["free_gems"].as(); + ti.reinforcement_deck.emplace_back(0); + ti.reinforcement_deck.emplace_back(0); + ti.reinforcement_deck.emplace_back(0); + + // Load units from DB (seeded by GimuServer::SeedDefaultUnits on boot) + const auto& unitRows = co_await theDb()->execSqlCoro( + "SELECT id, unit_id, unit_lv," + " 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_heal,add_heal,ext_heal,limit_over_heal," + " 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," + " eqip_item_id, eqip_item_frame_id, eqip_item_id2, eqip_item_frame_id2" + " FROM user_units WHERE user_id=$1;", + resp.login_info.user_id + ); + + for (const auto& row : unitRows) { 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; - - 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; + d.user_id = resp.login_info.user_id; + d.user_unit_id = row["id"].as(); + d.unit_id = std::stoi(row["unit_id"].as()); + d.unit_type_id = row["unit_type_id"].as(); + d.unit_lv = row["unit_lv"].as(); + d.exp = row["exp"].as(); + d.total_exp = row["total_exp"].as(); + d.base_hp = row["base_hp"].as(); + d.add_hp = row["add_hp"].as(); + d.ext_hp = row["ext_hp"].as(); + d.limit_over_hp = row["limit_over_hp"].as(); + d.base_atk = row["base_atk"].as(); + d.add_atk = row["add_atk"].as(); + d.ext_atk = row["ext_atk"].as(); + d.limit_over_atk = row["limit_over_atk"].as(); + d.base_def = row["base_def"].as(); + d.add_def = row["add_def"].as(); + d.ext_def = row["ext_def"].as(); + d.limit_over_def = row["limit_over_def"].as(); + d.base_heal = row["base_heal"].as(); + d.add_heal = row["add_heal"].as(); + d.ext_heal = row["ext_heal"].as(); + d.limit_over_heal = row["limit_over_heal"].as(); + d.element = row["element"].as(); + d.leader_skill_id = row["leader_skill_id"].as(); + d.skill_id = row["skill_id"].as(); + d.skill_lv = row["skill_lv"].as(); + d.extra_skill_id = row["extra_skill_id"].as(); + d.extra_skill_lv = row["extra_skill_lv"].as(); + d.equipitem_id = row["eqip_item_id"].as(); + d.equipitem_frame_id = row["eqip_item_frame_id"].as(); + d.equipitem_id2 = row["eqip_item_id2"].as(); + d.equipitem_frame_id2 = row["eqip_item_frame_id2"].as(); + d.fe_bp = row["fe_bp"].as(); + d.fe_used_bp = 0; + d.fe_max_usable_bp = row["fe_max_usable_bp"].as(); + d.new_flag = true; + d.unit_img_type = 0; + d.omni_level = 0; + d.ext_count = 0; + d.unk = 0; + d.unk2 = 0; + d.receive_date = {}; // epoch (chrono::time_point) + d.extra_passive_skill_id = 0; + d.extra_passive_skill_id2 = 0; + d.add_extra_passive_skill_id = 0; + d.fe_skill_info = ""; resp.unit_info.emplace_back(d); } + // Party decks — slot the first owned unit into every deck as a starting point + const int32_t firstUnitId = resp.unit_info.empty() ? 0 : 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.deck_num = i; + deck.deck_type = 1; + deck.user_unit_id = firstUnitId; resp.party_deck_info.emplace_back(deck); } diff --git a/launch.vs.json b/launch.vs.json new file mode 100644 index 0000000..a144b15 --- /dev/null +++ b/launch.vs.json @@ -0,0 +1,21 @@ +{ + "version": "0.2.1", + "configurations": [ + { + "type": "native", + "name": "gimuserverw Debug", + "project": "CMakeLists.txt", + "projectTarget": "gimuserverw.exe (standalone_frontend\\gimuserverw.exe)", + "args": [ "${workspaceRoot}\\deploy\\config.json" ], + "env": {} + }, + { + "type": "native", + "name": "gimuserverw Release", + "project": "CMakeLists.txt", + "projectTarget": "gimuserverw.exe (standalone_frontend\\gimuserverw.exe)", + "args": [ "${workspaceRoot}\\deploy\\config.json" ], + "env": {} + } + ] +} diff --git a/packet-generator b/packet-generator index 37256a2..27cc6d6 160000 --- a/packet-generator +++ b/packet-generator @@ -1 +1 @@ -Subproject commit 37256a25049637980fcac4c7f5fafa1654b45d2e +Subproject commit 27cc6d6bb5ad29490d932ffde9ee1bbe4fd5f502 diff --git a/rebuild.bat b/rebuild.bat new file mode 100644 index 0000000..adbc8a5 --- /dev/null +++ b/rebuild.bat @@ -0,0 +1,99 @@ +@echo off +setlocal + +set "SERVER_DIR=%~dp0" +set "VCPKG_ROOT=C:\Users\Evan\BF\vcpkg" +set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +set "BUILD_DIR=%SERVER_DIR%out\build\debug-win64" + +:: ── Locate VsDevCmd.bat via vswhere (VS 2017-2026+) ────────────────────────── +if not exist "%VSWHERE%" ( + echo ERROR: vswhere.exe not found. Visual Studio does not appear to be installed. + pause & exit /b 1 +) + +for /f "usebackq tokens=*" %%i in ( + `"%VSWHERE%" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath` +) do set "VS_PATH=%%i" + +if not defined VS_PATH ( + echo ERROR: No Visual Studio installation with C++ tools found. + echo Install the "Desktop development with C++" workload from the VS Installer. + pause & exit /b 1 +) + +set "VSDEVCMD=%VS_PATH%\Common7\Tools\VsDevCmd.bat" +if not exist "%VSDEVCMD%" ( + echo ERROR: VsDevCmd.bat not found at: + echo %VSDEVCMD% + pause & exit /b 1 +) + +:: ── Check vcpkg ─────────────────────────────────────────────────────────────── +if not exist "%VCPKG_ROOT%\vcpkg.exe" ( + echo ERROR: vcpkg not found at %VCPKG_ROOT% + echo Run the BF installer first to set up vcpkg. + pause & exit /b 1 +) + +echo ============================================================ +echo BF Server Rebuild (Ninja Multi-Config) +echo VS: %VS_PATH% +echo vcpkg: %VCPKG_ROOT% +echo Build dir: %BUILD_DIR% +echo ============================================================ +echo. + +:: ── Config choice ───────────────────────────────────────────────────────────── +set CONFIG=Debug +set /p CONFIG="Build configuration? (Debug/Release, default Debug): " +if /i "%CONFIG%"=="" set CONFIG=Debug +if /i "%CONFIG%"=="d" set CONFIG=Debug +if /i "%CONFIG%"=="r" set CONFIG=Release + +:: Map to the build preset name +if /i "%CONFIG%"=="Debug" set BUILD_PRESET=debug-win64-debug +if /i "%CONFIG%"=="Release" set BUILD_PRESET=debug-win64-release + +if not defined BUILD_PRESET ( + echo ERROR: Unknown configuration "%CONFIG%". Use Debug or Release. + pause & exit /b 1 +) + +:: ── Configure choice ────────────────────────────────────────────────────────── +:: Auto-suggest reconfigure if the build directory doesn't exist yet. +set RECONFIGURE=N +if not exist "%BUILD_DIR%\build.ninja" set RECONFIGURE=Y + +set /p RECONFIGURE="Re-run CMake configure? (y/N, default %RECONFIGURE%): " +if /i "%RECONFIGURE%"=="y" goto :configure +if /i "%RECONFIGURE%"=="Y" goto :configure +goto :build_only + +:configure +echo. +echo [1/2] Configuring (debug-win64 preset)... +echo NOTE: First build will compile the Rust packet-generator (~2-5 min). +echo. +cmd /c ""%VSDEVCMD%" -arch=amd64 -host_arch=amd64 && cd /d "%SERVER_DIR%" && cmake --preset debug-win64" +if errorlevel 1 ( + echo. + echo ERROR: CMake configure failed. + pause & exit /b 1 +) +echo. + +:build_only +echo [Building %CONFIG%]... +cmd /c ""%VSDEVCMD%" -arch=amd64 -host_arch=amd64 && cd /d "%SERVER_DIR%" && cmake --build --preset %BUILD_PRESET%" + +:done +if errorlevel 1 ( + echo. + echo BUILD FAILED. Check the output above for errors. +) else ( + echo. + echo Build succeeded. Artifacts: %BUILD_DIR%\%CONFIG%\ +) +pause +endlocal diff --git a/standalone_frontend/CMakeLists.txt b/standalone_frontend/CMakeLists.txt index 6dc396f..ca136b3 100644 --- a/standalone_frontend/CMakeLists.txt +++ b/standalone_frontend/CMakeLists.txt @@ -1,3 +1,11 @@ file(GLOB SRC "*.cpp" "*.hpp") add_executable(gimuserverw ${SRC}) target_link_libraries(gimuserverw PRIVATE gimuserver) + +# In Debug builds, bake the absolute path to deploy/config.json into the +# binary so F5 from VS (or any launcher) always finds the right config +# regardless of working directory. Release builds keep the default +# "./config.json" so the deployed package still works portably. +target_compile_definitions(gimuserverw PRIVATE + $<$:GIMU_DEFAULT_CONFIG_PATH="${CMAKE_SOURCE_DIR}/deploy/config.json"> +) diff --git a/standalone_frontend/main.cpp b/standalone_frontend/main.cpp index 7337535..a01b2f0 100644 --- a/standalone_frontend/main.cpp +++ b/standalone_frontend/main.cpp @@ -4,25 +4,55 @@ #include #include +#include + int main(int argc, char** argv) { #ifdef _WIN32 SetConsoleTitleW(L"GimuFrontier standalone server"); #endif + // Config path resolution priority: + // 1. Command-line argument (argv[1]) — explicit always wins. + // 2. GIMU_DEFAULT_CONFIG_PATH — baked in at compile time for Debug + // builds so F5 from VS works without touching the launch config. + // 3. "./config.json" — fallback for Release / command-line usage where + // the caller cd's into deploy/ first (e.g. rebuild.bat). +#ifndef GIMU_DEFAULT_CONFIG_PATH +#define GIMU_DEFAULT_CONFIG_PATH "./config.json" +#endif + const char* configArg = (argc > 1) ? argv[1] : GIMU_DEFAULT_CONFIG_PATH; + try { + // Resolve to an absolute path so the chdir below works regardless of + // what the current directory was at launch. + auto absConfig = std::filesystem::absolute(configArg); + + // Change into the config file's directory. Every path in config.json + // is relative to its own location (mst_root, document_root, etc.) so + // this makes the server portable without editing config.json. + std::filesystem::current_path(absConfig.parent_path()); + drogon::app() - .loadConfigFile("./config.json") + .loadConfigFile(absConfig.filename().string()) .registerBeginningAdvice([]() { - MigrationManager::RunMigrations(drogon::app().getDbClient()); - }) - .run() - ; + auto db = drogon::app().getDbClient(); + MigrationManager::RunMigrations(db); + drogon::app().getPlugin()->SeedDefaultUnits(db); + }) + .run(); } catch (const std::exception& ex) { printf("Fatal exception during execution: %s\n", ex.what()); +#ifdef _WIN32 + // Also write to the VS Output window so the message is visible + // without needing to watch the console window. + OutputDebugStringA("Fatal exception during execution: "); + OutputDebugStringA(ex.what()); + OutputDebugStringA("\n"); +#endif } drogon::HttpAppFramework::instance().getLoop()->queueInLoop([]()