feat(all): add verified gacha flows and native client studio
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user