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