feat(all): add verified gacha flows and native client studio

This commit is contained in:
2026-09-30 23:49:00 +08:00
parent addc394cae
commit bf4f259bad
66 changed files with 2387 additions and 2351 deletions
+31 -99
View File
@@ -1,20 +1,8 @@
# BD2 本地服务器
用于本地开发、协议研究和服务端实现练习。服务默认只监听环回地址,不应暴露到公网。
用于本地开发、协议研究和服务端实现练习。
仓库根目录的 `versions.json` 是服务端与客户端插件共用的版本配置:
`client_version` 用于维护/启动响应,并约束当前 seed 与存档,
`game_data_version` 和 `bundle_version` 分别选择 GameData 与 ServerData,
`seed_directory` 相对该配置文件解析。开发时服务端会从可执行文件目录或当前目录向上查找;
也可使用 `serve --version-config <文件>` 或环境变量 `BD2_VERSION_CONFIG` 显式指定。
`plugins.local_identity`、`plugins.login_ui` 与 `plugins.capture_environment` 是三个 BepInEx 插件各自的版本。
插件构建会从仓库根配置生成临时 C# 常量,不在源码中重复版本值。
## 许可与免责声明
本项目原创内容由 Flechazo 保留全部权利,具体中英双语条款见 [LICENSE](LICENSE)。通过项目权利人指定渠道合法取得副本的人,仅可用于个人学习、研究和本地非商业测试;未经项目权利人事先明确书面许可,禁止向第三方转发、镜像、上传、二次分发项目源码或构建产物,禁止售卖、收费提供、商业化运营或对公众开放基于本项目的服务。公开可见不代表允许转载或再分发。
本项目是非官方开发与研究项目,与 Brown Dust II 及其权利人无隶属或授权关系。游戏名称、商标、客户端、游戏资源和数据归各自权利人所有;本项目的许可不覆盖它们,也不变更第三方依赖的许可证。项目按现状提供,无正确性、安全性、可用性或不侵权保证。使用者须自行确认适用法律、相关协议和第三方权利要求,并自行承担使用、客户端修改及本地服务运行风险。
首先安装 Brown Dust II 客户端。
## 开发构建
@@ -26,12 +14,9 @@ go test ./...
go build .\cmd\bd2server
go build .\cmd\bd2client
Pop-Location
$env:PYTHONDONTWRITEBYTECODE = '1'
python -m unittest discover -s .\tools\python\tests -p 'test_*.py' -v
```
开发时无需先整理发布目录。在两个终端中分别从 `go` 目录运行:
开发时在两个终端中分别从 `go` 目录运行:
```powershell
$env:GOCACHE = (Join-Path $PWD '.cache\go-build')
@@ -43,117 +28,64 @@ $env:GOCACHE = (Join-Path $PWD '.cache\go-build')
go run .\cmd\bd2client --dev run
```
服务端开发入口会自动使用仓库根目录的版本、认证、资源、数据和存档配置;客户端开发入口会直接使用仓库版本清单和插件产物,并在已有游戏目录时先增量构建两个客户端插件。仍可在 `run` 后传入普通参数覆盖默认值,例如 `--listen` 或 `--game-dir`。
服务端开发入口会自动使用仓库根目录的版本、认证、资源、数据和存档配置;客户端开发入口会直接使用仓库版本清单和插件产物,并从本机开发配置读取游戏目录,随后增量构建两个客户端插件。首次开发前创建该配置:
`--dev run` 由仅限非发布构建的 Go 文件提供。发布脚本统一使用 `-tags release` 编译,最终 `bd2client` 与 `bd2server` 不包含该入口,传入 `--dev` 会按未知参数或未知命令拒绝。
```powershell
Copy-Item .\go\config.example.json .\go\config.json
```
编辑 `go/config.json`,将 `game_directory` 改为本机 Brown Dust II 安装目录。仍可在 `run` 后使用 `--game-dir` 临时覆盖;显式参数的优先级高于开发配置。
发布脚本统一使用 `-tags release` 编译。
## 发布包
```powershell
.\build-release.ps1 -GameDir "<客户端目录>"
.\build-release.ps1
```
脚本分别生成 `.build\bd2server-windows-x64.zip` 和 `.build\bd2client-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略和服务端数据目录,不包含客户端 DLL、游戏补丁或客户端目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件,不包含服务端存档或 secret。Go 构建缓存只写入 `go\.cache`。
脚本在 Windows 本机构建,分别生成 `.build\bd2server-windows-x64.zip` 和 `.build\bd2client-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略和服务端数据目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件。
macOS 客户端使用系统 Cocoa / WKWebView,需要 macOS 原生构建环境。本地 Windows 发布脚本不会交叉编译或生成 macOS 发布包;找到 macOS 测试者或准备公开发布时,再通过 GitHub Actions 的 macOS runner 完成原生构建、测试及 `.app` 打包。签名和公证也应在该流程中配置。
## 客户端插件项目
- `plugins/LocalIdentity/`:本地服务端客户端专用插件。
- `plugins/LoginUI/`:本地服 Discord/Google 登录界面插件。
- `plugins/CaptureEnvironment/`:独立原版对照客户端的抓包插件;不要装进本地服务端客户端。
- `plugins/CaptureEnvironment/`:独立原版对照客户端的抓包插件,不要装进本地服务端客户端。
## 可视化客户端工具
`bd2client.exe` 是独立的客户端设置工具,不会启动或依赖服务端进程。双击后会优先以 Microsoft Edge 应用窗口打开内嵌界面;系统没有 Edge 时回退到默认浏览器。它提供以下彼此独立的操作:
BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows 使用系统 WebView2 Runtime,macOS 使用系统 WKWebView;应用自己管理窗口和退出,原生窗口关闭后整个客户端进程会退出。它提供以下操作:
- 选择并验证 Brown Dust II 安装目录。
- 填写服务器 origin 和端口,并写入 `BepInEx/config/bd2.client.json`。
- 填写服务器 origin 和端口,自动写入游戏目录下的 `BepInEx/config/bd2.client.json`。
- 应用带原文件备份的客户端入口补丁。
- 检查 BepInEx 后安装或更新 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。
- 选择官方 CDN、服务器资源源或本地已下载资源;服务器资源源统一涵盖服主自建与反代,并通过 `PUT /client/resources` 公开接口预检资源 URL 和版本。
- 选择官方 CDN、服务器资源源或本地已下载资源。
客户端配置保存服务器地址、资源模式及本地模式所需的客户端路径,不保存 OAuth secret、服主 master key 或玩家登录凭据。朋友小服或通过 FRP 联机时建议选择官方 CDN,避免让资源下载占用服主与隧道带宽。服主启用服务器资源源时,客户端插件会在运行时从该服读取资源 URL 和版本;本地资源路径不会发送到服务端。
服主同步官方资源、自建静态 CDN 或配置缓存反代的步骤见 [资源与 CDN 配置指南](RESOURCES.md)。
服主同步官方资源、自建静态 CDN 或配置缓存反代的步骤见 [服主资源与 CDN 配置指南](docs/RESOURCES.md)。
## 首次安装
开发构建后可直接运行:
### 启动服务端
```powershell
.\go\bd2client.exe
```
双击 `bd2server.exe` 启动,单人本地服默认监听 `8080` 端口,可通过 `.\bd2server.exe serve --listen 127.0.0.1:xxxx` 修改监听端口。
## 首次安装本地客户端
服主部署、创建 OAuth 应用、取得 client ID/secret、配置回调地址和安全注入环境变量的完整步骤见 [服主配置指南](AUTHENTICATION.md)。
先手动安装 BepInEx:<https://github.com/BepInEx/BepInEx/releases>。然后修改客户端入口:
```powershell
& ".\.build\package\bd2client\bd2client.exe"
```
认证策略由 `bd2server.exe` 同目录的 `authentication.json` 权威决定。`mode=local`(默认)不依赖第三方登录,单人本地服保持不动,联机服可设为 `oauth` 来让玩家通过 Discord/Google 登录。
客户端工具会选择游戏目录、写入服务器 origin 和 CDN 模式、执行入口补丁,并安装或更新 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。服务端不会检查 BepInEx,也不会读取或修改客户端目录。不要把原版抓包插件 `BD2CaptureEnvironment.dll` 安装进这个客户端。
### 启动客户端
## 启动顺序
先手动安装 [BepInEx](https://github.com/BepInEx/BepInEx/releases)。然后打开我们的客户端工具 `bd2client.exe`。
顺序必须是:
进入客户端工具后首先要求你选择游戏根目录,也就是有 `BrownDust II.exe` 文件的目录,然后要填写服务端 url,如果是单人本地服则填写 `127.0.0.1:<服务端实际监听的端口>`,默认为 `8080`。如果为联机服则需要填写服主要求的 url。
1. 确认客户端已经安装 BepInEx。
2. 启动纯服务端;服务端只准备自己的 GameData 和玩家存档。
3. 运行 `bd2client.exe` 完成客户端配置和插件安装。
4. 健康检查返回成功后,再启动游戏客户端。
启动服务器:
## 许可与免责声明
```powershell
& ".\.build\package\bd2server\bd2server.exe" serve
```
本项目原创内容由 Flechazo 保留全部权利,具体中英双语条款见 [LICENSE](LICENSE)。通过项目权利人指定渠道合法取得副本的人,仅可用于个人学习、研究和本地非商业测试;未经项目权利人事先明确书面许可,禁止向第三方转发、镜像、上传、二次分发项目源码或构建产物,禁止售卖、收费提供、商业化运营或对公众开放基于本项目的服务。公开可见不代表允许转载或再分发。
启动时会准备服务端逻辑所需 GameData,随后打开玩家 SQLite 存档。数据库以明确的 `schema_version` 管理格式,Go 迁移器只允许按 `N→N+1` 顺序升级,并在同一个 SQLite 事务里完成全部迁移、版本写入和最终状态校验;任一步失败都会整体回滚。服务端不需要客户端、ServerData 或 Windows 游戏目录。客户端由 `bd2client.exe` 单独配置。
认证策略由 `bd2server.exe` 同目录的 `authentication.json` 权威决定。`mode=local`(默认)不依赖第三方登录;公网或联机服可设为 `oauth`。登录界面插件从当前连接的服务器读取公开策略,只显示服务器允许的 Discord/Google 入口,客户端不能自行打开未启用的 provider。服主创建 OAuth 应用、取得 client ID/secret、配置回调地址和安全注入环境变量的完整步骤见 [服主第三方登录配置指南](docs/AUTHENTICATION.md)。
OAuth 模式示例:
```json
{
"mode": "oauth",
"public_url": "https://bd2.example.com",
"master_key_env": "BD2_AUTH_MASTER_KEY",
"providers": {
"discord": {
"client_id": "Discord application client ID",
"client_secret_env": "BD2_DISCORD_CLIENT_SECRET"
},
"google": {
"client_id": "Google OAuth client ID",
"client_secret_env": "BD2_GOOGLE_CLIENT_SECRET"
}
},
"session": {
"access_ttl": "15m",
"refresh_ttl": "720h",
"device_transaction_ttl": "10m"
}
}
```
`public_url` 是客户端、浏览器和 OAuth provider 都能访问的固定 origin;公网地址必须使用 HTTPS,只有 loopback 开发地址允许 HTTP。OAuth 模式下服务端从它派生 `/game/` 地址;ServerData 与 GameData 地址由独立的 `resources.json` 决定。反向代理或内网穿透必须转发 `/auth/`、`/game/`、`/client/resources` 和健康检查;静态资源是否经过服主入口取决于所选 CDN 模式。provider 控制台的 redirect URI 必须分别注册为:
```text
https://bd2.example.com/auth/discord/callback
https://bd2.example.com/auth/google/callback
```
`authentication.json` 只保存 client ID 和环境变量名。master key 必须是 Base64 编码的 32 个随机字节;Discord/Google client secret 与 master key 的实际值只通过配置所指向的环境变量注入。master key 必须长期稳定并单独备份:更换或丢失它会使现有 `auth.db` 的身份摘要、token 摘要和待领取密文失效。服务端不会把 secret、第三方 token或游戏 token写入配置或日志。
认证数据位于玩家 `state.db` 同目录的独立 `auth.db`。access token、refresh token、device secret、OAuth state 和 provider subject 只保存带用途隔离的 HMAC-SHA-256;必须临时恢复的 PKCE verifier、OIDC nonce 和待领取登录结果使用 AES-256-GCM。provider token 只在回调内存中使用,不落盘。客户端 OAuth access token 只在进程内存中;Windows refresh credential 由当前用户 DPAPI 加密后以密文写入 PlayerPrefs 注册表,macOS 写入 Keychain Services,并按规范化服务器 origin 隔离。其他平台禁用自动登录,不会退化成明文存储。
当前版本正在先完成纯服务端与客户端工具分离;领域状态仍按现有单存档运行,真正的多玩家独立存档会在后续按 `account_id` 拆分为每账号 SQLite runtime,不会通过共享一份 `state.db` 冒充多账号。
健康检查:
```powershell
Invoke-WebRequest http://127.0.0.1:8080/healthz
```
健康检查成功后才能启动本地客户端。服务器尚未监听时启动客户端,会在登录或维护信息阶段连接失败。
客户端入口补丁和插件安装由独立的 `bd2client.exe` 完成,服务端不再提供 `patch-client` 子命令。
本项目是非官方开发与研究项目,与 Brown Dust II 及其权利人无隶属或授权关系。游戏名称、商标、客户端、游戏资源和数据归各自权利人所有;本项目的许可不覆盖它们,也不变更第三方依赖的许可证。项目按现状提供,无正确性、安全性、可用性或不侵权保证。使用者须自行确认适用法律、相关协议和第三方权利要求,并自行承担使用、客户端修改及本地服务运行风险。
+26 -81
View File
@@ -1,6 +1,5 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$GameDir,
[switch]$SkipTests
)
@@ -12,21 +11,36 @@ $buildRoot = Join-Path $root '.build'
$packageParent = Join-Path $buildRoot 'package'
$serverPackage = Join-Path $packageParent 'bd2server'
$clientPackage = Join-Path $packageParent 'bd2client'
$macAMD64Package = Join-Path $packageParent 'bd2client-macos-amd64'
$macARM64Package = Join-Path $packageParent 'bd2client-macos-arm64'
$serverGoDir = Join-Path $serverPackage 'go'
$serverStateDir = Join-Path $serverPackage 'data\state'
$clientPluginDir = Join-Path $clientPackage 'plugins'
$macAMD64ExecutableDir = Join-Path $macAMD64Package 'BD2 Client Studio.app\Contents\MacOS'
$macARM64ExecutableDir = Join-Path $macARM64Package 'BD2 Client Studio.app\Contents\MacOS'
$serverArchive = Join-Path $buildRoot 'bd2server-windows-x64.zip'
$clientArchive = Join-Path $buildRoot 'bd2client-windows-x64.zip'
$macAMD64Archive = Join-Path $buildRoot 'bd2client-macos-amd64.zip'
$macARM64Archive = Join-Path $buildRoot 'bd2client-macos-arm64.zip'
$versionConfig = Join-Path $root 'versions.json'
$authenticationConfig = Join-Path $root 'authentication.json'
$resourceConfig = Join-Path $root 'resources.json'
if ([string]::IsNullOrWhiteSpace($GameDir)) {
$developmentConfigPath = Join-Path $goRoot 'config.json'
if (-not [IO.File]::Exists($developmentConfigPath)) {
throw "GameDir was not provided and $developmentConfigPath does not exist. Copy go/config.example.json to go/config.json and set game_directory, or pass -GameDir explicitly."
}
try {
$developmentConfig = Get-Content -LiteralPath $developmentConfigPath -Raw | ConvertFrom-Json -ErrorAction Stop
} catch {
throw "Could not read development config at ${developmentConfigPath}: $($_.Exception.Message)"
}
$propertyNames = @($developmentConfig.PSObject.Properties.Name | Sort-Object)
if (($propertyNames -join ',') -ne 'game_directory,schema_version' -or
$developmentConfig.schema_version -ne 1 -or
-not ($developmentConfig.game_directory -is [string]) -or
[string]::IsNullOrWhiteSpace($developmentConfig.game_directory)) {
throw "Development config must contain only schema_version 1 and a non-empty game_directory: $developmentConfigPath"
}
$GameDir = $developmentConfig.game_directory
Write-Host "Using development game directory: $GameDir"
}
$GameDir = [IO.Path]::GetFullPath($GameDir)
$gameExecutable = Join-Path $GameDir 'BrownDust II.exe'
if (-not [IO.File]::Exists($gameExecutable)) {
@@ -36,7 +50,7 @@ if (-not [IO.File]::Exists($gameExecutable)) {
$env:GOCACHE = Join-Path $goRoot '.cache\go-build'
New-Item -ItemType Directory -Force -Path $env:GOCACHE, $buildRoot | Out-Null
foreach ($target in @($packageParent, $serverArchive, $clientArchive, $macAMD64Archive, $macARM64Archive)) {
foreach ($target in @($packageParent, $serverArchive, $clientArchive)) {
if (Test-Path -LiteralPath $target) {
$resolved = [IO.Path]::GetFullPath($target)
$expectedRoot = [IO.Path]::GetFullPath($buildRoot) + [IO.Path]::DirectorySeparatorChar
@@ -46,7 +60,7 @@ foreach ($target in @($packageParent, $serverArchive, $clientArchive, $macAMD64A
Remove-Item -LiteralPath $resolved -Recurse -Force
}
}
New-Item -ItemType Directory -Force -Path $serverPackage, $serverGoDir, $serverStateDir, $clientPackage, $clientPluginDir, $macAMD64ExecutableDir, $macARM64ExecutableDir | Out-Null
New-Item -ItemType Directory -Force -Path $serverPackage, $serverGoDir, $serverStateDir, $clientPackage, $clientPluginDir | Out-Null
Push-Location $goRoot
try {
@@ -55,29 +69,15 @@ try {
if ($LASTEXITCODE -ne 0) { throw "go test failed with exit code $LASTEXITCODE" }
go vet ./...
if ($LASTEXITCODE -ne 0) { throw "go vet failed with exit code $LASTEXITCODE" }
go test -tags release ./...
go test -tags 'release,production' ./...
if ($LASTEXITCODE -ne 0) { throw "release-tag go test failed with exit code $LASTEXITCODE" }
go vet -tags release ./...
go vet -tags 'release,production' ./...
if ($LASTEXITCODE -ne 0) { throw "release-tag go vet failed with exit code $LASTEXITCODE" }
}
go build -tags release -trimpath -ldflags '-s -w' -o (Join-Path $serverPackage 'bd2server.exe') .\cmd\bd2server
if ($LASTEXITCODE -ne 0) { throw "bd2server build failed with exit code $LASTEXITCODE" }
go build -tags release -trimpath -ldflags '-H windowsgui -s -w' -o (Join-Path $clientPackage 'bd2client.exe') .\cmd\bd2client
go build -tags 'release,production' -trimpath -ldflags '-H windowsgui -s -w' -o (Join-Path $clientPackage 'bd2client.exe') .\cmd\bd2client
if ($LASTEXITCODE -ne 0) { throw "bd2client build failed with exit code $LASTEXITCODE" }
$savedGOOS = $env:GOOS
$savedGOARCH = $env:GOARCH
try {
$env:GOOS = 'darwin'
$env:GOARCH = 'amd64'
go build -tags release -trimpath -ldflags '-s -w' -o (Join-Path $macAMD64ExecutableDir 'bd2client') .\cmd\bd2client
if ($LASTEXITCODE -ne 0) { throw "bd2client macOS amd64 build failed with exit code $LASTEXITCODE" }
$env:GOARCH = 'arm64'
go build -tags release -trimpath -ldflags '-s -w' -o (Join-Path $macARM64ExecutableDir 'bd2client') .\cmd\bd2client
if ($LASTEXITCODE -ne 0) { throw "bd2client macOS arm64 build failed with exit code $LASTEXITCODE" }
} finally {
$env:GOOS = $savedGOOS
$env:GOARCH = $savedGOARCH
}
} finally { Pop-Location }
$clientPlugins = @(
@@ -89,11 +89,6 @@ foreach ($plugin in $clientPlugins) {
if ($LASTEXITCODE -ne 0) { throw "$($plugin.Name) build failed with exit code $LASTEXITCODE" }
if (-not (Test-Path -LiteralPath $plugin.Output -PathType Leaf)) { throw "$($plugin.Name) build output is missing" }
Copy-Item -LiteralPath $plugin.Output -Destination (Join-Path $clientPluginDir $plugin.FileName) -Force
foreach ($macExecutableDir in @($macAMD64ExecutableDir, $macARM64ExecutableDir)) {
$macPluginDir = Join-Path $macExecutableDir 'plugins'
New-Item -ItemType Directory -Force -Path $macPluginDir | Out-Null
Copy-Item -LiteralPath $plugin.Output -Destination (Join-Path $macPluginDir $plugin.FileName) -Force
}
}
Copy-Item -LiteralPath (Join-Path $goRoot 'seed') -Destination $serverGoDir -Recurse -Force
@@ -101,66 +96,16 @@ Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $serverPackage 've
Copy-Item -LiteralPath $authenticationConfig -Destination (Join-Path $serverPackage 'authentication.json') -Force
Copy-Item -LiteralPath $resourceConfig -Destination (Join-Path $serverPackage 'resources.json') -Force
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $clientPackage 'versions.json') -Force
foreach ($macExecutableDir in @($macAMD64ExecutableDir, $macARM64ExecutableDir)) {
Copy-Item -LiteralPath $versionConfig -Destination (Join-Path $macExecutableDir 'versions.json') -Force
}
Copy-Item -LiteralPath (Join-Path $root 'RELEASE.md') -Destination (Join-Path $serverPackage 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'docs\AUTHENTICATION.md') -Destination (Join-Path $serverPackage 'AUTHENTICATION.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'docs\RESOURCES.md') -Destination (Join-Path $serverPackage 'RESOURCES.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $clientPackage 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $macAMD64Package 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'docs\CLIENT.md') -Destination (Join-Path $macARM64Package 'README.md') -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $serverPackage 'LICENSE') -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $clientPackage 'LICENSE') -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $macAMD64Package 'LICENSE') -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination (Join-Path $macARM64Package 'LICENSE') -Force
$infoPlist = @'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleDisplayName</key><string>BD2 Client Studio</string>
<key>CFBundleExecutable</key><string>bd2client</string>
<key>CFBundleIdentifier</key><string>cc.sighs.bd2.clientstudio</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>BD2 Client Studio</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
</dict></plist>
'@
foreach ($macPackage in @($macAMD64Package, $macARM64Package)) {
$plistPath = Join-Path $macPackage 'BD2 Client Studio.app\Contents\Info.plist'
[IO.File]::WriteAllText($plistPath, $infoPlist, [Text.UTF8Encoding]::new($false))
}
function Compress-MacApp {
param([string]$Source, [string]$Destination)
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::Open($Destination, [IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($file in Get-ChildItem -LiteralPath $Source -File -Recurse) {
$relative = [IO.Path]::GetRelativePath($Source, $file.FullName).Replace('\', '/')
$entry = [IO.Compression.ZipFileExtensions]::CreateEntryFromFile($archive, $file.FullName, $relative, [IO.Compression.CompressionLevel]::Optimal)
if ($relative.EndsWith('/Contents/MacOS/bd2client', [StringComparison]::Ordinal) -or $relative -eq 'BD2 Client Studio.app/Contents/MacOS/bd2client') {
$entry.ExternalAttributes = -2115174400 # Unix regular file mode 0755.
} else {
$entry.ExternalAttributes = -2119958528 # Unix regular file mode 0644.
}
}
} finally {
$archive.Dispose()
}
}
Compress-Archive -LiteralPath $serverPackage -DestinationPath $serverArchive -CompressionLevel Optimal
Compress-Archive -LiteralPath $clientPackage -DestinationPath $clientArchive -CompressionLevel Optimal
Compress-MacApp -Source $macAMD64Package -Destination $macAMD64Archive
Compress-MacApp -Source $macARM64Package -Destination $macARM64Archive
Write-Host "Built pure server directory: $serverPackage"
Write-Host "Built pure server archive: $serverArchive"
Write-Host "Built client directory: $clientPackage"
Write-Host "Built client archive: $clientArchive"
Write-Host "Built macOS amd64 client: $macAMD64Archive"
Write-Host "Built macOS arm64 client: $macARM64Archive"
+40 -4
View File
@@ -3,17 +3,23 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
clientconfig "bd2server/internal/client/config"
clientlayout "bd2server/internal/client/layout"
)
type clientDevelopmentConfig struct {
SchemaVersion int `json:"schema_version"`
GameDirectory string `json:"game_directory"`
}
func developmentRunOptions(args []string) ([]string, clientRunOptions, error) {
if len(args) == 0 || args[0] != "--dev" {
return args, clientRunOptions{}, nil
@@ -31,10 +37,11 @@ func developmentRunOptions(args []string) ([]string, clientRunOptions, error) {
return nil, clientRunOptions{}, err
}
if gameDir == "" {
preferences, preferenceErr := clientconfig.LoadPreferences()
if preferenceErr == nil {
gameDir = preferences.GameDirectory
config, configErr := loadClientDevelopmentConfig(filepath.Join(root, "go", "config.json"))
if configErr != nil {
return nil, clientRunOptions{}, configErr
}
gameDir = config.GameDirectory
}
if gameDir != "" {
if err := buildDevelopmentPlugins(root, gameDir); err != nil {
@@ -49,6 +56,35 @@ func developmentRunOptions(args []string) ([]string, clientRunOptions, error) {
}, nil
}
func loadClientDevelopmentConfig(path string) (clientDevelopmentConfig, error) {
data, err := os.ReadFile(filepath.Clean(path))
if errors.Is(err, os.ErrNotExist) {
return clientDevelopmentConfig{}, fmt.Errorf("development config %s is missing; copy go/config.example.json to go/config.json or pass --game-dir", path)
}
if err != nil {
return clientDevelopmentConfig{}, fmt.Errorf("read development config: %w", err)
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
var config clientDevelopmentConfig
if err := decoder.Decode(&config); err != nil {
return clientDevelopmentConfig{}, fmt.Errorf("decode development config: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return clientDevelopmentConfig{}, errors.New("development config must contain exactly one JSON object")
}
if config.SchemaVersion != 1 || strings.TrimSpace(config.GameDirectory) == "" {
return clientDevelopmentConfig{}, errors.New("development config requires schema_version 1 and game_directory")
}
absolute, err := filepath.Abs(filepath.Clean(config.GameDirectory))
if err != nil {
return clientDevelopmentConfig{}, fmt.Errorf("resolve development game directory: %w", err)
}
config.GameDirectory = absolute
return config, nil
}
func findClientDevelopmentRoot() (string, error) {
working, err := os.Getwd()
if err != nil {
+47 -6
View File
@@ -3,6 +3,8 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
@@ -15,7 +17,7 @@ func TestClientDevelopmentGameDirectory(t *testing.T) {
}{
{name: "separate", args: []string{"--game-dir", filepath.Join("some", "game")}, want: filepath.Join("some", "game")},
{name: "equals", args: []string{"--game-dir=" + filepath.Join("other", "game")}, want: filepath.Join("other", "game")},
{name: "absent", args: []string{"--no-browser"}},
{name: "absent", args: nil},
} {
t.Run(test.name, func(t *testing.T) {
got, err := clientDevelopmentGameDirectory(test.args)
@@ -37,14 +39,53 @@ func TestClientDevelopmentGameDirectoryRequiresValue(t *testing.T) {
}
}
func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) {
t.Setenv("APPDATA", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
args, options, err := developmentRunOptions([]string{"--dev", "run", "--no-browser"})
func TestLoadClientDevelopmentConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
gameDir := filepath.Join(dir, "BrownDust II")
quoted, err := json.Marshal(gameDir)
if err != nil {
t.Fatal(err)
}
if len(args) != 1 || args[0] != "--no-browser" {
if err := os.WriteFile(path, []byte(`{"schema_version":1,"game_directory":`+string(quoted)+`}`), 0o600); err != nil {
t.Fatal(err)
}
config, err := loadClientDevelopmentConfig(path)
if err != nil {
t.Fatal(err)
}
want, _ := filepath.Abs(gameDir)
if config.SchemaVersion != 1 || config.GameDirectory != want {
t.Fatalf("config=%+v want directory %q", config, want)
}
if _, err := loadClientDevelopmentConfig(filepath.Join(dir, "missing.json")); err == nil {
t.Fatal("missing development config accepted")
}
for name, body := range map[string]string{
"unknown": `{"schema_version":1,"game_directory":"x","extra":true}`,
"version": `{"schema_version":2,"game_directory":"x"}`,
"empty": `{"schema_version":1,"game_directory":""}`,
"trailing": `{"schema_version":1,"game_directory":"x"}{}`,
} {
t.Run(name, func(t *testing.T) {
bad := filepath.Join(dir, name+".json")
if err := os.WriteFile(bad, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadClientDevelopmentConfig(bad); err == nil {
t.Fatal("invalid development config accepted")
}
})
}
}
func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) {
gameDir := t.TempDir()
args, options, err := developmentRunOptions([]string{"--dev", "run", "--game-dir", gameDir})
if err != nil {
t.Fatal(err)
}
if len(args) != 2 || args[0] != "--game-dir" || args[1] != gameDir {
t.Fatalf("client args = %v", args)
}
for name, path := range map[string]string{
-4
View File
@@ -30,8 +30,6 @@ func main() {
func runClient(args []string, options clientRunOptions) error {
fs := flag.NewFlagSet("bd2client", flag.ContinueOnError)
listen := fs.String("listen", "127.0.0.1:0", "loopback address for the local setup interface")
noBrowser := fs.Bool("no-browser", false, "print the interface URL without opening a browser")
gameDir := fs.String("game-dir", "", "initial Brown Dust II installation directory")
if err := fs.Parse(args); err != nil {
return err
@@ -66,8 +64,6 @@ func runClient(args []string, options clientRunOptions) error {
}
}
if err := clientapp.Run(clientapp.Options{
Listen: *listen,
NoBrowser: *noBrowser,
InitialGameDir: *gameDir,
Logger: logger,
LogPath: logPath,
+10 -1
View File
@@ -213,6 +213,10 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("load active gacha GameData: %w", err)
}
firstGacha, err := gamedata.LoadFirstGacha(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load first gacha GameData: %w", err)
}
stateRepository, err := accountstate.Open(filepath.Clean(*stateFile))
if err != nil {
return fmt.Errorf("open account state database: %w", err)
@@ -426,7 +430,12 @@ func serve(args []string) (serveErr error) {
}
// The mapped client property is IsDoneFirstGachaPick. Its authoritative
// local state is the explicit GachaSubType=3 completion marker.
login.SetFirstGacha(gachaService.FirstGachaCompleted())
if err := login.AttachFirstGacha(gachaService); err != nil {
return fmt.Errorf("attach first gacha status to login: %w", err)
}
if err := gachaService.AttachFirstGacha(firstGacha); err != nil {
return fmt.Errorf("attach first gacha GameData: %w", err)
}
gachaService.AttachInventory(ownedItems)
gachaService.AttachEquipmentGacha(equipmentGacha, ownedEquipment)
gachaService.AttachPreviewMission(func() error {
+2 -5
View File
@@ -1,7 +1,4 @@
{
"listen": "127.0.0.1:8080",
"serverDataDir": "",
"gameDataDir": "",
"clientDir": "",
"versionConfig": "../versions.json"
"schema_version": 1,
"game_directory": "C:\\Path\\To\\BrownDust II"
}
+30 -2
View File
@@ -2,15 +2,43 @@ module bd2server
go 1.25.0
require modernc.org/sqlite v1.59.0
require (
github.com/wailsapp/wails/v2 v2.16.0
golang.org/x/sys v0.47.0
modernc.org/sqlite v1.59.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/text v0.39.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
+76
View File
@@ -1,25 +1,101 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.16.0 h1:hLYrSHUUq6hRXl3rWT9ZXiyFIgM94nVN9Ns6iEJzxyw=
github.com/wailsapp/wails/v2 v2.16.0/go.mod h1:scxrgwfsv6yR6fE6cCF+Flfl+JeU+SR87T9x4kILJ6M=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+241 -427
View File
@@ -1,20 +1,12 @@
// Package app serves bd2client's embedded, loopback-only setup interface.
// Package app exposes desktop business operations through native Go bindings.
package app
import (
"context"
"crypto/rand"
"crypto/subtle"
"embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
@@ -32,8 +24,6 @@ var webFS embed.FS
var errGameAlreadyRunning = errors.New("Brown Dust II is already running")
type Options struct {
Listen string
NoBrowser bool
InitialGameDir string
Logger *slog.Logger
LogPath string
@@ -42,7 +32,7 @@ type Options struct {
LoginUIPlugin string
}
type request struct {
type Request struct {
GameDirectory string `json:"game_directory"`
ServerOrigin string `json:"server_origin"`
CDNMode clientconfig.CDNMode `json:"cdn_mode"`
@@ -50,470 +40,294 @@ type request struct {
UILanguage string `json:"ui_language"`
}
type response struct {
func (r Request) settings() clientconfig.Settings {
return clientconfig.Settings{ServerOrigin: r.ServerOrigin, CDNMode: r.CDNMode, LocalResourceDirectory: r.LocalResourceDirectory}
}
type Response struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
Data any `json:"data,omitempty"`
}
type handler struct {
token string
origin string
initialGameDir string
browse func(string) (string, error)
browseResources func(string) (string, error)
shutdown func()
quitOnce sync.Once
logger *slog.Logger
logPath string
versions clientconfig.ReleaseVersions
initialSettings clientconfig.Settings
autoOpen bool
localIdentityPlugin string
loginUIPlugin string
type InitialState struct {
Platform string `json:"platform"`
LogPath string `json:"log_path"`
GameDirectory string `json:"game_directory"`
ServerOrigin string `json:"server_origin"`
CDNMode clientconfig.CDNMode `json:"cdn_mode"`
LocalResourceDirectory string `json:"local_resource_directory"`
AutoOpen bool `json:"auto_open"`
}
func Run(options Options) error {
logger := options.Logger
if logger == nil {
logger = slog.Default()
}
listen := options.Listen
if listen == "" {
listen = "127.0.0.1:0"
}
listener, err := net.Listen("tcp", listen)
if err != nil {
logger.Error("could not start local interface", "error", err)
return fmt.Errorf("start bd2client interface: %w", err)
}
address := listener.Addr().(*net.TCPAddr)
if !address.IP.IsLoopback() {
_ = listener.Close()
logger.Error("refused non-loopback interface", "address", listener.Addr().String())
return errors.New("bd2client interface must listen on a loopback address")
}
token, err := newToken()
if err != nil {
_ = listener.Close()
logger.Error("could not create local interface session", "error", err)
return err
}
origin := "http://" + listener.Addr().String()
initialSettings := clientconfig.Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}
autoOpen := false
if options.InitialGameDir != "" {
if _, inspectErr := clientsetup.Inspect(options.InitialGameDir, options.Versions); inspectErr != nil {
logger.Warn("saved game directory is no longer valid", "error", inspectErr)
} else if loaded, loadErr := clientconfig.Load(options.InitialGameDir); loadErr != nil {
logger.Warn("saved client connection settings are unavailable", "error", loadErr)
} else {
initialSettings = loaded
autoOpen = true
}
}
server := &http.Server{
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
done := make(chan struct{})
h := &handler{
token: token,
origin: origin,
initialGameDir: options.InitialGameDir,
browse: browseForGameDirectory,
browseResources: browseForResourceDirectory,
logger: logger,
logPath: options.LogPath,
versions: options.Versions,
initialSettings: initialSettings,
autoOpen: autoOpen,
localIdentityPlugin: options.LocalIdentityPlugin,
loginUIPlugin: options.LoginUIPlugin,
shutdown: func() {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = server.Shutdown(ctx)
}()
},
}
server.Handler = h.routes()
logger.Info("local interface listening", "address", listener.Addr().String())
go func() {
err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("local interface stopped unexpectedly", "error", err)
}
close(done)
}()
pageURL := origin + "/?session=" + token
fmt.Fprintf(os.Stdout, "BD2 Client Studio: %s\n", pageURL)
if !options.NoBrowser {
if err := openBrowser(pageURL); err != nil {
logger.Warn("could not open client window automatically", "error", err)
} else {
logger.Info("client window opened")
}
} else {
logger.Info("automatic client window disabled")
}
<-done
logger.Info("local interface stopped")
return nil
type NativeHost struct {
BrowseDirectory func(context.Context, string) (string, error)
Quit func(context.Context)
}
func (h *handler) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", h.index)
mux.HandleFunc("POST /api/browse", h.observe("browse game directory", h.authorize(h.browseDirectory)))
mux.HandleFunc("POST /api/browse-resources", h.observe("browse resource directory", h.authorize(h.browseResourceDirectory)))
mux.HandleFunc("POST /api/inspect", h.observe("inspect game directory", h.authorize(h.inspect)))
mux.HandleFunc("POST /api/resources", h.observe("check resource policy", h.authorize(h.resources)))
mux.HandleFunc("POST /api/save", h.observe("save settings", h.authorize(h.save)))
mux.HandleFunc("POST /api/patch", h.observe("patch client", h.authorize(h.patch)))
mux.HandleFunc("POST /api/install", h.observe("install plugins", h.authorize(h.install)))
mux.HandleFunc("POST /api/launch", h.observe("launch game", h.authorize(h.launch)))
mux.HandleFunc("POST /api/quit", h.observe("quit", h.authorize(h.quit)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
mux.ServeHTTP(w, r)
})
type Studio struct {
options Options
host NativeHost
ctx context.Context
ctxMu sync.RWMutex
cancel context.CancelFunc
mu sync.Mutex
quitOnce sync.Once
launch func(string) error
savePreferences func(string) error
}
func (h *handler) index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" || r.URL.Query().Get("session") != h.token {
http.NotFound(w, r)
return
}
data, err := webFS.ReadFile("web/index.html")
if err != nil {
http.Error(w, "embedded interface unavailable", http.StatusInternalServerError)
return
}
tmpl, err := template.New("index").Parse(string(data))
if err != nil {
http.Error(w, "embedded interface invalid", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src 'self'; font-src 'self'")
_ = tmpl.Execute(w, map[string]string{
"Token": h.token, "GameDirectory": h.initialGameDir, "LogPath": h.logPath, "Platform": runtime.GOOS,
"ServerOrigin": h.initialSettings.ServerOrigin, "CDNMode": string(h.initialSettings.CDNMode),
"LocalResourceDirectory": h.initialSettings.LocalResourceDirectory,
"AutoOpen": fmt.Sprintf("%t", h.autoOpen),
})
func NewStudio(options Options, host NativeHost) *Studio {
return &Studio{options: options, host: host, ctx: context.Background(), launch: launchGame, savePreferences: clientconfig.SavePreferences}
}
func (h *handler) authorize(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
provided := r.Header.Get("X-BD2-Session")
if len(provided) != len(h.token) || subtle.ConstantTimeCompare([]byte(provided), []byte(h.token)) != 1 {
h.log().Warn("API request rejected", "operation", r.URL.Path, "reason", "invalid session")
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if origin := r.Header.Get("Origin"); origin != "" && origin != h.origin {
h.log().Warn("API request rejected", "operation", r.URL.Path, "reason", "foreign origin")
http.Error(w, "forbidden origin", http.StatusForbidden)
return
}
next(w, r)
func (s *Studio) Startup(ctx context.Context) {
owned, cancel := context.WithCancel(ctx)
s.ctxMu.Lock()
if s.cancel != nil {
s.cancel()
}
s.ctx, s.cancel = owned, cancel
s.ctxMu.Unlock()
}
func (s *Studio) Shutdown() {
s.ctxMu.Lock()
cancel := s.cancel
s.cancel = nil
s.ctxMu.Unlock()
if cancel != nil {
cancel()
}
}
type statusWriter struct {
http.ResponseWriter
status int
func (s *Studio) context() context.Context {
s.ctxMu.RLock()
ctx := s.ctx
s.ctxMu.RUnlock()
return ctx
}
func (w *statusWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (h *handler) observe(operation string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tracked := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next(tracked, r)
if tracked.status >= http.StatusBadRequest {
h.log().Error("client API operation failed", "operation", operation, "status", tracked.status)
return
}
h.log().Info("client API operation completed", "operation", operation, "status", tracked.status)
}
}
func (h *handler) log() *slog.Logger {
if h.logger != nil {
return h.logger
func (s *Studio) log() *slog.Logger {
if s.options.Logger != nil {
return s.options.Logger
}
return slog.Default()
}
func (h *handler) browseDirectory(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
func (s *Studio) Initialize() InitialState {
s.mu.Lock()
defer s.mu.Unlock()
state := InitialState{Platform: runtime.GOOS, LogPath: s.options.LogPath, GameDirectory: s.options.InitialGameDir, ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}
if state.GameDirectory == "" {
return state
}
h.log().Info("directory selection opened", "kind", "game")
dir, err := h.browse(input.UILanguage)
if _, err := clientsetup.Inspect(state.GameDirectory, s.options.Versions); err != nil {
s.log().Warn("saved game directory is no longer valid", "error", err)
return state
}
settings, err := clientconfig.Load(state.GameDirectory)
if err != nil {
h.writeError(w, err)
return
s.log().Warn("saved client connection settings are unavailable", "error", err)
return state
}
if dir == "" {
h.log().Info("directory selection cancelled", "kind", "game")
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Selection cancelled"})
return
}
status, err := clientsetup.Inspect(dir, h.versions)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Game client found", Data: status})
state.ServerOrigin = settings.ServerOrigin
state.CDNMode = settings.CDNMode
state.LocalResourceDirectory = settings.LocalResourceDirectory
state.AutoOpen = true
return state
}
func (h *handler) browseResourceDirectory(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
func (s *Studio) perform(operation string, run func() (Response, error)) (Response, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.context().Err(); err != nil {
return Response{}, err
}
h.log().Info("directory selection opened", "kind", "resources")
dir, err := h.browseResources(input.UILanguage)
result, err := run()
if err != nil {
h.writeError(w, err)
return
s.log().Error("client operation failed", "operation", operation, "error", err)
return Response{}, err
}
if dir == "" {
h.log().Info("directory selection cancelled", "kind", "resources")
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Selection cancelled"})
return
}
policy, err := clientsetup.FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
ServerOrigin: "http://127.0.0.1",
CDNMode: clientconfig.CDNLocal,
LocalResourceDirectory: dir,
}, h.versions)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Local resource directory found", Data: policy})
s.log().Info("client operation completed", "operation", operation)
return result, nil
}
func (h *handler) inspect(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
status, err := clientsetup.Inspect(input.GameDirectory, h.versions)
if err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Game directory is valid", Data: status})
func success(message string, data any) Response {
return Response{OK: true, Message: message, Data: data}
}
func (h *handler) resources(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
func (s *Studio) chooseDirectory(language, kind string) (string, error) {
if s.host.BrowseDirectory == nil {
return "", errors.New("native directory picker is unavailable")
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
policy, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), h.versions)
if err != nil {
h.writeError(w, err)
return
prompt := "Select the Brown Dust II installation directory"
if kind == "resources" {
prompt = "Select the CDN directory containing ServerData and GameData"
}
message := "The client will use the release-locked official CDN"
if policy.Mode == clientconfig.CDNLocal {
message = "Local resources verified"
} else if policy.Mode == clientconfig.CDNServer {
message = "Server resource policy verified"
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: policy})
}
func (h *handler) save(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
settings, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), h.versions)
if err != nil {
h.writeError(w, err)
return
}
if err := clientconfig.SavePreferences(input.GameDirectory); err != nil {
h.writeError(w, err)
return
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Connection settings saved", Data: settings})
}
func (h *handler) patch(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
result, err := clientsetup.Patch(input.GameDirectory, input.settings(), h.versions)
if err != nil {
h.writeError(w, err)
return
}
if err := clientconfig.SavePreferences(input.GameDirectory); err != nil {
h.writeError(w, err)
return
}
message := "Client entry point patched; the original backup was retained"
if !result.Changed {
message = "Client patch is already complete; no asset file was rewritten"
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: result})
}
func (h *handler) install(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
result, err := clientsetup.InstallPlugins(
input.GameDirectory,
input.settings(),
h.versions,
h.localIdentityPlugin,
h.loginUIPlugin,
)
if err != nil {
h.writeError(w, err)
return
}
if err := clientconfig.SavePreferences(input.GameDirectory); err != nil {
h.writeError(w, err)
return
}
message := "BD2 client plugins installed or updated"
if !result.LocalIdentity.Changed && !result.LoginUI.Changed {
message = "BD2 client plugins are already up to date; no DLL was rewritten"
}
h.writeJSON(w, http.StatusOK, response{OK: true, Message: message, Data: result})
}
func (h *handler) launch(w http.ResponseWriter, r *http.Request) {
input, ok := h.decode(w, r)
if !ok {
return
}
status, err := clientsetup.Inspect(input.GameDirectory, h.versions)
if err != nil {
h.writeError(w, err)
return
}
if status.PatchedURL != clientsetup.PatchPlaceholder {
h.writeError(w, errors.New("apply the client patch before launching the game"))
return
}
if !status.BepInEx {
h.writeError(w, errors.New("install BepInEx before launching the game"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
defer cancel()
if _, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), h.versions); err != nil {
h.writeError(w, err)
return
}
if _, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), h.versions); err != nil {
h.writeError(w, err)
return
}
if err := clientconfig.SavePreferences(input.GameDirectory); err != nil {
h.writeError(w, err)
return
}
installation, err := clientlayout.Resolve(status.GameDirectory)
if err != nil {
h.writeError(w, err)
return
}
if !installation.SupportedOnHost() {
h.writeError(w, fmt.Errorf("cannot launch a %s game client from this operating system", installation.Kind))
return
}
for _, pluginName := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll"} {
info, statErr := os.Stat(filepath.Join(installation.Plugins, pluginName))
if statErr != nil || !info.Mode().IsRegular() {
h.writeError(w, fmt.Errorf("install or update the client plugins before launching; %s is missing", pluginName))
return
if language == "zh-CN" {
prompt = "选择 Brown Dust II 安装目录"
if kind == "resources" {
prompt = "选择包含 ServerData 和 GameData 的 CDN 目录"
}
}
if err := launchGame(installation.LaunchTarget()); err != nil {
if errors.Is(err, errGameAlreadyRunning) {
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Brown Dust II is already running"})
return
s.log().Info("directory selection opened", "kind", kind)
dir, err := s.host.BrowseDirectory(s.context(), prompt)
if err == nil && dir == "" {
s.log().Info("directory selection cancelled", "kind", kind)
}
return dir, err
}
func (s *Studio) Browse(input Request) (Response, error) {
return s.perform("browse game directory", func() (Response, error) {
dir, err := s.chooseDirectory(input.UILanguage, "game")
if err != nil {
return Response{}, err
}
h.writeError(w, fmt.Errorf("launch Brown Dust II: %w", err))
return
}
h.log().Info("game launch requested", "platform", installation.Kind, "client_version", status.ClientVersion)
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Brown Dust II started"})
if dir == "" {
return success("Selection cancelled", nil), nil
}
status, err := clientsetup.Inspect(dir, s.options.Versions)
return success("Game client found", status), err
})
}
func (h *handler) quit(w http.ResponseWriter, _ *http.Request) {
h.log().Info("client exit requested")
h.writeJSON(w, http.StatusOK, response{OK: true, Message: "Client tool exited"})
h.quitOnce.Do(h.shutdown)
func (s *Studio) BrowseResources(input Request) (Response, error) {
return s.perform("browse resource directory", func() (Response, error) {
dir, err := s.chooseDirectory(input.UILanguage, "resources")
if err != nil {
return Response{}, err
}
if dir == "" {
return success("Selection cancelled", nil), nil
}
policy, err := clientsetup.FetchResourcePolicy(s.context(), nil, clientconfig.Settings{ServerOrigin: "http://127.0.0.1", CDNMode: clientconfig.CDNLocal, LocalResourceDirectory: dir}, s.options.Versions)
return success("Local resource directory found", policy), err
})
}
func (h *handler) decode(w http.ResponseWriter, r *http.Request) (request, bool) {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var input request
if err := decoder.Decode(&input); err != nil {
h.writeError(w, fmt.Errorf("invalid request: %w", err))
return request{}, false
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
h.writeError(w, errors.New("request must contain exactly one JSON object"))
return request{}, false
}
return input, true
func (s *Studio) Inspect(input Request) (Response, error) {
return s.perform("inspect game directory", func() (Response, error) {
status, err := clientsetup.Inspect(input.GameDirectory, s.options.Versions)
return success("Game directory is valid", status), err
})
}
func (r request) settings() clientconfig.Settings {
return clientconfig.Settings{
ServerOrigin: r.ServerOrigin,
CDNMode: r.CDNMode,
LocalResourceDirectory: r.LocalResourceDirectory,
}
func (s *Studio) Resources(input Request) (Response, error) {
return s.perform("check resource policy", func() (Response, error) {
ctx, cancel := context.WithTimeout(s.context(), 12*time.Second)
defer cancel()
policy, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), s.options.Versions)
message := "The client will use the release-locked official CDN"
if policy.Mode == clientconfig.CDNLocal {
message = "Local resources verified"
} else if policy.Mode == clientconfig.CDNServer {
message = "Server resource policy verified"
}
return success(message, policy), err
})
}
func (h *handler) writeError(w http.ResponseWriter, err error) {
h.log().Error("client operation error", "error", err)
h.writeJSON(w, http.StatusBadRequest, response{OK: false, Message: err.Error()})
func (s *Studio) Save(input Request) (Response, error) {
return s.perform("save settings", func() (Response, error) {
settings, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), s.options.Versions)
if err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
return Response{}, err
}
return success("Connection settings saved", settings), nil
})
}
func (h *handler) writeJSON(w http.ResponseWriter, status int, value response) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
func (s *Studio) Patch(input Request) (Response, error) {
return s.perform("patch client", func() (Response, error) {
result, err := clientsetup.Patch(input.GameDirectory, input.settings(), s.options.Versions)
if err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
return Response{}, err
}
message := "Client entry point patched; the original backup was retained"
if !result.Changed {
message = "Client patch is already complete; no asset file was rewritten"
}
return success(message, result), nil
})
}
func newToken() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("generate UI session: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
func (s *Studio) Install(input Request) (Response, error) {
return s.perform("install plugins", func() (Response, error) {
result, err := clientsetup.InstallPlugins(input.GameDirectory, input.settings(), s.options.Versions, s.options.LocalIdentityPlugin, s.options.LoginUIPlugin)
if err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
return Response{}, err
}
message := "BD2 client plugins installed or updated"
if !result.LocalIdentity.Changed && !result.LoginUI.Changed {
message = "BD2 client plugins are already up to date; no DLL was rewritten"
}
return success(message, result), nil
})
}
func (s *Studio) Launch(input Request) (Response, error) {
return s.perform("launch game", func() (Response, error) {
status, err := clientsetup.Inspect(input.GameDirectory, s.options.Versions)
if err != nil {
return Response{}, err
}
if status.PatchedURL != clientsetup.PatchPlaceholder {
return Response{}, errors.New("apply the client patch before launching the game")
}
if !status.BepInEx {
return Response{}, errors.New("install BepInEx before launching the game")
}
ctx, cancel := context.WithTimeout(s.context(), 12*time.Second)
defer cancel()
if _, err := clientsetup.FetchResourcePolicy(ctx, nil, input.settings(), s.options.Versions); err != nil {
return Response{}, err
}
if _, err := clientsetup.SaveSettings(input.GameDirectory, input.settings(), s.options.Versions); err != nil {
return Response{}, err
}
if err := s.savePreferences(input.GameDirectory); err != nil {
return Response{}, err
}
installation, err := clientlayout.Resolve(status.GameDirectory)
if err != nil {
return Response{}, err
}
if !installation.SupportedOnHost() {
return Response{}, fmt.Errorf("cannot launch a %s game client from this operating system", installation.Kind)
}
for _, name := range []string{"BD2LocalIdentity.dll", "BD2LoginUI.dll"} {
info, err := os.Stat(filepath.Join(installation.Plugins, name))
if err != nil || !info.Mode().IsRegular() {
return Response{}, fmt.Errorf("install or update the client plugins before launching; %s is missing", name)
}
}
if err := s.launch(installation.LaunchTarget()); err != nil {
if errors.Is(err, errGameAlreadyRunning) {
return success("Brown Dust II is already running", nil), nil
}
return Response{}, fmt.Errorf("launch Brown Dust II: %w", err)
}
s.log().Info("game launch requested", "platform", installation.Kind, "client_version", status.ClientVersion)
return success("Brown Dust II started", nil), nil
})
}
func (s *Studio) Quit() {
s.quitOnce.Do(func() {
s.log().Info("client exit requested")
if s.host.Quit != nil {
s.host.Quit(s.context())
}
})
}
+163 -85
View File
@@ -1,10 +1,10 @@
package app
import (
"encoding/json"
"io"
"context"
"errors"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -13,89 +13,183 @@ import (
clientconfig "bd2server/internal/client/config"
)
func TestIndexRequiresSessionAndServesEmbeddedStudio(t *testing.T) {
h := &handler{token: "test-session", origin: "http://127.0.0.1"}
server := httptest.NewServer(h.routes())
defer server.Close()
response, err := http.Get(server.URL + "/")
func TestEmbeddedStudioUsesNativeBindings(t *testing.T) {
page, err := fs.ReadFile(webFS, "web/index.html")
if err != nil {
t.Fatal(err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusNotFound {
t.Fatalf("without session status=%d", response.StatusCode)
}
response, err = http.Get(server.URL + "/?session=test-session")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
buffer, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
page := string(buffer)
text := string(page)
for _, marker := range []string{
"BD2 Client Studio", "test-session", `name="bd2-platform"`,
"BD2 Client Studio", "window.go.app.Studio", "bridge().Initialize()",
`id="directoryScene"`, `id="serverScene"`, `id="deskScene"`,
`id="gameDir"`, `id="origin"`, `id="patch"`, `id="install"`, `id="launch"`,
`id="patch"`, `id="install"`, `id="launch"`,
`value="official"`, `value="local"`, `value="server"`,
`id="localResourceDir"`, `id="browseResources"`,
`Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Macau`, `Asia/Taipei`,
`const zhCN=CHINA_TIME_ZONES.has(detectedTimeZone)`,
"opening-curtain", "is-entering", "@keyframes reveal", "prefers-reduced-motion",
"prefers-reduced-motion",
"opening-curtain", "is-entering", "@keyframes reveal", "bridge().Quit()",
"Asia/Shanghai", "Asia/Hong_Kong", "Asia/Macau", "Asia/Taipei",
} {
if !strings.Contains(page, marker) {
t.Errorf("page lacks %q", marker)
if !strings.Contains(text, marker) {
t.Errorf("embedded desktop interface lacks %q", marker)
}
}
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("Cache-Control") != "no-store" {
t.Fatalf("security headers=%v", response.Header)
for _, obsolete := range []string{"fetch(", "/api/", "bd2-session", "X-BD2-Session", "{{.", "window_darwin.js"} {
if strings.Contains(text, obsolete) {
t.Errorf("embedded desktop interface retains obsolete browser bridge %q", obsolete)
}
}
}
func TestAPIRejectsMalformedAndTrailingJSON(t *testing.T) {
h := &handler{token: "test-session", origin: "http://local.invalid", versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}
for name, body := range map[string]string{
"malformed": `{`,
"trailing": `{}` + `{}`,
"unknown": `{"unexpected":true}`,
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(body))
request.Header.Set("X-BD2-Session", "test-session")
request.Header.Set("Origin", "http://local.invalid")
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
})
func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
dir := makeTestClient(t)
studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{})
if state := studio.Initialize(); state.AutoOpen {
t.Fatal("missing settings enabled automatic workspace")
}
if _, err := clientconfig.Save(dir, clientconfig.Settings{ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}); err != nil {
t.Fatal(err)
}
state := studio.Initialize()
if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" {
t.Fatalf("state=%+v", state)
}
}
func TestAPIRejectsMissingTokenAndForeignOrigin(t *testing.T) {
h := &handler{token: "test-session", origin: "http://local.invalid", browse: func(string) (string, error) { return "", nil }}
for name, values := range map[string][2]string{
"missing token": {"", "http://local.invalid"},
"foreign origin": {"test-session", "https://attacker.invalid"},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/browse", strings.NewReader("{}"))
request.Header.Set("X-BD2-Session", values[0])
request.Header.Set("Origin", values[1])
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
})
func TestStudioSaveRemembersOnlyValidatedSettings(t *testing.T) {
dir := makeTestClient(t)
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{})
var remembered string
studio.savePreferences = func(value string) error { remembered = value; return nil }
input := Request{GameDirectory: dir, ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}
result, err := studio.Save(input)
if err != nil || !result.OK || remembered != dir {
t.Fatalf("result=%+v remembered=%q err=%v", result, remembered, err)
}
remembered = ""
input.ServerOrigin = "http://untrusted.example.com"
if _, err := studio.Save(input); err == nil || remembered != "" {
t.Fatalf("invalid settings remembered=%q err=%v", remembered, err)
}
if _, err := studio.Launch(Request{GameDirectory: dir}); err == nil {
t.Fatal("unpatched game launch accepted")
}
}
func TestInspectAPI(t *testing.T) {
func TestStudioContextCancellationAndPickerCancellation(t *testing.T) {
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) { return "", nil }})
for _, browse := range []func(Request) (Response, error){studio.Browse, studio.BrowseResources} {
result, err := browse(Request{})
if err != nil || !result.OK || result.Message != "Selection cancelled" || result.Data != nil {
t.Fatalf("result=%+v err=%v", result, err)
}
}
ctx, cancel := context.WithCancel(context.Background())
studio.Startup(ctx)
cancel()
if _, err := studio.Resources(Request{ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}); !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
}
func TestStudioShutdownCancelsOwnedContext(t *testing.T) {
studio := NewStudio(Options{}, NativeHost{})
studio.Startup(context.Background())
owned := studio.context()
studio.Shutdown()
if !errors.Is(owned.Err(), context.Canceled) {
t.Fatalf("owned context err=%v", owned.Err())
}
}
func TestStudioInitializeDefaultsWithoutGameDirectory(t *testing.T) {
studio := NewStudio(Options{LogPath: "test.log"}, NativeHost{})
studio.Startup(context.Background())
state := studio.Initialize()
if state.Platform == "" || state.LogPath != "test.log" || state.GameDirectory != "" {
t.Fatalf("state=%+v", state)
}
if state.ServerOrigin != "http://127.0.0.1:8080" || state.CDNMode != clientconfig.CDNOfficial || state.AutoOpen {
t.Fatalf("defaults=%+v", state)
}
}
func TestStudioBrowseUsesNativeDirectoryPicker(t *testing.T) {
dir := makeTestClient(t)
var title string
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{
BrowseDirectory: func(_ context.Context, requested string) (string, error) {
title = requested
return dir, nil
},
})
studio.Startup(context.Background())
result, err := studio.Browse(Request{UILanguage: "zh-CN"})
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Data == nil || title != "选择 Brown Dust II 安装目录" {
t.Fatalf("result=%+v title=%q", result, title)
}
}
func TestStudioQuitUsesNativeLifecycleOnce(t *testing.T) {
quits := 0
studio := NewStudio(Options{}, NativeHost{Quit: func(context.Context) { quits++ }})
studio.Startup(context.Background())
studio.Quit()
studio.Quit()
if quits != 1 {
t.Fatalf("quits=%d", quits)
}
}
func TestStudioPropagatesOperationErrors(t *testing.T) {
want := errors.New("picker failed")
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) {
return "", want
}})
studio.Startup(context.Background())
if _, err := studio.Browse(Request{}); !errors.Is(err, want) {
t.Fatalf("err=%v", err)
}
}
func TestDesktopSecurityHeaders(t *testing.T) {
called := false
handler := desktopSecurityHeaders(httpHandlerFunc(func(header map[string]string) {
called = true
if header["Cache-Control"] != "no-store" || header["Content-Security-Policy"] == "" {
t.Fatalf("headers=%v", header)
}
}))
response := &headerRecorder{header: make(map[string][]string)}
handler.ServeHTTP(response, nil)
if !called {
t.Fatal("asset middleware did not call the next handler")
}
}
// Small local adapters keep this middleware test independent of httptest's
// network-shaped helpers.
type httpHandlerFunc func(map[string]string)
func (f httpHandlerFunc) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
headers := make(map[string]string)
for name, values := range w.Header() {
if len(values) != 0 {
headers[name] = values[0]
}
}
f(headers)
}
type headerRecorder struct{ header http.Header }
func (r *headerRecorder) Header() http.Header { return r.header }
func (*headerRecorder) Write([]byte) (int, error) { return 0, nil }
func (*headerRecorder) WriteHeader(int) {}
func makeTestClient(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
@@ -109,21 +203,5 @@ func TestInspectAPI(t *testing.T) {
t.Fatal(err)
}
}
h := &handler{token: "test-session", origin: "http://local.invalid", versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}
body, _ := json.Marshal(request{GameDirectory: dir})
req := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(string(body)))
req.Header.Set("X-BD2-Session", "test-session")
req.Header.Set("Origin", "http://local.invalid")
recorder := httptest.NewRecorder()
h.routes().ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var result response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if !result.OK {
t.Fatalf("response=%+v", result)
}
return dir
}
+82
View File
@@ -0,0 +1,82 @@
package app
import (
"context"
"fmt"
"io/fs"
"net/http"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
"github.com/wailsapp/wails/v2/pkg/options/windows"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// Run starts one native desktop window. Wails uses WebView2 on Windows and
// WKWebView on macOS; no loopback listener or external browser is involved.
func Run(runOptions Options) error {
assets, err := fs.Sub(webFS, "web")
if err != nil {
return fmt.Errorf("load embedded client interface: %w", err)
}
host := NativeHost{
BrowseDirectory: func(ctx context.Context, title string) (string, error) {
return wailsruntime.OpenDirectoryDialog(ctx, wailsruntime.OpenDialogOptions{Title: title})
},
Quit: wailsruntime.Quit,
}
studio := NewStudio(runOptions, host)
studio.log().Info("starting native client window", "windows_engine", "WebView2", "macos_engine", "WKWebView")
err = wails.Run(&options.App{
Title: "BD2 Client Studio",
Width: 1080,
Height: 720,
MinWidth: 860,
MinHeight: 580,
BackgroundColour: options.NewRGB(231, 220, 199),
AssetServer: &assetserver.Options{
Assets: assets,
Middleware: desktopSecurityHeaders,
},
OnStartup: studio.Startup,
OnShutdown: func(context.Context) {
studio.Shutdown()
studio.log().Info("native client window closed")
},
Bind: []interface{}{studio},
EnableDefaultContextMenu: false,
DragAndDrop: &options.DragAndDrop{
DisableWebViewDrop: true,
},
Windows: &windows.Options{
Theme: windows.Light,
BackdropType: windows.Mica,
DisablePinchZoom: true,
IsZoomControlEnabled: false,
EnableSwipeGestures: false,
},
Mac: &mac.Options{
TitleBar: mac.TitleBarDefault(),
Appearance: mac.NSAppearanceNameAqua,
DisableZoom: true,
},
})
if err != nil {
studio.log().Error("native client window stopped with an error", "error", err)
return fmt.Errorf("run native client window: %w", err)
}
return nil
}
func desktopSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'")
next.ServeHTTP(w, r)
})
}
-147
View File
@@ -1,147 +0,0 @@
//go:build windows
package app
import (
"fmt"
"runtime"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
coinitApartmentThreaded = 0x2
clsctxInprocServer = 0x1
fosNoChangeDir = 0x00000008
fosPickFolders = 0x00000020
fosForceFileSystem = 0x00000040
fosPathMustExist = 0x00000800
fosDontAddToRecent = 0x02000000
sigdnFileSystemPath = 0x80058000
errorCancelled = 0x800704c7
)
var (
ole32DLL = windows.NewLazySystemDLL("ole32.dll")
user32DLL = windows.NewLazySystemDLL("user32.dll")
coInitializeEx = ole32DLL.NewProc("CoInitializeEx")
coUninitialize = ole32DLL.NewProc("CoUninitialize")
coCreateInstance = ole32DLL.NewProc("CoCreateInstance")
coTaskMemFree = ole32DLL.NewProc("CoTaskMemFree")
getForegroundWindow = user32DLL.NewProc("GetForegroundWindow")
clsidFileOpenDialog = windows.GUID{Data1: 0xdc1c5a9c, Data2: 0xe88a, Data3: 0x4dde, Data4: [8]byte{0xa5, 0xa1, 0x60, 0xf8, 0x2a, 0x20, 0xae, 0xf7}}
iidIFileOpenDialog = windows.GUID{Data1: 0xd57c7288, Data2: 0xd4ad, Data3: 0x4768, Data4: [8]byte{0xbe, 0x02, 0x9d, 0x96, 0x95, 0x32, 0xd9, 0x60}}
)
// comObject is sufficient for IFileOpenDialog and IShellItem because COM
// interfaces begin with a pointer to a vtable. The methods used below are
// selected by their documented vtable positions.
type comObject struct {
vtable *[29]uintptr
}
func browseForDirectory(titleText string) (string, error) {
// COM apartment state belongs to an OS thread. Keep this handler on one
// thread from initialization until every interface has been released.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
result, _, _ := coInitializeEx.Call(0, coinitApartmentThreaded)
if hresultFailed(result) {
return "", hresultError("initialize Windows directory picker", result)
}
defer coUninitialize.Call()
var dialog *comObject
result, _, _ = coCreateInstance.Call(
uintptr(unsafe.Pointer(&clsidFileOpenDialog)),
0,
clsctxInprocServer,
uintptr(unsafe.Pointer(&iidIFileOpenDialog)),
uintptr(unsafe.Pointer(&dialog)),
)
if hresultFailed(result) {
return "", hresultError("create Windows directory picker", result)
}
if dialog == nil {
return "", fmt.Errorf("create Windows directory picker: the system returned no dialog")
}
defer comRelease(dialog)
var options uint32
result = comCall(dialog, 10, uintptr(unsafe.Pointer(&options))) // IFileDialog::GetOptions
if hresultFailed(result) {
return "", hresultError("read Windows directory picker options", result)
}
options |= fosNoChangeDir | fosPickFolders | fosForceFileSystem | fosPathMustExist | fosDontAddToRecent
result = comCall(dialog, 9, uintptr(options)) // IFileDialog::SetOptions
if hresultFailed(result) {
return "", hresultError("set Windows directory picker options", result)
}
title, err := windows.UTF16PtrFromString(titleText)
if err != nil {
return "", fmt.Errorf("set Windows directory picker title: %w", err)
}
result = comCall(dialog, 17, uintptr(unsafe.Pointer(title))) // IFileDialog::SetTitle
runtime.KeepAlive(title)
if hresultFailed(result) {
return "", hresultError("set Windows directory picker title", result)
}
owner, _, _ := getForegroundWindow.Call()
result = comCall(dialog, 3, owner) // IModalWindow::Show
if uint32(result) == errorCancelled {
return "", nil
}
if hresultFailed(result) {
return "", hresultError("show Windows directory picker", result)
}
var item *comObject
result = comCall(dialog, 20, uintptr(unsafe.Pointer(&item))) // IFileDialog::GetResult
if hresultFailed(result) {
return "", hresultError("read selected directory", result)
}
if item == nil {
return "", fmt.Errorf("read selected directory: the system returned no directory")
}
defer comRelease(item)
var path *uint16
result = comCall(item, 5, sigdnFileSystemPath, uintptr(unsafe.Pointer(&path))) // IShellItem::GetDisplayName
if hresultFailed(result) {
return "", hresultError("read selected directory path", result)
}
if path == nil {
return "", fmt.Errorf("read selected directory path: the system returned an empty path")
}
defer coTaskMemFree.Call(uintptr(unsafe.Pointer(path)))
return windows.UTF16PtrToString(path), nil
}
func comCall(object *comObject, method int, args ...uintptr) uintptr {
callArgs := make([]uintptr, 1, len(args)+1)
callArgs[0] = uintptr(unsafe.Pointer(object))
callArgs = append(callArgs, args...)
result, _, _ := syscall.SyscallN(object.vtable[method], callArgs...)
return result
}
func comRelease(object *comObject) {
if object != nil {
comCall(object, 2) // IUnknown::Release
}
}
func hresultFailed(result uintptr) bool {
return int32(uint32(result)) < 0
}
func hresultError(action string, result uintptr) error {
return fmt.Errorf("%s: HRESULT 0x%08X", action, uint32(result))
}
+4 -30
View File
@@ -2,12 +2,10 @@ package app
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"unicode"
"unicode/utf8"
@@ -40,13 +38,9 @@ func TestOpenPersistentLoggerUsesExecutableLogDirectory(t *testing.T) {
func TestPersistentOperationalLogsUseEnglish(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewTextHandler(&output, nil))
h := &handler{token: "test-session", origin: "http://local.invalid", logger: logger}
body := `{"game_directory":"Z:\\missing","server_origin":"http://127.0.0.1:8080","cdn_mode":"official","local_resource_directory":"","ui_language":"zh-CN"}`
request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader(body))
request.Header.Set("X-BD2-Session", "test-session")
request.Header.Set("Origin", "http://local.invalid")
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
studio := NewStudio(Options{Logger: logger}, NativeHost{})
studio.Startup(context.Background())
_, _ = studio.Inspect(Request{GameDirectory: `Z:\missing`, UILanguage: "zh-CN"})
for len(output.Bytes()) > 0 {
r, size := utf8.DecodeRune(output.Bytes())
if unicode.Is(unicode.Han, r) {
@@ -85,23 +79,3 @@ func TestRollingLogRetainsOneBackup(t *testing.T) {
t.Fatalf("active=%q backup=%q", active, previous)
}
}
func TestAuthorizationLogDoesNotIncludeSessionValues(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewTextHandler(&output, nil))
h := &handler{token: "expected-session-secret", origin: "http://local.invalid", logger: logger}
request := httptest.NewRequest(http.MethodPost, "/api/inspect", strings.NewReader("{}"))
request.Header.Set("X-BD2-Session", "provided-session-secret")
request.Header.Set("Origin", "http://local.invalid")
response := httptest.NewRecorder()
h.routes().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
logged := output.String()
for _, secret := range []string{"expected-session-secret", "provided-session-secret"} {
if strings.Contains(logged, secret) {
t.Fatalf("log contains session value %q: %s", secret, logged)
}
}
}
+1 -37
View File
@@ -12,45 +12,9 @@ import (
)
func ShowFatalError(err error) {
if err == nil {
return
}
message := strings.ReplaceAll(err.Error(), `"`, `\"`)
_ = exec.Command("osascript", "-e", `display alert "BD2 Client Studio" message "`+message+`" as critical`).Run()
}
func openBrowser(url string) error {
return exec.Command("open", url).Start()
}
func browseForGameDirectory(language string) (string, error) {
prompt := "Select the Brown Dust II.app bundle or its parent folder"
if language == "zh-CN" {
prompt = "选择 Brown Dust II.app 或其所在文件夹"
}
return macDirectoryPicker(prompt)
}
func browseForResourceDirectory(language string) (string, error) {
prompt := "Select the CDN directory containing ServerData and GameData"
if language == "zh-CN" {
prompt = "选择包含 ServerData 和 GameData 的 CDN 目录"
}
return macDirectoryPicker(prompt)
}
func macDirectoryPicker(prompt string) (string, error) {
prompt = strings.ReplaceAll(prompt, `"`, `\"`)
command := exec.Command("osascript", "-e", `POSIX path of (choose folder with prompt "`+prompt+`")`)
output, err := command.Output()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return "", nil
}
return "", fmt.Errorf("open macOS directory picker: %w", err)
_, _ = fmt.Fprintln(os.Stderr, "BD2 Client Studio:", err)
}
return strings.TrimSpace(string(output)), nil
}
func launchGame(target string) error {
-17
View File
@@ -6,8 +6,6 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
)
func ShowFatalError(err error) {
@@ -16,21 +14,6 @@ func ShowFatalError(err error) {
}
}
func openBrowser(url string) error {
if runtime.GOOS == "darwin" {
return exec.Command("open", url).Start()
}
return exec.Command("xdg-open", url).Start()
}
func browseForGameDirectory(string) (string, error) {
return "", errors.New("the native directory picker is unavailable on this platform; enter the Windows client directory manually")
}
func browseForResourceDirectory(string) (string, error) {
return "", errors.New("the native directory picker is unavailable on this platform; enter the resource directory manually")
}
func launchGame(string) error {
return errors.New("the Brown Dust II client is not supported on Linux")
}
@@ -4,7 +4,6 @@ package app
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -68,44 +67,6 @@ func visibleCommand(name string, args ...string) *exec.Cmd {
return command
}
func openBrowser(url string) error {
for _, edge := range edgeCandidates() {
if info, err := os.Stat(edge); err == nil && !info.IsDir() {
return hiddenCommand(edge, "--app="+url, "--window-size=1100,760", "--no-first-run").Start()
}
}
return hiddenCommand("rundll32.exe", "url.dll,FileProtocolHandler", url).Start()
}
func edgeCandidates() []string {
var candidates []string
if edge, err := exec.LookPath("msedge.exe"); err == nil {
candidates = append(candidates, edge)
}
for _, root := range []string{os.Getenv("ProgramFiles(x86)"), os.Getenv("ProgramFiles"), os.Getenv("LOCALAPPDATA")} {
if root != "" {
candidates = append(candidates, filepath.Join(root, "Microsoft", "Edge", "Application", "msedge.exe"))
}
}
return candidates
}
func browseForGameDirectory(language string) (string, error) {
title := "Select the Brown Dust II installation directory"
if language == "zh-CN" {
title = "选择 Brown Dust II 安装目录"
}
return browseForDirectory(title)
}
func browseForResourceDirectory(language string) (string, error) {
title := "Select the CDN directory containing ServerData and GameData"
if language == "zh-CN" {
title = "选择包含 ServerData 和 GameData 的 CDN 目录"
}
return browseForDirectory(title)
}
func launchGame(target string) error {
if processID, running, err := windowsExecutableProcessID(filepath.Base(target)); err != nil {
return err
@@ -29,12 +29,3 @@ func TestVisibleCommandDoesNotHideGUIWindow(t *testing.T) {
t.Fatalf("visible command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
}
}
func TestHRESULTFailureClassification(t *testing.T) {
if hresultFailed(0) || hresultFailed(1) {
t.Fatal("successful HRESULT classified as failure")
}
if !hresultFailed(errorCancelled) || !hresultFailed(0x80004005) {
t.Fatal("failed HRESULT classified as success")
}
}
+19 -25
View File
@@ -3,10 +3,6 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="bd2-session" content="{{.Token}}">
<meta name="bd2-platform" content="{{.Platform}}">
<meta name="bd2-initial-cdn" content="{{.CDNMode}}">
<meta name="bd2-auto-open" content="{{.AutoOpen}}">
<meta name="theme-color" content="#e8decc">
<title>BD2 Client Studio</title>
<style>
@@ -34,11 +30,11 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
<main>
<section class="scene intro-scene is-entering" id="directoryScene" aria-labelledby="directoryTitle">
<div><div class="eyebrow reveal" style="--i:1">01 / GAME DIRECTORY</div><h1 class="reveal" style="--i:2" id="directoryTitle" tabindex="-1">先从游戏目录<br>开始。</h1><p class="lead reveal" style="--i:3">选择已安装的游戏文件夹。<br>我们会检查游戏程序和资源文件,再继续设置连接。</p><div class="intro-label reveal" style="--i:4"><i></i>BrownDust II · 客户端配置</div></div>
<div class="form-card reveal" style="--i:4"><div class="card-number" aria-hidden="true">01</div><h2>选择游戏目录</h2><p class="description">请选择包含 <code>BrownDust II.exe</code> 的文件夹。</p><div id="directoryHome"><div id="directoryFields"><label class="field-label" for="gameDir">游戏安装目录</label><div class="input-row"><input id="gameDir" type="text" value="{{.GameDirectory}}" placeholder="例如 E:\Games\BrownDust II" autocomplete="off" spellcheck="false"><button class="btn" id="browse">浏览</button></div><div class="form-tools"><span id="directoryHint">可直接填写路径,也可以浏览目录。</span><button class="text-btn" id="inspect">检查目录</button></div><p class="inline-result" id="directoryResult" role="status"></p></div></div><button class="btn primary wide" id="directoryNext"><span>继续设置服务器</span><span class="arrow" aria-hidden="true">→</span></button><p class="small-note">此步骤只检查文件,不会修改游戏资源。</p></div>
<div class="form-card reveal" style="--i:4"><div class="card-number" aria-hidden="true">01</div><h2>选择游戏目录</h2><p class="description">请选择包含 <code>BrownDust II.exe</code> 的文件夹。</p><div id="directoryHome"><div id="directoryFields"><label class="field-label" for="gameDir">游戏安装目录</label><div class="input-row"><input id="gameDir" type="text" value="" placeholder="例如 E:\Games\BrownDust II" autocomplete="off" spellcheck="false"><button class="btn" id="browse">浏览</button></div><div class="form-tools"><span id="directoryHint">可直接填写路径,也可以浏览目录。</span><button class="text-btn" id="inspect">检查目录</button></div><p class="inline-result" id="directoryResult" role="status"></p></div></div><button class="btn primary wide" id="directoryNext"><span>继续设置服务器</span><span class="arrow" aria-hidden="true">→</span></button><p class="small-note">此步骤只检查文件,不会修改游戏资源。</p></div>
</section>
<section class="scene intro-scene" id="serverScene" hidden aria-labelledby="serverTitle">
<div><div class="eyebrow reveal" style="--i:1">02 / SERVER CONNECTION</div><h1 class="reveal" style="--i:2" id="serverTitle" tabindex="-1">接下来,<br>设置服务器。</h1><p class="lead reveal" style="--i:3">填写要连接的服务端地址。<br>进入工作台后,你可以选择资源来源、应用补丁和安装插件。</p><div class="intro-label reveal" style="--i:4"><i></i>目录已选择 · 之后仍可修改</div></div>
<div class="form-card reveal" style="--i:4"><div class="card-number" aria-hidden="true">02</div><h2>服务器地址</h2><p class="description">填写完整地址,可以包含端口。</p><div id="serverHome"><div id="serverFields"><label class="field-label" for="origin">地址与端口</label><input id="origin" type="text" value="{{.ServerOrigin}}" placeholder="https://play.example.com" autocomplete="url" spellcheck="false" aria-describedby="originHint"><p class="small-note" id="originHint">例如 <code>http://192.168.1.8:8080</code><br>不要添加 <code>/game/</code> 或其他路径。真实连接地址由 LI 插件读取。</p><p class="inline-result" id="serverResult" role="status"></p></div></div><button class="btn primary wide" id="serverNext"><span>进入工作台</span><span class="arrow" aria-hidden="true">→</span></button><div class="server-back"><button class="text-btn" id="serverBack">← 返回目录设置</button></div></div>
<div class="form-card reveal" style="--i:4"><div class="card-number" aria-hidden="true">02</div><h2>服务器地址</h2><p class="description">填写完整地址,可以包含端口。</p><div id="serverHome"><div id="serverFields"><label class="field-label" for="origin">地址与端口</label><input id="origin" type="text" value="" placeholder="https://play.example.com" autocomplete="url" spellcheck="false" aria-describedby="originHint"><p class="small-note" id="originHint">例如 <code>http://192.168.1.8:8080</code><br>不要添加 <code>/game/</code> 或其他路径。真实连接地址由 LI 插件读取。</p><p class="inline-result" id="serverResult" role="status"></p></div></div><button class="btn primary wide" id="serverNext"><span>进入工作台</span><span class="arrow" aria-hidden="true">→</span></button><div class="server-back"><button class="text-btn" id="serverBack">← 返回目录设置</button></div></div>
</section>
<section class="scene desk-scene" id="deskScene" hidden aria-labelledby="deskTitle">
<header class="desk-header reveal" style="--i:0"><div><div class="eyebrow">03 / WORKSPACE</div><h1 id="deskTitle" tabindex="-1">客户端工作台</h1><p>管理连接设置,完成游戏启动前的准备。</p></div><div class="desk-actions"><div class="connection-chip"><span class="dot" id="pulse"></span><span id="overallStatus">等待检查</span></div><button class="btn primary" id="launch">启动游戏 <span aria-hidden="true">▶</span></button></div></header>
@@ -48,9 +44,9 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
<label class="route"><input type="radio" name="cdn" value="official" checked><span class="route-content"><span class="route-top"><span class="route-code">OFFICIAL</span><span class="radio-dot"></span></span><strong>官方 CDN</strong><small>从官方节点下载资源,不占用服主的大文件流量。</small></span></label>
<label class="route"><input type="radio" name="cdn" value="local"><span class="route-content"><span class="route-top"><span class="route-code">LOCAL</span><span class="radio-dot"></span></span><strong>本机已下载资源</strong><small>直接读取电脑上的 ServerData 和 GameData,不再下载相同文件。</small></span></label>
<label class="route"><input type="radio" name="cdn" value="server"><span class="route-content"><span class="route-top"><span class="route-code">SERVER</span><span class="radio-dot"></span></span><strong>服主资源</strong><small>由服务器提供资源地址;服主可自建 CDN,也可以反代官方 CDN。</small></span></label>
</div><div class="local-resource" id="localResource" hidden><label class="field-label" for="localResourceDir">本机 CDN 根目录</label><div class="input-row"><input id="localResourceDir" type="text" value="{{.LocalResourceDirectory}}" placeholder="例如 E:\bd2\dl" autocomplete="off" spellcheck="false"><button class="btn" id="browseResources">浏览</button></div><p class="small-note">所选目录内必须同时包含 <code>ServerData</code> 和 <code>GameData</code>。</p></div><p class="route-hint" id="routeHint">官方模式由客户端直连版本锁定的官方 CDN。</p><div class="route-actions"><button class="text-btn" id="checkResources">验证资源来源 ↗</button><button class="btn" id="save">保存连接设置</button></div><p class="unsaved" id="dirtyNotice">当前设置尚未保存。</p><div class="policy" id="policy" role="status" hidden></div></section>
</div><div class="local-resource" id="localResource" hidden><label class="field-label" for="localResourceDir">本机 CDN 根目录</label><div class="input-row"><input id="localResourceDir" type="text" value="" placeholder="例如 E:\bd2\dl" autocomplete="off" spellcheck="false"><button class="btn" id="browseResources">浏览</button></div><p class="small-note">所选目录内必须同时包含 <code>ServerData</code> 和 <code>GameData</code>。</p></div><p class="route-hint" id="routeHint">官方模式由客户端直连版本锁定的官方 CDN。</p><div class="route-actions"><button class="text-btn" id="checkResources">验证资源来源 ↗</button><button class="btn" id="save">保存连接设置</button></div><p class="unsaved" id="dirtyNotice">当前设置尚未保存。</p><div class="policy" id="policy" role="status" hidden></div></section>
<section class="panel-section"><div class="section-title"><span class="index">03</span><h2>补丁与插件</h2></div><p class="prepare-note">操作前请先关闭游戏。补丁只修改内置 Intro 地址,并保留 <code>resources.assets.bak</code> 备份。安装插件前,请先手动安装 BepInEx。</p><div class="prepare-actions"><button class="btn rust" id="patch">应用客户端补丁 <span aria-hidden="true">↗</span></button><button class="btn primary" id="install">安装 / 更新插件 <span aria-hidden="true">↗</span></button></div></section>
</div><aside class="status-panel reveal" style="--i:3" aria-label="客户端状态"><div class="status-title"><h2>当前状态</h2><span>LOCAL STATUS</span></div><div><div class="check"><span>游戏程序</span><span id="sExe">等待检查</span></div><div class="check"><span>资源文件</span><span id="sAssets">等待检查</span></div><div class="check"><span>BepInEx</span><span id="sBep">等待检查</span></div><div class="check"><span>连接配置</span><span id="sConfig">尚未写入</span></div><p class="status-foot">状态来自本机文件检查,不代表服务器在线。<br>诊断日志:<code id="persistentLogPath">{{.LogPath}}</code></p><button class="text-btn" style="font-size:11px" id="refresh">重新检查 ↻</button></div><div class="log-wrap"><div class="log-heading"><span>最近操作</span><time id="logTime">—</time></div><div class="log" id="log" role="status"><strong>等待操作</strong><span>选择游戏目录后即可开始检查。</span></div><details class="history"><summary>查看操作记录</summary><ol id="historyList"></ol></details></div></aside></div>
</div><aside class="status-panel reveal" style="--i:3" aria-label="客户端状态"><div class="status-title"><h2>当前状态</h2><span>LOCAL STATUS</span></div><div><div class="check"><span>游戏程序</span><span id="sExe">等待检查</span></div><div class="check"><span>资源文件</span><span id="sAssets">等待检查</span></div><div class="check"><span>BepInEx</span><span id="sBep">等待检查</span></div><div class="check"><span>连接配置</span><span id="sConfig">尚未写入</span></div><p class="status-foot">状态来自本机文件检查,不代表服务器在线。<br>诊断日志:<code id="persistentLogPath"></code></p><button class="text-btn" style="font-size:11px" id="refresh">重新检查 ↻</button></div><div class="log-wrap"><div class="log-heading"><span>最近操作</span><time id="logTime">—</time></div><div class="log" id="log" role="status"><strong>等待操作</strong><span>选择游戏目录后即可开始检查。</span></div><details class="history"><summary>查看操作记录</summary><ol id="historyList"></ol></details></div></aside></div>
<footer class="desk-footer reveal" style="--i:4"><span>BD2 CLIENT STUDIO <span aria-hidden="true">/</span> 配置仅保存在你的电脑上</span><span>不会保存账号凭据或服主密钥</span></footer>
</section>
<section class="scene quit-scene" id="quitScene" hidden aria-labelledby="quitTitle"><div class="eyebrow reveal">CLIENT STUDIO</div><h1 class="reveal" id="quitTitle" tabindex="-1">工具已退出</h1><p class="reveal">现在可以关闭这个页面。</p></section>
@@ -61,10 +57,7 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
<noscript><p style="padding:20px">请启用 JavaScript 以使用客户端设置工具。</p></noscript>
<script>
'use strict';
const SESSION=document.querySelector('meta[name="bd2-session"]').content;
const HOST_PLATFORM=document.querySelector('meta[name="bd2-platform"]').content;
const INITIAL_CDN=document.querySelector('meta[name="bd2-initial-cdn"]').content;
const AUTO_OPEN=document.querySelector('meta[name="bd2-auto-open"]').content==='true';
let HOST_PLATFORM='',INITIAL_CDN='official',AUTO_OPEN=false;
const $=id=>document.getElementById(id);
const reduced=window.matchMedia('(prefers-reduced-motion: reduce)');
const CHINA_TIME_ZONES=new Set(['Asia/Shanghai','Asia/Chongqing','Asia/Harbin','Asia/Urumqi','Asia/Hong_Kong','Asia/Macau','Asia/Taipei']);
@@ -82,14 +75,15 @@ if(!zhCN){
document.querySelectorAll('[aria-label]').forEach(element=>{let value=element.getAttribute('aria-label');for(const [source,target] of translations)value=value.replaceAll(source,target);element.setAttribute('aria-label',value)});
$('gameDir').placeholder='Example: E:\\Games\\Brown Dust II';$('origin').placeholder='https://play.example.com';$('localResourceDir').placeholder='Example: E:\\bd2\\dl';
}
if(HOST_PLATFORM==='darwin'){$('gameDir').placeholder=localized('例如 /Applications/BrownDust II.app','Example: /Applications/BrownDust II.app');$('directoryScene').querySelector('.description').textContent=localized('请选择 BrownDust II.app 或它所在的文件夹。','Select BrownDust II.app or its parent folder.')}
let scene='directory',busy=false,transitioning=false,toastTimer,editorKind=null,savedSignature=null;
const status={executable:null,resources:null,bepinex:null,config:null};
function payload(){const mode=document.querySelector('input[name="cdn"]:checked').value;return{game_directory:$('gameDir').value.trim(),server_origin:$('origin').value.trim(),cdn_mode:mode,local_resource_directory:mode==='local'?$('localResourceDir').value.trim():'',ui_language:zhCN?'zh-CN':'en'}}
function signature(){return JSON.stringify(payload())}
const backendMessages={'Selection cancelled':'已取消选择','Game client found':'已找到游戏客户端','Local resource directory found':'已找到本机资源目录','Game directory is valid':'游戏目录有效','The client will use the release-locked official CDN':'将由客户端直接连接版本锁定的官方 CDN','Local resources verified':'本机资源已验证','Server resource policy verified':'服务器资源策略已确认','Connection settings saved':'连接设置已保存','Client entry point patched; the original backup was retained':'客户端入口补丁完成;原文件备份已保留','Client patch is already complete; no asset file was rewritten':'客户端补丁已经完成,未再次改写资源文件','BD2 client plugins installed or updated':'BD2 客户端插件已安装或更新','BD2 client plugins are already up to date; no DLL was rewritten':'BD2 客户端插件已经是最新版本,未再次改写 DLL','Brown Dust II started':'Brown Dust II 已启动','Brown Dust II is already running':'Brown Dust II 已经在运行','Client tool exited':'客户端工具已退出'};
const localizeMessage=message=>zhCN?(backendMessages[message]||message):message;
async function api(path,body={}){const res=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-BD2-Session':SESSION},body:JSON.stringify(body)});let data;try{data=await res.json()}catch{throw new Error(localized('客户端工具返回了无法解析的响应','The client tool returned an unreadable response'))}if(!res.ok||!data.ok)throw new Error(localizeMessage(data.message)||localized(`操作失败(HTTP ${res.status})`,`Operation failed (HTTP ${res.status})`));if(data.message)data.message=localizeMessage(data.message);return data}
const nativeMethods={browse:'Browse',browseResources:'BrowseResources',inspect:'Inspect',resources:'Resources',save:'Save',patch:'Patch',install:'Install',launch:'Launch'};
function bridge(){const bindings=window.go&&window.go.app&&window.go.app.Studio;if(!bindings)throw new Error(localized('客户端原生接口尚未就绪','The native client interface is unavailable'));return bindings}
async function api(operation,body={}){let data;try{data=await bridge()[nativeMethods[operation]](body)}catch(error){throw new Error(localizeMessage(error.message||String(error)))}if(!data||!data.ok)throw new Error(localizeMessage(data&&data.message)||localized('操作失败','Operation failed'));if(data.message)data.message=localizeMessage(data.message);return data}
function show(message,error=false){message=message||(error?localized('操作未完成','Operation failed'):localized('操作已完成','Operation completed'));clearTimeout(toastTimer);const t=$('toast');t.textContent=message;t.className='toast show'+(error?' error':'');toastTimer=setTimeout(()=>t.className='toast',5200);$('log').replaceChildren();const title=document.createElement('strong');title.textContent=error?localized('操作未完成','Operation failed'):localized('操作已完成','Operation completed');const desc=document.createElement('span');desc.textContent=message;$('log').append(title,desc);const now=new Date(),time=now.toLocaleTimeString(zhCN?'zh-CN':'en-US',{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('logTime').textContent=time;const row=document.createElement('li');if(error)row.className='error';const stamp=document.createElement('time');stamp.dateTime=now.toISOString();stamp.textContent=time;row.append(stamp,document.createTextNode(message));$('historyList').prepend(row);while($('historyList').children.length>20)$('historyList').lastElementChild.remove()}
function mark(id,value,yes=localized('已就绪','Ready'),no=localized('未检测到','Not found')){const e=$(id);e.textContent=value===null?localized('等待检查','Waiting'):value?yes:no;e.className=value===null?'':value?'ok':'warn'}
function renderStatus(){mark('sExe',status.executable);mark('sAssets',status.resources);mark('sBep',status.bepinex,localized('已安装','Installed'),localized('需手动安装','Manual install required'));mark('sConfig',status.config,localized('已写入','Written'),localized('尚未写入','Not written'));const checked=status.executable!==null&&status.resources!==null;const ready=status.executable&&status.resources;$('pulse').className='dot'+(checked?(ready?' good':' bad'):'');$('overallStatus').textContent=checked?(ready?localized('游戏文件已就绪','Game files ready'):localized('游戏文件待检查','Game files need attention')):localized('等待检查','Waiting');if(savedSignature&&signature()!==savedSignature){$('sConfig').textContent=localized('有未保存修改','Unsaved changes');$('sConfig').className='warn'}}
@@ -103,7 +97,7 @@ function setBusy(value){busy=value;document.querySelectorAll('button,input').for
async function action(id,fn){if(busy||transitioning)return;const b=$(id),old=b.innerHTML;setBusy(true);b.textContent=localized('请稍候…','Please wait…');try{await fn()}catch(e){const message=e.message||String(e);show(message,true);if(scene==='directory'||editorKind==='directory'){$('directoryResult').textContent=message;$('directoryResult').className='inline-result error'}if(scene==='server'||editorKind==='server'){$('serverResult').textContent=message;$('serverResult').className='inline-result error'}}finally{b.innerHTML=old;setBusy(false)}}
const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms));
async function go(next){if(transitioning||scene===next)return;transitioning=true;const old=$(scene+'Scene'),target=$(next+'Scene');if(!reduced.matches){old.classList.add('is-leaving');$('veil').classList.add('play');await delay(570)}old.hidden=true;old.classList.remove('is-leaving','is-entering');scene=next;document.body.dataset.scene=next;target.hidden=false;target.classList.add('is-entering');$('journey').hidden=next==='desk'||next==='quit';document.querySelectorAll('[data-step]').forEach(e=>{const active=e.dataset.step===next;e.classList.toggle('active',active);if(active)e.setAttribute('aria-current','step');else e.removeAttribute('aria-current')});window.scrollTo({top:0,behavior:'instant'});$(next==='directory'?'directoryTitle':next==='server'?'serverTitle':next==='desk'?'deskTitle':'quitTitle').focus({preventScroll:true});if(!reduced.matches)await delay(680);$('veil').classList.remove('play');transitioning=false}
async function inspect(){requireDirectory();const r=await api('/api/inspect',payload());applyStatus(r.data);show(r.message);return r}
async function inspect(){requireDirectory();const r=await api('inspect',payload());applyStatus(r.data);show(r.message);return r}
function openEditor(kind){if(busy||transitioning)return;editorKind=kind;$('editorTitle').textContent=kind==='directory'?localized('修改游戏目录','Change game directory'):localized('修改服务器地址','Change server address');$('editorContent').append($(kind+'Fields'));$('editor').showModal();$(kind==='directory'?'gameDir':'origin').focus()}
function closeEditor(){if(busy)return;$('editor').close()}
$('editor').addEventListener('close',()=>{if(editorKind)$(editorKind+'Home').append($(editorKind+'Fields'));editorKind=null;sync()});
@@ -112,22 +106,22 @@ $('closeEditor').onclick=closeEditor;
$('doneEditor').onclick=()=>{try{if(editorKind==='server')requireOrigin();else requireDirectory();closeEditor()}catch(e){show(e.message,true)}};
$('editDirectory').onclick=()=>openEditor('directory');$('editServer').onclick=()=>openEditor('server');
$('gameDir').addEventListener('input',()=>invalidate(true));$('origin').addEventListener('input',()=>{$('serverResult').textContent='';invalidate()});$('localResourceDir').addEventListener('input',()=>invalidate());document.querySelectorAll('input[name="cdn"]').forEach(e=>e.addEventListener('change',()=>invalidate()));
$('browse').onclick=()=>action('browse',async()=>{const previous=payload().game_directory;const r=await api('/api/browse',payload());if(r.data){applyStatus(r.data);if(payload().game_directory!==previous)$('policy').hidden=true}show(r.message)});
$('browseResources').onclick=()=>action('browseResources',async()=>{const r=await api('/api/browse-resources',payload());if(r.data){$('localResourceDir').value=r.data.local_directory||'';invalidate()}show(r.message)});
$('browse').onclick=()=>action('browse',async()=>{const previous=payload().game_directory;const r=await api('browse',payload());if(r.data){applyStatus(r.data);if(payload().game_directory!==previous)$('policy').hidden=true}show(r.message)});
$('browseResources').onclick=()=>action('browseResources',async()=>{const r=await api('browseResources',payload());if(r.data){$('localResourceDir').value=r.data.local_directory||'';invalidate()}show(r.message)});
$('inspect').onclick=()=>action('inspect',inspect);$('refresh').onclick=()=>action('refresh',inspect);
$('directoryNext').onclick=()=>action('directoryNext',async()=>{await inspect();sync();await go('server')});
$('serverBack').onclick=()=>{if(!busy)go('directory')};
$('serverNext').onclick=()=>action('serverNext',async()=>{requireOrigin();sync();await go('desk')});
$('checkResources').onclick=()=>action('checkResources',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/resources',payload()),p=r.data||{},missing=localized('未返回','Missing');$('policy').hidden=false;$('policy').textContent=p.mode==='official'?localized('OFFICIAL · 客户端使用版本锁定的官方地址','OFFICIAL · The client uses release-locked official URLs'):p.mode==='local'?`LOCAL · ${p.local_directory||payload().local_resource_directory}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}`:`SERVER · ${localized('由服务器提供资源地址','Resource URLs supplied by the server')}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}\nBundle · ${p.bundle_version||missing}\nGameData · ${p.game_data_version||missing}`;show(r.message)});
$('checkResources').onclick=()=>action('checkResources',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('resources',payload()),p=r.data||{},missing=localized('未返回','Missing');$('policy').hidden=false;$('policy').textContent=p.mode==='official'?localized('OFFICIAL · 客户端使用版本锁定的官方地址','OFFICIAL · The client uses release-locked official URLs'):p.mode==='local'?`LOCAL · ${p.local_directory||payload().local_resource_directory}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}`:`SERVER · ${localized('由服务器提供资源地址','Resource URLs supplied by the server')}\nServerData · ${p.server_data_url||missing}\nGameData · ${p.game_data_url||missing}\nBundle · ${p.bundle_version||missing}\nGameData · ${p.game_data_version||missing}`;show(r.message)});
function saved(){savedSignature=signature();status.config=true;sync()}
$('save').onclick=()=>action('save',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/save',payload());saved();show(r.message)});
$('patch').onclick=()=>action('patch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/patch',payload());saved();show(r.message)});
$('install').onclick=()=>action('install',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/install',payload());status.bepinex=true;saved();show(r.message)});
$('launch').onclick=()=>action('launch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('/api/launch',payload());saved();show(r.message)});
$('quit').onclick=()=>action('quit',async()=>{await api('/api/quit');clearTimeout(toastTimer);$('toast').className='toast';await go('quit');$('quit').hidden=true});
$('save').onclick=()=>action('save',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('save',payload());saved();show(r.message)});
$('patch').onclick=()=>action('patch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('patch',payload());saved();show(r.message)});
$('install').onclick=()=>action('install',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('install',payload());status.bepinex=true;saved();show(r.message)});
$('launch').onclick=()=>action('launch',async()=>{requireDirectory();requireOrigin();requireResources();const r=await api('launch',payload());saved();show(r.message)});
$('quit').onclick=()=>{if(!busy){clearTimeout(toastTimer);bridge().Quit().catch(error=>show(error.message||String(error),true))}};
$('gameDir').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();(editorKind?$('inspect'):$('directoryNext')).click()}});$('origin').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();(editorKind?$('doneEditor'):$('serverNext')).click()}});
const initialRadio=document.querySelector(`input[name="cdn"][value="${CSS.escape(INITIAL_CDN)}"]`);if(initialRadio)initialRadio.checked=true;
sync();if($('gameDir').value.trim()){action('inspect',async()=>{await inspect();if(AUTO_OPEN){saved();await go('desk')}})}
async function initialize(){setBusy(true);try{const initial=await bridge().Initialize();HOST_PLATFORM=initial.platform;INITIAL_CDN=initial.cdn_mode;AUTO_OPEN=initial.auto_open;$('gameDir').value=initial.game_directory||'';$('origin').value=initial.server_origin||'http://127.0.0.1:8080';$('localResourceDir').value=initial.local_resource_directory||'';$('persistentLogPath').textContent=initial.log_path||'';const initialRadio=document.querySelector(`input[name="cdn"][value="${CSS.escape(INITIAL_CDN)}"]`);if(initialRadio)initialRadio.checked=true;if(HOST_PLATFORM==='darwin'){$('gameDir').placeholder=localized('例如 /Applications/BrownDust II.app','Example: /Applications/BrownDust II.app');$('directoryScene').querySelector('.description').textContent=localized('请选择 BrownDust II.app 或它所在的文件夹。','Select BrownDust II.app or its parent folder.')}sync();if($('gameDir').value.trim()){await inspect();if(AUTO_OPEN){saved();await go('desk')}}}catch(error){show(error.message||String(error),true)}finally{setBusy(false)}}
initialize();
</script>
</body>
</html>
+15 -3
View File
@@ -36,10 +36,22 @@ type LoginSeed struct {
purchaseCounts PurchaseCountProvider
presetSlots PresetSlotProvider
inventorySlots InventorySlotProvider
firstGacha *bool
firstGacha FirstGachaProvider
}
func (s *LoginSeed) SetFirstGacha(value bool) { s.firstGacha = &value }
// FirstGachaProvider reads the mutable account flag for every login, including
// a login after confirming the starter draw without restarting the server.
type FirstGachaProvider interface {
FirstGachaCompleted() bool
}
func (s *LoginSeed) AttachFirstGacha(provider FirstGachaProvider) error {
if provider == nil {
return errors.New("account: missing first gacha provider")
}
s.firstGacha = provider
return nil
}
// CurrencyProvider supplies the authoritative mutable UserDBInfo wallet.
type CurrencyProvider interface {
@@ -349,7 +361,7 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
}
if s.firstGacha != nil {
value := uint64(0)
if *s.firstGacha {
if s.firstGacha.FirstGachaCompleted() {
value = 1
}
var err error
@@ -0,0 +1,42 @@
package account
import (
"testing"
"bd2server/internal/server/wire"
)
type mutableFirstGachaStatus struct{ completed bool }
func (p *mutableFirstGachaStatus) FirstGachaCompleted() bool { return p.completed }
func TestLoginReadsFirstGachaCompletionAtEachLogin(t *testing.T) {
// An old immutable seed value must be replaced by authoritative account
// state, then immediately change on the next login after confirmation.
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 27, 1)}
status := &mutableFirstGachaStatus{}
if err := seed.AttachFirstGacha(status); err != nil {
t.Fatal(err)
}
for _, completed := range []bool{false, true, false} {
status.completed = completed
body, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(body, 1)
if err != nil || !found {
t.Fatalf("user found=%v err=%v", found, err)
}
value, _, err := wire.Varint(user, 27)
if err != nil || (value == 1) != completed {
t.Fatalf("first gacha=%d want completed=%v err=%v", value, completed, err)
}
}
}
func TestLoginRejectsMissingFirstGachaProvider(t *testing.T) {
if err := (&LoginSeed{}).AttachFirstGacha(nil); err == nil {
t.Fatal("missing provider accepted")
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ package feature
import "bd2server/internal/server/wire"
// initialResponses are locally constructed, typed defaults for the 2.34.13
// initialResponses are locally constructed, typed defaults for the
// new-player account. These do not reuse recorded response bytes. Stateful
// entities (inventory, characters, quests, decks) are deliberately excluded.
func initialResponse(path string) (int, []byte, bool) {
@@ -0,0 +1,41 @@
package feature
import (
"bytes"
"testing"
"bd2server/internal/server/wire"
)
func TestBootstrapProtocolDefaults(t *testing.T) {
for _, test := range []struct {
path string
code int
proto []byte
}{
{"/PackInfo", 4, []byte{0x28, 3}},
{"/RecipeInfo", 46, []byte{0x12, 1, 101}},
{"/CashMailInfo", 140, []byte{0x18, 0}},
{"/AvatarInfo", 467, []byte{0x0a, 0}},
{"/GuildRaidSeasonReward", 310, []byte{0x0a, 0}},
{"/DeckInfo", 8, []byte{0x12, 4, 0, 0, 0, 0}},
{"/RootSortIdInfo", 367, []byte{0x0a, 7, 0x08, 2, 0x10, 0x8c, 1, 0x18, 1}},
} {
t.Run(test.path, func(t *testing.T) {
code, proto, handled, err := Handle(test.path, wire.AppendVarint(nil, 1, 42))
if err != nil || !handled || code != test.code || !bytes.Equal(proto, test.proto) {
t.Fatalf("code=%d proto=%x handled=%v err=%v", code, proto, handled, err)
}
if len(proto) != 0 {
proto[0] ^= 0xff
}
_, again, _, err := Handle(test.path, wire.AppendVarint(nil, 1, 43))
if err != nil || !bytes.Equal(again, test.proto) {
t.Fatalf("response mutation escaped into defaults: proto=%x err=%v", again, err)
}
if _, _, handled, err := Handle(test.path, nil); !handled || err == nil {
t.Fatal("bootstrap endpoint accepted missing sequence")
}
})
}
}
@@ -1,50 +0,0 @@
package feature
import (
"bytes"
"path/filepath"
"testing"
"bd2server/internal/server/fixture"
"bd2server/internal/server/wire"
)
// TestCaptureCompatibility is an optional development-time protocol audit.
// Normal server execution never opens the capture.
func TestCaptureCompatibility(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")
set, err := fixture.Load(root)
if err != nil {
t.Skipf("optional capture unavailable: %v", err)
}
key, err := set.SessionKey()
if err != nil {
t.Fatal(err)
}
for batch := 1; batch <= 2; batch++ {
body, err := set.BatchResponse(batch)
if err != nil {
t.Fatal(err)
}
items, decoded, err := fixture.DecodeBatch(body, []byte(key))
if err != nil {
t.Fatal(err)
}
for _, item := range items {
if _, _, handled, err := Handle(item.Path, wire.AppendVarint(nil, 1, 1)); err != nil || !handled {
t.Logf("batch=%d pending domain endpoint=%s", batch, item.Path)
}
if _, known := EmptyPacketCodes()[item.Path]; known {
if len(decoded[item.Path]) != 0 {
t.Errorf("%s captured protobuf was not empty", item.Path)
}
}
if _, _, known := initialResponse(item.Path); known {
code, generated, handled, err := Handle(item.Path, wire.AppendVarint(nil, 1, 1))
if err != nil || !handled || code != item.Response.PacketCode || !bytes.Equal(generated, decoded[item.Path]) {
t.Errorf("%s native response differs from observed 2.34.13 new account: code=%d vs %d proto=%x vs %x err=%v", item.Path, code, item.Response.PacketCode, generated, decoded[item.Path], err)
}
}
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// ErrInvalidRequest means a known endpoint was sent a malformed request.
var ErrInvalidRequest = errors.New("feature: invalid protobuf request")
// emptyResponses is the stateless subset of the 2.34.13 new-player batch whose
// emptyResponses is the stateless subset of the new-player batch whose
// successful response protobuf has zero bytes. Stateful routes leave this map
// as soon as their owning domain persists them. Packet codes are protocol
// values, not ordinals derived from the request or response order.
@@ -1,37 +0,0 @@
package fixture
import (
"fmt"
"path/filepath"
"testing"
"bd2server/internal/server/wire"
)
func TestDebugBattleStartResponse(t *testing.T) {
set, err := Load(filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254"))
if err != nil {
t.Skip(err)
}
for _, record := range set.RecordedResponses("/BattleStart") {
p, _, err := set.RecordedPayloadAt("/BattleStart", record.RequestSequence)
if err != nil {
t.Log(err)
continue
}
_ = wire.Walk(p.Proto, func(f wire.Field) error {
if f.Number == 1 || f.Number == 2 {
vals := []string{}
_ = wire.Walk(f.Value, func(x wire.Field) error {
if x.Type == 0 {
v, _, _ := wire.Varint(f.Value, x.Number)
vals = append(vals, fmt.Sprintf("%d=%d", x.Number, v))
}
return nil
})
t.Logf("client=%d capture=%d top=%d raw=%x fields=%v", record.ClientSequence, record.RequestSequence, f.Number, f.Value, vals)
}
return nil
})
}
}
-702
View File
@@ -1,702 +0,0 @@
// Package fixture reads captured BD2 protocol messages without duplicating the
// large capture in the source tree. It is intended for local-server bootstrap
// fixtures and protocol tests, not as a persistence format.
package fixture
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"bd2server/internal/server/cryptox"
)
const CaptureVersion = "2.34.13"
// NamedFixture names the small, stable bootstrap response set. Files remain
// in capture/<version>/<run>/bodies and are loaded on demand.
type NamedFixture struct {
Name string
Path string
File string
Key KeyKind
}
type KeyKind uint8
const (
Plain KeyKind = iota
FixedLogin
Session
)
var Bootstrap = []NamedFixture{
{Name: "maintenance", Path: "/MaintenanceInfo", File: "00010_resp_MaintenanceInfo.bin", Key: Plain},
{Name: "server_info", Path: "/ServerInfo", File: "00012_resp_ServerInfo.bin", Key: Plain},
{Name: "login", Path: "/LoginUser", File: "00016_resp_LoginUser.bin", Key: FixedLogin},
{Name: "notice", Path: "/NoticeInfo", File: "00020_resp_NoticeInfo.bin", Key: Plain},
}
var (
ErrNotFound = errors.New("bd2 fixture: response not found")
ErrUnsafePath = errors.New("bd2 fixture: unsafe capture path")
ErrNoPayload = errors.New("bd2 fixture: response has no data payload")
ErrLengthMismatch = errors.New("bd2 fixture: response length does not match decoded payload")
)
// Capture points at a single timestamped capture directory, i.e.
// capture/2.34.13/20260920-003254.
type Capture struct {
Root string
}
// Set is the local-server integration view of one captured new-player run.
// It intentionally keeps responses on disk and only loads bodies on demand;
// this avoids copying the capture into binaries or maintaining a second large
// fixture tree. Load accepts the timestamped capture directory itself.
type Set struct {
*Capture
core map[string]NamedFixture
responses map[string][]RecordedResponse
}
// RecordedResponse has an unambiguous selector: Path plus RequestSequence.
// Sequence is the one-based request event number in bd2_dump.jsonl, rather
// than an ordinal response count. This prevents a state-changing endpoint
// from accidentally receiving a response captured for another request.
type RecordedResponse struct {
Path string
Method string
File string
RequestSequence int
ClientSequence uint64
}
// RecordedResponses lists the unambiguous capture selectors for path. The
// returned slice is detached from the fixture index and safe for callers to
// retain as a scripted bootstrap plan.
func (s *Set) RecordedResponses(path string) []RecordedResponse {
return append([]RecordedResponse(nil), s.responses[path]...)
}
// Load opens an on-disk capture and indexes its stable core endpoints.
func Load(captureDir string) (*Set, error) {
c, err := Open(captureDir)
if err != nil {
return nil, err
}
core := make(map[string]NamedFixture, len(Bootstrap))
for _, spec := range Bootstrap {
core[spec.Path] = spec
}
responses, err := indexResponses(c.Root)
if err != nil {
return nil, err
}
return &Set{Capture: c, core: core, responses: responses}, nil
}
type dumpRecord struct {
Event string `json:"ev"`
Game bool `json:"game"`
FlowID string `json:"flow_id"`
Method string `json:"method"`
Path string `json:"path"`
RawFile string `json:"raw_file"`
PB []string `json:"pb"`
}
type indexedRequest struct {
CaptureSequence int
ClientSequence uint64
}
func indexResponses(root string) (map[string][]RecordedResponse, error) {
data, err := os.ReadFile(filepath.Join(root, "bd2_dump.jsonl"))
if err != nil {
return nil, fmt.Errorf("read capture index: %w", err)
}
requests := make(map[string]indexedRequest)
responses := make(map[string][]RecordedResponse)
sequence := 0
for lineNo, line := range bytes.Split(data, []byte{'\n'}) {
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var record dumpRecord
if err := json.Unmarshal(line, &record); err != nil {
return nil, fmt.Errorf("decode capture index line %d: %w", lineNo+1, err)
}
if !record.Game || record.FlowID == "" {
continue
}
if record.Event == "req" {
sequence++
requests[record.FlowID] = indexedRequest{CaptureSequence: sequence, ClientSequence: protobufSequence(record.PB)}
continue
}
if record.Event != "resp" || record.RawFile == "" {
continue
}
request, found := requests[record.FlowID]
if !found {
continue // capture can begin after an already-open HTTP flow
}
if err := safeName(filepath.Base(record.RawFile)); err != nil {
return nil, fmt.Errorf("capture index line %d: %w", lineNo+1, err)
}
responses[record.Path] = append(responses[record.Path], RecordedResponse{
Path: record.Path, Method: record.Method, File: filepath.Base(record.RawFile), RequestSequence: request.CaptureSequence, ClientSequence: request.ClientSequence,
})
}
return responses, nil
}
func protobufSequence(fields []string) uint64 {
const prefix = "#1 varint "
for _, field := range fields {
text := strings.TrimSpace(field)
if !strings.HasPrefix(text, prefix) {
continue
}
value, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(text, prefix)), 10, 64)
if err == nil {
return value
}
}
return 0
}
// RecordedResponseForClientSequence resolves the captured response using the
// endpoint and the request message's protobuf seq field. A missing/zero seq
// never falls back to a path-only match.
func (s *Set) RecordedResponseForClientSequence(path string, clientSequence uint64) (RecordedResponse, error) {
if clientSequence == 0 {
return RecordedResponse{}, fmt.Errorf("%w: response %s has no client sequence", ErrNotFound, path)
}
for _, response := range s.responses[path] {
if response.ClientSequence == clientSequence {
return response, nil
}
}
return RecordedResponse{}, fmt.Errorf("%w: response %s at client sequence %d", ErrNotFound, path, clientSequence)
}
func Open(root string) (*Capture, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("open capture: %w", err)
}
if !info.IsDir() {
return nil, fmt.Errorf("open capture: %s is not a directory", root)
}
if _, err := os.Stat(filepath.Join(root, "bodies")); err != nil {
return nil, fmt.Errorf("open capture bodies: %w", err)
}
return &Capture{Root: root}, nil
}
// Response is a raw captured HTTP body along with its recorded endpoint.
type Response struct {
Path string
File string
Body []byte
}
// BootstrapResponse loads one of the fixed bootstrap fixtures by symbolic name.
func (c *Capture) BootstrapResponse(name string) (Response, error) {
for _, spec := range Bootstrap {
if spec.Name == name {
return c.readResponse(spec.Path, spec.File)
}
}
return Response{}, fmt.Errorf("%w: bootstrap %q", ErrNotFound, name)
}
func (c *Capture) Maintenance() (Response, error) { return c.BootstrapResponse("maintenance") }
func (c *Capture) ServerInfo() (Response, error) { return c.BootstrapResponse("server_info") }
func (c *Capture) Login() (Response, error) { return c.BootstrapResponse("login") }
func (c *Capture) Notice() (Response, error) { return c.BootstrapResponse("notice") }
// CoreResponse returns the original JSON HTTP response for a bootstrap path.
// It is useful where a handler deliberately wants an exact captured response.
func (s *Set) CoreResponse(path string) ([]byte, error) {
spec, ok := s.core[path]
if !ok {
return nil, fmt.Errorf("%w: core path %q", ErrNotFound, path)
}
response, err := s.readResponse(spec.Path, spec.File)
if err != nil {
return nil, err
}
return append([]byte(nil), response.Body...), nil
}
// RecordedResponseAt retrieves one captured HTTP response only when both its
// endpoint path and original request sequence match. Passing an arbitrary
// ordinal is deliberately not supported; callers should record the sequence
// that their route/session policy maps to a fixture.
func (s *Set) RecordedResponseAt(path string, requestSequence int) ([]byte, RecordedResponse, error) {
for _, response := range s.responses[path] {
if response.RequestSequence != requestSequence {
continue
}
body, err := os.ReadFile(filepath.Join(s.Root, "bodies", response.File))
if err != nil {
return nil, RecordedResponse{}, fmt.Errorf("read recorded response %s: %w", response.File, err)
}
return body, response, nil
}
return nil, RecordedResponse{}, fmt.Errorf("%w: response %s at request sequence %d", ErrNotFound, path, requestSequence)
}
// RecordedPayloadAt decodes a selected captured response with the captured
// session key. LoginUser is always decoded with the fixed pre-login key.
func (s *Set) RecordedPayloadAt(path string, requestSequence int) (Payload, RecordedResponse, error) {
body, response, err := s.RecordedResponseAt(path, requestSequence)
if err != nil {
return Payload{}, RecordedResponse{}, err
}
key := []byte(nil)
if path == "/LoginUser" || path == "/JoinUser" {
key = cryptox.Key()
} else {
capturedKey, err := s.SessionKey()
if err != nil {
return Payload{}, RecordedResponse{}, err
}
key = []byte(capturedKey)
}
env, proto, encrypted, err := decodeEnvelope(body, key)
if err != nil {
return Payload{}, RecordedResponse{}, err
}
return Payload{Path: path, PacketCode: env.PacketCode, Proto: proto, Envelope: env, Encrypted: encrypted}, response, nil
}
// ReencryptRecordedAt is the safe replay helper for a selected response. It
// preserves unencrypted protocol responses, but re-encrypts every encrypted
// response payload with the supplied local-session key and fixes length.
func (s *Set) ReencryptRecordedAt(path string, requestSequence int, localSessionKey []byte) ([]byte, RecordedResponse, error) {
payload, response, err := s.RecordedPayloadAt(path, requestSequence)
if err != nil {
return nil, RecordedResponse{}, err
}
if !payload.Encrypted {
body, _, err := s.RecordedResponseAt(path, requestSequence)
return body, response, err
}
body, err := EncodePayload(payload, localSessionKey, true)
return body, response, err
}
// Payload is the decoded portion of a captured endpoint response. PacketCode
// and Proto are sufficient for a server to modify state and re-encrypt it.
type Payload struct {
Path string
PacketCode int
Proto []byte
Envelope Envelope
Encrypted bool
}
// CorePayload decodes a core fixture. LoginUser uses the fixed pre-login key;
// the remaining initial bootstrap bodies are direct base64 protobuf payloads.
func (s *Set) CorePayload(path string) (Payload, error) {
spec, ok := s.core[path]
if !ok {
return Payload{}, fmt.Errorf("%w: core path %q", ErrNotFound, path)
}
body, err := s.CoreResponse(path)
if err != nil {
return Payload{}, err
}
key := []byte(nil)
if spec.Key == FixedLogin {
key = cryptox.Key()
}
env, proto, encrypted, err := decodeEnvelope(body, key)
if err != nil {
return Payload{}, err
}
return Payload{Path: path, PacketCode: env.PacketCode, Proto: proto, Envelope: env, Encrypted: encrypted}, nil
}
// SessionKey returns the captured LoginUser user_key. It is intended only for
// offline fixture decoding; callers should replace it with a local test key
// once their account/session layer exists. This package never logs it.
func (s *Set) SessionKey() (string, error) {
payload, err := s.CorePayload("/LoginUser")
if err != nil {
return "", err
}
match := sessionKeyPattern.Find(payload.Proto)
if match == nil {
return "", errors.New("bd2 fixture: LoginUser payload does not contain user_key")
}
return string(match), nil
}
var sessionKeyPattern = regexp.MustCompile(`[0-9a-f]{32}`)
// ReencryptCore returns a core response whose payload has been encrypted with
// key. It is primarily for LoginUser replay after a handler substitutes a
// local user key in its protobuf. Callers who mutate protobuf should prefer
// EncodePayload below.
func (s *Set) ReencryptCore(path string, key []byte) ([]byte, error) {
payload, err := s.CorePayload(path)
if err != nil {
return nil, err
}
return EncodePayload(payload, key, true)
}
// EncodePayload serializes a server-owned payload with an accurate length.
func EncodePayload(payload Payload, key []byte, encrypted bool) ([]byte, error) {
return EncodeEnvelope(payload.Envelope, payload.Proto, key, encrypted)
}
// Batch loads one of the two recorded initial BatchRequest response bodies.
func (c *Capture) Batch(number int) (Response, error) {
switch number {
case 1:
return c.readResponse("/BatchRequest", "00041_resp_BatchRequest.bin")
case 2:
return c.readResponse("/BatchRequest", "00077_resp_BatchRequest.bin")
default:
return Response{}, fmt.Errorf("%w: batch %d", ErrNotFound, number)
}
}
// BatchResponse returns the captured raw BatchRequest response. For live
// replay prefer BatchForPaths, because capture response order is arbitrary.
func (s *Set) BatchResponse(index int) ([]byte, error) {
response, err := s.Batch(index)
if err != nil {
return nil, err
}
return append([]byte(nil), response.Body...), nil
}
// BatchForPaths returns the captured responses in exactly the request path
// order supplied by the client. It verifies both the captured request and
// response before selecting items, preventing accidental path drift.
func (s *Set) BatchForPaths(index int, paths []string) ([]BatchItem, error) {
key, err := s.SessionKey()
if err != nil {
return nil, err
}
return s.batchForPathsWithKey(index, paths, []byte(key))
}
// HandleBatch decodes an encrypted client BatchRequest body then produces a
// JSON BatchRequest response arranged to match its request order. The result
// is not AES-wrapped: captured BatchRequest responses are JSON arrays too.
func (s *Set) HandleBatch(index int, requestBody []byte, sessionKey []byte) ([]byte, error) {
requests, _, err := DecodeBatchRequest(requestBody, sessionKey)
if err != nil {
return nil, err
}
paths := make([]string, len(requests))
for i, request := range requests {
paths[i] = request.Path
}
items, err := s.batchForPathsCaptured(index, paths)
if err != nil {
return nil, err
}
if err := s.reencryptBatchItems(items, sessionKey); err != nil {
return nil, err
}
return json.Marshal(items)
}
func (s *Set) batchForPathsWithKey(index int, paths []string, key []byte) ([]BatchItem, error) {
items, err := s.batchForPathsCaptured(index, paths)
if err != nil {
return nil, err
}
if err := s.reencryptBatchItems(items, key); err != nil {
return nil, err
}
return items, nil
}
// batchForPathsCaptured selects data using only the captured session key. The
// captured request itself is validated before its response map is trusted.
func (s *Set) batchForPathsCaptured(index int, paths []string) ([]BatchItem, error) {
capturedKey, err := s.SessionKey()
if err != nil {
return nil, err
}
key := []byte(capturedKey)
if err := s.validateBatchRequest(index, key); err != nil {
return nil, err
}
raw, err := s.BatchResponse(index)
if err != nil {
return nil, err
}
items, _, err := DecodeBatch(raw, key)
if err != nil {
return nil, err
}
byPath := make(map[string]BatchItem, len(items))
for _, item := range items {
byPath[item.Path] = item
}
ordered := make([]BatchItem, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
if _, duplicate := seen[path]; duplicate {
return nil, fmt.Errorf("bd2 fixture: duplicate requested batch path %q", path)
}
seen[path] = struct{}{}
item, ok := byPath[path]
if !ok {
return nil, fmt.Errorf("%w: batch %d path %q", ErrNotFound, index, path)
}
ordered = append(ordered, item)
}
return ordered, nil
}
func (s *Set) reencryptBatchItems(items []BatchItem, localSessionKey []byte) error {
if len(localSessionKey) != cryptox.AESKeySize {
return cryptox.ErrInvalidKey
}
capturedKey, err := s.SessionKey()
if err != nil {
return err
}
for i := range items {
body, err := json.Marshal(items[i].Response)
if err != nil {
return err
}
env, proto, _, err := decodeEnvelope(body, []byte(capturedKey))
if err != nil {
return fmt.Errorf("decode batch response %s: %w", items[i].Path, err)
}
encoded, err := EncodeEnvelope(env, proto, localSessionKey, true)
if err != nil {
return err
}
if err := json.Unmarshal(encoded, &items[i].Response); err != nil {
return err
}
}
return nil
}
func (s *Set) validateBatchRequest(index int, key []byte) error {
var file string
switch index {
case 1:
file = "00040_req_BatchRequest.bin"
case 2:
file = "00076_req_BatchRequest.bin"
default:
return fmt.Errorf("%w: batch %d", ErrNotFound, index)
}
body, err := os.ReadFile(filepath.Join(s.Root, "bodies", file))
if err != nil {
return err
}
_, _, err = DecodeBatchRequest(body, key)
return err
}
func (c *Capture) readResponse(path, file string) (Response, error) {
if err := safeName(file); err != nil {
return Response{}, err
}
body, err := os.ReadFile(filepath.Join(c.Root, "bodies", file))
if err != nil {
return Response{}, fmt.Errorf("read fixture %s: %w", file, err)
}
return Response{Path: path, File: file, Body: body}, nil
}
func safeName(name string) error {
if filepath.Base(name) != name || strings.Contains(name, "..") || name == "" {
return fmt.Errorf("%w: %q", ErrUnsafePath, name)
}
return nil
}
// Envelope is the common game response envelope. Unknown fields are retained
// in Extra, which keeps a replay fixture forward-compatible.
type Envelope struct {
ErrorType int `json:"errorType"`
PacketCode int `json:"packetCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Length int `json:"length"`
Data string `json:"data"`
ServerNowTime int64 `json:"serverNowTime,omitempty"`
Notify string `json:"notify,omitempty"`
Extra json.RawMessage `json:"-"`
}
// DecodeEnvelope accepts either base64(protobuf) or
// base64(AES-CBC(base64(protobuf))). The latter uses key. Captures legitimately
// contain both forms, including after login, so the fallback is intentional.
func DecodeEnvelope(body, key []byte) (Envelope, []byte, error) {
env, proto, _, err := decodeEnvelope(body, key)
return env, proto, err
}
func decodeEnvelope(body, key []byte) (Envelope, []byte, bool, error) {
var env Envelope
if err := json.Unmarshal(body, &env); err != nil {
return Envelope{}, nil, false, fmt.Errorf("decode envelope JSON: %w", err)
}
if env.Data == "" {
if env.Length != 0 {
return Envelope{}, nil, false, fmt.Errorf("%w: advertised %d bytes", ErrNoPayload, env.Length)
}
return env, nil, false, nil
}
plain, encrypted, err := decodeData(env.Data, key)
if err != nil {
return Envelope{}, nil, false, err
}
// In an encrypted envelope length is the character count of the inner
// base64(protobuf), not the protobuf byte count. Direct-base64 bootstrap
// envelopes commonly leave it at zero (MaintenanceInfo is an example).
if encrypted && env.Length != base64.StdEncoding.EncodedLen(len(plain)) {
return Envelope{}, nil, false, fmt.Errorf("%w: advertised=%d wire=%d decoded=%d encrypted=%t", ErrLengthMismatch, env.Length, base64.StdEncoding.EncodedLen(len(plain)), len(plain), encrypted)
}
return env, plain, encrypted, nil
}
func decodeData(data string, key []byte) ([]byte, bool, error) {
if len(key) == cryptox.AESKeySize {
if inner, err := cryptox.DecryptBase64(data, key); err == nil {
if proto, err := base64.StdEncoding.DecodeString(string(inner)); err == nil {
return proto, true, nil
}
}
}
proto, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, false, fmt.Errorf("decode fixture data: %w", err)
}
return proto, false, nil
}
// EncodeEnvelope creates a client-shaped response. encrypted chooses the
// AES-wrapped variant; plain responses use a direct base64 protobuf payload.
func EncodeEnvelope(env Envelope, proto, key []byte, encrypted bool) ([]byte, error) {
copyEnv := env
if encrypted {
if len(key) != cryptox.AESKeySize {
return nil, cryptox.ErrInvalidKey
}
payload, err := cryptox.EncryptBase64Payload(proto, key)
if err != nil {
return nil, err
}
copyEnv.Data = payload
copyEnv.Length = base64.StdEncoding.EncodedLen(len(proto))
} else {
copyEnv.Data = base64.StdEncoding.EncodeToString(proto)
}
return json.Marshal(copyEnv)
}
// BatchItem is one JSON member in a BatchRequest response.
type BatchItem struct {
Path string `json:"path"`
Response Envelope `json:"responseData"`
}
// DecodeBatch parses and validates a BatchRequest response. It rejects
// duplicate endpoint paths because a local dispatcher could not replay them
// deterministically by path.
func DecodeBatch(body, key []byte) ([]BatchItem, map[string][]byte, error) {
var items []BatchItem
if err := json.Unmarshal(body, &items); err != nil {
return nil, nil, fmt.Errorf("decode batch JSON: %w", err)
}
if len(items) == 0 {
return nil, nil, errors.New("bd2 fixture: empty batch response")
}
decoded := make(map[string][]byte, len(items))
for i, item := range items {
if item.Path == "" || !strings.HasPrefix(item.Path, "/") {
return nil, nil, fmt.Errorf("bd2 fixture: batch item %d has invalid path %q", i, item.Path)
}
if _, exists := decoded[item.Path]; exists {
return nil, nil, fmt.Errorf("bd2 fixture: duplicate batch path %q", item.Path)
}
body, err := json.Marshal(item.Response)
if err != nil {
return nil, nil, err
}
_, proto, err := DecodeEnvelope(body, key)
if err != nil {
return nil, nil, fmt.Errorf("decode batch item %s: %w", item.Path, err)
}
decoded[item.Path] = proto
}
return items, decoded, nil
}
// Request describes a decoded BatchRequest member. RequestData is a direct
// base64 protobuf payload; the JSON array itself is AES-wrapped on the wire.
type Request struct {
Path string `json:"path"`
RequestData string `json:"requestData"`
}
func DecodeBatchRequest(body, key []byte) ([]Request, map[string][]byte, error) {
plain, err := cryptox.DecryptBase64(strings.TrimSpace(string(body)), key)
if err != nil {
return nil, nil, fmt.Errorf("decrypt batch request: %w", err)
}
var requests []Request
if err := json.Unmarshal(plain, &requests); err != nil {
return nil, nil, fmt.Errorf("decode batch request JSON: %w", err)
}
decoded := make(map[string][]byte, len(requests))
for i, req := range requests {
if req.Path == "" || !strings.HasPrefix(req.Path, "/") || req.RequestData == "" {
return nil, nil, fmt.Errorf("bd2 fixture: batch request item %d is invalid", i)
}
if _, exists := decoded[req.Path]; exists {
return nil, nil, fmt.Errorf("bd2 fixture: duplicate batch request path %q", req.Path)
}
proto, err := base64.StdEncoding.DecodeString(req.RequestData)
if err != nil {
return nil, nil, fmt.Errorf("decode batch request %s: %w", req.Path, err)
}
decoded[req.Path] = proto
}
return requests, decoded, nil
}
// Paths returns the fixture response paths in lexical order for diagnostics.
func Paths(items []BatchItem) []string {
paths := make([]string, 0, len(items))
for _, item := range items {
paths = append(paths, item.Path)
}
sort.Strings(paths)
return paths
}
// EqualProto deliberately treats nil and empty payloads equally; protocol
// endpoints with response length zero use both representations in practice.
func EqualProto(a, b []byte) bool { return bytes.Equal(a, b) }
var _ fs.FS // document that fixtures deliberately use an on-disk capture.
-360
View File
@@ -1,360 +0,0 @@
package fixture
import (
"bytes"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"testing"
"bd2server/internal/server/cryptox"
"bd2server/internal/server/wire"
)
func captureRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254"))
if err != nil {
t.Fatal(err)
}
return root
}
func TestCapturedQuestUpdateResponseShape(t *testing.T) {
set, err := Load(captureRoot(t))
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
captureSequence int
questID uint64
}{{60, 1}, {71, 4}, {120, 12}} {
payload, _, err := set.RecordedPayloadAt("/QuestUpdate", test.captureSequence)
if err != nil {
t.Fatal(err)
}
updated, found, err := wire.Varint(payload.Proto, 1)
if err != nil || !found || updated != test.questID {
t.Fatalf("sequence %d: updateQuestId=%d found=%v err=%v", test.captureSequence, updated, found, err)
}
rewardBundle, reward, err := wire.Bytes(payload.Proto, 2)
if err != nil || !reward || len(rewardBundle) != 0 {
t.Fatalf("sequence %d: reward bundle bytes=%d found=%v err=%v", test.captureSequence, len(rewardBundle), reward, err)
}
if payload.PacketCode != 19 {
t.Fatalf("sequence %d: packet code %d", test.captureSequence, payload.PacketCode)
}
}
}
func TestLogCapturedQuestClearShape(t *testing.T) {
set, err := Load(captureRoot(t))
if err != nil {
t.Fatal(err)
}
for _, sequence := range []int{61, 63, 121} {
payload, _, err := set.RecordedPayloadAt("/QuestClear", sequence)
if err != nil {
t.Fatal(err)
}
var fields []string
if err := wire.Walk(payload.Proto, func(field wire.Field) error {
fields = append(fields, fmt.Sprintf("#%d/type%d/%dB", field.Number, field.Type, len(field.Value)))
return nil
}); err != nil {
t.Fatal(err)
}
t.Logf("sequence=%d packet=%d fields=%v", sequence, payload.PacketCode, fields)
}
}
func TestLogNewPlayerQuestRewards(t *testing.T) {
set, err := Load(captureRoot(t))
if err != nil {
t.Fatal(err)
}
for _, record := range set.RecordedResponses("/QuestClear") {
payload, _, err := set.RecordedPayloadAt("/QuestClear", record.RequestSequence)
if err != nil {
t.Fatal(err)
}
quest, _, _ := wire.Varint(payload.Proto, 3)
nextRaw, _, _ := wire.Bytes(payload.Proto, 2)
next, _, _ := wire.Varint(nextRaw, 1)
reward, _, _ := wire.Bytes(payload.Proto, 1)
var items, chars, costumes []string
var responseChars, decks []string
_ = wire.Walk(reward, func(field wire.Field) error {
if field.Type != 2 {
return nil
}
switch field.Number {
case 1:
idx, _, _ := wire.Varint(field.Value, 1)
id, _, _ := wire.Varint(field.Value, 2)
typ, _, _ := wire.Varint(field.Value, 3)
count, _, _ := wire.Varint(field.Value, 4)
items = append(items, fmt.Sprintf("idx=%d id=%d type=%d count=%d", idx, id, typ, count))
case 2:
idx, _, _ := wire.Varint(field.Value, 1)
id, _, _ := wire.Varint(field.Value, 2)
hp, _, _ := wire.Varint(field.Value, 3)
level, _, _ := wire.Varint(field.Value, 4)
costume, _, _ := wire.Varint(field.Value, 7)
chars = append(chars, fmt.Sprintf("idx=%d id=%d hp=%d level=%d costumeIdx=%d raw=%x", idx, id, hp, level, costume, field.Value))
case 3:
idx, _, _ := wire.Varint(field.Value, 1)
id, _, _ := wire.Varint(field.Value, 2)
useChar, _, _ := wire.Varint(field.Value, 4)
costumes = append(costumes, fmt.Sprintf("idx=%d id=%d useChar=%d raw=%x", idx, id, useChar, field.Value))
}
return nil
})
_ = wire.Walk(payload.Proto, func(field wire.Field) error {
if field.Type != 2 {
return nil
}
if field.Number == 4 {
idx, _, _ := wire.Varint(field.Value, 1)
costume, _, _ := wire.Varint(field.Value, 2)
slot, _, _ := wire.Varint(field.Value, 3)
decks = append(decks, fmt.Sprintf("charIdx=%d costume=%d slot=%d", idx, costume, slot))
} else if field.Number == 5 {
idx, _, _ := wire.Varint(field.Value, 1)
id, _, _ := wire.Varint(field.Value, 2)
hp, _, _ := wire.Varint(field.Value, 3)
level, _, _ := wire.Varint(field.Value, 4)
useCostume, _, _ := wire.Varint(field.Value, 7)
connect, _, _ := wire.Varint(field.Value, 13)
responseChars = append(responseChars, fmt.Sprintf("idx=%d id=%d hp=%d level=%d costumeIdx=%d connect=%d raw=%x", idx, id, hp, level, useCostume, connect, field.Value))
}
return nil
})
t.Logf("quest=%d next=%d requestSeq=%d items=%v rewardChars=%v costumes=%v decks=%v responseChars=%v", quest, next, record.RequestSequence, items, chars, costumes, decks, responseChars)
}
}
func TestCapturedBootstrapResponsesDecode(t *testing.T) {
c, err := Open(captureRoot(t))
if err != nil {
t.Fatal(err)
}
for _, spec := range Bootstrap {
response, err := c.BootstrapResponse(spec.Name)
if err != nil {
t.Fatalf("%s: %v", spec.Name, err)
}
key := []byte(nil)
if spec.Key == FixedLogin {
key = cryptox.Key()
}
env, proto, err := DecodeEnvelope(response.Body, key)
if err != nil {
t.Fatalf("%s: %v", spec.Name, err)
}
if env.ErrorType != 0 || len(proto) == 0 {
t.Fatalf("%s: errorType=%d proto=%d length=%d", spec.Name, env.ErrorType, len(proto), env.Length)
}
}
}
func TestEnvelopeReencryptsAndKeepsLength(t *testing.T) {
c, err := Open(captureRoot(t))
if err != nil {
t.Fatal(err)
}
login, err := c.Login()
if err != nil {
t.Fatal(err)
}
env, proto, err := DecodeEnvelope(login.Body, cryptox.Key())
if err != nil {
t.Fatal(err)
}
reencoded, err := EncodeEnvelope(env, proto, cryptox.Key(), true)
if err != nil {
t.Fatal(err)
}
gotEnv, got, err := DecodeEnvelope(reencoded, cryptox.Key())
if err != nil {
t.Fatal(err)
}
if gotEnv.Length != base64.StdEncoding.EncodedLen(len(proto)) || !bytes.Equal(got, proto) {
t.Fatalf("reencrypted payload changed: got=%d want=%d", len(got), len(proto))
}
}
func TestBatchRequestsAndResponsesAreComplete(t *testing.T) {
c, err := Open(captureRoot(t))
if err != nil {
t.Fatal(err)
}
key, err := cryptox.SessionKey("7eeb5c2480c298f6fc4c00d22b1b0b15")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
number int
request string
want int
}{
{1, "00040_req_BatchRequest.bin", 57},
{2, "00076_req_BatchRequest.bin", 5},
} {
reqBody, err := os.ReadFile(filepath.Join(c.Root, "bodies", tc.request))
if err != nil {
t.Fatal(err)
}
reqs, reqPayloads, err := DecodeBatchRequest(reqBody, key)
if err != nil {
t.Fatalf("batch %d request: %v", tc.number, err)
}
response, err := c.Batch(tc.number)
if err != nil {
t.Fatal(err)
}
items, responsePayloads, err := DecodeBatch(response.Body, key)
if err != nil {
t.Fatalf("batch %d response: %v", tc.number, err)
}
if len(reqs) != tc.want || len(items) != tc.want || len(reqPayloads) != tc.want || len(responsePayloads) != tc.want {
t.Fatalf("batch %d: req=%d resp=%d (want %d)", tc.number, len(reqs), len(items), tc.want)
}
for path := range reqPayloads {
if _, ok := responsePayloads[path]; !ok {
t.Fatalf("batch %d lacks response path %s", tc.number, path)
}
}
}
}
func TestSetProvidesIntegrationAPIAndOrdersBatchByRequest(t *testing.T) {
set, err := Load(captureRoot(t))
if err != nil {
t.Fatal(err)
}
if _, err := set.CoreResponse("/MaintenanceInfo"); err != nil {
t.Fatal(err)
}
key, err := set.SessionKey()
if err != nil {
t.Fatal(err)
}
if len(key) != 32 {
t.Fatalf("session key has length %d", len(key))
}
req, err := os.ReadFile(filepath.Join(set.Root, "bodies", "00040_req_BatchRequest.bin"))
if err != nil {
t.Fatal(err)
}
requests, _, err := DecodeBatchRequest(req, []byte(key))
if err != nil {
t.Fatal(err)
}
paths := make([]string, len(requests))
for i := range requests {
paths[i] = requests[i].Path
}
ordered, err := set.BatchForPaths(1, paths)
if err != nil {
t.Fatal(err)
}
for i, item := range ordered {
if item.Path != paths[i] {
t.Fatalf("ordered[%d]=%s want %s", i, item.Path, paths[i])
}
}
}
func TestRecordedResponsesUseRequestSequenceAndCanReencrypt(t *testing.T) {
set, err := Load(captureRoot(t))
if err != nil {
t.Fatal(err)
}
// Sequence 10 is EventScheduleInfo in this capture. It is a session-key
// encrypted response; use a different local key to prove re-encryption.
payload, record, err := set.RecordedPayloadAt("/EventScheduleInfo", 10)
if err != nil {
t.Fatal(err)
}
if !payload.Encrypted || record.File != "00018_resp_EventScheduleInfo.bin" {
t.Fatalf("unexpected selected record: %#v encrypted=%t", record, payload.Encrypted)
}
newKey, err := cryptox.SessionKey("0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatal(err)
}
reencoded, _, err := set.ReencryptRecordedAt("/EventScheduleInfo", 10, newKey)
if err != nil {
t.Fatal(err)
}
_, proto, err := DecodeEnvelope(reencoded, newKey)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(proto, payload.Proto) {
t.Fatal("re-encrypted selected payload changed")
}
if _, _, err := set.RecordedResponseAt("/EventScheduleInfo", 999); err == nil {
t.Fatal("unsafe/nonexistent sequence unexpectedly resolved")
}
}
func TestRecordedResponseResolvesClientSequence(t *testing.T) {
set, err := Load(captureRoot(t))
if err != nil {
t.Fatal(err)
}
record, err := set.RecordedResponseForClientSequence("/TutorialClear", 103)
if err != nil {
t.Fatal(err)
}
if record.RequestSequence != 57 || record.File != "00109_resp_TutorialClear.bin" {
t.Fatalf("unexpected TutorialClear record: %+v", record)
}
if _, err := set.RecordedResponseForClientSequence("/TutorialClear", 999999); err == nil {
t.Fatal("unknown client sequence unexpectedly resolved")
}
}
func TestHandleBatchReencryptsAllResponseDataForLocalSession(t *testing.T) {
set, err := Load(captureRoot(t))
if err != nil {
t.Fatal(err)
}
capturedKey, err := set.SessionKey()
if err != nil {
t.Fatal(err)
}
original, err := os.ReadFile(filepath.Join(set.Root, "bodies", "00040_req_BatchRequest.bin"))
if err != nil {
t.Fatal(err)
}
plain, err := cryptox.DecryptBase64(string(original), []byte(capturedKey))
if err != nil {
t.Fatal(err)
}
localKey, err := cryptox.SessionKey("0123456789abcdef0123456789abcdef")
if err != nil {
t.Fatal(err)
}
localRequest, err := cryptox.EncryptBase64(plain, localKey)
if err != nil {
t.Fatal(err)
}
response, err := set.HandleBatch(1, []byte(localRequest), localKey)
if err != nil {
t.Fatal(err)
}
items, payloads, err := DecodeBatch(response, localKey)
if err != nil {
t.Fatal(err)
}
if len(items) != 57 || len(payloads) != 57 || items[0].Path != "/MailInfo" {
t.Fatalf("local batch response did not preserve request order/count: first=%q count=%d", items[0].Path, len(items))
}
if items[0].Response.ServerNowTime == 0 || items[0].Response.Notify == "" {
t.Fatal("batch response lost envelope metadata while re-encrypting")
}
}
+170
View File
@@ -0,0 +1,170 @@
package gacha
import (
"errors"
"fmt"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
const firstGachaResponseDigest = "first-gacha-confirm-v1"
type firstGachaPreview struct {
sequence uint64
rewards []gamedata.FirstGachaReward
equipment map[int]player.Equipment
response []byte
}
func (s *Service) previewFirstGacha(request []byte) (int, []byte, bool, error) {
if s.first == nil {
return 175, nil, true, errors.New("gacha: first gacha is not configured")
}
if s.FirstGachaCompleted() {
return 175, nil, true, errors.New("gacha: first gacha already completed")
}
for _, field := range []int{3, 4} {
value, _, err := wire.Varint(request, field)
if err != nil || value != 0 {
return 175, nil, true, errors.New("gacha: first gacha preview cannot use a cash product")
}
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 175, nil, true, errors.New("gacha: invalid first gacha preview sequence")
}
session := s.loginIdentity()
s.firstMu.Lock()
defer s.firstMu.Unlock()
if previous, found := s.firstPreviews[session]; found && previous.sequence == seq {
return 175, append([]byte(nil), previous.response...), true, nil
}
rewards, err := s.first.Roll()
if err != nil {
return 175, nil, true, err
}
preview := firstGachaPreview{sequence: seq, rewards: append([]gamedata.FirstGachaReward(nil), rewards...), equipment: make(map[int]player.Equipment)}
var bundle []byte
for sortID, reward := range rewards {
switch reward.Type {
case 11:
costume := wire.AppendVarint(nil, 2, reward.ID)
if sortID != 0 {
costume = wire.AppendVarint(costume, 6, uint64(sortID))
}
bundle = wire.AppendBytes(bundle, 3, costume)
case 10:
entry, err := firstGachaEquipment(s.first.EquipmentCatalog(), reward.ID, uint64(sortID))
if err != nil {
return 175, nil, true, err
}
preview.equipment[sortID] = entry
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry))
default:
return 175, nil, true, fmt.Errorf("gacha: unsupported first reward type %d", reward.Type)
}
}
preview.response = wire.AppendBytes(nil, 1, bundle)
s.firstPreviews[session] = preview
if s.onPreview != nil {
if err := s.onPreview(); err != nil {
delete(s.firstPreviews, session)
return 175, nil, true, fmt.Errorf("gacha: update first preview mission: %w", err)
}
}
return 175, append([]byte(nil), preview.response...), true, nil
}
func firstGachaEquipment(catalog *gamedata.EquipmentGachaCatalog, id, sortID uint64) (player.Equipment, error) {
if catalog == nil {
return player.Equipment{}, errors.New("gacha: first gacha equipment catalog missing")
}
main, sub, private, err := catalog.RollOptions(id)
if err != nil {
return player.Equipment{}, err
}
entry := player.Equipment{ID: id, SortID: sortID, Rank: []uint64{0, 0, 0}}
for _, option := range main {
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
for _, option := range sub {
entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
if private != nil {
entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID}
}
return entry, nil
}
func (s *Service) confirmFirstGacha(_ []byte, seq, buyType uint64, tickets []player.Item, explicitIdentity string) (int, []byte, bool, error) {
if s.first == nil || s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: first gacha runtime is not attached")
}
if buyType != 1 || len(tickets) != 0 {
return 146, nil, true, errors.New("gacha: first gacha requires normal free confirmation")
}
identity := explicitIdentity
if identity == "" {
identity = s.requestIdentity(s.first.GachaID, seq)
}
responseIdentity := identity + ":response"
if response, found, err := s.collection.GachaBatchResponse(responseIdentity, firstGachaResponseDigest); found || err != nil {
return 146, response, true, err
}
if s.FirstGachaCompleted() {
return 146, nil, true, errors.New("gacha: first gacha already completed")
}
session := s.loginIdentity()
s.firstMu.Lock()
preview, found := s.firstPreviews[session]
s.firstMu.Unlock()
if !found || len(preview.rewards) != s.first.Count {
return 146, nil, true, errors.New("gacha: first gacha confirmation requires a preview")
}
var costumeIDs, costumeSortIDs []uint64
var equipment []player.Equipment
for sortID, reward := range preview.rewards {
switch reward.Type {
case 11:
costumeIDs = append(costumeIDs, reward.ID)
costumeSortIDs = append(costumeSortIDs, uint64(sortID))
case 10:
candidate, ok := preview.equipment[sortID]
if !ok || candidate.ID != reward.ID {
return 146, nil, true, fmt.Errorf("gacha: first preview missing equipment slot %d", sortID)
}
saved, err := s.equipmentInventory.GrantGeneratedOnce(fmt.Sprintf("%s:first-equip:%d", identity, sortID), candidate)
if err != nil {
return 146, nil, true, err
}
equipment = append(equipment, saved)
default:
return 146, nil, true, fmt.Errorf("gacha: unsupported first reward type %d", reward.Type)
}
}
grant, err := s.collection.GrantRegularPurchase(identity, costumeIDs, s.first.CostumeCatalog(), player.GachaPurchase{
Group: s.first.Group, BuyType: 1, RewardCount: uint64(s.first.Count), RewardSortIDs: costumeSortIDs,
})
if err != nil {
return 146, nil, true, err
}
if err := s.creditOverflow(identity, grant); err != nil {
return 146, nil, true, err
}
bundle := s.rewardBundle(grant)
for _, entry := range equipment {
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry))
}
response := wire.AppendBytes(nil, 1, bundle)
if err := s.collection.RecordGachaBatch(responseIdentity, firstGachaResponseDigest, response); err != nil {
return 146, nil, true, err
}
if s.onPreview != nil {
if err := s.onPreview(); err != nil {
return 146, nil, true, fmt.Errorf("gacha: update first gacha mission: %w", err)
}
}
return 146, response, true, nil
}
@@ -0,0 +1,150 @@
package gacha
import (
"bytes"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestFirstGachaPreviewConfirmAndReplay(t *testing.T) {
characters := make(map[uint64]gamedata.CharacterDesign)
equipmentDesign := make(map[uint64]gamedata.EquipmentDesign)
program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 1, DropType: 1}
for i := uint64(0); i < 5; i++ {
costumeID := 61001 + i
equipmentID := 71001 + i
characters[costumeID] = fixtureCharacter(6100+i, 100+i)
equipmentDesign[equipmentID] = gamedata.EquipmentDesign{ID: equipmentID, Grade: 4}
program.Entries = append(program.Entries,
gamedata.FirstGachaRewardEntry{ItemType: 11, ItemID: costumeID, Count: 1, Weight: 1},
gamedata.FirstGachaRewardEntry{ItemType: 10, ItemID: equipmentID, Count: 1, Weight: 1})
}
first, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 10, program, characters, equipmentDesign)
if err != nil {
t.Fatal(err)
}
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{61001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
1: {ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
if err := service.AttachFirstGacha(first); err != nil {
t.Fatal(err)
}
service.AttachEquipmentGacha(first.EquipmentCatalog(), equipment)
service.BeginSession("first-session")
previewRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 11), 2, 20)
code, preview, handled, err := service.Handle("/GachaBuyPreview", previewRequest)
if err != nil || !handled || code != 175 {
t.Fatalf("preview code=%d handled=%v err=%v", code, handled, err)
}
bundle, found, err := wire.Bytes(preview, 1)
if err != nil || !found || countFields(bundle, 3) != 5 || countFields(bundle, 4) != 5 {
t.Fatalf("preview costumes=%d equipment=%d found=%v err=%v", countFields(bundle, 3), countFields(bundle, 4), found, err)
}
if len(collection.Costumes()) != 0 || len(equipment.All()) != 0 || service.FirstGachaCompleted() {
t.Fatal("preview changed authoritative ownership")
}
_, replayPreview, _, err := service.Handle("/GachaBuyPreview", previewRequest)
if err != nil || !bytes.Equal(preview, replayPreview) {
t.Fatalf("same preview request was not idempotent: err=%v", err)
}
buyRequest := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 20), 3, 1)
code, response, handled, err := service.Handle("/GachaBuy", buyRequest)
if err != nil || !handled || code != 146 {
t.Fatalf("confirm code=%d handled=%v err=%v", code, handled, err)
}
bundle, found, err = wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 3) != 5 || countFields(bundle, 4) != 5 {
t.Fatalf("confirm costumes=%d equipment=%d found=%v err=%v", countFields(bundle, 3), countFields(bundle, 4), found, err)
}
if !service.FirstGachaCompleted() || len(collection.Costumes()) != 5 || len(equipment.All()) != 5 {
t.Fatalf("confirmed state completed=%v costumes=%d equipment=%d", service.FirstGachaCompleted(), len(collection.Costumes()), len(equipment.All()))
}
if user := collection.GachaUser(2); user.TotalBuyCount != 10 || user.Point != 0 {
t.Fatalf("first gacha accounting=%+v", user)
}
for i, costume := range collection.Costumes() {
if costume.SortID != uint64(i*2) {
t.Fatalf("costume %d sort=%d want=%d", costume.ID, costume.SortID, i*2)
}
}
_, replay, _, err := service.Handle("/GachaBuy", buyRequest)
if err != nil || !bytes.Equal(response, replay) || len(collection.Costumes()) != 5 || len(equipment.All()) != 5 {
t.Fatalf("confirm replay mutated state: costumes=%d equipment=%d err=%v", len(collection.Costumes()), len(equipment.All()), err)
}
service.BeginSession("second-session")
if _, _, _, err := service.Handle("/GachaBuyPreview", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 20)); err == nil {
t.Fatal("completed first gacha reopened in a new session")
}
}
func TestFirstGachaRejectsConfirmationWithoutPreview(t *testing.T) {
// The full happy-path test proves attachment. This assertion locks down the
// security boundary: a forged buy cannot skip the server-owned reroll.
service := newFirstGachaTestService(t)
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 20), 3, 1)
if _, _, handled, err := service.Handle("/GachaBuy", request); err == nil || !handled {
t.Fatalf("direct confirmation handled=%v err=%v", handled, err)
}
}
func newFirstGachaTestService(t *testing.T) *Service {
t.Helper()
characters := map[uint64]gamedata.CharacterDesign{61001: fixtureCharacter(6100, 100)}
program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 10, Entries: []gamedata.FirstGachaRewardEntry{{ItemType: 11, ItemID: 61001, Count: 1, Weight: 1}}}
first, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 10, program, characters, nil)
if err != nil {
t.Fatal(err)
}
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{61001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}}}, characters)
if err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, _ := player.OpenCollectionStore(storage, nil)
wallet, _ := player.OpenWallet(storage, player.Currency{})
equipment, _ := player.OpenEquipmentInventory(storage)
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
if err := service.AttachFirstGacha(first); err != nil {
t.Fatal(err)
}
service.AttachEquipmentGacha(first.EquipmentCatalog(), equipment)
service.BeginSession("first-no-preview")
return service
}
+205 -15
View File
@@ -1,9 +1,10 @@
// Package gacha implements the local, non-payment version of the official
// infinite reroll product. Static pools come from verified GameData;
// only the latest preview and confirmed ownership are persisted locally.
// Package gacha implements server-authoritative draws from verified GameData,
// including the starter reroll and scheduled daily-free batches.
package gacha
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sync"
@@ -34,6 +35,7 @@ const (
type Service struct {
design *gamedata.InfiniteGachaDesign
first *gamedata.FirstGachaDesign
regular *gamedata.RegularGachaCatalog
collection *player.CollectionStore
wallet *player.Wallet
@@ -45,6 +47,8 @@ type Service struct {
previewEventIndex uint64
sessionMu sync.RWMutex
sessionID string
firstMu sync.Mutex
firstPreviews map[string]firstGachaPreview
now func() time.Time
}
@@ -71,6 +75,17 @@ func (s *Service) AttachEquipmentGacha(catalog *gamedata.EquipmentGachaCatalog,
s.equipmentCatalog, s.equipmentInventory = catalog, inventory
}
func (s *Service) AttachFirstGacha(design *gamedata.FirstGachaDesign) error {
if design == nil || design.GachaID == 0 || design.Count == 0 || design.Group.GachaSubType != 3 {
return errors.New("gacha: invalid first gacha design")
}
s.first = design
s.firstMu.Lock()
s.firstPreviews = make(map[string]firstGachaPreview)
s.firstMu.Unlock()
return nil
}
// FirstGachaCompleted reflects UserDBInfo.IsFirstGacha. The official symbol
// map names the client-side property IsDoneFirstGachaPick, and the client sets
// it after a GachaSubType=3 purchase succeeds.
@@ -86,6 +101,11 @@ func (s *Service) BeginSession(id string) {
s.sessionMu.Lock()
s.sessionID = id
s.sessionMu.Unlock()
s.firstMu.Lock()
if s.firstPreviews != nil {
s.firstPreviews = make(map[string]firstGachaPreview)
}
s.firstMu.Unlock()
}
func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularGachaCatalog, collection *player.CollectionStore, wallet *player.Wallet) (*Service, error) {
@@ -96,7 +116,7 @@ func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularG
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/GachaInfo" && path != "/GachaBuy" && path != "/GachaPointExchange" && path != "/GachaPointManualExchange" && path != "/GachaBuyPreview" && path != "/GachaBuyPreviewLock" && path != "/GachaSelectionSave" && path != "/CashShopPurchaseCountInfo" && path != "/CashShopBuy" {
if path != "/GachaInfo" && path != "/GachaBuy" && path != "/GachaMultiBuy" && path != "/GachaPointExchange" && path != "/GachaPointManualExchange" && path != "/GachaBuyPreview" && path != "/GachaBuyPreviewLock" && path != "/GachaSelectionSave" && path != "/CashShopPurchaseCountInfo" && path != "/CashShopBuy" {
return 0, nil, false, nil
}
seq, found, err := wire.Varint(request, 1)
@@ -112,6 +132,8 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return s.lockPreview(request)
case "/GachaBuy":
return s.buy(request, seq)
case "/GachaMultiBuy":
return s.multiBuy(request, seq)
case "/GachaPointExchange":
return s.pointExchange(request, seq)
case "/GachaPointManualExchange":
@@ -238,13 +260,149 @@ func (s *Service) lockPreview(request []byte) (int, []byte, bool, error) {
if err := s.collection.LockPreview(eventIndex); err != nil {
return 175, nil, true, err
}
// 2.34.13 has no separate PacketCodeTypeProto member for the lock RPC.
// no separate PacketCodeTypeProto member for the lock RPC.
// It is a sub-operation of GachaBuyPreview and the client callback only
// parses the empty response, so it uses the parent packet code.
return 175, nil, true, nil
}
// multiBuy is the client's AllGacha daily-free macro, not a paid multi-pull.
// Every item runs within the transport's one account transaction. 146 follows
// GachaBuy; its exact MultiBuy envelope awaits a 2.35.10 official capture.
func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error) {
const maxBatchSize = 256
var ids []uint64
appendID := func(id uint64) error {
if id == 0 || id > 1<<31-1 || len(ids) >= maxBatchSize {
return errors.New("gacha: invalid daily free batch ID or size")
}
ids = append(ids, id)
return nil
}
if err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
switch field.Type {
case 0:
id, n := binary.Uvarint(field.Value)
if n <= 0 {
return wire.ErrMalformed
}
return appendID(id)
case 2:
for packed := field.Value; len(packed) != 0; {
id, n := binary.Uvarint(packed)
if n <= 0 {
return wire.ErrMalformed
}
if err := appendID(id); err != nil {
return err
}
packed = packed[n:]
}
return nil
default:
return errors.New("gacha: invalid daily free batch ID wire type")
}
}); err != nil {
return 146, nil, true, err
}
if len(ids) == 0 {
return 146, nil, true, errors.New("gacha: empty daily free batch")
}
identity := fmt.Sprintf("multi-gacha:session:%s:seq:%d", s.loginIdentity(), seq)
var canonical []byte
for _, id := range ids {
canonical = binary.AppendUvarint(canonical, id)
}
digest := fmt.Sprintf("%x", sha256.Sum256(canonical))
if response, found, err := s.collection.GachaBatchResponse(identity, digest); found || err != nil {
return 146, response, true, err
}
// Validate the complete batch before a single allowance or reward changes.
// Counts are accumulated per group so repeated IDs cannot evade the limit.
day := dailyResetKey(s.now())
requested := make(map[uint64]uint64)
for _, id := range ids {
groupID, allowance := uint64(0), uint64(0)
if design, found := s.regular.Gacha(id); found {
group, grouped := s.regular.GroupForGacha(id)
if grouped && group.OneTimeGachaID == id && design.Count == 1 {
groupID, allowance = group.ID, design.FreeCountDay
}
} else if s.equipmentCatalog != nil {
if design, found := s.equipmentCatalog.Gacha(id); found && !design.TicketOnly && design.Count == 1 {
if s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: equipment inventory not attached")
}
if group, grouped := s.equipmentCatalog.GroupForGacha(id); grouped && group.OneTimeGachaID == id {
groupID, allowance = group.ID, design.FreeCountDay
}
}
}
if allowance == 0 || !s.dailyFreeScheduleOpen(groupID) {
return 146, nil, true, fmt.Errorf("gacha: unavailable daily free gacha %d", id)
}
requested[groupID]++
used := s.collection.GachaDailyCount(day, groupID, 0)
if used >= allowance || requested[groupID] > allowance-used {
return 146, nil, true, fmt.Errorf("gacha: daily free batch exceeds allowance group=%d", groupID)
}
}
var response []byte
for slot, id := range ids {
subrequest := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, id)
slotIdentity := fmt.Sprintf("%s:slot:%d:id:%d", identity, slot, id)
_, single, _, err := s.buyWithIdentity(subrequest, seq, slotIdentity)
if err != nil {
return 146, nil, true, fmt.Errorf("gacha: daily free batch slot %d: %w", slot, err)
}
result := wire.AppendVarint(nil, 1, id)
if err := wire.Walk(single, func(field wire.Field) error {
// Single-buy fields 1..5 correspond to multi-result fields 2..6.
switch field.Type {
case 0:
value, n := binary.Uvarint(field.Value)
if n <= 0 {
return wire.ErrMalformed
}
result = wire.AppendVarint(result, field.Number+1, value)
case 2:
result = wire.AppendBytes(result, field.Number+1, field.Value)
default:
return errors.New("gacha: unexpected single-buy response wire type")
}
return nil
}); err != nil {
return 146, nil, true, err
}
response = wire.AppendBytes(response, 1, result)
}
if err := s.collection.RecordGachaBatch(identity, digest, response); err != nil {
return 146, nil, true, err
}
return 146, response, true, nil
}
func (s *Service) dailyFreeScheduleOpen(groupID uint64) bool {
if s.schedule == nil {
return false
}
now := s.now().UnixMilli()
for _, entry := range s.schedule.Schedules {
if entry.GroupID == groupID && !entry.FreeCountBonus && now >= 0 && uint64(now) >= entry.StartTime && uint64(now) <= entry.EndTime {
return true
}
}
return false
}
func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
return s.buyWithIdentity(request, seq, "")
}
func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (int, []byte, bool, error) {
id, found, err := wire.Varint(request, 2)
if err != nil || !found || id == 0 {
return 146, nil, true, errors.New("gacha: missing gacha id")
@@ -287,10 +445,13 @@ func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
}); err != nil {
return 146, nil, true, err
}
if s.first != nil && id == s.first.GachaID {
return s.confirmFirstGacha(request, seq, buyType, tickets, identity)
}
design, ok := s.regular.Gacha(id)
if !ok && s.equipmentCatalog != nil {
if equipment, found := s.equipmentCatalog.Gacha(id); found {
return s.buyEquipment(seq, buyType, tickets, equipment)
return s.buyEquipmentWithIdentity(seq, buyType, tickets, equipment, identity)
}
}
if !ok {
@@ -324,7 +485,9 @@ func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
return 146, nil, true, fmt.Errorf("gacha: ticket %d is not valid for gacha %d", ticket.ID, id)
}
}
identity := s.requestIdentity(id, seq)
if identity == "" {
identity = s.requestIdentity(id, seq)
}
grant, already := s.collection.Grant(identity)
if isMoonrise && !already {
if _, completed := s.collection.Grant(moonriseDrawGrant); completed {
@@ -530,10 +693,15 @@ func (s *Service) stepForGacha(gachaID uint64) (uint64, gamedata.GachaStepDesign
// ticket-only draws have no schedule group and persist only their idempotency
// marker plus the generated equipment instances.
func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha) (int, []byte, bool, error) {
return s.buyEquipmentWithIdentity(seq, buyType, tickets, design, "")
}
func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha, identity string) (int, []byte, bool, error) {
if s.equipmentInventory == nil {
return 146, nil, true, errors.New("gacha: equipment inventory not attached")
}
if buyType != 1 || (!design.TicketOnly && design.PriceType != 3) {
isDailyFree := buyType == 0 && !design.TicketOnly && design.Count == 1 && design.FreeCountDay != 0
if (buyType != 1 && !isDailyFree) || (!design.TicketOnly && design.PriceType != 3) {
return 146, nil, true, fmt.Errorf("gacha: unsupported equipment buy type %d", buyType)
}
if design.TicketOnly && len(tickets) == 0 {
@@ -555,7 +723,12 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
if !hasGroup && !design.TicketOnly {
return 146, nil, true, fmt.Errorf("gacha: equipment gacha %d has no group", design.ID)
}
identity := s.requestIdentity(design.ID, seq)
if isDailyFree && len(tickets) != 0 {
return 146, nil, true, errors.New("gacha: daily free equipment draw cannot use tickets")
}
if identity == "" {
identity = s.requestIdentity(design.ID, seq)
}
grant, already := s.collection.Grant(identity)
var entries []player.Equipment
if already {
@@ -567,6 +740,9 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
entries = append(entries, entry)
}
} else {
if isDailyFree && s.collection.GachaDailyCount(dailyResetKey(s.now()), group.ID, 0) >= design.FreeCountDay {
return 146, nil, true, fmt.Errorf("gacha: daily equipment draw exhausted group=%d", group.ID)
}
var ticketCount uint64
for _, ticket := range tickets {
ticketCount += ticket.Count
@@ -582,7 +758,7 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
return 146, nil, true, err
}
}
if remain := uint64(design.Count) - ticketCount; remain != 0 {
if remain := uint64(design.Count) - ticketCount; remain != 0 && !isDailyFree {
if design.TicketOnly {
return 146, nil, true, errors.New("gacha: ticket-only equipment draw cannot use diamonds")
}
@@ -628,7 +804,11 @@ func (s *Service) buyEquipment(seq, buyType uint64, tickets []player.Item, desig
if design.TicketOnly {
grant, err = s.collection.GrantEquipmentDraw(identity)
} else {
grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates})
purchase := player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates}
if isDailyFree {
purchase.DailyKey, purchase.DailyLimit = dailyResetKey(s.now()), design.FreeCountDay
}
grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), purchase)
}
if err != nil {
return 146, nil, true, err
@@ -759,11 +939,17 @@ func (s *Service) requestIdentity(gachaID, seq uint64) string {
}
func (s *Service) preview(request []byte) (int, []byte, bool, error) {
id, found, err := wire.Varint(request, 2)
if err != nil || !found {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id)
}
if s.first != nil && id == s.first.GachaID {
return s.previewFirstGacha(request)
}
if _, bought := s.collection.Grant(infiniteGrant); bought {
return 175, nil, true, errors.New("gacha: infinite product already purchased")
}
id, found, err := wire.Varint(request, 2)
if err != nil || !found || id != gamedata.InfiniteGachaID {
if id != gamedata.InfiniteGachaID {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id)
}
group, found, err := wire.Varint(request, 3)
@@ -899,7 +1085,11 @@ func (s *Service) rewardBundle(grant player.CollectionGrant) []byte {
bundle = wire.AppendBytes(bundle, 3, player.CostumeWire(costume))
}
}
for sortID, costumeID := range grant.ViewCostumeIDs {
for position, costumeID := range grant.ViewCostumeIDs {
sortID := uint64(position)
if len(grant.ViewCostumeSortIDs) == len(grant.ViewCostumeIDs) {
sortID = grant.ViewCostumeSortIDs[position]
}
view := wire.AppendVarint(nil, 2, costumeID)
view = wire.AppendVarint(view, 3, 11)
view = wire.AppendVarint(view, 4, 1)
@@ -909,7 +1099,7 @@ func (s *Service) rewardBundle(grant player.CollectionGrant) []byte {
charView = wire.AppendVarint(charView, 3, 6)
charView = wire.AppendVarint(charView, 4, 1)
if sortID != 0 {
charView = wire.AppendVarint(charView, 6, uint64(sortID))
charView = wire.AppendVarint(charView, 6, sortID)
}
bundle = wire.AppendBytes(bundle, 6, charView)
}
+238 -2
View File
@@ -1,10 +1,15 @@
package gacha
import (
"bytes"
"encoding/binary"
"errors"
"os"
"path/filepath"
"testing"
"time"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
@@ -16,7 +21,7 @@ func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
const version = "20260910162539"
const version = "20260923193640"
infinite, err := gamedata.LoadInfiniteGacha(root, version)
if err != nil {
t.Fatal(err)
@@ -546,13 +551,244 @@ func TestUnverifiedGachaRPCsRemainUnhandled(t *testing.T) {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
for _, path := range []string{"/GachaMultiBuy", "/GachaLog"} {
for _, path := range []string{"/GachaLog"} {
if code, response, handled, err := service.Handle(path, request); err != nil || handled || code != 0 || response != nil {
t.Fatalf("%s code=%d bytes=%x handled=%v err=%v", path, code, response, handled, err)
}
}
}
func newMultiBuyTestService(t *testing.T, storage stateio.Store) *Service {
t.Helper()
character := fixtureCharacter(6090, 253)
characters := map[uint64]gamedata.CharacterDesign{60901: character}
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
11: {ID: 11, Count: 1, FreeCountDay: 2, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
12: {ID: 12, Count: 1, FreeCountDay: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
13: {ID: 13, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
for _, group := range []gamedata.GachaGroupDesign{{ID: 1, OneTimeGachaID: 11, TenTimeGachaID: 13, PointCount: 1}, {ID: 2, OneTimeGachaID: 12, PointCount: 1}} {
if err := regular.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil {
t.Fatal(err)
}
}
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 1000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("multi-login")
service.now = func() time.Time { return time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) }
service.schedule = &ScheduleSeed{Schedules: []ScheduleWindow{{GroupID: 1, StartTime: 1, EndTime: 2000000000000}, {GroupID: 2, StartTime: 1, EndTime: 2000000000000}}}
return service
}
func multiBuyRequest(seq uint64, packed bool, ids ...uint64) []byte {
request := wire.AppendVarint(nil, 1, seq)
var values []byte
for _, id := range ids {
if packed {
values = binary.AppendUvarint(values, id)
} else {
request = wire.AppendVarint(request, 2, id)
}
}
if packed {
request = wire.AppendBytes(request, 2, values)
}
return request
}
func TestGachaMultiBuyPreservesOrderRepeatedIDsAndRestartReplay(t *testing.T) {
storage := stateio.NewMemory()
service := newMultiBuyTestService(t, storage)
request := multiBuyRequest(10, true, 12, 11, 11)
code, response, handled, err := service.Handle("/GachaMultiBuy", request)
if err != nil || code != 146 || !handled {
t.Fatalf("multi code=%d handled=%v err=%v", code, handled, err)
}
var gotIDs []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number != 1 || field.Type != 2 {
return errors.New("invalid result")
}
id, _, err := wire.Varint(field.Value, 1)
if err != nil {
return err
}
gotIDs = append(gotIDs, id)
bundle, found, err := wire.Bytes(field.Value, 2)
if err != nil || !found || (countFields(bundle, 3)+countFields(bundle, 9)) != 1 {
return errors.New("missing costume/upgrade")
}
point, _, err := wire.Varint(field.Value, 3)
if err != nil || point != 1 {
return errors.New("missing point")
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(gotIDs) != 3 || gotIDs[0] != 12 || gotIDs[1] != 11 || gotIDs[2] != 11 {
t.Fatalf("order=%v", gotIDs)
}
if service.wallet.Snapshot().FreeJewelry != 1000 {
t.Fatal("free batch charged currency")
}
if owned := service.collection.Costumes(); len(owned) != 1 || owned[0].Level != 2 {
t.Fatalf("owned=%+v", owned)
}
if service.collection.GachaDailyCount("2026-09-30", 1, 0) != 2 || service.collection.GachaUser(1).Point != 2 {
t.Fatal("repeated IDs did not make independent draws")
}
service = newMultiBuyTestService(t, storage)
_, replay, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(10, false, 12, 11, 11))
if err != nil || !bytes.Equal(response, replay) {
t.Fatalf("restart/unpacked replay differs: %v", err)
}
if service.collection.Costumes()[0].Level != 2 || service.collection.GachaUser(1).Point != 2 {
t.Fatal("replay mutated rewards")
}
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(10, true, 11, 12, 11)); err == nil {
t.Fatal("same sequence changed ordered request")
}
service.BeginSession("next-login")
if _, _, _, err := service.Handle("/GachaMultiBuy", request); err == nil {
t.Fatal("new login bypassed exhausted daily limits")
}
service.now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) }
if _, _, _, err := service.Handle("/GachaMultiBuy", request); err != nil {
t.Fatalf("next UTC reset did not restore allowance: %v", err)
}
}
func TestGachaMultiBuyInvalidOrExcessBatchDoesNotGrantAnySlot(t *testing.T) {
for name, ids := range map[string][]uint64{"unknown": {11, 999}, "excess": {11, 11, 11}, "ten-pull": {11, 13}, "zero": {11, 0}, "negative-int32": {11, ^uint64(0)}, "empty": {}} {
t.Run(name, func(t *testing.T) {
service := newMultiBuyTestService(t, stateio.NewMemory())
code, _, handled, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, ids...))
if code != 146 || !handled || err == nil {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
if len(service.collection.Costumes()) != 0 || service.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || service.collection.GachaUser(1).Point != 0 {
t.Fatal("invalid batch partly granted")
}
})
}
service := newMultiBuyTestService(t, stateio.NewMemory())
service.schedule.Schedules[1].EndTime = 2
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, 11, 12)); err == nil || len(service.collection.Costumes()) != 0 {
t.Fatal("closed schedule partly granted")
}
}
func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
service := newMultiBuyTestService(t, repo)
if err := service.collection.EnsurePersisted(); err != nil {
t.Fatal(err)
}
service.AttachPreviewMission(func() error { return errors.New("injected mission failure after reward") })
operation, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, 11, 12)); err == nil {
t.Fatal("expected failure")
}
// A dirty rollback intentionally fail-stops domain memory. Reopen below
// verifies the disk state and prevents stale in-memory objects being reused.
if err := operation.Rollback(); err == nil || repo.Check() == nil {
t.Fatal("dirty rollback must fail-stop the current repository")
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
reopened := newMultiBuyTestService(t, repo)
if len(reopened.collection.Costumes()) != 0 || reopened.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || reopened.collection.GachaUser(1).Point != 0 {
t.Fatal("failed request persisted a partial batch")
}
if _, found, _ := reopened.collection.GachaBatchResponse("multi-gacha:session:multi-login:seq:1", "ignored"); found {
t.Fatal("failed request stored completion")
}
}
func TestGachaMultiBuyCurrent23510CostumeAndEquipmentPools(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "data", "resources", "GameData")
const version = "20260923193640"
if _, err := os.Stat(filepath.Join(root, version, "release", "common-dbdata.bin")); os.IsNotExist(err) {
t.Skip("installed 2.35.10 GameData archive is unavailable")
}
storage := stateio.NewMemory()
service := newMultiBuyTestService(t, storage)
regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{71, 166, 205, 207}, nil)
if err != nil {
t.Fatal(err)
}
equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{72, 153, 206, 208})
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
service.regular = regular
service.AttachEquipmentGacha(equipmentCatalog, equipment)
service.schedule.Schedules = nil
for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} {
service.schedule.Schedules = append(service.schedule.Schedules, ScheduleWindow{GroupID: groupID, StartTime: 1, EndTime: 2000000000000})
}
ids := []uint64{10100040, 20100034, 10100113, 20100070, 10100145, 20100082, 10100146, 20100083}
request := multiBuyRequest(99, true, ids...)
code, response, handled, err := service.Handle("/GachaMultiBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("installed mixed batch: code=%d handled=%v err=%v", code, handled, err)
}
if countFields(response, 1) != 8 || len(equipment.All()) != 4 {
t.Fatalf("results=%d equipment=%d", countFields(response, 1), len(equipment.All()))
}
if service.wallet.Snapshot().FreeJewelry != 1000 {
t.Fatal("installed free batch spent diamonds")
}
for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} {
if service.collection.GachaDailyCount("2026-09-30", groupID, 0) != 1 || service.collection.GachaUser(groupID).Point != 1 {
t.Fatalf("missing daily allowance/point group=%d", groupID)
}
}
if states := service.collection.GachaFixedStates(); len(states) != 4 {
t.Fatalf("missing four costume/equipment fixed types: %+v", states)
}
_, replay, _, err := service.Handle("/GachaMultiBuy", request)
if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 4 {
t.Fatalf("installed replay changed equipment/rewards: %v", err)
}
}
func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) {
const stepUpGroupID = 29
stepUpGachaIDs := []uint64{8100118, 8100119, 8100120, 8100121}
@@ -17,14 +17,14 @@ func TestRealTutorialStatsDoNotMisclassifyAttackAsHealth(t *testing.T) {
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
base, err := CharacterBaseStats(root, "20260910162539", 350, 20)
base, err := CharacterBaseStats(root, "20260923193640", 350, 20)
if err != nil {
t.Fatal(err)
}
if base.Health != 505 {
t.Fatalf("base health=%v, want 505", base.Health)
}
option, err := EquipmentOptionContribution(root, "20260910162539", EquipmentOption{GroupID: 1010010, ID: 3})
option, err := EquipmentOptionContribution(root, "20260923193640", EquipmentOption{GroupID: 1010010, ID: 3})
if err != nil {
t.Fatal(err)
}
@@ -41,7 +41,7 @@ func TestLoadedCharacterStatDesignMatchesDirectLookup(t *testing.T) {
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
design, err := LoadPictorialDesign(root, "20260910162539")
design, err := LoadPictorialDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
@@ -49,7 +49,7 @@ func TestLoadedCharacterStatDesignMatchesDirectLookup(t *testing.T) {
if err != nil {
t.Fatal(err)
}
direct, err := CharacterBaseStats(root, "20260910162539", 350, 20)
direct, err := CharacterBaseStats(root, "20260923193640", 350, 20)
if err != nil {
t.Fatal(err)
}
+5 -5
View File
@@ -10,7 +10,7 @@ func TestInstalledPack21FirstMonsterRewards(t *testing.T) {
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
rewards, err := BattleRewards(root, "20260910162539", 21, 1)
rewards, err := BattleRewards(root, "20260923193640", 21, 1)
if err != nil {
t.Fatal(err)
}
@@ -24,14 +24,14 @@ func TestInstalledPack22DeckAbsentFromPack21Rewards(t *testing.T) {
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
rewards, err := BattleDeckRewards(root, "20260910162539", 22, 9)
rewards, err := BattleDeckRewards(root, "20260923193640", 22, 9)
if err != nil {
t.Fatal(err)
}
if len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 14, Count: 1}) {
t.Fatalf("pack22 deck9 rewards = %+v, want type8/item14 x1", rewards)
}
if _, err := BattleDeckRewards(root, "20260910162539", 21, 9); err == nil {
if _, err := BattleDeckRewards(root, "20260923193640", 21, 9); err == nil {
t.Fatal("pack21 unexpectedly contains pack22-only deck9")
}
}
@@ -41,7 +41,7 @@ func TestInstalledTutorialGrowthReachesLevel20(t *testing.T) {
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
level, exp, refunds, err := CharacterGrowth(root, "20260910162539", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 3}})
level, exp, refunds, err := CharacterGrowth(root, "20260923193640", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 3}})
if err != nil {
t.Fatal(err)
}
@@ -58,7 +58,7 @@ func TestInstalledTutorialGrowthClientSelectionRefund(t *testing.T) {
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
level, exp, refunds, err := CharacterGrowth(root, "20260910162539", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 2}})
level, exp, refunds, err := CharacterGrowth(root, "20260923193640", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 2}})
if err != nil {
t.Fatal(err)
}
@@ -69,17 +69,18 @@ func TestCharAwakeActivationRequiresAllSlotsAndAddsEveryEffect(t *testing.T) {
}
}
func TestCharAwakeAgainstInstalledVersion23413(t *testing.T) {
func TestCharAwakeAgainstInstalledVersion23510(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := LoadCharAwakeDesign(root, "20260910162539")
design, err := LoadCharAwakeDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
entry := design.Characters[35]
if len(design.Characters) != 86 || len(entry.ImprintGrowth[0]) != 10 || len(entry.ImprintGrowth[1]) != 10 || len(entry.ImprintGrowth[2]) != 10 || len(entry.AwakeGrowth) != 2 {
// Current GameData CharAwakeTable contains 87 unique-character rows.
if len(design.Characters) != 87 || len(entry.ImprintGrowth[0]) != 10 || len(entry.ImprintGrowth[1]) != 10 || len(entry.ImprintGrowth[2]) != 10 || len(entry.AwakeGrowth) != 2 {
t.Fatalf("installed awakening design characters=%d char35=%+v", len(design.Characters), entry)
}
if entry.AwakeGrowth[0].Costs[0] != (CharAwakeCost{Type: 8, ID: 705, Count: 200}) || entry.AwakeGrowth[1].StatType != 14 || entry.AwakeGrowth[1].StatValue != .1 {
@@ -25,12 +25,12 @@ func TestCostumePotentialValidatePrerequisitesAndAggregate(t *testing.T) {
}
}
func TestCostumePotentialAgainstInstalledVersion23413(t *testing.T) {
func TestCostumePotentialAgainstInstalledVersion23510(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := LoadCostumePotentialDesign(root, "20260910162539")
design, err := LoadCostumePotentialDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
+10 -1
View File
@@ -25,6 +25,7 @@ type EquipmentGachaCatalog struct {
type EquipmentGacha struct {
ID uint64
Count int
FreeCountDay uint64
PriceType, Price uint64
TicketIDs []uint64
Pool []WeightedEquipment
@@ -225,6 +226,14 @@ func loadEquipmentGacha(db *sql.DB, id uint64) (EquipmentGacha, error) {
price, _ := packedInts(raw, 10)
kind, _ := packedInts(raw, 12)
tickets, _ := packedInts(raw, 8)
freeCounts, err := packedInts(raw, 4)
if err != nil || len(freeCounts) > 1 {
return EquipmentGacha{}, fmt.Errorf("gamedata: malformed equipment free allowance %d", id)
}
var freeCount uint64
if len(freeCounts) == 1 {
freeCount = freeCounts[0]
}
if len(count) != 1 || len(reward) != 1 || len(price) != 1 || len(kind) != 1 || kind[0] != 3 || price[0] != uint64(count[0])*200 {
return EquipmentGacha{}, fmt.Errorf("gamedata: malformed equipment gacha %d", id)
}
@@ -246,7 +255,7 @@ func loadEquipmentGacha(db *sql.DB, id uint64) (EquipmentGacha, error) {
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d branch %d rate=%d", id, i, pool[i].Weight)
}
}
return EquipmentGacha{ID: id, Count: int(count[0]), PriceType: kind[0], Price: price[0], TicketIDs: append([]uint64(nil), tickets...), Pool: pool}, nil
return EquipmentGacha{ID: id, Count: int(count[0]), FreeCountDay: freeCount, PriceType: kind[0], Price: price[0], TicketIDs: append([]uint64(nil), tickets...), Pool: pool}, nil
}
func loadEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, error) {
var raw []byte
@@ -5,12 +5,12 @@ import (
"testing"
)
func TestEquipmentSmeltingAgainstInstalledVersion23413(t *testing.T) {
func TestEquipmentSmeltingAgainstInstalledVersion23510(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := LoadEquipmentSmeltingDesign(root, "20260910162539")
design, err := LoadEquipmentSmeltingDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
@@ -5,12 +5,12 @@ import (
"testing"
)
func TestEquipmentTicketOnlyGachaAgainstInstalledVersion23413(t *testing.T) {
func TestEquipmentTicketOnlyGachaAgainstInstalledVersion23510(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
catalog, err := LoadEquipmentGacha(root, "20260910162539")
catalog, err := LoadEquipmentGacha(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
@@ -61,12 +61,12 @@ func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) {
}
}
func TestEquipmentUpgradeAgainstInstalledVersion23413(t *testing.T) {
func TestEquipmentUpgradeAgainstInstalledVersion23510(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := LoadEquipmentUpgradeDesign(root, "20260910162539")
design, err := LoadEquipmentUpgradeDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
+365
View File
@@ -0,0 +1,365 @@
package gamedata
import (
"database/sql"
"errors"
"fmt"
"math"
)
// FirstGachaDesign is the free starter reroll referenced by GameDefaultTable.
// It is separate from ordinary and cash-product gachas because its reward
// program emits both costumes and equipment and has no purchase currency.
type FirstGachaDesign struct {
Group GachaGroupDesign
GachaID uint64
Count int
RewardGroup *FirstGachaRewardGroup
characters map[uint64]CharacterDesign
equipment *EquipmentGachaCatalog
}
type FirstGachaReward struct{ Type, ID uint64 }
type FirstGachaRewardGroup struct {
ID, DropCount, DropType uint64
Entries []FirstGachaRewardEntry
}
type FirstGachaRewardEntry struct {
ItemType, ItemID, Count, Weight uint64
Group *FirstGachaRewardGroup
}
func (d *FirstGachaDesign) Character(id uint64) (CharacterDesign, bool) {
v, ok := d.characters[id]
return v, ok
}
func (d *FirstGachaDesign) EquipmentCatalog() *EquipmentGachaCatalog { return d.equipment }
// CostumeCatalog supplies character and duplicate-upgrade metadata only. It
// deliberately has no purchasable products, so it cannot enable a zero-price
// ordinary draw through the normal catalog.
func (d *FirstGachaDesign) CostumeCatalog() *RegularGachaCatalog {
return &RegularGachaCatalog{characters: d.characters}
}
func NewFirstGachaDesign(group GachaGroupDesign, id uint64, count int, program *FirstGachaRewardGroup, characters map[uint64]CharacterDesign, equipment map[uint64]EquipmentDesign) (*FirstGachaDesign, error) {
if group.ID == 0 || group.GachaSubType != 3 || group.GachaType != 0 || group.TenTimeGachaID != id || id == 0 || count <= 0 || count > 100 {
return nil, errors.New("gamedata: invalid first gacha identity")
}
n, err := firstGachaRewardCount(program, map[*FirstGachaRewardGroup]bool{})
if err != nil || n != uint64(count) {
return nil, fmt.Errorf("gamedata: first gacha reward count=%d want=%d: %v", n, count, err)
}
d := &FirstGachaDesign{Group: group, GachaID: id, Count: count, RewardGroup: program, characters: map[uint64]CharacterDesign{}, equipment: &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}}}
var visit func(*FirstGachaRewardGroup) error
visit = func(g *FirstGachaRewardGroup) error {
for _, e := range g.Entries {
switch e.ItemType {
case 9:
if err := visit(e.Group); err != nil {
return err
}
case 11:
c, ok := characters[e.ItemID]
if !ok || c.ID == 0 || c.HP == 0 {
return fmt.Errorf("gamedata: first gacha costume %d has no character", e.ItemID)
}
d.characters[e.ItemID] = c
case 10:
v, ok := equipment[e.ItemID]
if !ok || v.ID != e.ItemID || v.Grade == 0 {
return fmt.Errorf("gamedata: first gacha equipment %d has no design", e.ItemID)
}
d.equipment.equipment[e.ItemID] = v
}
}
return nil
}
if err := visit(program); err != nil {
return nil, err
}
return d, nil
}
func LoadFirstGacha(root, version string) (*FirstGachaDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadFirstGacha(db)
}
func loadFirstGacha(db *sql.DB) (*FirstGachaDesign, error) {
read := func(table string, id uint64) ([]byte, error) {
var b []byte
err := db.QueryRow("SELECT ProtoBuf FROM "+table+" WHERE id=?", id).Scan(&b)
return b, err
}
defaults, err := read("GameDefaultTable", 0)
if err != nil {
return nil, err
}
first, err := packedInts(defaults, 46)
if err != nil || len(first) != 1 || first[0] == 0 {
return nil, errors.New("gamedata: missing FirstLimitGachaId")
}
raw, err := read("GachaGroupTable", first[0])
if err != nil {
return nil, err
}
value := func(b []byte, n int) (uint64, error) {
v, err := packedInts(b, n)
if err != nil || len(v) > 1 {
return 0, fmt.Errorf("gamedata: malformed first gacha field %d", n)
}
if len(v) == 0 {
return 0, nil
}
return v[0], nil
}
id, err := value(raw, 18)
if err != nil || id != first[0] {
return nil, errors.New("gamedata: first gacha group identity mismatch")
}
for field, want := range map[int]uint64{16: 3, 17: 0, 28: 0, 3: 0, 4: 0, 5: 0, 24: 0} {
v, e := value(raw, field)
if e != nil || v != want {
return nil, fmt.Errorf("gamedata: first gacha group %d has unsupported field %d=%d", id, field, v)
}
}
gachaID, err := value(raw, 33)
if err != nil || gachaID == 0 {
return nil, errors.New("gamedata: first gacha product missing")
}
product, err := read("GachaTable", gachaID)
if err != nil {
return nil, err
}
for field, want := range map[int]uint64{9: gachaID, 1: 0, 2: 0, 3: 0, 4: 0, 10: 0, 11: 0, 12: 0} {
v, e := value(product, field)
if e != nil || v != want {
return nil, fmt.Errorf("gamedata: first gacha product has unsupported field %d=%d", field, v)
}
}
tickets, err := packedInts(product, 8)
if err != nil {
return nil, err
}
for _, id := range tickets {
if id != 0 {
return nil, errors.New("gamedata: first gacha cannot consume tickets")
}
}
count, err := value(product, 5)
if err != nil || count == 0 || count > 100 {
return nil, errors.New("gamedata: first gacha count invalid")
}
rewardID, err := value(product, 7)
if err != nil || rewardID == 0 {
return nil, errors.New("gamedata: first gacha reward missing")
}
program, err := loadFirstGachaRewardGroup(db, rewardID, map[uint64]bool{})
if err != nil {
return nil, err
}
characters := map[uint64]CharacterDesign{}
equipment := map[uint64]EquipmentDesign{}
var load func(*FirstGachaRewardGroup) error
load = func(g *FirstGachaRewardGroup) error {
for _, e := range g.Entries {
switch e.ItemType {
case 9:
if err := load(e.Group); err != nil {
return err
}
case 11:
if _, ok := characters[e.ItemID]; !ok {
v, err := loadGachaCharacterDesign(db, e.ItemID)
if err != nil {
return err
}
characters[e.ItemID] = v
}
case 10:
if _, ok := equipment[e.ItemID]; !ok {
v, err := loadEquipmentDesign(db, e.ItemID)
if err != nil {
return err
}
equipment[e.ItemID] = v
}
}
}
return nil
}
if err := load(program); err != nil {
return nil, err
}
points, err := value(raw, 27)
if err != nil {
return nil, err
}
group := GachaGroupDesign{ID: id, GachaSubType: 3, TenTimeGachaID: gachaID, PointCount: points}
return NewFirstGachaDesign(group, gachaID, int(count), program, characters, equipment)
}
func loadFirstGachaRewardGroup(db *sql.DB, id uint64, visiting map[uint64]bool) (*FirstGachaRewardGroup, error) {
if visiting[id] || len(visiting) > 32 {
return nil, fmt.Errorf("gamedata: first gacha reward cycle/depth at %d", id)
}
visiting[id] = true
defer delete(visiting, id)
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", id).Scan(&raw); err != nil {
return nil, err
}
fields := map[int][]uint64{}
for _, n := range []int{1, 2, 4, 5, 6, 8} {
v, err := packedInts(raw, n)
if err != nil {
return nil, err
}
fields[n] = v
}
if len(fields[1]) != 1 || len(fields[2]) > 1 {
return nil, errors.New("gamedata: malformed first reward execution")
}
g := &FirstGachaRewardGroup{ID: id, DropCount: fields[1][0]}
if len(fields[2]) == 1 {
g.DropType = fields[2][0]
}
ids := fields[5]
if len(ids) == 0 || len(ids) != len(fields[4]) || len(ids) != len(fields[6]) || len(ids) != len(fields[8]) {
return nil, errors.New("gamedata: malformed first reward entries")
}
for i, id := range ids {
e := FirstGachaRewardEntry{ItemID: id, ItemType: fields[6][i], Count: fields[4][i], Weight: fields[8][i]}
if e.ItemType == 9 {
child, err := loadFirstGachaRewardGroup(db, id, visiting)
if err != nil {
return nil, err
}
e.Group = child
}
g.Entries = append(g.Entries, e)
}
if _, err := firstGachaRewardCount(g, map[*FirstGachaRewardGroup]bool{}); err != nil {
return nil, err
}
return g, nil
}
func firstGachaRewardCount(g *FirstGachaRewardGroup, visiting map[*FirstGachaRewardGroup]bool) (uint64, error) {
if g == nil || g.ID == 0 || g.DropCount == 0 || g.DropCount > 100 || len(g.Entries) == 0 || g.DropType > 1 || visiting[g] || len(visiting) > 32 {
return 0, errors.New("gamedata: invalid first reward program")
}
visiting[g] = true
defer delete(visiting, g)
var total, each, weight uint64
for _, e := range g.Entries {
if e.ItemID == 0 || e.Count == 0 || e.Count > 100 || e.Weight == 0 || math.MaxUint64-weight < e.Weight {
return 0, errors.New("gamedata: invalid first reward entry")
}
weight += e.Weight
n := e.Count
switch e.ItemType {
case 9:
if e.Group == nil || e.Group.ID != e.ItemID {
return 0, errors.New("gamedata: first reward child mismatch")
}
child, err := firstGachaRewardCount(e.Group, visiting)
if err != nil || child > 100/e.Count {
return 0, errors.New("gamedata: invalid first reward child count")
}
n = child * e.Count
case 10, 11:
if e.Group != nil {
return 0, errors.New("gamedata: direct first reward has a child")
}
default:
return 0, fmt.Errorf("gamedata: unsupported first reward type %d", e.ItemType)
}
if g.DropType == 0 {
if each == 0 {
each = n
} else if each != n {
return 0, errors.New("gamedata: weighted first reward cardinality differs")
}
} else {
total += n
}
}
if g.DropType == 0 {
total = each * g.DropCount
}
if total > 100 {
return 0, errors.New("gamedata: first reward count exceeds bound")
}
return total, nil
}
func (d *FirstGachaDesign) Roll() ([]FirstGachaReward, error) { return d.roll(cryptoDraw) }
func (d *FirstGachaDesign) roll(draw func(uint64) (uint64, error)) ([]FirstGachaReward, error) {
if d == nil {
return nil, errors.New("gamedata: missing first gacha")
}
if n, err := firstGachaRewardCount(d.RewardGroup, map[*FirstGachaRewardGroup]bool{}); err != nil || n != uint64(d.Count) {
return nil, errors.New("gamedata: first reward program changed")
}
var out []FirstGachaReward
var run func(*FirstGachaRewardGroup) error
var emit func(FirstGachaRewardEntry) error
emit = func(e FirstGachaRewardEntry) error {
for n := uint64(0); n < e.Count; n++ {
if e.ItemType == 9 {
if err := run(e.Group); err != nil {
return err
}
} else {
out = append(out, FirstGachaReward{Type: e.ItemType, ID: e.ItemID})
}
}
return nil
}
run = func(g *FirstGachaRewardGroup) error {
if g.DropType == 1 {
for _, e := range g.Entries {
if err := emit(e); err != nil {
return err
}
}
return nil
}
var sum uint64
for _, e := range g.Entries {
sum += e.Weight
}
for n := uint64(0); n < g.DropCount; n++ {
v, err := draw(sum)
if err != nil {
return err
}
if v >= sum {
return errors.New("gamedata: first reward random out of bounds")
}
for _, e := range g.Entries {
if v < e.Weight {
if err := emit(e); err != nil {
return err
}
break
}
v -= e.Weight
}
}
return nil
}
if err := run(d.RewardGroup); err != nil {
return nil, err
}
if len(out) != d.Count {
return nil, errors.New("gamedata: first reward count mismatch")
}
return out, nil
}
@@ -0,0 +1,173 @@
package gamedata
import (
"database/sql"
"os"
"strings"
"testing"
"bd2server/internal/server/wire"
)
func firstGachaTestProgram() *FirstGachaRewardGroup {
return &FirstGachaRewardGroup{ID: 77, DropCount: 1, DropType: 1, Entries: []FirstGachaRewardEntry{
{ItemType: 11, ItemID: 5001, Count: 1, Weight: 1},
{ItemType: 9, ItemID: 78, Count: 1, Weight: 1, Group: &FirstGachaRewardGroup{ID: 78, DropCount: 2, Entries: []FirstGachaRewardEntry{
{ItemType: 10, ItemID: 1001, Count: 1, Weight: 25},
{ItemType: 10, ItemID: 1002, Count: 1, Weight: 75},
}}},
}}
}
func TestFirstGachaMixedProgramPreservesCountsAndBranchWeights(t *testing.T) {
design, err := NewFirstGachaDesign(GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 3, firstGachaTestProgram(),
map[uint64]CharacterDesign{5001: {ID: 500, HP: 100}},
map[uint64]EquipmentDesign{1001: {ID: 1001, Grade: 2}, 1002: {ID: 1002, Grade: 3}})
if err != nil {
t.Fatal(err)
}
values := []uint64{24, 25}
roll, err := design.roll(func(limit uint64) (uint64, error) {
if limit != 100 {
t.Fatalf("weight total=%d want=100", limit)
}
value := values[0]
values = values[1:]
return value, nil
})
if err != nil || len(roll) != 3 || roll[0] != (FirstGachaReward{Type: 11, ID: 5001}) || roll[1].ID != 1001 || roll[2].ID != 1002 {
t.Fatalf("roll=%v err=%v", roll, err)
}
if len(design.CostumeCatalog().Gachas) != 0 {
t.Fatal("starter design exposed a zero-price ordinary product")
}
if _, ok := design.Character(5001); !ok {
t.Fatal("starter character metadata missing")
}
}
func TestFirstGachaRejectsInvalidPrograms(t *testing.T) {
for _, kind := range []string{"cycle", "unsupported type", "mismatched weighted count", "zero weight", "excessive count"} {
t.Run(kind, func(t *testing.T) {
g := firstGachaTestProgram()
switch kind {
case "cycle":
g.Entries[1].ItemID = g.ID
g.Entries[1].Group = g
case "unsupported type":
g.Entries[0].ItemType = 8
case "mismatched weighted count":
g.Entries[1].Group.Entries[0].Count = 2
case "zero weight":
g.Entries[0].Weight = 0
case "excessive count":
g.Entries[1].Group.DropCount = 101
}
if _, err := firstGachaRewardCount(g, map[*FirstGachaRewardGroup]bool{}); err == nil {
t.Fatal("invalid mixed reward program accepted")
}
})
}
}
func TestFirstGachaLoaderRejectsNonStarterOrPricedMetadata(t *testing.T) {
for _, bad := range []string{"group subtype", "group type", "cash product", "scheduled", "priced", "ticket", "daily allowance"} {
t.Run(bad, func(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
for _, table := range []string{"GameDefaultTable", "GachaGroupTable", "GachaTable"} {
if _, err := db.Exec("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
}
group := wire.AppendVarint(nil, 18, 2)
group = wire.AppendVarint(group, 16, 3)
group = wire.AppendVarint(group, 33, 20)
product := wire.AppendVarint(nil, 9, 20)
product = wire.AppendVarint(product, 5, 10)
product = wire.AppendVarint(product, 7, 20)
switch bad {
case "group subtype":
group, _, err = wire.ReplaceVarint(group, 16, 1)
case "group type":
group = wire.AppendVarint(group, 17, 1)
case "cash product":
group = wire.AppendVarint(group, 3, 123)
case "scheduled":
group = wire.AppendVarint(group, 28, 1)
case "priced":
product = wire.AppendVarint(product, 10, 200)
case "ticket":
product = wire.AppendVarint(product, 8, 450000)
case "daily allowance":
product = wire.AppendVarint(product, 4, 1)
}
if err != nil {
t.Fatal(err)
}
for _, row := range []struct {
table string
id uint64
raw []byte
}{{"GameDefaultTable", 0, wire.AppendVarint(nil, 46, 2)}, {"GachaGroupTable", 2, group}, {"GachaTable", 20, product}} {
if _, err := db.Exec("INSERT INTO "+row.table+" VALUES (?,?)", row.id, row.raw); err != nil {
t.Fatal(err)
}
}
_, err = loadFirstGacha(db)
want := "unsupported field"
if bad == "ticket" {
want = "cannot consume tickets"
}
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("metadata was not rejected before reading rewards: err=%v want=%q", err, want)
}
})
}
}
func TestFirstGachaAgainstInstalledVersion23510(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := LoadFirstGacha(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
if design.Group.ID != 2 || design.GachaID != 20 || design.Count != 10 || design.Group.PointCount != 0 || len(design.RewardGroup.Entries) != 6 {
t.Fatalf("design=%+v", design)
}
// Current GameData gives one five-star, one four-star, three three-star
// costumes and five exclusive-equipment instances in every reroll.
for i := 0; i < 20; i++ {
roll, err := design.Roll()
if err != nil {
t.Fatal(err)
}
var costumes, equipment int
for _, reward := range roll {
switch reward.Type {
case 11:
costumes++
if _, ok := design.Character(reward.ID); !ok {
t.Fatalf("unknown costume %d", reward.ID)
}
case 10:
equipment++
if _, ok := design.EquipmentCatalog().equipment[reward.ID]; !ok {
t.Fatalf("unknown equipment %d", reward.ID)
}
default:
t.Fatalf("unexpected reward type %d", reward.Type)
}
}
if len(roll) != 10 || costumes != 5 || equipment != 5 {
t.Fatalf("mixed draw has costumes=%d equipment=%d results=%v", costumes, equipment, roll)
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ const (
officialFourStarRate = 1400
)
// InfiniteGachaDesign is the non-account portion of the 2.34.13 paid
// InfiniteGachaDesign is the non-account portion of the paid
// "infinite reroll" product. The local server deliberately makes final
// confirmation free, but still reads the official result pool from GameData.
type InfiniteGachaDesign struct {
@@ -169,12 +169,12 @@ func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) {
}
}
func TestLegacyRegularGachaLoaderAgainstInstalledVersion23413(t *testing.T) {
func TestLegacyRegularGachaLoaderAgainstInstalledVersion23510(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
catalog, err := LoadRegularCostumeGacha(root, "20260910162539")
catalog, err := LoadRegularCostumeGacha(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -127,7 +127,7 @@ func CharacterGrowth(root, version string, charID int, level, exp uint64, materi
}
// Convert max-level overflow back into the installed experience resources.
// The official 2.34.13 server uses larger denominations greedily and rounds
// Growth refunds use larger denominations greedily and round
// the final remainder up to one smallest slime. This can refund slightly
// more nominal EXP than the overflow: the verified tutorial sample has 551
// overflow EXP and returns resource 7 x3 (600 EXP), not x2.
+1 -1
View File
@@ -49,7 +49,7 @@ type AchievementDesign struct {
}
// LoadMissionDesign loads the three non-event design tables from the shared
// 2.34.13 database. Event missions intentionally remain outside this reader:
// current database. Event missions intentionally remain outside this reader:
// their eligibility depends on an active server schedule, not just GameData.
func LoadMissionDesign(root, version string) (*MissionDesign, error) {
plain, err := ReadQuestDatabase(root, version)
@@ -7,7 +7,7 @@ import (
"testing"
)
func TestCharacterGrowthPromotionsAgainstInstalledVersion23413(t *testing.T) {
func TestCharacterGrowthPromotionsAgainstInstalledVersion23510(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
@@ -20,12 +20,12 @@ func TestCharacterGrowthPromotionsAgainstInstalledVersion23413(t *testing.T) {
{Type: 8, ID: 14, Count: 4},
{Type: 4, Count: 10000},
}
result, err := CharacterGrowthPromotions(root, "20260910162539", 6510, 1, 0, submitted)
result, err := CharacterGrowthPromotions(root, "20260923193640", 6510, 1, 0, submitted)
if err != nil {
t.Fatal(err)
}
if result.CharacterID != 6514 || result.Level != 100 || result.Exp != 0 || len(result.Costs) != 8 {
t.Fatalf("installed 2.34.13 combined promotion=%+v", result)
t.Fatalf("installed 2.35.10 combined promotion=%+v", result)
}
}
@@ -75,7 +75,7 @@ func TestRealQuest21Formations(t *testing.T) {
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
formations, err := LoadQuestFormations(root, "20260910162539", 21)
formations, err := LoadQuestFormations(root, "20260923193640", 21)
if err != nil {
t.Fatal(err)
}
@@ -18,14 +18,14 @@ func TestRandomBoxOpenAggregatesOnlyKnownDeterministicReward(t *testing.T) {
}
}
// This locks the actual 2.34.13 row behind the installed GameData opt-in,
// This locks the actual current-version row behind the installed GameData opt-in,
// rather than replacing GameData lookup with a hand-written item mapping.
func TestLoadRandomBoxDesignInstalledEngravingEssence(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := LoadRandomBoxDesign(root, "20260910162539")
design, err := LoadRandomBoxDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -28,7 +28,7 @@ const openPacketCode = 132
// represented by ItemDBInfo in RewardDBInfoBundle. They are deliberately
// separate from character (6), equipment (10), and costume (11): the client
// requires CharDBInfo, EquipDBInfo, and CostumeDBInfo for those rewards, and
// this mailbox owns no such domain stores. The values are from 2.34.13
// this mailbox owns no such domain stores. The values are from the client
// DataManager.GetItemInfo and ItemDBInfo, not inferred from table names.
var itemDBInfoTypes = map[uint64]bool{
5: true, // food
+17 -17
View File
@@ -7,7 +7,6 @@ import (
"testing"
"time"
"bd2server/internal/server/fixture"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
@@ -345,26 +344,27 @@ func TestMailOpenAcceptsOfficialPackedRequest(t *testing.T) {
}
}
func TestStarterMatchesInitialAccountSample(t *testing.T) {
set, err := fixture.Load(filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254"))
if err != nil {
t.Skipf("optional capture unavailable: %v", err)
func TestStarterMailProtocolEncodingAndRoundTrip(t *testing.T) {
seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 7, Mails: []MailDBInfo{{
MailID: 7, MailType: 2, Title: "Welcome", Body: "Rewards", ExpiresAt: 1000, SentAt: 10,
RewardTypes: []uint64{3, 8}, RewardIDs: []uint64{0, 17}, RewardCounts: []uint64{5, 300},
}}}
path := filepath.Join(t.TempDir(), "mail.json")
if err := seed.Write(path); err != nil {
t.Fatal(err)
}
recorded, err := set.RecordedResponseForClientSequence("/MailInfo", 249)
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
payload, _, err := set.RecordedPayloadAt("/MailInfo", recorded.RequestSequence)
if err != nil {
t.Fatal(err)
}
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "mail.json"))
if err != nil {
t.Fatal(err)
}
code, actual, ok, err := seed.Handle("/MailInfo", wire.AppendVarint(nil, 1, 249))
if err != nil || !ok || code != payload.PacketCode || !bytes.Equal(actual, payload.Proto) {
t.Fatalf("MailInfo differs: code=%d want=%d ok=%v err=%v\\ngot =%x\\nwant=%x", code, payload.PacketCode, ok, err, actual, payload.Proto)
// MailInfoResponse contains MailDBInfo field 1 plus count and maximum ID.
// Reward arrays are packed protobuf varints; default ID 0 remains a slot.
wantMail := []byte{0x08, 7, 0x10, 2, 0x2a, 7, 'W', 'e', 'l', 'c', 'o', 'm', 'e', 0x32, 7, 'R', 'e', 'w', 'a', 'r', 'd', 's', 0x38, 0xe8, 7, 0x42, 2, 3, 8, 0x4a, 2, 0, 17, 0x52, 3, 5, 0xac, 2, 0x68, 10}
want := append([]byte{0x0a, byte(len(wantMail))}, wantMail...)
want = append(want, 0x10, 2, 0x18, 7)
code, actual, handled, err := loaded.Handle("/MailInfo", wire.AppendVarint(nil, 1, 249))
if err != nil || !handled || code != 131 || !bytes.Equal(actual, want) {
t.Fatalf("code=%d proto=%x handled=%v err=%v", code, actual, handled, err)
}
}
+2 -2
View File
@@ -31,9 +31,9 @@ func (s *ownedState) PictorialDiscovered() []player.Pictorial { return s.discove
func TestOfficialPictorialProgression(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA to verify installed 2.34.13 GameData")
t.Skip("set BD2_REAL_GAMEDATA to verify installed 2.35.10 GameData")
}
design, err := gamedata.LoadPictorialDesign(root, "20260910162539")
design, err := gamedata.LoadPictorialDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
+29 -9
View File
@@ -39,10 +39,13 @@ type CollectionGrant struct {
Upgrades []CostumeUpgrade `json:"upgrades,omitempty"`
Exchanges []CostumeExchange `json:"exchanges,omitempty"`
ViewCostumeIDs []uint64 `json:"view_costume_ids"`
ViewCostumeSortIDs []uint64 `json:"view_costume_sort_ids,omitempty"`
GachaGroupID uint64 `json:"gacha_group_id,omitempty"`
GachaPoint uint64 `json:"gacha_point,omitempty"`
GachaFixed []GachaFixedState `json:"gacha_fixed,omitempty"`
SelectionApplySortIDs []uint64 `json:"selection_apply_sort_ids,omitempty"`
GachaRequestDigest string `json:"gacha_request_digest,omitempty"`
GachaResponse []byte `json:"gacha_response,omitempty"`
}
type GachaUserState struct {
@@ -79,6 +82,10 @@ type GachaPurchase struct {
SelectionApplySortIDs []uint64
StepUpGroupID uint64
StepUpStep uint64
RewardCount uint64
// RewardSortIDs preserves positions from mixed reward programs. Ordinary
// costume-only gachas leave it empty and use their slice positions.
RewardSortIDs []uint64
}
type GachaSelection struct {
@@ -245,9 +252,9 @@ func (s *CollectionStore) EnsurePersisted() error {
func emptyCollectionGrant(grant CollectionGrant) bool {
return len(grant.CharacterIndices) == 0 && len(grant.CostumeIndices) == 0 &&
len(grant.Upgrades) == 0 && len(grant.Exchanges) == 0 &&
len(grant.ViewCostumeIDs) == 0 && grant.GachaGroupID == 0 &&
len(grant.ViewCostumeIDs) == 0 && len(grant.ViewCostumeSortIDs) == 0 && grant.GachaGroupID == 0 &&
grant.GachaPoint == 0 && len(grant.GachaFixed) == 0 &&
len(grant.SelectionApplySortIDs) == 0
len(grant.SelectionApplySortIDs) == 0 && grant.GachaRequestDigest == "" && len(grant.GachaResponse) == 0
}
// BindBaseCharacters attaches the authoritative base roster and rejects a
@@ -513,7 +520,7 @@ func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uin
if identity == "" || len(costumeIDs) == 0 || design == nil || purchase.Group.ID == 0 {
return CollectionGrant{}, errors.New("player: invalid regular gacha purchase")
}
return s.grantCostumes(identity, costumeIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error {
return s.grantCostumesSorted(identity, costumeIDs, purchase.RewardSortIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error {
if err := applyStepUpProgress(next, purchase.StepUpGroupID, purchase.StepUpStep); err != nil {
return err
}
@@ -579,15 +586,26 @@ func (s *CollectionStore) GrantEquipmentDraw(identity string) (CollectionGrant,
}
func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) {
return s.grantCostumesSorted(identity, costumeIDs, nil, character, mutate)
}
func (s *CollectionStore) grantCostumesSorted(identity string, costumeIDs, sortIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) {
if len(sortIDs) != 0 && len(sortIDs) != len(costumeIDs) {
return CollectionGrant{}, errors.New("player: costume reward sort count mismatch")
}
s.mu.Lock()
defer s.mu.Unlock()
if grant, ok := s.data.Grants[identity]; ok {
return cloneGrant(grant), nil
}
next := cloneCollection(s.data)
grant := CollectionGrant{ViewCostumeIDs: append([]uint64(nil), costumeIDs...)}
grant := CollectionGrant{ViewCostumeIDs: append([]uint64(nil), costumeIDs...), ViewCostumeSortIDs: append([]uint64(nil), sortIDs...)}
now := uint64(time.Now().UnixMilli())
for sortIndex, costumeID := range costumeIDs {
sortID := uint64(sortIndex)
if len(sortIDs) != 0 {
sortID = sortIDs[sortIndex]
}
if costumeID == 0 {
return CollectionGrant{}, errors.New("player: preview has invalid costume")
}
@@ -603,9 +621,9 @@ func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, ch
before := next.Costumes[position].Level
if before < maxLevel {
next.Costumes[position].Level++
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: next.Costumes[position].InvenIndex, CostumeID: costumeID, Before: before, After: next.Costumes[position].Level, SortID: uint64(sortIndex)})
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: next.Costumes[position].InvenIndex, CostumeID: costumeID, Before: before, After: next.Costumes[position].Level, SortID: sortID})
} else {
exchange, err := costumeOverflowExchange(costumeID, next.Costumes[position].InvenIndex, uint64(sortIndex), characterDesign)
exchange, err := costumeOverflowExchange(costumeID, next.Costumes[position].InvenIndex, sortID, characterDesign)
if err != nil {
return CollectionGrant{}, err
}
@@ -622,9 +640,9 @@ func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, ch
}
if before < maxLevel {
next.BaseCostumeLevels[key] = before + 1
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: s.base[base].InvenIndex, CostumeID: costumeID, Before: before, After: before + 1, SortID: uint64(sortIndex)})
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: s.base[base].InvenIndex, CostumeID: costumeID, Before: before, After: before + 1, SortID: sortID})
} else {
exchange, err := costumeOverflowExchange(costumeID, s.base[base].InvenIndex, uint64(sortIndex), characterDesign)
exchange, err := costumeOverflowExchange(costumeID, s.base[base].InvenIndex, sortID, characterDesign)
if err != nil {
return CollectionGrant{}, err
}
@@ -645,7 +663,7 @@ func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, ch
next.Characters = append(next.Characters, ownedCharacter)
grant.CharacterIndices = append(grant.CharacterIndices, characterIndex)
}
costume := Costume{InvenIndex: costumeIndex, ID: costumeID, UseChar: characterIndex, SortID: uint64(sortIndex), TimeValue: now}
costume := Costume{InvenIndex: costumeIndex, ID: costumeID, UseChar: characterIndex, SortID: sortID, TimeValue: now}
next.Costumes = append(next.Costumes, costume)
grant.CostumeIndices = append(grant.CostumeIndices, costumeIndex)
}
@@ -912,11 +930,13 @@ func findCharacterByID(base, collection []Character, id uint64) (uint64, bool) {
}
func cloneGrant(grant CollectionGrant) CollectionGrant {
grant.GachaResponse = append([]byte(nil), grant.GachaResponse...)
grant.CharacterIndices = append([]uint64(nil), grant.CharacterIndices...)
grant.CostumeIndices = append([]uint64(nil), grant.CostumeIndices...)
grant.Upgrades = append([]CostumeUpgrade(nil), grant.Upgrades...)
grant.Exchanges = append([]CostumeExchange(nil), grant.Exchanges...)
grant.ViewCostumeIDs = append([]uint64(nil), grant.ViewCostumeIDs...)
grant.ViewCostumeSortIDs = append([]uint64(nil), grant.ViewCostumeSortIDs...)
grant.GachaFixed = append([]GachaFixedState(nil), grant.GachaFixed...)
grant.SelectionApplySortIDs = append([]uint64(nil), grant.SelectionApplySortIDs...)
return grant
+37
View File
@@ -0,0 +1,37 @@
package player
import "errors"
// GachaBatchResponse returns the immutable result of a completed batch. The
// digest binds the full ordered request, including repeated gacha IDs.
func (s *CollectionStore) GachaBatchResponse(identity, digest string) ([]byte, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
grant, found := s.data.Grants[identity]
if !found {
return nil, false, nil
}
if grant.GachaRequestDigest != digest || len(grant.GachaResponse) == 0 {
return nil, true, errors.New("player: gacha batch request identity conflict")
}
return append([]byte(nil), grant.GachaResponse...), true, nil
}
// RecordGachaBatch participates in the caller's account operation alongside
// every reward, daily allowance and fixed-pity update in the batch.
func (s *CollectionStore) RecordGachaBatch(identity, digest string, response []byte) error {
if identity == "" || digest == "" || len(response) == 0 {
return errors.New("player: invalid gacha batch result")
}
s.mu.Lock()
defer s.mu.Unlock()
if prior, found := s.data.Grants[identity]; found {
if prior.GachaRequestDigest != digest {
return errors.New("player: gacha batch request identity conflict")
}
return nil
}
next := cloneCollection(s.data)
next.Grants[identity] = CollectionGrant{GachaRequestDigest: digest, GachaResponse: append([]byte(nil), response...)}
return s.commit(next)
}
+8 -4
View File
@@ -59,21 +59,25 @@ func applyGachaPurchase(next *collectionSnapshot, identity string, costumeIDs []
}
next.GachaApplied[prefix+identity] = true
}
point := purchase.Group.PointCount * uint64(len(costumeIDs))
rewardCount := uint64(len(costumeIDs))
if purchase.RewardCount != 0 {
rewardCount = purchase.RewardCount
}
point := purchase.Group.PointCount * rewardCount
user.Point += point
switch purchase.BuyType {
case 1: // GB_NORMAL
user.TotalBuyCount += uint64(len(costumeIDs))
user.TotalBuyCount += rewardCount
case 2: // GB_CASH outside the date-scoped daily discount
if purchase.DailyLimit == 0 {
if len(costumeIDs) == 1 {
if rewardCount == 1 {
user.OneCashPickCount++
} else {
user.TenCashPickCount++
}
}
case 3: // GB_CASH_CONTENT_TICKET
user.TotalBuyCount += uint64(len(costumeIDs))
user.TotalBuyCount += rewardCount
}
next.GachaUsers[groupKey] = user
grant.GachaGroupID = purchase.Group.ID
+1 -1
View File
@@ -48,7 +48,7 @@ func TestUseRandomBoxPersistsExactStackAndRewardFromInstalledGameData(t *testing
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
design, err := gamedata.LoadRandomBoxDesign(root, "20260910162539")
design, err := gamedata.LoadRandomBoxDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
+32 -24
View File
@@ -5,7 +5,6 @@ import (
"path/filepath"
"testing"
"bd2server/internal/server/fixture"
"bd2server/internal/server/wire"
)
@@ -25,32 +24,41 @@ func TestStarterCanAnswerWithoutCapture(t *testing.T) {
}
}
func TestStarterMatchesInitialAccountSample(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")
set, err := fixture.Load(root)
if err != nil {
t.Skipf("optional capture unavailable: %v", err)
func TestStarterProtocolEncodingAndRoundTrip(t *testing.T) {
seed := &Starter{
Version: "2.35.10",
Items: []Item{{InvenIndex: 1, ID: 2, Type: 8, Count: 3}},
Costumes: []Costume{{InvenIndex: 4, ID: 5, UseChar: 6}},
Characters: []Character{{InvenIndex: 6, ID: 7, HP: 8, Level: 1, UseCostume: 4, ConnectPotentialCostume: 5}},
FieldCharControlDeckType: 2,
}
key, err := set.SessionKey()
path := filepath.Join(t.TempDir(), "starter.json")
if err := seed.Write(path); err != nil {
t.Fatal(err)
}
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
batch, err := set.BatchResponse(1)
if err != nil {
t.Fatal(err)
}
_, recorded, err := fixture.DecodeBatch(batch, []byte(key))
if err != nil {
t.Fatal(err)
}
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json"))
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/ItemInfo", "/CostumeInfo", "/CharInfo"} {
_, proto, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 900001))
if err != nil || !ok || !bytes.Equal(proto, recorded[path]) {
t.Errorf("%s parsed player starter differs from source: got=%x want=%x err=%v", path, proto, recorded[path], err)
}
for _, test := range []struct {
path string
code int
proto []byte
}{
{"/ItemInfo", 21, []byte{0x0a, 8, 0x08, 1, 0x10, 2, 0x18, 8, 0x20, 3}},
{"/CostumeInfo", 40, []byte{0x0a, 6, 0x08, 4, 0x10, 5, 0x20, 6}},
{"/CharInfo", 9, []byte{0x0a, 12, 0x08, 6, 0x10, 7, 0x18, 8, 0x20, 1, 0x38, 4, 0x68, 5, 0x10, 2}},
} {
t.Run(test.path, func(t *testing.T) {
code, proto, handled, err := loaded.Handle(test.path, wire.AppendVarint(nil, 1, 900001))
if err != nil || !handled || code != test.code || !bytes.Equal(proto, test.proto) {
t.Fatalf("code=%d proto=%x handled=%v err=%v", code, proto, handled, err)
}
for _, request := range [][]byte{nil, {0x08, 0}, {0x08, 0x80}} {
if _, _, handled, err := loaded.Handle(test.path, request); !handled || err == nil {
t.Fatal("invalid request accepted")
}
}
})
}
}
+28 -38
View File
@@ -5,54 +5,44 @@ import (
"path/filepath"
"testing"
"bd2server/internal/server/fixture"
"bd2server/internal/server/wire"
)
func TestSeedByteCompatibility(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "data", "capture", "2.34.13", "20260920-003254")
set, err := fixture.Load(root)
if err != nil {
t.Skipf("optional capture unavailable: %v", err)
func TestSeedProtocolEncodingAndRoundTrip(t *testing.T) {
seed := &Seed{Version: StateVersion(), Responses: map[string]Response{
"/CashShopInfo": {PacketCode: 60, Fields: []Field{
{Number: 1, Type: 2, Fields: []Field{{Number: 1, Type: 0, Varint: 150}, {Number: 2, Type: 0, Varint: 7}}},
{Number: 2, Type: 1, Fixed64: 0x0807060504030201},
{Number: 3, Type: 5, Fixed32: 0x04030201},
{Number: 4, Type: 2, Bytes: []byte("shop")},
{Number: 5, Type: 2},
}},
}}
path := filepath.Join(t.TempDir(), "readonly.json")
if err := seed.Write(path); err != nil {
t.Fatal(err)
}
key, err := set.SessionKey()
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json"))
if err != nil {
t.Fatal(err)
want := []byte{0x0a, 5, 0x08, 0x96, 1, 0x10, 7, 0x11, 1, 2, 3, 4, 5, 6, 7, 8, 0x1d, 1, 2, 3, 4, 0x22, 4, 's', 'h', 'o', 'p', 0x2a, 0}
code, got, handled, err := loaded.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 999))
if err != nil || !handled || code != 60 || !bytes.Equal(got, want) {
t.Fatalf("code=%d proto=%x handled=%v err=%v", code, got, handled, err)
}
want := map[string]struct {
PacketCode int
Proto []byte
}{}
for batch := 1; batch <= 2; batch++ {
body, err := set.BatchResponse(batch)
if err != nil {
t.Fatal(err)
}
items, decoded, err := fixture.DecodeBatch(body, []byte(key))
if err != nil {
t.Fatal(err)
}
for _, item := range items {
if _, ok := seed.Responses[item.Path]; ok {
want[item.Path] = struct {
PacketCode int
Proto []byte
}{item.Response.PacketCode, decoded[item.Path]}
}
got[0] = 0
_, again, _, err := loaded.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1000))
if err != nil || !bytes.Equal(again, want) {
t.Fatal("encoded response aliases seed data")
}
for _, request := range [][]byte{nil, {0x08, 0}, {0x08, 0x80}} {
if _, _, handled, err := loaded.Handle("/CashShopInfo", request); !handled || err == nil {
t.Fatal("invalid request accepted")
}
}
if len(want) != len(seed.Responses) {
t.Fatalf("capture has %d seed responses, want %d", len(want), len(seed.Responses))
}
for path, expected := range want {
code, got, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 999))
if err != nil || !ok || code != expected.PacketCode || !bytes.Equal(got, expected.Proto) {
t.Errorf("%s: code=%d want=%d equal=%v err=%v", path, code, expected.PacketCode, bytes.Equal(got, expected.Proto), err)
}
if _, _, handled, err := loaded.Handle("/Unknown", nil); handled || err != nil {
t.Fatal("unknown endpoint accepted")
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ type RegularSeason struct {
Season uint64
}
// Service contains the official 2.34.13 calendar active for this fixed
// Service contains the configured regular-content calendar for this fixed
// client/GameData version. It is server configuration, not a captured packet:
// Handle encodes every protobuf field from these named values.
type Service struct {
+1 -18
View File
@@ -1,14 +1,12 @@
package schedule
import (
"bytes"
"os"
"testing"
"bd2server/internal/server/wire"
)
func TestVersion23413ContainsRequiredContentSix(t *testing.T) {
func TestVersion23510ContainsRequiredContentSix(t *testing.T) {
service := Current()
code, response, handled, err := service.Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !handled || code != 117 {
@@ -33,21 +31,6 @@ func TestVersion23413ContainsRequiredContentSix(t *testing.T) {
}
}
func TestVersion23413MatchesOptionalOfficialResponse(t *testing.T) {
path := os.Getenv("BD2_TEST_SCHEDULE_CAPTURE")
if path == "" {
t.Skip("set BD2_TEST_SCHEDULE_CAPTURE to compare an official response")
}
want, err := os.ReadFile(path)
if err != nil {
t.Skipf("optional official schedule sample unavailable: %v", err)
}
_, got, _, err := Current().Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 2))
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("schedule differs from official 2.34.13 response: equal=%v err=%v\ngot=%x\nwant=%x", bytes.Equal(got, want), err, got, want)
}
}
func TestScheduleRejectsMissingSequence(t *testing.T) {
if _, _, handled, err := Current().Handle("/ScheduleInfo", nil); !handled || err == nil {
t.Fatalf("missing sequence handled=%v err=%v", handled, err)
+3 -3
View File
@@ -390,7 +390,7 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.
}
// packInfo is the canonical protobuf encoding of the semantic new-account
// starter-pack state. Its first-call bytes match the 2.34.13 observed response.
// starter-pack state. Its response is generated from the authoritative local progress state.
func (s *Service) packInfo() ([]byte, error) {
return s.packInfoFor(s.seed.PackID)
}
@@ -531,7 +531,7 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa
if next != 0 {
out = wire.AppendBytes(out, 2, wire.AppendVarint(nil, 1, uint64(next)))
} else {
// QuestClearResponse.QuestInfo is dereferenced by the 2.34.13 client
// QuestClearResponse.QuestInfo is dereferenced by the client
// even when this is the final quest of a pack. An explicitly present,
// empty QuestDBInfo gives that generated protobuf property a non-null
// object whose Id is the client-recognized zero sentinel. Omitting the
@@ -562,7 +562,7 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa
out = wire.AppendBytes(out, 5, encodeCharacter(character))
}
} else if s.decks != nil {
// Official 2.34.13 QuestClear always echoes the authoritative current
// QuestClear echoes the authoritative current
// DeckInfo. Omitting it after quest 29 makes the client rebuild a party
// from ordinary owned characters and overwrite the story formation.
for _, current := range s.decks.CurrentDeck() {
+1 -1
View File
@@ -152,7 +152,7 @@ func TestQuestClearAdvancesAndPersists(t *testing.T) {
}
func TestFinalQuestClearIncludesEmptyNextQuestInfo(t *testing.T) {
// In 2.34.13 the completion coroutine unconditionally evaluates
// The client completion coroutine unconditionally evaluates
// QuestClearResponse.QuestInfo.Id. An absent protobuf field becomes a
// null C# reference; an explicitly present zero-length message becomes a
// non-null QuestDBInfo with ID 0, satisfying the client's final-quest
+4 -4
View File
@@ -50,9 +50,9 @@ mitmproxy 会把它们全缓冲进内存。加了之后超过 1MB 的响应不
## 输出
- 控制台:`game=true` 的实时打印
- `capture/2.34.13/<时间戳>/bd2_dump.jsonl`:全部流量,每行一条
- `capture/2.34.13/<时间戳>/bd2_dump.log`:便于人工检查的文本日志
- `capture/2.34.13/<时间戳>/bodies/`:`game=true` 的原始 body 原样存一份
- `capture/2.35.10/<时间戳>/bd2_dump.jsonl`:全部流量,每行一条
- `capture/2.35.10/<时间戳>/bd2_dump.log`:便于人工检查的文本日志
- `capture/2.35.10/<时间戳>/bodies/`:`game=true` 的原始 body 原样存一份
(服务端实现要按 schema 解,原字节比文本更值钱)
可用环境变量 `BD2_CAPTURE_DIR` 覆盖输出根目录。
@@ -78,7 +78,7 @@ TAG = time.strftime("%Y%m%d-%H%M%S")
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.dirname(os.path.dirname(SCRIPT_DIR))
CAPTURE_ROOT = os.path.abspath(
os.environ.get("BD2_CAPTURE_DIR", os.path.join(PROJECT_DIR, "data", "capture", "2.34.13"))
os.environ.get("BD2_CAPTURE_DIR", os.path.join(PROJECT_DIR, "data", "capture", "2.35.10"))
)
RUN_DIR = os.path.join(CAPTURE_ROOT, TAG)
LOG_PATH = os.path.join(RUN_DIR, "bd2_dump.jsonl")
+3 -3
View File
@@ -7,7 +7,7 @@ by an explicitly configured local bd2server.
Example:
python tools/python/dev_mail_grant.py serve `
--game-data E:\\bd2\\dl\\GameData --game-data-version 20260910162539 `
--game-data E:\\bd2\\dl\\GameData --game-data-version 20260923193640 `
--mail-seed go\\seed\\v2_35_10\\mail.json --output data\\dev\\mail-grants.json
"""
@@ -32,7 +32,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
from gamedata_db import read_database, walk_wire # noqa: E402
VERSION = "2.34.13"
VERSION = "2.35.10"
MAX_INT32 = (1 << 31) - 1
# These are the local server's ItemDBInfo-backed ElementTypes. Item names use
@@ -449,7 +449,7 @@ class MailGrantStore:
current_ids = {entry["mail_id"] for entry in self.seed["mails"]}
mail_id = max(current_ids, default=13_000_000_000) + 1
# MailDBInfo's InvenIndex is int64 in the 2.34.13 client descriptor.
# MailDBInfo's InvenIndex is int64 in the 2.35.10 client descriptor.
if mail_id > (1 << 63) - 1:
raise ValueError("没有可用的正 int64 邮件 ID")
now = int(time.time() * 1000)
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Convert decoded 2.34.13 protobuf messages into reviewed JSON seeds.
"""Convert decoded 2.35.10 protobuf messages into reviewed JSON seeds.
This replaces the old Go cmd/import-* programs. Network/capture decryption is
kept separate: inputs here are raw, already-decoded protobuf files. Outputs
@@ -27,7 +27,7 @@ from pathlib import Path
import struct
VERSION = "2.34.13"
VERSION = "2.35.10"
@dataclass(frozen=True)
+2 -2
View File
@@ -221,7 +221,7 @@ class DevelopmentMailGrantToolTests(unittest.TestCase):
source = root / "mail.json"
output = root / "generated.json"
source.write_text(json.dumps({
"version": "2.34.13", "mails": [], "mail_count": 1, "max_mail_id": 0,
"version": "2.35.10", "mails": [], "mail_count": 1, "max_mail_id": 0,
}), encoding="utf-8")
gold = {"id": 0, "element_type": 4, "name": "金币"}
store = dev_mail_grant.MailGrantStore(source, output, [gold], 365)
@@ -283,7 +283,7 @@ class DevelopmentMailGrantToolTests(unittest.TestCase):
source = root / "source-mail.json"
output = root / "generated-mail.json"
source.write_text(json.dumps({
"version": "2.34.13",
"version": "2.35.10",
"mails": [{
"mail_id": 100, "mail_type": 2, "title": "base", "body": "base",
"expires_at": 200, "reward_types": [8], "reward_ids": [7],