From 4de98ce012b4e5df12cf9fa03de5e80aca02b144 Mon Sep 17 00:00:00 2001 From: peach322 <1318832223@qq.com> Date: Tue, 12 May 2026 01:08:49 +0800 Subject: [PATCH 01/16] Merge pull request #1 from peach322/copilot/fix-giveall-command-issue docs: add Chinese guide for giveall target UID usage --- COMMAND_TARGET_CHS.md | 74 +++++++++++++++++++++++++++++++++++++++++++ README.md | 3 +- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 COMMAND_TARGET_CHS.md diff --git a/COMMAND_TARGET_CHS.md b/COMMAND_TARGET_CHS.md new file mode 100644 index 0000000..b1c505b --- /dev/null +++ b/COMMAND_TARGET_CHS.md @@ -0,0 +1,74 @@ +# 命令目标与 `giveall` 参数说明(中文) + +本文档说明为什么在控制台执行 `giveall` 时会出现“未找到玩家”,以及“ID 应该填什么”。 + +## 1. 目标解析规则 + +命令系统会按如下方式解析目标: + +- 不写目标时,默认目标是**命令发送者本人**。 +- 在控制台执行命令时,发送者是 `Console`,其 UID 为 `0`。 +- 指定目标的语法是:`@`(例如 `@1001`)。 +- 当前实现只解析 `@` 后面的**数字 UID**,不支持直接写用户名作为目标。 + +相关实现: + +- `/home/runner/work/MikuSB/MikuSB/GameServer/Command/CommandManager.cs` +- `/home/runner/work/MikuSB/MikuSB/GameServer/Command/CommandSender.cs` +- `/home/runner/work/MikuSB/MikuSB/Common/Enums/Player/FriendEnum.cs` + +## 2. 为什么会“未找到玩家” + +`giveall` 在执行前会检查目标是否在线(`CheckOnlineTarget()`): + +- 目标必须是**在线连接中的玩家**。 +- 目标是控制台(`uid=0`)或离线玩家时,会提示“未找到玩家”。 + +相关实现: + +- `/home/runner/work/MikuSB/MikuSB/GameServer/Command/Commands/CommandGiveAll.cs` +- `/home/runner/work/MikuSB/MikuSB/GameServer/Command/CommandArg.cs` + +## 3. `giveall` 参数含义(重点) + +以 `weapon` 为例: + +```text +/giveall weapon -p -l @ +``` + +- ``:物品 detail,`-1` 表示全部。 +- `-p`:物品 particular 参数(例如 `p1`)。 +- `-l`:等级参数。 +- `@`:目标玩家 UID。 + +注意: + +- `miku` 这种字符串不会被当成目标玩家名。 +- `p1` 是 `-p` 的物品参数,不是玩家 ID。 + +## 4. 到底该填 UID 还是用户名? + +结论: + +- 目标参数请填 **UID**(`@`)。 +- 不是用户名。 + +数据库对应关系: + +- 账号表:`Account`(`[SugarTable("Account")]`) +- 玩家 ID 字段:`Uid`(主键) +- 用户名字段:`Username` + +相关实现: + +- `/home/runner/work/MikuSB/MikuSB/Common/Database/Account/AccountData.cs` +- `/home/runner/work/MikuSB/MikuSB/Common/Database/BaseDatabaseDataHelper.cs` + +## 5. 正确示例 + +```text +/giveall weapon -1 p1 l90 @1001 +``` + +表示:给 UID 为 `1001` 的在线玩家发放全部武器(particular=1,level=90)。 diff --git a/README.md b/README.md index 8242b2b..bcdc19f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [Discord](https://discord.gg/aMwCu9JyUR) 日本語のドキュメントは [README_jp.md](README_jp.md) にあります。 +中文命令目标说明见 [COMMAND_TARGET_CHS.md](COMMAND_TARGET_CHS.md)。 ## Overview @@ -90,4 +91,4 @@ MikuSB was developed for educational and research purposes. - This repository does not include any copyrighted game assets, binaries, or master data. - Use this software at your own risk. The authors assume no responsibility for any damages or legal consequences resulting from its use. -If you are a rights holder and have any concerns regarding this software, please contact `devilpromt` or `kei_luna` on Discord. \ No newline at end of file +If you are a rights holder and have any concerns regarding this software, please contact `devilpromt` or `kei_luna` on Discord. From bef0fa1e1400618c90438bb71fa8c48c4d2edde4 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 12 May 2026 01:12:13 +0800 Subject: [PATCH 02/16] Copilot/translate md to chinese english (#2) * docs: add Chinese README translation and language links Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/cd59a2ab-c6f1-4a3f-a49f-24a765d08732 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * docs: add Chinese zero-to-run and database usage guide Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/a80b4c1a-38c1-4b55-a41f-aaf59cdfdcec Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * docs: add Chinese command guide for giveall and basic admin commands Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/233f9b34-5b67-450e-8148-d394c2b8b0c7 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- COMMAND_GUIDE_zh.md | 147 ++++++++++++++++++++++++++++++++++ README.md | 2 + README_jp.md | 5 +- README_zh.md | 96 ++++++++++++++++++++++ USAGE_zh.md | 189 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+), 2 deletions(-) create mode 100644 COMMAND_GUIDE_zh.md create mode 100644 README_zh.md create mode 100644 USAGE_zh.md diff --git a/COMMAND_GUIDE_zh.md b/COMMAND_GUIDE_zh.md new file mode 100644 index 0000000..60f4093 --- /dev/null +++ b/COMMAND_GUIDE_zh.md @@ -0,0 +1,147 @@ +# MikuSB 命令使用指南(从零开始) + +> 这份文档专门讲「怎么用命令」,尤其是 `giveall`(你说的 give 什么)。 + +## 1. 先把服务跑起来 + +在仓库根目录执行: + +```bash +dotnet build +dotnet run --project ./MikuSB +``` + +启动成功后,控制台会提示可输入 `help` 获取命令帮助。 + +--- + +## 2. 命令在哪里输入 + +你可以在两个地方输入命令: + +1. **服务端控制台**:直接输入命令(不带 `/`) + - 例如:`help` +2. **游戏内聊天**:命令前带 `/` + - 例如:`/help` + +--- + +## 3. 命令基础语法 + +命令结构: + +```text +<主命令> <子命令> <参数> @<目标UID> +``` + +- `@<目标UID>` 可选,用于指定目标玩家。 +- 不写 `@` 时,默认对命令发送者生效。 +- 目标玩家需要在线,否则会提示未找到玩家。 + +--- + +## 4. 先学会 help + +```text +help +help giveall +help girl +help debug +``` + +- `help`:列出命令 +- `help giveall`:查看 giveall 用法 + +--- + +## 5. giveall 怎么用(重点) + +`giveall` 主命令别名是 `ga`。 +可用子命令: + +- `weapon` +- `card` +- `weaponskin` +- `profile` +- `skinpart` +- `weaponpart` +- `call` +- `skin` + +### 5.1 参数规则(很关键) + +在当前实现里,选项参数建议写成: + +- `p1` +- `l90` +- `g9` + +即 **不要写成 `-p1` / `-l90` / `-g9`**,否则会被当成其他参数处理。 + +### 5.2 常见示例 + +```text +# 给自己所有武器,particular=1,等级90 +giveall weapon -1 p1 l90 + +# 给 UID=1 的玩家所有武器 +giveall weapon -1 p1 l90 @1 + +# 给自己所有支援卡 +giveall card -1 p1 l80 + +# 给自己所有武器皮肤 +giveall weaponskin -1 p1 + +# 给自己所有角色皮肤(genre=9 仅示例,按你资源配置调整) +giveall skin -1 g9 p1 l1 +``` + +说明: + +- `detail=-1` 代表“全部” +- `detail>=0` 代表给某个具体条目 + +--- + +## 6. 其他常用命令 + +### 6.1 girl(角色) + +```text +girl add -1 p1 l1 s9 +girl level -1 80 +girl neuronic -1 6 +girl break -1 45 +``` + +### 6.2 debug(调试输出) + +```text +debug on +debug off +debug simple +debug detail +debug file +``` + +--- + +## 7. 常见问题 + +### Q1:提示“未找到命令” + +- 用 `help` 看命令是否存在 +- 游戏聊天里记得加 `/` +- 控制台里不要加 `/` + +### Q2:提示“未找到玩家” + +- 目标 UID 不在线 +- 先确认玩家已登录,再使用 `@uid` + +### Q3:命令执行了但结果不对 + +- 优先用 `help <命令>` 对照参数格式 +- `giveall` 选项参数按 `p1 l90 g9` 这种写法输入 + diff --git a/README.md b/README.md index bcdc19f..91783de 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ [Discord](https://discord.gg/aMwCu9JyUR) +中文文档见 [README_zh.md](README_zh.md)。 日本語のドキュメントは [README_jp.md](README_jp.md) にあります。 +Detailed Chinese usage guide: [USAGE_zh.md](USAGE_zh.md). 中文命令目标说明见 [COMMAND_TARGET_CHS.md](COMMAND_TARGET_CHS.md)。 ## Overview diff --git a/README_jp.md b/README_jp.md index 9754462..e1f92de 100644 --- a/README_jp.md +++ b/README_jp.md @@ -5,7 +5,8 @@ [Discord](https://discord.gg/aMwCu9JyUR) -English documentation is available in [README.md](README.md). +English documentation is available in [README.md](README.md). +中文文档见 [README_zh.md](README_zh.md)。 ## 概要 @@ -91,4 +92,4 @@ MikuSBは教育および研究目的で開発されました。 - このリポジトリには、著作権で保護されたゲームアセット、バイナリ、マスターデータは一切含まれていません。 - 自己責任でご利用下さい。 著者は、本ソフトウェアによって生じるいかなる損害または法的結果についても一切責任を負いません。 -本ソフトウェアに関して懸念事項をお持ちの権利保有者は`devilpromt`または`kei_luna`にDiscordでご連絡下さい。 \ No newline at end of file +本ソフトウェアに関して懸念事項をお持ちの権利保有者は`devilpromt`または`kei_luna`にDiscordでご連絡下さい。 diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..35506c7 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,96 @@ +# MikuSB + +MikuSB 是某款地牢题材动漫游戏的服务器模拟器。 +它会从一个 `net9.0` 应用中启动 `SdkServer`、`GameServer`,以及可选的本地 HTTP/HTTPS 代理。 + +[Discord](https://discord.gg/aMwCu9JyUR) + +English documentation is available in [README.md](README.md). +日本語のドキュメントは [README_jp.md](README_jp.md) にあります。 +详细中文使用指导见 [USAGE_zh.md](USAGE_zh.md)。 +命令从零使用文档见 [COMMAND_GUIDE_zh.md](COMMAND_GUIDE_zh.md)。 + +## 概览 + +- `SdkServer` + - 提供 HTTP API 并分发响应 + - 返回服务器列表、版本查询和各类兜底响应 +- `GameServer` + - 接受基于 TCP 的游戏连接 + - 处理 `ReqCallGS` 与部分普通协议包 +- `Proxy` + - 启用时监听 `127.0.0.1:8888` + - 将部分 Snowbreak 相关域名重定向到本地 `SdkServer` +- `Common` / `Proto` / `TcpSharp` + - 共享数据、protobuf 定义与网络通信基础设施 + +## 项目结构 + +- [MikuSB](MikuSB): 入口程序 +- [SdkServer](SdkServer): HTTP 服务与分发 +- [GameServer](GameServer): 主游戏服务器 +- [Proxy](Proxy): 本地代理 +- [Common](Common): 配置、数据库与公共工具 +- [Proto](Proto): protobuf 定义 + +## 环境要求 + +- [.NET SDK 10.0](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0) + +## 运行 + +1. 还原依赖并构建。 + +```powershell +dotnet build +``` + +2. 开始使用。 + +## 功能列表 + +* [x] 登录与基础账号进入 +* [x] 玩家数据加载 +* [x] 背包数据加载 +* [x] 角色数据加载 +* [x] 皮肤数据加载 +* [x] 武器数据加载 +* [x] 大厅展示角色切换 +* [x] 角色皮肤切换 +* [x] 角色皮肤形态切换 +* [x] 武器替换 +* [x] 武器强化 +* [x] 玩家改名 +* [x] 当前已支持大厅状态的基础保存 +* [✓] 主线章节关卡进入及相关流程 +* [✓] 日常关卡进入及相关流程 +* [✓] 基础玩家设置同步 +* [✓] 基础个人资料同步 +* [✓] 活动相关请求 +* [✓] 成就相关请求 +* [✓] 编队相关请求 +* [✓] 预览相关请求 +* [✓] 部分商店相关请求 +* [ ] 完整战斗流程 +* [ ] 任务 / 委托进度 +* [ ] 抽卡 / 招募系统 +* [ ] 完整商店行为 +* [ ] 多人系统 +* [ ] 基地 / 宿舍系统 +* [ ] 客户端 API 全覆盖 + +## 贡献者 +- [Naruse](https://github.com/DevilProMT) +- [Kei-Luna](https://github.com/Kei-Luna) + +## 使用说明 +本软件仅用于本地环境下的研究与测试。 +不用于对官方服务进行未授权访问、干扰或商业用途。 + +## 法律免责声明 +MikuSB 仅为教育与研究目的开发。 +- 与原游戏及其相关系列有关的所有商标、版权及其他知识产权均归其各自所有者所有。 +- 本仓库不包含任何受版权保护的游戏资源、二进制文件或主数据。 +- 使用本软件需自行承担风险。作者不对因使用本软件导致的任何损失或法律后果负责。 + +若您是权利持有方并对本软件有任何顾虑,请在 Discord 联系 `devilpromt` 或 `kei_luna`。 diff --git a/USAGE_zh.md b/USAGE_zh.md new file mode 100644 index 0000000..dff2516 --- /dev/null +++ b/USAGE_zh.md @@ -0,0 +1,189 @@ +# MikuSB 使用指导(从零开始) + +> 本文档聚焦:完整命令流程、数据库字段含义与来源、数据如何生成。 + +## 1. 从零开始运行(开发模式) + +### 1.1 环境准备 + +- 安装 [.NET SDK 10.0](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0) +- 安装 Git + +### 1.2 获取源码 + +```bash +git clone https://github.com/AliceJump/MikuSB.git +cd MikuSB +``` + +### 1.3 构建 + +```bash +dotnet build +``` + +### 1.4 启动服务 + +```bash +dotnet run --project ./MikuSB +``` + +启动后会同时拉起: + +- `SdkServer`(HTTP) +- `GameServer`(TCP) +- 本地代理(默认启用,监听 `127.0.0.1:8888`) + +### 1.5 首次启动会自动生成的内容 + +- `Config/Config.json`(若不存在则生成默认配置) +- `Config/Database/Miku.db`(SQLite 数据库文件) +- 数据库表结构(Code First 自动建表) +- `proxy-certs/*`(代理根证书与派生证书) +- `Config/Handbook/*`(命令手册文本,按 TextMap 生成) + +## 2. 常用运行/发布命令 + +### 2.1 Linux 开发运行 + +```bash +dotnet build +dotnet run --project ./MikuSB +``` + +### 2.2 Linux 发布单文件 + +```bash +dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish +``` + +### 2.3 Windows 发布(多文件,和 CI 一致) + +```powershell +dotnet publish .\MikuSB\MikuSB.csproj -c Release -p:PublishProfile=MikuSB-Win64-MultiFile -o .\artifacts\publish\MikuSB +``` + +## 3. 资源与数据库“如何生成” + +### 3.1 资源文件来源 + +- 服务启动时会检查 `Resources/` 下的关键文件(如 `item/templates/card.json`、`item/templates/weapon.json`)。 +- 若缺失,会自动下载资源压缩包并解压到 `Resources/`。 +- 资源数据随后被加载为内存中的 `GameData.*` 字典/列表,用于后续角色、武器、道具初始化。 + +### 3.2 数据库与表“如何生成” + +- 数据库类型:SQLite(SqlSugar) +- 数据库路径:`Config/Database/Miku.db`(可通过 `Config/Config.json` 修改目录与文件名) +- 建表方式:扫描所有继承 `BaseDatabaseDataHelper` 的类型并执行 Code First 自动建表 + +### 3.3 数据“如何写入” + +- 玩家心跳会将 UID 加入待保存列表,默认每 5 分钟批量落盘 +- 进程退出时会触发最终一次保存(final flush) + +## 4. 数据库表与字段说明(含来源) + +> 主键统一为 `Uid`(玩家 UID)。 + +### 4.1 `Account` + +字段: + +- `Uid`:账号 UID。首次登录时若不存在账号会创建,默认从 1 开始递增。 +- `Username`:用户名。首次自动创建账号时默认 `"MIKU"`。 +- `Password`:密码哈希(SHA256);空密码会存空字符串。 +- `BanType`:封禁类型枚举。 +- `Phone`:手机号字段(默认 `"123456"`)。 +- `Permissions`(JSON):权限列表,来源于 `Config.json -> ServerOption.DefaultPermissions`。 +- `ComboToken` / `DispatchToken`:会话 token,调用对应生成方法时写入。 + +来源: + +- 自动创建:首次处理登录包时,若 UID=1 不存在则创建。 +- 也可通过逻辑/命令触发账号管理。 + +### 4.2 `Player` + +字段: + +- `Uid`:玩家 UID(与账号一致)。 +- `Name`:显示名,默认取账号名并标准化(空白时回退 `Miku`)。 +- `Signature`:签名(默认 `MikuPS`)。 +- `Level` / `Exp` / `Vigor` / `Gender`:玩家基础属性。 +- `RegisterTime`:注册时间(Unix 秒,创建对象时写入)。 +- `LastActiveTime`:最近活跃时间(初始化玩家管理器时刷新)。 +- `Attrs`(JSON):数值属性(大量引导/货币/关卡解锁等引导值)。 +- `StrAttrs`(JSON):字符串属性。 +- `ShowItems`(JSON):个人展示道具列表。 + +来源: + +- 当账号已存在但无玩家数据时,创建 `PlayerGameData`。 +- `Attrs` 会在玩家序列化流程中由引导与关卡数据补齐/抬高。 + +### 4.3 `inventory_data` + +字段: + +- `Uid`:玩家 UID。 +- `NextUniqueUid`:背包唯一物品 ID 分配器(默认从 `100000` 开始)。 +- `Items`(JSON):普通道具字典(包含补给、AR、彰痕等)。 +- `Weapons`(JSON):武器字典。 +- `Skins`(JSON):皮肤字典。 +- `SupportCards`(JSON):支援卡字典。 +- `SkinTypesBySkinId`(JSON):皮肤形态映射(`nSkinId -> nType`)。 + +来源: + +- 首次创建玩家后,系统会根据资源表批量发放初始皮肤、角色、补给等。 +- 各种业务请求(强化、替换、皮肤切换等)持续修改该表。 + +### 4.4 `character_data` + +字段: + +- `Uid`:玩家 UID。 +- `Characters`(JSON):角色列表。 + - 关键子字段:`Guid`、`TemplateId`、`Level`、`Break`、`Evolue`、`ProLevel`、`Trust`、`WeaponUniqueId`、`SkinId`、`WeaponSkinId`、`SupportSlots`、`UnlockedSkin`、`Spines`、`Affixs` 等。 +- `NextCharacterGuid`:角色 GUID 递增计数器。 + +来源: + +- 首次玩家初始化会按资源中的角色模板批量创建角色。 +- 创建角色时会自动补默认武器与皮肤关联。 + +### 4.5 `lineup_data` + +字段: + +- `Uid`:玩家 UID。 +- `LineupInfo`(JSON):编队字典,键为编队位。 + - 子字段:`Index`、`Name`、`Member1`、`Member2`、`Member3`。 + +来源: + +- 新玩家初始化后,会随机选 3 名角色写入默认编队。 +- 后续编队更新请求持续写入。 + +## 5. 快速排查与校验 + +### 5.1 查看数据库文件与表 + +```bash +ls -lah ./Config/Database +sqlite3 ./Config/Database/Miku.db ".tables" +sqlite3 ./Config/Database/Miku.db ".schema Account" +sqlite3 ./Config/Database/Miku.db ".schema Player" +``` + +### 5.2 查看配置中的数据库路径 + +```bash +cat ./Config/Config.json +``` + +## 6. 备注 + +- 本项目大量字段为 JSON 列(对象序列化存储),阅读时建议结合对应 C# 数据结构一起看。 +- 若要重置本地进度,先停止服务,再备份或删除 `Config/Database/Miku.db` 后重新启动。 From f9629113f4c4b5a21f272e544ae1f162c3359698 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Tue, 12 May 2026 01:14:16 +0800 Subject: [PATCH 03/16] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 91783de..75a89a5 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ [Discord](https://discord.gg/aMwCu9JyUR) -中文文档见 [README_zh.md](README_zh.md)。 -日本語のドキュメントは [README_jp.md](README_jp.md) にあります。 -Detailed Chinese usage guide: [USAGE_zh.md](USAGE_zh.md). -中文命令目标说明见 [COMMAND_TARGET_CHS.md](COMMAND_TARGET_CHS.md)。 +- 中文文档见 [README_zh.md](README_zh.md)。 +- 日本語のドキュメントは [README_jp.md](README_jp.md) にあります。 +- Detailed Chinese usage guide: [USAGE_zh.md](USAGE_zh.md). +- 中文命令目标说明见 [COMMAND_TARGET_CHS.md](COMMAND_TARGET_CHS.md)。 ## Overview From d665bbab7d77d75d5d76e1eb73db4172313452ae Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Wed, 13 May 2026 23:31:01 +0800 Subject: [PATCH 04/16] Enhance login process and player initialization features (#3) * feat: force login to MIKU account Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/e369b42c-448d-4dff-b6c5-d89adce87f9d Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * fix: handle forced account fallback exceptions Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/e369b42c-448d-4dff-b6c5-d89adce87f9d Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * refactor: share forced account resolution for login Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/e369b42c-448d-4dff-b6c5-d89adce87f9d Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * feat: auto login first account from database Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/769497ad-038b-41e0-85f5-666728e1a17c Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * refactor: select first account for auto-login fallback Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/769497ad-038b-41e0-85f5-666728e1a17c Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * feat: initialize default account at startup for new database Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/a9383232-9280-42fd-b19a-382476829fd7 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * fix: avoid logging startup account identifiers Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/a9383232-9280-42fd-b19a-382476829fd7 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * fix: use random password for startup-initialized account Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/a9383232-9280-42fd-b19a-382476829fd7 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * feat: auto grant level 90 weapons on new player initialization Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/defa0060-b00e-4c93-a6da-cdbbeb290b54 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * feat: initialize all giveall items for new players Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/b01b700a-f575-4057-b5ff-a58b9984255b Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * fix: backfill full player initialization on empty login data Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/a0c71d94-9dfa-4829-8f7e-df035a41cb88 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * fix: require exactly three characters before default lineup init Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/a0c71d94-9dfa-4829-8f7e-df035a41cb88 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * fix: set bootstrap equipment and character progression to level 80 Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/141dd18c-dc84-493e-9385-c83dddeb0db9 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * fix: set current directory to application base directory and disable auto-update * Merge branch 'main' into copilot/analyze-login-rejection * docs: add branch update summary document in Chinese Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/008a47ae-10f0-4b7d-8f08-cb8e4df6bfc0 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * fix: remove bootstrap character break assignment Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/38233087-3250-4377-a30b-b0ac4157baae Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> * feat: enhance login handling with token and email resolution * Add multilingual documentation for MikuSB - Created Japanese README (README_jp.md) with project overview, setup instructions, and feature list. - Created Chinese README (README_zh.md) with project overview, setup instructions, and feature list. - Added English command guide (COMMAND_GUIDE_en.md) detailing command usage, syntax, and common commands. - Added Chinese command guide (COMMAND_GUIDE_zh.md) detailing command usage, syntax, and common commands. - Introduced English command target documentation (COMMAND_TARGET_en.md) explaining target resolution and `giveall` parameters. - Introduced Chinese command target documentation (COMMAND_TARGET_zh.md) explaining target resolution and `giveall` parameters. - Added English Linux platform README (README_linux_en.md) with configuration and setup instructions. - Added Chinese Linux platform README (README_linux_zh.md) with configuration and setup instructions. - Created English usage guide (USAGE_en.md) covering setup, resource generation, and database structure. - Created Chinese usage guide (USAGE_zh.md) covering setup, resource generation, and database structure. * feat: add Japanese documentation and update multilingual support * add Japanese user documentation files * add Japanese command guide and command target docs * add Japanese Linux platform guide * add Japanese usage documentation * update English and Chinese multilingual documentation content --- Common/Database/Account/AccountData.cs | 5 + GameServer/Game/Player/PlayerInstance.cs | 146 +++++++++++--- .../Packet/Recv/Login/HandlerReqLogin.cs | 14 +- MikuSB/Program/LoaderManager.cs | 24 ++- MikuSB/Program/MikuSB.cs | 1 + MikuSB/Update/UpdateService.cs | 1 + README.md | 106 +++++----- README_jp.md | 96 --------- README_linux.md | 66 ------- README_zh.md | 96 --------- SdkServer/Handlers/RouteController.cs | 66 ++----- docs/dev/BRANCH_UPDATE_SUMMARY_en.md | 74 +++++++ docs/dev/BRANCH_UPDATE_SUMMARY_zh.md | 74 +++++++ docs/user/README_jp.md | 115 +++++++++++ docs/user/README_zh.md | 115 +++++++++++ docs/user/commands/COMMAND_GUIDE_en.md | 160 +++++++++++++++ docs/user/commands/COMMAND_GUIDE_jp.md | 158 +++++++++++++++ .../user/commands/COMMAND_GUIDE_zh.md | 59 +++--- docs/user/commands/COMMAND_TARGET_en.md | 60 ++++++ docs/user/commands/COMMAND_TARGET_jp.md | 60 ++++++ .../user/commands/COMMAND_TARGET_zh.md | 26 +-- docs/user/platform/README_linux_en.md | 41 ++++ docs/user/platform/README_linux_jp.md | 41 ++++ docs/user/platform/README_linux_zh.md | 41 ++++ docs/user/usage/USAGE_en.md | 184 ++++++++++++++++++ docs/user/usage/USAGE_jp.md | 127 ++++++++++++ USAGE_zh.md => docs/user/usage/USAGE_zh.md | 15 +- 27 files changed, 1533 insertions(+), 438 deletions(-) delete mode 100644 README_jp.md delete mode 100644 README_linux.md delete mode 100644 README_zh.md create mode 100644 docs/dev/BRANCH_UPDATE_SUMMARY_en.md create mode 100644 docs/dev/BRANCH_UPDATE_SUMMARY_zh.md create mode 100644 docs/user/README_jp.md create mode 100644 docs/user/README_zh.md create mode 100644 docs/user/commands/COMMAND_GUIDE_en.md create mode 100644 docs/user/commands/COMMAND_GUIDE_jp.md rename COMMAND_GUIDE_zh.md => docs/user/commands/COMMAND_GUIDE_zh.md (63%) create mode 100644 docs/user/commands/COMMAND_TARGET_en.md create mode 100644 docs/user/commands/COMMAND_TARGET_jp.md rename COMMAND_TARGET_CHS.md => docs/user/commands/COMMAND_TARGET_zh.md (65%) create mode 100644 docs/user/platform/README_linux_en.md create mode 100644 docs/user/platform/README_linux_jp.md create mode 100644 docs/user/platform/README_linux_zh.md create mode 100644 docs/user/usage/USAGE_en.md create mode 100644 docs/user/usage/USAGE_jp.md rename USAGE_zh.md => docs/user/usage/USAGE_zh.md (96%) diff --git a/Common/Database/Account/AccountData.cs b/Common/Database/Account/AccountData.cs index d75f5b8..84dca94 100644 --- a/Common/Database/Account/AccountData.cs +++ b/Common/Database/Account/AccountData.cs @@ -40,6 +40,11 @@ public class AccountData : BaseDatabaseDataHelper return result; } + public static AccountData? GetFirstAccount() + => DatabaseHelper.GetAllInstance()? + .OrderBy(account => account.Uid) + .FirstOrDefault(); + public static AccountData? GetAccountByDispatchToken(string dispatchToken) { AccountData? result = null; diff --git a/GameServer/Game/Player/PlayerInstance.cs b/GameServer/Game/Player/PlayerInstance.cs index 6a66e54..24ea4f4 100644 --- a/GameServer/Game/Player/PlayerInstance.cs +++ b/GameServer/Game/Player/PlayerInstance.cs @@ -19,6 +19,8 @@ namespace MikuSB.GameServer.Game.Player; public class PlayerInstance(PlayerGameData data) { + private const uint BootstrapLevel = 80; + #region Property public Connection? Connection { get; set; } @@ -50,35 +52,7 @@ public PlayerInstance(int uid) : this(new PlayerGameData { Uid = uid }) var t = Task.Run(async () => { await InitialPlayerManager(); - foreach (var skinCard in GameData.CardSkinData.Values) - { - await InventoryManager.AddSkinItem((ItemTypeEnum)skinCard.Genre, skinCard.Detail, skinCard.Particular, skinCard.Level, false); - } - foreach (var ar in GameData.ArItemData.Values) - { - await InventoryManager.AddArItem((ItemTypeEnum)ar.Genre, ar.Detail, ar.Particular, ar.Level, false); - } - foreach (var manifest in GameData.ManifestationData.Values) - { - await InventoryManager.AddManifestationItem((ItemTypeEnum)manifest.Genre, manifest.Detail, manifest.Particular, manifest.Level, false); - } - foreach (var card in GameData.CardData.Values) - { - await CharacterManager.AddCharacter((ItemTypeEnum)card.Genre, card.Detail, card.Particular, card.Level, sendPacket:false); - } - foreach (var supplies in GameData.AllSuppliesData) - { - await InventoryManager.AddSuppliesItem(supplies, 90000, false); - } - - var selected = CharacterManager.CharacterData.Characters - .OrderBy(_ => Guid.NewGuid()) - .Take(3) - .Select(x => x.Guid) - .ToList(); - - await LineupManager.UpdateLineup(1, selected[0], selected[1], selected[2],false); - + await InitializeAllDatabaseData(); }); t.Wait(); @@ -106,6 +80,7 @@ private async ValueTask InitialPlayerManager() public async ValueTask OnEnterGame() { if (!Initialized) await InitialPlayerManager(); + if (ShouldBackfillAllDatabaseData()) await InitializeAllDatabaseData(); Data.EnsureDisplayName(); await CharacterManager.RepairCharacterWeapons(); await EnsureSupplies(); @@ -145,6 +120,117 @@ public async ValueTask SendPacket(int cmdId, IMessage msg) #endregion + private bool ShouldBackfillAllDatabaseData() + { + if (CharacterManager.CharacterData.Characters.Count > 0) + return false; + + var inventoryData = InventoryManager.InventoryData; + return inventoryData.Items.Count == 0 + && inventoryData.Weapons.Count == 0 + && inventoryData.Skins.Count == 0 + && inventoryData.SupportCards.Count == 0; + } + + private async ValueTask InitializeAllDatabaseData() + { + foreach (var weapon in GameData.WeaponData.Values) + { + if (weapon.Level <= 0) + continue; + + await InventoryManager.AddWeaponItem((ItemTypeEnum)weapon.Genre, weapon.Detail, weapon.Particular, + weapon.Level, BootstrapLevel, false); + } + foreach (var supportCard in GameData.SupportCardData) + { + if (supportCard.Level <= 0) + continue; + + await InventoryManager.AddSupportCardItem(supportCard.Detail, supportCard.Particular, supportCard.Level, BootstrapLevel, false); + } + foreach (var weaponSkin in GameData.WeaponSkinData.Values) + { + if (weaponSkin.Level <= 0) + continue; + + await InventoryManager.AddWeaponSkinItem((ItemTypeEnum)weaponSkin.Genre, weaponSkin.Detail, weaponSkin.Particular, weaponSkin.Level, false); + } + foreach (var skinCard in GameData.CardSkinData.Values) + { + if (skinCard.Level <= 0) + continue; + + await InventoryManager.AddSkinItem((ItemTypeEnum)skinCard.Genre, skinCard.Detail, skinCard.Particular, skinCard.Level, false); + } + foreach (var profile in GameData.ProfileData.Values) + { + if (profile.Level <= 0) + continue; + + await InventoryManager.AddProfileItem((ItemTypeEnum)profile.Genre, profile.Detail, profile.Particular, profile.Level, false); + } + foreach (var skinPart in GameData.CardSkinPartsData.Values) + { + if (skinPart.Level <= 0) + continue; + + await InventoryManager.AddSkinPartItem((ItemTypeEnum)skinPart.Genre, skinPart.Detail, skinPart.Particular, skinPart.Level, false); + } + foreach (var callItem in GameData.CallItemData.Values) + { + if (callItem.Level <= 0) + continue; + + await InventoryManager.AddCallItem((ItemTypeEnum)callItem.Genre, callItem.Detail, callItem.Particular, callItem.Level, false); + } + foreach (var weaponPart in GameData.WeaponPartsData.Values) + { + if (weaponPart.Level <= 0) + continue; + + await InventoryManager.AddWeaponPartItem((ItemTypeEnum)weaponPart.Genre, weaponPart.Detail, weaponPart.Particular, weaponPart.Level, false); + } + foreach (var furniture in GameData.DormGiftData.Values) + { + if (furniture.Level <= 0) + continue; + + await InventoryManager.AddHouseFurnitureItem((ItemTypeEnum)furniture.Genre, furniture.Detail, furniture.Particular, furniture.Level, false); + } + foreach (var ar in GameData.ArItemData.Values) + { + await InventoryManager.AddArItem((ItemTypeEnum)ar.Genre, ar.Detail, ar.Particular, ar.Level, false); + } + foreach (var manifest in GameData.ManifestationData.Values) + { + await InventoryManager.AddManifestationItem((ItemTypeEnum)manifest.Genre, manifest.Detail, manifest.Particular, manifest.Level, false); + } + foreach (var card in GameData.CardData.Values) + { + var character = await CharacterManager.AddCharacter((ItemTypeEnum)card.Genre, card.Detail, card.Particular, card.Level, sendPacket: false); + if (character == null) + continue; + + character.Level = BootstrapLevel; + } + foreach (var supplies in GameData.AllSuppliesData) + { + await InventoryManager.AddSuppliesItem(supplies, 90000, false); + } + + if (!LineupManager.LineupData.LineupInfo.ContainsKey(1)) + { + var selected = CharacterManager.CharacterData.Characters + .OrderBy(_ => Guid.NewGuid()) + .Take(3) + .Select(x => x.Guid) + .ToList(); + if (selected.Count == 3) + await LineupManager.UpdateLineup(1, selected[0], selected[1], selected[2], false); + } + } + #region Actions public async ValueTask OnHeartBeat() { @@ -434,4 +520,4 @@ public void BuildPlayerAttr(bool additional = false) yield return (132, 1, 0); } #endregion -} \ No newline at end of file +} diff --git a/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs b/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs index 5f719ce..0a3c179 100644 --- a/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs +++ b/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs @@ -46,6 +46,9 @@ public class HandlerReqLogin : Handler } } + private static AccountData? ResolveAutoLoginAccount() + => AccountData.GetFirstAccount(); + public override async Task OnHandle(Connection connection, byte[] data, ushort seqNo) { var req = ReqLogin.Parser.ParseFrom(data); @@ -56,9 +59,14 @@ public override async Task OnHandle(Connection connection, byte[] data, ushort s ?? AccountData.GetAccountByDispatchToken(sdkAuthToken ?? ""); if (account == null) { - Logger.Warn($"Rejected login: provider={req.Provider}, token={req.Token}, authToken={sdkAuthToken}"); - await connection.SendPacket(CmdIds.NtfLogout); - return; + account = ResolveAutoLoginAccount(); + if (account == null) + { + Logger.Warn($"Rejected login: provider={req.Provider}, token={req.Token}, authToken={sdkAuthToken}, reason=no account exists"); + await connection.SendPacket(CmdIds.NtfLogout); + return; + } + Logger.Warn($"Auto login accepted with first account: provider={req.Provider}, token={req.Token}, authToken={sdkAuthToken}, username={account.Username}, uid={account.Uid}"); } if (!ResourceManager.IsLoaded) // resource manager not loaded, return diff --git a/MikuSB/Program/LoaderManager.cs b/MikuSB/Program/LoaderManager.cs index 04bfc29..eb1aed7 100644 --- a/MikuSB/Program/LoaderManager.cs +++ b/MikuSB/Program/LoaderManager.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Components; using MikuSB.Data; using MikuSB.Database; +using MikuSB.Database.Account; using MikuSB.GameServer.Command; using MikuSB.GameServer.Server; using MikuSB.GameServer.Server.CallGS; @@ -19,6 +20,9 @@ namespace MikuSB.MikuSB.Program; public class LoaderManager : MikuSB { + private const string InitialAccountUsername = "MIKU"; + private const int InitialAccountUid = 1; + public static void InitConfig() { // Initialize log @@ -80,6 +84,11 @@ public static void InitConfig() public static void InitDatabase() { + var databaseFile = new FileInfo(Path.Combine( + ConfigManager.Config.Path.DatabasePath, + ConfigManager.Config.GameServer.DatabaseName)); + var shouldInitializeData = !databaseFile.Exists; + // Initialize the database try { @@ -87,6 +96,9 @@ public static void InitDatabase() while (!DatabaseHelper.LoadAccount) Thread.Sleep(100); + if (shouldInitializeData) + InitializeStartupData(); + Logger.Info(I18NManager.Translate("Server.ServerInfo.LoadedItem", I18NManager.Translate("Word.DatabaseAccount"))); } @@ -99,6 +111,16 @@ public static void InitDatabase() } } + private static void InitializeStartupData() + { + if (AccountData.GetFirstAccount() != null) + return; + + var startupPassword = Crypto.CreateSessionKey($"{InitialAccountUsername}-{DateTime.UtcNow.Ticks}"); + _ = AccountData.CreateAccount(InitialAccountUsername, InitialAccountUid, startupPassword); + Logger.Info("Initialized startup account for fresh database."); + } + public static async Task InitSdkServer() { SdkServer.SdkServer.Start([]); @@ -180,4 +202,4 @@ public static async Task InitCommand(CancellationToken exitToken) await IConsole.ListenConsole(exitToken); } -} \ No newline at end of file +} diff --git a/MikuSB/Program/MikuSB.cs b/MikuSB/Program/MikuSB.cs index ff779d5..e560920 100644 --- a/MikuSB/Program/MikuSB.cs +++ b/MikuSB/Program/MikuSB.cs @@ -24,6 +24,7 @@ public class MikuSB public static async Task Main() { + Directory.SetCurrentDirectory(AppContext.BaseDirectory); var time = DateTime.Now; IConsole.InitConsole(); LoaderManager.InitConfig(); diff --git a/MikuSB/Update/UpdateService.cs b/MikuSB/Update/UpdateService.cs index 1c6f2d9..0b0b468 100644 --- a/MikuSB/Update/UpdateService.cs +++ b/MikuSB/Update/UpdateService.cs @@ -167,6 +167,7 @@ private static bool AreRequiredResourcesPresent() return RequiredResourceFiles.All(fileName => File.Exists(Path.Combine(resourcePath, fileName))); } + private static async Task DownloadAndInstallResourcesAsync() { using var client = CreateHttpClient(); diff --git a/README.md b/README.md index 351de60..4d31c2b 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,18 @@ # MikuSB -MikuSB is a server emulator of a certain dungeon anime game. -`SdkServer`, `GameServer`, and an optional local HTTP/HTTPS proxy are started from a single `net9.0` application. +Languages: English | [中文](docs/user/README_zh.md) | [日本語](docs/user/README_jp.md) + +MikuSB is a server emulator of a certain dungeon anime game. +`SdkServer`, `GameServer`, and an optional local HTTP/HTTPS proxy are started from a single `net10.0` application. [Discord](https://discord.gg/aMwCu9JyUR) -- 中文文档见 [README_zh.md](README_zh.md)。 -- 日本語のドキュメントは [README_jp.md](README_jp.md) にあります。 -- Detailed Chinese usage guide: [USAGE_zh.md](USAGE_zh.md). -- 中文命令目标说明见 [COMMAND_TARGET_CHS.md](COMMAND_TARGET_CHS.md)。 +## Documentation + +- [Linux guide](docs/user/platform/README_linux_en.md) +- [Usage guide](docs/user/usage/USAGE_en.md) +- [Command guide](docs/user/commands/COMMAND_GUIDE_en.md) +- [Command target notes](docs/user/commands/COMMAND_TARGET_en.md) ## Overview @@ -40,58 +44,72 @@ ## Running 1. Restore dependencies and build. + ```powershell dotnet build ``` -2. Set `GamePath` in `Config.json` to the path of your game executable. -3. Start the server and run the `game` command. -4. Create an account in the server console. -5. Enjoy. + +2. Set `GamePath` in `Config/Config.json` to the path of your game executable. +3. Start the server. + +```powershell +dotnet run --project .\MikuSB +``` + +4. Create an account in the server console. +5. Run the `game` command in the server console. +6. Start the game and log in. + +For publish commands and generated data details, see the [usage guide](docs/user/usage/USAGE_en.md). ## Feature List -* [x] Login and basic account entry -* [x] Player data loading -* [x] Inventory loading -* [x] Character loading -* [x] Skin loading -* [x] Weapon loading -* [x] Lobby display character switching -* [x] Character skin switching -* [x] Character skin form switching -* [x] Weapon replacement -* [x] Weapon upgrade -* [x] Player rename -* [x] Basic saving of currently supported lobby state -* [✓] Main chapter stage entry and related flow -* [✓] Daily stage entry and related flow -* [✓] Basic player setting synchronization -* [✓] Basic profile synchronization -* [✓] Activity-related requests -* [✓] Achievement-related requests -* [✓] Lineup-related requests -* [✓] Preview-related requests -* [✓] Some shop-related requests -* [ ] Full combat flow -* [ ] Mission / quest progression -* [ ] Gacha / recruitment systems -* [ ] Complete shop behavior -* [ ] Multiplayer systems -* [ ] Base / dorm systems -* [ ] Full client API coverage +- [x] Login and basic account entry +- [x] Player data loading +- [x] Inventory loading +- [x] Character loading +- [x] Skin loading +- [x] Weapon loading +- [x] Lobby display character switching +- [x] Character skin switching +- [x] Character skin form switching +- [x] Weapon replacement +- [x] Weapon upgrade +- [x] Player rename +- [x] Basic saving of currently supported lobby state +- [x] Main chapter stage entry and related flow +- [x] Daily stage entry and related flow +- [x] Basic player setting synchronization +- [x] Basic profile synchronization +- [x] Activity-related requests +- [x] Achievement-related requests +- [x] Lineup-related requests +- [x] Preview-related requests +- [x] Some shop-related requests +- [ ] Full combat flow +- [ ] Mission / quest progression +- [ ] Gacha / recruitment systems +- [ ] Complete shop behavior +- [ ] Multiplayer systems +- [ ] Base / dorm systems +- [ ] Full client API coverage ## Contributors + - [Naruse](https://github.com/DevilProMT) - [Kei-Luna](https://github.com/Kei-Luna) ## Notes on use -This software is intended for research and testing purposes in a local environment. + +This software is intended for research and testing purposes in a local environment. It is not intended for unauthorized access to, interference with, or commercial use of official services. ## Legal Disclaimer -MikuSB was developed for educational and research purposes. -- All trademarks, copyrights, and other intellectual property related to the original game and its associated franchise belong to their respective owners. -- This repository does not include any copyrighted game assets, binaries, or master data. -- Use this software at your own risk. The authors assume no responsibility for any damages or legal consequences resulting from its use. + +MikuSB was developed for educational and research purposes. + +- All trademarks, copyrights, and other intellectual property related to the original game and its associated franchise belong to their respective owners. +- This repository does not include any copyrighted game assets, binaries, or master data. +- Use this software at your own risk. The authors assume no responsibility for any damages or legal consequences resulting from its use. If you are a rights holder and have any concerns regarding this software, please contact `devilpromt` or `kei_luna` on Discord. diff --git a/README_jp.md b/README_jp.md deleted file mode 100644 index 558eb4d..0000000 --- a/README_jp.md +++ /dev/null @@ -1,96 +0,0 @@ -# MikuSB - -MikuSBは、あるダンジョンアニメゲームのサーバーエミュレーターです。 -`SdkServer`、`GameServer`、任意のローカル HTTP/HTTPS プロキシを 1 つの `net9.0` アプリとして起動します。 - -[Discord](https://discord.gg/aMwCu9JyUR) - -English documentation is available in [README.md](README.md). -中文文档见 [README_zh.md](README_zh.md)。 - -## 概要 - -- `SdkServer` - - HTTP API とディスパッチを返します - - サーバー一覧、バージョン照会、各種フォールバックレスポンスを返します -- `GameServer` - - TCP ベースのゲーム接続を受けます - - `ReqCallGS` と一部の通常パケットを処理します -- `Proxy` - - 有効時のみ `127.0.0.1:8888` で待ち受けます - - 一部の Snowbreak 関連ドメインをローカル `SdkServer` へリダイレクトします -- `Common` / `Proto` / `TcpSharp` - - 共通データ、protobuf 定義、通信基盤です - -## プロジェクト構成 - -- [MikuSB](MikuSB): エントリーポイント -- [SdkServer](SdkServer): HTTP サーバーとディスパッチ -- [GameServer](GameServer): ゲームサーバー本体 -- [Proxy](Proxy): ローカルプロキシ -- [Common](Common): 設定、DB、共通処理 -- [Proto](Proto): protobuf 定義 - -## 要件 - -- [.NET SDK 10.0](https://dotnet.microsoft.com/ja-jp/download/dotnet/10.0) - -## 起動方法 - -1. 依存を復元してビルドします。 -```powershell -dotnet build -``` -2. Config.json の`GamePath`にあなたのゲームの実行ファイルのパスを書き込みます -3. サーバーを起動し`game`コマンドを入力します -4. サーバーコンソールでアカウントを作成する -5. 楽しむ - -## 機能一覧 - -* [x] ログインと基本的なアカウント入場 -* [x] プレイヤーデータの読み込み -* [x] 所持品の読み込み -* [x] キャラクターの読み込み -* [x] スキンの読み込み -* [x] 武器の読み込み -* [x] ロビー表示キャラクターの変更 -* [x] キャラクタースキンの変更 -* [x] キャラクタースキン形態の変更 -* [x] 武器の付け替え -* [x] 武器の強化 -* [x] プレイヤー名の変更 -* [x] 現在対応済みロビー状態の基本保存 -* [✓] メイン章のステージ入場と関連フロー -* [✓] デイリーのステージ入場と関連フロー -* [✓] 基本的なプレイヤー設定同期 -* [✓] 基本的なプロフィール同期 -* [✓] イベント関連リクエスト -* [✓] 実績関連リクエスト -* [✓] 編成関連リクエスト -* [✓] プレビュー関連リクエスト -* [✓] 一部のショップ関連リクエスト -* [ ] 完全な戦闘フロー -* [ ] ミッション / クエスト進行 -* [ ] ガチャ / 募集システム -* [ ] 完全なショップ挙動 -* [ ] マルチプレイシステム -* [ ] 基地 / 宿舎システム -* [ ] クライアント API 全体の対応 - -## 貢献者 -- [Naruse](https://github.com/DevilProMT) -- [Kei-Luna](https://github.com/Kei-Luna) - - -## 利用上の注意 -本ソフトウェアはローカル環境での研究・検証用途を想定しています。 -公式サービスへの不正な接続、妨害、または商用利用を意図したものではありません。 - -## 法的免責事項 -MikuSBは教育および研究目的で開発されました。 -- 元のゲーム及び関連フランチャイズに関するすべての商標、著作権知的財産権はそれぞれの所有者に帰属します。 -- このリポジトリには、著作権で保護されたゲームアセット、バイナリ、マスターデータは一切含まれていません。 -- 自己責任でご利用下さい。 著者は、本ソフトウェアによって生じるいかなる損害または法的結果についても一切責任を負いません。 - -本ソフトウェアに関して懸念事項をお持ちの権利保有者は`devilpromt`または`kei_luna`にDiscordでご連絡下さい。 diff --git a/README_linux.md b/README_linux.md deleted file mode 100644 index 36b910b..0000000 --- a/README_linux.md +++ /dev/null @@ -1,66 +0,0 @@ -# MikuSB on Linux - - -## Config - -### setup steam launch options as following - -`HTTP_PROXY="http://127.0.0.1:8888" HTTPS_PROXY="http://127.0.0.1:8888" ALL_PROXY="http://127.0.0.1:8888" %command%` - -### start local server and keep it running - -``` -./MikuSB -``` - -### find root CA cert, and create ca bundle - -root CA cert, should in the path: `proxy-certs/MikuSB.Proxy.Root.pem` - - -### setup root CA for proton/wine - -not sure, even I remove Proton PFX (Wine prefix) folder, without redo this step, still no cert issue. - -`Proton Hotfix` is the proton version which selected in steam `Force the use of a specific Steam Play compatibility tool` - -```bash -APPID= -STEAM_COMPAT_DATA_PATH=~/.steam/steam/steamapps/compatdata/$APPID/pfx -STEAM_WINE_PATH="$HOME/.steam/steam/steamapps/common/Proton Hotfix/files/bin/wine" -WINEPREFIX=$STEAM_COMPAT_DATA_PATH $STEAM_WINE_PATH certutil -addstore -f Root proxy-certs/MikuSB.Proxy.Root.pem -``` - -### start the game and enjoy - - -## development - -1. Restore dependencies and build. - -```bash -dotnet build -``` - -2. run it - -```bash -dotnet run --project ./MikuSB -``` - -## release build - -```bash -DOTNET_CLI_UI_LANGUAGE=en time dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish - -# output will in ./publish/* -cd ./publish - -# start server -./MikuSB -``` - -## TODO: - -* [ ] tool/script for CA cert create and install to proton/wine -* [ ] automatic done in main program diff --git a/README_zh.md b/README_zh.md deleted file mode 100644 index 35506c7..0000000 --- a/README_zh.md +++ /dev/null @@ -1,96 +0,0 @@ -# MikuSB - -MikuSB 是某款地牢题材动漫游戏的服务器模拟器。 -它会从一个 `net9.0` 应用中启动 `SdkServer`、`GameServer`,以及可选的本地 HTTP/HTTPS 代理。 - -[Discord](https://discord.gg/aMwCu9JyUR) - -English documentation is available in [README.md](README.md). -日本語のドキュメントは [README_jp.md](README_jp.md) にあります。 -详细中文使用指导见 [USAGE_zh.md](USAGE_zh.md)。 -命令从零使用文档见 [COMMAND_GUIDE_zh.md](COMMAND_GUIDE_zh.md)。 - -## 概览 - -- `SdkServer` - - 提供 HTTP API 并分发响应 - - 返回服务器列表、版本查询和各类兜底响应 -- `GameServer` - - 接受基于 TCP 的游戏连接 - - 处理 `ReqCallGS` 与部分普通协议包 -- `Proxy` - - 启用时监听 `127.0.0.1:8888` - - 将部分 Snowbreak 相关域名重定向到本地 `SdkServer` -- `Common` / `Proto` / `TcpSharp` - - 共享数据、protobuf 定义与网络通信基础设施 - -## 项目结构 - -- [MikuSB](MikuSB): 入口程序 -- [SdkServer](SdkServer): HTTP 服务与分发 -- [GameServer](GameServer): 主游戏服务器 -- [Proxy](Proxy): 本地代理 -- [Common](Common): 配置、数据库与公共工具 -- [Proto](Proto): protobuf 定义 - -## 环境要求 - -- [.NET SDK 10.0](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0) - -## 运行 - -1. 还原依赖并构建。 - -```powershell -dotnet build -``` - -2. 开始使用。 - -## 功能列表 - -* [x] 登录与基础账号进入 -* [x] 玩家数据加载 -* [x] 背包数据加载 -* [x] 角色数据加载 -* [x] 皮肤数据加载 -* [x] 武器数据加载 -* [x] 大厅展示角色切换 -* [x] 角色皮肤切换 -* [x] 角色皮肤形态切换 -* [x] 武器替换 -* [x] 武器强化 -* [x] 玩家改名 -* [x] 当前已支持大厅状态的基础保存 -* [✓] 主线章节关卡进入及相关流程 -* [✓] 日常关卡进入及相关流程 -* [✓] 基础玩家设置同步 -* [✓] 基础个人资料同步 -* [✓] 活动相关请求 -* [✓] 成就相关请求 -* [✓] 编队相关请求 -* [✓] 预览相关请求 -* [✓] 部分商店相关请求 -* [ ] 完整战斗流程 -* [ ] 任务 / 委托进度 -* [ ] 抽卡 / 招募系统 -* [ ] 完整商店行为 -* [ ] 多人系统 -* [ ] 基地 / 宿舍系统 -* [ ] 客户端 API 全覆盖 - -## 贡献者 -- [Naruse](https://github.com/DevilProMT) -- [Kei-Luna](https://github.com/Kei-Luna) - -## 使用说明 -本软件仅用于本地环境下的研究与测试。 -不用于对官方服务进行未授权访问、干扰或商业用途。 - -## 法律免责声明 -MikuSB 仅为教育与研究目的开发。 -- 与原游戏及其相关系列有关的所有商标、版权及其他知识产权均归其各自所有者所有。 -- 本仓库不包含任何受版权保护的游戏资源、二进制文件或主数据。 -- 使用本软件需自行承担风险。作者不对因使用本软件导致的任何损失或法律后果负责。 - -若您是权利持有方并对本软件有任何顾虑,请在 Discord 联系 `devilpromt` 或 `kei_luna`。 diff --git a/SdkServer/Handlers/RouteController.cs b/SdkServer/Handlers/RouteController.cs index 82f9ec9..da081da 100644 --- a/SdkServer/Handlers/RouteController.cs +++ b/SdkServer/Handlers/RouteController.cs @@ -190,6 +190,9 @@ public IActionResult GetSeasunConfig() } } + private static AccountData? ResolveAutoLoginAccount() + => AccountData.GetFirstAccount(); + private IActionResult BuildLoginFailedResponse(string message) { object rsp = new @@ -220,12 +223,12 @@ public async Task LoginByToken( [FromQuery] string? uid, [FromQuery] string? token, [FromForm] string? form_uid, - [FromForm] string? form_token - ) + [FromForm] string? form_token) { - var finalUid = uid ?? form_uid ?? await GetJsonBodyValue("uid"); - var finalToken = token ?? form_token ?? await GetJsonBodyValue("token"); - var account = ResolveAccountForSdkLogin(null, finalUid, finalToken); + var bodyUid = await GetJsonBodyValue("uid"); + var bodyToken = await GetJsonBodyValue("token"); + var account = ResolveAccountForSdkLogin(null, uid ?? form_uid ?? bodyUid, token ?? form_token ?? bodyToken) + ?? ResolveAutoLoginAccount(); if (account == null) return BuildLoginFailedResponse("Account not found."); @@ -264,51 +267,16 @@ public async Task Login( [FromQuery] string? email, [FromForm] string? form_uid, [FromForm] string? form_token, - [FromForm] string? form_email - ) + [FromForm] string? form_email) { - var finalEmail = email ?? form_email ?? await GetJsonBodyValue("email"); - if (!string.IsNullOrWhiteSpace(finalEmail)) - { - var username = finalEmail.Split('@')[0]; - var accountData = AccountData.GetAccountByUserName(username); - if (accountData == null) - { - if (!ConfigManager.Config.ServerOption.AutoCreateUser) return BuildLoginFailedResponse("Account not found."); - AccountData.CreateAccount(username, 0, "123456"); - accountData = AccountData.GetAccountByUserName(username)!; - } - - var finalUidValue = accountData.Uid.ToString(); - var finalTokenValue = accountData.GenerateComboToken(); - - object emailLoginRsp = new - { - code = 0, - data = new - { - associatedAccounts = Array.Empty(), - isFirstLogin = false, - isNeedKoreaSciAuth = false, - ksOpenId = $"ks_{finalUidValue}", - nickname = accountData.Username, - passportId = finalUidValue, - playerFillAgeUrl = "", - status = 0, - thirdPartyUid = "", - token = finalTokenValue, - type = "guest", - uid = accountData.Uid - }, - msg = "操作成功" - }; - - return Ok(emailLoginRsp); - } - - var finalUid = uid ?? form_uid ?? await GetJsonBodyValue("uid"); - var finalToken = token ?? form_token ?? await GetJsonBodyValue("token"); - var account = ResolveAccountForSdkLogin(finalEmail, finalUid, finalToken); + var bodyEmail = await GetJsonBodyValue("email"); + var bodyUid = await GetJsonBodyValue("uid"); + var bodyToken = await GetJsonBodyValue("token"); + var account = ResolveAccountForSdkLogin( + email ?? form_email ?? bodyEmail, + uid ?? form_uid ?? bodyUid, + token ?? form_token ?? bodyToken) + ?? ResolveAutoLoginAccount(); if (account == null) return BuildLoginFailedResponse("Account not found."); diff --git a/docs/dev/BRANCH_UPDATE_SUMMARY_en.md b/docs/dev/BRANCH_UPDATE_SUMMARY_en.md new file mode 100644 index 0000000..d63aecf --- /dev/null +++ b/docs/dev/BRANCH_UPDATE_SUMMARY_en.md @@ -0,0 +1,74 @@ +# Branch update summary (based on origin/main) + +> Branch: `copilot/analyze-login-rejection` +> Baseline: `origin/main` +> Stats: 13 commits, 5 files changed (+163 / -141) + +## 1. Commit list (oldest to newest) + +1. `038e236` feat: force login to MIKU account +2. `8ea8b75` fix: handle forced account fallback exceptions +3. `13c7ed8` refactor: share forced account resolution for login +4. `89c8e81` feat: auto login first account from database +5. `1d217f0` refactor: select first account for auto-login fallback +6. `60b091d` feat: initialize default account at startup for new database +7. `f502007` fix: avoid logging startup account identifiers +8. `fbb31c8` fix: use random password for startup-initialized account +9. `2ca4f0a` feat: auto grant level 90 weapons on new player initialization +10. `61a231f` feat: initialize all giveall items for new players +11. `80fbf48` fix: backfill full player initialization on empty login data +12. `25c76c2` fix: require exactly three characters before default lineup init +13. `8582e3c` fix: set bootstrap equipment and character progression to level 80 + +## 2. File-level overview + +### 1) `Common/Database/Account/AccountData.cs` +- Added `GetFirstAccount()`: + - Selects the first account by ascending `Uid`. + - Serves as the shared fallback for auto-login. + +### 2) `MikuSB/Program/LoaderManager.cs` +- Added startup initialization for fresh databases: + - Detects first-run by checking the database file. + - Calls `InitializeStartupData()` on first run. + - Creates a default account `MIKU` (`Uid=1`) when no account exists. + - Initial password is a random session key. + +### 3) `SdkServer/Handlers/RouteController.cs` +- SDK login logic is consolidated to the “first account auto-login” path: + - `/seasun/login` + - `/seasun/loginByToken` +- Removed the earlier token/email/uid multi-source resolution path. +- Returns a unified login-failed response when no account is found. + +### 4) `GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs` +- Added auto fallback on login packet handling: + - If token/dispatch/combo resolution fails, fallback to `GetFirstAccount()`. + - Reject login if no account exists. + - Continue login flow if an account is available. + +### 5) `GameServer/Game/Player/PlayerInstance.cs` +- Added and reused `InitializeAllDatabaseData()`: + - Covers weapons, support cards, skins, profiles, accessories, furniture, AR, manifestation, characters, and supplies. + - Used for both new player creation and empty-login backfill. +- Added `ShouldBackfillAllDatabaseData()`: + - Triggers full backfill when characters and key inventory are empty. +- Default lineup init is guarded: + - Only writes lineup when exactly three characters are selected. +- Bootstrap level is unified: + - Constant `BootstrapLevel = 80`. + +## 3. Core themes vs main + +1. **Login resilience**: fallback to the first account when tokens do not match. +2. **Fresh DB usability**: auto-create a default account on first start. +3. **Player data self-healing**: backfill full data when a login has empty records. +4. **Unified bootstrap rules**: consolidated initial level configuration. + +## 4. Changed files + +- `Common/Database/Account/AccountData.cs` +- `GameServer/Game/Player/PlayerInstance.cs` +- `GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs` +- `MikuSB/Program/LoaderManager.cs` +- `SdkServer/Handlers/RouteController.cs` diff --git a/docs/dev/BRANCH_UPDATE_SUMMARY_zh.md b/docs/dev/BRANCH_UPDATE_SUMMARY_zh.md new file mode 100644 index 0000000..a3eff29 --- /dev/null +++ b/docs/dev/BRANCH_UPDATE_SUMMARY_zh.md @@ -0,0 +1,74 @@ +# 本分支基于主线(origin/main)更新总结 + +> 分支:`copilot/analyze-login-rejection` +> 对比基线:`origin/main` +> 统计:13 个提交,5 个文件变更(+163 / -141) + +## 一、提交列表(按时间从旧到新) + +1. `038e236` feat: force login to MIKU account +2. `8ea8b75` fix: handle forced account fallback exceptions +3. `13c7ed8` refactor: share forced account resolution for login +4. `89c8e81` feat: auto login first account from database +5. `1d217f0` refactor: select first account for auto-login fallback +6. `60b091d` feat: initialize default account at startup for new database +7. `f502007` fix: avoid logging startup account identifiers +8. `fbb31c8` fix: use random password for startup-initialized account +9. `2ca4f0a` feat: auto grant level 90 weapons on new player initialization +10. `61a231f` feat: initialize all giveall items for new players +11. `80fbf48` fix: backfill full player initialization on empty login data +12. `25c76c2` fix: require exactly three characters before default lineup init +13. `8582e3c` fix: set bootstrap equipment and character progression to level 80 + +## 二、文件级更新概览 + +### 1) `Common/Database/Account/AccountData.cs` +- 新增 `GetFirstAccount()`: + - 从账号表中按 `Uid` 升序选择首个账号; + - 作为自动登录回退账号解析的统一入口。 + +### 2) `MikuSB/Program/LoaderManager.cs` +- 在数据库初始化阶段新增“新库首启数据初始化”逻辑: + - 通过数据库文件存在性判断是否为首次初始化; + - 首次初始化时调用 `InitializeStartupData()`; + - 若库中无账号,则自动创建默认账号 `MIKU`(`Uid=1`); + - 初始密码改为随机生成(会话密钥形式)。 + +### 3) `SdkServer/Handlers/RouteController.cs` +- 收敛 SDK 登录相关分支逻辑到“首账号自动登录”路径: + - `/seasun/login` + - `/seasun/loginByToken` +- 移除原有较复杂的 token/email/uid 多源解析代码路径; +- 在找不到账号时统一返回登录失败响应。 + +### 4) `GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs` +- 登录包处理增加自动回退: + - token/dispatch/combo 解析失败后,回退到 `GetFirstAccount()`; + - 若无可用账号则拒绝登录; + - 有可用账号则继续登录流程。 + +### 5) `GameServer/Game/Player/PlayerInstance.cs` +- 新增并复用完整初始化方法 `InitializeAllDatabaseData()`: + - 覆盖武器、支援卡、皮肤、资料、挂件、家具、AR、显现、角色、补给等初始化; + - 统一用于“新玩家创建”与“空数据登录回填”。 +- 新增 `ShouldBackfillAllDatabaseData()`: + - 当角色与关键库存均为空时触发全量回填。 +- 默认阵容初始化增加防护: + - 仅在随机选出**恰好 3 名角色**时执行阵容写入。 +- 角色引导等级调整: + - 统一常量 `BootstrapLevel = 80`。 + +## 三、本分支相对主线的核心变化主题 + +1. **登录容错增强**:token 无法匹配时可回退首账号,减少首次接入/异常数据导致的登录拒绝。 +2. **新库可开箱运行**:首次启动自动生成默认账号,降低初始化门槛。 +3. **玩家数据自愈能力提升**:对空档案进行登录时全量回填,避免关键数据缺失。 +4. **初始化规则统一化**:引导等级配置集中化,行为更稳定可控。 + +## 四、变更文件清单 + +- `Common/Database/Account/AccountData.cs` +- `GameServer/Game/Player/PlayerInstance.cs` +- `GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs` +- `MikuSB/Program/LoaderManager.cs` +- `SdkServer/Handlers/RouteController.cs` diff --git a/docs/user/README_jp.md b/docs/user/README_jp.md new file mode 100644 index 0000000..e17ce2d --- /dev/null +++ b/docs/user/README_jp.md @@ -0,0 +1,115 @@ +# MikuSB + +Languages: [English](../../README.md) | [中文](README_zh.md) | 日本語 + +MikuSBは、あるダンジョンアニメゲームのサーバーエミュレーターです。 +`SdkServer`、`GameServer`、任意のローカル HTTP/HTTPS プロキシを 1 つの `net10.0` アプリとして起動します。 + +[Discord](https://discord.gg/aMwCu9JyUR) + +## ドキュメント + +- [Linux ガイド](platform/README_linux_jp.md) +- [使用ガイド](usage/USAGE_jp.md) +- [コマンドガイド](commands/COMMAND_GUIDE_jp.md) +- [コマンド対象説明](commands/COMMAND_TARGET_jp.md) + +## 概要 + +- `SdkServer` + - HTTP API とディスパッチを返します + - サーバー一覧、バージョン照会、各種フォールバックレスポンスを返します +- `GameServer` + - TCP ベースのゲーム接続を受けます + - `ReqCallGS` と一部の通常パケットを処理します +- `Proxy` + - 有効時のみ `127.0.0.1:8888` で待ち受けます + - 一部の Snowbreak 関連ドメインをローカル `SdkServer` へリダイレクトします +- `Common` / `Proto` / `TcpSharp` + - 共通データ、protobuf 定義、通信基盤です + +## プロジェクト構成 + +- [MikuSB](../../MikuSB): エントリーポイント +- [SdkServer](../../SdkServer): HTTP サーバーとディスパッチ +- [GameServer](../../GameServer): ゲームサーバー本体 +- [Proxy](../../Proxy): ローカルプロキシ +- [Common](../../Common): 設定、DB、共通処理 +- [Proto](../../Proto): protobuf 定義 + +## 要件 + +- [.NET SDK 10.0](https://dotnet.microsoft.com/ja-jp/download/dotnet/10.0) + +## 起動方法 + +1. 依存関係を復元してビルドします。 + +```powershell +dotnet build +``` + +2. `Config/Config.json` の `GamePath` にゲーム実行ファイルのパスを設定します。 +3. サーバーを起動します。 + +```powershell +dotnet run --project .\MikuSB +``` + +4. サーバーコンソールでアカウントを作成します。 +5. サーバーコンソールで `game` コマンドを実行します。 +6. ゲームを起動してログインします。 + +公開コマンドと生成データの詳細は[使用ガイド](usage/USAGE_jp.md)を参照してください。 + +## 機能一覧 + +- [x] ログインと基本的なアカウント入場 +- [x] プレイヤーデータの読み込み +- [x] 所持品の読み込み +- [x] キャラクターの読み込み +- [x] スキンの読み込み +- [x] 武器の読み込み +- [x] ロビー表示キャラクターの変更 +- [x] キャラクタースキンの変更 +- [x] キャラクタースキン形態の変更 +- [x] 武器の付け替え +- [x] 武器の強化 +- [x] プレイヤー名の変更 +- [x] 現在対応済みロビー状態の基本保存 +- [x] メイン章のステージ入場と関連フロー +- [x] デイリーのステージ入場と関連フロー +- [x] 基本的なプレイヤー設定同期 +- [x] 基本的なプロフィール同期 +- [x] イベント関連リクエスト +- [x] 実績関連リクエスト +- [x] 編成関連リクエスト +- [x] プレビュー関連リクエスト +- [x] 一部のショップ関連リクエスト +- [ ] 完全な戦闘フロー +- [ ] ミッション / クエスト進行 +- [ ] ガチャ / 募集システム +- [ ] 完全なショップ挙動 +- [ ] マルチプレイシステム +- [ ] 基地 / 宿舎システム +- [ ] クライアント API 全体の対応 + +## 貢献者 + +- [Naruse](https://github.com/DevilProMT) +- [Kei-Luna](https://github.com/Kei-Luna) + +## 利用上の注意 + +本ソフトウェアはローカル環境での研究・検証用途を想定しています。 +公式サービスへの不正な接続、妨害、または商用利用を意図したものではありません。 + +## 法的免責事項 + +MikuSBは教育および研究目的で開発されました。 + +- 元のゲーム及び関連フランチャイズに関するすべての商標、著作権知的財産権はそれぞれの所有者に帰属します。 +- このリポジトリには、著作権で保護されたゲームアセット、バイナリ、マスターデータは一切含まれていません。 +- 自己責任でご利用下さい。 著者は、本ソフトウェアによって生じるいかなる損害または法的結果についても一切責任を負いません。 + +本ソフトウェアに関して懸念事項をお持ちの権利保有者は`devilpromt`または`kei_luna`にDiscordでご連絡下さい。 diff --git a/docs/user/README_zh.md b/docs/user/README_zh.md new file mode 100644 index 0000000..af0cc00 --- /dev/null +++ b/docs/user/README_zh.md @@ -0,0 +1,115 @@ +# MikuSB + +Languages: [English](../../README.md) | 中文 | [日本語](README_jp.md) + +MikuSB 是某款地牢题材动漫游戏的服务器模拟器。 +它会从一个 `net10.0` 应用中启动 `SdkServer`、`GameServer`,以及可选的本地 HTTP/HTTPS 代理。 + +[Discord](https://discord.gg/aMwCu9JyUR) + +## 文档 + +- [使用指导](usage/USAGE_zh.md) +- [命令使用指南](commands/COMMAND_GUIDE_zh.md) +- [命令目标说明](commands/COMMAND_TARGET_zh.md) +- [Linux 使用说明](platform/README_linux_zh.md) + +## 概览 + +- `SdkServer` + - 提供 HTTP API 并分发响应 + - 返回服务器列表、版本查询和各类兜底响应 +- `GameServer` + - 接受基于 TCP 的游戏连接 + - 处理 `ReqCallGS` 与部分普通协议包 +- `Proxy` + - 启用时监听 `127.0.0.1:8888` + - 将部分 Snowbreak 相关域名重定向到本地 `SdkServer` +- `Common` / `Proto` / `TcpSharp` + - 共享数据、protobuf 定义与网络通信基础设施 + +## 项目结构 + +- [MikuSB](../../MikuSB): 入口程序 +- [SdkServer](../../SdkServer): HTTP 服务与分发 +- [GameServer](../../GameServer): 主游戏服务器 +- [Proxy](../../Proxy): 本地代理 +- [Common](../../Common): 配置、数据库与公共工具 +- [Proto](../../Proto): protobuf 定义 + +## 环境要求 + +- [.NET SDK 10.0](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0) + +## 运行 + +1. 还原依赖并构建。 + +```powershell +dotnet build +``` + +2. 在 `Config/Config.json` 中将 `GamePath` 设置为游戏可执行文件路径。 +3. 启动服务。 + +```powershell +dotnet run --project .\MikuSB +``` + +4. 在服务端控制台创建账号。 +5. 在服务端控制台执行 `game` 命令。 +6. 启动游戏并登录。 + +发布命令与生成数据说明见[使用指导](usage/USAGE_zh.md)。 + +## 功能列表 + +- [x] 登录与基础账号进入 +- [x] 玩家数据加载 +- [x] 背包数据加载 +- [x] 角色数据加载 +- [x] 皮肤数据加载 +- [x] 武器数据加载 +- [x] 大厅展示角色切换 +- [x] 角色皮肤切换 +- [x] 角色皮肤形态切换 +- [x] 武器替换 +- [x] 武器强化 +- [x] 玩家改名 +- [x] 当前已支持大厅状态的基础保存 +- [x] 主线章节关卡进入及相关流程 +- [x] 日常关卡进入及相关流程 +- [x] 基础玩家设置同步 +- [x] 基础个人资料同步 +- [x] 活动相关请求 +- [x] 成就相关请求 +- [x] 编队相关请求 +- [x] 预览相关请求 +- [x] 部分商店相关请求 +- [ ] 完整战斗流程 +- [ ] 任务 / 委托进度 +- [ ] 抽卡 / 招募系统 +- [ ] 完整商店行为 +- [ ] 多人系统 +- [ ] 基地 / 宿舍系统 +- [ ] 客户端 API 全覆盖 + +## 贡献者 + +- [Naruse](https://github.com/DevilProMT) +- [Kei-Luna](https://github.com/Kei-Luna) + +## 使用说明 + +本软件仅用于本地环境下的研究与测试。 +不用于对官方服务进行未授权访问、干扰或商业用途。 + +## 法律免责声明 + +MikuSB 仅为教育与研究目的开发。 + +- 与原游戏及其相关系列有关的所有商标、版权及其他知识产权均归其各自所有者所有。 +- 本仓库不包含任何受版权保护的游戏资源、二进制文件或主数据。 +- 使用本软件需自行承担风险。作者不对因使用本软件导致的任何损失或法律后果负责。 + +若您是权利持有方并对本软件有任何顾虑,请在 Discord 联系 `devilpromt` 或 `kei_luna`。 diff --git a/docs/user/commands/COMMAND_GUIDE_en.md b/docs/user/commands/COMMAND_GUIDE_en.md new file mode 100644 index 0000000..a15d50c --- /dev/null +++ b/docs/user/commands/COMMAND_GUIDE_en.md @@ -0,0 +1,160 @@ +# MikuSB Command Guide (From Zero) + +Languages: English | [中文](COMMAND_GUIDE_zh.md) | [日本語](COMMAND_GUIDE_jp.md) + +> This guide explains how to use commands, especially `giveall`. + +## 1. Start the server + +Start the server first. Setup and run commands are covered in the [usage guide](../usage/USAGE_en.md). + +After startup, the console will show that you can type `help` for command help. + +--- + +## 2. Where to enter commands + +You can enter commands in two places: + +1. **Server console**: enter commands directly (no `/`) + - Example: `help` +2. **In-game chat**: prefix commands with `/` + - Example: `/help` + +--- + +## 3. Basic command syntax + +Command structure: + +```text +
@ +``` + +- `@` is optional and specifies the target player. +- Without `@`, the command applies to the sender by default. +- Target resolution details are covered in [command target notes](COMMAND_TARGET_en.md). + +Notes: + +- Options are passed as `p1 l90 g9 s9` (no leading `-`). +- `detail`/`guid` can be `-1` to apply to all. + +--- + +## 4. Learn `help` first + +```text +help +help giveall +help girl +help debug +``` + +--- + +## 5. Common commands (quick list) + +- `help [command]` (alias: `h`) +- `game ` +- `account create ` +- `account list` +- `debug [on|off|simple|detail|file]` (alias: `dbg`) +- `girl add p l s` (alias: `g`) +- `girl level ` +- `girl neuronic ` +- `girl break ` +- `giveall [options]` (alias: `ga`) + +--- + +## 6. How to use `giveall` (important) + +The main command `giveall` is also aliased as `ga`. +Available subcommands: + +- `weapon` +- `card` +- `weaponskin` +- `profile` +- `skinpart` +- `weaponpart` +- `call` +- `skin` +- `furniture` + +### 6.1 Parameter rules (important) + +In the current implementation, option parameters should be written as: + +- `p1` +- `l90` +- `g9` + +Do **not** write `-p1` / `-l90` / `-g9` or they will be parsed as other parameters. + +### 6.2 Common examples + +```text +# Give yourself all weapons, particular=1, level 90 +giveall weapon -1 p1 l90 + +# Give all weapons to UID=1 +giveall weapon -1 p1 l90 @1 + +# Give yourself all support cards +giveall card -1 p1 l80 + +# Give yourself all weapon skins +giveall weaponskin -1 p1 + +# Give yourself all character skins (genre=9 is just an example) +giveall skin -1 g9 p1 l1 +``` + +Notes: + +- `detail=-1` means “all” +- `detail>=0` means a specific item + +--- + +## 7. `girl` commands + +```text +girl add -1 p1 l1 s9 +girl level -1 80 +girl neuronic -1 6 +girl break -1 45 +``` + +--- + +## 8. Debug toggles + +```text +debug on +debug off +debug simple +debug detail +debug file +``` + +--- + +## 9. FAQ + +### Q1: “Command not found” + +- Use `help` to confirm the command exists +- Remember `/` in chat +- Do not use `/` in the server console + +### Q2: “Player not found” + +See [command target notes](COMMAND_TARGET_en.md). + +### Q3: Command ran but results look wrong + +- Check `help ` for the required parameters +- For `giveall`, use the `p1 l90 g9` style diff --git a/docs/user/commands/COMMAND_GUIDE_jp.md b/docs/user/commands/COMMAND_GUIDE_jp.md new file mode 100644 index 0000000..a0dd4d1 --- /dev/null +++ b/docs/user/commands/COMMAND_GUIDE_jp.md @@ -0,0 +1,158 @@ +# MikuSB コマンド使用ガイド(ゼロから) + +Languages: [English](COMMAND_GUIDE_en.md) | [中文](COMMAND_GUIDE_zh.md) | 日本語 + +> この文書では、特に `giveall` を中心にコマンドの使い方を説明します。 + +## 1. サーバーを起動する + +先にサーバーを起動してください。セットアップと起動コマンドは[使用ガイド](../usage/USAGE_jp.md)を参照してください。 + +起動後、コンソールで `help` を入力するとコマンドヘルプを確認できます。 + +--- + +## 2. コマンド入力場所 + +コマンドは次の 2 か所で入力できます。 + +1. **サーバーコンソール**: `/` なしで直接入力します。 + - 例: `help` +2. **ゲーム内チャット**: コマンドの前に `/` を付けます。 + - 例: `/help` + +--- + +## 3. 基本構文 + +```text +
@ +``` + +- `@` は任意で、対象プレイヤーを指定します。 +- `@` を省略すると、コマンド送信者が対象になります。 +- 対象解決の詳細は[コマンド対象説明](COMMAND_TARGET_jp.md)を参照してください。 + +補足: + +- オプションは `p1 l90 g9 s9` のように書きます(先頭に `-` は付けません)。 +- `detail` / `guid` は `-1` で全対象を表します。 + +--- + +## 4. まず `help` を確認する + +```text +help +help giveall +help girl +help debug +``` + +--- + +## 5. よく使うコマンド + +- `help [command]`(別名: `h`) +- `game ` +- `account create ` +- `account list` +- `debug [on|off|simple|detail|file]`(別名: `dbg`) +- `girl add p l s`(別名: `g`) +- `girl level ` +- `girl neuronic ` +- `girl break ` +- `giveall [options]`(別名: `ga`) + +--- + +## 6. `giveall` の使い方 + +`giveall` の別名は `ga` です。 +利用できるサブコマンド: + +- `weapon` +- `card` +- `weaponskin` +- `profile` +- `skinpart` +- `weaponpart` +- `call` +- `skin` +- `furniture` + +### 6.1 パラメーター規則 + +現在の実装では、オプションは次のように書きます。 + +- `p1` +- `l90` +- `g9` + +`-p1` / `-l90` / `-g9` のようには書かないでください。別の引数として解析されます。 + +### 6.2 例 + +```text +# 自分に全武器を付与、particular=1、レベル90 +giveall weapon -1 p1 l90 + +# UID=1 のプレイヤーに全武器を付与 +giveall weapon -1 p1 l90 @1 + +# 自分に全サポートカードを付与 +giveall card -1 p1 l80 + +# 自分に全武器スキンを付与 +giveall weaponskin -1 p1 + +# 自分に全キャラクタースキンを付与(genre=9 は例) +giveall skin -1 g9 p1 l1 +``` + +補足: + +- `detail=-1` は「全部」を意味します。 +- `detail>=0` は特定の項目を意味します。 + +--- + +## 7. `girl` コマンド + +```text +girl add -1 p1 l1 s9 +girl level -1 80 +girl neuronic -1 6 +girl break -1 45 +``` + +--- + +## 8. debug 切り替え + +```text +debug on +debug off +debug simple +debug detail +debug file +``` + +--- + +## 9. FAQ + +### Q1: “Command not found” + +- `help` でコマンドが存在するか確認します。 +- ゲーム内チャットでは `/` を付けます。 +- サーバーコンソールでは `/` を付けません。 + +### Q2: “Player not found” + +[コマンド対象説明](COMMAND_TARGET_jp.md)を参照してください。 + +### Q3: コマンドは実行されたが結果がおかしい + +- `help ` で必要な引数を確認します。 +- `giveall` のオプションは `p1 l90 g9` の形式で入力します。 diff --git a/COMMAND_GUIDE_zh.md b/docs/user/commands/COMMAND_GUIDE_zh.md similarity index 63% rename from COMMAND_GUIDE_zh.md rename to docs/user/commands/COMMAND_GUIDE_zh.md index 60f4093..1970b8b 100644 --- a/COMMAND_GUIDE_zh.md +++ b/docs/user/commands/COMMAND_GUIDE_zh.md @@ -1,15 +1,12 @@ # MikuSB 命令使用指南(从零开始) -> 这份文档专门讲「怎么用命令」,尤其是 `giveall`(你说的 give 什么)。 +Languages: [English](COMMAND_GUIDE_en.md) | 中文 | [日本語](COMMAND_GUIDE_jp.md) -## 1. 先把服务跑起来 +> 这份文档专门讲「怎么用命令」,尤其是 `giveall`。 -在仓库根目录执行: +## 1. 先把服务跑起来 -```bash -dotnet build -dotnet run --project ./MikuSB -``` +请先启动服务。安装与运行命令见[使用指导](../usage/USAGE_zh.md)。 启动成功后,控制台会提示可输入 `help` 获取命令帮助。 @@ -36,7 +33,12 @@ dotnet run --project ./MikuSB - `@<目标UID>` 可选,用于指定目标玩家。 - 不写 `@` 时,默认对命令发送者生效。 -- 目标玩家需要在线,否则会提示未找到玩家。 +- 目标解析细节见[命令目标说明](COMMAND_TARGET_zh.md)。 + +说明: + +- 选项参数写成 `p1 l90 g9 s9`(不带 `-`)。 +- `detail` / `guid` 允许使用 `-1` 表示全部。 --- @@ -49,14 +51,26 @@ help girl help debug ``` -- `help`:列出命令 -- `help giveall`:查看 giveall 用法 +--- + +## 5. 常用命令速览 + +- `help [command]`(别名:`h`) +- `game ` +- `account create ` +- `account list` +- `debug [on|off|simple|detail|file]`(别名:`dbg`) +- `girl add p l s`(别名:`g`) +- `girl level ` +- `girl neuronic ` +- `girl break ` +- `giveall <类型> [选项]`(别名:`ga`) --- -## 5. giveall 怎么用(重点) +## 6. giveall 怎么用(重点) -`giveall` 主命令别名是 `ga`。 +`giveall` 主命令别名是 `ga`。 可用子命令: - `weapon` @@ -67,8 +81,9 @@ help debug - `weaponpart` - `call` - `skin` +- `furniture` -### 5.1 参数规则(很关键) +### 6.1 参数规则(很关键) 在当前实现里,选项参数建议写成: @@ -78,7 +93,7 @@ help debug 即 **不要写成 `-p1` / `-l90` / `-g9`**,否则会被当成其他参数处理。 -### 5.2 常见示例 +### 6.2 常见示例 ```text # 给自己所有武器,particular=1,等级90 @@ -93,7 +108,7 @@ giveall card -1 p1 l80 # 给自己所有武器皮肤 giveall weaponskin -1 p1 -# 给自己所有角色皮肤(genre=9 仅示例,按你资源配置调整) +# 给自己所有角色皮肤(genre=9 仅示例) giveall skin -1 g9 p1 l1 ``` @@ -104,9 +119,7 @@ giveall skin -1 g9 p1 l1 --- -## 6. 其他常用命令 - -### 6.1 girl(角色) +## 7. girl 命令 ```text girl add -1 p1 l1 s9 @@ -115,7 +128,9 @@ girl neuronic -1 6 girl break -1 45 ``` -### 6.2 debug(调试输出) +--- + +## 8. debug 开关 ```text debug on @@ -127,7 +142,7 @@ debug file --- -## 7. 常见问题 +## 9. 常见问题 ### Q1:提示“未找到命令” @@ -137,11 +152,9 @@ debug file ### Q2:提示“未找到玩家” -- 目标 UID 不在线 -- 先确认玩家已登录,再使用 `@uid` +见[命令目标说明](COMMAND_TARGET_zh.md)。 ### Q3:命令执行了但结果不对 - 优先用 `help <命令>` 对照参数格式 - `giveall` 选项参数按 `p1 l90 g9` 这种写法输入 - diff --git a/docs/user/commands/COMMAND_TARGET_en.md b/docs/user/commands/COMMAND_TARGET_en.md new file mode 100644 index 0000000..52931fa --- /dev/null +++ b/docs/user/commands/COMMAND_TARGET_en.md @@ -0,0 +1,60 @@ +# Command targets and `giveall` parameters (EN) + +Languages: English | [中文](COMMAND_TARGET_zh.md) | [日本語](COMMAND_TARGET_jp.md) + +This document explains why running `giveall` in the console can show “player not found”, and what ID you should provide. + +## 1. Target resolution rules + +The command system resolves targets as follows: + +- If no target is provided, the default target is the **command sender**. +- When running in the console, the sender is `Console` with UID `0`. +- Target syntax is: `@` (for example, `@1001`). +- The current implementation only parses numeric UIDs after `@`; it does not accept usernames. + +## 2. Why “player not found” happens + +`giveall` checks whether the target is online (`CheckOnlineTarget()`): + +- The target must be an **online connected player**. +- If the target is the console (`uid=0`) or offline, it will report “player not found”. + +## 3. `giveall` parameter meaning (important) + +Using `weapon` as an example: + +```text +/giveall weapon p l @ +``` + +- ``: item detail, `-1` means all. +- `p`: item particular parameter (for example `p1`). +- `l`: level parameter. +- `@`: target player UID. + +Notes: + +- Strings like `miku` are not treated as target usernames. +- `p1` is an item parameter, not a player ID. + +## 4. UID or username? + +Conclusion: + +- Use **UID** as the target parameter (`@`). +- Do not use usernames. + +Database mapping: + +- Account table: `Account` (`[SugarTable("Account")]`) +- Player ID field: `Uid` (primary key) +- Username field: `Username` + +## 5. Correct example + +```text +/giveall weapon -1 p1 l90 @1001 +``` + +Meaning: give all weapons to the online player with UID `1001` (particular=1, level=90). diff --git a/docs/user/commands/COMMAND_TARGET_jp.md b/docs/user/commands/COMMAND_TARGET_jp.md new file mode 100644 index 0000000..b80f24f --- /dev/null +++ b/docs/user/commands/COMMAND_TARGET_jp.md @@ -0,0 +1,60 @@ +# コマンド対象と `giveall` パラメーター説明(日本語) + +Languages: [English](COMMAND_TARGET_en.md) | [中文](COMMAND_TARGET_zh.md) | 日本語 + +この文書では、コンソールで `giveall` を実行したときに “player not found” が出る理由と、どの ID を指定するべきかを説明します。 + +## 1. 対象解決ルール + +コマンドシステムは次のように対象を解決します。 + +- 対象を指定しない場合、既定の対象は**コマンド送信者**です。 +- コンソールで実行した場合、送信者は `Console` で UID は `0` です。 +- 対象指定の構文は `@` です(例: `@1001`)。 +- 現在の実装では `@` の後ろの数値 UID のみを解析し、ユーザー名は対象として扱いません。 + +## 2. “player not found” が出る理由 + +`giveall` は実行前に対象がオンラインか確認します。 + +- 対象は**オンライン接続中のプレイヤー**である必要があります。 +- 対象がコンソール(`uid=0`)またはオフラインの場合、“player not found” が表示されます。 + +## 3. `giveall` パラメーターの意味 + +`weapon` を例にします。 + +```text +/giveall weapon p l @ +``` + +- ``: アイテム detail。`-1` は全部を意味します。 +- `p`: アイテム particular パラメーター(例: `p1`)。 +- `l`: レベルパラメーター。 +- `@`: 対象プレイヤー UID。 + +注意: + +- `miku` のような文字列は対象ユーザー名として扱われません。 +- `p1` はアイテムパラメーターであり、プレイヤー ID ではありません。 + +## 4. UID かユーザー名か + +結論: + +- 対象パラメーターには **UID** を指定してください(`@`)。 +- ユーザー名は指定しません。 + +データベース上の対応: + +- アカウントテーブル: `Account`(`[SugarTable("Account")]`) +- プレイヤー ID フィールド: `Uid`(主キー) +- ユーザー名フィールド: `Username` + +## 5. 正しい例 + +```text +/giveall weapon -1 p1 l90 @1001 +``` + +意味: UID `1001` のオンラインプレイヤーに全武器を付与します(particular=1、level=90)。 diff --git a/COMMAND_TARGET_CHS.md b/docs/user/commands/COMMAND_TARGET_zh.md similarity index 65% rename from COMMAND_TARGET_CHS.md rename to docs/user/commands/COMMAND_TARGET_zh.md index b1c505b..9544c54 100644 --- a/COMMAND_TARGET_CHS.md +++ b/docs/user/commands/COMMAND_TARGET_zh.md @@ -1,5 +1,7 @@ # 命令目标与 `giveall` 参数说明(中文) +Languages: [English](COMMAND_TARGET_en.md) | 中文 | [日本語](COMMAND_TARGET_jp.md) + 本文档说明为什么在控制台执行 `giveall` 时会出现“未找到玩家”,以及“ID 应该填什么”。 ## 1. 目标解析规则 @@ -11,12 +13,6 @@ - 指定目标的语法是:`@`(例如 `@1001`)。 - 当前实现只解析 `@` 后面的**数字 UID**,不支持直接写用户名作为目标。 -相关实现: - -- `/home/runner/work/MikuSB/MikuSB/GameServer/Command/CommandManager.cs` -- `/home/runner/work/MikuSB/MikuSB/GameServer/Command/CommandSender.cs` -- `/home/runner/work/MikuSB/MikuSB/Common/Enums/Player/FriendEnum.cs` - ## 2. 为什么会“未找到玩家” `giveall` 在执行前会检查目标是否在线(`CheckOnlineTarget()`): @@ -24,28 +20,23 @@ - 目标必须是**在线连接中的玩家**。 - 目标是控制台(`uid=0`)或离线玩家时,会提示“未找到玩家”。 -相关实现: - -- `/home/runner/work/MikuSB/MikuSB/GameServer/Command/Commands/CommandGiveAll.cs` -- `/home/runner/work/MikuSB/MikuSB/GameServer/Command/CommandArg.cs` - ## 3. `giveall` 参数含义(重点) 以 `weapon` 为例: ```text -/giveall weapon -p -l @ +/giveall weapon p l @ ``` - ``:物品 detail,`-1` 表示全部。 -- `-p`:物品 particular 参数(例如 `p1`)。 -- `-l`:等级参数。 +- `p`:物品 particular 参数(例如 `p1`)。 +- `l`:等级参数。 - `@`:目标玩家 UID。 注意: - `miku` 这种字符串不会被当成目标玩家名。 -- `p1` 是 `-p` 的物品参数,不是玩家 ID。 +- `p1` 是物品参数,不是玩家 ID。 ## 4. 到底该填 UID 还是用户名? @@ -60,11 +51,6 @@ - 玩家 ID 字段:`Uid`(主键) - 用户名字段:`Username` -相关实现: - -- `/home/runner/work/MikuSB/MikuSB/Common/Database/Account/AccountData.cs` -- `/home/runner/work/MikuSB/MikuSB/Common/Database/BaseDatabaseDataHelper.cs` - ## 5. 正确示例 ```text diff --git a/docs/user/platform/README_linux_en.md b/docs/user/platform/README_linux_en.md new file mode 100644 index 0000000..67a6fe9 --- /dev/null +++ b/docs/user/platform/README_linux_en.md @@ -0,0 +1,41 @@ +# MikuSB on Linux + +Languages: English | [中文](README_linux_zh.md) | [日本語](README_linux_jp.md) + +## Config + +### Steam Launch Options + +`HTTP_PROXY="http://127.0.0.1:8888" HTTPS_PROXY="http://127.0.0.1:8888" ALL_PROXY="http://127.0.0.1:8888" %command%` + +### Start Local Server + +```bash +./MikuSB +``` + +For build and publish commands, see the [usage guide](../usage/USAGE_en.md). + +### Root CA Bundle + +The root CA cert should be in `proxy-certs/MikuSB.Proxy.Root.pem`. + +### Proton/Wine Root CA + +This step may not be required again if the Proton PFX (Wine prefix) folder is recreated. + +`Proton Hotfix` is the Proton version selected in Steam `Force the use of a specific Steam Play compatibility tool`. + +```bash +APPID= +STEAM_COMPAT_DATA_PATH=~/.steam/steam/steamapps/compatdata/$APPID/pfx +STEAM_WINE_PATH="$HOME/.steam/steam/steamapps/common/Proton Hotfix/files/bin/wine" +WINEPREFIX=$STEAM_COMPAT_DATA_PATH $STEAM_WINE_PATH certutil -addstore -f Root proxy-certs/MikuSB.Proxy.Root.pem +``` + +### Start The Game + +## TODO + +- [ ] Tool/script for CA cert creation and Proton/Wine installation +- [ ] Automatically handle the setup in the main program diff --git a/docs/user/platform/README_linux_jp.md b/docs/user/platform/README_linux_jp.md new file mode 100644 index 0000000..1a4eae5 --- /dev/null +++ b/docs/user/platform/README_linux_jp.md @@ -0,0 +1,41 @@ +# MikuSB on Linux + +Languages: [English](README_linux_en.md) | [中文](README_linux_zh.md) | 日本語 + +## 設定 + +### Steam 起動オプション + +`HTTP_PROXY="http://127.0.0.1:8888" HTTPS_PROXY="http://127.0.0.1:8888" ALL_PROXY="http://127.0.0.1:8888" %command%` + +### ローカルサーバーを起動する + +```bash +./MikuSB +``` + +ビルドと公開コマンドは[使用ガイド](../usage/USAGE_jp.md)を参照してください。 + +### ルート CA バンドル + +ルート CA 証明書のパスは `proxy-certs/MikuSB.Proxy.Root.pem` です。 + +### Proton/Wine ルート CA + +Proton PFX(Wine prefix)フォルダーを再作成した場合でも、この手順が再度必要にならないことがあります。 + +`Proton Hotfix` は Steam の `Force the use of a specific Steam Play compatibility tool` で選択した Proton バージョンです。 + +```bash +APPID= +STEAM_COMPAT_DATA_PATH=~/.steam/steam/steamapps/compatdata/$APPID/pfx +STEAM_WINE_PATH="$HOME/.steam/steam/steamapps/common/Proton Hotfix/files/bin/wine" +WINEPREFIX=$STEAM_COMPAT_DATA_PATH $STEAM_WINE_PATH certutil -addstore -f Root proxy-certs/MikuSB.Proxy.Root.pem +``` + +### ゲームを起動する + +## TODO + +- [ ] CA 証明書を作成し Proton/Wine に導入するツールまたはスクリプト +- [ ] メインプログラムで自動処理する diff --git a/docs/user/platform/README_linux_zh.md b/docs/user/platform/README_linux_zh.md new file mode 100644 index 0000000..1b2ba05 --- /dev/null +++ b/docs/user/platform/README_linux_zh.md @@ -0,0 +1,41 @@ +# MikuSB 在 Linux 上使用 + +Languages: [English](README_linux_en.md) | 中文 | [日本語](README_linux_jp.md) + +## 配置 + +### Steam 启动项 + +`HTTP_PROXY="http://127.0.0.1:8888" HTTPS_PROXY="http://127.0.0.1:8888" ALL_PROXY="http://127.0.0.1:8888" %command%` + +### 启动本地服务器 + +```bash +./MikuSB +``` + +构建与发布命令见[使用指导](../usage/USAGE_zh.md)。 + +### 根证书包 + +根证书路径:`proxy-certs/MikuSB.Proxy.Root.pem` + +### Proton/Wine 根证书 + +注意:即使删除 Proton PFX(Wine 前缀)目录,不重新执行这一步也可能不报证书问题。 + +`Proton Hotfix` 是 Steam 中选择的 `Force the use of a specific Steam Play compatibility tool` 对应版本。 + +```bash +APPID= +STEAM_COMPAT_DATA_PATH=~/.steam/steam/steamapps/compatdata/$APPID/pfx +STEAM_WINE_PATH="$HOME/.steam/steam/steamapps/common/Proton Hotfix/files/bin/wine" +WINEPREFIX=$STEAM_COMPAT_DATA_PATH $STEAM_WINE_PATH certutil -addstore -f Root proxy-certs/MikuSB.Proxy.Root.pem +``` + +### 启动游戏 + +## TODO + +- [ ] 提供自动生成并安装 CA 证书的工具/脚本 +- [ ] 在主程序中自动完成上述步骤 diff --git a/docs/user/usage/USAGE_en.md b/docs/user/usage/USAGE_en.md new file mode 100644 index 0000000..365703a --- /dev/null +++ b/docs/user/usage/USAGE_en.md @@ -0,0 +1,184 @@ +# MikuSB Usage Guide (From Zero) + +Languages: English | [中文](USAGE_zh.md) | [日本語](USAGE_jp.md) + +> This document focuses on: full command flow, database fields and their sources, and how data is generated. + +## 1. Run from scratch (development) + +### 1.1 Requirements + +- Install [.NET SDK 10.0](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- Install Git + +### 1.2 Get the source + +```bash +git clone https://github.com/AliceJump/MikuSB.git +cd MikuSB +``` + +### 1.3 Build + +```bash +dotnet build +``` + +### 1.4 Start the server + +```bash +dotnet run --project ./MikuSB +``` + +After startup, the following services run together: + +- `SdkServer` (HTTP) +- `GameServer` (TCP) +- Local proxy (enabled by default, listens on `127.0.0.1:8888`) + +### 1.5 What is created on first start + +- `Config/Config.json` (generated if missing) +- `Config/Database/Miku.db` (SQLite database file) +- Database tables (Code First auto-create) +- `proxy-certs/*` (proxy root CA and derived certificates) +- `Config/Handbook/*` (command handbook text generated from TextMap) + +## 2. Publish commands + +### 2.1 Linux single-file publish + +```bash +dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish +``` + +### 2.2 Windows publish (multi-file, same as CI) + +```powershell +dotnet publish .\MikuSB\MikuSB.csproj -c Release -p:PublishProfile=MikuSB-Win64-MultiFile -o .\artifacts\publish\MikuSB +``` + +## 3. How resources and database are generated + +### 3.1 Resource files + +- On startup, the server checks key files under `Resources/` (for example `item/templates/card.json`, `item/templates/weapon.json`). +- If missing, it downloads the resource archive and extracts it into `Resources/`. +- The resource data is then loaded into in-memory `GameData.*` dictionaries/lists for later initialization of characters, weapons, and items. + +### 3.2 Database and table creation + +- Database type: SQLite (SqlSugar) +- Database path: `Config/Database/Miku.db` (can be changed in `Config/Config.json`) +- Table creation: scans all types that inherit `BaseDatabaseDataHelper` and runs Code First table creation + +### 3.3 How data is written + +- Player heartbeats enqueue UIDs for saving; by default it flushes every 5 minutes +- On process exit, a final flush is triggered + +## 4. Database tables and fields (with sources) + +> Primary key is unified as `Uid` (player UID). + +### 4.1 `Account` + +Fields: + +- `Uid`: account UID. If missing on first login it is created, starting from 1. +- `Username`: username. Default for auto-created account is `"MIKU"`. +- `Password`: password hash (SHA256); empty password stores an empty string. +- `BanType`: ban type enum. +- `Phone`: phone field (default `"123456"`). +- `Permissions` (JSON): permission list from `Config.json -> ServerOption.DefaultPermissions`. +- `ComboToken` / `DispatchToken`: session tokens written when generated. + +Source: + +- Auto-created: first login creates UID=1 if missing. +- Also manageable via logic/commands. + +### 4.2 `Player` + +Fields: + +- `Uid`: player UID (same as account). +- `Name`: display name, defaulted from account name (fallback to `Miku` when blank). +- `Signature`: signature (default `MikuPS`). +- `Level` / `Exp` / `Vigor` / `Gender`: base player attributes. +- `RegisterTime`: registration time (Unix seconds). +- `LastActiveTime`: last active time (refreshed on player manager init). +- `Attrs` (JSON): numeric attributes (tutorial values, currency, unlocks, etc.). +- `StrAttrs` (JSON): string attributes. +- `ShowItems` (JSON): profile display items. + +Source: + +- If account exists but player data does not, `PlayerGameData` is created. +- `Attrs` are filled/raised by tutorial and stage data during serialization. + +### 4.3 `inventory_data` + +Fields: + +- `Uid`: player UID. +- `NextUniqueUid`: inventory unique item ID allocator (starts at `100000`). +- `Items` (JSON): item dictionary (supplies, AR, manifestation, etc.). +- `Weapons` (JSON): weapon dictionary. +- `Skins` (JSON): skin dictionary. +- `SupportCards` (JSON): support card dictionary. +- `SkinTypesBySkinId` (JSON): skin form mapping (`nSkinId -> nType`). + +Source: + +- On first player creation, initial skins/characters/supplies are granted from resource tables. +- Business requests (upgrade, replace, skin change, etc.) continually modify this table. + +### 4.4 `character_data` + +Fields: + +- `Uid`: player UID. +- `Characters` (JSON): character list. + - Key subfields: `Guid`, `TemplateId`, `Level`, `Break`, `Evolue`, `ProLevel`, `Trust`, `WeaponUniqueId`, `SkinId`, `WeaponSkinId`, `SupportSlots`, `UnlockedSkin`, `Spines`, `Affixs`, etc. +- `NextCharacterGuid`: character GUID counter. + +Source: + +- First-time player initialization creates characters from resource templates. +- Character creation automatically assigns default weapon/skin bindings. + +### 4.5 `lineup_data` + +Fields: + +- `Uid`: player UID. +- `LineupInfo` (JSON): lineup dictionary keyed by lineup slot. + - Subfields: `Index`, `Name`, `Member1`, `Member2`, `Member3`. + +Source: + +- New player initialization randomly picks 3 characters for the default lineup. +- Later lineup updates keep writing to this table. + +## 5. Quick checks + +### 5.1 Inspect database files and tables + +```bash +ls -lah ./Config/Database +sqlite3 ./Config/Database/Miku.db ".tables" +sqlite3 ./Config/Database/Miku.db ".schema Account" +sqlite3 ./Config/Database/Miku.db ".schema Player" +``` + +### 5.2 Check database path in config + +```bash +cat ./Config/Config.json +``` + +## 6. Notes + +- This project stores many fields as JSON columns; read the corresponding C# data structures when needed. +- To reset local progress, stop the server, then back up or delete `Config/Database/Miku.db` and restart. diff --git a/docs/user/usage/USAGE_jp.md b/docs/user/usage/USAGE_jp.md new file mode 100644 index 0000000..f840dec --- /dev/null +++ b/docs/user/usage/USAGE_jp.md @@ -0,0 +1,127 @@ +# MikuSB 使用ガイド(ゼロから) + +Languages: [English](USAGE_en.md) | [中文](USAGE_zh.md) | 日本語 + +> この文書では、起動手順、公開コマンド、データベース項目、データ生成の流れを扱います。 + +## 1. ゼロから起動する(開発環境) + +### 1.1 要件 + +- [.NET SDK 10.0](https://dotnet.microsoft.com/ja-jp/download/dotnet/10.0) をインストールする +- Git をインストールする + +### 1.2 ソースコードを取得する + +```bash +git clone https://github.com/AliceJump/MikuSB.git +cd MikuSB +``` + +### 1.3 ビルド + +```bash +dotnet build +``` + +### 1.4 サーバーを起動する + +```bash +dotnet run --project ./MikuSB +``` + +起動後、次のサービスが同時に動作します。 + +- `SdkServer`(HTTP) +- `GameServer`(TCP) +- ローカルプロキシ(既定で有効、`127.0.0.1:8888` で待ち受け) + +### 1.5 初回起動時に作成されるもの + +- `Config/Config.json`(存在しない場合に生成) +- `Config/Database/Miku.db`(SQLite データベース) +- データベーステーブル(Code First で自動作成) +- `proxy-certs/*`(プロキシ用ルート CA と派生証明書) +- `Config/Handbook/*`(TextMap から生成されるコマンドハンドブック) + +## 2. 公開コマンド + +### 2.1 Linux 単一ファイル公開 + +```bash +dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish +``` + +### 2.2 Windows 公開(複数ファイル、CI と同等) + +```powershell +dotnet publish .\MikuSB\MikuSB.csproj -c Release -p:PublishProfile=MikuSB-Win64-MultiFile -o .\artifacts\publish\MikuSB +``` + +## 3. リソースとデータベースの生成 + +### 3.1 リソースファイル + +- 起動時に `Resources/` 配下の主要ファイルを確認します。 +- 不足している場合はリソースアーカイブをダウンロードして展開します。 +- 読み込まれたデータは、キャラクター、武器、アイテムの初期化に使われます。 + +### 3.2 データベースとテーブル作成 + +- データベース種類: SQLite(SqlSugar) +- データベースパス: `Config/Database/Miku.db`(`Config/Config.json` で変更可能) +- テーブル作成: `BaseDatabaseDataHelper` を継承する型を走査し、Code First で作成します。 + +### 3.3 データの保存 + +- プレイヤーのハートビートで UID が保存キューに入ります。 +- 既定では 5 分ごとに保存されます。 +- プロセス終了時に最後の保存が実行されます。 + +## 4. 主なデータベーステーブル + +### 4.1 `Account` + +- `Uid`: アカウント UID +- `Username`: ユーザー名 +- `Password`: パスワードハッシュ +- `Permissions`: 権限リスト +- `ComboToken` / `DispatchToken`: セッショントークン + +### 4.2 `Player` + +- `Uid`: プレイヤー UID +- `Name`: 表示名 +- `Level` / `Exp` / `Vigor` / `Gender`: 基本属性 +- `Attrs` / `StrAttrs`: 属性データ +- `ShowItems`: プロフィール表示項目 + +### 4.3 `inventory_data` + +- `Items`: アイテム辞書 +- `Weapons`: 武器辞書 +- `Skins`: スキン辞書 +- `SupportCards`: サポートカード辞書 + +### 4.4 `character_data` + +- `Characters`: キャラクター一覧 +- `NextCharacterGuid`: キャラクター GUID カウンター + +### 4.5 `lineup_data` + +- `LineupInfo`: 編成情報 + +## 5. 確認コマンド + +```bash +ls -lah ./Config/Database +sqlite3 ./Config/Database/Miku.db ".tables" +sqlite3 ./Config/Database/Miku.db ".schema Account" +sqlite3 ./Config/Database/Miku.db ".schema Player" +``` + +## 6. 注意 + +- 多くの項目は JSON カラムとして保存されます。 +- ローカル進行状況をリセットする場合は、サーバーを停止してから `Config/Database/Miku.db` をバックアップまたは削除してください。 diff --git a/USAGE_zh.md b/docs/user/usage/USAGE_zh.md similarity index 96% rename from USAGE_zh.md rename to docs/user/usage/USAGE_zh.md index dff2516..1f4da76 100644 --- a/USAGE_zh.md +++ b/docs/user/usage/USAGE_zh.md @@ -1,5 +1,7 @@ # MikuSB 使用指导(从零开始) +Languages: [English](USAGE_en.md) | 中文 | [日本語](USAGE_jp.md) + > 本文档聚焦:完整命令流程、数据库字段含义与来源、数据如何生成。 ## 1. 从零开始运行(开发模式) @@ -42,22 +44,15 @@ dotnet run --project ./MikuSB - `proxy-certs/*`(代理根证书与派生证书) - `Config/Handbook/*`(命令手册文本,按 TextMap 生成) -## 2. 常用运行/发布命令 - -### 2.1 Linux 开发运行 - -```bash -dotnet build -dotnet run --project ./MikuSB -``` +## 2. 发布命令 -### 2.2 Linux 发布单文件 +### 2.1 Linux 发布单文件 ```bash dotnet publish ./MikuSB/MikuSB.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true --property:PublishDir=../publish ``` -### 2.3 Windows 发布(多文件,和 CI 一致) +### 2.2 Windows 发布(多文件,和 CI 一致) ```powershell dotnet publish .\MikuSB\MikuSB.csproj -c Release -p:PublishProfile=MikuSB-Win64-MultiFile -o .\artifacts\publish\MikuSB From d815e713182335bb5b722dfcf13e13c24c0cea72 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Thu, 14 May 2026 00:16:17 +0800 Subject: [PATCH 05/16] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=80=89=E9=A1=B9=E5=88=B0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Common/Configuration/ConfigContainer.cs | 1 + Config/Config.json | 1 + MikuSB/Update/UpdateService.cs | 6 ++++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Common/Configuration/ConfigContainer.cs b/Common/Configuration/ConfigContainer.cs index 325ddb0..5b4253b 100644 --- a/Common/Configuration/ConfigContainer.cs +++ b/Common/Configuration/ConfigContainer.cs @@ -61,6 +61,7 @@ public class ServerOption public string FallbackLanguage { get; set; } = "EN"; public string[] DefaultPermissions { get; set; } = ["Admin"]; public ServerProfile ServerProfile { get; set; } = new(); + public bool EnableAutoUpdate { get; set; } = true; public bool EnableGmMenu { get; set; } = false; public bool AutoCreateUser { get; set; } = true; public bool SavePersonalDebugFile { get; set; } = false; diff --git a/Config/Config.json b/Config/Config.json index 0ba1d80..5727b82 100644 --- a/Config/Config.json +++ b/Config/Config.json @@ -31,6 +31,7 @@ "Name": "Miku-chan", "Uid": 80 }, + "EnableAutoUpdate": true, "AutoCreateUser": true, "SavePersonalDebugFile": false, "AutoSendResponseWhenNoHandler": true, diff --git a/MikuSB/Update/UpdateService.cs b/MikuSB/Update/UpdateService.cs index 0b0b468..227ada0 100644 --- a/MikuSB/Update/UpdateService.cs +++ b/MikuSB/Update/UpdateService.cs @@ -11,7 +11,6 @@ namespace MikuSB.MikuSB.Update; public static class UpdateService { private static readonly Logger Logger = new("Updater"); - private static readonly bool UpdateEnabled = true; private static readonly bool AskBeforeUpdate = true; private static readonly string RepositoryOwner = "MikuLeaks"; private static readonly string RepositoryName = "MikuSB"; @@ -30,8 +29,11 @@ public static class UpdateService public static async Task TryStartSelfUpdateAsync() { - if (!UpdateEnabled) + if (!ConfigManager.Config.ServerOption.EnableAutoUpdate) + { + Logger.Debug("Auto update skipped because it is disabled in Config.json."); return false; + } if (string.IsNullOrWhiteSpace(RepositoryOwner) || string.IsNullOrWhiteSpace(RepositoryName) From a2d89757a71cba23fafa9e4e8ff1d48670ab148c Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Fri, 15 May 2026 00:59:26 +0800 Subject: [PATCH 06/16] chore: add build and release workflow --- .github/workflows/build-and-release.yml | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/build-and-release.yml diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml new file mode 100644 index 0000000..5ed7344 --- /dev/null +++ b/.github/workflows/build-and-release.yml @@ -0,0 +1,75 @@ +name: Build and Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Optional tag name to create a release (useful for manual runs)' + required: false + +jobs: + build-and-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 'latest' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build -c Release --no-restore + + - name: Publish + run: dotnet publish MikuSB/MikuSB.csproj -c Release -o ./artifacts/publish --no-build + + - name: Determine release tag + id: tag + run: | + if [ -n "${{ github.event.inputs.tag }}" ]; then + echo "RELEASE_TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV + else + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + else + echo "RELEASE_TAG=manual-${GITHUB_RUN_ID}" >> $GITHUB_ENV + fi + fi + shell: bash + + - name: Archive publish output + run: | + mkdir -p artifacts + cd artifacts + zip -r release-${RELEASE_TAG}.zip publish + shell: bash + + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.RELEASE_TAG }} + release_name: Release ${{ env.RELEASE_TAG }} + draft: false + prerelease: false + + - name: Upload release asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: artifacts/release-${{ env.RELEASE_TAG }}.zip + asset_name: MikuSB-${{ env.RELEASE_TAG }}.zip + asset_content_type: application/zip From 8bd3648ab91640f075ad98bf2490b965f777ddda Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Fri, 15 May 2026 01:15:17 +0800 Subject: [PATCH 07/16] fix: use valid dotnet-version format --- .github/workflows/build-and-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 5ed7344..e2b8961 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -21,7 +21,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 'latest' + dotnet-version: '10.0' - name: Restore run: dotnet restore From d417ab7538dba2e75fc330b900a96a822daf54e6 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Fri, 15 May 2026 01:22:07 +0800 Subject: [PATCH 08/16] fix: add RuntimeIdentifier to publish command --- .github/workflows/build-and-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index e2b8961..dd6a6f6 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -30,7 +30,7 @@ jobs: run: dotnet build -c Release --no-restore - name: Publish - run: dotnet publish MikuSB/MikuSB.csproj -c Release -o ./artifacts/publish --no-build + run: dotnet publish MikuSB/MikuSB.csproj -c Release -o ./artifacts/publish --no-build -r linux-x64 - name: Determine release tag id: tag From 7d28fba4171aff6462fcc1174a0563c30e1c0604 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Fri, 15 May 2026 01:24:25 +0800 Subject: [PATCH 09/16] fix: add RuntimeIdentifier to restore and build --- .github/workflows/build-and-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index dd6a6f6..d9d6f63 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -24,10 +24,10 @@ jobs: dotnet-version: '10.0' - name: Restore - run: dotnet restore + run: dotnet restore -r linux-x64 - name: Build - run: dotnet build -c Release --no-restore + run: dotnet build -c Release --no-restore -r linux-x64 - name: Publish run: dotnet publish MikuSB/MikuSB.csproj -c Release -o ./artifacts/publish --no-build -r linux-x64 From cd11d67e29034f8d2e1587a5bd26ba24eb4843f6 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Fri, 15 May 2026 01:26:41 +0800 Subject: [PATCH 10/16] chore: remove RuntimeIdentifier parameters from workflow --- .github/workflows/build-and-release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index d9d6f63..e2b8961 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -24,13 +24,13 @@ jobs: dotnet-version: '10.0' - name: Restore - run: dotnet restore -r linux-x64 + run: dotnet restore - name: Build - run: dotnet build -c Release --no-restore -r linux-x64 + run: dotnet build -c Release --no-restore - name: Publish - run: dotnet publish MikuSB/MikuSB.csproj -c Release -o ./artifacts/publish --no-build -r linux-x64 + run: dotnet publish MikuSB/MikuSB.csproj -c Release -o ./artifacts/publish --no-build - name: Determine release tag id: tag From a46a25a2d6ebc86fc715788309f298f9c59c322c Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Fri, 15 May 2026 01:27:11 +0800 Subject: [PATCH 11/16] chore: add VSCode task for running MikuSB project --- .github/workflows/build-and-release.yml | 75 ------------------------- .vscode/tasks.json | 17 ++++++ 2 files changed, 17 insertions(+), 75 deletions(-) delete mode 100644 .github/workflows/build-and-release.yml create mode 100644 .vscode/tasks.json diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml deleted file mode 100644 index e2b8961..0000000 --- a/.github/workflows/build-and-release.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Build and Release - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - tag: - description: 'Optional tag name to create a release (useful for manual runs)' - required: false - -jobs: - build-and-release: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: '10.0' - - - name: Restore - run: dotnet restore - - - name: Build - run: dotnet build -c Release --no-restore - - - name: Publish - run: dotnet publish MikuSB/MikuSB.csproj -c Release -o ./artifacts/publish --no-build - - - name: Determine release tag - id: tag - run: | - if [ -n "${{ github.event.inputs.tag }}" ]; then - echo "RELEASE_TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV - else - if [[ "${GITHUB_REF}" == refs/tags/* ]]; then - echo "RELEASE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - else - echo "RELEASE_TAG=manual-${GITHUB_RUN_ID}" >> $GITHUB_ENV - fi - fi - shell: bash - - - name: Archive publish output - run: | - mkdir -p artifacts - cd artifacts - zip -r release-${RELEASE_TAG}.zip publish - shell: bash - - - name: Create GitHub Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ env.RELEASE_TAG }} - release_name: Release ${{ env.RELEASE_TAG }} - draft: false - prerelease: false - - - name: Upload release asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: artifacts/release-${{ env.RELEASE_TAG }}.zip - asset_name: MikuSB-${{ env.RELEASE_TAG }}.zip - asset_content_type: application/zip diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..ff5aa6a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run MikuSB", + "type": "shell", + "command": "dotnet", + "args": [ + "run", + "--project", + ".\\MikuSB\\MikuSB.csproj" + ], + "isBackground": true, + "group": "build" + } + ] +} \ No newline at end of file From fb703d8ad5680c9914d82045fcf0c7fcc01febf2 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Sun, 17 May 2026 03:34:54 +0800 Subject: [PATCH 12/16] Implement character level-up and improve support card system (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Changed the location where client save data is stored to the MikuSB directory. * Character level-up implemented. * Small fix(login system) * account delete command * Rename the log file * Fix SupporterCard_Upgrade * Support Card Affix * Improved the system so that affixes are correctly applied when obtaining support cards using the give command. * Update version.txt * 新增多个处理程序:BattlePassLogic_ClientRefresh、Gacha_Launch、GuideLogic_WriteGuideLog 和 ShopLogic_GetGoodsList --------- Co-authored-by: Kei-Luna Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- Common/Data/Excel/SupportAffixExcel.cs | 27 ++ Common/Data/Excel/SupportAffixPoolExcel.cs | 35 +++ Common/Data/Excel/SupportCardExcel.cs | 8 + Common/Data/GameData.cs | 2 + Common/Database/Inventory/InventoryData.cs | 3 + .../Message/LanguageCHS.cs | 9 +- .../Message/LanguageCHT.cs | 9 +- .../Message/LanguageEN.cs | 9 +- Common/Util/ConfigManager.cs | 22 ++ GameServer/Command/Commands/CommandAccount.cs | 37 +++ GameServer/Game/Inventory/InventoryManager.cs | 12 + .../Game/Support/SupportAffixService.cs | 40 +++ .../BattlePassLogic_ClientRefresh.cs | 73 +++++ .../CallGS/Handlers/Gacha/Gacha_Launch.cs | 34 ++ .../Handlers/Girl/GirlCard_UpdateLevel.cs | 296 ++++++++++++++++++ .../Guide/GuideLogic_WriteGuideLog.cs | 14 + .../Handlers/Shop/ShopLogic_GetGoodsList.cs | 59 ++++ .../SupporterCard/SupporterCard_Upgrade.cs | 23 +- MikuSB.Loader/GameLaunchService.cs | 30 +- MikuSB/Program/LoaderManager.cs | 39 ++- SdkServer/Handlers/RouteController.cs | 44 ++- version.txt | 2 +- 22 files changed, 806 insertions(+), 21 deletions(-) create mode 100644 Common/Data/Excel/SupportAffixExcel.cs create mode 100644 Common/Data/Excel/SupportAffixPoolExcel.cs create mode 100644 GameServer/Game/Support/SupportAffixService.cs create mode 100644 GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs create mode 100644 GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs create mode 100644 GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpdateLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/Guide/GuideLogic_WriteGuideLog.cs create mode 100644 GameServer/Server/CallGS/Handlers/Shop/ShopLogic_GetGoodsList.cs diff --git a/Common/Data/Excel/SupportAffixExcel.cs b/Common/Data/Excel/SupportAffixExcel.cs new file mode 100644 index 0000000..5c4487f --- /dev/null +++ b/Common/Data/Excel/SupportAffixExcel.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("item/support/affix.json")] +public class SupportAffixExcel : ExcelResource +{ + [JsonProperty("ID")] public int Id { get; set; } + [JsonExtensionData] public IDictionary ExtraData { get; set; } = new Dictionary(); + + public int TierCount => + ExtraData + .Where(x => x.Key != "ID" && x.Key != "Sift" && x.Key != "Comment") + .Select(x => x.Value) + .OfType() + .Select(x => x.Count) + .DefaultIfEmpty(0) + .Max(); + + public override uint GetId() => (uint)Id; + + public override void Loaded() + { + GameData.SupportAffixData[Id] = this; + } +} diff --git a/Common/Data/Excel/SupportAffixPoolExcel.cs b/Common/Data/Excel/SupportAffixPoolExcel.cs new file mode 100644 index 0000000..3cc6bdd --- /dev/null +++ b/Common/Data/Excel/SupportAffixPoolExcel.cs @@ -0,0 +1,35 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("item/support/affix_pool.json")] +public class SupportAffixPoolExcel : ExcelResource +{ + [JsonProperty("ID")] public int Id { get; set; } + public List AffixGroup1 { get; set; } = []; + public int Weight1 { get; set; } + public List AffixGroup2 { get; set; } = []; + public int Weight2 { get; set; } + public List AffixGroup3 { get; set; } = []; + public int Weight3 { get; set; } + public List AffixGroup4 { get; set; } = []; + public int Weight4 { get; set; } + + public IEnumerable<(IReadOnlyList Affixs, int Weight)> Groups + { + get + { + if (AffixGroup1.Count > 0 && Weight1 > 0) yield return (AffixGroup1, Weight1); + if (AffixGroup2.Count > 0 && Weight2 > 0) yield return (AffixGroup2, Weight2); + if (AffixGroup3.Count > 0 && Weight3 > 0) yield return (AffixGroup3, Weight3); + if (AffixGroup4.Count > 0 && Weight4 > 0) yield return (AffixGroup4, Weight4); + } + } + + public override uint GetId() => (uint)Id; + + public override void Loaded() + { + GameData.SupportAffixPoolData[Id] = this; + } +} diff --git a/Common/Data/Excel/SupportCardExcel.cs b/Common/Data/Excel/SupportCardExcel.cs index 8502909..8231ee1 100644 --- a/Common/Data/Excel/SupportCardExcel.cs +++ b/Common/Data/Excel/SupportCardExcel.cs @@ -11,7 +11,9 @@ public class SupportCardExcel : ExcelResource public uint Level { get; set; } public uint Icon { get; set; } public uint ProvideExp { get; set; } + public uint Color { get; set; } [JsonProperty("LevelLimitID")] public int LevelLimitId { get; set; } + [JsonProperty("AffixPool")] public List AffixPool { get; set; } = []; public uint MaxLevel => LevelLimitId switch { @@ -21,6 +23,12 @@ public class SupportCardExcel : ExcelResource _ => 10 }; + // Number of affixes granted initially + public int InitialAffixCount => Color >= 5 ? 2 : 1; + + // Total maximum affixes (including ones unlocked at max level) + public int TotalAffixCount => Color >= 5 ? 3 : 2; + public ulong TemplateId => GameResourceTemplateId.FromGdpl(Genre, Detail, Particular, Level); public override uint GetId() => Icon; diff --git a/Common/Data/GameData.cs b/Common/Data/GameData.cs index 5f81860..6a87f96 100644 --- a/Common/Data/GameData.cs +++ b/Common/Data/GameData.cs @@ -23,6 +23,8 @@ public static class GameData public static Dictionary SpineData { get; private set; } = []; public static Dictionary NodeConditionData { get; private set; } = []; public static List SupportCardData { get; private set; } = []; + public static Dictionary SupportAffixData { get; private set; } = []; + public static Dictionary SupportAffixPoolData { get; private set; } = []; public static Dictionary WeaponSkinData { get; private set; } = []; public static Dictionary DailyLevelData { get; private set; } = []; public static Dictionary ProfileData { get; private set; } = []; diff --git a/Common/Database/Inventory/InventoryData.cs b/Common/Database/Inventory/InventoryData.cs index 4c3125c..575b5f9 100644 --- a/Common/Database/Inventory/InventoryData.cs +++ b/Common/Database/Inventory/InventoryData.cs @@ -106,6 +106,8 @@ public override Item ToProto() public class GameSupportCardInfo : BaseGameItemInfo { public uint AffixId { get; set; } + [SugarColumn(IsJson = true)] public List Affixs { get; set; } = []; + public override Item ToProto() { var proto = new Item @@ -120,6 +122,7 @@ public override Item ToProto() Exp = Exp } }; + proto.Enhance.Affixs.AddRange(Affixs); proto.Slots[(uint)ItemSupportCardSlotTypeEnum.SLOT_AFFIXINDEX] = AffixId; return proto; } diff --git a/Common/Internationalization/Message/LanguageCHS.cs b/Common/Internationalization/Message/LanguageCHS.cs index 2274b54..8237888 100644 --- a/Common/Internationalization/Message/LanguageCHS.cs +++ b/Common/Internationalization/Message/LanguageCHS.cs @@ -222,9 +222,16 @@ public class HelpTextCHS public class AccountTextCHS { public string Desc => "管理 SDK 登录使用的账号映射"; - public string Usage => "用法: /account create <邮箱> "; + public string Usage => + "用法: /account create <邮箱> \n" + + "用法: /account delete <邮箱|UID>\n" + + "用法: /account list"; public string Created => "已创建账号映射: {0} -> UID {1}"; public string CreateFailed => "创建账号映射失败: {0}"; + public string Deleted => "已删除账号映射: {0} -> UID {1}"; + public string DeleteFailed => "删除账号映射失败: {0}"; + public string DeleteOnline => "账号在线时无法删除: {0} -> UID {1}"; + public string NotFound => "未找到账号: {0}"; } /// diff --git a/Common/Internationalization/Message/LanguageCHT.cs b/Common/Internationalization/Message/LanguageCHT.cs index 3350d8b..6135cef 100644 --- a/Common/Internationalization/Message/LanguageCHT.cs +++ b/Common/Internationalization/Message/LanguageCHT.cs @@ -222,9 +222,16 @@ public class HelpTextCHT public class AccountTextCHT { public string Desc => "管理 SDK 登入使用的帳號映射"; - public string Usage => "用法: /account create <郵箱> "; + public string Usage => + "用法: /account create <郵箱> \n" + + "用法: /account delete <郵箱|UID>\n" + + "用法: /account list"; public string Created => "已建立帳號映射: {0} -> UID {1}"; public string CreateFailed => "建立帳號映射失敗: {0}"; + public string Deleted => "已刪除帳號映射: {0} -> UID {1}"; + public string DeleteFailed => "刪除帳號映射失敗: {0}"; + public string DeleteOnline => "帳號在線時無法刪除: {0} -> UID {1}"; + public string NotFound => "未找到帳號: {0}"; } /// diff --git a/Common/Internationalization/Message/LanguageEN.cs b/Common/Internationalization/Message/LanguageEN.cs index 7d19c86..0051c3b 100644 --- a/Common/Internationalization/Message/LanguageEN.cs +++ b/Common/Internationalization/Message/LanguageEN.cs @@ -188,9 +188,16 @@ public class HelpTextEN public class AccountTextEN { public string Desc => "Manage account mappings for SDK logins"; - public string Usage => "Usage: /account create "; + public string Usage => + "Usage: /account create \n" + + "Usage: /account delete \n" + + "Usage: /account list"; public string Created => "Created account mapping: {0} -> UID {1}"; public string CreateFailed => "Failed to create account mapping: {0}"; + public string Deleted => "Deleted account mapping: {0} -> UID {1}"; + public string DeleteFailed => "Failed to delete account mapping: {0}"; + public string DeleteOnline => "Cannot delete account while online: {0} -> UID {1}"; + public string NotFound => "Account not found: {0}"; } /// diff --git a/Common/Util/ConfigManager.cs b/Common/Util/ConfigManager.cs index 70e72bf..c5ac375 100644 --- a/Common/Util/ConfigManager.cs +++ b/Common/Util/ConfigManager.cs @@ -19,6 +19,11 @@ public static void LoadConfig() //LoadHotfixData(); } + public static void SaveConfig() + { + SaveData(Config, ConfigFilePath); + } + private static void LoadConfigData() { var file = new FileInfo(ConfigFilePath); @@ -43,9 +48,26 @@ private static void LoadConfigData() Config = JsonConvert.DeserializeObject(json)!; } + Config.Loader.Arguments = NormalizeLoaderArguments(Config.Loader.Arguments); SaveData(Config, ConfigFilePath); } + private static string[] NormalizeLoaderArguments(string[]? arguments) + { + var result = new List(arguments ?? []); + var userDataDirectory = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "Client_User_Data")); + Directory.CreateDirectory(userDataDirectory); + + var userDirArgument = $"-userdir={userDataDirectory}"; + var existingIndex = result.FindIndex(x => x.StartsWith("-userdir=", StringComparison.OrdinalIgnoreCase)); + if (existingIndex >= 0) + result[existingIndex] = userDirArgument; + else + result.Add(userDirArgument); + + return result.ToArray(); + } + private static void LoadHotfixData() { var file = new FileInfo(HotfixFilePath); diff --git a/GameServer/Command/Commands/CommandAccount.cs b/GameServer/Command/Commands/CommandAccount.cs index fdb62eb..928e70c 100644 --- a/GameServer/Command/Commands/CommandAccount.cs +++ b/GameServer/Command/Commands/CommandAccount.cs @@ -2,6 +2,7 @@ using MikuSB.Database.Account; using MikuSB.Enums.Player; using MikuSB.Internationalization; +using MikuSB.GameServer.Server; using System.Text; namespace MikuSB.GameServer.Command.Commands; @@ -37,6 +38,42 @@ public async ValueTask Create(CommandArg arg) } } + [CommandMethod("delete")] + public async ValueTask Delete(CommandArg arg) + { + if (!await arg.CheckArgCnt(1)) + return; + + var identifier = arg.Args[0].Trim(); + var account = int.TryParse(identifier, out var uid) && uid > 0 + ? AccountData.GetAccountByUid(uid) + : AccountData.GetAccountByUserName(identifier); + + if (account == null) + { + await arg.SendMsg(I18NManager.Translate("Game.Command.Account.NotFound", identifier)); + return; + } + + try + { + if (Listener.GetActiveConnection(account.Uid) != null) + { + await arg.SendMsg(I18NManager.Translate("Game.Command.Account.DeleteOnline", account.Username, + account.Uid.ToString())); + return; + } + + AccountData.DeleteAccount(account.Uid); + await arg.SendMsg(I18NManager.Translate("Game.Command.Account.Deleted", account.Username, + account.Uid.ToString())); + } + catch (Exception ex) + { + await arg.SendMsg(I18NManager.Translate("Game.Command.Account.DeleteFailed", ex.Message)); + } + } + [CommandMethod("list")] public async ValueTask List(CommandArg arg) { diff --git a/GameServer/Game/Inventory/InventoryManager.cs b/GameServer/Game/Inventory/InventoryManager.cs index c3d79fd..8a45455 100644 --- a/GameServer/Game/Inventory/InventoryManager.cs +++ b/GameServer/Game/Inventory/InventoryManager.cs @@ -4,6 +4,7 @@ using MikuSB.Database.Inventory; using MikuSB.Enums.Item; using MikuSB.GameServer.Game.Player; +using MikuSB.GameServer.Game.Support; using MikuSB.GameServer.Server.Packet.Send.Misc; namespace MikuSB.GameServer.Game.Inventory; @@ -135,7 +136,18 @@ private static uint GetWeaponBreak(uint level) ItemType = genre, ItemCount = 1, Level = cardLevel, + AffixId = 1, }; + + var affixCount = cardLevel >= spCard.MaxLevel ? spCard.TotalAffixCount : spCard.InitialAffixCount; + for (int i = 0; i < affixCount && i < spCard.AffixPool.Count; i++) + { + var (affixId, tier) = SupportAffixService.GenerateRandomAffix(spCard.AffixPool[i]); + if (affixId == 0) continue; + info.Affixs.Add(affixId); + info.Affixs.Add(tier); + } + InventoryData.SupportCards[info.UniqueId] = info; if (sendPacket) await Player.SendPacket(new PacketNtfCallScript([info])); diff --git a/GameServer/Game/Support/SupportAffixService.cs b/GameServer/Game/Support/SupportAffixService.cs new file mode 100644 index 0000000..dc28b67 --- /dev/null +++ b/GameServer/Game/Support/SupportAffixService.cs @@ -0,0 +1,40 @@ +using MikuSB.Data; + +namespace MikuSB.GameServer.Game.Support; + +public static class SupportAffixService +{ + // Returns (affixId, tier) - both 1-based. Returns (0,0) if pool not found. + public static (uint AffixId, uint Tier) GenerateRandomAffix(int poolId) + { + if (!GameData.SupportAffixPoolData.TryGetValue(poolId, out var pool)) + return (0, 0); + + var groups = pool.Groups.ToList(); + if (groups.Count == 0) + return (0, 0); + + var totalWeight = groups.Sum(x => x.Weight); + var roll = Random.Shared.Next(totalWeight); + var cumulative = 0; + var selectedAffixs = groups[0].Affixs; + + foreach (var (affixIds, weight) in groups) + { + cumulative += weight; + if (roll < cumulative) + { + selectedAffixs = affixIds; + break; + } + } + + if (selectedAffixs.Count == 0) + return (0, 0); + + var affixId = selectedAffixs[Random.Shared.Next(selectedAffixs.Count)]; + var tierCount = GameData.SupportAffixData.GetValueOrDefault(affixId)?.TierCount ?? 5; + var tier = (uint)(Random.Shared.Next(tierCount) + 1); + return ((uint)affixId, tier); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs b/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs new file mode 100644 index 0000000..6008508 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BattlePass; + +[CallGSApi("BattlePassLogic_ClientRefresh")] +public class BattlePassLogic_ClientRefresh : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = string.IsNullOrEmpty(param) + ? new BattlePassLogicClientRefreshParam { ClickNum = 0 } + : JsonSerializer.Deserialize(param); + var clickNum = req?.ClickNum ?? 0; + var battlePassList = LoadBattlePassGoods(); + var playerLevel = connection.Player?.Data.Level ?? 0; + + var response = JsonSerializer.Serialize(new + { + clicknum = clickNum, + nBattlePassLevel = playerLevel, + nBattlePassExp = 0, + tbBattlePassList = battlePassList, + BattlePassList = battlePassList + }); + + await CallGSRouter.SendScript(connection, "BattlePassLogic_ClientRefresh", response); + } + + private static JsonElement[] LoadBattlePassGoods() + { + var path = Path.Combine(AppContext.BaseDirectory, "Resources", "purchase", "ibgoods.json"); + if (!File.Exists(path)) + return []; + + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement + .EnumerateArray() + .Where(row => IsBattlePassGoods(row)) + .Select(row => row.Clone()) + .ToArray(); + } + + private static bool IsBattlePassGoods(JsonElement row) + { + if (row.TryGetProperty("GoodsTag", out var goodsTag) && GetStringValue(goodsTag)?.Contains("battlepass", StringComparison.OrdinalIgnoreCase) == true) + return true; + + if (row.TryGetProperty("IosId", out var iosId) && GetStringValue(iosId)?.Contains("battlepass", StringComparison.OrdinalIgnoreCase) == true) + return true; + + if (row.TryGetProperty("AndroidId", out var androidId) && GetStringValue(androidId)?.Contains("battlepass", StringComparison.OrdinalIgnoreCase) == true) + return true; + + return false; + } + + private static string? GetStringValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.GetRawText(), + _ => null + }; + } +} + +internal sealed class BattlePassLogicClientRefreshParam +{ + [JsonPropertyName("clicknum")] + public int ClickNum { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs new file mode 100644 index 0000000..5e8aeea --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Gacha; + +[CallGSApi("Gacha_Launch")] +public class Gacha_Launch : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var gachaList = LoadGachaList(); + + var response = JsonSerializer.Serialize(new + { + tbGachaList = gachaList, + GachaList = gachaList + }); + + await CallGSRouter.SendScript(connection, "Gacha_Launch", response); + } + + private static JsonElement[] LoadGachaList() + { + var path = Path.Combine(AppContext.BaseDirectory, "Resources", "gacha", "gacha.json"); + if (!File.Exists(path)) + return []; + + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement + .EnumerateArray() + .Select(row => row.Clone()) + .ToArray(); + } +} diff --git a/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpdateLevel.cs b/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpdateLevel.cs new file mode 100644 index 0000000..bde2790 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpdateLevel.cs @@ -0,0 +1,296 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Girl; + +[CallGSApi("GirlCard_UpdateLevel")] +public class GirlCard_UpdateLevel : ICallGSHandler +{ + private const uint CashGroupId = 1; + private const uint SilverMoneyType = 3; + private const uint SilverSid = SilverMoneyType * 2 + 1; + private const uint RoleMaxLevel = 80; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.Id == 0 || req.Materials == null || req.Materials.Count == 0) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var card = player.CharacterManager.GetCharacterByGUID((uint)req.Id); + if (card == null) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var cardTemplate = GameData.CardData.Values.FirstOrDefault(x => + GameResourceTemplateId.FromGdpl(x.Genre, x.Detail, x.Particular, x.Level) == card.TemplateId); + if (cardTemplate == null) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var levelCap = GetCardLevelCap(player.Data.Level, cardTemplate.LevelLimitID); + if (levelCap == 0) + { + levelCap = card.Level; + } + + if (card.Level >= RoleMaxLevel) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "{\"sErr\":\"tip.card_max_level\"}"); + return; + } + + var requestedMaterials = new Dictionary(); + foreach (var row in req.Materials) + { + if (row == null || row.Id == 0 || row.Num == 0) + continue; + + requestedMaterials[(uint)row.Id] = requestedMaterials.GetValueOrDefault((uint)row.Id) + row.Num; + } + + if (requestedMaterials.Count == 0) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "{\"sErr\":\"tip.material_not_enough\"}"); + return; + } + + ulong totalExp = 0; + ulong totalSilverCost = 0; + foreach (var (itemId, count) in requestedMaterials) + { + var item = player.InventoryManager.GetNormalItem(itemId); + if (item == null || item.ItemCount < count) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "{\"sErr\":\"tip.material_not_enough\"}"); + return; + } + + if (!GameData.SuppliesData.TryGetValue((uint)item.TemplateId, out var supplies) || supplies.ProvideExp == 0) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + totalExp += (ulong)supplies.ProvideExp * count; + totalSilverCost += (ulong)supplies.ConsumeGold * count; + } + + var silverAttr = GetOrCreateAttr(player.Data, CashGroupId, SilverSid); + if ((ulong)silverAttr.Val < totalSilverCost) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "{\"sErr\":\"tip.material_not_enough\"}"); + return; + } + + var syncItems = new List(); + foreach (var (itemId, count) in requestedMaterials) + { + var item = player.InventoryManager.GetNormalItem(itemId)!; + item.ItemCount -= count; + + if (item.ItemCount == 0) + { + player.InventoryManager.InventoryData.Items.Remove(item.UniqueId); + syncItems.Add(BuildRemovedProto(item)); + } + else + { + syncItems.Add(item.ToProto()); + } + } + + silverAttr.Val -= checked((uint)totalSilverCost); + + var (newLevel, newExp) = ApplyCardExp(card.Level, card.Exp, totalExp, levelCap); + card.Level = newLevel; + card.Exp = checked((int)newExp); + syncItems.Add(card.ToProto()); + + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + DatabaseHelper.SaveDatabaseType(player.Data); + + var sync = new NtfSyncPlayer(); + sync.Items.AddRange(syncItems); + sync.Custom[player.ToPackedAttrKey(CashGroupId, SilverSid)] = silverAttr.Val; + sync.Custom[player.ToShiftedAttrKey(CashGroupId, SilverSid)] = silverAttr.Val; + + await CallGSRouter.SendScript(connection, "GirlCard_UpdateLevel", "null", sync); + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid, + Val = 0 + }; + data.Attrs.Add(attr); + return attr; + } + + private static Item BuildRemovedProto(BaseGameItemInfo item) + { + var proto = item.ToProto(); + proto.Count = 0; + return proto; + } + + private static uint GetCardLevelCap(uint playerLevel, int levelLimitId) + { + var limits = LoadCardLevelLimit(levelLimitId); + if (limits.Count == 0) + return 0; + + uint nearestAccountLevel = 0; + uint nearestCardLevel = 0; + + foreach (var (accountLevel, cardLevel) in limits) + { + if (accountLevel < playerLevel) + { + nearestAccountLevel = accountLevel; + nearestCardLevel = cardLevel; + continue; + } + + if (accountLevel == playerLevel) + return Math.Min(cardLevel, RoleMaxLevel); + + var distance = accountLevel - nearestAccountLevel; + if (distance == 0) + return Math.Min(cardLevel, RoleMaxLevel); + + var percent = (playerLevel - nearestAccountLevel) / (double)distance; + var interpolated = (uint)Math.Floor(nearestCardLevel + ((cardLevel - nearestCardLevel) * percent)); + return Math.Min(interpolated, RoleMaxLevel); + } + + return Math.Min(nearestCardLevel, RoleMaxLevel); + } + + private static List<(uint AccountLevel, uint CardLevel)> LoadCardLevelLimit(int levelLimitId) + { + var path = Path.Combine( + AppContext.BaseDirectory, + "Resources", + "item", + "level_limit.json"); + + if (!File.Exists(path)) + return []; + + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var result = new List<(uint AccountLevel, uint CardLevel)>(); + + foreach (var row in doc.RootElement.EnumerateArray()) + { + if (!row.TryGetProperty("ID", out var idProp) || idProp.GetInt32() != levelLimitId) + continue; + + if (!row.TryGetProperty("Type", out var typeProp) || typeProp.GetInt32() != 1) + continue; + + if (!row.TryGetProperty("Limit", out var limitProp) || limitProp.ValueKind != JsonValueKind.Object) + continue; + + foreach (var property in limitProp.EnumerateObject()) + { + if (!uint.TryParse(property.Name, out var accountLevel)) + continue; + + uint cardLevel; + if (property.Value.ValueKind == JsonValueKind.Number) + { + cardLevel = property.Value.GetUInt32(); + } + else if (property.Value.ValueKind == JsonValueKind.String && + uint.TryParse(property.Value.GetString(), out var parsed)) + { + cardLevel = parsed; + } + else + { + continue; + } + + result.Add((accountLevel, cardLevel)); + } + + break; + } + + result.Sort((a, b) => a.AccountLevel.CompareTo(b.AccountLevel)); + return result; + } + + private static (uint Level, ulong Exp) ApplyCardExp(uint level, int currentExp, ulong addedExp, uint levelCap) + { + var destLevel = level == 0 ? 1u : level; + var destExp = (ulong)Math.Max(0, currentExp) + addedExp; + + if (levelCap > 0 && destLevel >= levelCap) + return (destLevel, destExp); + + while (destLevel < RoleMaxLevel) + { + var needExp = GetCardNeedExp(destLevel); + if (needExp == 0 || destExp < needExp) + break; + + destExp -= needExp; + destLevel++; + + if (levelCap > 0 && destLevel >= levelCap) + return (levelCap, destExp); + } + + return (destLevel, destExp); + } + + private static uint GetCardNeedExp(uint currentLevel) + { + if (GameData.UpgradeExpData.TryGetValue((int)currentLevel, out var row)) + return row.CardNeedExp; + + return 0; + } +} + +internal sealed class GirlCardUpdateLevelParam +{ + [JsonPropertyName("Id")] + public int Id { get; set; } + + [JsonPropertyName("tbMaterials")] + public List Materials { get; set; } = []; +} + +internal sealed class GirlCardLevelMaterial +{ + [JsonPropertyName("Id")] + public int Id { get; set; } + + [JsonPropertyName("Num")] + public uint Num { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Guide/GuideLogic_WriteGuideLog.cs b/GameServer/Server/CallGS/Handlers/Guide/GuideLogic_WriteGuideLog.cs new file mode 100644 index 0000000..e7b9398 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Guide/GuideLogic_WriteGuideLog.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Guide; + +[CallGSApi("GuideLogic_WriteGuideLog")] +public class GuideLogic_WriteGuideLog : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + // Client writes guide progress log. Return empty success JSON to client. + // param: {nGuideId, ...} + await CallGSRouter.SendScript(connection, "GuideLogic_WriteGuideLog", "{}"); + } +} diff --git a/GameServer/Server/CallGS/Handlers/Shop/ShopLogic_GetGoodsList.cs b/GameServer/Server/CallGS/Handlers/Shop/ShopLogic_GetGoodsList.cs new file mode 100644 index 0000000..ee57e57 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Shop/ShopLogic_GetGoodsList.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Shop; + +[CallGSApi("ShopLogic_GetGoodsList")] +public class ShopLogic_GetGoodsList : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = string.IsNullOrEmpty(param) + ? new ShopLogicGetGoodsListParam { ShopId = 0 } + : JsonSerializer.Deserialize(param); + var shopId = req?.ShopId ?? 0; + var goodsList = LoadShopGoods(shopId); + + var response = JsonSerializer.Serialize(new + { + nShopId = shopId, + tbGoodsList = goodsList, + GoodsList = goodsList + }); + + await CallGSRouter.SendScript(connection, "ShopLogic_GetGoodsList", response); + } + + private static JsonElement[] LoadShopGoods(int shopId) + { + if (shopId <= 0) + return []; + + var path = Path.Combine(AppContext.BaseDirectory, "Resources", "shop", "goods.json"); + if (!File.Exists(path)) + return []; + + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement + .EnumerateArray() + .Where(row => row.TryGetProperty("ShopId", out var shopIdProp) && GetIntValue(shopIdProp) == shopId) + .Select(row => row.Clone()) + .ToArray(); + } + + private static int GetIntValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Number => element.GetInt32(), + JsonValueKind.String => int.TryParse(element.GetString(), out var value) ? value : 0, + _ => 0 + }; + } +} + +internal sealed class ShopLogicGetGoodsListParam +{ + [JsonPropertyName("nShopId")] + public int ShopId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/SupporterCard/SupporterCard_Upgrade.cs b/GameServer/Server/CallGS/Handlers/SupporterCard/SupporterCard_Upgrade.cs index 805ffda..4b08f99 100644 --- a/GameServer/Server/CallGS/Handlers/SupporterCard/SupporterCard_Upgrade.cs +++ b/GameServer/Server/CallGS/Handlers/SupporterCard/SupporterCard_Upgrade.cs @@ -1,5 +1,6 @@ using MikuSB.Data; using MikuSB.Database; +using MikuSB.GameServer.Game.Support; using MikuSB.Proto; using System.Text.Json; using System.Text.Json.Serialization; @@ -19,7 +20,7 @@ public async Task Handle(Connection connection, string param, ushort seqNo) return; } - var supportCard = player.InventoryManager.InventoryData.Items.GetValueOrDefault((uint)req.SupportCardUid); + var supportCard = player.InventoryManager.GetSupportCardItem((uint)req.SupportCardUid); if (supportCard == null) { await CallGSRouter.SendScript(connection, "Logistics_Upgrade", "{}"); @@ -68,10 +69,11 @@ public async Task Handle(Connection connection, string param, ushort seqNo) } // Apply exp and level up + if (supportCard.Level == 0) supportCard.Level = 1; supportCard.Exp += gainedExp; while (supportCard.Level < maxLevel) { - var expNeeded = GetExpNeeded(supportCard.Level + 1); + var expNeeded = GetExpNeeded(supportCard.Level); if (expNeeded == 0 || supportCard.Exp < expNeeded) break; supportCard.Exp -= expNeeded; supportCard.Level++; @@ -80,6 +82,23 @@ public async Task Handle(Connection connection, string param, ushort seqNo) { supportCard.Exp = 0; supportCard.Level = maxLevel; + + // Unlock next affix slot when reaching max level for the first time + if (supportCardExcel != null) + { + var currentSlots = supportCard.Affixs.Count / 2; + var totalSlots = supportCardExcel.TotalAffixCount; + if (currentSlots < totalSlots && currentSlots < supportCardExcel.AffixPool.Count) + { + var poolId = supportCardExcel.AffixPool[currentSlots]; + var (affixId, tier) = SupportAffixService.GenerateRandomAffix(poolId); + if (affixId > 0) + { + supportCard.Affixs.Add(affixId); + supportCard.Affixs.Add(tier); + } + } + } } syncItems.Add(supportCard.ToProto()); diff --git a/MikuSB.Loader/GameLaunchService.cs b/MikuSB.Loader/GameLaunchService.cs index 23cf723..a09529b 100644 --- a/MikuSB.Loader/GameLaunchService.cs +++ b/MikuSB.Loader/GameLaunchService.cs @@ -293,11 +293,14 @@ public sealed class LaunchOptions public static LaunchOptions FromConfig(IEnumerable? extraGameArguments = null) { var config = ConfigManager.Config; + var serverBaseDirectory = AppContext.BaseDirectory; var gamePath = ResolvePath(config.Loader.GamePath, AppContext.BaseDirectory); - var patchPaths = ResolvePatchPaths(config.Loader.PatchPaths, AppContext.BaseDirectory); + var patchPaths = ResolvePatchPaths(config.Loader.PatchPaths, serverBaseDirectory); var gameArgs = new List(config.Loader.Arguments ?? []); if (extraGameArguments is not null) gameArgs.AddRange(extraGameArguments.Where(x => !string.IsNullOrWhiteSpace(x))); + gameArgs = EnsureUserDirArgument(gameArgs, serverBaseDirectory); + PersistResolvedArgumentsIfChanged(config, gameArgs); var env = new Dictionary(StringComparer.OrdinalIgnoreCase); if (config.Loader.SetAllProxy && config.Proxy.Enabled) @@ -330,6 +333,31 @@ public static LaunchOptions FromConfig(IEnumerable? extraGameArguments = }; } + private static List EnsureUserDirArgument(List gameArgs, string baseDirectory) + { + var userDataDirectory = Path.GetFullPath(Path.Combine(baseDirectory, "Client_User_Data")); + Directory.CreateDirectory(userDataDirectory); + + var userDirArgument = $"-userdir={userDataDirectory}"; + var existingIndex = gameArgs.FindIndex(x => x.StartsWith("-userdir=", StringComparison.OrdinalIgnoreCase)); + if (existingIndex >= 0) + gameArgs[existingIndex] = userDirArgument; + else + gameArgs.Add(userDirArgument); + + return gameArgs; + } + + private static void PersistResolvedArgumentsIfChanged(Configuration.ConfigContainer config, List gameArgs) + { + var currentArgs = config.Loader.Arguments ?? []; + if (currentArgs.SequenceEqual(gameArgs, StringComparer.Ordinal)) + return; + + config.Loader.Arguments = gameArgs.ToArray(); + ConfigManager.SaveConfig(); + } + private static string? ResolvePath(string? value, string baseDirectory) { if (string.IsNullOrWhiteSpace(value)) diff --git a/MikuSB/Program/LoaderManager.cs b/MikuSB/Program/LoaderManager.cs index eb1aed7..7ab98ab 100644 --- a/MikuSB/Program/LoaderManager.cs +++ b/MikuSB/Program/LoaderManager.cs @@ -26,16 +26,35 @@ public class LoaderManager : MikuSB public static void InitConfig() { // Initialize log - var counter = 0; - FileInfo file; - while (true) - { - file = new FileInfo(ConfigManager.Config.Path.LogPath + $"/{DateTime.Now:yyyy-MM-dd}-{++counter}.log"); - if (file is not { Exists: false, Directory: not null }) continue; - file.Directory.Create(); - break; - } - Logger.SetLogFile(file); + var logDir = ConfigManager.Config.Path.LogPath; + var logFile = new FileInfo(Path.Combine(logDir, "Server.log")); + logFile.Directory?.Create(); + + if (logFile.Exists) + { + // Read start time from first log line, fall back to file creation time + DateTime logStartTime; + try + { + var firstLine = File.ReadLines(logFile.FullName).FirstOrDefault() ?? ""; + // Format: [HH:mm:ss] ... + var timeStr = firstLine.Length >= 10 ? firstLine[1..9] : ""; + var dateStr = logFile.CreationTime.ToString("yyyy-MM-dd"); + logStartTime = DateTime.TryParse($"{dateStr} {timeStr}", out var parsed) + ? parsed + : logFile.CreationTime; + } + catch + { + logStartTime = logFile.CreationTime; + } + + var backupName = $"Server-backup-{logStartTime:yyyy.MM.dd-HH.mm.ss}.log"; + var backupFile = new FileInfo(Path.Combine(logDir, backupName)); + logFile.MoveTo(backupFile.FullName, overwrite: true); + } + + Logger.SetLogFile(new FileInfo(Path.Combine(logDir, "Server.log"))); // Init all directories try diff --git a/SdkServer/Handlers/RouteController.cs b/SdkServer/Handlers/RouteController.cs index da081da..0d6e86a 100644 --- a/SdkServer/Handlers/RouteController.cs +++ b/SdkServer/Handlers/RouteController.cs @@ -270,12 +270,48 @@ public async Task Login( [FromForm] string? form_email) { var bodyEmail = await GetJsonBodyValue("email"); + var finalEmail = email ?? form_email ?? bodyEmail; + if (!string.IsNullOrWhiteSpace(finalEmail)) + { + var normalizedEmail = finalEmail.Trim(); + var accountData = AccountData.GetAccountByEmail(normalizedEmail); + if (accountData == null) + { + if (!ConfigManager.Config.ServerOption.AutoCreateUser) return BuildLoginFailedResponse("Account not found."); + AccountData.CreateAccount(normalizedEmail, 0, "123456"); + accountData = AccountData.GetAccountByEmail(normalizedEmail)!; + } + + var finalUidValue = accountData.Uid.ToString(); + var finalTokenValue = accountData.GenerateComboToken(); + + object emailLoginRsp = new + { + code = 0, + data = new + { + associatedAccounts = Array.Empty(), + isFirstLogin = false, + isNeedKoreaSciAuth = false, + ksOpenId = $"ks_{finalUidValue}", + nickname = accountData.Username, + passportId = finalUidValue, + playerFillAgeUrl = "", + status = 0, + thirdPartyUid = "", + token = finalTokenValue, + type = "guest", + uid = accountData.Uid + }, + msg = "操作成功" + }; + + return Ok(emailLoginRsp); + } + var bodyUid = await GetJsonBodyValue("uid"); var bodyToken = await GetJsonBodyValue("token"); - var account = ResolveAccountForSdkLogin( - email ?? form_email ?? bodyEmail, - uid ?? form_uid ?? bodyUid, - token ?? form_token ?? bodyToken) + var account = ResolveAccountForSdkLogin(finalEmail, uid ?? form_uid ?? bodyUid, token ?? form_token ?? bodyToken) ?? ResolveAutoLoginAccount(); if (account == null) return BuildLoginFailedResponse("Account not found."); diff --git a/version.txt b/version.txt index 32160ff..1c47632 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v=2.9 \ No newline at end of file +v=3.1 \ No newline at end of file From 9aa6469797c4edb46c73d66e3c80b5d2271b6392 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Sun, 17 May 2026 03:51:50 +0800 Subject: [PATCH 13/16] Enhance support card system and account management (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 添加游戏启动参数解析和处理功能 * 更新运行说明,添加直接启动游戏的命令示例 * Resolve merge conflicts with origin/main Agent-Logs-Url: https://github.com/AliceJump/MikuSB/sessions/d6f25cca-5fa3-4c52-a33e-e5f9c1ce6391 Co-authored-by: AliceJump <149395013+AliceJump@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .vscode/launch.json | 15 ++++++ Common/Data/Excel/SupportCardExcel.cs | 1 - Common/Data/GameData.cs | 2 +- GameServer/Game/Inventory/InventoryManager.cs | 2 +- MikuSB/Program/MikuSB.cs | 50 ++++++++++++++++++- README.md | 5 +- version.txt | 2 +- 7 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..bd4cfbe --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run MikuSB (build output dir)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/MikuSB/bin/Debug/net10.0/MikuSB.exe", + "args": [], + "cwd": "${workspaceFolder}/MikuSB/bin/Debug/net10.0", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] +} diff --git a/Common/Data/Excel/SupportCardExcel.cs b/Common/Data/Excel/SupportCardExcel.cs index 91daf3a..23239f5 100644 --- a/Common/Data/Excel/SupportCardExcel.cs +++ b/Common/Data/Excel/SupportCardExcel.cs @@ -39,7 +39,6 @@ public class SupportCardExcel : ExcelResource [JsonIgnore] public IReadOnlyList FixedAffixCost => ParseFlatCost(FixedAffixCostRaw); - public ulong TemplateId => GameResourceTemplateId.FromGdpl(Genre, Detail, Particular, Level); public override uint GetId() => Icon; diff --git a/Common/Data/GameData.cs b/Common/Data/GameData.cs index 5a665c8..79a9fac 100644 --- a/Common/Data/GameData.cs +++ b/Common/Data/GameData.cs @@ -44,4 +44,4 @@ public static ulong FromGdpl(uint genre, uint detail, uint particular, uint leve public static ulong FromGdpl(IReadOnlyList gdpl) => gdpl.Count >= 4 ? FromGdpl(gdpl[0], gdpl[1], gdpl[2], gdpl[3]) : 0; -} \ No newline at end of file +} diff --git a/GameServer/Game/Inventory/InventoryManager.cs b/GameServer/Game/Inventory/InventoryManager.cs index 5141eee..6d56d64 100644 --- a/GameServer/Game/Inventory/InventoryManager.cs +++ b/GameServer/Game/Inventory/InventoryManager.cs @@ -365,4 +365,4 @@ private static uint GetSuppliesMaxCount(SuppliesExcel suppliesData) => return furnitureInfo; } -} \ No newline at end of file +} diff --git a/MikuSB/Program/MikuSB.cs b/MikuSB/Program/MikuSB.cs index e560920..4e420d8 100644 --- a/MikuSB/Program/MikuSB.cs +++ b/MikuSB/Program/MikuSB.cs @@ -1,5 +1,6 @@ using MikuSB.Data; using MikuSB.Database; +using MikuSB.Loader; using MikuSB.MikuSB.Tool; using MikuSB.GameServer.Command; using MikuSB.GameServer.Server; @@ -22,7 +23,7 @@ public class MikuSB private static readonly CancellationTokenSource _cts = new(); private static int _exitCode = 0; - public static async Task Main() + public static async Task Main(string[] args) { Directory.SetCurrentDirectory(AppContext.BaseDirectory); var time = DateTime.Now; @@ -31,6 +32,8 @@ public static async Task Main() if (await UpdateService.TryStartSelfUpdateAsync()) return; + TryRunStartupGame(args); + RegisterExitEvent(); await LoaderManager.InitSdkServer(); LoaderManager.InitPacket(); @@ -66,6 +69,51 @@ public static async Task Main() await ProcessExit(Volatile.Read(ref _exitCode)); } + private static void TryRunStartupGame(string[] args) + { + if (!args.Any(x => string.Equals(x, "-game", StringComparison.OrdinalIgnoreCase))) + return; + + try + { + var extraGameArgs = ParseGameCommandArgs(args); + var pid = GameLaunchService.Launch(extraGameArgs); + Logger.Info(I18NManager.Translate("Game.Command.Game.Started", pid.ToString(CultureInfo.InvariantCulture))); + } + catch (Exception ex) + { + Logger.Error(I18NManager.Translate("Game.Command.Game.Failed", ex.Message), ex); + } + } + + private static string[] ParseGameCommandArgs(string[] args) + { + var extraArgs = new List(); + var hasPathOverride = false; + + for (int i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "-path", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 < args.Length) + { + ConfigManager.Config.Loader.GamePath = args[++i]; + hasPathOverride = true; + } + } + else if (string.Equals(args[i], "-arg", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 < args.Length) + extraArgs.Add(args[++i]); + } + } + + if (hasPathOverride) + Logger.Info("Startup -path override applied for this run."); + + return extraArgs.ToArray(); + } + #region Exit private static void RegisterExitEvent() diff --git a/README.md b/README.md index 4d31c2b..b73502c 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,6 @@ Languages: English | [中文](docs/user/README_zh.md) | [日本語](docs/user/RE ```powershell dotnet build ``` - 2. Set `GamePath` in `Config/Config.json` to the path of your game executable. 3. Start the server. @@ -57,8 +56,8 @@ dotnet run --project .\MikuSB ``` 4. Create an account in the server console. -5. Run the `game` command in the server console. -6. Start the game and log in. +5. Run the `game` command in the server console (arguments are passed through to the game process). +6. Start the game and log in, or launch directly with `MikuSB.exe -game [-path game_path] [-arg param1] [-arg param2]`. For publish commands and generated data details, see the [usage guide](docs/user/usage/USAGE_en.md). diff --git a/version.txt b/version.txt index 9fd0a3d..e5706cc 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v=3.2 \ No newline at end of file +v=3.2 From a69d62800bcd54993f43ea1ac34a8f6897df4d49 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Sun, 24 May 2026 07:15:42 +0800 Subject: [PATCH 14/16] Implement new features and fixes for Gacha and ClimbTower (#7) * Added a warning against scam. Die, scammers! * Gacha * Gacha_UpSelect * Update version.txt * Fixed an issue where adding too many support cards could prevent users from logging in. * Update version.txt * Character episode chapters are now playable. * Implement BossPvP logic (I implemented this based on undownding's code. Thank you!) * Update version.txt * small fix * Added functionality to Rogue3D * Update version.txt * GirlCard_UpBySpecialBreak * Update version.txt * ClimbTowerLogic_CheckCycleLevel TowerLevel_EnterLevel * ExtendFightDynamicLog ExtendFightLog * TowerLevel_LevelSettlement ClimbTowerLogic_RecordProgres * Update version.txt * ClimbTowerLogic_GetReward * ClimbTowerLogic_SetLevelDiff --------- Co-authored-by: Kei-Luna Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../Data/Excel/BossPvpBossChallengeExcel.cs | 32 + Common/Data/Excel/BossPvpBossExcel.cs | 17 + Common/Data/Excel/BossPvpNumExcel.cs | 15 + Common/Data/Excel/ClimbTowerAwardExcel.cs | 56 ++ Common/Data/Excel/ClimbTowerDiffExcel.cs | 18 + .../Data/Excel/ClimbTowerLevelOrderExcel.cs | 17 + Common/Data/Excel/ClimbTowerTimeExcel.cs | 57 ++ Common/Data/Excel/GachaExcel.cs | 46 ++ Common/Data/Excel/GachaProbabilityExcel.cs | 18 + Common/Data/Excel/OtherItemExcel.cs | 38 ++ Common/Data/Excel/RoleLevelExcel.cs | 14 + Common/Data/Excel/SpecialBreakExcel.cs | 39 ++ Common/Data/Excel/TowerLevelExcel.cs | 20 + Common/Data/GameData.cs | 14 + GameServer/Game/BossPvp/BossPvpService.cs | 498 ++++++++++++++++ GameServer/Game/Player/PlayerInstance.cs | 15 +- .../BossPvp/BossPvpLogic_EnterLevel.cs | 13 + .../BossPvp/BossPvpLogic_GetOpenID.cs | 13 + .../BossPvp/BossPvpLogic_GetReward.cs | 13 + .../BossPvp/BossPvpLogic_LevelFail.cs | 14 + .../BossPvp/BossPvpLogic_LevelMopup.cs | 13 + .../BossPvp/BossPvpLogic_LevelSettlement.cs | 14 + .../Handlers/BossPvp/BossPvpLogic_Record.cs | 13 + .../Chapter/Chapter_DealLevelSettlement.cs | 33 +- .../CallGS/Handlers/Gacha/Gacha_Launch.cs | 550 +++++++++++++++++- .../CallGS/Handlers/Gacha/Gacha_UpSelect.cs | 82 +++ .../Girl/GirlCard_UpBySpecialBreak.cs | 127 ++++ .../Handlers/Misc/ExtendFightDynamicLog.cs | 10 + .../CallGS/Handlers/Misc/ExtendFightLog.cs | 10 + .../Rogue3D/Rogue3D_EnterSeasonLevel.cs | 71 +++ .../Rogue3D/Rogue3D_SelectSeasonTalent.cs | 47 ++ .../Tower/ClimbTowerLogic_CheckCycleLevel.cs | 118 ++++ .../Tower/ClimbTowerLogic_GetReward.cs | 449 ++++++++++++++ .../Tower/ClimbTowerLogic_RecordProgres.cs | 219 +++++++ .../Tower/ClimbTowerLogic_SetLevelDiff.cs | 76 +++ .../Handlers/Tower/TowerLevel_EnterLevel.cs | 39 ++ .../Tower/TowerLevel_LevelSettlement.cs | 178 ++++++ .../Packet/Recv/Login/HandlerReqLogin.cs | 22 +- .../Packet/Send/Login/PacketRspLogin.cs | 27 +- MikuSB/Program/MikuSB.cs | 13 +- README.md | 6 + version.txt | 2 +- 42 files changed, 3060 insertions(+), 26 deletions(-) create mode 100644 Common/Data/Excel/BossPvpBossChallengeExcel.cs create mode 100644 Common/Data/Excel/BossPvpBossExcel.cs create mode 100644 Common/Data/Excel/BossPvpNumExcel.cs create mode 100644 Common/Data/Excel/ClimbTowerAwardExcel.cs create mode 100644 Common/Data/Excel/ClimbTowerDiffExcel.cs create mode 100644 Common/Data/Excel/ClimbTowerLevelOrderExcel.cs create mode 100644 Common/Data/Excel/ClimbTowerTimeExcel.cs create mode 100644 Common/Data/Excel/GachaExcel.cs create mode 100644 Common/Data/Excel/GachaProbabilityExcel.cs create mode 100644 Common/Data/Excel/OtherItemExcel.cs create mode 100644 Common/Data/Excel/RoleLevelExcel.cs create mode 100644 Common/Data/Excel/SpecialBreakExcel.cs create mode 100644 Common/Data/Excel/TowerLevelExcel.cs create mode 100644 GameServer/Game/BossPvp/BossPvpService.cs create mode 100644 GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_EnterLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetOpenID.cs create mode 100644 GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetReward.cs create mode 100644 GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelFail.cs create mode 100644 GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelMopup.cs create mode 100644 GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelSettlement.cs create mode 100644 GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_Record.cs create mode 100644 GameServer/Server/CallGS/Handlers/Gacha/Gacha_UpSelect.cs create mode 100644 GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpBySpecialBreak.cs create mode 100644 GameServer/Server/CallGS/Handlers/Misc/ExtendFightDynamicLog.cs create mode 100644 GameServer/Server/CallGS/Handlers/Misc/ExtendFightLog.cs create mode 100644 GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_EnterSeasonLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_SelectSeasonTalent.cs create mode 100644 GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_CheckCycleLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_GetReward.cs create mode 100644 GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_RecordProgres.cs create mode 100644 GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_SetLevelDiff.cs create mode 100644 GameServer/Server/CallGS/Handlers/Tower/TowerLevel_EnterLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/Tower/TowerLevel_LevelSettlement.cs diff --git a/Common/Data/Excel/BossPvpBossChallengeExcel.cs b/Common/Data/Excel/BossPvpBossChallengeExcel.cs new file mode 100644 index 0000000..afdc459 --- /dev/null +++ b/Common/Data/Excel/BossPvpBossChallengeExcel.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/bosspvp/boss_challenge.json")] +public class BossPvpBossChallengeExcel : ExcelResource +{ + public uint ID { get; set; } + public string StartTime { get; set; } = ""; + public string EndTime { get; set; } = ""; + public List tbTaskID { get; set; } = []; + + [JsonExtensionData] public IDictionary ExtraData { get; set; } = new Dictionary(); + + [JsonIgnore] public List BossIds { get; private set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + BossIds = ExtraData + .Where(x => x.Key.StartsWith("Boss", StringComparison.Ordinal) && int.TryParse(x.Key[4..], out _)) + .OrderBy(x => int.Parse(x.Key[4..], CultureInfo.InvariantCulture)) + .Select(x => x.Value.Type == JTokenType.Integer ? x.Value.Value() : 0u) + .Where(x => x > 0) + .ToList(); + + GameData.BossPvpBossChallengeData[ID] = this; + } +} diff --git a/Common/Data/Excel/BossPvpBossExcel.cs b/Common/Data/Excel/BossPvpBossExcel.cs new file mode 100644 index 0000000..1f07907 --- /dev/null +++ b/Common/Data/Excel/BossPvpBossExcel.cs @@ -0,0 +1,17 @@ +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/bosspvp/boss.json")] +public class BossPvpBossExcel : ExcelResource +{ + public uint ID { get; set; } + public uint LevelID { get; set; } + public uint BossID { get; set; } + public List> BossLevel { get; set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.BossPvpBossData[ID] = this; + } +} diff --git a/Common/Data/Excel/BossPvpNumExcel.cs b/Common/Data/Excel/BossPvpNumExcel.cs new file mode 100644 index 0000000..85d49d9 --- /dev/null +++ b/Common/Data/Excel/BossPvpNumExcel.cs @@ -0,0 +1,15 @@ +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/bosspvp/num.json")] +public class BossPvpNumExcel : ExcelResource +{ + public uint Week { get; set; } + public uint Num { get; set; } + + public override uint GetId() => Week; + + public override void Loaded() + { + GameData.BossPvpNumData[Week] = this; + } +} diff --git a/Common/Data/Excel/ClimbTowerAwardExcel.cs b/Common/Data/Excel/ClimbTowerAwardExcel.cs new file mode 100644 index 0000000..c33013a --- /dev/null +++ b/Common/Data/Excel/ClimbTowerAwardExcel.cs @@ -0,0 +1,56 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/climb_tower_award.json")] +public class ClimbTowerAwardExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("Diff")] public JToken? DiffRaw { get; set; } + [JsonProperty("FirstAward")] public List> FirstAward { get; set; } = []; + [JsonProperty("StarCount1")] public int StarCount1 { get; set; } + [JsonProperty("StarAward1")] public List> StarAward1 { get; set; } = []; + [JsonProperty("StarCount2")] public int StarCount2 { get; set; } + [JsonProperty("StarAward2")] public List> StarAward2 { get; set; } = []; + [JsonProperty("StarCount3")] public int StarCount3 { get; set; } + [JsonProperty("StarAward3")] public List> StarAward3 { get; set; } = []; + + [JsonIgnore] + public int Diff => DiffRaw?.Type switch + { + JTokenType.Integer => Math.Max(1, DiffRaw.Value()), + JTokenType.String when int.TryParse(DiffRaw.Value(), out var value) => Math.Max(1, value), + _ => 1 + }; + + public override uint GetId() => (ID * 10u) + (uint)Diff; + + public override void Loaded() + { + if (!GameData.ClimbTowerAwardData.TryGetValue(ID, out var diffMap)) + { + diffMap = []; + GameData.ClimbTowerAwardData[ID] = diffMap; + } + + diffMap[Diff] = this; + } + + public int GetStarCount(int group) => group switch + { + 1 => StarCount1, + 2 => StarCount2, + 3 => StarCount3, + _ => 0 + }; + + public IReadOnlyList> GetRewards(int group) => group switch + { + 0 => FirstAward, + 1 => StarAward1, + 2 => StarAward2, + 3 => StarAward3, + _ => [] + }; +} diff --git a/Common/Data/Excel/ClimbTowerDiffExcel.cs b/Common/Data/Excel/ClimbTowerDiffExcel.cs new file mode 100644 index 0000000..6f89b4d --- /dev/null +++ b/Common/Data/Excel/ClimbTowerDiffExcel.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/climb_tower_diff.json")] +public class ClimbTowerDiffExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("Level1")] public int Level1 { get; set; } + [JsonProperty("Level2")] public int Level2 { get; set; } + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.ClimbTowerDiffData[ID] = this; + } +} diff --git a/Common/Data/Excel/ClimbTowerLevelOrderExcel.cs b/Common/Data/Excel/ClimbTowerLevelOrderExcel.cs new file mode 100644 index 0000000..9e4fea1 --- /dev/null +++ b/Common/Data/Excel/ClimbTowerLevelOrderExcel.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/climb_tower_levelorder.json")] +public class ClimbTowerLevelOrderExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("LevelID")] public uint LevelID { get; set; } + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.ClimbTowerLevelOrderData[ID] = this; + } +} diff --git a/Common/Data/Excel/ClimbTowerTimeExcel.cs b/Common/Data/Excel/ClimbTowerTimeExcel.cs new file mode 100644 index 0000000..da1aed0 --- /dev/null +++ b/Common/Data/Excel/ClimbTowerTimeExcel.cs @@ -0,0 +1,57 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/climb_tower_time.json")] +public class ClimbTowerTimeExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("Level1")] public List> Level1 { get; set; } = []; + [JsonProperty("Level2")] public JToken? Level2Raw { get; set; } + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.ClimbTowerTimeData[ID] = this; + } + + public IReadOnlyList> GetLevelGroups(int type) + { + if (type == 1) + return Level1; + + if (Level2Raw == null) + return []; + + if (Level2Raw.Type == JTokenType.Array) + { + return Level2Raw + .Children() + .OfType() + .Select(x => (IReadOnlyList)x.Values().ToList()) + .ToList(); + } + + if (Level2Raw.Type == JTokenType.Object) + { + return Level2Raw + .Children() + .Select(x => new + { + Key = uint.TryParse(x.Name, CultureInfo.InvariantCulture, out var key) ? key : 0u, + Value = x.Value.Type == JTokenType.Integer ? x.Value.Value() : 0u + }) + .Where(x => x.Key > 0 && x.Value > 0) + .OrderBy(x => x.Key) + .Select(x => (IReadOnlyList)new List { x.Key, x.Value }) + .ToList(); + } + + return []; + } +} diff --git a/Common/Data/Excel/GachaExcel.cs b/Common/Data/Excel/GachaExcel.cs new file mode 100644 index 0000000..03f2715 --- /dev/null +++ b/Common/Data/Excel/GachaExcel.cs @@ -0,0 +1,46 @@ +using MikuSB.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("gacha/gacha.json")] +public class GachaExcel : ExcelResource +{ + public uint ID { get; set; } + public List? Pool { get; set; } + public uint Probability { get; set; } + public uint ProbabilityTen { get; set; } + public JToken? ProtectNum { get; set; } + public JToken? UpNum { get; set; } + public uint? ProtectTag { get; set; } + public uint? ProtectType { get; set; } + public JToken? ProtectCount { get; set; } + public uint? UpSelect { get; set; } + + public override uint GetId() => ID; + public override void Loaded() => GameData.GachaData[ID] = this; + + public override void AfterAllDone() + { + foreach (var poolName in Pool ?? []) + { + if (GameData.GachaPoolData.ContainsKey(poolName)) continue; + var path = ConfigManager.Config.Path.ResourcePath + "/gacha/pool/" + poolName + ".json"; + if (!File.Exists(path)) continue; + var json = File.ReadAllText(path); + var items = JsonConvert.DeserializeObject>(json) ?? []; + GameData.GachaPoolData[poolName] = items; + } + } +} + +public class GachaPoolItem +{ + public int ID { get; set; } + public int Rarity { get; set; } + public List GDPL { get; set; } = []; + public int Weight { get; set; } + public int? UPTag { get; set; } + public int? UPSelectTag { get; set; } +} diff --git a/Common/Data/Excel/GachaProbabilityExcel.cs b/Common/Data/Excel/GachaProbabilityExcel.cs new file mode 100644 index 0000000..d797989 --- /dev/null +++ b/Common/Data/Excel/GachaProbabilityExcel.cs @@ -0,0 +1,18 @@ +namespace MikuSB.Data.Excel; + +[ResourceEntity("gacha/probability.json")] +public class GachaProbabilityExcel : ExcelResource +{ + public uint ID { get; set; } + public int Rarity1 { get; set; } + public int Rarity2 { get; set; } + public int Rarity3 { get; set; } + public int Rarity4 { get; set; } + public int Rarity5 { get; set; } + public int Rarity6 { get; set; } + + public int[] Weights => [Rarity1, Rarity2, Rarity3, Rarity4, Rarity5, Rarity6]; + + public override uint GetId() => ID; + public override void Loaded() => GameData.GachaProbabilityData[ID] = this; +} diff --git a/Common/Data/Excel/OtherItemExcel.cs b/Common/Data/Excel/OtherItemExcel.cs new file mode 100644 index 0000000..685c0a1 --- /dev/null +++ b/Common/Data/Excel/OtherItemExcel.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("item/templates/others.json")] +public class OtherItemExcel : ExcelResource +{ + public uint Genre { get; set; } + public uint Detail { get; set; } + public uint Particular { get; set; } + public uint Level { get; set; } + [JsonProperty("GMnum")] public JToken? GMnumRaw { get; set; } + + [JsonIgnore] + public uint GMnum => ReadUInt(GMnumRaw); + + public override uint GetId() => (uint)GameResourceTemplateId.FromGdpl(Genre, Detail, Particular, Level); + + public override void Loaded() + { + GameData.OtherItemData[GetId()] = this; + } + + private static uint ReadUInt(JToken? token) + { + if (token == null) + return 0; + + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => (uint)Math.Max(0, token.Value()), + JTokenType.String when uint.TryParse(token.Value(), out var value) => value, + _ => 0 + }; + } +} diff --git a/Common/Data/Excel/RoleLevelExcel.cs b/Common/Data/Excel/RoleLevelExcel.cs new file mode 100644 index 0000000..fddf417 --- /dev/null +++ b/Common/Data/Excel/RoleLevelExcel.cs @@ -0,0 +1,14 @@ +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/role/level.json")] +public class RoleLevelExcel : ExcelResource +{ + public uint ID { get; set; } + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.RoleLevelData[ID] = this; + } +} diff --git a/Common/Data/Excel/SpecialBreakExcel.cs b/Common/Data/Excel/SpecialBreakExcel.cs new file mode 100644 index 0000000..a1919f3 --- /dev/null +++ b/Common/Data/Excel/SpecialBreakExcel.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("item/cardbreak/breaknew.json")] +public class SpecialBreakExcel : ExcelResource +{ + [JsonProperty("ID")] public int Id { get; set; } + + [JsonProperty("1Items1")] public List> Items1 { get; set; } = []; + [JsonProperty("2Items1")] public List> Items2 { get; set; } = []; + [JsonProperty("3Items1")] public List> Items3 { get; set; } = []; + [JsonProperty("4Items1")] public List> Items4 { get; set; } = []; + + public List> GetItems(uint breakLevel) => breakLevel switch + { + 1 => Items1, + 2 => Items2, + 3 => Items3, + 4 => Items4, + _ => [] + }; + + public bool HasBreakLevel(uint breakLevel) => breakLevel switch + { + 1 => Items1.Count > 0, + 2 => Items2.Count > 0, + 3 => Items3.Count > 0, + 4 => Items4.Count > 0, + _ => false + }; + + public override uint GetId() => (uint)Id; + + public override void Loaded() + { + GameData.SpecialBreakData[Id] = this; + } +} diff --git a/Common/Data/Excel/TowerLevelExcel.cs b/Common/Data/Excel/TowerLevelExcel.cs new file mode 100644 index 0000000..0163f8f --- /dev/null +++ b/Common/Data/Excel/TowerLevelExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/climbtower/level.json")] +public class TowerLevelExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("MapID")] public uint MapID { get; set; } + [JsonProperty("FightID")] public uint FightID { get; set; } + [JsonProperty("TaskPath")] public string TaskPath { get; set; } = ""; + [JsonProperty("ConsumeVigor")] public List ConsumeVigor { get; set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.TowerLevelData[ID] = this; + } +} diff --git a/Common/Data/GameData.cs b/Common/Data/GameData.cs index 79a9fac..c54c2ea 100644 --- a/Common/Data/GameData.cs +++ b/Common/Data/GameData.cs @@ -13,6 +13,7 @@ public static class GameData public static Dictionary BreakLevelLimitData { get; private set; } = []; public static Dictionary RecycleData { get; private set; } = []; public static Dictionary ChapterLevelData { get; private set; } = []; + public static Dictionary RoleLevelData { get; private set; } = []; public static Dictionary ArItemData { get; private set; } = []; public static Dictionary ManifestationData { get; private set; } = []; public static Dictionary Rogue3DDifficultData { get; private set; } = []; @@ -20,6 +21,7 @@ public static class GameData public static Dictionary Rogue3DTalentData { get; private set; } = []; public static Dictionary Rogue3DDailyBuffData { get; private set; } = []; public static Dictionary BreakData { get; private set; } = []; + public static Dictionary SpecialBreakData { get; private set; } = []; public static Dictionary SpineData { get; private set; } = []; public static Dictionary NodeConditionData { get; private set; } = []; public static List SupportCardData { get; private set; } = []; @@ -28,6 +30,15 @@ public static class GameData public static Dictionary SupportFixedData { get; private set; } = []; public static Dictionary WeaponSkinData { get; private set; } = []; public static Dictionary DailyLevelData { get; private set; } = []; + public static Dictionary BossPvpBossChallengeData { get; private set; } = []; + public static Dictionary BossPvpBossData { get; private set; } = []; + public static Dictionary BossPvpNumData { get; private set; } = []; + public static Dictionary ClimbTowerTimeData { get; private set; } = []; + public static Dictionary ClimbTowerDiffData { get; private set; } = []; + public static Dictionary> ClimbTowerAwardData { get; private set; } = []; + public static Dictionary ClimbTowerLevelOrderData { get; private set; } = []; + public static Dictionary TowerLevelData { get; private set; } = []; + public static Dictionary OtherItemData { get; private set; } = []; public static Dictionary ProfileData { get; private set; } = []; public static Dictionary CardSkinPartsData { get; private set; } = []; public static Dictionary CallItemData { get; private set; } = []; @@ -35,6 +46,9 @@ public static class GameData public static Dictionary GuideData { get; private set; } = []; public static Dictionary DormGiftData { get; private set; } = []; public static Dictionary HouseFurniturePosData { get; private set; } = []; + public static Dictionary GachaData { get; private set; } = []; + public static Dictionary GachaProbabilityData { get; private set; } = []; + public static Dictionary> GachaPoolData { get; private set; } = []; } public static class GameResourceTemplateId diff --git a/GameServer/Game/BossPvp/BossPvpService.cs b/GameServer/Game/BossPvp/BossPvpService.cs new file mode 100644 index 0000000..2914a9f --- /dev/null +++ b/GameServer/Game/BossPvp/BossPvpService.cs @@ -0,0 +1,498 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Inventory; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Game.BossPvp; + +internal static class BossPvpService +{ + private const uint GroupId = 51; + private const uint ActivitySubId = 0; + private const uint ChallengeNumSid = 1; + private const uint DiffStartId = 10; + private const uint LevelStartSid = 100; + private const uint LevelStride = 10; + private const uint BossLineup1 = 15; + private const uint BossLineup2 = 16; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static async ValueTask<(object Response, NtfSyncPlayer Sync)> HandleGetOpenIdAsync(PlayerInstance player) + { + await EnsureBossLineupsAsync(player); + + var sync = new NtfSyncPlayer(); + var season = GetOpenSeason(); + var seasonId = season?.ID ?? 1u; + + SetStr(player, ActivitySubId, seasonId.ToString(CultureInfo.InvariantCulture), sync); + SetStr(player, ChallengeNumSid, GetDailyChallengeNum().ToString(CultureInfo.InvariantCulture), sync); + + if (season != null) + { + for (var index = 0; index < season.BossIds.Count; index++) + { + var bossLevelId = season.BossIds[index]; + EnsureStr(player, DiffStartId + (uint)(index + 1), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 1), EmptySnapshotJson(), sync); + EnsureStr(player, GetBossSid(bossLevelId, 2), EmptySnapshotJson(), sync); + EnsureStr(player, GetBossSid(bossLevelId, 3), EmptySnapshotJson(), sync); + EnsureStr(player, GetBossSid(bossLevelId, 4), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 5), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 6), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 7), "0", sync); + EnsureStr(player, GetBossSid(bossLevelId, 8), "0", sync); + } + } + + var response = new + { + nID = seasonId, + tbTimeCfg = new[] + { + new + { + nStartTime = -1, + nEndTime = -1 + } + } + }; + + return (response, sync); + } + + public static object HandleEnterLevel(string? param) + { + var req = Deserialize(param); + return new + { + nSeed = Random.Shared.Next(1, int.MaxValue), + nID = req?.NId ?? 0 + }; + } + + public static (object Response, NtfSyncPlayer Sync) HandleRecord(PlayerInstance player, string? param) + { + var req = Deserialize(param); + if (req == null) + { + return (new { bRecord = false }, new NtfSyncPlayer()); + } + + var sync = new NtfSyncPlayer(); + if (req.BRecord) + { + var historyScore = ReadInt(player, GetBossSid(req.NId, 4)); + var currentScore = ComputeIntegral(req.NId, req.NDiff, req.ResidueTime); + if (currentScore >= historyScore) + { + WriteBestRun(player, req.NId, req.NTeamId, req.NTime, currentScore, sync); + } + } + + return (new { bRecord = req.BRecord }, sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? param) + { + var req = param?.Deserialize(JsonOptions); + var sync = new NtfSyncPlayer(); + if (req == null) + { + return (new JsonObject(), sync); + } + + var totalSid = GetBossSid(req.NId, 7); + var successSid = GetBossSid(req.NId, 6); + var diffSid = GetBossSid(req.NId, 8); + + SetStr(player, totalSid, (ReadInt(player, totalSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + SetStr(player, successSid, (ReadInt(player, successSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + + var clearedDiff = Math.Max(ReadInt(player, diffSid), req.NDiff); + SetStr(player, diffSid, clearedDiff.ToString(CultureInfo.InvariantCulture), sync); + + var positionSid = TryGetPositionDiffSid(req.NId); + if (positionSid != null) + { + var newPositionDiff = Math.Max(ReadInt(player, positionSid.Value), req.NDiff); + SetStr(player, positionSid.Value, newPositionDiff.ToString(CultureInfo.InvariantCulture), sync); + } + + var score = ComputeIntegral(req.NId, req.NDiff, req.ResidueTime); + if (score > ReadInt(player, GetBossSid(req.NId, 4))) + { + WriteBestRun(player, req.NId, req.NTeamId, req.NTime, score, sync); + } + + return (new JsonObject(), sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleFail(PlayerInstance player, JsonNode? param) + { + var req = param?.Deserialize(JsonOptions); + var sync = new NtfSyncPlayer(); + if (req == null) + { + return (new JsonObject(), sync); + } + + var totalSid = GetBossSid(req.NId, 7); + SetStr(player, totalSid, (ReadInt(player, totalSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + + return (new JsonObject(), sync); + } + + public static (object Response, NtfSyncPlayer Sync) HandleMopup(PlayerInstance player, string? param) + { + var req = Deserialize(param); + var sync = new NtfSyncPlayer(); + if (req == null) + { + return (new { }, sync); + } + + var totalSid = GetBossSid(req.NId, 7); + var successSid = GetBossSid(req.NId, 6); + var diffSid = GetBossSid(req.NId, 8); + + SetStr(player, totalSid, (ReadInt(player, totalSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + SetStr(player, successSid, (ReadInt(player, successSid) + 1).ToString(CultureInfo.InvariantCulture), sync); + + var clearedDiff = Math.Max(ReadInt(player, diffSid), req.NDiff); + SetStr(player, diffSid, clearedDiff.ToString(CultureInfo.InvariantCulture), sync); + + var positionSid = TryGetPositionDiffSid(req.NId); + if (positionSid != null) + { + var newPositionDiff = Math.Max(ReadInt(player, positionSid.Value), req.NDiff + 1); + SetStr(player, positionSid.Value, newPositionDiff.ToString(CultureInfo.InvariantCulture), sync); + } + + var score = ComputeIntegral(req.NId, req.NDiff, 0); + if (score > ReadInt(player, GetBossSid(req.NId, 4))) + { + WriteBestRun(player, req.NId, 0, 0, score, sync); + } + + return (new { }, sync); + } + + public static object HandleGetReward(string? param) + { + _ = Deserialize(param); + return new { tbAward = Array.Empty() }; + } + + private static async ValueTask EnsureBossLineupsAsync(PlayerInstance player) + { + var lineups = player.LineupManager.LineupData.LineupInfo; + var baseLineup = lineups.GetValueOrDefault(1) ?? lineups.Values.FirstOrDefault(); + if (baseLineup == null) + { + return; + } + + if (!lineups.ContainsKey((int)BossLineup1)) + { + await player.LineupManager.UpdateLineup((int)BossLineup1, baseLineup.Member1, baseLineup.Member2, baseLineup.Member3, true); + } + + if (!lineups.ContainsKey((int)BossLineup2)) + { + await player.LineupManager.UpdateLineup((int)BossLineup2, baseLineup.Member1, baseLineup.Member2, baseLineup.Member3, true); + } + } + + private static void WriteBestRun(PlayerInstance player, uint bossLevelId, uint lineupId, double finishTime, int score, NtfSyncPlayer sync) + { + var snapshots = CaptureLineupSnapshots(player, lineupId); + SetStr(player, GetBossSid(bossLevelId, 1), System.Text.Json.JsonSerializer.Serialize(snapshots[0], JsonOptions), sync); + SetStr(player, GetBossSid(bossLevelId, 2), System.Text.Json.JsonSerializer.Serialize(snapshots[1], JsonOptions), sync); + SetStr(player, GetBossSid(bossLevelId, 3), System.Text.Json.JsonSerializer.Serialize(snapshots[2], JsonOptions), sync); + SetStr(player, GetBossSid(bossLevelId, 4), score.ToString(CultureInfo.InvariantCulture), sync); + SetStr(player, GetBossSid(bossLevelId, 5), Math.Max(0, (int)Math.Floor(finishTime)).ToString(CultureInfo.InvariantCulture), sync); + } + + private static BossPvpRoleSnapshot[] CaptureLineupSnapshots(PlayerInstance player, uint lineupId) + { + var lineups = player.LineupManager.LineupData.LineupInfo; + var lineup = lineups.GetValueOrDefault((int)lineupId) + ?? lineups.GetValueOrDefault((int)BossLineup1) + ?? lineups.GetValueOrDefault(1) + ?? lineups.Values.FirstOrDefault(); + + if (lineup == null) + { + return [new(), new(), new()]; + } + + return + [ + CaptureRoleSnapshot(player, lineup.Member1), + CaptureRoleSnapshot(player, lineup.Member2), + CaptureRoleSnapshot(player, lineup.Member3) + ]; + } + + private static BossPvpRoleSnapshot CaptureRoleSnapshot(PlayerInstance player, uint characterGuid) + { + if (characterGuid == 0) + { + return new BossPvpRoleSnapshot(); + } + + var character = player.CharacterManager.GetCharacterByGUID(characterGuid); + if (character == null) + { + return new BossPvpRoleSnapshot(); + } + + var snapshot = new BossPvpRoleSnapshot + { + Role = character.Guid, + Weapon = character.WeaponUniqueId + }; + + var weapon = player.InventoryManager.GetWeaponItem(character.WeaponUniqueId); + if (weapon != null) + { + snapshot.Wgdpl = BuildWeaponGdpl(weapon); + snapshot.Wslot = weapon.PartSlots; + } + + var supports = character.SupportSlots + .OrderBy(x => x.Key) + .Select(x => x.Value) + .Where(x => x != 0) + .Take(3) + .ToArray(); + + if (supports.Length > 0) + { + snapshot.S1 = supports[0]; + snapshot.Sgdpl1 = BuildSupportGdpl(player.InventoryManager.GetSupportCardItem(supports[0])); + } + + if (supports.Length > 1) + { + snapshot.S2 = supports[1]; + snapshot.Sgdpl2 = BuildSupportGdpl(player.InventoryManager.GetSupportCardItem(supports[1])); + } + + if (supports.Length > 2) + { + snapshot.S3 = supports[2]; + snapshot.Sgdpl3 = BuildSupportGdpl(player.InventoryManager.GetSupportCardItem(supports[2])); + } + + return snapshot; + } + + private static List BuildWeaponGdpl(GameWeaponInfo weapon) + { + var gdpl = DecodeGdpl(weapon.TemplateId); + gdpl.Add(weapon.Level); + gdpl.Add(weapon.Evolue); + return gdpl; + } + + private static List BuildSupportGdpl(GameSupportCardInfo? support) + { + if (support == null) + { + return []; + } + + var gdpl = DecodeGdpl(support.TemplateId); + gdpl.Add(support.Level); + gdpl.Add(0); + return gdpl; + } + + private static List DecodeGdpl(ulong templateId) + { + return + [ + (uint)(templateId & 0xFFFF), + (uint)((templateId >> 16) & 0xFFFF), + (uint)((templateId >> 32) & 0xFFFF), + (uint)((templateId >> 48) & 0xFFFF) + ]; + } + + private static int ComputeIntegral(uint bossLevelId, int diff, int residueTime) + { + if (!GameData.BossPvpBossData.TryGetValue(bossLevelId, out var boss) || diff <= 0 || diff > boss.BossLevel.Count) + { + return 0; + } + + var info = boss.BossLevel[diff - 1]; + if (info.Count == 0) + { + return 0; + } + + var multiplier = info.Count > 2 ? info[2] : 0; + var baseScore = info.Count > 3 ? info[3] : 0; + var residueScore = info.Count > 4 ? info[4] : 0; + var total = (baseScore + residueScore * Math.Max(0, residueTime)) * multiplier; + return (int)Math.Floor(total + 0.5); + } + + private static uint? TryGetPositionDiffSid(uint bossLevelId) + { + var season = GetOpenSeason(); + if (season == null) + { + return null; + } + + var index = season.BossIds.FindIndex(x => x == bossLevelId); + return index >= 0 ? DiffStartId + (uint)(index + 1) : null; + } + + private static BossPvpBossChallengeExcel? GetOpenSeason() + { + var now = DateTimeOffset.Now; + var current = GameData.BossPvpBossChallengeData.Values + .OrderBy(x => x.ID) + .FirstOrDefault(x => + { + var startAt = ParseBossTime(x.StartTime); + var endAt = ParseBossTime(x.EndTime); + return startAt != null && endAt != null && now >= startAt && now <= endAt; + }); + + return current ?? GameData.BossPvpBossChallengeData.Values.OrderBy(x => x.ID).FirstOrDefault(); + } + + private static uint GetDailyChallengeNum() + { + var now = DateTime.Now; + if (now.Hour < 4) + { + now = now.AddHours(-4); + } + + var week = now.DayOfWeek == DayOfWeek.Sunday ? 7 : (int)now.DayOfWeek; + return GameData.BossPvpNumData.TryGetValue((uint)week, out var count) ? count.Num : 8; + } + + private static int ReadInt(PlayerInstance player, uint sid) + { + var attr = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid)?.Val; + return int.TryParse(attr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : 0; + } + + private static void EnsureStr(PlayerInstance player, uint sid, string value, NtfSyncPlayer sync) + { + var attr = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + { + return; + } + + SetStr(player, sid, value, sync); + } + + private static void SetStr(PlayerInstance player, uint sid, string value, NtfSyncPlayer sync) + { + player.SetStrAttr(GroupId, sid, value); + sync.CustomStr[player.ToShiftedAttrKey(GroupId, sid)] = value; + } + + private static uint GetBossSid(uint bossLevelId, uint offset) => (LevelStride * bossLevelId) + LevelStartSid + offset; + + private static string EmptySnapshotJson() => System.Text.Json.JsonSerializer.Serialize(new BossPvpRoleSnapshot(), JsonOptions); + + private static T? Deserialize(string? param) + { + if (string.IsNullOrWhiteSpace(param)) + { + return default; + } + + return System.Text.Json.JsonSerializer.Deserialize(param, JsonOptions); + } + + private static DateTimeOffset? ParseBossTime(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var raw = value.Trim().Trim('[', ']'); + if (!DateTime.TryParseExact(raw, "yyyyMMddHHmm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var localTime)) + { + return null; + } + + return new DateTimeOffset(localTime); + } + + private sealed class EnterLevelParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + } + + private sealed class RecordParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + [JsonPropertyName("nDiff")] public int NDiff { get; set; } + [JsonPropertyName("nTime")] public double NTime { get; set; } + [JsonPropertyName("ResidueTime")] public int ResidueTime { get; set; } + [JsonPropertyName("bRecord")] public bool BRecord { get; set; } + [JsonPropertyName("nTeamID")] public uint NTeamId { get; set; } + } + + private sealed class SettlementParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + [JsonPropertyName("nDiff")] public int NDiff { get; set; } + [JsonPropertyName("nTime")] public double NTime { get; set; } + [JsonPropertyName("ResidueTime")] public int ResidueTime { get; set; } + [JsonPropertyName("nTeamID")] public uint NTeamId { get; set; } + } + + private sealed class FailParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + } + + private sealed class MopupParam + { + [JsonPropertyName("nID")] public uint NId { get; set; } + [JsonPropertyName("nDiff")] public int NDiff { get; set; } + } + + private sealed class RewardParam + { + [JsonPropertyName("tbTaskID")] public List TaskIds { get; set; } = []; + } + + private sealed class BossPvpRoleSnapshot + { + [JsonPropertyName("role")] public uint Role { get; set; } + [JsonPropertyName("weapon")] public uint Weapon { get; set; } + [JsonPropertyName("s1")] public uint S1 { get; set; } + [JsonPropertyName("s2")] public uint S2 { get; set; } + [JsonPropertyName("s3")] public uint S3 { get; set; } + [JsonPropertyName("wgdpl")] public List Wgdpl { get; set; } = []; + [JsonPropertyName("wslot")] public Dictionary Wslot { get; set; } = []; + [JsonPropertyName("sgdpl1")] public List Sgdpl1 { get; set; } = []; + [JsonPropertyName("sgdpl2")] public List Sgdpl2 { get; set; } = []; + [JsonPropertyName("sgdpl3")] public List Sgdpl3 { get; set; } = []; + } +} diff --git a/GameServer/Game/Player/PlayerInstance.cs b/GameServer/Game/Player/PlayerInstance.cs index 24ea4f4..7bf4d61 100644 --- a/GameServer/Game/Player/PlayerInstance.cs +++ b/GameServer/Game/Player/PlayerInstance.cs @@ -282,7 +282,7 @@ public PlayerProfile ToServerFriendProto() return proto; } - public Proto.Player ToPlayerProto() + public Proto.Player ToPlayerProto(bool includeSupportCards = true) { BuildPlayerAttr(); var displayName = PlayerGameData.NormalizeDisplayName(Data.Name); @@ -303,7 +303,10 @@ public Proto.Player ToPlayerProto() foreach (var item in InventoryManager.InventoryData.Items.Values) proto.Items.Add(item.ToProto()); foreach (var skin in InventoryManager.InventoryData.Skins.Values) proto.Items.Add(skin.ToProto()); foreach (var weapon in InventoryManager.InventoryData.Weapons.Values) proto.Items.Add(weapon.ToProto()); - foreach (var card in InventoryManager.InventoryData.SupportCards.Values) proto.Items.Add(card.ToProto()); + if (includeSupportCards) + { + foreach (var card in InventoryManager.InventoryData.SupportCards.Values) proto.Items.Add(card.ToProto()); + } foreach (var x in Data.Attrs) { uint gid = x.Gid; @@ -508,6 +511,14 @@ public void BuildPlayerAttr(bool additional = false) yield return (22, levelId, 1_700_000_000); } + // Role fragment chapters use Condition.PRE_LEVEL against Launch.GPASSID as well. + // Mark every role level as cleared so character-specific stages beyond the first one unlock. + foreach (var levelId in GameData.RoleLevelData.Keys) + { + yield return (21, levelId, 7); + yield return (22, levelId, 1_700_000_000); + } + foreach (var guide in GameData.GuideData.Values) { yield return (4, guide.ID, 999); diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_EnterLevel.cs new file mode 100644 index 0000000..2a011e2 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_EnterLevel.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_EnterLevel")] +public class BossPvpLogic_EnterLevel : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var response = BossPvpService.HandleEnterLevel(param); + await CallGSRouter.SendScript(connection, "BossPvpLogic_EnterLevel", System.Text.Json.JsonSerializer.Serialize(response)); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetOpenID.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetOpenID.cs new file mode 100644 index 0000000..aa959a0 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetOpenID.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_GetOpenID")] +public class BossPvpLogic_GetOpenID : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = await BossPvpService.HandleGetOpenIdAsync(connection.Player!); + await CallGSRouter.SendScript(connection, "BossPvpLogic_GetOpenID", System.Text.Json.JsonSerializer.Serialize(response), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetReward.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetReward.cs new file mode 100644 index 0000000..bba3a86 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_GetReward.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_GetReward")] +public class BossPvpLogic_GetReward : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var response = BossPvpService.HandleGetReward(param); + await CallGSRouter.SendScript(connection, "BossPvpLogic_GetReward", System.Text.Json.JsonSerializer.Serialize(response)); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelFail.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelFail.cs new file mode 100644 index 0000000..a44e10d --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelFail.cs @@ -0,0 +1,14 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_LevelFail")] +public class BossPvpLogic_LevelFail : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var node = System.Text.Json.Nodes.JsonNode.Parse(param); + var (response, sync) = BossPvpService.HandleFail(connection.Player!, node); + await CallGSRouter.SendScript(connection, "BossPvpLogic_LevelFail", response.ToJsonString(), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelMopup.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelMopup.cs new file mode 100644 index 0000000..10ec06b --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelMopup.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_LevelMopup")] +public class BossPvpLogic_LevelMopup : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = BossPvpService.HandleMopup(connection.Player!, param); + await CallGSRouter.SendScript(connection, "BossPvpLogic_LevelMopup", System.Text.Json.JsonSerializer.Serialize(response), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelSettlement.cs new file mode 100644 index 0000000..a7f33d4 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_LevelSettlement.cs @@ -0,0 +1,14 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_LevelSettlement")] +public class BossPvpLogic_LevelSettlement : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var node = System.Text.Json.Nodes.JsonNode.Parse(param); + var (response, sync) = BossPvpService.HandleSettlement(connection.Player!, node); + await CallGSRouter.SendScript(connection, "BossPvpLogic_LevelSettlement", response.ToJsonString(), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_Record.cs b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_Record.cs new file mode 100644 index 0000000..d110b84 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/BossPvp/BossPvpLogic_Record.cs @@ -0,0 +1,13 @@ +using MikuSB.GameServer.Game.BossPvp; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.BossPvp; + +[CallGSApi("BossPvpLogic_Record")] +public class BossPvpLogic_Record : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = BossPvpService.HandleRecord(connection.Player!, param); + await CallGSRouter.SendScript(connection, "BossPvpLogic_Record", System.Text.Json.JsonSerializer.Serialize(response), sync); + } +} diff --git a/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs b/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs index 5237ae1..45b1a8a 100644 --- a/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs +++ b/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs @@ -1,6 +1,9 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using MikuSB.GameServer.Game.BossPvp; +using MikuSB.Proto; +using MikuSB.GameServer.Server.CallGS.Handlers.Tower; namespace MikuSB.GameServer.Server.CallGS.Handlers.Chapter; @@ -10,17 +13,20 @@ public class Chapter_DealLevelSettlement : ICallGSHandler public async Task Handle(Connection connection, string param, ushort seqNo) { var req = JsonSerializer.Deserialize(param); + NtfSyncPlayer? extraSync = null; var response = new JsonObject { ["sCmd"] = req?.SCmd ?? "Chapter_LevelSettlement", - ["tbParam"] = BuildSettlementPayload(req?.SCmd, req?.TbParam) + ["tbParam"] = BuildSettlementPayload(connection, req?.SCmd, req?.TbParam, out extraSync) }; - await CallGSRouter.SendScript(connection, "Chapter_DealLevelSettlement", response.ToJsonString()); + await CallGSRouter.SendScript(connection, "Chapter_DealLevelSettlement", response.ToJsonString(), extraSync!); } - private static JsonNode BuildSettlementPayload(string? sCmd, JsonNode? tbParam) + private static JsonNode BuildSettlementPayload(Connection connection, string? sCmd, JsonNode? tbParam, out NtfSyncPlayer? extraSync) { + extraSync = null; + if (string.Equals(sCmd, "Chapter_LevelSettlement", StringComparison.Ordinal)) { return new JsonArray(); @@ -37,6 +43,27 @@ private static JsonNode BuildSettlementPayload(string? sCmd, JsonNode? tbParam) return result; } + if (string.Equals(sCmd, "BossPvpLogic_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = BossPvpService.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } + + if (string.Equals(sCmd, "BossPvpLogic_LevelFail", StringComparison.Ordinal)) + { + var (response, sync) = BossPvpService.HandleFail(connection.Player!, tbParam); + extraSync = sync; + return response; + } + + if (string.Equals(sCmd, "TowerLevel_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = TowerLevel_LevelSettlement.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } + return tbParam?.DeepClone() ?? new JsonObject(); } } diff --git a/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs index 5e8aeea..98d82a0 100644 --- a/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs +++ b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_Launch.cs @@ -1,3 +1,12 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using Newtonsoft.Json.Linq; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -6,29 +15,538 @@ namespace MikuSB.GameServer.Server.CallGS.Handlers.Gacha; [CallGSApi("Gacha_Launch")] public class Gacha_Launch : ICallGSHandler { + private const uint GachaGid = 5; + private const uint GachaSgid = 42; + private const uint SidTotalTime = 1; + private const uint SidDailyTotalTime = 2; + private const uint Interval = 10; + private const uint SidTimeInheritStart = 20000; + private const uint SidTimeNotInheritStart = 10; + private const uint SidAddTimeItem = 1; + private const uint SidAddTimeProb = 2; + private const uint SidAddProtectType = 3; + private const uint SidAddTotalTime = 7; + private const int UpSelectIndex = 0; + private const int UpSelectGetFlagIndex = 1; + private static readonly Random Rng = new(); + public async Task Handle(Connection connection, string param, ushort seqNo) { - var gachaList = LoadGachaList(); - - var response = JsonSerializer.Serialize(new + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.NId == 0 || req.NTime is not (1 or 10)) + { + await CallGSRouter.SendScript(connection, "Gacha_Launch", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.GachaData.TryGetValue((uint)req.NId, out var gachaCfg)) + { + await CallGSRouter.SendScript(connection, "Gacha_Launch", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var poolNames = (gachaCfg.Pool ?? []) + .Where(GameData.GachaPoolData.ContainsKey) + .ToList(); + var allPoolItems = poolNames + .SelectMany(p => GameData.GachaPoolData[p]) + .ToList(); + + if (allPoolItems.Count == 0 || !GameData.GachaProbabilityData.TryGetValue(gachaCfg.Probability, out var baseProbCfg)) + { + await CallGSRouter.SendScript(connection, "Gacha_Launch", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var pityState = LoadPityState(player, gachaCfg); + var upSelectState = LoadUpSelectState(player, gachaCfg); + var config = BuildRuntimeConfig(gachaCfg, poolNames); + var awards = new List>(); + var tbNew = new List(); + var tbTrigger = new List(); + var syncItems = new List(); + var sync = new NtfSyncPlayer(); + + for (int i = 0; i < req.NTime; i++) + { + var forceTopUp = config.UpTarget != null && pityState.ProtectType == 2; + var hitHardPity = config.ProtectThreshold > 0 && pityState.ItemCount + 1 >= config.ProtectThreshold; + var useTenGuarantee = gachaCfg.ProbabilityTen != 0 + && pityState.TenCount + 1 >= 10 + && !HasGuaranteedTenRarity(config, awards); + + GachaProbabilityExcel probCfg = baseProbCfg; + if (useTenGuarantee && GameData.GachaProbabilityData.TryGetValue(gachaCfg.ProbabilityTen, out var tenProbCfg)) + probCfg = tenProbCfg; + + GachaPoolItem? item; + bool trigger = false; + + if (hitHardPity) + { + item = PickGuaranteedItem(gachaCfg, config, preferUp: forceTopUp); + trigger = item != null; + } + else + { + var rarity = RollRarity(probCfg); + item = forceTopUp && config.UpTarget != null && rarity >= config.TopRarity + ? PickGuaranteedItem(gachaCfg, config, preferUp: true) + : PickItem(allPoolItems, rarity); + trigger = forceTopUp && item != null && config.UpTarget != null && item.Rarity == config.UpTarget.Rarity; + } + + if (item != null && upSelectState.SelectedItem != null && item.Rarity >= config.TopRarity) + { + bool forceSelected = upSelectState.GuaranteedNext; + bool shouldSelect = forceSelected || Rng.Next(100) < 50; + if (shouldSelect) + { + var selectedItem = FindExactItem(allPoolItems, upSelectState.SelectedItem); + if (selectedItem != null && selectedItem.Rarity >= config.TopRarity) + item = selectedItem; + } + } + + if (item == null || item.GDPL.Count < 4) + { + tbTrigger.Add(false); + continue; + } + + var g = item.GDPL[0]; + var d = item.GDPL[1]; + var p = item.GDPL[2]; + var l = item.GDPL[3]; + + awards.Add([g, d, p, l]); + tbTrigger.Add(trigger); + + UpdatePityState(pityState, config, item); + UpdateUpSelectState(upSelectState, config, item); + + var itemType = (ItemTypeEnum)g; + switch (itemType) + { + case ItemTypeEnum.TYPE_CARD: + { + var alreadyOwned = player.CharacterManager.GetCharacterGDPL(itemType, (int)d, (int)p) != null; + if (!alreadyOwned) + { + var charInfo = await player.CharacterManager.AddCharacter(itemType, d, p, sendPacket: false); + if (charInfo != null) + { + syncItems.Add(charInfo.ToProto()); + tbNew.Add(awards.Count); + } + } + break; + } + case ItemTypeEnum.TYPE_WEAPON: + { + var weaponInfo = await player.InventoryManager.AddWeaponItem(itemType, d, p, l, sendPacket: false); + if (weaponInfo != null) syncItems.Add(weaponInfo.ToProto()); + break; + } + case ItemTypeEnum.TYPE_SUPPORT: + { + var cardInfo = await player.InventoryManager.AddSupportCardItem(d, p, l, sendPacket: false); + if (cardInfo != null) syncItems.Add(cardInfo.ToProto()); + break; + } + } + } + + if (awards.Count == 0) + { + await CallGSRouter.SendScript(connection, "Gacha_Launch", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + SavePityState(player, gachaCfg, pityState, awards.Count, sync); + SaveUpSelectState(player, gachaCfg, upSelectState, sync); + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + sync.Items.AddRange(syncItems); + + var rsp = BuildResponse(req.NId, awards, tbNew, tbTrigger); + await CallGSRouter.SendScript(connection, "Gacha_Launch", rsp, sync); + } + + private static bool HasGuaranteedTenRarity(GachaRuntimeConfig config, List> awards) + { + if (awards.Count == 0) + return false; + + int windowStart = awards.Count >= 9 ? awards.Count - 9 : 0; + for (int i = windowStart; i < awards.Count; i++) + { + var award = awards[i]; + if (award.Count < 4) + continue; + + var template = FindPoolItemByGdpl(config.AllPoolItems, award); + if (template != null && template.Rarity >= config.TenGuaranteeRarity) + return true; + } + + return false; + } + + private static GachaPoolItem? FindPoolItemByGdpl(List pool, List gdpl) => + pool.FirstOrDefault(x => + x.GDPL.Count >= 4 && + x.GDPL[0] == gdpl[0] && + x.GDPL[1] == gdpl[1] && + x.GDPL[2] == gdpl[2] && + x.GDPL[3] == gdpl[3]); + + private static GachaPoolItem? FindExactItem(List pool, uint[] gdpl) => + pool.FirstOrDefault(x => + x.GDPL.Count >= 4 && + x.GDPL[0] == gdpl[0] && + x.GDPL[1] == gdpl[1] && + x.GDPL[2] == gdpl[2] && + x.GDPL[3] == gdpl[3]); + + private static GachaRuntimeConfig BuildRuntimeConfig(GachaExcel gachaCfg, List poolNames) + { + var allPoolItems = poolNames.SelectMany(name => GameData.GachaPoolData[name]).ToList(); + var protectPools = ParsePoolRarities(gachaCfg.ProtectNum); + var upTarget = ParseSinglePoolRarity(gachaCfg.UpNum); + var topRarity = new[] { upTarget?.Rarity ?? 0 }.Concat(protectPools.Select(x => x.Rarity)).Max(); + if (topRarity <= 0) + topRarity = allPoolItems.Count == 0 ? 0 : allPoolItems.Max(x => x.Rarity); + + return new GachaRuntimeConfig + { + AllPoolItems = allPoolItems, + ProtectThreshold = ParseThreshold(gachaCfg.ProtectNum), + ProtectPools = protectPools, + UpTarget = upTarget, + TopRarity = topRarity, + TenGuaranteeRarity = 4 + }; + } + + private static int ParseThreshold(JToken? token) + { + if (token is not JArray arr || arr.Count == 0) + return 0; + + return arr[0]?.Value() ?? 0; + } + + private static List ParsePoolRarities(JToken? token) + { + var result = new List(); + if (token is not JArray arr || arr.Count < 2 || arr[1] is not JArray entries) + return result; + + foreach (var entry in entries.OfType()) { - tbGachaList = gachaList, - GachaList = gachaList - }); + if (entry.Count < 2) + continue; + + var poolName = entry[0]?.Value(); + var rarity = entry[1]?.Value() ?? 0; + if (string.IsNullOrWhiteSpace(poolName) || rarity <= 0) + continue; - await CallGSRouter.SendScript(connection, "Gacha_Launch", response); + result.Add(new PoolRarityRef(poolName, rarity)); + } + + return result; } - private static JsonElement[] LoadGachaList() + private static PoolRarityRef? ParseSinglePoolRarity(JToken? token) { - var path = Path.Combine(AppContext.BaseDirectory, "Resources", "gacha", "gacha.json"); - if (!File.Exists(path)) - return []; + if (token is not JArray arr || arr.Count < 2 || arr[1] is not JArray entry || entry.Count < 2) + return null; - using var doc = JsonDocument.Parse(File.ReadAllText(path)); - return doc.RootElement - .EnumerateArray() - .Select(row => row.Clone()) - .ToArray(); + var poolName = entry[0]?.Value(); + var rarity = entry[1]?.Value() ?? 0; + return string.IsNullOrWhiteSpace(poolName) || rarity <= 0 ? null : new PoolRarityRef(poolName, rarity); + } + + private static GachaPityState LoadPityState(PlayerInstance player, GachaExcel gachaCfg) + { + var baseSid = GetBaseSid(gachaCfg); + return new GachaPityState + { + ItemCount = (int)GetAttr(player, GachaGid, baseSid + SidAddTimeItem), + TenCount = (int)GetAttr(player, GachaGid, baseSid + SidAddTimeProb), + ProtectType = Math.Max(1, (int)GetAttr(player, GachaGid, baseSid + SidAddProtectType)), + PoolTotalTime = (int)GetAttr(player, GachaGid, baseSid + SidAddTotalTime) + }; + } + + private static void SavePityState(PlayerInstance player, GachaExcel gachaCfg, GachaPityState state, int drawCount, NtfSyncPlayer sync) + { + var baseSid = GetBaseSid(gachaCfg); + + SetAttr(player, sync, GachaGid, SidTotalTime, GetAttr(player, GachaGid, SidTotalTime) + (uint)drawCount); + SetAttr(player, sync, GachaGid, SidDailyTotalTime, GetAttr(player, GachaGid, SidDailyTotalTime) + (uint)drawCount); + SetAttr(player, sync, GachaGid, baseSid + SidAddTimeItem, (uint)state.ItemCount); + SetAttr(player, sync, GachaGid, baseSid + SidAddTimeProb, (uint)state.TenCount); + SetAttr(player, sync, GachaGid, baseSid + SidAddProtectType, (uint)Math.Max(1, state.ProtectType)); + SetAttr(player, sync, GachaGid, baseSid + SidAddTotalTime, (uint)(state.PoolTotalTime + drawCount)); + } + + private static GachaUpSelectState LoadUpSelectState(PlayerInstance player, GachaExcel gachaCfg) + { + if (gachaCfg.UpSelect != 1) + return new GachaUpSelectState(); + + var raw = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == GachaSgid && x.Sid == gachaCfg.ID)?.Val; + if (string.IsNullOrWhiteSpace(raw)) + return new GachaUpSelectState(); + + try + { + var state = JArray.Parse(raw); + uint[]? selected = null; + if (state.Count > UpSelectIndex && state[UpSelectIndex] is JArray selectedArray && selectedArray.Count >= 4) + { + selected = + [ + selectedArray[0]?.Value() ?? 0, + selectedArray[1]?.Value() ?? 0, + selectedArray[2]?.Value() ?? 0, + selectedArray[3]?.Value() ?? 0 + ]; + } + + return new GachaUpSelectState + { + SelectedItem = selected, + GuaranteedNext = state.Count > UpSelectGetFlagIndex && (state[UpSelectGetFlagIndex]?.Value() ?? 0) == 1, + RawState = state + }; + } + catch + { + return new GachaUpSelectState(); + } + } + + private static void SaveUpSelectState(PlayerInstance player, GachaExcel gachaCfg, GachaUpSelectState state, NtfSyncPlayer sync) + { + if (gachaCfg.UpSelect != 1 || state.RawState == null) + return; + + EnsureArraySize(state.RawState, 2); + state.RawState[UpSelectGetFlagIndex] = state.GuaranteedNext ? 1 : 0; + + var value = state.RawState.ToString(Newtonsoft.Json.Formatting.None); + player.SetStrAttr(GachaSgid, gachaCfg.ID, value); + sync.CustomStr[player.ToShiftedAttrKey(GachaSgid, gachaCfg.ID)] = value; + } + + private static uint GetBaseSid(GachaExcel gachaCfg) + { + if (gachaCfg.ProtectTag.HasValue) + return SidTimeInheritStart + (gachaCfg.ProtectTag.Value * Interval); + + return SidTimeNotInheritStart + (gachaCfg.ID * Interval); + } + + private static uint GetAttr(PlayerInstance player, uint gid, uint sid) => + player.Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid)?.Val ?? 0; + + private static void SetAttr(PlayerInstance player, NtfSyncPlayer sync, uint gid, uint sid, uint value) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr == null) + { + attr = new PlayerAttr { Gid = gid, Sid = sid }; + player.Data.Attrs.Add(attr); + } + + attr.Val = value; + sync.Custom[player.ToPackedAttrKey(gid, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(gid, sid)] = value; + } + + private static void UpdatePityState(GachaPityState state, GachaRuntimeConfig config, GachaPoolItem item) + { + if (item.Rarity >= config.TenGuaranteeRarity) + state.TenCount = 0; + else + state.TenCount++; + + if (item.Rarity >= config.TopRarity) + { + state.ItemCount = 0; + if (config.UpTarget != null) + state.ProtectType = IsFromPool(item, config.UpTarget) ? 1 : 2; + else + state.ProtectType = 1; + } + else + { + state.ItemCount++; + } + } + + private static void UpdateUpSelectState(GachaUpSelectState state, GachaRuntimeConfig config, GachaPoolItem item) + { + if (state.SelectedItem == null || item.Rarity < config.TopRarity) + return; + + state.GuaranteedNext = !MatchesGdpl(item, state.SelectedItem); + } + + private static bool MatchesGdpl(GachaPoolItem item, uint[] gdpl) => + item.GDPL.Count >= 4 && + item.GDPL[0] == gdpl[0] && + item.GDPL[1] == gdpl[1] && + item.GDPL[2] == gdpl[2] && + item.GDPL[3] == gdpl[3]; + + private static void EnsureArraySize(JArray state, int size) + { + while (state.Count < size) + state.Add(JValue.CreateNull()); + } + + private static bool IsFromPool(GachaPoolItem item, PoolRarityRef target) => + item.Rarity == target.Rarity && + GameData.GachaPoolData.TryGetValue(target.PoolName, out var pool) && + pool.Any(x => x.ID == item.ID); + + private static int RollRarity(GachaProbabilityExcel prob) + { + var weights = prob.Weights; + int total = weights.Sum(); + int roll = Rng.Next(total); + int cumulative = 0; + for (int i = 0; i < weights.Length; i++) + { + cumulative += weights[i]; + if (roll < cumulative) + return i + 1; + } + + return 3; + } + + private static GachaPoolItem? PickGuaranteedItem(GachaExcel gachaCfg, GachaRuntimeConfig config, bool preferUp) + { + if (preferUp && config.UpTarget != null) + { + var upItem = PickItemFromPool(config.UpTarget.PoolName, config.UpTarget.Rarity); + if (upItem != null) + return upItem; + } + + foreach (var poolRef in config.ProtectPools) + { + var item = PickItemFromPool(poolRef.PoolName, poolRef.Rarity); + if (item != null) + return item; + } + + return PickItem(config.AllPoolItems, config.TopRarity); + } + + private static GachaPoolItem? PickItemFromPool(string poolName, int rarity) + { + if (!GameData.GachaPoolData.TryGetValue(poolName, out var pool)) + return null; + + return PickItem(pool, rarity); + } + + private static GachaPoolItem? PickItem(List pool, int rarity) + { + var candidates = pool.Where(x => x.Rarity == rarity).ToList(); + if (candidates.Count == 0) + { + candidates = pool.Where(x => x.Rarity == rarity - 1).ToList(); + if (candidates.Count == 0) + return pool.FirstOrDefault(); + } + + int total = candidates.Sum(x => x.Weight); + if (total <= 0) + return candidates[Rng.Next(candidates.Count)]; + + int roll = Rng.Next(total); + int cumulative = 0; + foreach (var item in candidates) + { + cumulative += item.Weight; + if (roll < cumulative) + return item; + } + + return candidates.Last(); + } + + private static string BuildResponse(int nId, List> awards, List tbNew, List tbTrigger) + { + var sb = new StringBuilder(); + sb.Append("{\"nId\":"); + sb.Append(nId); + sb.Append(",\"tbAwards\":["); + for (int i = 0; i < awards.Count; i++) + { + if (i > 0) + sb.Append(','); + + sb.Append('['); + sb.Append(string.Join(',', awards[i])); + sb.Append(']'); + } + + sb.Append("],\"nBoxCount\":0,\"tbNew\":["); + sb.Append(string.Join(',', tbNew)); + sb.Append("],\"tbTrigger\":["); + sb.Append(string.Join(',', tbTrigger.Select(b => b ? "true" : "false"))); + sb.Append("]}"); + return sb.ToString(); } } + +internal sealed class GachaLaunchParam +{ + [JsonPropertyName("nId")] + public int NId { get; set; } + + [JsonPropertyName("bPickUp")] + public bool BPickUp { get; set; } + + [JsonPropertyName("nTime")] + public int NTime { get; set; } +} + +internal sealed class GachaPityState +{ + public int ItemCount { get; set; } + public int TenCount { get; set; } + public int ProtectType { get; set; } = 1; + public int PoolTotalTime { get; set; } +} + +internal sealed class GachaRuntimeConfig +{ + public List AllPoolItems { get; set; } = []; + public int ProtectThreshold { get; set; } + public List ProtectPools { get; set; } = []; + public PoolRarityRef? UpTarget { get; set; } + public int TopRarity { get; set; } + public int TenGuaranteeRarity { get; set; } +} + +internal sealed class GachaUpSelectState +{ + public uint[]? SelectedItem { get; set; } + public bool GuaranteedNext { get; set; } + public JArray? RawState { get; set; } = new(); +} + +internal sealed record PoolRarityRef(string PoolName, int Rarity); diff --git a/GameServer/Server/CallGS/Handlers/Gacha/Gacha_UpSelect.cs b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_UpSelect.cs new file mode 100644 index 0000000..eab74fa --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Gacha/Gacha_UpSelect.cs @@ -0,0 +1,82 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Proto; +using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Gacha; + +[CallGSApi("Gacha_UpSelect")] +public class Gacha_UpSelect : ICallGSHandler +{ + private const uint GachaStrGid = 42; + private const int UpSelectIndex = 0; + private const int UpSelectGetFlagIndex = 1; + private const int UpPickPoolIndex = 2; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + var player = connection.Player!; + if (req == null || req.NId == 0 || req.Gdpl == null || req.Gdpl.Count < 4) + { + await CallGSRouter.SendScript(connection, "Gacha_UpSelect", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.GachaData.TryGetValue((uint)req.NId, out var gachaCfg) || gachaCfg.UpSelect != 1) + { + await CallGSRouter.SendScript(connection, "Gacha_UpSelect", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var valid = (gachaCfg.Pool ?? []) + .Where(GameData.GachaPoolData.ContainsKey) + .SelectMany(name => GameData.GachaPoolData[name]) + .Any(item => + item.UPSelectTag == 1 && + item.GDPL.Count >= 4 && + item.GDPL[0] == req.Gdpl[0] && + item.GDPL[1] == req.Gdpl[1] && + item.GDPL[2] == req.Gdpl[2] && + item.GDPL[3] == req.Gdpl[3]); + + if (!valid) + { + await CallGSRouter.SendScript(connection, "Gacha_UpSelect", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var existing = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == GachaStrGid && x.Sid == (uint)req.NId)?.Val; + var state = string.IsNullOrWhiteSpace(existing) ? new JArray() : JArray.Parse(existing); + + EnsureArraySize(state, 3); + state[UpSelectIndex] = new JArray(req.Gdpl); + state[UpSelectGetFlagIndex] = 0; + if (state[UpPickPoolIndex] == null) + state[UpPickPoolIndex] = 0; + + player.SetStrAttr(GachaStrGid, (uint)req.NId, state.ToString(Newtonsoft.Json.Formatting.None)); + DatabaseHelper.SaveDatabaseType(player.Data); + + var sync = new NtfSyncPlayer(); + sync.CustomStr[player.ToShiftedAttrKey(GachaStrGid, (uint)req.NId)] = state.ToString(Newtonsoft.Json.Formatting.None); + await CallGSRouter.SendScript(connection, "Gacha_UpSelect", "{}", sync); + } + + private static void EnsureArraySize(JArray state, int size) + { + while (state.Count < size) + state.Add(JValue.CreateNull()); + } +} + +internal sealed class GachaUpSelectParam +{ + [JsonPropertyName("nId")] + public int NId { get; set; } + + [JsonPropertyName("gdpl")] + public List? Gdpl { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpBySpecialBreak.cs b/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpBySpecialBreak.cs new file mode 100644 index 0000000..790fd75 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Girl/GirlCard_UpBySpecialBreak.cs @@ -0,0 +1,127 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Girl; + +[CallGSApi("GirlCard_UpBySpecialBreak")] +public class GirlCard_UpBySpecialBreak : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.CardId == 0) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var card = player.CharacterManager.GetCharacterByGUID((uint)req.CardId); + if (card == null) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var cardTemplate = GameData.CardData.Values.FirstOrDefault(x => + GameResourceTemplateId.FromGdpl(x.Genre, x.Detail, x.Particular, x.Level) == card.TemplateId); + if (cardTemplate == null) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (cardTemplate.BreakMatID <= 10000 || + !GameData.SpecialBreakData.TryGetValue(cardTemplate.BreakMatID, out var specialBreakExcel)) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var nextBreak = card.Break + 1; + if (!specialBreakExcel.HasBreakLevel(nextBreak)) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"tip.already_max_break\"}"); + return; + } + + var requestedMaterials = new Dictionary(); + foreach (var row in specialBreakExcel.GetItems(nextBreak)) + { + if (row.Count < 5) + continue; + + var templateId = GameResourceTemplateId.FromGdpl( + (uint)Math.Max(0, row[0]), + (uint)Math.Max(0, row[1]), + (uint)Math.Max(0, row[2]), + (uint)Math.Max(0, row[3])); + var count = (uint)Math.Max(0, row[4]); + if (templateId == 0 || count == 0) + continue; + + requestedMaterials[templateId] = requestedMaterials.GetValueOrDefault(templateId) + count; + } + + if (requestedMaterials.Count == 0) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"tip.not_material_for_break\"}"); + return; + } + + foreach (var (templateId, count) in requestedMaterials) + { + var item = player.InventoryManager.InventoryData.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (item == null || item.ItemCount < count) + { + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{\"sErr\":\"tip.not_material_for_break\"}"); + return; + } + } + + var syncItems = new List(); + foreach (var (templateId, count) in requestedMaterials) + { + var item = player.InventoryManager.InventoryData.Items.Values.First(x => x.TemplateId == templateId); + item.ItemCount -= count; + + if (item.ItemCount == 0) + { + player.InventoryManager.InventoryData.Items.Remove(item.UniqueId); + syncItems.Add(BuildRemovedProto(item)); + } + else + { + syncItems.Add(item.ToProto()); + } + } + + card.Break = nextBreak; + syncItems.Add(card.ToProto()); + + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var sync = new NtfSyncPlayer(); + sync.Items.AddRange(syncItems); + + await CallGSRouter.SendScript(connection, "GirlCard_UpBySpecialBreak", "{}", sync); + } + + private static Item BuildRemovedProto(BaseGameItemInfo item) + { + var proto = item.ToProto(); + proto.Count = 0; + return proto; + } +} + +internal sealed class GirlCardUpBySpecialBreakParam +{ + [JsonPropertyName("nCardId")] + public int CardId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Misc/ExtendFightDynamicLog.cs b/GameServer/Server/CallGS/Handlers/Misc/ExtendFightDynamicLog.cs new file mode 100644 index 0000000..159ff02 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Misc/ExtendFightDynamicLog.cs @@ -0,0 +1,10 @@ +namespace MikuSB.GameServer.Server.CallGS.Handlers.Misc; + +[CallGSApi("ExtendFightDynamicLog")] +public class ExtendFightDynamicLog : ICallGSHandler +{ + public Task Handle(Connection connection, string param, ushort seqNo) + { + return Task.CompletedTask; + } +} diff --git a/GameServer/Server/CallGS/Handlers/Misc/ExtendFightLog.cs b/GameServer/Server/CallGS/Handlers/Misc/ExtendFightLog.cs new file mode 100644 index 0000000..1a5acf8 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Misc/ExtendFightLog.cs @@ -0,0 +1,10 @@ +namespace MikuSB.GameServer.Server.CallGS.Handlers.Misc; + +[CallGSApi("ExtendFightLog")] +public class ExtendFightLog : ICallGSHandler +{ + public Task Handle(Connection connection, string param, ushort seqNo) + { + return Task.CompletedTask; + } +} diff --git a/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_EnterSeasonLevel.cs b/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_EnterSeasonLevel.cs new file mode 100644 index 0000000..bac30d9 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_EnterSeasonLevel.cs @@ -0,0 +1,71 @@ +using MikuSB.Data; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Rogue3D; + +// Enters the Rogue3D season level. Returns a random seed used by the client for map generation. +// Persists SeasonGameplayId (sid=1006) and SeasonEnterFlag (sid=1008) as player attributes (GroupId=124). +// param: {"nDiffId", "nTeamID", "tbTeam", "tbBuffList", "tbLog"} +// Response: {"nSeed": int} on success, {"sErr": "key"} on failure +[CallGSApi("Rogue3D_EnterSeasonLevel")] +public class Rogue3D_EnterSeasonLevel : ICallGSHandler +{ + private const uint GroupId = 124; + private const uint SeasonGameplayIdSid = 1006; + private const uint SeasonEnterFlagSid = 1008; + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null) + { + await CallGSRouter.SendScript(connection, "Rogue3D_EnterSeasonLevel", "{\"nSeed\":0}"); + return; + } + + if (!GameData.Rogue3DDifficultData.TryGetValue(req.DiffId, out var cfg) || cfg.GameplayGroup.Count == 0) + { + await CallGSRouter.SendScript(connection, "Rogue3D_EnterSeasonLevel", "{\"sErr\":\"rogue3.massage_gameProcessError\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + + SetAttr(player, SeasonGameplayIdSid, cfg.GameplayGroup[0], sync); + SetAttr(player, SeasonEnterFlagSid, 1, sync); + + var seed = Random.Next(1, 1_000_000_000); + await CallGSRouter.SendScript(connection, "Rogue3D_EnterSeasonLevel", $"{{\"nSeed\":{seed}}}", sync); + } + + private static void SetAttr(PlayerInstance player, uint sid, uint val, NtfSyncPlayer sync) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr == null) + { + attr = new PlayerAttr { Gid = GroupId, Sid = sid }; + player.Data.Attrs.Add(attr); + } + + if (attr.Val == val) + { + return; + } + + attr.Val = val; + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = val; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = val; + } +} + +internal sealed class EnterSeasonLevelParam +{ + [JsonPropertyName("nDiffId")] + public uint DiffId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_SelectSeasonTalent.cs b/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_SelectSeasonTalent.cs new file mode 100644 index 0000000..26be649 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Rogue3D/Rogue3D_SelectSeasonTalent.cs @@ -0,0 +1,47 @@ +using MikuSB.Database.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Rogue3D; + +// Selects the Rogue3D season talent and persists it as player attribute (GroupId=124, TalentId=1007). +// param: {"nTalentId": int} +// Response: {} on success, {"sErr": "key"} on failure +[CallGSApi("Rogue3D_SelectSeasonTalent")] +public class Rogue3D_SelectSeasonTalent : ICallGSHandler +{ + private const uint GroupId = 124; + private const uint SeasonTalentIdSid = 1007; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null) + { + await CallGSRouter.SendScript(connection, "Rogue3D_SelectSeasonTalent", "{}"); + return; + } + + var player = connection.Player!; + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == SeasonTalentIdSid); + if (attr == null) + { + attr = new PlayerAttr { Gid = GroupId, Sid = SeasonTalentIdSid }; + player.Data.Attrs.Add(attr); + } + attr.Val = req.TalentId; + + var sync = new NtfSyncPlayer(); + sync.Custom[player.ToPackedAttrKey(GroupId, SeasonTalentIdSid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(GroupId, SeasonTalentIdSid)] = attr.Val; + + await CallGSRouter.SendScript(connection, "Rogue3D_SelectSeasonTalent", "{}", sync); + } +} + +internal sealed class SelectSeasonTalentParam +{ + [JsonPropertyName("nTalentId")] + public uint TalentId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_CheckCycleLevel.cs b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_CheckCycleLevel.cs new file mode 100644 index 0000000..a7e2e9c --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_CheckCycleLevel.cs @@ -0,0 +1,118 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("ClimbTowerLogic_CheckCycleLevel")] +public class ClimbTowerLogic_CheckCycleLevel : ICallGSHandler +{ + private const uint TowerGroupId = 3; + private const uint TimeSubId = 1; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var current = ResolveCurrentCycle(GameData.ClimbTowerTimeData.Values, DateTime.Now); + if (current == null) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_CheckCycleLevel", "{}"); + return; + } + + var currentTimeId = GetAttr(player.Data, TowerGroupId, TimeSubId); + var sync = new NtfSyncPlayer(); + if (currentTimeId != current.ID) + { + ResetTowerAttrs(player, sync); + SetAttr(player.Data, TowerGroupId, TimeSubId, current.ID, sync, player); + DatabaseHelper.SaveDatabaseType(player.Data); + } + + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_CheckCycleLevel", $$"""{"timeID":{{current.ID}}}""", sync); + } + + private static ClimbTowerTimeExcel? ResolveCurrentCycle(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null) + return latestStarted.Config; + + return parsed.FirstOrDefault()?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static uint GetAttr(PlayerGameData data, uint gid, uint sid) + { + return data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid)?.Val ?? 0; + } + + private static void ResetTowerAttrs(PlayerInstance player, NtfSyncPlayer sync) + { + var towerAttrs = player.Data.Attrs + .Where(x => x.Gid == TowerGroupId) + .ToList(); + + foreach (var attr in towerAttrs) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = 0; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = 0; + } + + player.Data.Attrs.RemoveAll(x => x.Gid == TowerGroupId); + } + + private static void SetAttr(PlayerGameData data, uint gid, uint sid, uint value, NtfSyncPlayer sync, PlayerInstance player) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr == null) + { + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + } + + attr.Val = value; + sync.Custom[player.ToPackedAttrKey(gid, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(gid, sid)] = value; + } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_GetReward.cs b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_GetReward.cs new file mode 100644 index 0000000..eb8372c --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_GetReward.cs @@ -0,0 +1,449 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("ClimbTowerLogic_GetReward")] +public class ClimbTowerLogic_GetReward : ICallGSHandler +{ + private const uint TowerGroupId = 3; + private const uint RewardStateSidBase = 100; + private const uint TowerLevelStateSidBase = 10000; + private const uint LaunchPassGroupId = 22; + private const uint AdvancedDiffSid = 4; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.Layer <= 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var cycle = ResolveCurrentCycle(GameData.ClimbTowerTimeData.Values, DateTime.Now); + if (cycle == null) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!TryResolveLayer(cycle, req.Layer, player.Data, out var towerIds, out var diff)) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.ClimbTowerAwardData.TryGetValue((uint)req.Layer, out var diffMap) || + !diffMap.TryGetValue(diff, out var rewardCfg)) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var groups = ResolveRequestedGroups(req.Group); + if (groups.Count == 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var claimableGroups = groups + .Where(group => CanClaimGroup(player.Data, rewardCfg, towerIds, req.Layer, group)) + .Distinct() + .ToList(); + + if (claimableGroups.Count == 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var sync = new NtfSyncPlayer(); + var rewardStateAttr = GetOrCreateAttr(player.Data, TowerGroupId, RewardStateSidBase + (uint)req.Layer); + var responseRewards = new JsonArray(); + + foreach (var group in claimableGroups) + { + rewardStateAttr.Val |= 1u << GetFlagBitOffset(group); + + foreach (var reward in rewardCfg.GetRewards(group)) + { + if (reward.Count < 5) + continue; + + await GrantRewardAsync(player, sync, reward); + responseRewards.Add(new JsonArray( + (int)reward[0], + (int)reward[1], + (int)reward[2], + (int)reward[3], + (int)reward[4])); + } + } + + SyncAttr(sync, player, rewardStateAttr); + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var rsp = new JsonObject + { + ["tbRewards"] = responseRewards + }; + + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_GetReward", rsp.ToJsonString(), sync); + } + + private static async Task GrantRewardAsync(PlayerInstance player, NtfSyncPlayer sync, IReadOnlyList reward) + { + var itemType = (ItemTypeEnum)reward[0]; + var detail = reward[1]; + var particular = reward[2]; + var level = reward[3]; + var count = Math.Max(1u, reward[4]); + + switch (itemType) + { + case ItemTypeEnum.TYPE_CARD: + for (var i = 0u; i < count; i++) + { + var character = await player.CharacterManager.AddCharacter(itemType, detail, particular, level, sendPacket: false); + if (character != null) + sync.Items.Add(character.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON: + for (var i = 0u; i < count; i++) + { + var weapon = await player.InventoryManager.AddWeaponItem(itemType, detail, particular, level, sendPacket: false); + if (weapon != null) + sync.Items.Add(weapon.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPORT: + for (var i = 0u; i < count; i++) + { + var support = await player.InventoryManager.AddSupportCardItem(detail, particular, level, sendPacket: false); + if (support != null) + sync.Items.Add(support.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPLIES: + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(reward[0], detail, particular, level); + if (GameData.SuppliesData.TryGetValue(templateId, out var supplies)) + { + var item = await player.InventoryManager.AddSuppliesItem(supplies, count, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + case ItemTypeEnum.TYPE_USEABLE: + { + var item = AddOtherItem(player.InventoryManager.InventoryData, reward[0], detail, particular, level, count); + if (item != null) + sync.Items.Add(item.ToProto()); + break; + } + case ItemTypeEnum.TYPE_WEAPON_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_HOUSE: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddHouseFurnitureItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_PROFILE: + case ItemTypeEnum.TYPE_FRAME: + case ItemTypeEnum.TYPE_BADGE: + case ItemTypeEnum.TYPE_COVER: + case ItemTypeEnum.TYPE_NAMECARD: + case ItemTypeEnum.TYPE_EXPRESSION: + case ItemTypeEnum.TYPE_BUBBLE: + case ItemTypeEnum.TYPE_ANALYST: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddProfileItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_MANIFESTATION: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddManifestationItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_AR: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddArItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CALL: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddCallItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + } + + private static BaseGameItemInfo? AddOtherItem(InventoryData inventory, uint genre, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(genre, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return null; + + var maxCount = otherItem.GMnum > 0 ? otherItem.GMnum : 99999u; + var existing = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (existing != null) + { + existing.ItemCount = Math.Min(existing.ItemCount + count, maxCount); + return existing; + } + + var item = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = inventory.NextUniqueUid++, + ItemType = ItemTypeEnum.TYPE_USEABLE, + ItemCount = Math.Min(count, maxCount) + }; + inventory.Items[item.UniqueId] = item; + return item; + } + + private static bool CanClaimGroup( + PlayerGameData data, + ClimbTowerAwardExcel rewardCfg, + IReadOnlyList towerIds, + int layer, + int group) + { + if (group is < 0 or > 3 || IsRewardClaimed(data, layer, group)) + return false; + + if (group == 0) + return IsLayerPass(data, towerIds); + + var requiredStar = rewardCfg.GetStarCount(group); + return requiredStar > 0 && GetLayerStar(data, towerIds) >= requiredStar; + } + + private static bool IsLayerPass(PlayerGameData data, IReadOnlyList towerIds) + { + foreach (var towerId in towerIds) + { + if (!GameData.ClimbTowerLevelOrderData.TryGetValue(towerId, out var orderCfg)) + return false; + + var passAttr = data.Attrs.FirstOrDefault(x => x.Gid == LaunchPassGroupId && x.Sid == orderCfg.LevelID); + if (passAttr == null || passAttr.Val == 0) + return false; + } + + return true; + } + + private static int GetLayerStar(PlayerGameData data, IReadOnlyList towerIds) + { + var total = 0; + foreach (var towerId in towerIds) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == TowerGroupId && x.Sid == TowerLevelStateSidBase + towerId); + var value = attr?.Val ?? 0; + for (var i = 0; i < 9; i++) + { + if (((value >> i) & 1u) != 0) + total++; + } + } + + return total; + } + + private static bool IsRewardClaimed(PlayerGameData data, int layer, int group) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == TowerGroupId && x.Sid == RewardStateSidBase + (uint)layer); + if (attr == null) + return false; + + var offset = GetFlagBitOffset(group); + return ((attr.Val >> offset) & 0xFu) > 0; + } + + private static int GetFlagBitOffset(int group) => group switch + { + 0 => 0, + 1 => 4, + 2 => 8, + 3 => 12, + _ => 0 + }; + + private static List ResolveRequestedGroups(int? group) + { + if (!group.HasValue) + return [0, 1, 2, 3]; + + return group.Value is >= 0 and <= 3 ? [group.Value] : []; + } + + private static bool TryResolveLayer( + ClimbTowerTimeExcel cycle, + int layer, + PlayerGameData data, + out IReadOnlyList towerIds, + out int diff) + { + var basicGroups = cycle.GetLevelGroups(1); + if (layer <= basicGroups.Count) + { + towerIds = basicGroups[layer - 1]; + diff = 1; + return towerIds.Count > 0; + } + + var advancedIndex = layer - basicGroups.Count; + var advancedGroups = cycle.GetLevelGroups(2); + if (advancedIndex <= 0 || advancedIndex > advancedGroups.Count) + { + towerIds = []; + diff = 0; + return false; + } + + var diffAttr = data.Attrs.FirstOrDefault(x => x.Gid == TowerGroupId && x.Sid == AdvancedDiffSid); + diff = (int)(diffAttr?.Val ?? 0); + towerIds = advancedGroups[advancedIndex - 1]; + return diff > 0 && towerIds.Count > 0; + } + + private static ClimbTowerTimeExcel? ResolveCurrentCycle(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null) + return latestStarted.Config; + + return parsed.FirstOrDefault()?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class ClimbTowerGetRewardParam +{ + [JsonPropertyName("nType")] + public int? Type { get; set; } + + [JsonPropertyName("nLayer")] + public int Layer { get; set; } + + [JsonPropertyName("nGroup")] + public int? Group { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_RecordProgres.cs b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_RecordProgres.cs new file mode 100644 index 0000000..fba93a1 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_RecordProgres.cs @@ -0,0 +1,219 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("ClimbTowerLogic_RecordProgres")] +public class ClimbTowerLogic_RecordProgres : ICallGSHandler +{ + private const uint TowerGroupId = 3; + private const uint BasicProgressSid = 2; + private const uint AdvancedProgressSid = 3; + private const uint LevelStateSidBase = 10000; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.Area <= 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_RecordProgres", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var cycle = ResolveCurrentCycle(GameData.ClimbTowerTimeData.Values, DateTime.Now); + if (cycle == null) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_RecordProgres", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var towerType = ResolveTowerType(cycle, (uint)req.LevelId); + if (towerType == 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_RecordProgres", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var sync = new NtfSyncPlayer(); + + var levelStateSid = LevelStateSidBase + (uint)req.LevelId; + var levelState = GetOrCreateAttr(player.Data, TowerGroupId, levelStateSid); + levelState.Val = MergeAreaStars(levelState.Val, req.Area, req.StarMask); + SyncAttr(sync, player, levelState); + + var progressSid = towerType == 1 ? BasicProgressSid : AdvancedProgressSid; + var progressAttr = GetOrCreateAttr(player.Data, TowerGroupId, progressSid); + progressAttr.Val = req.Area >= 3 ? 0u : PackProgress((uint)req.LevelId, (uint)(req.Area + 1)); + SyncAttr(sync, player, progressAttr); + + if (req.RoleHP.Count > 0 || req.TeamEnergy.HasValue) + { + SaveRoleState(player, sync, towerType, req.RoleHP, req.TeamEnergy.GetValueOrDefault()); + } + + DatabaseHelper.SaveDatabaseType(player.Data); + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_RecordProgres", "{}", sync); + } + + private static void SaveRoleState( + PlayerInstance player, + NtfSyncPlayer sync, + int towerType, + List> roleHp, + int teamEnergy) + { + var slotStart = towerType == 2 ? 4u : 1u; + + for (var slot = slotStart; slot < slotStart + 3; slot++) + { + var templateAttr = GetOrCreateAttr(player.Data, TowerGroupId, slot * 10); + var hpAttr = GetOrCreateAttr(player.Data, TowerGroupId, slot * 10 + 1); + templateAttr.Val = 0; + hpAttr.Val = 0; + SyncAttr(sync, player, templateAttr); + SyncAttr(sync, player, hpAttr); + } + + for (var i = 0; i < Math.Min(roleHp.Count, 3); i++) + { + var row = roleHp[i]; + if (row == null || row.Count < 2) + continue; + + var slot = slotStart + (uint)i; + var templateAttr = GetOrCreateAttr(player.Data, TowerGroupId, slot * 10); + var hpAttr = GetOrCreateAttr(player.Data, TowerGroupId, slot * 10 + 1); + templateAttr.Val = (uint)Math.Max(0, row[0]); + hpAttr.Val = (uint)Math.Max(0, row[1]); + SyncAttr(sync, player, templateAttr); + SyncAttr(sync, player, hpAttr); + } + + var energyAttr = GetOrCreateAttr(player.Data, TowerGroupId, slotStart * 10 + 2); + energyAttr.Val = (uint)Math.Max(0, teamEnergy); + SyncAttr(sync, player, energyAttr); + } + + private static uint MergeAreaStars(uint currentValue, int area, int starMask) + { + var areaIndex = Math.Clamp(area, 1, 3) - 1; + var result = currentValue; + for (var i = 0; i < 3; i++) + { + if (((starMask >> i) & 1) == 0) + continue; + + var bitIndex = areaIndex * 3 + i; + result |= 1u << bitIndex; + } + + return result; + } + + private static uint PackProgress(uint levelId, uint area) => (area << 24) | (levelId & 0x00FF_FFFF); + + private static int ResolveTowerType(ClimbTowerTimeExcel cycle, uint levelId) + { + if (ContainsLevel(cycle.GetLevelGroups(1), levelId)) + return 1; + + if (ContainsLevel(cycle.GetLevelGroups(2), levelId)) + return 2; + + return 0; + } + + private static bool ContainsLevel(IEnumerable> groups, uint levelId) + { + return groups.Any(group => group.Any(id => id == levelId)); + } + + private static ClimbTowerTimeExcel? ResolveCurrentCycle(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null) + return latestStarted.Config; + + return parsed.FirstOrDefault()?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class ClimbTowerRecordProgressParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nArea")] + public int Area { get; set; } + + [JsonPropertyName("nStar")] + public int StarMask { get; set; } + + [JsonPropertyName("tbRoleHP")] + public List> RoleHP { get; set; } = []; + + [JsonPropertyName("nTeamEnergy")] + public int? TeamEnergy { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_SetLevelDiff.cs b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_SetLevelDiff.cs new file mode 100644 index 0000000..ec96141 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/ClimbTowerLogic_SetLevelDiff.cs @@ -0,0 +1,76 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("ClimbTowerLogic_SetLevelDiff")] +public class ClimbTowerLogic_SetLevelDiff : ICallGSHandler +{ + private const uint TowerGroupId = 3; + private const uint DiffSid = 4; + private const uint HisDiffSid = 5; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.Diff <= 0) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_SetLevelDiff", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.ClimbTowerDiffData.ContainsKey((uint)req.Diff)) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_SetLevelDiff", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var hisDiff = GetAttrValue(player.Data, TowerGroupId, HisDiffSid); + if (req.Diff > hisDiff + 1) + { + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_SetLevelDiff", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var diffAttr = GetOrCreateAttr(player.Data, TowerGroupId, DiffSid); + diffAttr.Val = (uint)req.Diff; + + var sync = new NtfSyncPlayer(); + sync.Custom[player.ToPackedAttrKey(diffAttr.Gid, diffAttr.Sid)] = diffAttr.Val; + sync.Custom[player.ToShiftedAttrKey(diffAttr.Gid, diffAttr.Sid)] = diffAttr.Val; + + DatabaseHelper.SaveDatabaseType(player.Data); + await CallGSRouter.SendScript(connection, "ClimbTowerLogic_SetLevelDiff", "{}", sync); + } + + private static uint GetAttrValue(PlayerGameData data, uint gid, uint sid) + { + return data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid)?.Val ?? 0; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } +} + +internal sealed class ClimbTowerSetLevelDiffParam +{ + [JsonPropertyName("nDiff")] + public int Diff { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_EnterLevel.cs new file mode 100644 index 0000000..4df6a7b --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_EnterLevel.cs @@ -0,0 +1,39 @@ +using MikuSB.Data; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("TowerLevel_EnterLevel")] +public class TowerLevel_EnterLevel : ICallGSHandler +{ + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.TeamId <= 0) + { + await CallGSRouter.SendScript(connection, "TowerLevel_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.TowerLevelData.ContainsKey((uint)req.LevelId)) + { + await CallGSRouter.SendScript(connection, "TowerLevel_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var rsp = $"{{\"nSeed\":{Random.Next(1, 1_000_000_000)}}}"; + await CallGSRouter.SendScript(connection, "TowerLevel_EnterLevel", rsp); + } +} + +internal sealed class TowerLevelEnterLevelParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTeamID")] + public int TeamId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_LevelSettlement.cs new file mode 100644 index 0000000..0fdb5b6 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/TowerLevel_LevelSettlement.cs @@ -0,0 +1,178 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using MikuSB.Util; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("TowerLevel_LevelSettlement")] +public class TowerLevel_LevelSettlement : ICallGSHandler +{ + private static readonly Logger Logger = new("Tower"); + private const uint TowerGroupId = 3; + private const uint LaunchPassGroupId = 22; + private const uint BasicProgressSid = 2; + private const uint AdvancedProgressSid = 3; + private const uint LevelStateSidBase = 10000; + private const int FinalArea = 3; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = HandleSettlement(connection.Player!, JsonNode.Parse(param)); + await CallGSRouter.SendScript(connection, "TowerLevel_LevelSettlement", response.ToJsonString(), sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? tbParam) + { + var req = tbParam?.Deserialize(); + if (req == null || req.TowerId == 0 || req.LevelId == 0) + { + Logger.Error($"Invalid tower settlement payload: {tbParam?.ToJsonString() ?? "null"}"); + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + } + + var cycle = ResolveCurrentCycle(GameData.ClimbTowerTimeData.Values, DateTime.Now); + if (cycle == null) + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + + var towerType = ResolveTowerType(cycle, (uint)req.TowerId); + if (towerType == 0) + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + + var sync = new NtfSyncPlayer(); + var levelStateSid = LevelStateSidBase + (uint)req.TowerId; + var levelState = GetOrCreateAttr(player.Data, TowerGroupId, levelStateSid); + levelState.Val = MergeAreaStars(levelState.Val, FinalArea, req.StarMask); + SyncAttr(sync, player, levelState); + + var progressSid = towerType == 1 ? BasicProgressSid : AdvancedProgressSid; + var progressAttr = GetOrCreateAttr(player.Data, TowerGroupId, progressSid); + progressAttr.Val = 0; + SyncAttr(sync, player, progressAttr); + + var passAttr = GetOrCreateAttr(player.Data, LaunchPassGroupId, (uint)req.LevelId); + passAttr.Val = Math.Max(1u, passAttr.Val + 1); + SyncAttr(sync, player, passAttr); + + Logger.Info( + $"Tower settlement saved. uid={player.Uid} towerId={req.TowerId} levelId={req.LevelId} starMask={req.StarMask} " + + $"towerStateSid={levelStateSid} towerStateVal={levelState.Val} progressSid={progressSid} passVal={passAttr.Val}"); + + DatabaseHelper.SaveDatabaseType(player.Data); + return (new JsonObject(), sync); + } + + private static uint MergeAreaStars(uint currentValue, int area, int starMask) + { + var areaIndex = Math.Clamp(area, 1, 3) - 1; + var result = currentValue; + for (var i = 0; i < 3; i++) + { + if (((starMask >> i) & 1) == 0) + continue; + + var bitIndex = areaIndex * 3 + i; + result |= 1u << bitIndex; + } + + return result; + } + + private static int ResolveTowerType(ClimbTowerTimeExcel cycle, uint levelId) + { + if (ContainsLevel(cycle.GetLevelGroups(1), levelId)) + return 1; + + if (ContainsLevel(cycle.GetLevelGroups(2), levelId)) + return 2; + + return 0; + } + + private static bool ContainsLevel(IEnumerable> groups, uint levelId) + { + return groups.Any(group => group.Any(id => id == levelId)); + } + + private static ClimbTowerTimeExcel? ResolveCurrentCycle(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null) + return latestStarted.Config; + + return parsed.FirstOrDefault()?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(MikuSB.Proto.NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class TowerLevelSettlementParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTowerID")] + public int TowerId { get; set; } + + [JsonPropertyName("nStar")] + public int StarMask { get; set; } +} diff --git a/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs b/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs index 0a3c179..3b39552 100644 --- a/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs +++ b/GameServer/Server/Packet/Recv/Login/HandlerReqLogin.cs @@ -21,6 +21,7 @@ namespace MikuSB.GameServer.Server.Packet.Recv.Login; public class HandlerReqLogin : Handler { private static readonly Logger Logger = new("ReqLogin"); + private const int SupportCardLoginSplitThreshold = 2000; private static string? ExtractSdkAuthToken(string? token) { @@ -88,7 +89,10 @@ public override async Task OnHandle(Connection connection, byte[] data, ushort s $"Debug-{DateTime.Now:yyyy-MM-dd HH-mm-ss}.log"); await connection.Player.OnEnterGame(); connection.Player.Connection = connection; - await connection.SendPacket(new PacketRspLogin(connection.Player!)); + var splitSupportCards = connection.Player.InventoryManager.InventoryData.SupportCards.Count > SupportCardLoginSplitThreshold; + await connection.SendPacket(new PacketRspLogin(connection.Player!, !splitSupportCards)); + if (splitSupportCards) + await SendSupportCardsOnLogin(connection); await connection.SendPacket(new PacketNtfCallScript(connection.Player!)); await SendDebugLoginState(connection); @@ -98,6 +102,22 @@ public override async Task OnHandle(Connection connection, byte[] data, ushort s await SendGirlSkinTypeOnLogin(connection); } + private static async Task SendSupportCardsOnLogin(Connection connection) + { + var player = connection.Player; + if (player == null) + return; + + var supportCards = player.InventoryManager.InventoryData.SupportCards.Values.ToList(); + Logger.Info($"Split support card sync on login: total={supportCards.Count}, chunkSize={SupportCardLoginSplitThreshold}"); + + foreach (var chunk in supportCards.Chunk(SupportCardLoginSplitThreshold)) + { + var packet = new PacketNtfCallScript(chunk.ToList()); + await connection.SendPacket(packet); + } + } + private static void ApplySavedGirlSkinTypes(PlayerInstance player) { var inventoryData = player.InventoryManager.InventoryData; diff --git a/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs b/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs index c3bec83..45805bc 100644 --- a/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs +++ b/GameServer/Server/Packet/Send/Login/PacketRspLogin.cs @@ -10,18 +10,41 @@ public class PacketRspLogin : BasePacket { private static readonly Logger Logger = new("RspLogin"); - public PacketRspLogin(PlayerInstance player) : base(CmdIds.RspLogin) + public PacketRspLogin(PlayerInstance player, bool includeSupportCards = true) : base(CmdIds.RspLogin) { + var characterCount = player.CharacterManager.CharacterData.Characters.Count; + var itemCount = player.InventoryManager.InventoryData.Items.Count; + var skinCount = player.InventoryManager.InventoryData.Skins.Count; + var weaponCount = player.InventoryManager.InventoryData.Weapons.Count; + var supportCardCount = player.InventoryManager.InventoryData.SupportCards.Count; + var attrCount = player.Data.Attrs.Count; + var strAttrCount = player.Data.StrAttrs.Count; + var showItemCount = player.Data.ShowItems.Count; + var proto = new RspLogin { Timestamp = (uint)Extensions.GetUnixSec(), WorldChannel = 1, AreaId = 1, - Data = player.ToPlayerProto(), + Data = player.ToPlayerProto(includeSupportCards), NeedRename = false }; var bytes = Google.Protobuf.MessageExtensions.ToByteArray(proto); + Logger.Info( + "RspLogin content: " + + $"characters={characterCount}, " + + $"items={itemCount}, " + + $"skins={skinCount}, " + + $"weapons={weaponCount}, " + + $"supportCards={supportCardCount}, " + + $"supportCardsInRspLogin={(includeSupportCards ? supportCardCount : 0)}, " + + $"attrs={attrCount}, " + + $"strAttrs={strAttrCount}, " + + $"showItems={showItemCount}, " + + $"protoItems={proto.Data.Items.Count}, " + + $"protoAttrs={proto.Data.Attrs.Count}, " + + $"protoStrAttrs={proto.Data.StrAttrs.Count}"); Logger.Info($"RspLogin proto size: {bytes.Length} bytes"); SetData(bytes); diff --git a/MikuSB/Program/MikuSB.cs b/MikuSB/Program/MikuSB.cs index 4e420d8..9a9bf87 100644 --- a/MikuSB/Program/MikuSB.cs +++ b/MikuSB/Program/MikuSB.cs @@ -29,6 +29,7 @@ public static async Task Main(string[] args) var time = DateTime.Now; IConsole.InitConsole(); LoaderManager.InitConfig(); + ShowAntiScamWarning(); if (await UpdateService.TryStartSelfUpdateAsync()) return; @@ -69,6 +70,16 @@ public static async Task Main(string[] args) await ProcessExit(Volatile.Read(ref _exitCode)); } + private static void ShowAntiScamWarning() + { + Logger.Warn("============================================================"); + Logger.Warn("MikuSB is completely free and open source."); + Logger.Warn("If you paid anyone for this server, you were scammed."); + Logger.Warn("Request a refund immediately and report the seller to us."); + Logger.Warn("Discord: https://discord.gg/aMwCu9JyUR"); + Logger.Warn("============================================================"); + } + private static void TryRunStartupGame(string[] args) { if (!args.Any(x => string.Equals(x, "-game", StringComparison.OrdinalIgnoreCase))) @@ -158,4 +169,4 @@ private static async Task ProcessExit(int exitCode) } # endregion -} \ No newline at end of file +} diff --git a/README.md b/README.md index b73502c..99ff499 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ Languages: English | [中文](docs/user/README_zh.md) | [日本語](docs/user/RE - [Command guide](docs/user/commands/COMMAND_GUIDE_en.md) - [Command target notes](docs/user/commands/COMMAND_TARGET_en.md) +## Scam Warning + +MikuSB is completely free and open source. +If anyone sold you this server or charged money to provide it, that was a scam. +Request a refund immediately and report the seller to us on Discord with any relevant proof or purchase details. + ## Overview - `SdkServer` diff --git a/version.txt b/version.txt index e5706cc..af583f4 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v=3.2 +v=4.0 \ No newline at end of file From 859104794af8f80847d8b348514e77cdef7d7763 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Sun, 24 May 2026 07:27:16 +0800 Subject: [PATCH 15/16] Implement various gameplay features and fixes (#8) * Added a warning against scam. Die, scammers! * Gacha * Gacha_UpSelect * Update version.txt * Fixed an issue where adding too many support cards could prevent users from logging in. * Update version.txt * Character episode chapters are now playable. * Implement BossPvP logic (I implemented this based on undownding's code. Thank you!) * Update version.txt * small fix * Added functionality to Rogue3D * Update version.txt * GirlCard_UpBySpecialBreak * Update version.txt * ClimbTowerLogic_CheckCycleLevel TowerLevel_EnterLevel * ExtendFightDynamicLog ExtendFightLog * TowerLevel_LevelSettlement ClimbTowerLogic_RecordProgres * Update version.txt * ClimbTowerLogic_GetReward * ClimbTowerLogic_SetLevelDiff * Lineups_Update * fix Chapter_DealLevelSettlement * Implement TowerEvent * Update version.txt * VirCapture can Enter --------- Co-authored-by: Kei-Luna Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- Common/Data/Excel/TowerEventLevelExcel.cs | 20 +++ Common/Data/Excel/VirCaptureSeasonExcel.cs | 18 ++ Common/Data/Excel/VirCaptureTimeExcel.cs | 18 ++ Common/Data/Excel/VirCaptureTrialTimeExcel.cs | 19 ++ Common/Data/GameData.cs | 4 + .../Chapter/Chapter_DealLevelSettlement.cs | 25 ++- .../CallGS/Handlers/Lineup/Lineups_Update.cs | 43 +++++ .../Tower/TowerEventChapter_EnterLevel.cs | 39 +++++ .../TowerEventChapter_LevelSettlement.cs | 82 +++++++++ .../VirCapture/VirCapture_CheckOpenAct.cs | 164 ++++++++++++++++++ MikuSB/Program/MikuSB.cs | 1 - README.md | 6 + version.txt | 2 +- 13 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 Common/Data/Excel/TowerEventLevelExcel.cs create mode 100644 Common/Data/Excel/VirCaptureSeasonExcel.cs create mode 100644 Common/Data/Excel/VirCaptureTimeExcel.cs create mode 100644 Common/Data/Excel/VirCaptureTrialTimeExcel.cs create mode 100644 GameServer/Server/CallGS/Handlers/Lineup/Lineups_Update.cs create mode 100644 GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_EnterLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_LevelSettlement.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_CheckOpenAct.cs diff --git a/Common/Data/Excel/TowerEventLevelExcel.cs b/Common/Data/Excel/TowerEventLevelExcel.cs new file mode 100644 index 0000000..4e9218f --- /dev/null +++ b/Common/Data/Excel/TowerEventLevelExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("challenge/tower_event/level.json")] +public class TowerEventLevelExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("MapID")] public uint MapID { get; set; } + [JsonProperty("FightID")] public uint FightID { get; set; } + [JsonProperty("TaskPath")] public string TaskPath { get; set; } = ""; + [JsonProperty("ConsumeVigor")] public List ConsumeVigor { get; set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.TowerEventLevelData[ID] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureSeasonExcel.cs b/Common/Data/Excel/VirCaptureSeasonExcel.cs new file mode 100644 index 0000000..22a3ab0 --- /dev/null +++ b/Common/Data/Excel/VirCaptureSeasonExcel.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/season.json")] +public class VirCaptureSeasonExcel : ExcelResource +{ + [JsonProperty("Id")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.VirCaptureSeasonData[Id] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureTimeExcel.cs b/Common/Data/Excel/VirCaptureTimeExcel.cs new file mode 100644 index 0000000..2195228 --- /dev/null +++ b/Common/Data/Excel/VirCaptureTimeExcel.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/timelist.json")] +public class VirCaptureTimeExcel : ExcelResource +{ + [JsonProperty("Id")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.VirCaptureTimeData[Id] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureTrialTimeExcel.cs b/Common/Data/Excel/VirCaptureTrialTimeExcel.cs new file mode 100644 index 0000000..bdeb804 --- /dev/null +++ b/Common/Data/Excel/VirCaptureTrialTimeExcel.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/trial_timelist.json")] +public class VirCaptureTrialTimeExcel : ExcelResource +{ + [JsonProperty("Id")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("AwardTime")] public string AwardTime { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.VirCaptureTrialTimeData[Id] = this; + } +} diff --git a/Common/Data/GameData.cs b/Common/Data/GameData.cs index c54c2ea..08087f2 100644 --- a/Common/Data/GameData.cs +++ b/Common/Data/GameData.cs @@ -38,6 +38,7 @@ public static class GameData public static Dictionary> ClimbTowerAwardData { get; private set; } = []; public static Dictionary ClimbTowerLevelOrderData { get; private set; } = []; public static Dictionary TowerLevelData { get; private set; } = []; + public static Dictionary TowerEventLevelData { get; private set; } = []; public static Dictionary OtherItemData { get; private set; } = []; public static Dictionary ProfileData { get; private set; } = []; public static Dictionary CardSkinPartsData { get; private set; } = []; @@ -49,6 +50,9 @@ public static class GameData public static Dictionary GachaData { get; private set; } = []; public static Dictionary GachaProbabilityData { get; private set; } = []; public static Dictionary> GachaPoolData { get; private set; } = []; + public static Dictionary VirCaptureTimeData { get; private set; } = []; + public static Dictionary VirCaptureSeasonData { get; private set; } = []; + public static Dictionary VirCaptureTrialTimeData { get; private set; } = []; } public static class GameResourceTemplateId diff --git a/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs b/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs index 45b1a8a..1ce535f 100644 --- a/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs +++ b/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs @@ -45,7 +45,8 @@ private static JsonNode BuildSettlementPayload(Connection connection, string? sC if (string.Equals(sCmd, "BossPvpLogic_LevelSettlement", StringComparison.Ordinal)) { - var (response, sync) = BossPvpService.HandleSettlement(connection.Player!, tbParam); + var normalized = NormalizeBossPvpSettlement(tbParam); + var (response, sync) = BossPvpService.HandleSettlement(connection.Player!, normalized); extraSync = sync; return response; } @@ -64,8 +65,30 @@ private static JsonNode BuildSettlementPayload(Connection connection, string? sC return response; } + if (string.Equals(sCmd, "TowerEventChapter_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = TowerEventChapter_LevelSettlement.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } return tbParam?.DeepClone() ?? new JsonObject(); } + + private static JsonNode? NormalizeBossPvpSettlement(JsonNode? tbParam) + { + if (tbParam is not JsonObject obj) + return tbParam; + + var clone = obj.DeepClone() as JsonObject ?? obj; + if (clone.TryGetPropertyValue("ResidueTime", out var residueNode) && + residueNode is JsonValue residueValue && + residueValue.TryGetValue(out var residueTime)) + { + clone["ResidueTime"] = (int)Math.Max(0, Math.Round(residueTime, MidpointRounding.AwayFromZero)); + } + + return clone; + } } internal sealed class DealLevelSettlementParam diff --git a/GameServer/Server/CallGS/Handlers/Lineup/Lineups_Update.cs b/GameServer/Server/CallGS/Handlers/Lineup/Lineups_Update.cs new file mode 100644 index 0000000..80b892f --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Lineup/Lineups_Update.cs @@ -0,0 +1,43 @@ +using MikuSB.Database; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Lineup; + +[CallGSApi("Lineups_Update")] +public class Lineups_Update : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize>(param); + if (req == null) + { + await CallGSRouter.SendScript(connection, "UpdateLineup", "{}"); + return; + } + + foreach (var lineup in req) + { + if (lineup == null) + continue; + + await connection.Player!.LineupManager.UpdateLineup( + lineup.Index, + lineup.Member1, + lineup.Member2, + lineup.Member3); + } + + DatabaseHelper.SaveDatabaseType(connection.Player!.LineupManager.LineupData); + await CallGSRouter.SendScript(connection, "UpdateLineup", "{}"); + } +} + +internal sealed class LineupUpdateBatchParam +{ + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("index")] public int Index { get; set; } + [JsonPropertyName("member1")] public uint Member1 { get; set; } + [JsonPropertyName("member2")] public uint Member2 { get; set; } + [JsonPropertyName("member3")] public uint Member3 { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_EnterLevel.cs new file mode 100644 index 0000000..1909c4d --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_EnterLevel.cs @@ -0,0 +1,39 @@ +using MikuSB.Data; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("TowerEventChapter_EnterLevel")] +public class TowerEventChapter_EnterLevel : ICallGSHandler +{ + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.TeamId <= 0) + { + await CallGSRouter.SendScript(connection, "TowerEventChapter_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.TowerEventLevelData.ContainsKey((uint)req.LevelId)) + { + await CallGSRouter.SendScript(connection, "TowerEventChapter_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var rsp = $"{{\"nSeed\":{Random.Next(1, 1_000_000_000)}}}"; + await CallGSRouter.SendScript(connection, "TowerEventChapter_EnterLevel", rsp); + } +} + +internal sealed class TowerEventEnterLevelParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTeamID")] + public int TeamId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_LevelSettlement.cs new file mode 100644 index 0000000..bb0de54 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Tower/TowerEventChapter_LevelSettlement.cs @@ -0,0 +1,82 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using MikuSB.Util; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Tower; + +[CallGSApi("TowerEventChapter_LevelSettlement")] +public class TowerEventChapter_LevelSettlement : ICallGSHandler +{ + private const uint LevelStateGroupId = 21; + private const uint LaunchPassGroupId = 22; + private const uint PassedFlagMask = (1u << 8) | 0b111u; + private static readonly Logger Logger = new("TowerEvent"); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = HandleSettlement(connection.Player!, JsonNode.Parse(param)); + await CallGSRouter.SendScript(connection, "TowerEventChapter_LevelSettlement", response.ToJsonString(), sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? tbParam) + { + var req = tbParam?.Deserialize(); + if (req == null || req.LevelId == 0 || req.ChapterId == 0) + { + Logger.Error($"Invalid tower event settlement payload: {tbParam?.ToJsonString() ?? "null"}"); + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + } + + var sync = new NtfSyncPlayer(); + + var levelStateAttr = GetOrCreateAttr(player.Data, LevelStateGroupId, (uint)req.LevelId); + levelStateAttr.Val |= PassedFlagMask; + SyncAttr(sync, player, levelStateAttr); + + var passAttr = GetOrCreateAttr(player.Data, LaunchPassGroupId, (uint)req.LevelId); + passAttr.Val = Math.Max(1u, passAttr.Val + 1); + SyncAttr(sync, player, passAttr); + + Logger.Info( + $"TowerEvent settlement saved. uid={player.Uid} chapterId={req.ChapterId} levelId={req.LevelId} " + + $"levelStateVal={levelStateAttr.Val} passVal={passAttr.Val}"); + + DatabaseHelper.SaveDatabaseType(player.Data); + return (new JsonObject(), sync); + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class TowerEventSettlementParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nChapterID")] + public int ChapterId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_CheckOpenAct.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_CheckOpenAct.cs new file mode 100644 index 0000000..43d5465 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_CheckOpenAct.cs @@ -0,0 +1,164 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCapture_CheckOpenAct")] +public class VirCapture_CheckOpenAct : ICallGSHandler +{ + private const uint GroupId = 128; + private const uint ActIdSid = 1; + private const uint CurLevelSid = 3; + private const uint TrialActIdSid = 6; + private const uint SeasonActIdSid = 9; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var now = DateTime.Now; + var act = ResolveCurrent(GameData.VirCaptureTimeData.Values, now); + if (act == null) + { + await CallGSRouter.SendScript(connection, "VirCapture_CheckOpenAct", "{\"bOpen\":false}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + + SetAttr(player, ActIdSid, act.Id, sync); + EnsureMinAttr(player, CurLevelSid, 1, sync); + + var response = new JsonObject + { + ["bOpen"] = true, + ["nId"] = act.Id, + ["nStartTime"] = ToUnixSeconds(ParseConfigTime(act.StartTime)), + ["nEndTime"] = ToUnixSeconds(ParseConfigTime(act.EndTime)) + }; + + var season = ResolveCurrent(GameData.VirCaptureSeasonData.Values, now); + if (season != null) + { + SetAttr(player, SeasonActIdSid, season.Id, sync); + response["tbSeason"] = new JsonObject + { + ["nId"] = season.Id, + ["nStartTime"] = ToUnixSeconds(ParseConfigTime(season.StartTime)), + ["nEndTime"] = ToUnixSeconds(ParseConfigTime(season.EndTime)) + }; + } + else + { + SetAttr(player, SeasonActIdSid, 0, sync); + } + + var trial = ResolveCurrent(GameData.VirCaptureTrialTimeData.Values, now); + SetAttr(player, TrialActIdSid, trial?.Id ?? 0, sync); + + await CallGSRouter.SendScript(connection, "VirCapture_CheckOpenAct", response.ToJsonString(), sync); + } + + private static T? ResolveCurrent(IEnumerable configs, DateTime now) where T : class + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(GetTimeValue(x, true)), + End = ParseConfigTime(GetTimeValue(x, false)) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null && latestStarted.End > latestStarted.Start) + return latestStarted.Config; + + return null; + } + + private static string? GetTimeValue(T value, bool start) where T : class + { + return value switch + { + VirCaptureTimeExcel time => start ? time.StartTime : time.EndTime, + VirCaptureSeasonExcel season => start ? season.StartTime : season.EndTime, + _ => null + }; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static long ToUnixSeconds(DateTime? value) + { + return value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeSeconds() : 0L; + } + + private static void EnsureMinAttr(PlayerInstance player, uint sid, uint minValue, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val < minValue) + { + attr.Val = minValue; + SyncAttr(player, sync, sid, attr.Val); + } + } + + private static void SetAttr(PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val != value) + { + attr.Val = value; + SyncAttr(player, sync, sid, value); + } + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, uint sid, uint value) + { + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } +} diff --git a/MikuSB/Program/MikuSB.cs b/MikuSB/Program/MikuSB.cs index 9a9bf87..9585c39 100644 --- a/MikuSB/Program/MikuSB.cs +++ b/MikuSB/Program/MikuSB.cs @@ -124,7 +124,6 @@ private static string[] ParseGameCommandArgs(string[] args) return extraArgs.ToArray(); } - #region Exit private static void RegisterExitEvent() diff --git a/README.md b/README.md index 99ff499..d34c1ce 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ MikuSB is completely free and open source. If anyone sold you this server or charged money to provide it, that was a scam. Request a refund immediately and report the seller to us on Discord with any relevant proof or purchase details. +## Scam Warning + +MikuSB is completely free and open source. +If anyone sold you this server or charged money to provide it, that was a scam. +Request a refund immediately and report the seller to us on Discord with any relevant proof or purchase details. + ## Overview - `SdkServer` diff --git a/version.txt b/version.txt index af583f4..a130fc8 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v=4.0 \ No newline at end of file +v=4.1 From 51496ef95d36a641d1547581397b399120b0a942 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Thu, 28 May 2026 22:33:57 +0800 Subject: [PATCH 16/16] Fixed an issue where clicking on a feature not yet implemented on the server side would cause an infinite loading loop. (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added a warning against scam. Die, scammers! * Gacha * Gacha_UpSelect * Update version.txt * Fixed an issue where adding too many support cards could prevent users from logging in. * Update version.txt * Character episode chapters are now playable. * Implement BossPvP logic (I implemented this based on undownding's code. Thank you!) * Update version.txt * small fix * Added functionality to Rogue3D * Update version.txt * GirlCard_UpBySpecialBreak * Update version.txt * ClimbTowerLogic_CheckCycleLevel TowerLevel_EnterLevel * ExtendFightDynamicLog ExtendFightLog * TowerLevel_LevelSettlement ClimbTowerLogic_RecordProgres * Update version.txt * ClimbTowerLogic_GetReward * ClimbTowerLogic_SetLevelDiff * Lineups_Update * fix Chapter_DealLevelSettlement * Implement TowerEvent * Update version.txt * VirCapture can Enter * VirCaptureLevel_EnterLevel * VirCaptureLevel_SavePos VirCaptureLevel_SaveCapture VirCaptureLevel_ChangeFlag * Create VirCaptureLevel_SaveFightData.cs * Basic functionality of VirCapture has been implemented. * Update version.txt * VirCapture fix EXP * FishingServer_ConvertFood * VirCaptureTower_EnterLevel * Update version.txt * VirCapture_GetLevelAward * DreamCard can Enter * DreamCard_LevelSettlement DreamCard_UpdateData * Update version.txt * DLCLogic_CheckOpenAct * BattlePass * IBLogic * MoneySync * Fixed an issue where clicking on a feature not yet implemented on the server side would cause an infinite loading loop. * 实现游戏启动命令解析和启动功能 --------- Co-authored-by: Kei-Luna --- Common/Data/Excel/BattlePassTimeExcel.cs | 23 + Common/Data/Excel/DlcActivityExcel.cs | 21 + Common/Data/Excel/DreamCardActivityExcel.cs | 20 + Common/Data/Excel/FishingFoodExcel.cs | 89 ++++ Common/Data/Excel/IbGoodsExcel.cs | 76 +++ Common/Data/Excel/MonsterCardExcel.cs | 26 + Common/Data/Excel/OtherItemExcel.cs | 9 + .../Excel/VirCaptureCaptureRegionExcel.cs | 20 + Common/Data/Excel/VirCaptureLevelListExcel.cs | 21 + Common/Data/Excel/VirCaptureTimeExcel.cs | 2 + Common/Data/Excel/VirCaptureTowerExcel.cs | 44 ++ Common/Data/GameData.cs | 9 + GameServer/Game/Inventory/InventoryManager.cs | 21 + GameServer/Game/Player/PlayerInstance.cs | 25 + GameServer/Server/CallGS/CallGSRouter.cs | 10 + .../BattlePassLogic_ClientRefresh.cs | 136 ++++-- .../Chapter/Chapter_DealLevelSettlement.cs | 19 + .../Handlers/DLC/DLCLogic_CheckOpenAct.cs | 112 +++++ .../Handlers/DreamCard/DreamCard_CheckOpen.cs | 59 +++ .../DreamCard/DreamCard_EnterLevel.cs | 227 +++++++++ .../DreamCard/DreamCard_LevelSettlement.cs | 281 +++++++++++ .../DreamCard/DreamCard_UpdateData.cs | 59 +++ .../Fishing/FishingServer_ConvertFood.cs | 245 ++++++++++ .../CallGS/Handlers/Misc/Adjust_Record.cs | 59 +++ .../CallGS/Handlers/Shop/IBLogic_BuyGoods.cs | 451 ++++++++++++++++++ .../Handlers/Shop/IBLogic_GoodsRedDot.cs | 71 +++ .../VirCaptureCaptureRewardResolver.cs | 134 ++++++ .../VirCapture/VirCaptureLevel_ChangeFlag.cs | 40 ++ .../VirCapture/VirCaptureLevel_EnterLevel.cs | 171 +++++++ .../VirCapture/VirCaptureLevel_SaveCapture.cs | 222 +++++++++ .../VirCaptureLevel_SaveFightData.cs | 45 ++ .../VirCapture/VirCaptureLevel_SavePos.cs | 48 ++ .../VirCapture/VirCaptureStateHelper.cs | 157 ++++++ .../VirCapture/VirCaptureTower_EnterLevel.cs | 79 +++ .../VirCaptureTower_LevelSettlement.cs | 94 ++++ .../VirCapture/VirCapture_ChangeFormation.cs | 140 ++++++ .../VirCapture/VirCapture_GetLevelAward.cs | 292 ++++++++++++ .../Packet/Send/Misc/PacketNtfCallScript.cs | 2 + MikuSB/Program/MikuSB.cs | 44 +- README.md | 6 + version.txt | 2 +- 41 files changed, 3540 insertions(+), 71 deletions(-) create mode 100644 Common/Data/Excel/BattlePassTimeExcel.cs create mode 100644 Common/Data/Excel/DlcActivityExcel.cs create mode 100644 Common/Data/Excel/DreamCardActivityExcel.cs create mode 100644 Common/Data/Excel/FishingFoodExcel.cs create mode 100644 Common/Data/Excel/IbGoodsExcel.cs create mode 100644 Common/Data/Excel/MonsterCardExcel.cs create mode 100644 Common/Data/Excel/VirCaptureCaptureRegionExcel.cs create mode 100644 Common/Data/Excel/VirCaptureLevelListExcel.cs create mode 100644 Common/Data/Excel/VirCaptureTowerExcel.cs create mode 100644 GameServer/Server/CallGS/Handlers/DLC/DLCLogic_CheckOpenAct.cs create mode 100644 GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_CheckOpen.cs create mode 100644 GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_EnterLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_LevelSettlement.cs create mode 100644 GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_UpdateData.cs create mode 100644 GameServer/Server/CallGS/Handlers/Fishing/FishingServer_ConvertFood.cs create mode 100644 GameServer/Server/CallGS/Handlers/Misc/Adjust_Record.cs create mode 100644 GameServer/Server/CallGS/Handlers/Shop/IBLogic_BuyGoods.cs create mode 100644 GameServer/Server/CallGS/Handlers/Shop/IBLogic_GoodsRedDot.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureCaptureRewardResolver.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_ChangeFlag.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_EnterLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveCapture.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveFightData.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SavePos.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureStateHelper.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_EnterLevel.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_LevelSettlement.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_ChangeFormation.cs create mode 100644 GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_GetLevelAward.cs diff --git a/Common/Data/Excel/BattlePassTimeExcel.cs b/Common/Data/Excel/BattlePassTimeExcel.cs new file mode 100644 index 0000000..1089ef5 --- /dev/null +++ b/Common/Data/Excel/BattlePassTimeExcel.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("battlepass/timelist.json")] +public class BattlePassTimeExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("BuyStartTime")] public string BuyStartTime { get; set; } = ""; + [JsonProperty("BuyEndTime")] public string BuyEndTime { get; set; } = ""; + [JsonProperty("Condition")] public string Condition { get; set; } = ""; + [JsonProperty("ExpStep")] public uint ExpStep { get; set; } + [JsonProperty("MaxExPerWeek")] public uint MaxExPerWeek { get; set; } + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.BattlePassTimeData[Id] = this; + } +} diff --git a/Common/Data/Excel/DlcActivityExcel.cs b/Common/Data/Excel/DlcActivityExcel.cs new file mode 100644 index 0000000..8250cf4 --- /dev/null +++ b/Common/Data/Excel/DlcActivityExcel.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/dlc_activities.json")] +public class DlcActivityExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("EnterStartTime")] public string EnterStartTime { get; set; } = ""; + [JsonProperty("CloseEndTime")] public string CloseEndTime { get; set; } = ""; + [JsonProperty("Condition")] public string Condition { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.DlcActivityData[Id] = this; + } +} diff --git a/Common/Data/Excel/DreamCardActivityExcel.cs b/Common/Data/Excel/DreamCardActivityExcel.cs new file mode 100644 index 0000000..ef739be --- /dev/null +++ b/Common/Data/Excel/DreamCardActivityExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/DreamCard/activity.json")] +public class DreamCardActivityExcel : ExcelResource +{ + [JsonProperty("ID")] public uint ID { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("Condition")] public string Condition { get; set; } = ""; + [JsonProperty("LevelListID")] public List LevelListID { get; set; } = []; + + public override uint GetId() => ID; + + public override void Loaded() + { + GameData.DreamCardActivityData[ID] = this; + } +} diff --git a/Common/Data/Excel/FishingFoodExcel.cs b/Common/Data/Excel/FishingFoodExcel.cs new file mode 100644 index 0000000..0f3d7c5 --- /dev/null +++ b/Common/Data/Excel/FishingFoodExcel.cs @@ -0,0 +1,89 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/fishing/food.json")] +public class FishingFoodExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + [JsonProperty("FoodType")] public JToken? FoodTypeRaw { get; set; } + [JsonProperty("NeedItem")] public JToken? NeedItemRaw { get; set; } + [JsonProperty("CreateItems")] public JToken? CreateItemsRaw { get; set; } + [JsonProperty("EffectTime")] public JToken? EffectTimeRaw { get; set; } + [JsonProperty("FishingLevel")] public JToken? FishingLevelRaw { get; set; } + [JsonProperty("SeasonId")] public JToken? SeasonIdRaw { get; set; } + [JsonProperty("BaitNum")] public JToken? BaitNumRaw { get; set; } + [JsonProperty("FoodArea")] public JToken? FoodAreaRaw { get; set; } + + [JsonIgnore] public uint FoodType => ReadUInt(FoodTypeRaw); + [JsonIgnore] public uint EffectTime => ReadUInt(EffectTimeRaw); + [JsonIgnore] public uint FishingLevel => ReadUInt(FishingLevelRaw); + [JsonIgnore] public uint SeasonId => ReadUInt(SeasonIdRaw); + [JsonIgnore] public List> NeedItem => ReadNestedUIntList(NeedItemRaw); + [JsonIgnore] public List CreateItems => ReadUIntList(CreateItemsRaw); + [JsonIgnore] public List BaitNum => ReadUIntList(BaitNumRaw); + [JsonIgnore] public List FoodArea => ReadUIntList(FoodAreaRaw); + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.FishingFoodData[Id] = this; + } + + private static int ReadInt(JToken? token) + { + if (token == null) + return 0; + + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => (int)token.Value(), + JTokenType.String when int.TryParse(token.Value(), out var value) => value, + _ => 0 + }; + } + + private static uint ReadUInt(JToken? token) + { + var value = ReadInt(token); + return value > 0 ? (uint)value : 0; + } + + private static List ReadUIntList(JToken? token) + { + if (token is not JArray array) + return []; + + var result = new List(array.Count); + foreach (var item in array) + { + var value = ReadUInt(item); + if (value > 0) + result.Add(value); + } + + return result; + } + + private static List> ReadNestedUIntList(JToken? token) + { + if (token is not JArray array) + return []; + + var result = new List>(array.Count); + foreach (var row in array.OfType()) + { + var values = new List(row.Count); + foreach (var item in row) + { + values.Add(ReadUInt(item)); + } + result.Add(values); + } + + return result; + } +} diff --git a/Common/Data/Excel/IbGoodsExcel.cs b/Common/Data/Excel/IbGoodsExcel.cs new file mode 100644 index 0000000..c7bbfd4 --- /dev/null +++ b/Common/Data/Excel/IbGoodsExcel.cs @@ -0,0 +1,76 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("purchase/ibgoods.json")] +public class IbGoodsExcel : ExcelResource +{ + [JsonProperty("GoodsId")] private JToken? GoodsIdRaw { get; set; } + [JsonProperty("Type")] private JToken? TypeRaw { get; set; } + [JsonProperty("PreId")] private JToken? PreIdRaw { get; set; } + [JsonProperty("LimitTimes")] private JToken? LimitTimesRaw { get; set; } + [JsonProperty("Item")] private JToken? ItemRaw { get; set; } + [JsonProperty("Cost")] private JToken? CostRaw { get; set; } + [JsonProperty("Cost2")] private JToken? Cost2Raw { get; set; } + [JsonProperty("PcId")] public string PcId { get; set; } = ""; + [JsonProperty("IosId")] public string IosId { get; set; } = ""; + [JsonProperty("AndroidId")] public string AndroidId { get; set; } = ""; + + public override uint GetId() => GoodsId; + + public override void Loaded() + { + GameData.IbGoodsData[GoodsId] = this; + } + + [JsonIgnore] + public uint GoodsId => ReadUInt(GoodsIdRaw); + + [JsonIgnore] + public int Type => (int)ReadUInt(TypeRaw); + + [JsonIgnore] + public uint PreId => ReadUInt(PreIdRaw); + + [JsonIgnore] + public uint LimitTimes => ReadUInt(LimitTimesRaw); + + [JsonIgnore] + public List Item => ReadUIntList(ItemRaw); + + [JsonIgnore] + public List Cost => ReadUIntList(CostRaw); + + [JsonIgnore] + public List Cost2 => ReadUIntList(Cost2Raw); + + public string GetProductId() => + !string.IsNullOrWhiteSpace(PcId) ? PcId : + !string.IsNullOrWhiteSpace(AndroidId) ? AndroidId : + IosId; + + private static uint ReadUInt(JToken? token) + { + if (token == null || token.Type is JTokenType.Null or JTokenType.Undefined) + return 0; + + if (token.Type == JTokenType.Integer) + return token.Value(); + + if (token.Type == JTokenType.String && uint.TryParse(token.Value(), out var value)) + return value; + + return 0; + } + + private static List ReadUIntList(JToken? token) + { + if (token is not JArray array) + return []; + + return array + .Select(entry => entry.Type == JTokenType.Integer ? entry.Value() : 0u) + .ToList(); + } +} diff --git a/Common/Data/Excel/MonsterCardExcel.cs b/Common/Data/Excel/MonsterCardExcel.cs new file mode 100644 index 0000000..6861cf5 --- /dev/null +++ b/Common/Data/Excel/MonsterCardExcel.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("item/templates/monster_card.json")] +public class MonsterCardExcel : ExcelResource +{ + [JsonProperty("Genre")] public uint Genre { get; set; } + [JsonProperty("Detail")] public uint Detail { get; set; } + [JsonProperty("Particular")] public uint Particular { get; set; } + [JsonProperty("Level")] public uint Level { get; set; } + [JsonProperty("Color")] public uint Color { get; set; } + [JsonProperty("RikiId")] public uint RikiId { get; set; } + [JsonProperty("CostValue")] public uint CostValue { get; set; } + [JsonProperty("Exp")] public uint Exp { get; set; } + + [JsonIgnore] + public ulong TemplateId => GameResourceTemplateId.FromGdpl(Genre, Detail, Particular, Level); + + public override uint GetId() => Particular; + + public override void Loaded() + { + GameData.MonsterCardData[TemplateId] = this; + } +} diff --git a/Common/Data/Excel/OtherItemExcel.cs b/Common/Data/Excel/OtherItemExcel.cs index 685c0a1..2db7076 100644 --- a/Common/Data/Excel/OtherItemExcel.cs +++ b/Common/Data/Excel/OtherItemExcel.cs @@ -10,8 +10,17 @@ public class OtherItemExcel : ExcelResource public uint Detail { get; set; } public uint Particular { get; set; } public uint Level { get; set; } + public string LuaType { get; set; } = ""; + [JsonProperty("UseMode")] public JToken? UseModeRaw { get; set; } + [JsonProperty("Param1")] public JToken? Param1Raw { get; set; } [JsonProperty("GMnum")] public JToken? GMnumRaw { get; set; } + [JsonIgnore] + public uint UseMode => ReadUInt(UseModeRaw); + + [JsonIgnore] + public uint Param1 => ReadUInt(Param1Raw); + [JsonIgnore] public uint GMnum => ReadUInt(GMnumRaw); diff --git a/Common/Data/Excel/VirCaptureCaptureRegionExcel.cs b/Common/Data/Excel/VirCaptureCaptureRegionExcel.cs new file mode 100644 index 0000000..c1c3869 --- /dev/null +++ b/Common/Data/Excel/VirCaptureCaptureRegionExcel.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/captureregion.json")] +public class VirCaptureCaptureRegionExcel : ExcelResource +{ + [JsonProperty("Id")] public uint Id { get; set; } + [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; + [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("MapId")] public uint MapId { get; set; } + [JsonProperty("LevelRegionName")] public string LevelRegionName { get; set; } = ""; + + public override uint GetId() => Id; + + public override void Loaded() + { + GameData.VirCaptureCaptureRegionData[Id] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureLevelListExcel.cs b/Common/Data/Excel/VirCaptureLevelListExcel.cs new file mode 100644 index 0000000..4c4e945 --- /dev/null +++ b/Common/Data/Excel/VirCaptureLevelListExcel.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/levellist.json")] +public class VirCaptureLevelListExcel : ExcelResource +{ + [JsonProperty("Level")] public uint Level { get; set; } + [JsonProperty("Exp")] public uint Exp { get; set; } + [JsonProperty("Num")] public uint Num { get; set; } + [JsonProperty("MaxCost")] public uint MaxCost { get; set; } + [JsonProperty("Rewards")] public List> Rewards { get; set; } = []; + [JsonProperty("ExpUp")] public double ExpUp { get; set; } + + public override uint GetId() => Level; + + public override void Loaded() + { + GameData.VirCaptureLevelListData[Level] = this; + } +} diff --git a/Common/Data/Excel/VirCaptureTimeExcel.cs b/Common/Data/Excel/VirCaptureTimeExcel.cs index 2195228..9cf04c3 100644 --- a/Common/Data/Excel/VirCaptureTimeExcel.cs +++ b/Common/Data/Excel/VirCaptureTimeExcel.cs @@ -8,6 +8,8 @@ public class VirCaptureTimeExcel : ExcelResource [JsonProperty("Id")] public uint Id { get; set; } [JsonProperty("StartTime")] public string StartTime { get; set; } = ""; [JsonProperty("EndTime")] public string EndTime { get; set; } = ""; + [JsonProperty("CaptureRegionId")] public List CaptureRegionId { get; set; } = []; + [JsonProperty("MaxExp")] public uint MaxExp { get; set; } public override uint GetId() => Id; diff --git a/Common/Data/Excel/VirCaptureTowerExcel.cs b/Common/Data/Excel/VirCaptureTowerExcel.cs new file mode 100644 index 0000000..c7ee1d8 --- /dev/null +++ b/Common/Data/Excel/VirCaptureTowerExcel.cs @@ -0,0 +1,44 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace MikuSB.Data.Excel; + +[ResourceEntity("dlc/vircapture/tower.json")] +public class VirCaptureTowerExcel : ExcelResource +{ + [JsonProperty("ID")] public uint Id { get; set; } + [JsonProperty("Condition")] public JToken? ConditionRaw { get; set; } + [JsonProperty("MapID")] public uint MapId { get; set; } + [JsonProperty("TrialCard")] public List TrialCard { get; set; } = []; + [JsonProperty("TaskPath")] public string TaskPath { get; set; } = ""; + + [JsonIgnore] + public Dictionary Condition { get; } = []; + + public override uint GetId() => Id; + + public override void Loaded() + { + Condition.Clear(); + if (ConditionRaw is JObject obj) + { + foreach (var property in obj.Properties()) + { + if (!int.TryParse(property.Name, out var key)) + continue; + + uint value = 0; + if (property.Value.Type == JTokenType.Integer) + value = property.Value.Value(); + else if (property.Value.Type == JTokenType.String && + uint.TryParse(property.Value.Value(), out var parsed)) + value = parsed; + + if (value > 0) + Condition[key] = value; + } + } + + GameData.VirCaptureTowerData[Id] = this; + } +} diff --git a/Common/Data/GameData.cs b/Common/Data/GameData.cs index 08087f2..6c64591 100644 --- a/Common/Data/GameData.cs +++ b/Common/Data/GameData.cs @@ -53,6 +53,15 @@ public static class GameData public static Dictionary VirCaptureTimeData { get; private set; } = []; public static Dictionary VirCaptureSeasonData { get; private set; } = []; public static Dictionary VirCaptureTrialTimeData { get; private set; } = []; + public static Dictionary VirCaptureCaptureRegionData { get; private set; } = []; + public static Dictionary VirCaptureLevelListData { get; private set; } = []; + public static Dictionary MonsterCardData { get; private set; } = []; + public static Dictionary FishingFoodData { get; private set; } = []; + public static Dictionary VirCaptureTowerData { get; private set; } = []; + public static Dictionary DreamCardActivityData { get; private set; } = []; + public static Dictionary DlcActivityData { get; private set; } = []; + public static Dictionary BattlePassTimeData { get; private set; } = []; + public static Dictionary IbGoodsData { get; private set; } = []; } public static class GameResourceTemplateId diff --git a/GameServer/Game/Inventory/InventoryManager.cs b/GameServer/Game/Inventory/InventoryManager.cs index 6d56d64..e5c3956 100644 --- a/GameServer/Game/Inventory/InventoryManager.cs +++ b/GameServer/Game/Inventory/InventoryManager.cs @@ -208,6 +208,27 @@ private static uint GetWeaponBreak(uint level) return InventoryData.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); } + public async ValueTask AddMonsterCardItem(uint detail, uint particular, uint level = 1, bool sendPacket = true) + { + const ItemTypeEnum genre = ItemTypeEnum.TYPE_MONSTER_CARD; + var templateId = GameResourceTemplateId.FromGdpl((uint)genre, detail, particular, level); + if (!GameData.MonsterCardData.ContainsKey(templateId)) + return null; + + var monsterInfo = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = InventoryData.NextUniqueUid++, + ItemType = genre, + ItemCount = 1 + }; + InventoryData.Items[monsterInfo.UniqueId] = monsterInfo; + + if (sendPacket) await Player.SendPacket(new PacketNtfCallScript([monsterInfo])); + + return monsterInfo; + } + private static uint GetSuppliesMaxCount(SuppliesExcel suppliesData) => suppliesData.Genre == 5 && suppliesData.Detail == 4 ? 999999u : 99999u; diff --git a/GameServer/Game/Player/PlayerInstance.cs b/GameServer/Game/Player/PlayerInstance.cs index 7bf4d61..5aca457 100644 --- a/GameServer/Game/Player/PlayerInstance.cs +++ b/GameServer/Game/Player/PlayerInstance.cs @@ -291,6 +291,8 @@ public Proto.Player ToPlayerProto(bool includeSupportCards = true) Pid = (ulong)Data.Uid, Account = displayName, Provider = displayName, + Channel = "gm", + Subchannel = "gm", Name = displayName, Level = Data.Level, Sex = Data.Gender, @@ -328,6 +330,11 @@ public Proto.Player ToPlayerProto(bool includeSupportCards = true) proto.StrAttrs[ToShiftedAttrKey(x.Gid, x.Sid)] = x.Val; } + foreach (var (key, value) in BuildMoneySync()) + { + proto.Money[key] = value; + } + proto.ShowItems.AddRange(Data.ShowItems); return proto; @@ -381,6 +388,24 @@ public uint ToShiftedAttrKey(uint gid, uint sid) return (gid << 16) | sid; } + public Dictionary BuildMoneySync() + { + var currentMoney = (int)Math.Min(int.MaxValue, GetAttrValue(1, 3)); + var sync = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["."] = currentMoney, + ["gm.gm"] = currentMoney, + ["jinshan.jinshan"] = currentMoney, + ["pc_jinshan.pc_jinshan"] = currentMoney + }; + return sync; + } + + private uint GetAttrValue(uint gid, uint sid) + { + return Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid)?.Val ?? 0; + } + public void BuildPlayerAttr(bool additional = false) { var bootstrapAttrs = BuildLobbyBootstrapAttrs().ToList(); diff --git a/GameServer/Server/CallGS/CallGSRouter.cs b/GameServer/Server/CallGS/CallGSRouter.cs index 6ea0281..e3cadb3 100644 --- a/GameServer/Server/CallGS/CallGSRouter.cs +++ b/GameServer/Server/CallGS/CallGSRouter.cs @@ -8,6 +8,7 @@ public static class CallGSRouter { private static readonly Logger Logger = new("CallGS"); private static readonly Dictionary Handlers = []; + private const string UnavailableTipKey = "ui.TxtNotOpen"; public static void Init() { @@ -32,11 +33,13 @@ public static async Task Route(Connection connection, ReqCallGS req, ushort seqN catch (Exception e) { Logger.Error($"[{req.Api}] {e.Message}", e); + await SendUnavailableResponse(connection, req.Api); } return; } Logger.Error($"No handler for CallGS API: {req.Api}"); + await SendUnavailableResponse(connection, req.Api); } public static async Task SendScript(Connection connection, string api, string arg, NtfSyncPlayer extra = null!) @@ -44,4 +47,11 @@ public static async Task SendScript(Connection connection, string api, string ar var rsp = new NtfCallScript { Api = api, Arg = arg, ExtraSync = extra }; await connection.SendPacket(CmdIds.NtfScript, rsp); } + + private static Task SendUnavailableResponse(Connection connection, string api) + { + // Many client Lua handlers treat sErr/sError as a recoverable failure path, + // which is preferable to leaving the request hanging forever. + return SendScript(connection, api, $$"""{"sErr":"{{UnavailableTipKey}}","sError":"{{UnavailableTipKey}}"}"""); + } } diff --git a/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs b/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs index 6008508..46d6f1f 100644 --- a/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs +++ b/GameServer/Server/CallGS/Handlers/BattlePass/BattlePassLogic_ClientRefresh.cs @@ -1,73 +1,113 @@ -using System.Text.Json; -using System.Text.Json.Serialization; +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json.Nodes; namespace MikuSB.GameServer.Server.CallGS.Handlers.BattlePass; [CallGSApi("BattlePassLogic_ClientRefresh")] public class BattlePassLogic_ClientRefresh : ICallGSHandler { + private const uint GroupId = 25; + private const uint CurIdSid = 1; + public async Task Handle(Connection connection, string param, ushort seqNo) { - var req = string.IsNullOrEmpty(param) - ? new BattlePassLogicClientRefreshParam { ClickNum = 0 } - : JsonSerializer.Deserialize(param); - var clickNum = req?.ClickNum ?? 0; - var battlePassList = LoadBattlePassGoods(); - var playerLevel = connection.Player?.Data.Level ?? 0; - - var response = JsonSerializer.Serialize(new + var now = DateTime.Now; + var battlePass = ResolveCurrent(GameData.BattlePassTimeData.Values, now); + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + + if (battlePass == null) + { + SetAttr(player, CurIdSid, 0, sync); + await CallGSRouter.SendScript(connection, "BattlePassLogic_ClientRefresh", "{}", sync); + return; + } + + SetAttr(player, CurIdSid, battlePass.Id, sync); + + var response = new JsonObject { - clicknum = clickNum, - nBattlePassLevel = playerLevel, - nBattlePassExp = 0, - tbBattlePassList = battlePassList, - BattlePassList = battlePassList - }); - - await CallGSRouter.SendScript(connection, "BattlePassLogic_ClientRefresh", response); + ["nId"] = battlePass.Id, + ["nStartTime"] = ToUnixSeconds(ParseConfigTime(battlePass.StartTime)), + ["nEndTime"] = ToUnixSeconds(ParseConfigTime(battlePass.EndTime)) + }; + + await CallGSRouter.SendScript(connection, "BattlePassLogic_ClientRefresh", response.ToJsonString(), sync); } - private static JsonElement[] LoadBattlePassGoods() + private static BattlePassTimeExcel? ResolveCurrent(IEnumerable configs, DateTime now) { - var path = Path.Combine(AppContext.BaseDirectory, "Resources", "purchase", "ibgoods.json"); - if (!File.Exists(path)) - return []; - - using var doc = JsonDocument.Parse(File.ReadAllText(path)); - return doc.RootElement - .EnumerateArray() - .Where(row => IsBattlePassGoods(row)) - .Select(row => row.Clone()) - .ToArray(); + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now && x.End > x.Start); + return latestStarted?.Config; } - private static bool IsBattlePassGoods(JsonElement row) + private static DateTime? ParseConfigTime(string? raw) { - if (row.TryGetProperty("GoodsTag", out var goodsTag) && GetStringValue(goodsTag)?.Contains("battlepass", StringComparison.OrdinalIgnoreCase) == true) - return true; + if (string.IsNullOrWhiteSpace(raw)) + return null; - if (row.TryGetProperty("IosId", out var iosId) && GetStringValue(iosId)?.Contains("battlepass", StringComparison.OrdinalIgnoreCase) == true) - return true; + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; - if (row.TryGetProperty("AndroidId", out var androidId) && GetStringValue(androidId)?.Contains("battlepass", StringComparison.OrdinalIgnoreCase) == true) - return true; + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } - return false; + private static long ToUnixSeconds(DateTime? value) + { + return value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeSeconds() : 0L; } - private static string? GetStringValue(JsonElement element) + private static void SetAttr(PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) { - return element.ValueKind switch + var attr = GetOrCreateAttr(player, sid); + if (attr.Val != value) { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.GetRawText(), - _ => null - }; + attr.Val = value; + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } } -} -internal sealed class BattlePassLogicClientRefreshParam -{ - [JsonPropertyName("clicknum")] - public int ClickNum { get; set; } + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } } diff --git a/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs b/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs index 1ce535f..4434d6e 100644 --- a/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs +++ b/GameServer/Server/CallGS/Handlers/Chapter/Chapter_DealLevelSettlement.cs @@ -3,7 +3,9 @@ using System.Text.Json.Serialization; using MikuSB.GameServer.Game.BossPvp; using MikuSB.Proto; +using MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; using MikuSB.GameServer.Server.CallGS.Handlers.Tower; +using MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; namespace MikuSB.GameServer.Server.CallGS.Handlers.Chapter; @@ -27,6 +29,8 @@ private static JsonNode BuildSettlementPayload(Connection connection, string? sC { extraSync = null; + extraSync = null; + if (string.Equals(sCmd, "Chapter_LevelSettlement", StringComparison.Ordinal)) { return new JsonArray(); @@ -71,6 +75,21 @@ private static JsonNode BuildSettlementPayload(Connection connection, string? sC extraSync = sync; return response; } + + if (string.Equals(sCmd, "VirCaptureTower_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = VirCaptureTower_LevelSettlement.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } + + if (string.Equals(sCmd, "DreamCard_LevelSettlement", StringComparison.Ordinal)) + { + var (response, sync) = DreamCard_LevelSettlement.HandleSettlement(connection.Player!, tbParam); + extraSync = sync; + return response; + } + return tbParam?.DeepClone() ?? new JsonObject(); } diff --git a/GameServer/Server/CallGS/Handlers/DLC/DLCLogic_CheckOpenAct.cs b/GameServer/Server/CallGS/Handlers/DLC/DLCLogic_CheckOpenAct.cs new file mode 100644 index 0000000..b5b7958 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DLC/DLCLogic_CheckOpenAct.cs @@ -0,0 +1,112 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DLC; + +[CallGSApi("DLCLogic_CheckOpenAct")] +public class DLCLogic_CheckOpenAct : ICallGSHandler +{ + private const uint GroupId = 15; + private const uint ActIdSid = 1; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var now = DateTime.Now; + var act = ResolveCurrent(GameData.DlcActivityData.Values, now); + if (act == null) + { + await CallGSRouter.SendScript(connection, "DLCLogic_CheckOpenAct", "{\"bOpen\":false}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + SetAttr(player, ActIdSid, act.Id, sync); + + var response = new JsonObject + { + ["bOpen"] = true, + ["nId"] = act.Id, + ["nStartTime"] = ToUnixSeconds(ParseConfigTime(act.EnterStartTime)), + ["nEndTime"] = ToUnixSeconds(ParseConfigTime(act.CloseEndTime)) + }; + + await CallGSRouter.SendScript(connection, "DLCLogic_CheckOpenAct", response.ToJsonString(), sync); + } + + private static DlcActivityExcel? ResolveCurrent(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.EnterStartTime), + End = ParseConfigTime(x.CloseEndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now && x.End > x.Start); + return latestStarted?.Config; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static long ToUnixSeconds(DateTime? value) + { + return value.HasValue ? new DateTimeOffset(value.Value).ToUnixTimeSeconds() : 0L; + } + + private static void SetAttr(PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val != value) + { + attr.Val = value; + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } +} diff --git a/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_CheckOpen.cs b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_CheckOpen.cs new file mode 100644 index 0000000..20cbe07 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_CheckOpen.cs @@ -0,0 +1,59 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using System.Globalization; +using System.Text.Json.Nodes; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; + +[CallGSApi("DreamCard_CheckOpen")] +public class DreamCard_CheckOpen : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var now = DateTime.Now; + var ids = GameData.DreamCardActivityData.Values + .Where(x => IsOpen(x, now)) + .OrderBy(x => x.ID) + .Select(x => JsonValue.Create(x.ID)) + .ToArray(); + + var response = new JsonObject + { + ["tbID"] = new JsonArray(ids) + }; + + await CallGSRouter.SendScript(connection, "DreamCard_CheckOpen", response.ToJsonString()); + } + + private static bool IsOpen(DreamCardActivityExcel config, DateTime now) + { + var start = ParseConfigTime(config.StartTime); + if (!start.HasValue || start > now) + return false; + + var end = ParseConfigTime(config.EndTime); + if (end.HasValue && now >= end.Value) + return false; + + return string.IsNullOrWhiteSpace(config.Condition); + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } +} diff --git a/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_EnterLevel.cs new file mode 100644 index 0000000..a829217 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_EnterLevel.cs @@ -0,0 +1,227 @@ +using MikuSB.Data; +using MikuSB.Util; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; + +[CallGSApi("DreamCard_EnterLevel")] +public class DreamCard_EnterLevel : ICallGSHandler +{ + private static readonly Random Random = new(); + private static readonly Lazy LevelIndex = new(LoadLevelIndex); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId <= 0 || req.Diff <= 0 || req.Type is < 1 or > 3) + { + await CallGSRouter.SendScript(connection, "DreamCard_EnterLevel", "null"); + return; + } + + var now = DateTime.Now; + if (!IsAllowed(req, now)) + { + await CallGSRouter.SendScript(connection, "DreamCard_EnterLevel", "null"); + return; + } + + var response = new JsonObject + { + ["nSeed"] = Random.Next(1, 1_000_000_000), + ["nID"] = req.LevelId, + ["nDiff"] = req.Diff, + ["nType"] = req.Type + }; + + await CallGSRouter.SendScript(connection, "DreamCard_EnterLevel", response.ToJsonString()); + } + + private static bool IsAllowed(DreamCardEnterLevelParam req, DateTime now) + { + var index = LevelIndex.Value; + if (index == null) + return true; + + return req.Type switch + { + 1 => index.OpenOrdinaryLevelIds(now).Contains((uint)req.LevelId), + 2 => index.IsChallengeOpen((uint)req.LevelId, now), + 3 => index.IsEndlessOpen((uint)req.LevelId, now), + _ => false + }; + } + + private static DreamCardLevelIndex? LoadLevelIndex() + { + try + { + var resourceRoot = ConfigManager.Config.Path.ResourcePath; + var dreamCardRoot = Path.Combine(resourceRoot, "dlc", "DreamCard"); + + var ordinaryLevels = LoadJson>(Path.Combine(dreamCardRoot, "levellist.json")) ?? []; + var challengeLevels = LoadJson>(Path.Combine(dreamCardRoot, "challenge.json")) ?? []; + var endlessLevels = LoadJson>(Path.Combine(dreamCardRoot, "endless.json")) ?? []; + + return new DreamCardLevelIndex(ordinaryLevels, challengeLevels, endlessLevels); + } + catch + { + return null; + } + } + + private static T? LoadJson(string path) + { + if (!File.Exists(path)) + return default; + + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } +} + +internal sealed class DreamCardEnterLevelParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nDiff")] + public int Diff { get; set; } + + [JsonPropertyName("nType")] + public int Type { get; set; } + + [JsonPropertyName("nRoleId")] + public int RoleId { get; set; } +} + +internal sealed class DreamCardLevelIndex +{ + private readonly HashSet ordinaryLevelIds; + private readonly Dictionary challengeLevels; + private readonly Dictionary endlessLevels; + + public DreamCardLevelIndex( + IEnumerable ordinaryLevels, + IEnumerable challengeLevels, + IEnumerable endlessLevels) + { + ordinaryLevelIds = ordinaryLevels + .Where(x => x.LevelListId > 0) + .Select(x => x.LevelListId) + .ToHashSet(); + + this.challengeLevels = challengeLevels + .Where(x => x.ChallengeId > 0) + .GroupBy(x => x.ChallengeId) + .ToDictionary(x => x.Key, x => x.First()); + + this.endlessLevels = endlessLevels + .Where(x => x.EndlessId > 0) + .GroupBy(x => x.EndlessId) + .ToDictionary(x => x.Key, x => x.First()); + } + + public HashSet OpenOrdinaryLevelIds(DateTime now) + { + var ids = new HashSet(); + foreach (var activity in GameData.DreamCardActivityData.Values) + { + if (!IsActivityOpen(activity, now)) + continue; + + foreach (var id in activity.LevelListID) + { + if (ordinaryLevelIds.Contains(id)) + ids.Add(id); + } + } + + return ids; + } + + public bool IsChallengeOpen(uint id, DateTime now) + { + return challengeLevels.TryGetValue(id, out var entry) && IsWithin(entry.StartTime, entry.EndTime, now); + } + + public bool IsEndlessOpen(uint id, DateTime now) + { + return endlessLevels.TryGetValue(id, out var entry) && IsWithin(entry.StartTime, entry.EndTime, now); + } + + private static bool IsActivityOpen(Data.Excel.DreamCardActivityExcel config, DateTime now) + { + var start = ParseConfigTime(config.StartTime); + if (!start.HasValue || start > now) + return false; + + var end = ParseConfigTime(config.EndTime); + if (end.HasValue && now >= end.Value) + return false; + + return string.IsNullOrWhiteSpace(config.Condition); + } + + private static bool IsWithin(string? startRaw, string? endRaw, DateTime now) + { + var start = ParseConfigTime(startRaw); + if (!start.HasValue || now < start.Value) + return false; + + var end = ParseConfigTime(endRaw); + return !end.HasValue || now < end.Value; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } +} + +internal sealed class DreamCardOrdinaryLevelEntry +{ + [JsonPropertyName("LevelListID")] + public uint LevelListId { get; set; } +} + +internal sealed class DreamCardChallengeLevelEntry +{ + [JsonPropertyName("ChallengeId")] + public uint ChallengeId { get; set; } + + [JsonPropertyName("StartTime")] + public string StartTime { get; set; } = ""; + + [JsonPropertyName("EndTime")] + public string EndTime { get; set; } = ""; +} + +internal sealed class DreamCardEndlessLevelEntry +{ + [JsonPropertyName("EndlessID")] + public uint EndlessId { get; set; } + + [JsonPropertyName("StartTime")] + public string StartTime { get; set; } = ""; + + [JsonPropertyName("EndTime")] + public string EndTime { get; set; } = ""; +} diff --git a/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_LevelSettlement.cs new file mode 100644 index 0000000..b407d39 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_LevelSettlement.cs @@ -0,0 +1,281 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; + +[CallGSApi("DreamCard_LevelSettlement")] +public class DreamCard_LevelSettlement : ICallGSHandler +{ + private const uint LevelGroupId = 152; + private const uint LevelSubNum = 10; + private const int OrdinaryType = 1; + private const int ChallengeType = 2; + private const int EndlessType = 3; + + private static readonly Lazy SettlementIndex = new(LoadIndex); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = HandleSettlement(connection.Player!, JsonNode.Parse(param)); + await CallGSRouter.SendScript(connection, "DreamCard_LevelSettlement", response.ToJsonString(), sync); + } + + public static (JsonObject Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? tbParam) + { + var req = tbParam?.Deserialize(); + if (req == null || req.LevelId <= 0 || req.Diff <= 0 || req.Type is < OrdinaryType or > EndlessType) + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + + var sync = new NtfSyncPlayer(); + var response = new JsonObject + { + ["nID"] = req.LevelId, + ["nDiff"] = req.Diff, + ["nType"] = req.Type + }; + + switch (req.Type) + { + case OrdinaryType: + HandleOrdinary(player, sync, response, req); + break; + case ChallengeType: + HandleChallenge(player, sync, response, req); + break; + case EndlessType: + HandleEndless(response, req); + break; + } + + DatabaseHelper.SaveDatabaseType(player.Data); + return (response, sync); + } + + private static void HandleOrdinary(PlayerInstance player, NtfSyncPlayer sync, JsonObject response, DreamCardLevelSettlementParam req) + { + var baseSid = (uint)(LevelSubNum * req.LevelId); + + var passAttr = GetOrCreateAttr(player.Data, LevelGroupId, baseSid + 1); + passAttr.Val += 1; + SyncAttr(sync, player, passAttr); + + var diffAttr = GetOrCreateAttr(player.Data, LevelGroupId, baseSid + 2); + diffAttr.Val = Math.Max(diffAttr.Val, (uint)req.Diff); + SyncAttr(sync, player, diffAttr); + + var starAttr = GetOrCreateAttr(player.Data, LevelGroupId, baseSid + 3); + starAttr.Val = MergeDifficultyBits(starAttr.Val, req.Diff, req.StarValue); + SyncAttr(sync, player, starAttr); + + if (TryGetOrdinaryRewardId((uint)req.LevelId, (uint)req.Diff, out var rewardId) && rewardId > 0) + response["nRewardID"] = rewardId; + } + + private static void HandleChallenge(PlayerInstance player, NtfSyncPlayer sync, JsonObject response, DreamCardLevelSettlementParam req) + { + var baseSid = (uint)(LevelSubNum * req.LevelId); + var scoreSid = baseSid + (uint)req.Diff + 4; + + var currentScore = (uint)Math.Max(0, req.Score); + var scoreAttr = GetOrCreateAttr(player.Data, LevelGroupId, scoreSid); + var newRecord = currentScore > scoreAttr.Val; + scoreAttr.Val = Math.Max(scoreAttr.Val, currentScore); + SyncAttr(sync, player, scoreAttr); + + var challengePeriodId = ResolveCurrentChallengePeriodId(DateTime.Now); + if (challengePeriodId > 0) + { + var periodAttr = GetOrCreateAttr(player.Data, LevelGroupId, 0); + periodAttr.Val = challengePeriodId; + SyncAttr(sync, player, periodAttr); + } + + response["NewRecord"] = newRecord; + } + + private static void HandleEndless(JsonObject response, DreamCardLevelSettlementParam req) + { + response["NewRecord"] = false; + } + + private static uint MergeDifficultyBits(uint currentValue, int diff, int starMask) + { + var bitStart = Math.Max(0, diff - 1) * 3; + var result = currentValue; + for (var i = 0; i < 3; i++) + { + if (((starMask >> i) & 1) == 0) + continue; + + result |= 1u << (bitStart + i); + } + + return result; + } + + private static bool TryGetOrdinaryRewardId(uint levelId, uint diff, out uint rewardId) + { + rewardId = 0; + var index = SettlementIndex.Value; + if (index == null) + return false; + + return index.TryGetOrdinaryRewardId(levelId, diff, out rewardId); + } + + private static uint ResolveCurrentChallengePeriodId(DateTime now) + { + var index = SettlementIndex.Value; + return index?.ResolveCurrentChallengePeriodId(now) ?? 0; + } + + private static DreamCardSettlementIndex? LoadIndex() + { + try + { + var root = Path.Combine(MikuSB.Util.ConfigManager.Config.Path.ResourcePath, "dlc", "DreamCard"); + var ordinaryLevels = LoadJson>(Path.Combine(root, "levellist.json")) ?? []; + var challengeTimes = LoadJson>(Path.Combine(root, "chall_time.json")) ?? []; + return new DreamCardSettlementIndex(ordinaryLevels, challengeTimes); + } + catch + { + return null; + } + } + + private static T? LoadJson(string path) + { + if (!File.Exists(path)) + return default; + + return JsonSerializer.Deserialize(File.ReadAllText(path)); + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class DreamCardLevelSettlementParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nDiff")] + public int Diff { get; set; } + + [JsonPropertyName("nType")] + public int Type { get; set; } + + [JsonPropertyName("nStarValue")] + public int StarValue { get; set; } + + [JsonPropertyName("nScore")] + public int Score { get; set; } +} + +internal sealed class DreamCardSettlementIndex +{ + private readonly Dictionary<(uint LevelId, uint Diff), uint> ordinaryRewardIds; + private readonly List challengeTimes; + + public DreamCardSettlementIndex( + IEnumerable ordinaryLevels, + IEnumerable challengeTimes) + { + ordinaryRewardIds = ordinaryLevels + .Where(x => x.LevelListId > 0 && x.HardStage > 0) + .GroupBy(x => (x.LevelListId, x.HardStage)) + .ToDictionary(x => x.Key, x => x.First().RewardId); + + this.challengeTimes = challengeTimes.ToList(); + } + + public bool TryGetOrdinaryRewardId(uint levelId, uint diff, out uint rewardId) + { + return ordinaryRewardIds.TryGetValue((levelId, diff), out rewardId); + } + + public uint ResolveCurrentChallengePeriodId(DateTime now) + { + foreach (var entry in challengeTimes.OrderBy(x => x.ChallTimeId)) + { + var start = ParseConfigTime(entry.StartTime); + var end = ParseConfigTime(entry.EndTime); + if (!start.HasValue || !end.HasValue) + continue; + + if (start.Value <= now && now < end.Value) + return entry.ChallTimeId; + } + + return 0; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } +} + +internal sealed class DreamCardOrdinarySettlementEntry +{ + [JsonPropertyName("LevelListID")] + public uint LevelListId { get; set; } + + [JsonPropertyName("HardStage")] + public uint HardStage { get; set; } + + [JsonPropertyName("RewardID")] + public uint RewardId { get; set; } +} + +internal sealed class DreamCardChallengeTimeEntry +{ + [JsonPropertyName("ChallTimeID")] + public uint ChallTimeId { get; set; } + + [JsonPropertyName("StartTime")] + public string StartTime { get; set; } = ""; + + [JsonPropertyName("EndTime")] + public string EndTime { get; set; } = ""; +} diff --git a/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_UpdateData.cs b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_UpdateData.cs new file mode 100644 index 0000000..7498fdb --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/DreamCard/DreamCard_UpdateData.cs @@ -0,0 +1,59 @@ +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.DreamCard; + +[CallGSApi("DreamCard_UpdateData")] +public class DreamCard_UpdateData : ICallGSHandler +{ + private const uint DataGroupId = 62; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + var dirty = false; + + try + { + var entries = JsonSerializer.Deserialize>(param) ?? []; + foreach (var entry in entries) + { + if (entry.Id <= 0) + continue; + + var value = NormalizeJson(entry.Data); + player.SetStrAttr(DataGroupId, (uint)entry.Id, value); + sync.CustomStr[player.ToShiftedAttrKey(DataGroupId, (uint)entry.Id)] = value; + dirty = true; + } + } + catch + { + // Ignore malformed payloads so the client-side save queue can continue. + } + + if (dirty) + DatabaseHelper.SaveDatabaseType(player.Data); + + await CallGSRouter.SendScript(connection, "DreamCard_UpdateData", "{}", sync); + } + + private static string NormalizeJson(JsonElement data) + { + return data.ValueKind == JsonValueKind.Undefined + ? "null" + : data.GetRawText(); + } +} + +internal sealed class DreamCardUpdateDataEntry +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("data")] + public JsonElement Data { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Fishing/FishingServer_ConvertFood.cs b/GameServer/Server/CallGS/Handlers/Fishing/FishingServer_ConvertFood.cs new file mode 100644 index 0000000..1597cf8 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Fishing/FishingServer_ConvertFood.cs @@ -0,0 +1,245 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Fishing; + +[CallGSApi("FishingServer_ConvertFood")] +public class FishingServer_ConvertFood : ICallGSHandler +{ + private const uint FishingGroupId = 32; + private const uint CashGroupId = 1; + private const uint FoodBaseSid = 30000; + private const uint FoodAvaTimeSubType = 1; + private const uint ExploreAvaTimeSubType = 2; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.FoodId <= 0 || req.Num <= 0) + { + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", "{\"sError\":\"error.BadParam\"}"); + return; + } + + if (!GameData.FishingFoodData.TryGetValue((uint)req.FoodId, out var food)) + { + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", "{\"sError\":\"error.BadParam\"}"); + return; + } + + var count = Math.Max(1u, req.Num); + var sync = new NtfSyncPlayer(); + + if (!HasEnoughMaterials(player.InventoryManager.InventoryData, food.NeedItem, count) || + !HasEnoughCash(player.Data, food.BaitNum, count)) + { + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", "{\"sError\":\"tip.girlcard_cmd_err\"}"); + return; + } + + ConsumeMaterials(player.InventoryManager.InventoryData, food.NeedItem, count, sync.Items); + ConsumeCash(player, food.BaitNum, count, sync); + + var response = new JsonObject + { + ["nFoodID"] = req.FoodId + }; + + switch (food.FoodType) + { + case 1: + ApplyFoodDuration(player, food, FoodAvaTimeSubType, count, sync); + break; + case 2: + { + var rewards = await CreateItemsAsync(player, sync, food.CreateItems, count); + response["tbBait"] = rewards; + break; + } + case 3: + ApplyFoodDuration(player, food, ExploreAvaTimeSubType, count, sync); + break; + default: + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", "{\"sError\":\"error.BadParam\"}"); + return; + } + + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.Data); + + await CallGSRouter.SendScript(connection, "FishingServer_ConvertFood", response.ToJsonString(), sync); + } + + private static bool HasEnoughMaterials(InventoryData inventory, IEnumerable> costs, uint multiplier) + { + foreach (var cost in costs) + { + if (cost.Count < 5) + return false; + + var templateId = GameResourceTemplateId.FromGdpl(cost[0], cost[1], cost[2], cost[3]); + var item = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + var needCount = checked(cost[4] * multiplier); + if (item == null || item.ItemCount < needCount) + return false; + } + + return true; + } + + private static void ConsumeMaterials(InventoryData inventory, IEnumerable> costs, uint multiplier, ICollection syncItems) + { + foreach (var cost in costs) + { + var templateId = GameResourceTemplateId.FromGdpl(cost[0], cost[1], cost[2], cost[3]); + var item = inventory.Items.Values.First(x => x.TemplateId == templateId); + var needCount = checked(cost[4] * multiplier); + item.ItemCount -= needCount; + + if (item.ItemCount == 0) + { + inventory.Items.Remove(item.UniqueId); + var proto = item.ToProto(); + proto.Count = 0; + syncItems.Add(proto); + } + else + { + syncItems.Add(item.ToProto()); + } + } + } + + private static bool HasEnoughCash(PlayerGameData data, IReadOnlyList baitNum, uint multiplier) + { + if (baitNum.Count < 2) + return true; + + var moneyType = baitNum[0]; + var need = checked(baitNum[1] * multiplier); + var sid = moneyType * 2 + 1; + var attr = data.Attrs.FirstOrDefault(x => x.Gid == CashGroupId && x.Sid == sid); + return (attr?.Val ?? 0) >= need; + } + + private static void ConsumeCash(MikuSB.GameServer.Game.Player.PlayerInstance player, IReadOnlyList baitNum, uint multiplier, NtfSyncPlayer sync) + { + if (baitNum.Count < 2) + return; + + var moneyType = baitNum[0]; + var sid = moneyType * 2 + 1; + var need = checked(baitNum[1] * multiplier); + var attr = GetOrCreateAttr(player.Data, CashGroupId, sid); + attr.Val -= need; + SyncAttr(player, sync, attr); + } + + private static void ApplyFoodDuration(MikuSB.GameServer.Game.Player.PlayerInstance player, FishingFoodExcel food, uint subType, uint count, NtfSyncPlayer sync) + { + var sid = FoodBaseSid + food.Id * 10 + subType; + var attr = GetOrCreateAttr(player.Data, FishingGroupId, sid); + var now = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var startTime = Math.Max(attr.Val, now); + attr.Val = checked(startTime + food.EffectTime * count); + SyncAttr(player, sync, attr); + } + + private static async Task CreateItemsAsync(MikuSB.GameServer.Game.Player.PlayerInstance player, NtfSyncPlayer sync, IReadOnlyList createItem, uint multiplier) + { + var rewards = new JsonArray(); + if (createItem.Count < 5) + return rewards; + + var itemType = (ItemTypeEnum)createItem[0]; + var detail = createItem[1]; + var particular = createItem[2]; + var level = createItem[3]; + var totalCount = checked(createItem[4] * multiplier); + + switch (itemType) + { + case ItemTypeEnum.TYPE_SUPPLIES: + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(createItem[0], detail, particular, level); + if (GameData.SuppliesData.TryGetValue(templateId, out var supplies)) + { + var item = await player.InventoryManager.AddSuppliesItem(supplies, totalCount, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + case ItemTypeEnum.TYPE_USEABLE: + { + var item = AddOtherItem(player.InventoryManager.InventoryData, detail, particular, level, totalCount); + if (item != null) + sync.Items.Add(item.ToProto()); + break; + } + } + + rewards.Add(new JsonArray((int)createItem[0], (int)detail, (int)particular, (int)level, (int)totalCount)); + return rewards; + } + + private static BaseGameItemInfo? AddOtherItem(InventoryData inventory, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl((uint)ItemTypeEnum.TYPE_USEABLE, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return null; + + var maxCount = otherItem.GMnum > 0 ? otherItem.GMnum : 99999u; + var existing = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (existing != null) + { + existing.ItemCount = Math.Min(existing.ItemCount + count, maxCount); + return existing; + } + + var item = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = inventory.NextUniqueUid++, + ItemType = ItemTypeEnum.TYPE_USEABLE, + ItemCount = Math.Min(count, maxCount) + }; + inventory.Items[item.UniqueId] = item; + return item; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr { Gid = gid, Sid = sid, Val = 0 }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(MikuSB.GameServer.Game.Player.PlayerInstance player, NtfSyncPlayer sync, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class FishingConvertFoodParam +{ + [JsonPropertyName("nFoodID")] + public int FoodId { get; set; } + + [JsonPropertyName("nNum")] + public uint Num { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Misc/Adjust_Record.cs b/GameServer/Server/CallGS/Handlers/Misc/Adjust_Record.cs new file mode 100644 index 0000000..e14d2ae --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Misc/Adjust_Record.cs @@ -0,0 +1,59 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Misc; + +[CallGSApi("Adjust_Record")] +public class Adjust_Record : ICallGSHandler +{ + private const uint GroupId = 107; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.Type == 0) + { + await CallGSRouter.SendScript(connection, "Adjust_Record", "null"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + var attr = GetOrCreateAttr(player, req.Type); + + if (attr.Val == 0) + { + attr.Val = 1; + sync.Custom[player.ToPackedAttrKey(GroupId, req.Type)] = 1; + sync.Custom[player.ToShiftedAttrKey(GroupId, req.Type)] = 1; + DatabaseHelper.SaveDatabaseType(player.Data); + } + + await CallGSRouter.SendScript(connection, "Adjust_Record", "null", sync); + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } +} + +internal sealed class AdjustRecordParam +{ + [JsonPropertyName("nType")] + public uint Type { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Shop/IBLogic_BuyGoods.cs b/GameServer/Server/CallGS/Handlers/Shop/IBLogic_BuyGoods.cs new file mode 100644 index 0000000..d3c091a --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Shop/IBLogic_BuyGoods.cs @@ -0,0 +1,451 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Shop; + +[CallGSApi("IBLogic_BuyGoods")] +public class IBLogic_BuyGoods : ICallGSHandler +{ + private const uint BuyGroupId = 26; + private const uint RedGroupId = 113; + private const uint CashGroupId = 1; + private const uint BattlePassGroupId = 25; + private const uint BattlePassCurIdSid = 1; + private const uint BattlePassStatusSid = 2; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + var player = connection.Player!; + if (req?.Type == 3 && req.GoodsId > 0 && req.Count > 0) + { + await HandleBattlePassPurchase(connection, player, req); + return; + } + + if (req == null || + req.GoodsId == 0 || + req.Count == 0 || + !GameData.IbGoodsData.TryGetValue(req.GoodsId, out var goods)) + { + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (goods.LimitTimes > 0) + { + var buyAttr = GetOrCreateAttr(player, BuyGroupId, req.GoodsId); + if (buyAttr.Val >= goods.LimitTimes) + { + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", "{\"sErr\":\"tip.Mall_Limit_Buy\"}"); + return; + } + } + + var rewardItems = BuildRewardItems(goods, req); + if (rewardItems.Count == 0) + { + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var sync = new NtfSyncPlayer(); + foreach (var reward in rewardItems) + await GrantRewardAsync(player, sync, reward); + + var buyCountAttr = GetOrCreateAttr(player, BuyGroupId, req.GoodsId); + buyCountAttr.Val += req.Count; + SyncAttr(player, sync, buyCountAttr); + + var redAttr = GetOrCreateAttr(player, RedGroupId, req.GoodsId); + if (redAttr.Val == 0) + { + redAttr.Val = 1; + SyncAttr(player, sync, redAttr); + } + + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var responseGoods = new JsonArray(); + foreach (var reward in rewardItems) + { + var row = new JsonArray(); + foreach (var value in reward) + row.Add((int)value); + responseGoods.Add(row); + } + + var rsp = new JsonObject + { + ["nGoodsId"] = (int)req.GoodsId, + ["tbGoods"] = responseGoods + }; + + var productId = goods.GetProductId(); + if (!string.IsNullOrWhiteSpace(productId)) + rsp["sProductId"] = productId; + + var cost = req.Index == 2 ? goods.Cost2 : goods.Cost; + if (cost.Count >= 2) + rsp["nTotalPrice"] = (int)cost[1]; + + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", rsp.ToJsonString(), sync); + } + + private static async Task HandleBattlePassPurchase(Connection connection, PlayerInstance player, IbBuyGoodsParam req) + { + var sync = new NtfSyncPlayer(); + var battlePassId = ResolveCurrentBattlePassId(); + if (battlePassId > 0) + { + var curIdAttr = GetOrCreateAttr(player, BattlePassGroupId, BattlePassCurIdSid); + curIdAttr.Val = battlePassId; + SyncAttr(player, sync, curIdAttr); + } + + var statusAttr = GetOrCreateAttr(player, BattlePassGroupId, BattlePassStatusSid); + if (statusAttr.Val < 2) + { + statusAttr.Val = 2; + SyncAttr(player, sync, statusAttr); + } + + var buyCountAttr = GetOrCreateAttr(player, BuyGroupId, req.GoodsId); + buyCountAttr.Val += req.Count; + SyncAttr(player, sync, buyCountAttr); + + var redAttr = GetOrCreateAttr(player, RedGroupId, req.GoodsId); + if (redAttr.Val == 0) + { + redAttr.Val = 1; + SyncAttr(player, sync, redAttr); + } + + DatabaseHelper.SaveDatabaseType(player.Data); + + var rsp = new JsonObject + { + ["nGoodsId"] = (int)req.GoodsId, + ["tbGoods"] = new JsonArray() + }; + + await CallGSRouter.SendScript(connection, "IBLogic_BuyGoods", rsp.ToJsonString(), sync); + } + + private static List> BuildRewardItems(IbGoodsExcel goods, IbBuyGoodsParam req) + { + var rewards = new List>(); + + if (goods.Item.Count >= 4) + rewards.Add(WithCount(goods.Item, req.Count)); + + if (req.SelectItem1?.Count >= 4) + rewards.Add(WithCount(req.SelectItem1, req.Count)); + + if (req.SelectItem2?.Count >= 4) + rewards.Add(WithCount(req.SelectItem2, req.Count)); + + return rewards; + } + + private static List WithCount(IReadOnlyList item, uint buyCount) + { + var reward = item.Take(5).ToList(); + while (reward.Count < 5) + reward.Add(1); + + reward[4] = Math.Max(1u, reward[4]) * Math.Max(1u, buyCount); + return reward; + } + + private static async Task GrantRewardAsync(PlayerInstance player, NtfSyncPlayer sync, IReadOnlyList reward) + { + if (reward.Count < 5) + return; + + var itemType = (ItemTypeEnum)reward[0]; + var detail = reward[1]; + var particular = reward[2]; + var level = reward[3]; + var count = Math.Max(1u, reward[4]); + + switch (itemType) + { + case ItemTypeEnum.TYPE_CARD: + for (var i = 0u; i < count; i++) + { + var character = await player.CharacterManager.AddCharacter(itemType, detail, particular, level, sendPacket: false); + if (character != null) + sync.Items.Add(character.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON: + for (var i = 0u; i < count; i++) + { + var weapon = await player.InventoryManager.AddWeaponItem(itemType, detail, particular, level, sendPacket: false); + if (weapon != null) + sync.Items.Add(weapon.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPORT: + for (var i = 0u; i < count; i++) + { + var support = await player.InventoryManager.AddSupportCardItem(detail, particular, level, sendPacket: false); + if (support != null) + sync.Items.Add(support.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPLIES: + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(reward[0], detail, particular, level); + if (!GameData.SuppliesData.TryGetValue(templateId, out var supplies)) + break; + + var item = await player.InventoryManager.AddSuppliesItem(supplies, count, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + break; + } + case ItemTypeEnum.TYPE_USEABLE: + { + if (!TryGrantCashBox(player, sync, detail, particular, level, count)) + { + var item = AddOtherItem(player.InventoryManager.InventoryData, reward[0], detail, particular, level, count); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + case ItemTypeEnum.TYPE_WEAPON_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_HOUSE: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddHouseFurnitureItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_PROFILE: + case ItemTypeEnum.TYPE_FRAME: + case ItemTypeEnum.TYPE_BADGE: + case ItemTypeEnum.TYPE_COVER: + case ItemTypeEnum.TYPE_NAMECARD: + case ItemTypeEnum.TYPE_EXPRESSION: + case ItemTypeEnum.TYPE_BUBBLE: + case ItemTypeEnum.TYPE_ANALYST: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddProfileItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_MANIFESTATION: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddManifestationItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_AR: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddArItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CALL: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddCallItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + } + + private static BaseGameItemInfo? AddOtherItem(InventoryData inventory, uint genre, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(genre, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return null; + + var maxCount = otherItem.GMnum > 0 ? otherItem.GMnum : 99999u; + var existing = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (existing != null) + { + existing.ItemCount = Math.Min(existing.ItemCount + count, maxCount); + return existing; + } + + var item = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = inventory.NextUniqueUid++, + ItemType = ItemTypeEnum.TYPE_USEABLE, + ItemCount = Math.Min(count, maxCount) + }; + inventory.Items[item.UniqueId] = item; + return item; + } + + private static bool TryGrantCashBox(PlayerInstance player, NtfSyncPlayer sync, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl((uint)ItemTypeEnum.TYPE_USEABLE, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return false; + + uint moneyType = otherItem.LuaType switch + { + "money_box" => 1, + "gold_box" => 2, + "silver_box" => 3, + "vigor_box" => 4, + _ => 0 + }; + + if (moneyType == 0 || otherItem.Param1 == 0) + return false; + + var amount = checked(otherItem.Param1 * count); + var sid = moneyType * 2 + 1; + var attr = GetOrCreateAttr(player, CashGroupId, sid); + attr.Val += amount; + SyncAttr(player, sync, attr); + if (moneyType == 1) + { + foreach (var (key, value) in player.BuildMoneySync()) + sync.Money[key] = value; + } + return true; + } + + private static uint ResolveCurrentBattlePassId() + { + var now = DateTime.Now; + var parsed = GameData.BattlePassTimeData.Values + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config.Id; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now && x.End > x.Start); + return latestStarted?.Config.Id ?? 0; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint gid, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class IbBuyGoodsParam +{ + [JsonPropertyName("nType")] + public int Type { get; set; } + + [JsonPropertyName("nGoodsId")] + public uint GoodsId { get; set; } + + [JsonPropertyName("nCount")] + public uint Count { get; set; } + + [JsonPropertyName("nIndex")] + public int Index { get; set; } + + [JsonPropertyName("tbSelectItem1")] + public List? SelectItem1 { get; set; } + + [JsonPropertyName("tbSelectItem2")] + public List? SelectItem2 { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/Shop/IBLogic_GoodsRedDot.cs b/GameServer/Server/CallGS/Handlers/Shop/IBLogic_GoodsRedDot.cs new file mode 100644 index 0000000..8c2bea3 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/Shop/IBLogic_GoodsRedDot.cs @@ -0,0 +1,71 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.Shop; + +[CallGSApi("IBLogic_GoodsRedDot")] +public class IBLogic_GoodsRedDot : ICallGSHandler +{ + private const uint RedGroupId = 113; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req?.GoodsIds == null || req.GoodsIds.Count == 0) + { + await CallGSRouter.SendScript(connection, "IBLogic_GoodsRedDot", "null"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + var changed = false; + + foreach (var goodsId in req.GoodsIds.Where(x => x > 0).Distinct()) + { + var attr = GetOrCreateAttr(player, RedGroupId, goodsId); + if (attr.Val > 0) + continue; + + attr.Val = 1; + SyncAttr(player, sync, attr); + changed = true; + } + + if (changed) + DatabaseHelper.SaveDatabaseType(player.Data); + + await CallGSRouter.SendScript(connection, "IBLogic_GoodsRedDot", "null", sync); + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint gid, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class IbGoodsRedDotParam +{ + [JsonPropertyName("tbList")] + public List GoodsIds { get; set; } = []; +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureCaptureRewardResolver.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureCaptureRewardResolver.cs new file mode 100644 index 0000000..2c87845 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureCaptureRewardResolver.cs @@ -0,0 +1,134 @@ +using MikuSB.Data.Excel; +using MikuSB.Util; +using Newtonsoft.Json.Linq; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +internal static class VirCaptureCaptureRewardResolver +{ + private static readonly Lock CacheLock = new(); + private static readonly Dictionary> RegionCache = []; + private static readonly Dictionary>> BossCache = []; + + public static List? ResolveGdpl(VirCaptureCaptureRegionExcel captureRegion, uint regionId) + { + if (string.IsNullOrWhiteSpace(captureRegion.LevelRegionName)) + return null; + + var regionMap = GetOrLoadRegionMap(captureRegion.LevelRegionName); + if (!regionMap.TryGetValue(regionId, out var regionReward)) + return null; + + if (regionReward.PalType == 2) + return GetOrLoadBossMap(captureRegion.LevelRegionName).GetValueOrDefault(regionId); + + return regionReward.Rewards1; + } + + private static Dictionary GetOrLoadRegionMap(string mapName) + { + lock (CacheLock) + { + if (RegionCache.TryGetValue(mapName, out var cached)) + return cached; + + var loaded = new Dictionary(); + var path = Path.Combine( + ConfigManager.Config.Path.ResourcePath, + "dlc", + "vircapture", + mapName, + "region_info.json"); + + if (File.Exists(path)) + { + var array = JArray.Parse(File.ReadAllText(path)); + foreach (var token in array) + { + var id = ReadUInt(token["Id"]); + if (id == 0) + continue; + + loaded[id] = new VirCaptureLevelRegionReward + { + PalType = ReadInt(token["PalType"]), + Rewards1 = token["Rewards1"]?.ToObject>() ?? [] + }; + } + } + + RegionCache[mapName] = loaded; + return loaded; + } + } + + private static Dictionary> GetOrLoadBossMap(string mapName) + { + lock (CacheLock) + { + if (BossCache.TryGetValue(mapName, out var cached)) + return cached; + + var loaded = new Dictionary>(); + var path = Path.Combine( + ConfigManager.Config.Path.ResourcePath, + "dlc", + "vircapture", + mapName, + "boss.json"); + + if (File.Exists(path)) + { + var array = JArray.Parse(File.ReadAllText(path)); + foreach (var token in array) + { + var regionId = ReadUInt(token["RegionId"]); + var boss = token["Boss"]?.ToObject>(); + if (regionId == 0 || boss == null || boss.Count < 4) + continue; + + loaded.TryAdd(regionId, boss); + } + } + + BossCache[mapName] = loaded; + return loaded; + } + } + + private sealed class VirCaptureLevelRegionReward + { + public int PalType { get; init; } + public List Rewards1 { get; init; } = []; + } + + private static uint ReadUInt(JToken? token) + { + if (token == null || token.Type == JTokenType.Null) + return 0; + + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => Math.Max(0u, (uint)token.Value()), + JTokenType.String when uint.TryParse(token.Value(), out var value) => value, + JTokenType.String => 0, + _ => 0 + }; + } + + private static int ReadInt(JToken? token) + { + if (token == null || token.Type == JTokenType.Null) + return 0; + + return token.Type switch + { + JTokenType.Integer => token.Value(), + JTokenType.Float => (int)token.Value(), + JTokenType.String when int.TryParse(token.Value(), out var value) => value, + JTokenType.String => 0, + _ => 0 + }; + } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_ChangeFlag.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_ChangeFlag.cs new file mode 100644 index 0000000..98ea278 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_ChangeFlag.cs @@ -0,0 +1,40 @@ +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_ChangeFlag")] +public class VirCaptureLevel_ChangeFlag : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.RegionId == 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_ChangeFlag", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + VirCaptureStateHelper.SetPointState(player, (uint)req.LevelId, (uint)req.RegionId, req.Clean ? 0u : 1u, sync); + + DatabaseHelper.SaveDatabaseType(player.Data); + var rsp = $"{{\"nLevelID\":{req.LevelId},\"nRegionId\":{req.RegionId},\"bClean\":{req.Clean.ToString().ToLowerInvariant()}}}"; + await CallGSRouter.SendScript(connection, "VirCaptureLevel_ChangeFlag", rsp, sync); + } +} + +internal sealed class VirCaptureChangeFlagParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nRegionId")] + public int RegionId { get; set; } + + [JsonPropertyName("bClean")] + public bool Clean { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_EnterLevel.cs new file mode 100644 index 0000000..f5933ef --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_EnterLevel.cs @@ -0,0 +1,171 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_EnterLevel")] +public class VirCaptureLevel_EnterLevel : ICallGSHandler +{ + private const uint GroupId = 128; + private const uint MapDataStart = 10000; + private const uint MaxMapCount = 3; + private const uint MaxMapDataLen = 3000; + private const uint OffMapId = 1; + private const uint OffDayNight = 7; + private const uint OffMapLevel = 8; + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.TeamId <= 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var now = DateTime.Now; + var act = ResolveCurrent(GameData.VirCaptureTimeData.Values, now); + if (act == null || !act.CaptureRegionId.Contains((uint)req.LevelId)) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", "{\"sErr\":\"ui.TxtNotOpen\"}"); + return; + } + + if (!GameData.VirCaptureCaptureRegionData.TryGetValue((uint)req.LevelId, out var region)) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var regionStart = ParseConfigTime(region.StartTime); + var regionEnd = ParseConfigTime(region.EndTime); + if (!regionStart.HasValue || !regionEnd.HasValue || now < regionStart.Value || now >= regionEnd.Value) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", "{\"sErr\":\"ui.TxtNotOpen\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + EnsureMapState(player, (uint)req.LevelId, sync); + + var rsp = $"{{\"nSeed\":{Random.Next(1, 1_000_000_000)}}}"; + await CallGSRouter.SendScript(connection, "VirCaptureLevel_EnterLevel", rsp, sync); + } + + private static void EnsureMapState(PlayerInstance player, uint levelId, NtfSyncPlayer sync) + { + var slotStart = FindOrAllocateMapSlot(player, levelId); + if (slotStart == 0) + return; + + EnsureMapAttr(player, slotStart + OffMapId, levelId, sync); + EnsureMapAttr(player, slotStart + OffDayNight, 1, sync); + EnsureMapAttr(player, slotStart + OffMapLevel, 1, sync); + } + + private static uint FindOrAllocateMapSlot(PlayerInstance player, uint levelId) + { + uint? emptySlot = null; + for (uint i = 0; i < MaxMapCount; i++) + { + var slotStart = MapDataStart + (i * MaxMapDataLen); + var mapIdAttr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == slotStart + OffMapId); + if (mapIdAttr?.Val == levelId) + return slotStart; + + if (emptySlot == null && (mapIdAttr == null || mapIdAttr.Val == 0)) + emptySlot = slotStart; + } + + return emptySlot ?? 0; + } + + private static void EnsureMapAttr(PlayerInstance player, uint sid, uint minValue, NtfSyncPlayer sync) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr == null) + { + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid, + Val = minValue + }; + player.Data.Attrs.Add(attr); + SyncAttr(player, sync, sid, minValue); + return; + } + + if (attr.Val < minValue) + { + attr.Val = minValue; + SyncAttr(player, sync, sid, attr.Val); + } + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, uint sid, uint value) + { + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } + + private static VirCaptureTimeExcel? ResolveCurrent(IEnumerable configs, DateTime now) + { + var parsed = configs + .Select(x => new + { + Config = x, + Start = ParseConfigTime(x.StartTime), + End = ParseConfigTime(x.EndTime) + }) + .Where(x => x.Start.HasValue && x.End.HasValue) + .OrderBy(x => x.Start) + .ToList(); + + var current = parsed.FirstOrDefault(x => x.Start <= now && now < x.End); + if (current != null) + return current.Config; + + var latestStarted = parsed.LastOrDefault(x => x.Start <= now); + if (latestStarted != null && latestStarted.End > latestStarted.Start) + return latestStarted.Config; + + return null; + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var value) + ? value + : null; + } +} + +internal sealed class VirCaptureEnterLevelParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTeamID")] + public int TeamId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveCapture.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveCapture.cs new file mode 100644 index 0000000..c7854dc --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveCapture.cs @@ -0,0 +1,222 @@ +using MikuSB.Database; +using MikuSB.Data; +using MikuSB.Enums.Item; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_SaveCapture")] +public class VirCaptureLevel_SaveCapture : ICallGSHandler +{ + private const uint VirCaptureGroupId = 128; + private const uint CurExpSid = 2; + private const uint CurLevelSid = 3; + private const uint BagNumSid = 5; + private const uint DailyExpSid = 8; + private const uint ColorMaxStartSid = 11; + private const uint RikiGroupId = 135; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.RegionId == 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + VirCaptureStateHelper.SetPointState(player, (uint)req.LevelId, (uint)req.RegionId, 2u, sync); + + if (!GameData.VirCaptureCaptureRegionData.TryGetValue((uint)req.LevelId, out var captureRegion)) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var rewardGdpl = VirCaptureCaptureRewardResolver.ResolveGdpl(captureRegion, (uint)req.RegionId); + if (rewardGdpl == null || rewardGdpl.Count < 4 || rewardGdpl[0] != (uint)ItemTypeEnum.TYPE_MONSTER_CARD) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", "{\"sErr\":\"error.BadParam\"}", sync); + return; + } + + var grantedItem = await player.InventoryManager.AddMonsterCardItem( + rewardGdpl[1], + rewardGdpl[2], + rewardGdpl[3], + sendPacket: false); + if (grantedItem == null) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", "{\"sErr\":\"error.BadParam\"}", sync); + return; + } + + sync.Items.Add(grantedItem.ToProto()); + SyncVirCaptureCounters(player, grantedItem.TemplateId, sync); + ApplyCaptureExp(player, grantedItem.TemplateId, sync); + + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + + var response = new JsonObject + { + ["nLevelID"] = req.LevelId, + ["nRegionId"] = req.RegionId, + ["nAddItemId"] = grantedItem.UniqueId, + ["tbGDPL"] = new JsonArray(rewardGdpl.Select(x => JsonValue.Create((int)x)).ToArray()) + }; + + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveCapture", response.ToJsonString(), sync); + } + + private static void SyncVirCaptureCounters(MikuSB.GameServer.Game.Player.PlayerInstance player, ulong templateId, NtfSyncPlayer sync) + { + var bagCount = (uint)player.InventoryManager.InventoryData.Items.Values.Count(x => x.ItemType == ItemTypeEnum.TYPE_MONSTER_CARD); + VirCaptureStateHelper.SetUnsignedAttr(player, BagNumSid, bagCount, sync); + + if (!GameData.MonsterCardData.TryGetValue(templateId, out var monsterCard) || monsterCard.RikiId == 0) + return; + + var colorSid = ColorMaxStartSid + Math.Max(0u, monsterCard.Color - 1u); + var colorAttr = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == colorSid); + var nextColorValue = (colorAttr?.Val ?? 0) + 1; + VirCaptureStateHelper.SetUnsignedAttr(player, colorSid, nextColorValue, sync); + + var rikiAttr = player.Data.Attrs.FirstOrDefault(x => x.Gid == RikiGroupId && x.Sid == monsterCard.RikiId); + if (rikiAttr == null) + { + rikiAttr = new Database.Player.PlayerAttr + { + Gid = RikiGroupId, + Sid = monsterCard.RikiId, + Val = 0 + }; + player.Data.Attrs.Add(rikiAttr); + } + + rikiAttr.Val += 1; + sync.Custom[player.ToPackedAttrKey(RikiGroupId, monsterCard.RikiId)] = rikiAttr.Val; + sync.Custom[player.ToShiftedAttrKey(RikiGroupId, monsterCard.RikiId)] = rikiAttr.Val; + } + + private static void ApplyCaptureExp(MikuSB.GameServer.Game.Player.PlayerInstance player, ulong templateId, NtfSyncPlayer sync) + { + if (!GameData.MonsterCardData.TryGetValue(templateId, out var monsterCard) || monsterCard.Exp == 0) + return; + + var curLevelAttr = GetOrCreateVirCaptureAttr(player, CurLevelSid); + var curExpAttr = GetOrCreateVirCaptureAttr(player, CurExpSid); + var dailyExpAttr = GetOrCreateVirCaptureAttr(player, DailyExpSid); + + var maxLevel = GameData.VirCaptureLevelListData.Count == 0 ? 1u : GameData.VirCaptureLevelListData.Keys.Max(); + var curLevel = Math.Max(1u, curLevelAttr.Val); + if (curLevel >= maxLevel) + return; + + var baseExp = monsterCard.Exp; + if (GameData.VirCaptureLevelListData.TryGetValue(curLevel, out var currentLevelCfg) && currentLevelCfg.ExpUp > 1d) + baseExp = (uint)Math.Floor(baseExp * currentLevelCfg.ExpUp); + + var maxDailyExp = ResolveCurrentAct(player)?.MaxExp ?? 0u; + if (maxDailyExp > 0 && dailyExpAttr.Val >= maxDailyExp) + return; + + var gainExp = baseExp; + if (maxDailyExp > 0) + gainExp = Math.Min(gainExp, maxDailyExp - dailyExpAttr.Val); + + if (gainExp == 0) + return; + + dailyExpAttr.Val += gainExp; + SyncVirCaptureAttr(player, DailyExpSid, dailyExpAttr.Val, sync); + + var pendingExp = curExpAttr.Val + gainExp; + while (GameData.VirCaptureLevelListData.TryGetValue(curLevel, out var levelCfg) && curLevel < maxLevel) + { + if (pendingExp < levelCfg.Exp) + break; + + pendingExp -= levelCfg.Exp; + curLevel++; + } + + curLevelAttr.Val = curLevel; + curExpAttr.Val = curLevel >= maxLevel + ? GameData.VirCaptureLevelListData.GetValueOrDefault(maxLevel)?.Exp ?? pendingExp + : pendingExp; + + SyncVirCaptureAttr(player, CurLevelSid, curLevelAttr.Val, sync); + SyncVirCaptureAttr(player, CurExpSid, curExpAttr.Val, sync); + } + + private static Database.Player.PlayerAttr GetOrCreateVirCaptureAttr(MikuSB.GameServer.Game.Player.PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new Database.Player.PlayerAttr + { + Gid = VirCaptureGroupId, + Sid = sid, + Val = 0 + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncVirCaptureAttr(MikuSB.GameServer.Game.Player.PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + sync.Custom[player.ToPackedAttrKey(VirCaptureGroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(VirCaptureGroupId, sid)] = value; + } + + private static MikuSB.Data.Excel.VirCaptureTimeExcel? ResolveCurrentAct(MikuSB.GameServer.Game.Player.PlayerInstance player) + { + var actId = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == 1)?.Val ?? 0; + if (actId > 0 && GameData.VirCaptureTimeData.TryGetValue(actId, out var act)) + return act; + + var now = DateTime.Now; + return GameData.VirCaptureTimeData.Values + .Select(x => new { Config = x, Start = ParseConfigTime(x.StartTime), End = ParseConfigTime(x.EndTime) }) + .Where(x => x.Start.HasValue && x.End.HasValue && x.Start <= now && now < x.End) + .OrderBy(x => x.Start) + .Select(x => x.Config) + .FirstOrDefault(); + } + + private static DateTime? ParseConfigTime(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + + var normalized = raw.Trim().Trim('[', ']'); + if (normalized.Length != 12) + return null; + + return DateTime.TryParseExact( + normalized, + "yyyyMMddHHmm", + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out var value) + ? value + : null; + } +} + +internal sealed class VirCaptureSaveCaptureParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nRegionId")] + public int RegionId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveFightData.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveFightData.cs new file mode 100644 index 0000000..1e94d3d --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SaveFightData.cs @@ -0,0 +1,45 @@ +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_SaveFightData")] +public class VirCaptureLevel_SaveFightData : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0 || req.RegionId == 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveFightData", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + VirCaptureStateHelper.SetPointState(player, (uint)req.LevelId, (uint)req.RegionId, 2u, sync); + + DatabaseHelper.SaveDatabaseType(player.Data); + + var response = new JsonObject + { + ["nLevelID"] = req.LevelId, + ["nRegionId"] = req.RegionId, + ["tbRewards"] = new JsonArray() + }; + + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SaveFightData", response.ToJsonString(), sync); + } +} + +internal sealed class VirCaptureSaveFightDataParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nRegionId")] + public int RegionId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SavePos.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SavePos.cs new file mode 100644 index 0000000..a080b0e --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureLevel_SavePos.cs @@ -0,0 +1,48 @@ +using MikuSB.Database; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureLevel_SavePos")] +public class VirCaptureLevel_SavePos : ICallGSHandler +{ + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId == 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SavePos", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var sync = new NtfSyncPlayer(); + VirCaptureStateHelper.SetSignedMapOffset(player, (uint)req.LevelId, VirCaptureStateHelper.OffPosX, req.PosX, sync); + VirCaptureStateHelper.SetSignedMapOffset(player, (uint)req.LevelId, VirCaptureStateHelper.OffPosY, req.PosY, sync); + VirCaptureStateHelper.SetSignedMapOffset(player, (uint)req.LevelId, VirCaptureStateHelper.OffPosZ, req.PosZ, sync); + VirCaptureStateHelper.SetSignedMapOffset(player, (uint)req.LevelId, VirCaptureStateHelper.OffToward, req.Toward, sync); + + DatabaseHelper.SaveDatabaseType(player.Data); + await CallGSRouter.SendScript(connection, "VirCaptureLevel_SavePos", "{}", sync); + } +} + +internal sealed class VirCaptureSavePosParam +{ + [JsonPropertyName("nLevelID")] + public int LevelId { get; set; } + + [JsonPropertyName("nPosX")] + public int PosX { get; set; } + + [JsonPropertyName("nPosY")] + public int PosY { get; set; } + + [JsonPropertyName("nPosZ")] + public int PosZ { get; set; } + + [JsonPropertyName("nToward")] + public int Toward { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureStateHelper.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureStateHelper.cs new file mode 100644 index 0000000..371cc40 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureStateHelper.cs @@ -0,0 +1,157 @@ +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +internal static class VirCaptureStateHelper +{ + public const uint GroupId = 128; + public const uint MapDataStart = 10000; + public const uint MapDataEnd = 19000; + public const uint MaxMapCount = 3; + public const uint MaxMapDataLen = 3000; + public const uint MaxPatrolPoint = 500; + public const uint MaxOtherPoint = 2500; + public const uint MinMaterialId = 50000; + public const uint MaxMaterialId = 51500; + + public const uint OffMapId = 1; + public const uint OffTurnNum = 2; + public const uint OffPosX = 3; + public const uint OffPosY = 4; + public const uint OffPosZ = 5; + public const uint OffToward = 6; + public const uint OffDayNight = 7; + public const uint OffMapLevel = 8; + public const uint OffPatrolStart = 51; + public const uint OffPatrolEnd = 1000; + public const uint OffOtherStart = 1001; + public const uint OffOtherEnd = 1500; + public const uint OffMaterialStart = 1501; + public const uint OffMaterialEnd = 3000; + + public static uint FindOrAllocateMapSlot(PlayerInstance player, uint levelId) + { + uint? emptySlot = null; + for (uint i = 0; i < MaxMapCount; i++) + { + var slotStart = MapDataStart + (i * MaxMapDataLen); + var mapIdAttr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == slotStart + OffMapId); + if (mapIdAttr?.Val == levelId) + return slotStart; + + if (emptySlot == null && (mapIdAttr == null || mapIdAttr.Val == 0)) + emptySlot = slotStart; + } + + return emptySlot ?? 0; + } + + public static void EnsureBaseMapState(PlayerInstance player, uint levelId, NtfSyncPlayer sync) + { + var slotStart = FindOrAllocateMapSlot(player, levelId); + if (slotStart == 0) + return; + + EnsureUnsignedAttr(player, slotStart + OffMapId, levelId, sync); + EnsureUnsignedAttr(player, slotStart + OffDayNight, 1, sync); + EnsureUnsignedAttr(player, slotStart + OffMapLevel, 1, sync); + } + + public static void SetSignedMapOffset(PlayerInstance player, uint levelId, uint offset, int value, NtfSyncPlayer sync) + { + var slotStart = FindOrAllocateMapSlot(player, levelId); + if (slotStart == 0) + return; + + EnsureBaseMapState(player, levelId, sync); + SetUnsignedAttr(player, slotStart + offset, unchecked((uint)value), sync); + } + + public static void SetPointState(PlayerInstance player, uint levelId, uint pointId, uint value, NtfSyncPlayer sync) + { + var slotStart = FindOrAllocateMapSlot(player, levelId); + if (slotStart == 0 || pointId == 0) + return; + + EnsureBaseMapState(player, levelId, sync); + + if (pointId <= MaxPatrolPoint) + { + var sid = slotStart + (OffPatrolStart - 1) + pointId; + SetUnsignedAttr(player, sid, value, sync); + return; + } + + if (pointId <= MaxOtherPoint) + { + var relative = pointId - MaxPatrolPoint; + var sid = slotStart + (uint)Math.Floor(relative / 30d) + OffOtherStart; + if (sid > slotStart + OffOtherEnd) + return; + + var bit = (int)(relative % 30); + var attr = GetOrCreateAttr(player, sid); + var next = value > 0 + ? attr.Val | (1u << bit) + : attr.Val & ~(1u << bit); + if (next != attr.Val) + { + attr.Val = next; + SyncAttr(player, sync, sid, next); + } + return; + } + + if (pointId > MinMaterialId && pointId <= MaxMaterialId) + { + var sid = slotStart + (OffMaterialStart - 1) + (pointId - MinMaterialId); + if (sid >= slotStart + OffMaterialEnd) + return; + + SetUnsignedAttr(player, sid, value, sync); + } + } + + public static void EnsureUnsignedAttr(PlayerInstance player, uint sid, uint minValue, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val < minValue) + { + attr.Val = minValue; + SyncAttr(player, sync, sid, attr.Val); + } + } + + public static void SetUnsignedAttr(PlayerInstance player, uint sid, uint value, NtfSyncPlayer sync) + { + var attr = GetOrCreateAttr(player, sid); + if (attr.Val != value) + { + attr.Val = value; + SyncAttr(player, sync, sid, value); + } + } + + private static PlayerAttr GetOrCreateAttr(PlayerInstance player, uint sid) + { + var attr = player.Data.Attrs.FirstOrDefault(x => x.Gid == GroupId && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = GroupId, + Sid = sid + }; + player.Data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(PlayerInstance player, NtfSyncPlayer sync, uint sid, uint value) + { + sync.Custom[player.ToPackedAttrKey(GroupId, sid)] = value; + sync.Custom[player.ToShiftedAttrKey(GroupId, sid)] = value; + } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_EnterLevel.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_EnterLevel.cs new file mode 100644 index 0000000..7e4289b --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_EnterLevel.cs @@ -0,0 +1,79 @@ +using MikuSB.Data; +using MikuSB.Database.Player; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureTower_EnterLevel")] +public class VirCaptureTower_EnterLevel : ICallGSHandler +{ + private const uint LaunchPassGroupId = 22; + private const uint VirCaptureGroupId = 128; + private const uint VirCaptureLevelSid = 3; + private static readonly Random Random = new(); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null || req.LevelId <= 0 || req.TeamId <= 0) + { + await CallGSRouter.SendScript(connection, "VirCaptureTower_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (!GameData.VirCaptureTowerData.TryGetValue((uint)req.LevelId, out var levelCfg)) + { + await CallGSRouter.SendScript(connection, "VirCaptureTower_EnterLevel", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + if (!CheckConditions(player.Data, levelCfg.Condition)) + { + await CallGSRouter.SendScript(connection, "VirCaptureTower_EnterLevel", "{\"sErr\":\"tip.LevelLocked\"}"); + return; + } + + await CallGSRouter.SendScript(connection, "VirCaptureTower_EnterLevel", $"{{\"nSeed\":{Random.Next(1, 1_000_000_000)}}}"); + } + + private static bool CheckConditions(PlayerGameData data, IReadOnlyDictionary conditions) + { + foreach (var (key, value) in conditions) + { + switch (key) + { + case 1: + if (data.Level < value) + return false; + break; + case 2: + { + var pass = data.Attrs.FirstOrDefault(x => x.Gid == LaunchPassGroupId && x.Sid == value)?.Val ?? 0; + if (pass == 0) + return false; + break; + } + case 20: + { + var virLevel = data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == VirCaptureLevelSid)?.Val ?? 0; + if (virLevel < value) + return false; + break; + } + } + } + + return true; + } +} + +internal sealed class VirCaptureTowerEnterLevelParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nTeamID")] + public int TeamId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_LevelSettlement.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_LevelSettlement.cs new file mode 100644 index 0000000..1f5981a --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCaptureTower_LevelSettlement.cs @@ -0,0 +1,94 @@ +using MikuSB.Database; +using MikuSB.Database.Player; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using MikuSB.Util; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCaptureTower_LevelSettlement")] +public class VirCaptureTower_LevelSettlement : ICallGSHandler +{ + private const uint LaunchLevelStateGroupId = 21; + private const uint LaunchPassGroupId = 22; + private const uint PassedFlagBit = 1u << 8; + private static readonly Logger Logger = new("VirCaptureTower"); + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var (response, sync) = HandleSettlement(connection.Player!, JsonNode.Parse(param)); + await CallGSRouter.SendScript(connection, "VirCaptureTower_LevelSettlement", response.ToJsonString(), sync); + } + + public static (JsonNode Response, NtfSyncPlayer Sync) HandleSettlement(PlayerInstance player, JsonNode? tbParam) + { + var req = tbParam?.Deserialize(); + if (req == null || req.LevelId == 0) + { + Logger.Error($"Invalid vircapture tower settlement payload: {tbParam?.ToJsonString() ?? "null"}"); + return (new JsonObject { ["sErr"] = "error.BadParam" }, new NtfSyncPlayer()); + } + + var sync = new NtfSyncPlayer(); + + var levelStateAttr = GetOrCreateAttr(player.Data, LaunchLevelStateGroupId, (uint)req.LevelId); + levelStateAttr.Val |= MergeStarMask(req.StarMask) | PassedFlagBit; + SyncAttr(sync, player, levelStateAttr); + + var passAttr = GetOrCreateAttr(player.Data, LaunchPassGroupId, (uint)req.LevelId); + passAttr.Val = Math.Max(1u, passAttr.Val + 1); + SyncAttr(sync, player, passAttr); + + Logger.Info( + $"VirCaptureTower settlement saved. uid={player.Uid} levelId={req.LevelId} starMask={req.StarMask} " + + $"levelStateVal={levelStateAttr.Val} passVal={passAttr.Val}"); + + DatabaseHelper.SaveDatabaseType(player.Data); + return (new JsonObject(), sync); + } + + private static uint MergeStarMask(int starMask) + { + uint result = 0; + for (var i = 0; i < 3; i++) + { + if (((starMask >> i) & 1) != 0) + result |= 1u << i; + } + + return result; + } + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid + }; + data.Attrs.Add(attr); + return attr; + } + + private static void SyncAttr(NtfSyncPlayer sync, PlayerInstance player, PlayerAttr attr) + { + sync.Custom[player.ToPackedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(attr.Gid, attr.Sid)] = attr.Val; + } +} + +internal sealed class VirCaptureTowerSettlementParam +{ + [JsonPropertyName("nID")] + public int LevelId { get; set; } + + [JsonPropertyName("nStar")] + public int StarMask { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_ChangeFormation.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_ChangeFormation.cs new file mode 100644 index 0000000..16b8416 --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_ChangeFormation.cs @@ -0,0 +1,140 @@ +using MikuSB.Data; +using MikuSB.Database; +using MikuSB.Enums.Item; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCapture_ChangeFormation")] +public class VirCapture_ChangeFormation : ICallGSHandler +{ + private const uint StrGroupId = 57; + private const uint FormationSid = 1; + private const uint VirCaptureGroupId = 128; + private const uint CurLevelSid = 3; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var req = JsonSerializer.Deserialize(param); + if (req == null) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var player = connection.Player!; + var formation = ReadFormation(player); + var addId = (uint)Math.Max(0, req.Id); + var unloadId = (uint)Math.Max(0, req.UnloadId); + + var unloadIndex = unloadId == 0 ? -1 : formation.FindIndex(x => x == unloadId); + if (unloadId > 0 && unloadIndex < 0) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + if (addId > 0) + { + if (formation.Contains(addId)) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var addItem = player.InventoryManager.GetNormalItem(addId); + if (addItem == null || addItem.ItemType != ItemTypeEnum.TYPE_MONSTER_CARD) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + } + + if (unloadIndex >= 0) + formation.RemoveAt(unloadIndex); + + if (addId > 0) + { + if (unloadIndex >= 0 && unloadIndex <= formation.Count) + formation.Insert(unloadIndex, addId); + else + formation.Add(addId); + } + + if (!ValidateFormation(player, formation)) + { + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", "{\"sErr\":\"error.BadParam\"}"); + return; + } + + var json = JsonSerializer.Serialize(formation); + player.SetStrAttr(StrGroupId, FormationSid, json); + + DatabaseHelper.SaveDatabaseType(player.Data); + + var sync = new NtfSyncPlayer(); + sync.CustomStr[player.ToShiftedAttrKey(StrGroupId, FormationSid)] = json; + + var response = new JsonObject + { + ["nId"] = req.Id, + ["nUnloadId"] = req.UnloadId, + ["bAdd"] = addId > 0 + }; + + await CallGSRouter.SendScript(connection, "VirCapture_ChangeFormation", response.ToJsonString(), sync); + } + + private static List ReadFormation(MikuSB.GameServer.Game.Player.PlayerInstance player) + { + var raw = player.Data.StrAttrs.FirstOrDefault(x => x.Gid == StrGroupId && x.Sid == FormationSid)?.Val; + if (string.IsNullOrWhiteSpace(raw)) + return []; + + try + { + return JsonSerializer.Deserialize>(raw) ?? []; + } + catch + { + return []; + } + } + + private static bool ValidateFormation(MikuSB.GameServer.Game.Player.PlayerInstance player, List formation) + { + var curLevel = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == CurLevelSid)?.Val ?? 1; + if (!GameData.VirCaptureLevelListData.TryGetValue(curLevel, out var levelCfg)) + return formation.Count == 0; + + if (formation.Count > levelCfg.Num) + return false; + + uint totalCost = 0; + foreach (var itemId in formation) + { + var item = player.InventoryManager.GetNormalItem(itemId); + if (item == null || item.ItemType != ItemTypeEnum.TYPE_MONSTER_CARD) + return false; + + if (!GameData.MonsterCardData.TryGetValue(item.TemplateId, out var monsterCfg)) + return false; + + totalCost += monsterCfg.CostValue; + } + + return totalCost <= levelCfg.MaxCost; + } +} + +internal sealed class VirCaptureChangeFormationParam +{ + [JsonPropertyName("nId")] + public int Id { get; set; } + + [JsonPropertyName("nUnloadId")] + public int UnloadId { get; set; } +} diff --git a/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_GetLevelAward.cs b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_GetLevelAward.cs new file mode 100644 index 0000000..9fa0ffa --- /dev/null +++ b/GameServer/Server/CallGS/Handlers/VirCapture/VirCapture_GetLevelAward.cs @@ -0,0 +1,292 @@ +using MikuSB.Data; +using MikuSB.Data.Excel; +using MikuSB.Database; +using MikuSB.Database.Inventory; +using MikuSB.Database.Player; +using MikuSB.Enums.Item; +using MikuSB.GameServer.Game.Player; +using MikuSB.Proto; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace MikuSB.GameServer.Server.CallGS.Handlers.VirCapture; + +[CallGSApi("VirCapture_GetLevelAward")] +public class VirCapture_GetLevelAward : ICallGSHandler +{ + private const uint VirCaptureGroupId = 128; + private const uint CurLevelSid = 3; + private const uint LevelAwardFlagStartSid = 101; + private const uint LevelAwardFlagEndSid = 120; + + public async Task Handle(Connection connection, string param, ushort seqNo) + { + var player = connection.Player!; + var req = JsonSerializer.Deserialize(param); + if (req == null || req.IdList == null || req.IdList.Count == 0) + { + await CallGSRouter.SendScript(connection, "VirCapture_GetLevelAward", "{\"tbAwardList\":[]}"); + return; + } + + var curLevel = player.Data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == CurLevelSid)?.Val ?? 0; + var requestedLevels = req.IdList + .Where(x => x > 0) + .Select(x => (uint)x) + .Distinct() + .OrderBy(x => x) + .ToList(); + + var claimLevels = requestedLevels + .Where(level => level <= curLevel && CanClaimLevel(player.Data, level)) + .ToList(); + + var sync = new NtfSyncPlayer(); + var responseAwards = new JsonArray(); + + foreach (var level in claimLevels) + { + if (!GameData.VirCaptureLevelListData.TryGetValue(level, out var levelCfg) || + levelCfg.Rewards.Count == 0) + { + continue; + } + + SetClaimed(player, sync, level); + + foreach (var reward in levelCfg.Rewards) + { + if (reward.Count < 5) + continue; + + await GrantRewardAsync(player, sync, reward); + responseAwards.Add(new JsonArray( + (int)reward[0], + (int)reward[1], + (int)reward[2], + (int)reward[3], + (int)reward[4])); + } + } + + DatabaseHelper.SaveDatabaseType(player.Data); + DatabaseHelper.SaveDatabaseType(player.InventoryManager.InventoryData); + DatabaseHelper.SaveDatabaseType(player.CharacterManager.CharacterData); + + var rsp = new JsonObject + { + ["tbAwardList"] = responseAwards + }; + await CallGSRouter.SendScript(connection, "VirCapture_GetLevelAward", rsp.ToJsonString(), sync); + } + + private static bool CanClaimLevel(PlayerGameData data, uint level) + { + var sid = GetLevelAwardSid(level); + if (sid < LevelAwardFlagStartSid || sid > LevelAwardFlagEndSid) + return false; + + var pos = GetLevelAwardBit(level); + var attr = data.Attrs.FirstOrDefault(x => x.Gid == VirCaptureGroupId && x.Sid == sid); + return ((attr?.Val ?? 0) & (1u << pos)) == 0; + } + + private static void SetClaimed(PlayerInstance player, NtfSyncPlayer sync, uint level) + { + var sid = GetLevelAwardSid(level); + var pos = GetLevelAwardBit(level); + var attr = GetOrCreateAttr(player.Data, VirCaptureGroupId, sid); + attr.Val |= 1u << pos; + sync.Custom[player.ToPackedAttrKey(VirCaptureGroupId, sid)] = attr.Val; + sync.Custom[player.ToShiftedAttrKey(VirCaptureGroupId, sid)] = attr.Val; + } + + private static uint GetLevelAwardSid(uint level) => LevelAwardFlagStartSid + (level / 30); + + private static int GetLevelAwardBit(uint level) => (int)(level % 30); + + private static PlayerAttr GetOrCreateAttr(PlayerGameData data, uint gid, uint sid) + { + var attr = data.Attrs.FirstOrDefault(x => x.Gid == gid && x.Sid == sid); + if (attr != null) + return attr; + + attr = new PlayerAttr + { + Gid = gid, + Sid = sid, + Val = 0 + }; + data.Attrs.Add(attr); + return attr; + } + + private static async Task GrantRewardAsync(PlayerInstance player, NtfSyncPlayer sync, IReadOnlyList reward) + { + var itemType = (ItemTypeEnum)reward[0]; + var detail = reward[1]; + var particular = reward[2]; + var level = reward[3]; + var count = Math.Max(1u, reward[4]); + + switch (itemType) + { + case ItemTypeEnum.TYPE_CARD: + for (var i = 0u; i < count; i++) + { + var character = await player.CharacterManager.AddCharacter(itemType, detail, particular, level, sendPacket: false); + if (character != null) + sync.Items.Add(character.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON: + for (var i = 0u; i < count; i++) + { + var weapon = await player.InventoryManager.AddWeaponItem(itemType, detail, particular, level, sendPacket: false); + if (weapon != null) + sync.Items.Add(weapon.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPORT: + for (var i = 0u; i < count; i++) + { + var support = await player.InventoryManager.AddSupportCardItem(detail, particular, level, sendPacket: false); + if (support != null) + sync.Items.Add(support.ToProto()); + } + break; + case ItemTypeEnum.TYPE_SUPPLIES: + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(reward[0], detail, particular, level); + if (GameData.SuppliesData.TryGetValue(templateId, out var supplies)) + { + var item = await player.InventoryManager.AddSuppliesItem(supplies, count, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + case ItemTypeEnum.TYPE_USEABLE: + { + var item = AddOtherItem(player.InventoryManager.InventoryData, reward[0], detail, particular, level, count); + if (item != null) + sync.Items.Add(item.ToProto()); + break; + } + case ItemTypeEnum.TYPE_WEAPON_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_HOUSE: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddHouseFurnitureItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_PROFILE: + case ItemTypeEnum.TYPE_FRAME: + case ItemTypeEnum.TYPE_BADGE: + case ItemTypeEnum.TYPE_COVER: + case ItemTypeEnum.TYPE_NAMECARD: + case ItemTypeEnum.TYPE_EXPRESSION: + case ItemTypeEnum.TYPE_BUBBLE: + case ItemTypeEnum.TYPE_ANALYST: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddProfileItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_WEAPON_SKIN: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddWeaponSkinItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_MANIFESTATION: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddManifestationItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CARD_SKIN_PART: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddSkinPartItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_AR: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddArItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + case ItemTypeEnum.TYPE_CALL: + for (var i = 0u; i < count; i++) + { + var item = await player.InventoryManager.AddCallItem(itemType, detail, particular, level, sendPacket: false); + if (item != null) + sync.Items.Add(item.ToProto()); + } + break; + } + } + + private static BaseGameItemInfo? AddOtherItem(InventoryData inventory, uint genre, uint detail, uint particular, uint level, uint count) + { + var templateId = (uint)GameResourceTemplateId.FromGdpl(genre, detail, particular, level); + if (!GameData.OtherItemData.TryGetValue(templateId, out var otherItem)) + return null; + + var maxCount = otherItem.GMnum > 0 ? otherItem.GMnum : 99999u; + var existing = inventory.Items.Values.FirstOrDefault(x => x.TemplateId == templateId); + if (existing != null) + { + existing.ItemCount = Math.Min(existing.ItemCount + count, maxCount); + return existing; + } + + var item = new BaseGameItemInfo + { + TemplateId = templateId, + UniqueId = inventory.NextUniqueUid++, + ItemType = ItemTypeEnum.TYPE_USEABLE, + ItemCount = Math.Min(count, maxCount) + }; + inventory.Items[item.UniqueId] = item; + return item; + } +} + +internal sealed class VirCaptureGetLevelAwardParam +{ + [JsonPropertyName("nId")] + public int ActId { get; set; } + + [JsonPropertyName("tbIdList")] + public List IdList { get; set; } = []; +} diff --git a/GameServer/Server/Packet/Send/Misc/PacketNtfCallScript.cs b/GameServer/Server/Packet/Send/Misc/PacketNtfCallScript.cs index 7e65044..8dd4e5e 100644 --- a/GameServer/Server/Packet/Send/Misc/PacketNtfCallScript.cs +++ b/GameServer/Server/Packet/Send/Misc/PacketNtfCallScript.cs @@ -126,6 +126,8 @@ public PacketNtfCallScript(PlayerInstance Player) : base(CmdIds.NtfScript) sync.Custom[Player.ToPackedAttrKey(gid, sid)] = val; sync.Custom[Player.ToShiftedAttrKey(gid, sid)] = val; } + foreach (var (key, value) in Player.BuildMoneySync()) + sync.Money[key] = value; proto.ExtraSync = sync; SetData(proto); diff --git a/MikuSB/Program/MikuSB.cs b/MikuSB/Program/MikuSB.cs index 9585c39..bb98747 100644 --- a/MikuSB/Program/MikuSB.cs +++ b/MikuSB/Program/MikuSB.cs @@ -29,7 +29,6 @@ public static async Task Main(string[] args) var time = DateTime.Now; IConsole.InitConsole(); LoaderManager.InitConfig(); - ShowAntiScamWarning(); if (await UpdateService.TryStartSelfUpdateAsync()) return; @@ -80,6 +79,7 @@ private static void ShowAntiScamWarning() Logger.Warn("============================================================"); } + #region Exit private static void TryRunStartupGame(string[] args) { if (!args.Any(x => string.Equals(x, "-game", StringComparison.OrdinalIgnoreCase))) @@ -96,36 +96,35 @@ private static void TryRunStartupGame(string[] args) Logger.Error(I18NManager.Translate("Game.Command.Game.Failed", ex.Message), ex); } } - private static string[] ParseGameCommandArgs(string[] args) { - var extraArgs = new List(); - var hasPathOverride = false; + var result = new List(); - for (int i = 0; i < args.Length; i++) + for (var i = 0; i < args.Length; i++) { - if (string.Equals(args[i], "-path", StringComparison.OrdinalIgnoreCase)) - { - if (i + 1 < args.Length) - { - ConfigManager.Config.Loader.GamePath = args[++i]; - hasPathOverride = true; - } - } - else if (string.Equals(args[i], "-arg", StringComparison.OrdinalIgnoreCase)) + var arg = args[i]; + + // skip launcher flag itself + if (string.Equals(arg, "-game", StringComparison.OrdinalIgnoreCase)) + continue; + + // everything after -- will be forwarded directly + if (string.Equals(arg, "--", StringComparison.Ordinal)) { - if (i + 1 < args.Length) - extraArgs.Add(args[++i]); + for (var j = i + 1; j < args.Length; j++) + result.Add(args[j]); + + break; } - } - if (hasPathOverride) - Logger.Info("Startup -path override applied for this run."); + // optional: + // support quoted args that shell already split incorrectly + if (!string.IsNullOrWhiteSpace(arg)) + result.Add(arg); + } - return extraArgs.ToArray(); + return result.ToArray(); } - #region Exit - private static void RegisterExitEvent() { AppDomain.CurrentDomain.ProcessExit += (_, _) => @@ -169,3 +168,4 @@ private static async Task ProcessExit(int exitCode) # endregion } + diff --git a/README.md b/README.md index d34c1ce..cd366e0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ MikuSB is completely free and open source. If anyone sold you this server or charged money to provide it, that was a scam. Request a refund immediately and report the seller to us on Discord with any relevant proof or purchase details. +## Scam Warning + +MikuSB is completely free and open source. +If anyone sold you this server or charged money to provide it, that was a scam. +Request a refund immediately and report the seller to us on Discord with any relevant proof or purchase details. + ## Overview - `SdkServer` diff --git a/version.txt b/version.txt index a130fc8..28b8e5b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v=4.1 +v=4.5 \ No newline at end of file